Dockerfile 최적화 실전 가이드 — 빌드·크기·보안 총정리
Dockerfile, 그냥 작성하면 안 되는 이유
Docker를 사용해 본 경험이 있다면, 아마 docker-compose up -d 한 줄로 서비스를 띄워본 기억이 있을 것입니다. Pi-hole, Grafana, Uptime Kuma처럼 잘 만들어진 공식 이미지를 가져다 쓸 때는 이미지 내부를 들여다볼 필요가 없습니다. 하지만 내가 직접 만든 웹 애플리케이션을 컨테이너로 패키징하거나, 공식 이미지를 커스터마이징해야 할 때, 또는 CI/CD 파이프라인에서 빌드 시간을 줄이고 싶을 때라면 이야기가 달라집니다. 이때부터는 Dockerfile 최적화를 직접 수행할 수 있는 능력이 필수입니다.
처음 Dockerfile을 작성하면 대부분 비슷한 순서를 따릅니다. FROM으로 베이스 이미지를 고르고, COPY로 소스 코드를 통째로 복사하고, RUN으로 의존성을 설치하고, CMD로 실행 명령을 지정합니다. 동작은 합니다. 하지만 이렇게 만든 이미지에는 세 가지 심각한 문제가 숨어 있습니다.
첫째, 이미지 크기가 비대합니다. Python 공식 이미지를 베이스로 쓰면 아무 라이브러리를 추가하지 않아도 이미 1GB를 넘깁니다. 여기에 의존성을 설치하면 1.5GB에 달하는 이미지가 만들어집니다. 레지스트리에 푸시하는 데 시간이 걸리고, 배포 서버에서 pull 받는 것도 느립니다. 둘째, 빌드 속도가 답답합니다. 소스 코드를 한 줄만 수정해도 의존성 전체를 처음부터 다시 설치하기 때문에 빌드에 5분 이상 걸리는 경우가 허다합니다. CI/CD 파이프라인에서 이 시간이 매 커밋마다 반복되면 개발 생산성이 눈에 띄게 떨어집니다. 셋째, 보안 취약점이 많습니다. 빌드 도구, 컴파일러, 개발 전용 라이브러리, 디버그 심볼이 프로덕션 이미지에 그대로 남아 있으면 공격 표면이 불필요하게 넓어집니다. 보안 스캐너를 돌리면 취약점이 수백 개 검출되는 것도 드문 일이 아닙니다.
좋은 소식은, 이 세 가지 문제를 모두 해결하는 검증된 기법들이 있다는 것입니다. 이 글에서는 Docker의 레이어 시스템과 캐시가 어떻게 동작하는지 원리부터 이해하고, 멀티스테이지 빌드로 이미지 크기를 수십 분의 일로 줄이는 방법, 보안 하드닝으로 프로덕션 수준의 견고한 이미지를 만드는 과정을 단계별로 안내합니다. Python, Node.js, Go 세 언어의 실전 예제를 포함하며, 최적화 전후의 차이를 수치로 직접 비교합니다.
Docker 이미지의 레이어 시스템과 캐시 전략
Dockerfile 최적화의 모든 기법은 하나의 핵심 원리에서 출발합니다. Docker 이미지가 여러 겹의 읽기 전용 레이어로 구성되어 있다는 사실입니다. 이 구조를 이해하면 왜 어떤 Dockerfile은 빌드가 수 초 만에 끝나고 어떤 것은 몇 분씩 걸리는지, 왜 같은 앱인데 이미지 크기가 10배 이상 차이 나는지가 명확해집니다.
레이어란 무엇인가
Dockerfile에서 FROM, RUN, COPY, ADD 명령어는 각각 하나의 새로운 레이어를 생성합니다. 각 레이어는 직전 레이어 대비 변경된 부분(diff)만 저장하는 읽기 전용 파일 시스템 스냅샷입니다. 최종 이미지는 이 레이어들을 아래에서 위로 쌓아 올린 것이고, 컨테이너를 실행하면 맨 위에 쓰기 가능한 컨테이너 레이어가 하나 추가됩니다.
예를 들어, FROM python:3.12-slim이 첫 번째 레이어를 결정하고, RUN pip install flask가 두 번째 레이어, COPY . /app이 세 번째 레이어가 됩니다. 이 세 레이어의 크기를 합하면 최종 이미지 크기가 됩니다. 따라서 각 레이어에 무엇이 들어가는지가 곧 이미지 크기를 결정합니다.
캐시 무효화 — 빌드 속도를 좌우하는 핵심 규칙
Docker는 이미지를 빌드할 때 각 레이어의 캐시를 확인합니다. 이전에 같은 명령어로 같은 입력을 처리한 적이 있으면 레이어를 새로 만들지 않고 기존 것을 재사용합니다. 이것이 Docker 빌드 캐시이며, 두 번째 이후의 빌드가 빨라지는 이유입니다.
여기서 반드시 기억해야 할 규칙이 있습니다. 어떤 레이어의 캐시가 무효화되면, 그 이후의 모든 레이어 캐시도 함께 무효화됩니다. 캐시가 체인처럼 연결되어 있기 때문입니다. 이 규칙 하나가 Dockerfile 작성 순서 최적화의 핵심입니다.
가장 흔한 실수를 살펴보겠습니다. COPY . /app을 먼저 실행하고 그 다음에 RUN pip install -r requirements.txt를 실행하는 순서입니다. 이 경우 프로젝트의 아무 파일이나 한 글자만 수정해도 COPY 레이어의 캐시가 깨지고, 그 뒤에 오는 pip install 레이어도 처음부터 다시 실행됩니다. 의존성이 100개든 200개든 전부 다시 다운로드하고 설치합니다.
해결책은 단순합니다. 의존성 파일만 먼저 복사하는 것입니다. 먼저 COPY requirements.txt .로 의존성 목록만 복사하고, RUN pip install -r requirements.txt로 설치를 완료합니다. 그 다음에 COPY . /app으로 나머지 소스 코드를 복사합니다. 이렇게 하면 requirements.txt 파일이 바뀌지 않는 한 pip install 레이어의 캐시가 계속 유지됩니다. 소스 코드만 수정했을 때는 마지막 COPY 레이어만 새로 만들어지므로 빌드가 몇 초 만에 완료됩니다. Node.js라면 package.json과 package-lock.json을 먼저 복사하고, Go라면 go.mod와 go.sum을 먼저 복사합니다. 원리는 동일합니다.

