왜 이미지 크기가 문제가 되는가
컨테이너 이미지는 방치하면 조금씩 커진다. 누군가 디버깅용으로 build-essential을 추가하고, 베이스 이미지를 slim에서 full로 바꾸고, 빌드 산출물과 캐시가 최종 레이어에 남는다. 개별 PR에서는 몇 MB라 눈에 띄지 않지만, 몇 달 뒤 200MB이던 이미지가 900MB가 되어 있다.
크기 증가는 단순히 저장 비용 문제가 아니다. 롤아웃 시 노드마다 이미지를 pull해야 하므로 배포 지연과 콜드 스타트가 길어지고, 오토스케일 반응 속도가 떨어진다. 공격 표면(불필요한 패키지)도 함께 늘어난다. 문제는 "언제 커졌는지" 아무도 모른다는 점이다. 그래서 커밋 단위로 크기를 측정하고, 임계치를 넘으면 CI를 실패시키는 회귀 게이트가 필요하다.
측정: 무엇을 기준으로 삼을 것인가
세 가지 값을 구분해야 한다. 압축 전 크기(docker images가 보여주는 값), 레지스트리 push 시 전송되는 압축 크기, 그리고 레이어별 크기다. 게이트는 보통 압축 전 총 크기를 절대 임계치와 함께 쓰고, 여기에 base 대비 증가분(델타)을 병행한다.
#!/usr/bin/env bash
set -euo pipefail
IMAGE="$1" # 예: myapp:pr-123
LIMIT_MB="${2:-500}" # 절대 상한
bytes=$(docker image inspect "$IMAGE" --format '{{.Size}}')
mb=$(( bytes / 1024 / 1024 ))
echo "image_size_mb=$mb"
if [ "$mb" -gt "$LIMIT_MB" ]; then
echo "::error::image ${mb}MB exceeds limit ${LIMIT_MB}MB"
exit 1
fi
절대 상한만으로는 "이미 큰 이미지가 더 커지는" 경우를 못 막는다. main 브랜치의 크기를 아티팩트나 캐시에 저장해 두고, PR 크기와 비교하는 델타 게이트를 함께 두는 것이 실무적으로 효과가 크다.
GitHub Actions 게이트 예시
name: image-size-gate
on: [pull_request]
jobs:
size-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build
run: docker build -t myapp:pr .
- name: Restore baseline
uses: actions/cache@v4
with:
path: .size-baseline
key: image-size-baseline
- name: Compare
run: |
cur=$(docker image inspect myapp:pr --format '{{.Size}}')
cur_mb=$(( cur / 1024 / 1024 ))
base_mb=$(cat .size-baseline 2>/dev/null || echo "$cur_mb")
delta=$(( cur_mb - base_mb ))
echo "current=${cur_mb}MB base=${base_mb}MB delta=${delta}MB"
# 절대 상한 500MB, 증가분 20MB 초과 시 실패
[ "$cur_mb" -le 500 ] || { echo "::error::over 500MB"; exit 1; }
[ "$delta" -le 20 ] || { echo "::error::grew ${delta}MB"; exit 1; }
baseline 파일은 main 브랜치 워크플로에서 갱신하고, PR에서는 읽기만 한다. 이렇게 하면 PR 저자가 자기 브랜치에서 baseline을 조작해 게이트를 우회할 수 없다.
임계치는 어떻게 정할까
| 방식 | 장점 | 단점 |
|---|---|---|
| 절대 상한 | 단순, 명확 | 이미 큰 이미지 증가 못 막음 |
| 델타(증가분) | 점진적 팽창 차단 | baseline 관리 필요 |
| 비율(%) | 규모 무관 적용 | 작은 이미지에서 과민 |
절대 상한 + 델타 조합을 권장한다. 델타는 고정 MB(예: 20MB)와 비율(예: 5%) 중 큰 값을 허용치로 쓰면 작은 이미지와 큰 이미지 모두에서 무난하게 동작한다.
원인 진단: 레이어 단위로 쪼개기
게이트가 실패했을 때 "어느 레이어가 범인인지" 바로 보여줘야 개발자가 고칠 수 있다. docker history로 레이어별 크기를 뽑아 상위 항목을 리포트한다.
import json, subprocess, sys
img = sys.argv[1]
out = subprocess.check_output(
["docker", "history", "--no-trunc", "--format",
"{{.Size}}\t{{.CreatedBy}}", img], text=True)
rows = []
for line in out.strip().splitlines():
size, cmd = line.split("\t", 1)
rows.append((size, cmd[:80]))
rows.sort(key=lambda r: r[0], reverse=True)
for size, cmd in rows[:8]:
print(f"{size:>10} {cmd}")
여기서 자주 드러나는 패턴은 apt 캐시 미삭제, 멀티스테이지 미사용, COPY . .로 .git과 테스트 자료까지 포함되는 경우다. .dockerignore 정비와 멀티스테이지 빌드만으로 상당 부분이 해결된다.
운영 시 주의점
첫째, 게이트는 처음부터 hard fail로 걸지 말고 경고(warning) 모드로 며칠 운영해 baseline과 노이즈를 파악한 뒤 차단으로 전환한다. 갑작스러운 차단은 팀의 반발을 산다.
둘째, 정당한 증가에 대한 예외 경로를 둔다. 새 런타임 도입처럼 의도된 증가는 라벨(예: allow-size-growth)이나 baseline 재설정 커밋으로 승인한다. 예외가 없으면 개발자는 게이트를 우회하려 이미지를 편법으로 줄인다.
셋째, 측정 환경을 고정한다. 빌드 캐시 상태, 베이스 이미지 태그(latest 대신 다이제스트 고정), 빌더 버전에 따라 크기가 달라지므로 CI 러너와 빌드 인자를 통일해야 비교가 의미를 가진다. 마지막으로 게이트 수치는 매 실행마다 로그·아티팩트로 남겨 추세를 추적하자. 단발성 차단보다 장기 추세가 회귀를 더 잘 드러낸다.