로컬에서 잘 도는 이미지를 그대로 배포하면 안 되는 이유
로컬에서 잘 돌아가는 Dockerfile을 그대로 프로덕션에 올렸다가 세 가지 문제를 한꺼번에 만난 적이 있습니다. 이미지가 1.2GB라 배포마다 몇 분씩 걸렸고, 컨테이너 안 프로세스가 root 권한으로 돌고 있었고, 빌드 도구가 이미지 안에 그대로 남아 있었습니다. 셋 다 같은 원인에서 나옵니다 — 개발 편의를 위한 Dockerfile을 그대로 배포용으로 썼다는 것.
멀티스테이지 빌드 — 빌드 도구와 실행 환경을 분리합니다
TypeScript나 Java처럼 컴파일이 필요한 언어를 생각해보면, 빌드하는 데 필요한 것(컴파일러, 타입 체커, devDependencies)과 실행하는 데 필요한 것(런타임, 컴파일된 결과물)이 다릅니다. 그런데 하나의 Dockerfile에 전부 넣으면 실행 시점에는 필요 없는 빌드 도구까지 이미지에 그대로 남습니다.
# 1단계: 빌드 전용
FROM node:20 AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# 2단계: 실행 전용
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
CMD ["node", "dist/server.js"]AS builder 로 이름 붙인 첫 번째 스테이지에서 전체 빌드 과정을 거칩니다. 두 번째 스테이지는 완전히 새로운 베이스에서 시작하고, COPY --from=builder 로 빌드 결과물만 가져옵니다. 컴파일러, 소스맵, devDependencies, 테스트 코드는 전부 첫 번째 스테이지에 남고 최종 이미지에는 포함되지 않습니다.
이 방식으로 이미지 크기가 절반 이하로 줄어드는 경우가 흔합니다. 크기가 줄면 배포 속도만 빨라지는 게 아니라, 이미지 안에 있는 파일이 적을수록 공격 표면(취약점이 있을 수 있는 패키지 수)도 줄어듭니다.
root로 실행하면 안 되는 이유
Dockerfile에 USER 를 지정하지 않으면 컨테이너 안 프로세스는 기본적으로 root로 실행됩니다. 컨테이너는 커널을 호스트와 공유하기 때문에(1장 참고), 컨테이너 안의 root와 호스트의 root가 완전히 무관하지는 않습니다. 컨테이너 탈출 취약점이 발견되면, 컨테이너 안에서 root 권한을 가진 프로세스는 그 취약점을 악용하기 훨씬 쉬운 위치에 있습니다.
FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
CMD ["node", "dist/server.js"]node 공식 이미지에는 이미 node 라는 비루트 사용자가 준비되어 있어서, 직접 사용자를 만들 필요 없이 USER node 한 줄이면 충분한 경우도 많습니다. 이미지마다 기본 제공 여부가 다르니 확인해보세요.
한 가지 주의할 점: USER 를 지정하기 전에 필요한 파일 권한 설정(chown 등)을 다 끝내야 합니다. 비루트 사용자로 전환한 다음에 root 권한이 필요한 작업(예: apt-get install)을 시도하면 그 시점에 실패합니다.
.dockerignore와 시크릿 — 이미지에 몰래 들어가는 것들
COPY . . 를 쓰면 .env 파일, .git 디렉터리, 로컬 인증서 파일까지 전부 이미지 레이어에 들어갈 수 있습니다. 문제는 이미지 레이어가 diff 형태로 계속 누적된다는 겁니다. 나중 단계에서 RUN rm .env 로 지워도, 그 파일은 이전 레이어에 이미 기록되어 있어서 이미지 안에 여전히 존재합니다. docker history 나 docker save 로 이미지를 풀어보면 지웠다고 생각한 파일이 나오는 걸 보고 놀라는 경우가 있습니다.
# .dockerignore
.env
.env.*
.git
*.pem
*.key시크릿(API 키, DB 비밀번호)은 이미지에 넣는 게 아니라 런타임에 환경변수나 시크릿 관리 도구로 주입하는 게 원칙입니다. 빌드 시점에 꼭 시크릿이 필요한 경우(예: private 패키지 저장소 인증)라면 BuildKit의 --mount=type=secret 기능을 쓰면 해당 레이어에 값이 영구히 남지 않게 처리할 수 있습니다. 이 부분은 빌드 도구 버전에 따라 문법이 달라질 수 있으니 사용 중인 Docker 버전의 문서를 확인하는 걸 권합니다.
정리하면
프로덕션 이미지를 만들 때 확인할 세 가지: 빌드 도구가 최종 이미지에 남아있지 않은가(멀티스테이지), 프로세스가 불필요하게 root로 도는 건 아닌가(USER), 민감한 파일이 실수로 레이어에 박제되지는 않았는가(.dockerignore). 셋 다 Dockerfile을 처음 짤 때는 신경 쓰지 않아도 당장 돌아가기 때문에, 로컬에서 테스트할 때는 문제가 드러나지 않습니다. 그래서 더 놓치기 쉽습니다.