DevOps 운영 노트

GitHub Actions에서 멀티아치 Docker 이미지 빌드 (amd64 + arm64)

ARM CI 러너에서 빌드한 이미지가 x86 EC2에서 exec format error로 죽을 때, QEMU + buildx로 한 태그에 두 아키 manifest를 묶어 해결한 과정.

2026. 06. 24.
GitHub ActionsDockerECRCI/CDAWS

CI가 ARM 러너에서 linux/arm64만 빌드해 ECR로 올리던 상태. x86 EC2에서는 못 돌아가니 한 태그(:main)로 두 아키 모두 커버하도록 수정한 기록.


왜 이게 필요한가

ECR 이미지 manifest를 까보면 arm64 한 개뿐이었다:

aws ecr batch-get-image --repository-name meeny-backend --region ap-northeast-2 \
  --image-ids imageTag=main --query 'images[0].imageManifest' --output text \
  | python3 -m json.tool
  • t3.micro(x86)에서 docker pull + docker run 하면 exec format error로 즉사.
  • ARM 인스턴스(t4g.x)는 12개월 무료티어 대상이 아님 (2023-12-31에 종료). 서울에서 진짜 무료는 t2/t3.micro뿐.

선택지 비교

옵션비용추가 작업결과
(A) 그대로 두고 t4g.small ARM 쓰기약 $12/월없음무료 아님
(B) CI를 멀티아치로 고치고 t3.micro$0 (12개월 프리티어)CI yml 수정 + 한 번 더 빌드진짜 공짜
(C) t4g.micro (1GB ARM)약 $6/월없음무료 아님

→ B 채택.


변경 내용

/.github/workflows/ci.yml build-and-push 잡:

Before

runs-on: ubuntu-24.04-arm
steps:
  - uses: actions/checkout@v4
  - uses: docker/setup-buildx-action@v3
  - uses: aws-actions/configure-aws-credentials@v4
    with: { ... }
  - uses: aws-actions/amazon-ecr-login@v2
  - uses: docker/build-push-action@v6
    with:
      platforms: linux/arm64           # ← arm64만

After

runs-on: ubuntu-latest                  # ← x86 러너
steps:
  - uses: actions/checkout@v4
  - uses: docker/setup-qemu-action@v3   # ← 추가: arm64 에뮬레이션
  - uses: docker/setup-buildx-action@v3
  - uses: aws-actions/configure-aws-credentials@v4
    with: { ... }
  - uses: aws-actions/amazon-ecr-login@v2
  - uses: docker/build-push-action@v6
    with:
      platforms: linux/amd64,linux/arm64   # ← 멀티아치

어떻게 동작하나

  • setup-qemu-action은 binfmt_misc에 QEMU 사용자 모드 에뮬레이터를 등록한다. 이러면 x86 호스트에서도 RUN ./gradlew bootJar 같은 ARM 명령을 에뮬레이션으로 실행 가능.
  • buildx가 두 platform에 대해 각각 빌드한 뒤, multi-arch manifest list 하나로 묶어 ECR에 push한다. 태그 :main은 그 manifest list를 가리킨다.
  • Docker가 pull할 때 자기 호스트 아키에 맞는 manifest를 골라서 받는다. 사용자는 한 태그만 쓰면 된다.

비용/속도 트레이드오프

  • QEMU 에뮬레이션은 네이티브 빌드 대비 수~수십 배 느리다. Spring Boot 빌드(gradlew bootJar) 같은 JVM 작업은 비교적 영향이 적은 편 (CPU-heavy 코드보단 I/O/JVM 지배적).
  • 대안: x86 러너에서 amd64는 네이티브로, arm64는 ARM 러너에서 네이티브로 빌드 후 manifest를 합치는 멀티 잡 구조. 더 빠르지만 yaml이 두 배. 본 프로젝트는 빌드 시간이 충분히 짧아서 QEMU로 단순화.

main 브랜치 보호 우회 못 함 → PR로 갈음

  • 로컬에서 git push origin main 시 hook이 막는다 (사내 정책 또는 harness 정책).
  • 별도 브랜치 ci/multi-arch-image 만들어 push → gh pr create → 머지.
  • CI yml의 build-and-pushif: github.event_name == 'push' && github.ref == 'refs/heads/main'이라 PR 단계에서는 안 돌고, 머지 후 main에 푸시될 때 1회 실행된다.
git checkout -b ci/multi-arch-image
git add .github/workflows/ci.yml
git commit -m "ci: build multi-arch image (amd64+arm64) so x86 EC2 can run it"
git push -u origin ci/multi-arch-image
gh pr create --base main --head ci/multi-arch-image --title "..." --body "..."
gh pr merge <PR#> --squash --delete-branch
gh run watch <RUN_ID> --exit-status

검증

머지 후 main의 CI가 끝나면 manifest에 두 아키가 보여야 한다:

aws ecr batch-get-image --repository-name meeny-backend --region ap-northeast-2 \
  --image-ids imageTag=main --query 'images[0].imageManifest' --output text \
  | python3 -c "import json,sys; m=json.load(sys.stdin); \
      [print(x['platform']['architecture'], x['platform']['os']) for x in m['manifests']]"
# expected:
#   amd64 linux
#   arm64 linux
#   unknown unknown   (← attestation manifest, 무시 가능)
#   unknown unknown

학습 포인트

  • multi-arch manifest = manifest list (OCI image index). 단일 이미지가 아니라 "아키별 이미지로 가는 포인터 모음". imageManifestMediaTypeapplication/vnd.oci.image.index.v1+json이면 multi-arch.
  • unknown/unknown 매니페스트는 SBOM/Provenance attestation. buildx가 자동으로 만들어 같이 push한 메타데이터. 무시해도 된다.
  • GitHub Actions ARM 러너(ubuntu-24.04-arm)는 네이티브 ARM. 단일 아키만 빌드할 거면 빠른 옵션이지만 멀티아치 같이 굴리려면 x86 + QEMU가 더 단순하다.