arm64 Docker 빌드가 QEMU에서 멈출 때: 네이티브 러너로 해결
앞 글 빌드는 CI에서, 실행은 서버에서에서, 빌드를 서버 밖 GitHub Actions로 옮기는 pull-only 구조를 정리했습니다. 계획은 깔끔했습니다. 그런데 막상 옮기고 나니, 계획서에는 없던 벽이 하나 기다리고 있었습니다. 빌드가 6시간 동안 멈춰 있다가 죽는 것이었습니다.
계획: 빌드를 박스 밖으로
합치려는 서버는 ARM64(aarch64) 아키텍처였습니다. 그래서 GitHub Actions에서 이미지를 만들 때도 반드시 linux/arm64용으로 빌드해야 했습니다. x86으로 만든 이미지를 ARM 서버에서 실행하면 exec format error가 나기 때문입니다.
가장 흔한 방법은 QEMU 에뮬레이션입니다. 일반적인 x86 러너 위에서 docker/setup-qemu-action과 buildx로 arm64를 흉내 내어 빌드하는 것입니다. 어떤 플랜에서도 동작하고, 설정도 몇 줄이면 끝나서 자연스럽게 이 방식을 골랐습니다.
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-qemu-action@v3 # x86 위에서 arm64 에뮬레이션
with: { platforms: arm64 }
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v6
with:
platforms: linux/arm64
push: true문제: 첫 빌드는 5분, 두 번째는 6시간 멈춤
처음 돌렸을 때는 백엔드와 프론트엔드 이미지가 모두 5분 남짓에 무사히 올라갔습니다. 그래서 다 됐다고 생각했습니다. 그런데 사소한 설정 하나를 고치고 다시 푸시하자, 백엔드는 여전히 빠르게 끝났는데 프론트엔드 빌드만 6시간 내내 멈춰 있다가 GitHub의 잡 타임아웃에 걸려 취소됐습니다.
같은 코드, 같은 Dockerfile인데 한 번은 5분, 한 번은 6시간이었습니다. 재현이 들쭉날쭉하다는 점이 오히려 불길했습니다. 로그를 보면 특정 스텝에서 진행 표시가 멈춘 채 6시간을 흘려보내고 있었습니다.
# 잡 스텝 상태 (2차 실행)
build (backend) → success (4.7분)
build (frontend) → cancelled (6h0m14s) ← Build 스텝에서 멈춤
원인: QEMU가 네이티브 바이너리를 에뮬레이션할 때
범인은 QEMU 자체였습니다. 백엔드는 TypeScript 컴파일(tsc)만 하면 되지만, 프론트엔드는 Vite로 번들링하면서 내부적으로 esbuild라는 네이티브 바이너리를 사용합니다. 이 네이티브 실행 파일을 x86 위에서 arm64로 에뮬레이션하는 순간, 비결정적으로 멈추는 일이 벌어졌습니다.
QEMU는 명령어 하나하나를 번역해서 실행하는 방식이라 원래도 느리지만, 특정 네이티브 코드 경로에서는 아예 진행이 되지 않고 붙잡히는 경우가 있습니다. 첫 실행이 운 좋게 통과한 건 그저 운이었다는 생각이 들었습니다. 편해 보이던 QEMU 크로스빌드가, 사실은 확률적으로 죽는 다리였던 셈입니다.
해결: 네이티브 arm64 러너로 전환
해결책은 에뮬레이션을 걷어내는 것이었습니다. GitHub이 정식 제공하는 네이티브 ARM64 러너 ubuntu-24.04-arm로 바꾸면, esbuild가 진짜 ARM 위에서 네이티브로 돌기 때문에 흉내 낼 것이 없어집니다.
jobs:
build:
runs-on: ubuntu-24.04-arm # 네이티브 arm64 — QEMU 불필요
timeout-minutes: 30 # 혹시 모를 hang 방어 (6시간 방지)
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3 # QEMU 스텝 제거
- uses: docker/build-push-action@v6
with:
platforms: linux/arm64
push: true결과는 극적이었습니다.
이미지 | QEMU 크로스빌드 | 네이티브 arm64 러너 |
|---|---|---|
프론트엔드(Vite) | 6시간 멈춤 → 취소 | 1분 34초 |
백엔드(NestJS) | 약 4.7분 | 28초 |
여기에 더해 timeout-minutes를 걸어두었습니다. 근본 원인은 해결됐지만, 어떤 이유로든 다시 멈추더라도 6시간이 아니라 30분 안에 실패로 끝나게 하는 안전망입니다. 실패는 빨리 아는 편이 낫다는 생각이 들었습니다.
정리하며
돌이켜 생각해보면, "일단 어디서든 동작한다"는 QEMU의 편의성이 오히려 함정이었습니다. 크로스빌드가 필요할 때 QEMU는 여전히 유용하지만, esbuild나 sharp처럼 네이티브 바이너리를 쓰는 빌드라면 이야기가 다릅니다.
몇 가지 교훈을 남깁니다.
네이티브 러너를 우선 검토: arm64 이미지가 필요하면
ubuntu-24.04-arm같은 네이티브 러너가 가용한지 먼저 확인하는 편이 좋겠다는 결론에 이르렀습니다. 빠르고 안정적입니다.QEMU는 폴백으로: 네이티브 러너를 못 쓰는 환경에서만 QEMU를 쓰고, 반드시
timeout-minutes로 hang을 방어합니다.PR에서 빌드를 검증: 병합 전 PR에서 arm64 빌드를 한 번 돌려두면, 이런 에뮬레이션 문제를 프로덕션 전에 잡을 수 있습니다.
다음 글에서는, 이렇게 만든 이미지를 서버가 실제로 받아 실행할 때 만나는 :latest 태그의 함정을 다룹니다. 새 이미지를 올렸는데도 서버가 예전 버전을 붙잡는 문제였습니다.
관련 글
OCI ARM64 서버 통합기: 빌드 RAM 병목을 GitHub Actions로 분리했습니다
무료 OCI ARM64 서버 2대를 1대로 합치려다, 진짜 병목이 런타임이 아니라 '빌드 RAM 피크'라는 걸 알게 됐습니다. 박스에서 빌드를 걷어내고 GitHub Actions 네이티브 arm64 러너로 빌드해 GHCR에 올리는 '빌드/실행 분리'로 통합을 해낸 회고입니다.
빌드는 CI에서, 실행은 서버에서: GHCR pull-only 배포
작은 서버는 앱을 실행할 여유는 있어도 빌드에서 죽습니다. 빌드를 GitHub Actions로 빼고 서버는 완성 이미지를 받아 실행만 하는 pull-only 구조와, private 레지스트리 인증까지 정리했습니다.
Coolify HEALTHCHECK 버그를 발견하고 오픈소스 PR을 올리기까지
NestJS Worker 배포 중 Coolify의 HEALTHCHECK 감지 버그를 발견했습니다. Dockerfile 전체에서 문자열을 검색하는 로직이 multi-stage target을 고려하지 않는 문제였고, 소스 분석부터 PR 제출까지의 과정을 정리했습니다.