fix(build-and-push): build via docker context, not buildx-create-over-ssh
All checks were successful
Test build-and-push (self-test) / Build tiny multi-arch image (push) Successful in 9s

The 'docker buildx create --name X ssh://...' driver hangs against
docker-build.haven with 'waiting for connection: context deadline exceeded',
even though SSH, the daemon, and dial-stdio all work. Root cause is that
docker-container-driver-over-SSH path; the remote daemon's own default
buildkit (containerd-snapshotter) works fine.

Rework:
- create a docker *context* over SSH and run 'docker --context remote-builder
  buildx build --push' directly (drops docker/build-push-action, which needs
  a named builder)
- (re)install qemu/binfmt on the remote each run when an arm* platform is
  requested (binfmt is kernel state, lost on daemon/host restart -- this was
  causing 'exec format error' for arm64 after the daemon was restarted)
- gentags now emits -t args for the direct buildx invocation

Validated end-to-end on real infra: multi-arch amd64+arm64 build + push to
git.ivanch.me/ivanch/pipeline-actions-selftest:citest, manifest confirmed
multi-arch.
This commit is contained in:
2026-07-12 13:54:52 -03:00
parent dec613eb63
commit 3fe11a16bc

View File

@@ -74,9 +74,7 @@ runs:
# Fail fast with a real diagnostic instead of a slow buildx # Fail fast with a real diagnostic instead of a slow buildx
# "context deadline exceeded" if the runner can't reach / auth to the # "context deadline exceeded" if the runner can't reach / auth to the
# remote docker daemon over SSH. A hard `timeout` wrapper guarantees we # remote docker daemon over SSH. A hard `timeout` wrapper guarantees we
# never hang: ConnectTimeout only bounds the TCP handshake, so a # never hang: ConnectTimeout only bounds the TCP handshake.
# firewall that accepts the SYN but drops later packets would otherwise
# stall SSH negotiation indefinitely.
echo "== probing ssh://${BUILDER_USER}@${BUILDER_HOST} ==" echo "== probing ssh://${BUILDER_USER}@${BUILDER_HOST} =="
if timeout 25 ssh \ if timeout 25 ssh \
-o BatchMode=yes \ -o BatchMode=yes \
@@ -92,35 +90,45 @@ runs:
rc=$? rc=$?
echo "SSH probe FAILED (exit ${rc})" echo "SSH probe FAILED (exit ${rc})"
if [ "${rc}" = "124" ]; then if [ "${rc}" = "124" ]; then
echo "-> timed out: TCP likely opens but SSH negotiation stalls." echo "-> timed out: TCP opens but SSH/daemon does not answer."
echo "-> Check firewall path runner -> ${BUILDER_HOST}:22 (VLAN), or MTU." echo "-> Check: dockerd running on ${BUILDER_HOST}? firewall path? MTU?"
fi fi
exit "${rc}" exit "${rc}"
fi fi
- name: Set up remote Docker Buildx builder - name: Set up remote Docker context and buildx
shell: bash shell: bash
env: env:
BUILDER_HOST: ${{ inputs.docker_host }} BUILDER_HOST: ${{ inputs.docker_host }}
BUILDER_USER: ${{ inputs.docker_user }} BUILDER_USER: ${{ inputs.docker_user }}
PLATFORMS: ${{ inputs.platforms }}
run: | run: |
# Never set the DOCKER_HOST env var to the builder hostname: a bare # NOTE on approach: we drive the *remote daemon's own default buildx
# value is treated as tcp://host:2375 and, more importantly, DOCKER_HOST # builder* through a docker context over SSH. We deliberately do NOT use
# *overrides* the active docker context/builder. Keep it unset so buildx # `docker buildx create --name X ssh://...` (docker-container driver over
# talks to the SSH endpoint we specify below. # SSH): that path hangs on "waiting for connection: context deadline
# exceeded" against this builder host, whereas a plain docker context +
# the daemon's built-in buildkit (containerd-snapshotter) works and
# supports multi-arch. Also never set DOCKER_HOST (it overrides the
# active context).
unset DOCKER_HOST || true unset DOCKER_HOST || true
# Create the buildx builder directly from the SSH endpoint. We do NOT
# create a docker context of the same name first: buildx refuses to
# create a builder instance whose name collides with an existing
# context ("instance name already exists as context builder").
docker buildx rm -f remote-builder 2>/dev/null || true
docker context rm -f remote-builder 2>/dev/null || true docker context rm -f remote-builder 2>/dev/null || true
docker buildx create \ docker context create remote-builder \
--name remote-builder \ --docker "host=ssh://${BUILDER_USER}@${BUILDER_HOST}"
--use \
"ssh://${BUILDER_USER}@${BUILDER_HOST}" # Ensure qemu/binfmt is registered on the remote for cross-arch builds.
docker buildx inspect --bootstrap # This registration lives in the kernel and is lost on daemon/host
# restart, so (re)install it every run. Cheap and idempotent.
if echo "${PLATFORMS}" | grep -q 'arm'; then
echo "== ensuring qemu/binfmt on remote for cross-arch =="
docker --context remote-builder run --privileged --rm \
tonistiigi/binfmt --install all >/dev/null 2>&1 || \
echo "WARN: binfmt install returned non-zero (may already be present)"
fi
# Sanity-check the remote builder answers (fast, no container spawn).
docker --context remote-builder buildx inspect >/dev/null
- name: Generate tags list - name: Generate tags list
id: gentags id: gentags
@@ -129,25 +137,31 @@ runs:
IMAGE: ${{ inputs.image }} IMAGE: ${{ inputs.image }}
IMAGE_TAGS: ${{ inputs.image_tag }} IMAGE_TAGS: ${{ inputs.image_tag }}
run: | run: |
TAGS_LIST="" TAGS_ARGS=""
IFS=',' read -ra ADDR <<< "${IMAGE_TAGS}" IFS=',' read -ra ADDR <<< "${IMAGE_TAGS}"
for tag in "${ADDR[@]}"; do for tag in "${ADDR[@]}"; do
trimmed_tag=$(echo "${tag}" | xargs) trimmed_tag=$(echo "${tag}" | xargs)
if [ -n "${trimmed_tag}" ]; then if [ -n "${trimmed_tag}" ]; then
TAGS_LIST="${TAGS_LIST}${IMAGE}:${trimmed_tag}"$'\n' TAGS_ARGS="${TAGS_ARGS} -t ${IMAGE}:${trimmed_tag}"
fi fi
done done
echo "tag_args=${TAGS_ARGS}" >> "$GITHUB_OUTPUT"
echo "tags<<EOF" >> "$GITHUB_OUTPUT"
echo -n "${TAGS_LIST}" >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT"
- name: Build and Push Multi-Arch Image - name: Build and Push Multi-Arch Image
uses: docker/build-push-action@v6 shell: bash
with: env:
push: true BUILD_CONTEXT: ${{ inputs.build_context }}
context: ${{ inputs.build_context }} BUILD_DOCKERFILE: ${{ inputs.build_dockerfile }}
file: ${{ inputs.build_dockerfile }} PLATFORMS: ${{ inputs.platforms }}
builder: remote-builder TAG_ARGS: ${{ steps.gentags.outputs.tag_args }}
platforms: ${{ inputs.platforms }} run: |
tags: ${{ steps.gentags.outputs.tags }} unset DOCKER_HOST || true
# Build on the remote daemon's default buildkit via the SSH context and
# push directly. Registry credentials come from the `docker login` in
# the first step (buildx forwards the runner's auth to buildkit).
docker --context remote-builder buildx build \
--platform "${PLATFORMS}" \
--file "${BUILD_CONTEXT}/${BUILD_DOCKERFILE}" \
${TAG_ARGS} \
--push \
"${BUILD_CONTEXT}"