Backend

배포 시간 14분에서 53초로: 빌드 구조 개선과 ARM64 정합성 해결

zerozeroseven 2026. 5. 31. 16:46

 

CI/CD를 운영하면서 dev 배포를 돌리던 중 컨테이너가 Restarting 상태로 머물렀고, /dev/health, /dev/swagger-ui/index.html도 정상 확인이 되지 않았다.

애플리케이션 설정 문제를 먼저 의심했지만, 로그에서 exec format error가 반복적으로 확인됐다.


exec format error는 바이너리(또는 이미지 내부 실행 파일) 아키텍처가 런타임과 맞지 않을 때 발생하는 대표적인 에러다.

실제 EC2는 ARM64(aarch64)인데, GitHub Actions에서 빌드한 이미지는 기본값으로 amd64가 생성되고 있었다.

즉, 배포 성공실행 가능이 분리된 상태였고, 먼저 아키텍처 정합성을 맞추는 게 우선이었다.

 

1. 실행 실패부터 해결: ARM64 아키텍처 정합성 맞추기

dev/prod 배포 워크플로우에 QEMU, Buildx를 추가하고, 이미지 빌드 플랫폼을 linux/arm64로 고정했다.

- name: Set up QEMU
  uses: docker/setup-qemu-action@v4

- name: Set up Docker Buildx
  uses: docker/setup-buildx-action@v3

- name: Build and push image
  uses: docker/build-push-action@v6
  with:
    platforms: linux/arm64

 

 

여기서 QEMU는 이기종 아키텍처 빌드를 가능하게 하고, Buildx는 멀티/크로스 플랫폼 이미지를 빌드하는 빌더 역할을 한다.

핵심은 EC2 런타임(ARM64)과 배포 이미지 아키텍처를 일치시켜, 컨테이너가 올라오는 것이 아니라 실행 가능한 상태를 보장하는 것이다.

ARM64 문제를 해결하고 나서 배포를 다시 돌리니, 이번에는 성능 병목이 눈에 띄었다.

Build and push image 단계가 첫 실행 기준 14분 13초로 너무 오래 걸렸다.

 

2. 병목 구간 줄이기: 빌드 책임 분리 + 캐시 전략 적용

기존 구조는 Docker build 내부에서 ./gradlew bootJar를 실행하고 있었다.
이 방식은 배포 때마다 Gradle Wrapper/의존성/앱 빌드가 다시 발생해, 이미지 빌드 단계가 과도하게 무거워진다.

 

그래서 파이프라인 책임을 분리했다.

 

  • GitHub Actions에서 애플리케이션 산출물(JAR) 먼저 생성
  • Docker는 JAR를 복사해 실행 이미지 패키징만 수행
  • Gradle 캐시 + Docker BuildKit 캐시를 각각 적용
  • 실행 JAR 이름을 app.jar로 고정해 와일드카드 의존 제거
- name: Set up JDK 21
  uses: actions/setup-java@v4
  with:
    distribution: temurin
    java-version: "21"
    cache: gradle

- name: Build jar
  run: ./gradlew bootJar --no-daemon

- name: Prepare jar for Docker image
  run: |
    JAR_FILE=$(find build/libs -maxdepth 1 -type f -name "*.jar" ! -name "*-plain.jar" | head -n 1)
    cp "$JAR_FILE" build/libs/app.jar
with:
  cache-from: type=gha
  cache-to: type=gha,mode=max

 

cache: gradle는 Gradle 의존성/빌드 재사용에, cache-from/to: gha는 Docker 레이어 재사용에 기여한다.

또한 JAR 경로를 고정하면 *-SNAPSHOT.jar 패턴 변화나 다중 산출물 상황에서 발생할 수 있는 불안정성을 줄일 수 있다.


이 변경 이후 dev 배포 기준 Build and push image는 14분 13초에서 53초로 단축됐다.

 

3. 추가 최적화 시도: Layered JAR로 레이어 캐시 효율 개선

속도가 개선된 뒤에는 추가적인 캐시 효율 개선 가능성을 생각해봤다.

Spring Boot layertools를 적용하면 의존성 레이어와 애플리케이션 레이어를 분리할 수 있어서, 변경 범위가 작을 때 캐시 재사용 효율이 좋아질 수 있다.

 

먼저 bootJar를 layered jar로 생성하도록 설정했다.

tasks.bootJar {
    layered {
        enabled.set(true)
    }
}

 

Dockerfile도 layertools 추출 기반으로 변경했다.

RUN java -Djarmode=layertools -jar app.jar extract

COPY --chown=appuser:appgroup --from=builder /builder/dependencies/ ./
COPY --chown=appuser:appgroup --from=builder /builder/spring-boot-loader/ ./
COPY --chown=appuser:appgroup --from=builder /builder/snapshot-dependencies/ ./
COPY --chown=appuser:appgroup --from=builder /builder/application/ ./

 

레이어를 분리하면 일반적으로 다음 효과를 기대할 수 있다.

 

  • 의존성 변경이 없으면 dependencies 레이어 재사용
  • 애플리케이션 코드만 바뀌면 application 레이어만 갱신
  • 결과적으로 반복 빌드에서 push 대상 레이어와 빌드 시간이 줄어들 가능성

다만 이번 측정에서는 Build and push image가 1분 4초로 확인돼, 직전 캐시 반영 배포(53초) 대비 유의미한 추가 단축은 확인하지 못했다.
즉, 이번 단계는 즉시 속도 개선보다 캐시 친화적인 이미지 구조를 갖춘 것에 더 의미가 있었다.

마무리

이번 작업은 배포 파이프라인을 단계별로 정리한 과정이었다.
먼저 ARM64 환경과 이미지 아키텍처를 일치시켜 실행 실패 원인을 제거했고, 이후에는 애플리케이션 빌드와 이미지 빌드 책임을 분리해 배포 시간을 단축했다.
마지막으로 layered jar 기반 레이어 분리 구조를 적용해, 반복 배포에서 캐시 재사용이 가능한 형태로 이미지 빌드 구조를 정돈했다.

이번 개선의 핵심은 속도 수치 자체보다, 배포 실패 원인을 구조적으로 제거하고 운영 가능한 배포 흐름으로 정리했다는 점이다.