.dockerignore로 불필요한 파일 제외하기
COPY . /app 명령어는 현재 디렉토리의 모든 파일을 이미지 안으로 복사합니다. 여기에는 .git 디렉토리, node_modules, __pycache__, .env 환경 변수 파일, IDE 설정 폴더, 로그 파일 등 이미지에 전혀 필요 없는 파일이 대량으로 포함됩니다. 이 불필요한 파일들은 이미지 크기를 불리고, 빌드 컨텍스트 전송 시간을 늘리며, 파일이 바뀔 때마다 불필요한 캐시 무효화를 일으킵니다.
프로젝트 루트에 .dockerignore 파일을 만들어서 제외할 대상을 지정합니다. .gitignore와 문법이 같으므로 이미 익숙할 것입니다. 일반적으로 제외하는 항목은 다음과 같습니다.
- .git — 프로젝트 규모에 따라 수십~수백 MB에 달하므로 제외 효과가 큽니다.
- node_modules — 컨테이너 안에서 새로 설치해야 하므로 호스트의 것은 불필요합니다.
- __pycache__, *.pyc — Python 바이트코드 캐시는 이미지에서 의미가 없습니다.
- .env — 시크릿이 이미지에 포함되면 보안 사고로 이어집니다.
- Dockerfile, docker-compose.yml — 빌드 설정 파일 자체는 앱 실행에 불필요합니다.
- .vscode, .idea — IDE 설정 디렉토리입니다.
- *.log, *.md — 런타임에 필요하지 않은 문서와 로그 파일입니다.
.dockerignore가 없는 프로젝트에 이 파일 하나를 추가하는 것만으로 빌드 컨텍스트 크기가 절반 이하로 줄어드는 경우가 많습니다.
RUN 명령어 합치기의 올바른 기준
흔히 “RUN 명령어를 하나로 합치라”고 조언합니다. 그 이유는 두 가지입니다. 첫째, apt-get update와 apt-get install을 별도의 RUN으로 분리하면 update 레이어가 캐시에 남아서 오래된 패키지 목록으로 설치를 시도할 위험이 있습니다. 둘째, 레이어 수가 많아지면 이미지 크기가 미세하게 증가합니다.
하지만 무조건 합치는 것이 능사는 아닙니다. 올바른 기준은 이렇습니다. 거의 바뀌지 않는 시스템 패키지 설치는 하나의 RUN으로 합치되, 자주 바뀔 수 있는 애플리케이션 레벨 설치는 별도 RUN으로 분리해서 캐시 히트율을 높입니다. 예를 들어 apt-get update와 apt-get install은 반드시 같은 RUN에 넣지만, pip install과 앱 초기화 명령은 별도로 둡니다.
시스템 패키지를 설치할 때 한 가지 더 중요한 포인트가 있습니다. apt-get install 뒤에 반드시 같은 RUN 안에서 rm -rf /var/lib/apt/lists/*로 패키지 목록 캐시를 삭제합니다. 이 삭제를 별도의 RUN에 넣으면 효과가 없습니다. 앞 레이어에 이미 기록된 파일은 뒤 레이어에서 삭제해도 이전 레이어에 그대로 남아 있어서 이미지 크기가 줄어들지 않기 때문입니다. 이것은 Docker 레이어 시스템의 근본 특성입니다.
BuildKit 캐시 마운트로 빌드 더 빠르게
Docker BuildKit은 캐시 마운트라는 강력한 기능을 제공합니다. RUN 명령어에 –mount=type=cache 옵션을 붙이면 패키지 매니저의 다운로드 캐시를 빌드 간에 영구적으로 공유할 수 있습니다.
Python의 pip는 다운로드한 패키지를 /root/.cache/pip에 보관합니다. 캐시 마운트를 사용하면 requirements.txt가 바뀌어서 레이어 캐시가 무효화되더라도, 이미 다운로드한 패키지 파일은 그대로 재사용할 수 있습니다. 변경되거나 추가된 패키지만 새로 받으므로 네트워크 시간이 대폭 절약됩니다. Node.js의 npm(/root/.npm), apt-get(/var/cache/apt) 등도 같은 원리로 캐시 마운트를 적용할 수 있습니다.
BuildKit은 Docker Desktop 최신 버전에서 기본 활성화되어 있습니다. 만약 활성화되지 않은 환경이라면 빌드 명령 앞에 DOCKER_BUILDKIT=1 환경변수를 설정하거나, Docker 설정 파일에서 기본값을 변경하면 됩니다. BuildKit은 캐시 마운트 외에도 병렬 스테이지 빌드, 더 나은 빌드 로그, 시크릿 마운트 등 다양한 개선 기능을 제공하므로 활성화해 두는 것을 강력히 권장합니다.
멀티스테이지 빌드 — 이미지 크기를 극적으로 줄이는 핵심 기법
Dockerfile 최적화에서 가장 큰 효과를 내는 단일 기법을 꼽으라면 단연 멀티스테이지 빌드입니다. 하나의 Dockerfile 안에 여러 개의 FROM 문을 사용해서 빌드 단계와 실행 단계를 완전히 분리하는 방법입니다. 이 기법 하나로 이미지 크기를 80~98%까지 줄일 수 있습니다.
빌드 도구가 프로덕션에 남으면 생기는 일
애플리케이션을 빌드하려면 다양한 도구가 필요합니다. Python에서는 C 확장을 컴파일하기 위해 gcc와 make가 필요할 수 있고, Node.js에서는 devDependencies에 TypeScript 컴파일러, ESLint, Jest 등이 포함됩니다. Go에서는 전체 Go 툴체인이 필요합니다.
문제는 이 빌드 도구들이 프로덕션 실행에는 전혀 필요 없다는 것입니다. Flask 서버를 돌리는 데 gcc가 필요하지 않고, Express API를 서빙하는 데 TypeScript 컴파일러가 필요하지 않습니다. 그런데 단일 스테이지 Dockerfile에서는 이 모든 것이 최종 이미지에 고스란히 남습니다. 이미지 크기가 커지는 것은 물론, 각 도구가 잠재적 보안 취약점이 됩니다.
멀티스테이지 빌드는 이 문제를 우아하게 해결합니다. 하나의 Dockerfile에 FROM 문을 여러 번 작성합니다. 각 FROM은 새로운 스테이지를 시작하고, AS 키워드로 이름을 붙일 수 있습니다. 이후 스테이지에서 COPY –from=스테이지이름 명령으로 이전 스테이지의 파일을 선택적으로 가져옵니다. 최종 이미지에는 마지막 스테이지의 레이어만 포함되고, 이전 스테이지의 빌드 도구, 소스 코드, 중간 산출물은 모두 폐기됩니다.
Python 웹 애플리케이션 실전 예제
Python FastAPI 애플리케이션을 컨테이너화하는 시나리오로 비교해 보겠습니다.
최적화 전의 단일 스테이지 Dockerfile은 FROM python:3.12로 전체 Python 이미지(약 1.0GB)를 기반으로 시작합니다. WORKDIR을 설정하고, COPY . .으로 모든 소스를 복사한 뒤, RUN pip install -r requirements.txt로 의존성을 설치합니다. 결과 이미지는 앱 규모에 따라 1.2~1.5GB에 달합니다.
멀티스테이지로 전환합니다. 첫 번째 스테이지를 builder라고 이름 붙이고, python:3.12를 기반으로 가상환경을 만듭니다. python -m venv /opt/venv로 가상환경을 생성하고, PATH에 /opt/venv/bin을 추가한 상태에서 pip install을 실행합니다. 두 번째 스테이지는 python:3.12-slim(약 150MB)을 기반으로 합니다. COPY –from=builder /opt/venv /opt/venv로 가상환경 디렉토리 하나만 가져오고, 앱 소스 코드를 복사한 뒤 CMD를 지정합니다.
핵심은 가상환경(venv)을 활용하는 것입니다. 시스템 Python에 직접 pip install을 하면 패키지가 여러 시스템 디렉토리에 흩어져서 어떤 파일을 복사해야 하는지 파악하기 어렵습니다. 가상환경을 사용하면 /opt/venv 디렉토리 하나를 통째로 복사하는 것으로 모든 의존성이 따라옵니다. 결과적으로 이미지 크기가 1.2GB에서 약 200~300MB로 줄어듭니다. gcc, make 등의 빌드 도구와 pip 다운로드 캐시는 builder 스테이지에만 존재하고 최종 이미지에는 흔적도 남지 않습니다.

Node.js 프론트엔드·백엔드 실전 예제
Node.js 프로젝트(Next.js, Nuxt, Express 등)를 컨테이너화할 때도 패턴은 동일합니다. 다만 Node.js 생태계의 특성에 맞게 세 단계로 분리하는 것이 효과적입니다.
첫 번째 스테이지(deps)에서 package.json과 package-lock.json만 복사해서 npm ci –omit=dev로 프로덕션 의존성만 깔끔하게 설치합니다. 두 번째 스테이지(build)에서는 전체 의존성(devDependencies 포함)을 설치하고 npm run build를 실행합니다. 세 번째 스테이지(runtime)에서 node:20-alpine(약 180MB)을 기반으로 하고, deps 스테이지의 프로덕션 node_modules와 build 스테이지의 빌드 결과물(.next, dist 등)만 선택적으로 복사합니다.
이 세 단계 분리의 핵심은 devDependencies를 프로덕션에서 완전히 제거하는 것입니다. TypeScript 컴파일러, ESLint, Webpack, Jest 같은 개발 도구는 빌드에만 필요하고 런타임에는 쓰이지 않습니다. 결과 이미지는 단일 스테이지 대비 70~80% 더 작은 200~400MB 수준이 됩니다.
Go — scratch까지 가능한 궁극의 경량화
Go는 멀티스테이지 빌드의 진가를 가장 극적으로 보여주는 언어입니다. Go 컴파일러는 외부 의존성이 없는 단일 정적 바이너리를 만들 수 있기 때문입니다.
builder 스테이지에서 golang:1.22 이미지(약 850MB)를 기반으로 go build를 실행합니다. 이때 CGO_ENABLED=0 환경변수로 CGo를 비활성화하고, -ldflags=”-s -w” 플래그로 디버그 심볼을 제거합니다. 런타임 스테이지에서는 scratch를 사용합니다. scratch는 Docker가 제공하는 완전히 비어 있는 이미지입니다. 파일 시스템에 아무것도 없고, 셸도, libc도, 인증서도 포함되어 있지 않습니다.
builder에서 만든 바이너리 하나만 COPY –from=builder로 가져오면 끝입니다. 최종 이미지 크기는 바이너리 크기와 사실상 동일합니다. 보통 10~30MB 수준입니다. 850MB짜리 빌드 환경에서 시작해서 20MB 이미지를 만드는 것이니, 97% 이상의 크기 감소입니다.
다만 scratch를 사용하면 컨테이너 안에서 셸을 열 수 없으므로 디버깅이 까다롭습니다. 운영 중 트러블슈팅이 걱정된다면 gcr.io/distroless/static 이미지를 대안으로 고려하세요. distroless는 셸은 없지만 CA 인증서, 시간대 데이터 같은 최소한의 런타임 파일을 포함해서 HTTPS 통신이나 시간 처리가 필요한 서비스에 적합합니다.
최적화 전후 이미지 크기 한눈에 비교
세 언어의 최적화 전후 이미지 크기를 비교하면 멀티스테이지 빌드의 위력이 분명해집니다.
- Python — 최적화 전 python:3.12 기반 약 1.2GB → 멀티스테이지 + python:3.12-slim 약 250MB. 약 79% 감소.
- Node.js — 최적화 전 node:20 기반 약 1.5GB → 멀티스테이지 + node:20-alpine 약 300MB. 약 80% 감소.
- Go — 최적화 전 golang:1.22 기반 약 900MB → 멀티스테이지 + scratch 약 15MB. 약 98% 감소.
물론 실제 수치는 애플리케이션의 의존성 규모에 따라 달라지지만, 멀티스테이지 빌드만으로 이미지 크기를 최소 절반 이하로 줄일 수 있다는 점은 변하지 않습니다. 빌드 도구를 제거하는 것이기 때문에 앱의 기능에는 일절 영향이 없습니다.
베이스 이미지 선택 — 모든 최적화의 출발점
Dockerfile 첫 줄의 FROM이 이미지의 크기, 보안 수준, 호환성의 기본 바닥을 결정합니다. 같은 애플리케이션이라도 어떤 베이스 이미지를 선택하느냐에 따라 최종 크기가 수백 MB 달라집니다. 주요 베이스 이미지를 크기 순으로 비교해 보겠습니다.
주요 베이스 이미지 크기와 특징
ubuntu:24.04는 약 78MB입니다. 가장 친숙하고 패키지 생태계(apt)가 풍부하지만, 프로덕션 컨테이너에는 불필요한 도구가 포함되어 있습니다. 개발·디버깅 용도에는 편리하지만 프로덕션 베이스로는 권장하지 않습니다.
debian:bookworm-slim은 약 75MB입니다. Debian의 최소 설치 버전으로, 불필요한 패키지와 문서를 제거한 것입니다. Python이나 Node.js 공식 이미지에서 -slim 태그가 붙은 것이 이 이미지를 기반으로 합니다. glibc 호환성을 유지하면서도 크기를 줄인 균형 잡힌 선택지입니다.
alpine:3.20은 약 7MB입니다. musl libc와 BusyBox를 기반으로 한 초경량 리눅스 배포판입니다. 이미지 크기를 극적으로 줄일 수 있지만 주의점이 있습니다. musl은 glibc와 바이너리 호환이 되지 않으므로, glibc 전용으로 빌드된 네이티브 라이브러리가 동작하지 않을 수 있습니다. Python에서 numpy, pandas, cryptography 같은 C 확장 라이브러리를 사용한다면 musl 환경에서 빌드 실패나 성능 저하가 발생할 수 있습니다.
gcr.io/distroless/base-debian12는 약 20MB입니다. Google이 관리하는 이미지로, 셸과 패키지 매니저가 포함되어 있지 않습니다. 오직 애플리케이션 실행에 필요한 최소한의 런타임 라이브러리만 담고 있습니다. 컨테이너에 들어가서 디버깅하는 것이 불가능하지만, 그만큼 공격 표면이 극도로 작습니다.
scratch는 0바이트입니다. 완전히 비어 있는 이미지입니다. 정적 링크된 단일 바이너리만 실행할 수 있습니다. Go, Rust처럼 정적 컴파일이 가능한 언어에서만 사용할 수 있으며, 궁극의 최소 이미지를 만들 때 쓰입니다.
언어·상황별 베이스 이미지 선택 기준
만능 정답은 없지만, 실전에서 통용되는 기준이 있습니다.
- Python 웹 앱이라면 python:3.12-slim을 권장합니다. alpine도 크기 면에서 매력적이지만, C 확장 라이브러리 호환 문제가 빈번합니다. slim은 glibc를 사용하므로 이런 문제 없이 전체 이미지보다 훨씬 가볍습니다.
- Node.js 앱에는 node:20-alpine이 좋은 선택입니다. Node.js 생태계는 대부분 순수 JavaScript라 musl 호환 문제가 적습니다. sharp 같은 네이티브 모듈을 쓰면 추가 설정이 필요할 수 있지만 대부분 잘 동작합니다.
- Go, Rust처럼 정적 바이너리를 만들 수 있는 언어라면 scratch 또는 distroless를 적극 고려합니다.
- 범용적으로 안전한 선택을 원한다면 debian-slim 계열에서 시작하세요. 충분히 작으면서도 호환성 문제가 거의 없습니다.
한 가지 더 중요한 점은 이미지 태그 관리입니다. FROM python:3.12를 쓰면 최신 패치 버전을 자동으로 가져옵니다. 편리하지만, 어제와 오늘 빌드한 이미지의 시스템 패키지 구성이 달라질 수 있습니다. 프로덕션에서 재현 가능한 빌드가 중요하다면 python:3.12.4-slim-bookworm처럼 패치 버전과 OS 릴리스까지 명시하는 것이 안전합니다. 반대로 latest 태그는 절대 프로덕션 Dockerfile에 사용하지 마세요. 어떤 버전이 내려오는지 예측할 수 없고, 빌드 재현이 불가능합니다.
보안 하드닝 — 프로덕션 이미지를 견고하게 만드는 원칙
이미지 크기를 줄이는 것 자체가 보안 개선이기도 합니다. 이미지에 포함된 파일과 도구가 적을수록 공격 표면이 줄어들기 때문입니다. 하지만 크기 최적화만으로는 부족합니다. 프로덕션 수준의 보안을 갖추려면 몇 가지 추가 조치가 반드시 필요합니다.
root가 아닌 사용자로 실행하기
Dockerfile에서 별도의 USER 명령어를 지정하지 않으면 컨테이너 안의 프로세스는 root 권한으로 실행됩니다. 컨테이너 격리가 뚫리는 취약점이 발생했을 때, root로 실행 중이면 호스트 시스템까지 위험해질 수 있습니다.
해결 방법은 간단합니다. Dockerfile에서 RUN addgroup –system app && adduser –system –ingroup app app으로 시스템 사용자와 그룹을 만들고, CMD 앞에 USER app 명령어를 추가합니다. 이후에 실행되는 모든 RUN, CMD, ENTRYPOINT는 해당 사용자의 제한된 권한으로 실행됩니다.
주의할 점은 USER 명령어 이후에는 파일 쓰기 권한이 제한된다는 것입니다. 로그 디렉토리 생성, 임시 파일 경로 설정 등 쓰기 권한이 필요한 작업은 USER 명령어 이전에 처리하세요. 또는 해당 디렉토리의 소유권을 미리 변경해 두는 방법도 있습니다.
시크릿을 이미지 레이어에 남기지 않기
Dockerfile 보안에서 가장 위험한 실수 중 하나는 API 키, 데이터베이스 비밀번호 같은 시크릿을 ENV나 ARG로 전달하는 것입니다. ENV로 설정한 값은 이미지의 메타데이터에 영구적으로 기록됩니다. ARG로 전달한 값도 빌드 히스토리에 남습니다. 이미지를 레지스트리에 푸시하면 docker inspect 한 줄로 누구든 시크릿을 열람할 수 있습니다.
빌드 시점에 시크릿이 필요한 경우, 예를 들어 비공개 패키지 레지스트리에서 라이브러리를 다운로드해야 할 때는 BuildKit의 시크릿 마운트를 사용합니다. –mount=type=secret 옵션으로 시크릿 파일을 RUN 명령어 실행 중에만 일시적으로 마운트하면, 해당 파일은 어떤 레이어에도 기록되지 않습니다. 빌드가 끝나면 흔적이 완전히 사라집니다.
런타임에 필요한 시크릿은 Docker Compose의 secrets 기능이나 환경변수를 통해 컨테이너 실행 시점에 주입합니다. Dockerfile에 시크릿을 하드코딩하는 것은 어떤 경우에도 절대 하면 안 됩니다.
불필요한 패키지를 배제하고 HEALTHCHECK를 추가하기
apt-get install을 할 때 –no-install-recommends 옵션을 반드시 추가합니다. 이 옵션 없이 설치하면 추천 패키지가 자동으로 딸려오는데, 이들은 편의를 위한 것이지 필수가 아닙니다. 이 옵션 하나로 수십 MB를 절약하고 불필요한 취약점 노출을 줄일 수 있습니다.
HEALTHCHECK 명령어도 프로덕션 이미지에 반드시 추가해야 합니다. Docker 데몬이 주기적으로 컨테이너의 건강 상태를 확인할 수 있게 합니다. HEALTHCHECK –interval=30s –timeout=5s –retries=3 형태로 설정하고, 웹 서비스라면 /healthz 엔드포인트를 호출하는 커맨드를 지정합니다. Docker Compose의 depends_on 조건이나 오케스트레이션 도구의 롤링 업데이트에서 건강한 컨테이너와 비정상 컨테이너를 구분하는 데 핵심 역할을 합니다. 개발 환경에서는 없어도 동작에 문제가 없지만, 프로덕션에서는 필수라고 보면 됩니다.
읽기 전용 파일 시스템과 정기 스캐닝
docker run에 –read-only 옵션을 추가하거나 docker-compose.yml에서 read_only: true를 설정하면 컨테이너 파일 시스템을 읽기 전용으로 마운트할 수 있습니다. 악성 코드가 파일을 쓰거나 수정하는 것을 원천 차단하므로, 침해 시 피해 범위를 크게 제한합니다. 임시 파일이 필요한 디렉토리만 tmpfs로 별도 마운트하면 대부분의 애플리케이션이 문제 없이 동작합니다.
또한 이미지를 빌드한 뒤 보안 스캐너로 정기적으로 취약점을 검사하는 습관이 필요합니다. 아무리 최적화한 이미지라도 시간이 지나면 베이스 이미지에 새로운 CVE가 발견됩니다. docker scout는 Docker Desktop에 내장된 스캐닝 도구로, 한 줄 명령으로 이미지의 취약점 현황을 확인할 수 있습니다. 오픈소스 대안으로는 trivy가 있으며, OS 패키지뿐 아니라 Python, Node.js, Go 등 애플리케이션 의존성의 취약점까지 검사합니다. CI/CD 파이프라인에 통합해서 매 빌드마다 자동으로 스캐닝하면 새로운 취약점에 빠르게 대응할 수 있습니다.
Dockerfile 품질을 높여주는 실전 도구와 안티패턴
수동 최적화도 중요하지만, 자동화된 검사 도구를 활용하면 사람이 놓치기 쉬운 문제를 체계적으로 잡을 수 있습니다. Dockerfile 작성에 도움이 되는 핵심 도구 두 가지와, 실무에서 자주 발견되는 안티패턴을 정리합니다.
Hadolint — Dockerfile 전용 린터
Hadolint는 Dockerfile의 모범 사례를 자동으로 검사하는 린터입니다. apt-get update와 install이 같은 RUN에 있는지, COPY 대신 ADD를 잘못 쓰고 있지 않은지, 불필요한 sudo 사용은 없는지 등 수십 가지 규칙을 확인합니다. Docker를 통해 실행할 수 있으므로 별도 설치가 필요 없습니다. docker run –rm -i hadolint/hadolint에 Dockerfile을 파이프하면 즉시 검사 결과를 받을 수 있고, 각 경고에는 규칙 번호와 구체적인 수정 방법이 함께 표시됩니다. VS Code 확장도 제공되어 편집 중에 실시간 피드백을 받을 수 있고, GitHub Actions에 hadolint-action을 추가하면 모든 PR에서 자동으로 Dockerfile 품질을 검증합니다.
dive — 이미지 레이어 시각 분석
dive는 Docker 이미지의 각 레이어를 터미널에서 시각적으로 분석하는 도구입니다. 왼쪽에 레이어 목록, 오른쪽에 파일 시스템 트리가 나타나며, 각 레이어에서 어떤 파일이 추가·수정·삭제되었는지, 각 레이어의 크기가 얼마인지를 직관적으로 파악할 수 있습니다. 특히 유용한 것은 이미지 효율성 점수입니다. 중복 파일이나 이전 레이어에서 삭제되었지만 여전히 공간을 차지하는 파일을 탐지해서 추가 최적화 여지를 알려줍니다. 새 이미지를 빌드한 뒤 한 번씩 돌려보면 예상치 못한 공간 낭비를 발견할 수 있습니다.
반드시 피해야 할 Dockerfile 안티패턴 5가지
실무에서 Dockerfile을 리뷰할 때 반복적으로 발견되는 안티패턴 다섯 가지를 정리합니다.
- apt-get update를 별도 RUN에 두기 — update만 있는 레이어가 캐시되면 오래된 패키지 목록으로 install을 시도합니다. 반드시 같은 RUN에서 update와 install을 함께 실행하고, 마지막에 패키지 캐시를 삭제합니다.
- COPY . .을 의존성 설치보다 앞에 두기 — 소스 코드의 아무 변경이든 의존성 설치 캐시를 무효화합니다. 의존성 파일(requirements.txt, package.json)을 먼저 복사하세요.
- latest 태그 사용 — 어떤 버전이 빌드에 쓰이는지 예측할 수 없고, 빌드를 재현할 방법이 없습니다. 반드시 특정 버전을 명시합니다.
- 모든 것을 하나의 거대한 RUN에 몰아넣기 — 합치라는 조언을 과잉 적용한 경우입니다. 성격이 다른 작업(시스템 패키지 설치, 앱 의존성 설치, 설정)은 캐시 효율을 위해 적절히 분리합니다.
- ENV나 ARG로 시크릿 전달 — 이미지 메타데이터와 빌드 히스토리에 평문으로 기록됩니다. BuildKit 시크릿 마운트를 사용하거나, 런타임에 환경변수로 주입합니다.

Dockerfile 최적화 최종 점검 체크리스트
Dockerfile을 작성하거나 팀에서 리뷰할 때, 아래 열 가지 항목을 확인하면 대부분의 문제를 사전에 방지할 수 있습니다.
- 의존성 파일(requirements.txt, package.json 등)을 소스 코드보다 먼저 COPY했는가?
- .dockerignore로 .git, node_modules, .env 등을 제외했는가?
- 멀티스테이지 빌드로 빌드 도구를 최종 이미지에서 제거했는가?
- 가능한 한 작은 베이스 이미지(-slim, alpine, distroless)를 선택했는가?
- 이미지 태그에 특정 버전을 명시했는가? (latest 사용 금지)
- RUN에서 설치한 패키지 캐시를 같은 RUN 안에서 삭제했는가?
- 비root 사용자(USER)를 설정했는가?
- ENV나 ARG로 시크릿을 전달하지 않았는가?
- HEALTHCHECK를 추가했는가?
- 빌드 후 Hadolint와 trivy로 품질·취약점 검사를 했는가?
이 열 가지를 하나씩 점검하는 데 5분이면 충분합니다. 한 번 체크리스트로 정착시키면 팀 전체의 Dockerfile 품질이 일관되게 유지됩니다.
마무리 — 작은 습관 두 가지로 시작하기
Dockerfile 최적화를 한 번에 완벽하게 적용할 필요는 없습니다. 우선 가장 효과가 큰 두 가지부터 시작하세요. 첫째, COPY 순서를 바꿔서 의존성 파일을 소스 코드보다 먼저 복사합니다. 이것만으로 대부분의 빌드에서 의존성 설치 캐시가 유지되어 빌드 시간이 체감될 정도로 빨라집니다. 둘째, 멀티스테이지 빌드를 도입해서 빌드 도구를 최종 이미지에서 완전히 분리합니다. 이 두 가지만으로도 이미지 크기가 절반 이하로 줄고, 보안 취약점도 상당수 사라집니다.
그 다음 단계로 베이스 이미지를 -slim이나 alpine으로 전환하고, 비root 사용자를 설정하고, HEALTHCHECK를 추가합니다. 마지막으로 Hadolint와 trivy를 CI/CD 파이프라인에 통합하면 팀 전체가 작성하는 모든 Dockerfile의 품질이 자동으로 관리됩니다.
컨테이너는 현대 소프트웨어 배포의 사실상 표준이 되었고, 잘 만든 이미지는 배포 속도, 인프라 비용, 보안 수준 모두에 직접적인 영향을 미칩니다. 이 글에서 다룬 기법들은 언어나 프레임워크에 관계없이 모든 프로젝트에 공통으로 적용됩니다. 다음에 Dockerfile을 작성할 때 이 체크리스트를 한 번 펼쳐 보세요. 한 번 익힌 기본기는 이후 모든 프로젝트에서 반복 투자 없이 계속 효과를 발휘합니다.
Photo by Tanhauser Vázquez R. on Pexels
- [온프레미스 AI Assistant 아키텍처 — Qwen3·Qwen3-VL 14일 설계] 11/14화: LLM 메모리 아키텍처 실전 — 단기·장기·세션 기억 설계
- [opencode 12일 집중 — 터미널 네이티브 AI 코딩 에이전트 완전 정복] 7/12화: AGENTS.md 완전 가이드 — opencode 프로젝트 메모리 설계법
- [온프레미스 AI Assistant 아키텍처 — Qwen3·Qwen3-VL 14일 설계] 10/14화: Graph RAG·멀티모달 RAG 실전 — 고급 검색 아키텍처
- [온프레미스 AI Assistant 아키텍처 — Qwen3·Qwen3-VL 14일 설계] 9/14화: 온프레미스 RAG 파이프라인 — bge-m3·Qdrant 자체 호스팅 실전
참고 자료
- Dockerfile best practices — Docker 공식 문서 — Docker가 권장하는 Dockerfile 작성 모범 사례와 빌드 최적화 가이드
- Docker (software) — Wikipedia — Docker의 아키텍처, 이미지 레이어 구조, 컨테이너 기술 전반에 대한 백과사전적 개요