멀티플랫폼 빌드는 실패했는데 태그는 남았다: Buildx 0.37.1이 고친 부분 푸시

두 아키텍처 이미지를 한 태그로 푸시하는 빌드가 실패했습니다. 보통은 레지스트리에 새 태그가 생기지 않았다고 생각합니다. Docker Buildx 0.37.0에서는 그 가정이 깨질 수 있었습니다. linux/arm64 빌드가 실패해 전체 명령은 오류로 끝났는데, 먼저 끝난 linux/amd64 결과가 요청한 태그를 차지할 수 있었습니다.

Buildx 0.37.1은 이 문제를 수정했습니다. 핵심은 각 노드가 최종 태그를 직접 게시하지 못하게 하고, 플랫폼별 결과를 digest로 먼저 올린 뒤 모든 노드가 성공했을 때만 manifest list에 최종 태그를 붙이는 것입니다. 실패한 명령의 종료 코드만 보는 배포 파이프라인이라면 이 차이를 놓치기 쉽습니다.

실패와 게시가 한 트랜잭션이 아니었던 이유

멀티노드 빌드에서는 노드마다 담당 플랫폼이 다를 수 있습니다. 공식 재현은 첫 번째 노드가 linux/arm64, 두 번째 노드가 linux/amd64를 처리하도록 구성합니다. Dockerfile은 arm64에서 30초 뒤 실패하고 amd64에서는 정상 이미지를 만듭니다. 전체 docker buildx build --push 명령은 결국 실패합니다.

문제는 amd64 노드가 더 먼저 export를 끝낼 수 있다는 점입니다. 영향을 받는 버전에서는 이 노드가 registry.example/repro:test 같은 요청 태그로 바로 푸시했습니다. 뒤늦게 arm64가 실패하면 Buildx는 두 플랫폼을 묶는 최종 manifest list를 만들지 못합니다. 그래도 레지스트리에는 이미 amd64 단일 이미지가 같은 태그로 남습니다.

arm64 노드는 실패하고 amd64 노드는 먼저 요청 태그를 게시해 전체 빌드 실패 뒤 단일 플랫폼 태그가 남는 Buildx 0.37.0 경로

그림 1. 한 노드의 export가 먼저 끝나면 전체 멀티플랫폼 빌드가 실패해도 요청 태그가 부분 이미지로 남을 수 있었다. 출처: github.com/docker/buildx/pull/4058.

이 상태는 단순한 찌꺼기 파일보다 위험합니다. 배포 시스템이 태그 존재만 확인하면 전체 멀티플랫폼 산출물이 준비됐다고 오판할 수 있습니다. amd64 환경에서는 정상처럼 보이고 arm64 환경에서만 manifest 선택이 실패하거나 기대한 플랫폼을 찾지 못할 수 있습니다. 빌드 실패와 태그 공개가 원자적으로 묶이지 않았기 때문입니다.

공유 상태를 복제한 뒤 드러난 회귀

PR #4058은 회귀의 출발점을 commit de05d88f로 설명합니다. 이 변경은 각 driver가 자신의 solve 옵션을 준비할 수 있도록 exporter attribute map을 노드별로 복제했습니다. 복제 자체는 합리적입니다. 서로 다른 노드가 같은 mutable map을 건드리면 의도하지 않은 결합이 생길 수 있기 때문입니다.

하지만 기존 멀티노드 푸시 준비 코드는 그 map이 공유된다는 사실에 기대고 있었습니다. 첫 노드의 exporter 이름을 태그 없는 repository-only 형태로 바꾸면 같은 map을 보던 다른 노드도 함께 바뀌었습니다. map이 노드별 사본으로 분리된 뒤에도 pushNames는 전체 target에서 한 번만 채워졌습니다. 첫 노드가 이름을 바꾼 다음에는 다음 노드가 이미 준비됐다고 판단해 이름 변경을 건너뛸 수 있었습니다.

결과적으로 노드마다 안전 장치가 다르게 적용됐습니다. 한 노드는 digest 전용 푸시를 했지만 다른 노드는 요청 태그를 그대로 들고 있었습니다. 동시성 자체보다 더 까다로운 버그입니다. 공유 객체를 올바르게 복제했더라도, 그 객체에 기대던 제어 흐름까지 노드별로 다시 계산하지 않으면 원래의 묵시적 동기화가 사라집니다.

0.37.1은 각 노드에서 exporter를 준비한다

수정된 prepareMultiDriverExports는 노드별 SolveOpt를 받을 때마다 실행됩니다. 함수 안의 pushPrepared는 호출마다 새로 시작하므로 각 노드의 첫 번째 push image exporter를 확인합니다. 해당 exporter의 이름은 태그를 제거한 repository-only 경로로 바뀌고 push-by-digest=true가 설정됩니다.

반면 pushNames는 전체 target 범위에 남습니다. 최종적으로 붙여야 할 요청 태그는 한 번만 보관하되, 각 노드가 그 태그를 직접 쓰지 못하도록 준비 작업은 반복하는 구조입니다. 모든 플랫폼 solve와 digest push가 성공하면 Buildx가 manifest list를 조립하고 그때 최종 태그를 게시합니다.

