# 개발용 컨테이너 이미지가 어제와 다르게 빌드되는 이유
같은 Dockerfile로 빌드했는데 어제는 되고 오늘은 apt-get install이 404로 실패합니다. 반대로 패키지를 하나 추가했는데 나머지는 몇 달 전 버전 그대로 설치되기도 합니다. 두 증상은 반대로 보이지만 원인이 같습니다. 패키지 인덱스와 실제 설치가 서로 다른 캐시 레이어에 묶여 있기 때문입니다.
개발용 이미지는 특히 이 문제에 취약합니다. 자주 수정되고, 도구를 계속 추가하며, "일단 되니까" 상태로 오래 방치되기 때문입니다. 이 글에서는 캐시가 만드는 불일치의 구조, 재현성을 어디까지 확보할지 정하는 기준, 그리고 개발 이미지 특유의 문제를 정리합니다.
# 1. 인덱스와 설치가 갈라지는 구조
문서가 문제 상황을 그대로 보여 줍니다.
FROM ubuntu:22.04
RUN apt-get update
RUN apt-get install -y --no-install-recommends curl
여기서 apt-get install 줄에 nginx를 추가하면, Docker는 apt-get update 줄의 캐시를 재사용합니다. 명령어가 바뀌지 않았으니 다시 실행할 이유가 없습니다. 그 결과 몇 달 전에 받아 둔 패키지 인덱스로 새 패키지를 설치하게 됩니다.
이것이 두 증상 모두의 원인입니다. 낡은 인덱스가 가리키는 버전이 저장소에서 이미 사라졌다면 404가 나고, 아직 남아 있다면 낡은 버전이 조용히 설치됩니다. 후자가 더 위험합니다. 빌드가 성공하기 때문에 아무도 눈치채지 못합니다.
해결은 두 명령을 한 레이어로 묶는 것입니다. 문서는 이 기법을 "cache busting"이라고 부릅니다.
Always combine
RUN apt-get updatewithapt-get installin the sameRUNstatement.
RUN apt-get update && apt-get install -y --no-install-recommends \
package-bar \
package-baz \
package-foo
패키지 목록이 바뀌면 명령어 문자열이 바뀌고, 그러면 apt-get update도 함께 다시 실행됩니다. 인덱스와 설치가 항상 같은 시점의 것이 됩니다.
# 2. 재현성을 어디까지 가져갈 것인가
1절의 수정으로 불일치는 사라지지만 재현성은 여전히 없습니다. 같은 Dockerfile을 오늘과 다음 달에 빌드하면 다른 버전이 설치됩니다. 여기서 선택이 필요합니다.
버전을 고정하면 재현됩니다.
RUN apt-get update && apt-get install -y --no-install-recommends \
s3cmd=1.1.* \
curl
문서가 지적하듯 버전 지정이 바뀌면 캐시가 무효화되므로, 고정 자체가 갱신을 명시적인 행위로 만듭니다.
문제는 비용입니다. 개발 이미지에 도구가 스무 개 들어 있다면 스무 개를 고정하고, 베이스 이미지가 올라갈 때마다 스무 개를 다시 맞춰야 합니다. 보안 패치도 자동으로 들어오지 않습니다.
층위별로 나눠 결정하는 편이 현실적입니다.
| 대상 | 고정 수준 | 이유 |
|---|---|---|
| 베이스 이미지 | 다이제스트 고정 (ubuntu@sha256:...) | 가장 큰 변동 요인, 고정 비용이 한 줄 |
| 런타임·언어 버전 | 정확한 버전 | 애플리케이션 동작에 직결 |
| 애플리케이션 의존성 | 잠금 파일 | 이미 도구가 지원 |
| OS 유틸리티 | 고정하지 않음 | 갱신 비용 대비 위험 낮음 |
핵심은 베이스 이미지 태그가 가변이라는 사실입니다. ubuntu:22.04는 같은 이름으로 내용이 계속 갱신됩니다. 이것 하나만 다이제스트로 고정해도 "어제와 다른 이미지"의 큰 원인이 사라집니다.
FROM ubuntu:22.04@sha256:0e0402cd13f68137edb0266e1d2c682f217814420f2d43d300ed8f65479b14fb
# 3. 개발 이미지에만 있는 문제
운영 이미지는 작고 단일 목적이라 관리하기 쉽습니다. 개발 이미지는 성격이 다릅니다.
사용자와 권한. 컨테이너 안에서 만든 파일이 호스트 마운트에 남을 때 소유자가 어긋나면 편집이 안 되거나 루트 소유 파일이 쌓입니다. 개발 이미지는 호스트 사용자와 UID/GID를 맞춰야 실제로 쓸 수 있습니다.
ARG USER_UID=1000
ARG USER_GID=1000
RUN groupadd -g "$USER_GID" dev \
&& useradd -m -u "$USER_UID" -g "$USER_GID" -s /bin/bash dev
USER dev
셸 설정과 진입점. zsh나 셸 플러그인을 이미지에 굽고 나면, 설정을 바꿀 때마다 이미지를 다시 빌드해야 합니다. 자주 바뀌는 설정은 이미지가 아니라 마운트나 진입 스크립트로 빼는 편이 낫습니다. 진입 스크립트를 쓸 때는 실패를 조용히 넘기지 않도록 해야 합니다.
#!/usr/bin/env bash
set -euo pipefail # 없으면 초기화 실패가 무시된 채 셸이 뜬다
계속 커지는 이미지. 도구를 하나씩 추가하다 보면 수 GB가 되고, 팀원이 늘 때마다 pull 시간이 누적됩니다. 자주 바뀌지 않는 층을 아래로, 자주 바뀌는 층을 위로 배치해 캐시가 잘 맞게 하는 것이 기본입니다. 이득이 생기는 원리는 도커 멀티스테이징 (opens new window)에서 다룬 것과 같습니다.
# 4. 언제 다시 빌드해야 하는가
개발 이미지는 "언제 갱신할지"가 정해져 있지 않아 방치되기 쉽습니다. 세 가지 계기를 정해 두면 관리가 됩니다.
- 의도적 갱신 - 베이스 다이제스트를 올리는 커밋. 사람이 결정하고 기록이 남습니다.
- 정기 갱신 - 주기적으로 다이제스트 갱신 PR을 만들어 검토합니다.
- 긴급 갱신 - 취약점 대응.
세 경우 모두 커밋으로 표현되는 것이 중요합니다. 아무 변경 없이 재빌드해서 내용이 달라지는 상황을 없애는 것이 재현성의 실질입니다.
빌드가 실제로 재현되는지는 두 번 빌드해 비교하면 확인됩니다.
docker build --no-cache -t img:a . && docker build --no-cache -t img:b .
docker image inspect img:a img:b --format '{{.Id}}'
완전히 같은 ID가 나오는 경우는 드뭅니다. 타임스탬프와 패키지 메타데이터가 개입하기 때문입니다. 목표를 비트 단위 동일성이 아니라 "설치된 버전 집합의 동일성" 으로 잡는 편이 현실적입니다.
docker run --rm img:a dpkg -l | awk '{print $2, $3}' | sort > a.txt
docker run --rm img:b dpkg -l | awk '{print $2, $3}' | sort > b.txt
diff a.txt b.txt && echo "패키지 구성 동일"
# 5. 트러블슈팅
| 증상 | 원인 | 조치 |
|---|---|---|
| 어제 되던 빌드가 404로 실패 | 캐시된 낡은 인덱스가 사라진 버전을 가리킴 | update와 install을 한 레이어로 |
| 패키지를 추가했는데 나머지가 옛 버전 | 같은 원인, 실패하지 않을 뿐 | 위와 동일 |
| 같은 태그인데 내용이 다름 | 베이스 이미지 태그가 가변 | 다이제스트 고정 |
| 마운트한 파일이 루트 소유 | 컨테이너 사용자 UID 불일치 | 빌드 인자로 UID/GID 맞춤 |
| 초기화가 실패해도 셸이 정상으로 보임 | 진입 스크립트가 오류를 무시 | set -euo pipefail |
| 이미지가 계속 커짐 | 레이어 순서·불필요 패키지 | --no-install-recommends, 레이어 재배치 |
# 6. 마무리
apt-get update를 별도 레이어에 두면 인덱스와 설치가 다른 시점의 것이 됩니다. 실패하지 않고 낡은 버전이 설치되는 경우가 더 위험합니다.- 한 레이어로 묶으면 불일치는 사라지지만 재현성은 별개입니다. 전부 고정할 필요는 없고, 베이스 이미지 다이제스트 고정이 비용 대비 효과가 가장 큽니다.
- 개발 이미지는 UID 정합, 진입 스크립트의 오류 처리, 크기 관리라는 고유 문제가 있습니다.
- 재현성의 실질은 비트 단위 동일성이 아니라 "변경이 커밋으로만 일어나는 상태" 입니다.