Published on

GitHub Actions로 Docker 이미지 70% 줄이는 파이프라인

Authors

서버 비용과 배포 속도는 결국 이미지 크기빌드 시간으로 귀결되는 경우가 많습니다. 특히 GitHub Actions에서 매번 도커 이미지를 빌드하고 레지스트리에 푸시한다면, 불필요한 레이어와 캐시 미스는 곧바로 CI 시간 증가와 네트워크 비용으로 이어집니다.

이 글은 단순히 멀티스테이지 써라 수준이 아니라, GitHub Actions 파이프라인에서 실제로 이미지 크기를 70% 가까이 줄이는 최적화 조합을 “재현 가능한 형태”로 정리합니다. 핵심은 다음 4가지입니다.

  • Dockerfile 자체 최적화: 멀티스테이지, 의존성 설치 방식, 레이어 최소화
  • 베이스 이미지 전략: distroless 혹은 alpine의 현실적 선택
  • BuildKit 캐시를 CI에 연결: 캐시 내보내기와 가져오기
  • 공급망 품질: SBOM 생성, 취약점 스캔, 서명까지 파이프라인에 통합

추가로, 배포 단계에서 AWS를 쓴다면 OIDC 연동이 필수에 가깝습니다. 관련해서는 GitHub Actions OIDC로 AWS 배포 403 해결 가이드도 함께 보면 파이프라인 완성도가 올라갑니다.

1) “70% 감소”가 나오는 전형적 원인

이미지가 커지는 원인은 대부분 아래 중 하나입니다.

  1. 빌드 도구가 런타임 이미지에 남아 있음
    • node, npm, gcc, make, python, pip 같은 빌드 체인이 그대로 포함
  2. 의존성 설치가 비효율적
    • npm install이 매번 전체 재설치
    • pip가 wheel 캐시 없이 소스 빌드
  3. 레이어가 잘게 쪼개져 캐시가 깨짐
    • COPY . .를 너무 일찍 해서, 작은 파일 변경에도 의존성 레이어가 무효화
  4. 불필요한 파일이 들어감
    • 테스트, 문서, .git, 로컬 캐시, 소스맵, dev dependency 등

즉, “런타임에 필요한 것만 남기고”, “캐시가 잘 맞도록 순서를 재배치”하면 체감이 크게 납니다.

2) Dockerfile 최적화의 정석: 멀티스테이지 + 최소 런타임

여기서는 Node.js 앱을 예시로 들지만, 원리는 Go, Java, Python에도 동일합니다.

2-1) 나쁜 예시(대부분의 이미지 비대화 패턴)

FROM node:20
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
EXPOSE 3000
CMD ["npm", "start"]

문제점:

  • COPY . .가 너무 빨라서 소스 변경 때마다 npm install 캐시가 깨짐
  • 빌드 산출물만 필요해도 런타임에 node:20 전체가 포함
  • dev dependency가 그대로 남는 경우가 많음

2-2) 개선 예시: 의존성 캐시 + 멀티스테이지 + prod만 포함

# syntax=docker/dockerfile:1.7

FROM node:20-bookworm AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
    npm ci

FROM node:20-bookworm AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

FROM gcr.io/distroless/nodejs20-debian12 AS runtime
WORKDIR /app
ENV NODE_ENV=production

# 런타임에 필요한 것만 복사
COPY --from=build /app/dist ./dist
COPY --from=build /app/package.json ./package.json

# distroless는 쉘이 없으므로 엔트리포인트는 명확히
CMD ["dist/index.js"]

포인트:

  • package.json과 락 파일만 먼저 복사해서 의존성 레이어 캐시를 살림
  • --mount=type=cache로 npm 캐시를 레이어에 남기지 않고 빌드 속도만 올림
  • 최종 런타임은 distroless로 줄여 공격면과 이미지 크기 동시 감소

distroless는 쉘이 없어서 디버깅이 불편하지만, 운영 이미지로는 강력합니다. 디버깅이 필요하면 별도 태그로 alpine 기반 디버그 이미지를 만들거나, kubectl debug 계열로 접근하는 방식이 더 안전합니다.

3) .dockerignore가 1차 방어선이다

