# 개발용 컨테이너 이미지가 어제와 다르게 빌드되는 이유

같은 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 update with apt-get install in the same RUN statement.

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로 실패 캐시된 낡은 인덱스가 사라진 버전을 가리킴 updateinstall을 한 레이어로
패키지를 추가했는데 나머지가 옛 버전 같은 원인, 실패하지 않을 뿐 위와 동일
같은 태그인데 내용이 다름 베이스 이미지 태그가 가변 다이제스트 고정
마운트한 파일이 루트 소유 컨테이너 사용자 UID 불일치 빌드 인자로 UID/GID 맞춤
초기화가 실패해도 셸이 정상으로 보임 진입 스크립트가 오류를 무시 set -euo pipefail
이미지가 계속 커짐 레이어 순서·불필요 패키지 --no-install-recommends, 레이어 재배치

# 6. 마무리

  • apt-get update를 별도 레이어에 두면 인덱스와 설치가 다른 시점의 것이 됩니다. 실패하지 않고 낡은 버전이 설치되는 경우가 더 위험합니다.
  • 한 레이어로 묶으면 불일치는 사라지지만 재현성은 별개입니다. 전부 고정할 필요는 없고, 베이스 이미지 다이제스트 고정이 비용 대비 효과가 가장 큽니다.
  • 개발 이미지는 UID 정합, 진입 스크립트의 오류 처리, 크기 관리라는 고유 문제가 있습니다.
  • 재현성의 실질은 비트 단위 동일성이 아니라 "변경이 커밋으로만 일어나는 상태" 입니다.

# 참고