Dockerfile에 FROM이 두 번 나온다고 해서 최종 이미지가 자동으로 작아지는 것은 아니다. 최종 단계에 무엇을 복사하는지가 핵심이다. 이 글은 이 저장소의 Dockerfile을 2026년 7월 23일 기준으로 점검한 결과와 측정 방법을 정리한다.
현재 Dockerfile에서 확인한 사실
현재 파일의 주요 명령은 다음과 같다.
FROM node:22-slim AS builder
...
RUN npm ci; npx quartz plugin install
FROM node:22-slim
WORKDIR /usr/src/app
COPY --from=builder /usr/src/app/ /usr/src/app/
COPY . .
CMD ["npx", "quartz", "build", "--serve"]
읽기 전용 검사 결과는 다음과 같다.
| 검사 항목 | 현재 값 | 의미 |
|---|---|---|
| FROM 수 | 2 | builder와 최종 단계는 구분되어 있다. |
| builder 베이스 | node:22-slim | Git 설치, npm ci, Quartz 플러그인 설치를 수행한다. |
| 최종 베이스 | node:22-slim | builder보다 더 작은 런타임 베이스는 아니다. |
| 단계 간 복사 | /usr/src/app 전체 | 빌드 전용 의존성도 함께 복사될 수 있다. |
| 소스 복사 | COPY . . | 저장소 전체가 다시 들어간다. |
| .dockerignore | 없음 | .git, public, 로컬 도구가 빌드 컨텍스트에 포함될 수 있다. |
따라서 이 Dockerfile은 멀티스테이지 문법을 사용하지만 “최종 산출물만 옮기는 슬림 이미지”라고 단정할 수 없다. 이미지 크기가 몇 퍼센트 줄었다는 기존 수치도 이 저장소에서 측정한 적이 없어 삭제했다.
멀티스테이지의 판단 기준
Docker의 공식 멀티스테이지 문서는 각 FROM이 새 단계를 시작하고 COPY —from으로 필요한 산출물만 선택할 수 있다고 설명한다.
| 질문 | 좋은 신호 | 점검이 필요한 신호 |
|---|---|---|
| 최종 단계에 무엇을 복사하는가? | public, dist, 단일 바이너리 | builder 작업 디렉터리 전체 |
| 실행에 컴파일러가 필요한가? | 필요 없음 | Git, gcc, 테스트 도구가 남음 |
| 베이스가 다른가? | build와 runtime 역할이 구분됨 | 두 단계가 같고 전체 파일을 복사 |
| 빌드 컨텍스트가 제한되는가? | .dockerignore가 있음 | 저장소 전체를 전송 |
| 실행 사용자가 제한되는가? | 비루트 USER | 기본 root |
멀티스테이지는 보안 기능 그 자체도 아니다. 패키지가 적어지면 공격 표면을 줄이는 데 도움이 될 수 있지만, 베이스 이미지 업데이트, 비루트 사용자, 비밀 관리, 취약점 스캔은 별도 작업이다.
정적 사이트에 맞춘 목표 구조
Quartz를 빌드한 뒤 정적 파일만 제공하려는 목적이라면 개념적으로 다음처럼 역할을 분리할 수 있다. 이 코드는 현재 Dockerfile에 적용된 결과가 아니라 검토용 예시다.
FROM node:22-slim AS builder
WORKDIR /app
COPY package.json package-lock.json quartz.lock.json ./
COPY quartz/ ./quartz/
RUN npm ci && npx quartz plugin install
COPY content/ ./content/
COPY quartz.config.yaml quartz.ts ./
RUN node ./quartz/bootstrap-cli.mjs build
FROM nginx:alpine AS runtime
COPY --from=builder /app/public/ /usr/share/nginx/html/
실제 적용 전에는 Quartz가 빌드 시 읽는 모든 설정과 정적 자산을 목록화해야 한다. 이 저장소는 플러그인과 커스텀 컴포넌트를 사용하므로 위의 COPY 목록만으로 빌드가 된다고 보장할 수 없다.
서버 미리보기까지 컨테이너 안에서 실행해야 한다면 정적 웹 서버 대신 Node 런타임을 남기는 별도 목표가 필요하다.
.dockerignore 후보
현재 저장소에는 .dockerignore가 없다. 다음 항목은 검토할 수 있지만, 무조건 복사해서 쓰면 안 된다.
.git
.github
node_modules
public
.tools
*.log
python-server.*.log
예를 들어 빌드 과정에서 Git 메타데이터로 수정일을 계산한다면 .git 제외가 결과를 바꿀 수 있다. 제외 목록은 실제 빌드가 통과하는지 확인하면서 추가해야 한다.
이미지 크기와 내용을 재현해서 비교하기
Docker가 설치된 환경에서는 추측 대신 두 이미지를 같은 조건으로 빌드한다.
docker build --no-cache -t confusion-blog:current -f Dockerfile .
docker image inspect confusion-blog:current --format "{{.Size}}"
docker history --no-trunc confusion-blog:current
개선 Dockerfile을 별도 파일에 만들었다면 다음처럼 비교한다.
docker build --no-cache -t confusion-blog:candidate -f Dockerfile.candidate .
docker image inspect confusion-blog:candidate --format "{{.Size}}"
docker history --no-trunc confusion-blog:candidate
비교표에는 최소한 다음 조건을 함께 기록해야 한다.
| 필드 | 기록 예 |
|---|---|
| 커밋 | git rev-parse —short HEAD |
| 플랫폼 | linux/amd64 또는 linux/arm64 |
| 캐시 | —no-cache 사용 여부 |
| 베이스 digest | docker image inspect 결과 |
| 최종 바이트 | 두 이미지의 Size |
| 빌드 시간 | 같은 호스트에서 여러 번 측정한 중앙값 |
Docker의 빌드 권장사항도 멀티스테이지와 재사용 가능한 단계를 설명한다.
한계
- 이 작업 환경에는 docker 명령이 설치되어 있지 않아 이미지 빌드와 크기 비교를 실행하지 못했다.
- 따라서 이 글은 MB, 백분율, 배포 시간 절감 수치를 제시하지 않는다.
- 예시 nginx 런타임은 Quartz의 개발 서버 기능과 동적 동작을 제공하지 않는다.
- Alpine과 slim의 크기 차이가 호환성 문제보다 항상 중요한 것은 아니다. 네이티브 모듈과 libc 차이를 테스트해야 한다.
- 이미지가 작아도 취약점이 0개라는 뜻은 아니다.
- 현재 Dockerfile을 수정한 글이 아니므로 실제 변경 전 CI와 컨테이너 실행 테스트가 필요하다.
정확한 결론은 “멀티스테이지를 썼다”가 아니라 동일한 커밋을 빌드한 이미지의 바이트와 실행 결과로 내려야 한다.