CI에서 docker build 컨텍스트가 커지면, 빌드 시간이 늘고 레이어 캐시도 불안정해집니다. 아래는 실무에서 거의 고정 템플릿에 가깝습니다.

# .dockerignore
.git
.github
node_modules
npm-debug.log
Dockerfile*
docker-compose*.yml
README.md
*.md
coverage
.vscode
.idea
.env
.env.*
*.log

특히 .git 디렉터리 하나만 포함돼도 컨텍스트가 수십 MB 이상 튀는 경우가 흔합니다.

4) GitHub Actions에서 BuildKit 캐시를 “진짜로” 쓰는 방법

Dockerfile을 잘 짜도, CI가 매번 클린 빌드를 하면 속도도 비용도 손해입니다. 핵심은 docker/build-push-actioncache-fromcache-to입니다.

아래 워크플로는 GHCR로 푸시하는 예시이며, 캐시는 GitHub Actions 캐시 백엔드를 씁니다.

name: build-and-push

on:
  push:
    branches: ["main"]

permissions:
  contents: read
  packages: write

jobs:
  docker:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4

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

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

      - name: Login to GHCR
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and push
        uses: docker/build-push-action@v6
        with:
          context: .
          file: ./Dockerfile
          push: true
          tags: |
            ghcr.io/${{ github.repository }}:sha-${{ github.sha }}
            ghcr.io/${{ github.repository }}:latest
          cache-from: type=gha
          cache-to: type=gha,mode=max
          provenance: false

실무 팁:

  • cache-tomode=max를 기본으로 두면 캐시 적중률이 좋아집니다.
  • 멀티 아키텍처가 필요 없다면 platforms를 생략해 빌드 시간을 줄입니다.
  • provenance는 조직 정책에 따라 켜야 할 수 있지만, 필요 없으면 끄면 약간 단순해집니다.

5) 이미지 크기 줄이는 “결정타” 체크리스트

여기부터는 70%까지 줄이는 데 자주 결정타가 되는 항목들입니다.

5-1) dev dependency 제거

Node 기준으로는 npm ci --omit=dev를 런타임에 적용하거나, 빌드 산출물만 복사하는 전략을 씁니다. 위 Dockerfile처럼 dist만 복사하면 dev dependency가 최종 이미지에 들어갈 이유가 없습니다.

5-2) 패키지 매니저 캐시 제거는 “최종 스테이지”에서만 의미 있음