각 Buildx 노드가 repository-only digest를 푸시하고 모든 플랫폼 성공 뒤 manifest list에 최종 태그를 붙이는 0.37.1의 두 단계 게시 흐름

그림 2. 0.37.1은 플랫폼별 digest 푸시와 사람이 소비하는 최종 태그 게시를 분리했다. 출처: github.com/docker/buildx/pull/4058/files.

추가 image exporter는 기존 동작을 유지합니다. 수정은 여러 exporter를 모두 재작성하지 않고 각 노드에서 첫 번째 pushed image exporter에만 적용됩니다. upstream 단위 테스트도 두 노드와 두 exporter를 만들어, 두 노드의 첫 exporter만 repository-only와 push-by-digest=true로 바뀌고 두 번째 exporter는 원래 태그를 유지하는지 검사합니다.

통합 테스트는 더 직접적입니다. remote multi-node worker에서 amd64와 arm64를 함께 빌드하고 arm64만 실패시킨 다음, 전체 명령이 오류로 끝났는지 확인합니다. 이어서 레지스트리에서 목표 태그를 읽으려는 시도도 실패해야 통과합니다. 즉 "빌드가 실패했다"와 "태그가 생기지 않았다"를 별도 assertion으로 둡니다.

PR 병합과 안정 릴리스를 따로 확인했다

수정 PR #4058은 2026년 9월 9일 병합됐습니다. 안정 버전에 실제 포함됐는지는 별도 확인이 필요합니다. v0.37.1의 서명된 태그는 commit 0b265a9f를 가리키며, v0.37.0과 비교한 여덟 개 commit 가운데 exporter 준비 추출, 부분 푸시 수정, remote multi-node 회귀 테스트의 세 backport가 들어 있습니다.

노드별 exporter 속성 복제 commit, 부분 푸시 수정 PR 병합, Buildx 0.37.1 안정 릴리스의 계보

그림 3. 회귀 도입과 수정 PR, 안정 릴리스 포함 여부를 각각 확인했다. 출처: github.com/docker/buildx/compare/v0.37.0...v0.37.1.

v0.37.1 릴리스 노트도 이 수정 사항을 "실패한 멀티노드 이미지 푸시가 요청 태그를 부분 이미지로 남기는 문제"로 적고 PR #4058을 연결합니다. 이번 조사에서는 v0.37.1 태그 소스를 받아 TestPrepareMultiDriverExportsForEveryNode를 실행해 노드별 exporter 재작성 테스트를 확인했습니다. 다만 독립 레지스트리와 두 remote BuildKit 노드를 구성해 통합 재현을 다시 돌리지는 않았습니다. 원격 실패 후 태그가 실제로 없는지는 upstream 통합 테스트의 범위로 남겨 둡니다.

배포 파이프라인에서 확인할 것

Buildx 0.37.0으로 멀티노드 --push를 사용한다면 0.37.1 이상으로 올리는 편이 안전합니다. 특히 새 태그뿐 아니라 이미 존재하는 이동 태그를 덮어쓰는 배포는 주의해야 합니다. 실패 시 새 부분 이미지가 태그를 차지했다면 단순 재시도 전까지 소비자가 잘못된 플랫폼 집합을 읽을 수 있습니다.

검증도 두 층으로 나눠야 합니다. 첫째, 빌드 명령의 종료 상태와 각 플랫폼 작업의 성공을 확인합니다. 둘째, 레지스트리에서 최종 태그의 manifest index를 읽어 기대한 플랫폼 집합과 digest를 확인합니다. imagetools inspect 결과에 linux/amd64와 linux/arm64가 모두 있는지 보는 식입니다. 태그가 존재한다는 사실만으로는 충분하지 않습니다.

릴리스 자동화에서는 staging tag나 digest를 먼저 다루고, 검증이 끝난 index에만 배포 태그를 붙이는 구조가 좋습니다. Buildx 0.37.1이 내부에서 적용한 경계와 같습니다. 플랫폼별 blob과 manifest를 올리는 작업은 부분적으로 진행될 수 있지만, 사람이 의미를 부여한 최종 태그는 전체 성공 뒤 한 번만 움직여야 합니다.

마지막으로 회귀 테스트는 성공 경로만으로 부족합니다. 한 플랫폼을 의도적으로 늦게 실패시키고, 다른 플랫폼이 export를 먼저 끝내도록 만들어야 합니다. 그런 다음 명령 오류, 최종 태그 부재, 플랫폼별 임시 digest의 처리까지 따로 확인해야 합니다. 여러 노드가 같은 설정을 받는 테스트도 필요합니다. 공유 map을 복제하는 리팩터링은 데이터 경합을 줄일 수 있지만, 공유 상태가 맡던 숨은 동기화까지 자동으로 보존해 주지는 않습니다.

참고 자료

댓글

이 블로그의 인기 게시물

체크아웃은 분리됐는데 Git 상태는 공유된다: Codex 0.154 worktree의 세 경계

잠근 작업이 커밋 뒤 다시 나온다: PostgreSQL 에이전트 큐의 소유권 설계

코딩 에이전트는 API 문서보다 AGENTS.md를 먼저 읽었다