빌드는 실패했는데 태그는 남았다: Buildx 0.37.1이 고친 멀티노드 푸시의 원자성
실패한 빌드가 레지스트리를 바꿀 수 있었다
docker buildx build --platform linux/amd64,linux/arm64 --push가 실패하면 새 태그도 생기지 않을 거라고 기대하기 쉽다. Docker Buildx 0.37.0의 멀티노드 경로에서는 그 기대가 깨질 수 있었다. 한 노드가 linux/amd64 이미지를 먼저 끝내고 다른 노드의 linux/arm64 빌드가 실패하면, 명령은 오류로 끝나면서도 요청한 태그가 amd64 단일 이미지에 붙을 수 있었다.
Docker가 9월 11일 공개한 Buildx 0.37.1 릴리스 노트는 이 문제를 "실패한 멀티노드 이미지 푸시가 요청한 태그를 부분 이미지에 남기는 현상"으로 적었다. 수정 PR #4058의 재현 절차도 같다. 두 노드 중 arm64 쪽을 의도적으로 실패시키면 최종 manifest list 조립에는 도달하지 못하지만, 먼저 끝난 amd64 노드가 태그를 공개할 수 있었다.
이건 "레이어가 일부만 올라갔다"는 뜻과 다르다. 레지스트리에는 콘텐츠 주소인 digest 객체가 먼저 올라갈 수 있다. 문제는 사용자가 지정한 가변 태그가 완성되지 않은 단일 플랫폼 결과를 가리켰다는 점이다. 배포 자동화가 빌드 명령의 종료 코드만 보고 중단하더라도, 다른 시스템이 그 태그를 감시하거나 주기적으로 당기고 있었다면 부분 결과를 발견할 수 있다.
그림 1. Buildx 0.37.0의 멀티노드 경로에서는 전체 명령이 실패해도 먼저 끝난 노드가 요청 태그를 단일 플랫폼 이미지에 붙일 수 있었다. 출처: github.com/docker/buildx/pull/4058.
멀티플랫폼 이미지와 멀티노드 빌드는 같은 말이 아니다
Docker 공식 문서에서 멀티플랫폼 빌드는 한 번의 호출로 여러 운영체제·CPU 조합을 대상으로 하는 빌드다. 결과는 보통 각 플랫폼 manifest를 가리키는 image index 또는 manifest list로 묶인다. OCI Image Index 1.1.1도 manifests 배열을 플랫폼별 descriptor 목록으로 정의한다.
그 빌드를 반드시 여러 노드에서 수행해야 하는 것은 아니다. 에뮬레이션이나 교차 컴파일을 쓸 수도 있고, 각 플랫폼을 서로 다른 네이티브 노드에 맡길 수도 있다. 이번 결함은 후자, 즉 len(drivers[k]) > 1인 Buildx 멀티드라이버 경로와 레지스트리 푸시가 만날 때의 문제다. 일반 단일 노드 빌드나 로컬 docker 출력 전체가 같은 방식으로 영향을 받았다고 넓혀 말하면 안 된다.
정상적인 멀티노드 레지스트리 푸시는 두 단계로 생각하면 쉽다. 각 노드는 자기 플랫폼 이미지를 태그 없이 repository와 digest로 올린다. 모든 노드가 성공한 다음 Buildx가 descriptor를 모아 index를 만들고, 마지막에만 사용자가 요청한 태그를 index에 붙인다. 태그 공개가 일종의 커밋 지점인 셈이다.
복사 자체가 버그가 아니라 숨은 공유 상태를 드러냈다
원인은 단순한 병렬 실행 경쟁보다 조금 더 구체적이다. PR 설명에 따르면 0.37 이전에는 여러 노드가 같은 exporter 속성 맵을 공유했다. 첫 노드의 name을 태그가 없는 repository 이름으로 바꾸고 push-by-digest=true를 넣으면, 같은 맵을 보는 다른 노드에도 그 변경이 우연히 전파됐다.
0.37.0에 포함된 de05d88f 커밋은 드라이버가 각 노드의 solve option을 준비하도록 구조를 정리하면서 exporter 속성 맵을 노드별로 복제했다. 격리는 올바른 변경이었다. 하지만 기존 멀티노드 준비 코드는 target 전체에 하나뿐인 pushNames가 비어 있을 때만 속성을 바꿨다. 첫 노드가 태그를 저장하면 pushNames가 채워지고, 독립 맵을 받은 두 번째 노드는 name과 push-by-digest 변환을 건너뛰었다.
고정된 v0.37.0 소스와 v0.37.1 소스를 대조하면 차이가 선명하다. 0.37.0은 pushNames == "" 조건 안에서 태그 저장과 노드 exporter 변환을 함께 수행한다. 0.37.1은 둘을 분리한다. 최종 태그 문자열은 target 범위에서 한 번만 저장하되, pushPrepared는 각 노드의 helper 호출마다 새로 시작한다. 따라서 모든 노드의 첫 image exporter가 repository-only digest push로 바뀐다.
이번 조사에서는 실제 원격 멀티노드 BuildKit과 레지스트리를 띄워 재현하지 않았다. 대신 두 개의 독립 exporter 맵에 0.37.0과 0.37.1의 guard 범위만 옮긴 작은 구조 모델을 실행했다. 0.37.0 모델에서는 두 번째 노드가 registry.example/repro:test를 유지했고, 0.37.1 모델에서는 두 노드 모두 registry.example/repro와 push-by-digest=true를 받았다. 이 계산은 소스의 상태 전이를 확인한 것이지, 실제 네트워크 푸시를 재현한 성능·통합 시험은 아니다.
그림 2. 최종 태그 문자열은 target 범위에 남기고 exporter 준비 여부는 노드마다 다시 시작하도록 guard 범위를 분리했다. 출처: github.com/docker/buildx/commit/e968045a5999673b56dec56b480cc403bc314ffd.
0.37.1은 태그 공개를 모든 노드 성공 뒤로 돌렸다
v0.37.1의 BuildWithResultHandler는 각 노드의 SolveOpt마다 prepareMultiDriverExports()를 호출한다. 각 노드는 태그 없는 digest push를 수행하고 descriptor를 반환한다. 그 뒤 eg2.Wait()가 모든 노드 작업의 성공을 확인한다. 하나라도 실패하면 함수가 즉시 오류를 반환하므로 manifest list 조립과 태그 푸시에 들어가지 않는다.
모든 노드가 성공했을 때만 Buildx는 반환된 descriptor를 모아 imagetools.Combine()으로 index를 만든다. 이어 imagetools.Push()가 처음 보관해 둔 pushNames에 최종 index를 게시한다. 릴리스 브랜치에는 이 수정이 e968045a로 cherry-pick됐고, 실패한 멀티노드 푸시 뒤 태그가 존재하지 않아야 한다는 원격 멀티노드 통합 시험도 d21727d7로 들어갔다.
여기서 원자성을 너무 크게 해석하면 곤란하다. PR이 보장하려는 범위는 "실패한 멀티노드 빌드가 요청 태그를 부분 이미지에 남기지 않는다"는 것이다. 각 플랫폼 blob이나 digest 참조가 레지스트리에 전혀 전송되지 않는다는 뜻은 아니다. 또한 레지스트리 자체의 모든 동시 쓰기, 같은 태그를 갱신하는 다른 CI 작업, 배포 도구의 캐시까지 한 트랜잭션으로 묶어 주는 것도 아니다.
그림 3. v0.37.1은 모든 노드가 성공한 뒤에만 플랫폼 descriptor를 image index로 합치고 요청 태그를 공개한다. 출처: github.com/docker/buildx/blob/v0.37.1/build/build.go.
운영에서는 태그와 digest를 서로 다른 증거로 다뤄야 한다
Buildx 0.37.0으로 여러 네이티브 노드에 플랫폼을 나눠 --push하는 파이프라인은 0.37.1 이상으로 올리는 편이 안전하다. 다만 업그레이드만으로 배포 계약이 완성되지는 않는다. 빌드와 배포를 같은 가변 태그 하나로 연결하면 다른 작업의 동시 갱신이나 레지스트리 지연을 구분하기 어렵다.
CI에서는 먼저 Buildx 버전을 기록하고, 성공한 빌드가 반환한 최종 index digest를 배포 입력으로 넘기는 편이 낫다. docker buildx imagetools inspect나 레지스트리 API로 그 index에 기대한 플랫폼 descriptor가 모두 있는지 확인한 뒤 태그를 승격한다. 같은 태그를 여러 작업이 쓸 수 있다면 repository와 tag만이 아니라 실행 ID와 digest를 함께 로그에 남겨야 한다.
실패 복구도 "명령이 실패했으니 원격 변경은 0건"이라고 가정하면 안 된다. 0.37.0을 사용한 이력이 있다면 요청 태그가 존재하는지, 무엇을 가리키는지 먼저 읽는다. 예상치 못한 단일 플랫폼 manifest가 보여도 곧바로 삭제하면 다른 작업의 정당한 결과일 수 있다. 빌드 실행 ID, 시작 전 태그 digest, 종료 후 digest, 플랫폼 목록을 대조한 뒤 복구 범위를 정해야 한다.
이번 결론은 Buildx 0.37.1 릴리스 노트, PR #4058, v0.37.0·v0.37.1 고정 소스, Docker 멀티플랫폼·registry exporter 문서, OCI Image Index 1.1.1을 대조한 결과다. 실제 레지스트리 통합 재현은 하지 않았으며, 공개 PR의 재현 및 통합 시험 설계를 구현 범위와 함께 검토했다. 이 수정은 CVE나 Docker Engine 전체의 보안 결함으로 발표된 것이 아니다. 멀티노드 Buildx 푸시의 게시 일관성 결함으로 보는 것이 정확하다.
참고 자료
- Docker Buildx 릴리스: v0.37.1 release notes
- 수정과 재현: docker/buildx PR #4058
- 릴리스 브랜치 수정: commit e968045a
- 원격 멀티노드 회귀 시험: commit d21727d7
- 비교용 고정 소스: Buildx v0.37.0 build.go, Buildx v0.37.1 build.go
- Docker 공식 문서: Multi-platform builds, Image and registry exporters
- OCI 사양: OCI Image Index 1.1.1



댓글
댓글 쓰기