많은 분들이 rm -rf /var/lib/apt/lists/*를 모든 스테이지에 넣는데, 멀티스테이지를 쓰면 최종 스테이지에만 남는 것이 중요합니다. 빌드 스테이지를 아무리 청소해도 최종 이미지로 복사하지 않으면 크기에 영향이 없습니다.

5-3) Alpine는 만능이 아니다

  • 장점: 작다
  • 단점: musl 기반이라 네이티브 모듈, 인증서, DNS, 타임존 등에서 예상치 못한 이슈가 나올 수 있음

최근에는 debian-slim 혹은 distroless-debian이 운영 안정성과 크기의 균형이 좋습니다.

5-4) 소스맵, 테스트 아티팩트, 문서 제거

프론트엔드 번들(예: Vite, Next.js standalone)에서 소스맵이 크기를 크게 늘립니다. 운영에서 소스맵이 꼭 필요하면 별도 업로드(Sentry 등)로 분리하는 게 일반적입니다.

6) “최적화가 제대로 됐는지” 수치로 검증하기

6-1) 레이어별 용량 확인

로컬이나 CI에서 다음을 실행하면 레이어 구성과 크기를 빠르게 볼 수 있습니다.

docker history --no-trunc your-image:tag

어떤 RUN 레이어가 비대해지는지 확인하고, 해당 레이어에서 생성된 파일이 최종 스테이지로 복사되는지 역추적합니다.

6-2) 컨테이너 내부 파일 크기 상위 확인

docker run --rm your-image:tag sh -c 'du -ah /app | sort -rh | head -n 30'

단, distrolesssh가 없으니 디버그용 이미지에서만 수행하거나, 빌드 스테이지에서 점검용 타깃을 잠깐 만들어 확인합니다.

7) 공급망 품질까지 포함한 “실전 파이프라인” 구성

이미지를 줄이는 것만큼 중요한 게, 줄인 이미지가 안전하고 추적 가능해야 한다는 점입니다. 최소한 아래 2개는 권장합니다.

  • SBOM 생성
  • 취약점 스캔

7-1) Trivy로 이미지 스캔

      - name: Trivy scan
        uses: aquasecurity/trivy-action@0.28.0
        with:
          image-ref: ghcr.io/${{ github.repository }}:sha-${{ github.sha }}
          format: table
          exit-code: "1"
          ignore-unfixed: true
          vuln-type: "os,library"
          severity: "CRITICAL,HIGH"

스캔을 통과하지 못하면 배포를 막는 게 일반적입니다. 다만 초기 도입 시에는 exit-code0으로 두고 리포트만 쌓은 뒤, 기준을 점진적으로 강화하는 방식이 현실적입니다.

7-2) SBOM 생성

SBOM은 도입하면 나중에 사고 대응이 훨씬 쉬워집니다. 예를 들어 syft를 사용하면 다음처럼 생성할 수 있습니다.

      - name: Generate SBOM
        uses: anchore/sbom-action@v0
        with:
          image: ghcr.io/${{ github.repository }}:sha-${{ github.sha }}
          format: spdx-json
          output-file: sbom.spdx.json

      - name: Upload SBOM artifact
        uses: actions/upload-artifact@v4
        with:
          name: sbom
          path: sbom.spdx.json

8) 자주 터지는 함정과 해결책

8-1) 캐시가 안 먹는 이유 3가지

  • COPY . .가 의존성 설치보다 먼저임
  • 락 파일이 자주 바뀜(버전 고정이 안 됨)
  • 빌드 시점에 ARG로 매번 다른 값을 넣어 레이어가 깨짐

해결은 “의존성 레이어를 최대한 앞에, 변동이 적게”입니다.

8-2) distroless에서 런타임 에러가 나는데 로그가 부족함

  • 운영은 distroless로 가되, 동일한 산출물을 담은 debug 태그를 별도로 만들어 alpine이나 debian-slim로 쉘을 제공
  • 또는 Kubernetes 환경이면 ephemeral container로 디버깅

8-3) 보안 스캔 때문에 이미지 크기가 늘었다

스캔 도구를 이미지에 넣는 실수를 종종 합니다. 스캔은 어디까지나 CI 단계에서 실행하고, 결과만 아티팩트로 남기세요.

9) 예시: 최종적으로 기대하는 개선 폭

프로젝트마다 다르지만, 아래 조합이 동시에 들어가면 체감상 50%에서 70%까지도 자주 가능합니다.

  • 멀티스테이지로 빌드 체인 제거
  • distroless 또는 slim으로 런타임 축소
  • .dockerignore로 컨텍스트 최소화
  • BuildKit 캐시 연결로 재빌드 비용 감소
  • dev dependency와 소스맵 제거

이미지 크기가 줄면 배포 속도뿐 아니라 롤백, 스케일 아웃, 장애 복구 시간도 함께 줄어듭니다. 이는 단순 최적화가 아니라 운영 안정성에 직접적인 투자입니다.

10) 다음 단계: 배포까지 연결하기

이미지 최적화 파이프라인을 만들었다면, 다음은 안전한 자격 증명으로 클라우드 배포를 자동화하는 단계입니다. 특히 AWS를 쓰는 경우, 장기 키 대신 OIDC로 전환하는 것이 표준에 가깝습니다. 실제로 403이나 권한 꼬임을 겪는 팀이 많으니, 배포 파이프라인까지 묶을 계획이라면 GitHub Actions OIDC로 AWS 배포 403 해결 가이드를 함께 참고하세요.


정리하면, “Dockerfile 최적화”와 “CI 캐시 최적화”는 따로가 아니라 한 세트입니다. Dockerfile에서 캐시가 깨지지 않게 설계하고, GitHub Actions에서 BuildKit 캐시를 안정적으로 유지하면, 이미지 크기와 빌드 시간이 동시에 내려갑니다. 운영 환경에서 이 조합이 가장 꾸준히 성과를 냅니다.