Dockerfile 빌드 모범 사례
Dockerfile 빌드 모범 사례 — 작고 안전한 이미지를 만드는 기준
Dockerfile을 처음 쓸 때는 '잘 돌아가기만 하면 되지' 싶을 때가 많죠. 그런데 이미지를 오래 운영하다 보면 크기, 빌드 속도, 보안까지 신경 써야 할 때가 와요. 도커가 공식적으로 권장하는 빌드 모범 사례를 따라서 쓰면, 그런 고민을 처음부터 덜 수 있어요.
본문
멀티스테이지 빌드를 쓰세요
멀티스테이지 빌드는 최종 이미지의 크기를 확 줄여줘요. 이미지를 만들 때와 최종 산출물을 깔끔하게 분리해서, 결과 이미지에 애플리케이션 실행에 필요한 파일만 담기게 만들죠. 여러 스테이지로 나누면 빌드 단계를 병렬로 실행해 더 효율적으로 빌드할 수도 있어요.
공통 부분이 많은 이미지가 여럿 있다면, 공유 컴포넌트를 담은 재사용 스테이지를 만들고 각 이미지가 그 스테이지를 기반으로 하게 하는 것도 좋아요. 도커는 공통 스테이지를 한 번만 빌드하면 되니까 호스트 메모리를 더 효율적으로 쓰고 이미지도 더 빨리 로드돼요. 같은 일을 하는 스테이지가 여러 개 있는 것보다 공통 베이스 스테이지를 하나 유지하는 게 관리하기도 쉬워요.
올바른 베이스 이미지를 고르세요
보안 좋은 이미지의 첫걸음은 베이스 이미지 선택이에요. 신뢰할 수 있는 출처에서 만들었고, 되도록 작은 이미지를 고르세요. 도커는 세 종류의 신뢰할 수 있는 이미지 출처를 마련해 두고 있어요.
- Docker Official Images — 문서가 명확하고 모범 사례를 권장하며 주기적으로 갱신되는 큐레이션 모음이라 안전한 출발점이 돼요.
- Verified Publisher — 도커와 파트너십을 맺은 조직이 발행·관리하는 고품질 이미지로, 도커가 저장소 콘텐츠의 진위를 검증해요.
- Docker-Sponsored Open Source — 도커의 오픈소스 프로그램을 통해 지원받는 오픈소스 프로젝트가 발행·관리하는 이미지예요.
베이스 이미지를 고를 때는 이런 프로그램에 속한 배지가 있는지 확인해보세요. 그리고 직접 빌드할 때는 요구사항에 맞는 최소한의 이미지를 고르는 게 좋아요. 작은 베이스 이미지는 이식성과 빠른 다운로드를 줄 뿐 아니라, 의존성으로 인해 생기는 취약점 수도 줄여줘요.
빌드·단위 테스트용과 프로덕션용(보통 더 슬림) 이미지 두 종류를 쓰는 것도 고려해보세요. 개발 후반부에 가면 컴파일러나 빌드 시스템, 디버깅 도구 같은 건 사실 필요 없어요. 의존성이 적은 작은 이미지가 공격 표면을 상당히 낮춰주죠.
이미지를 자주 다시 빌드하세요
도커 이미지는 불변(immutable)이에요. 이미지를 빌드한다는 건 그 순간의 스냅샷을 찍는 일이에요. 베이스 이미지나 라이브러리, 빌드에 쓰는 다른 소프트웨어까지 전부 그 시점 기준으로 담기죠. 그래서 이미지를 최신이고 안전하게 유지하려면, 갱신된 의존성과 함께 주기적으로 다시 빌드해야 해요.
--pull 플래그로 신선한 베이스 이미지를 받아올 수 있어요. 아래 Dockerfile은 ubuntu 이미지의 24.04 태그를 써요. 이 태그는 퍼블리셔가 보안 패치·갱신된 라이브러리로 이미지를 재빌드하면 그 밑의 실제 버전이 달라질 수 있어요.
# syntax=docker/dockerfile:1
FROM ubuntu:24.04
RUN apt-get -y update && apt-get install -y --no-install-recommends python3
최신 베이스 이미지를 받으려면 --pull 플래그를 쓰세요. 이 플래그는 로컬에 캐시된 버전이 있어도 베이스 이미지의 새 버전을 확인하고 받도록 강제해요.
$ docker build --pull -t my-image:my-tag .
--no-cache 플래그는 빌드 캐시를 끄고 모든 레이어를 처음부터 다시 빌드하게 해요.
$ docker build --no-cache -t my-image:my-tag .
이렇게 하면 apt-get이나 npm 같은 패키지 매니저에서 최신 의존성 버전을 받아와요. 다만 베이스 이미지를 새로 가져오지는 않아요 — 그건 --pull의 역할이에요. 두 플래그는 목적이 다르니, 신선한 베이스 이미지와 모든 빌드 단계 재실행을 함께 원한다면 둘을 같이 쓰면 돼요.
$ docker build --pull --no-cache -t my-image:my-tag .
.dockerignore로 불필요한 파일 배제하기
소스 저장소 구조를 바꾸지 않고 빌드에 관련 없는 파일을 빼고 싶다면 .dockerignore 파일을 써요. .gitignore와 비슷한 배제 패턴을 지원해요. 예를 들어 확장자가 .md인 파일을 모두 제외하려면 이렇게 적어요.
*.md
임시 컨테이너 만들기
Dockerfile이 정의하는 이미지는 가능한 한 임시적(ephemeral)인 컨테이너를 만들어야 해요. 임시적이라는 건, 컨테이너는 중지·파괴되고 최소한의 설정·구성으로 다시 빌드·교체될 수 있다는 뜻이에요. 각 컨테이너가 하나의 관심사만 갖도록 애플리케이션을 분리하면 수평 확장과 컨테이너 재사용이 쉬워져요. 한 컨테이너에 하나의 프로세스만 두는 건 좋은 기준이지만, 절대 규칙은 아니에요. 컨테이너를 최대한 깔끔하고 모듈화해서 유지하는 게 목표예요.
불필요한 패키지는 설치하지 마세요
'있으면 좋겠다'는 이유로 추가 패키지를 설치하는 건 피하세요. 예를 들어 데이터베이스 이미지에 텍스트 에디터를 넣을 필요는 없어요. 필요한 패키지만 설치하면 이미지의 복잡성과 의존성, 파일 크기, 빌드 시간이 모두 줄어요.
멀티라인 인자를 정렬하세요
가능하면 멀티라인 인자를 알파벳순으로 정렬해서 유지보수를 쉽게 해요. 패키지 중복을 피하고 목록 갱신을 쉽게 만들며, PR 리뷰도 훨씬 수월해져요. 백슬래시(\) 앞에 공백을 넣는 것도 도움이 돼요. buildpack-deps 이미지의 예를 볼게요.
RUN apt-get update && apt-get install -y --no-install-recommends \
bzr \
cvs \
git \
mercurial \
subversion \
&& rm -rf /var/lib/apt/lists/*
빌드 캐시 활용하기
이미지를 빌드할 때 도커는 Dockerfile의 지시어를 순서대로 실행하면서, 각 지시어마다 빌드 캐시에서 재사용 가능한지 확인해요. 빌드 캐시가 어떻게 동작하고 언제 무효화되는지 이해하는 게 빌드 속도를 높이는 핵심이에요.
베이스 이미지 버전 고정하기
이미지 태그는 mutable(변할 수 있음)이에요. 퍼블리셔가 태그를 새 이미지로 가리키게 갱신할 수 있죠. 퍼블리셔가 새 버전 이미지로 태그를 갱신하면, 소비자는 이미지를 다시 빌드할 때 자동으로 새 버전을 받는 이점이 있어요.
예를 들어 Dockerfile에 FROM alpine:3.21이라고 쓰면, 3.21은 3.21의 최신 패치 버전으로 해석돼요.
# syntax=docker/dockerfile:1
FROM alpine:3.21
어느 시점에는 3.21 태그가 3.21.1 버전을 가리킬 수 있어요. 3개월 뒤 다시 빌드하면 같은 태그가 3.21.4 같은 다른 버전을 가리킬 수도 있죠. 이 퍼블리싱 워크플로는 대부분의 퍼블리셔가 쓰는 모범 사례지만 강제되지는 않아요.
태그 방식의 단점은 매 빌드가 같은 결과를 보장하지 않는다는 점이에요. 호환을 깨는 변경이 생길 수도 있고, 정확히 어떤 이미지 버전을 쓰는지에 대한 감사 기록도 없어요. 공급망 무결성을 완전히 보장하려면 이미지 버전을 특정 digest로 고정할 수 있어요. 아래 Dockerfile은 앞의 3.21 태그에 digest 참조를 함께 붙인 예시예요.
# syntax=docker/dockerfile:1
FROM alpine:3.21@sha256:a8560b36e8b8210634f77d9f7f9efd7ffa463e380b75e2e74aff4511df3ef88c
이렇게 하면 퍼블리셔가 3.21 태그를 갱신해도, 빌드는 항상 a8560b36e8b8210634f77d9f7f9efd7ffa463e380b75e2e74aff4511df3ef88c digest에 고정된 이미지 버전을 사용해요. 다만 예상치 못한 변화를 막아주는 대신, 베이스 이미지 digest를 매번 직접 찾아 쓰는 건 번거롭고 자동 보안 수정을 포기하게 되는 면도 있어요.
베이스 이미지를 자동으로 최신으로 유지하려면 GitHub Dependabot을 package-ecosystem: "docker"로 설정해서 쓰면 돼요. Dependabot이 일정에 맞춰 베이스 이미지 태그·digest를 갱신하는 풀 리퀘스트를 만들어줘요. 이 방식은 버전이 자동으로 바뀌는 태그를 쓰는 것보다 좋아요. 언제 어떻게 바뀌었는지 통제하고 감사 기록도 남으니까요.
CI에서 이미지 빌드·테스트하기
소스 컨트롤에 변경을 커밋하거나 풀 리퀘스트를 만들 때, GitHub Actions 같은 CI/CD 파이프라인에서 도커 이미지를 자동으로 빌드·태그·테스트하는 게 좋아요.
CMD와 ENTRYPOINT
CMD와 ENTRYPOINT는 둘 다 컨테이너 실행 명령을 정의한다는 점에서 헷갈리기 쉬워요. 정리하면, CMD는 컨테이너 실행 시 기본으로 실행될 명령이나 인자를 정의해요. ENTRYPOINT는 컨테이너의 메인 프로세스로 고정해서 실행되는 명령이에요. 사용자가 docker run 뒤에 인자를 주면, 그 인자가 CMD를 대체하거나 ENTRYPOINT의 인자로 붙는 방식이에요. 두 지시어의 구체적인 동작과 예시는 Dockerfile 레퍼런스의 해당 절을 확인해볼게요.
VOLUME
데이터베이스 저장 영역, 설정 저장소, 도커 컨테이너가 만드는 파일·폴더 같은 것은 VOLUME 지시어로 노출하는 걸 강력히 권장해요. 이미지의 변경 가능하거나 사용자가 서비스하는 부분에는 VOLUME을 쓰는 게 좋아요.
USER
서비스가 권한 없이도 실행될 수 있다면, USER로 비-root 사용자로 전환하세요. 먼저 Dockerfile에서 사용자와 그룹을 만들고 그 사용자로 바꿔요.
RUN groupadd -r postgres && useradd --no-log-init -r -g postgres postgres
여기서 한 가지 유의할 점이 있어요. 이미지의 사용자·그룹에는 비결정적인 UID/GID가 부여돼요. 이미지를 재빌드해도 '다음' UID/GID가 할당되는 방식이라, 정확한 값이 중요하다면 명시적 UID/GID를 지정해야 해요. 또 Go archive/tar 패키지의 희소 파일 처리가 안 풀린 버그 때문에, 아주 큰 UID의 사용자를 컨테이너 안에 만들면 /var/log/faillog가 NULL(\0) 문자로 채워져 디스크를 소진할 수 있어요. 이때는 useradd에 --no-log-init 플래그를 넘기면 우회할 수 있어요. Debian/Ubuntu의 adduser 래퍼는 이 플래그를 지원하지 않아요.
sudo 설치나 사용은 예측 불가능한 TTY·시그널 전달 동작을 일으킬 수 있어 피하는 게 좋아요. 루트로 데몬을 초기화하면서 비-root로 실행하는 것처럼 sudo와 비슷한 기능이 정말 필요하다면 gosu를 고려해보세요. 마지막으로, 레이어와 복잡성을 줄이려면 USER를 자주 왔다 갔다 전환하는 건 피하세요.
WORKDIR
명확성과 신뢰성을 위해 WORKDIR은 항상 절대 경로를 쓰세요. 그리고 RUN cd … && do-something 같은 지시어를 쌓는 대신 WORKDIR을 쓰는 게 좋아요. 읽고, 트러블슈팅하고, 관리하기가 훨씬 쉬워요.
ONBUILD
ONBUILD 명령은 현재 Dockerfile 빌드가 끝난 뒤 실행돼요. 이 이미지를 FROM으로 파생한 자식 이미지에서 발동하죠. ONBUILD를 부모 Dockerfile이 자식 Dockerfile에 주는 지시어라고 생각하면 이해가 쉬워요. 자식 Dockerfile의 어떤 명령보다 먼저 실행돼요.
ONBUILD는 특정 이미지를 FROM으로 해서 빌드할 이미지에 유용해요. 예를 들어 그 언어로 작성된 임의의 사용자 소프트웨어를 Dockerfile 안에서 빌드하는 언어 스택 이미지에 쓰죠. ONBUILD로 빌드되는 이미지는 ruby:1.9-onbuild처럼 별도 태그를 주는 게 좋아요.
ONBUILD 안에 ADD나 COPY를 넣을 때는 조심해야 해요. 새 빌드의 컨텍스트에 추가하려는 리소스가 없으면 이미지가 치명적으로 실패하거든요. 별도 태그를 붙이는 것(위 권장)이 Dockerfile 작성자가 선택하게 하므로 실패를 완화하는 데 도움이 돼요.