website: 백엔드 도커 빌드 스테이지 BUILDPLATFORM 고정으로 arm64 크로스 빌드 단축
CD 파이프라인에서 11분 넘게 걸리던 이미지 빌드 스텝의 원인을 찾아 Dockerfile 한 줄로 고친 PR로, dev에 병합되었습니다.
요약
이 PR은 백엔드 배포 CD에서 "이미지 빌드 & 푸시" 단계가 11분 44초나 걸리던 문제를 해결합니다. 전체 배포 시간이 약 13분이었으니 이 단계 하나가 거의 전부를 차지하고 있었던 셈입니다. 원인을 추적해보니 Dockerfile의 builder 스테이지가 워크플로의 platforms: linux/arm64 지정에 통째로 묶여서, Gradle 컴파일 자체가 QEMU arm64 에뮬레이션 위에서 돌고 있었습니다. backend/Dockerfile에 --platform=$BUILDPLATFORM 한 줄을 추가하는 것으로 문제를 해결했고, dev 브랜치에 병합되었습니다.
배경 및 목적
서비스가 배포되는 OCI 서버가 arm64 아키텍처라서, GitHub Actions 러너(amd64)에서 크로스 빌드가 필요한 상황이었습니다. 문제는 멀티스테이지 Dockerfile을 짤 때 워크플로에서 지정한 --platform linux/arm64가 빌더 스테이지와 런타임 스테이지 모두에 그대로 적용되고 있었다는 점입니다.
Gradle로 컴파일하는 builder 스테이지까지 arm64로 지정되다 보니, amd64 GH 러너 위에서 QEMU를 통해 arm64를 흉내내며 컴파일이 돌아가는 상황이 벌어졌습니다. 이게 CD 시간의 대부분을 잡아먹고 있었던 겁니다.
구현 내용
핵심 변경은 backend/Dockerfile의 첫 줄입니다.
- FROM gradle:8-jdk21-alpine AS builder
+ # 빌드 스테이지는 빌드 머신 아키텍처(BUILDPLATFORM, GH 러너는 amd64)로 고정 —
+ # Java는 바이트코드로 컴파일되므로 QEMU arm64 에뮬레이션 위에서 돌릴 필요가 없다.
+ # 최종 런타임 이미지만 buildx가 지정한 --platform(arm64)을 그대로 따른다.
+ FROM --platform=$BUILDPLATFORM gradle:8-jdk21-alpine AS builder
Java는 네이티브 기계어가 아니라 JVM 바이트코드로 컴파일되기 때문에, 어떤 CPU 위에서 컴파일하든 결과 산출물(class 파일, jar)은 완전히 동일합니다. $BUILDPLATFORM은 docker buildx가 자동으로 채워주는 변수로, "실제 빌드가 실행되는 머신"의 아키텍처(여기선 GH 러너인 amd64)를 가리킵니다. 이 한 줄만 추가하면 컴파일 스테이지가 에뮬레이션 없이 네이티브 속도로 돌게 됩니다.
반면 최종 런타임 스테이지(eclipse-temurin:21-jre-alpine)는 플랫폼 지정을 건드리지 않아서, buildx가 워크플로에서 지정한 --platform linux/arm64를 그대로 따릅니다. 즉 결과물인 최종 이미지는 여전히 arm64이고, 바뀐 건 오직 "컴파일이 어느 아키텍처 위에서 도느냐"뿐입니다.
의존성 쪽도 짚고 넘어갈 부분이 있었습니다. sqlite-jdbc처럼 네이티브(JNI) 바이너리를 쓰는 라이브러리가 있으면 크로스 컴파일에서 문제가 될 수 있는데, 이 jar는 여러 OS/아키텍처용 네이티브 바이너리를 전부 번들해두고 실행 시점 JVM 기준으로 알맞은 걸 선택하는 방식이라 빌드 머신 아키텍처와 무관하게 안전하다는 걸 확인했습니다.
변경된 파일은 backend/Dockerfile과 pm/docs/learnings.md 두 개뿐이고, 워크플로 파일(cd.yml)이나 docker-compose.yml, 롤백 로직은 전혀 건드리지 않았습니다. 리스크를 최소화하면서 Dockerfile 한 줄로 문제를 해결한 셈입니다.
검증은 로컬에서 docker buildx build --platform linux/arm64로 동일한 크로스 빌드 조건을 재현하는 방식으로 진행했습니다.
docker image inspect로 최종 이미지 아키텍처가arm64/linux인 것을 확인compileJava가 에뮬레이션 없이 정상 속도(~72초)로 완료 — 기존 CI 로그에서 builder 스테이지를 포함한 전체가 11분대였던 것과 비교하면 확연한 차이
커밋 히스토리를 보면 수정 → 학습 기록 → dev 병합 순으로 깔끔하게 진행됐는데, 시행착오라기보다는 원인 분석을 먼저 끝내고 정확히 필요한 곳만 고친 흐름에 가깝습니다.
기술적 의사결정
여기서 핵심 판단은 "컴파일형 언어라고 해서 전부 크로스 빌드 시 타깃 아키텍처에서 컴파일해야 하는 건 아니다"라는 부분입니다. Java/JVM 계열처럼 바이트코드로 컴파일되는 언어는 컴파일 결과물이 아키텍처에 종속되지 않으므로, 멀티스테이지 Dockerfile에서 컴파일 스테이지를 빌드 머신 네이티브에 고정해도 무방합니다. 반면 Go나 Rust처럼 네이티브 기계어로 컴파일하는 언어는 이 방법이 그대로 적용되지 않고, 타깃 아키텍처를 위한 별도의 크로스컴파일 플래그가 필요하다는 차이도 함께 정리해뒀습니다.
docker-compose.yml이나 CD 워크플로 자체를 바꾸는 대신 Dockerfile 한 줄만 수정하는 방향을 택한 것도 눈여겨볼 지점입니다. 배포 파이프라인의 다른 부분(헬스체크, 스모크 테스트, 롤백 로직)에 영향을 주지 않으면서 문제의 근본 원인만 정확히 제거하는 최소 변경 전략입니다.
배운 점 및 개선점
pm/docs/learnings.md에 남긴 기록이 이 PR의 사고 과정을 잘 보여줍니다. "왜 arm64로 빌드해야 하지?"라는 질문에 대한 답은 사실 "런타임 이미지만"이라는 것 — 컴파일이 CPU-bound인 컴파일형 언어를 도커로 크로스 빌드할 때는 스테이지 분리부터 의심해보는 게 습관이 되면 좋겠다는 메모를 남겼습니다.
Test plan에는 아직 체크되지 않은 항목이 남아 있습니다. 실제 dev CD 런에서 "이미지 빌드 & 푸시" 스텝 시간이 로컬 검증한 만큼 줄어드는지, 그리고 stage 헬스체크·스모크 테스트가 기존과 동일하게 통과하는지는 병합 후 실제 파이프라인에서 확인이 필요한 부분입니다.