Dockerfile — 명령어와 그 함정
명령어는 18개뿐인데, 그중 절반이 비슷해 보이는 것끼리 짝을 이루고 있다.
COPYvsADD,CMDvsENTRYPOINT,ENVvsARG— 이 짝이 사고의 근원이다.
1. 전체 목록
FROM · RUN · CMD · LABEL · EXPOSE · ENV · ADD · COPY · ENTRYPOINT VOLUME · USER · WORKDIR · ARG · ONBUILD · STOPSIGNAL · HEALTHCHECK · SHELL MAINTAINER (폐기 — LABEL maintainer 를 쓴다)
- 레이어를 만드는 것 — RUN · COPY · ADD
- 메타데이터만 바꾸는 것 — CMD · ENTRYPOINT · ENV · LABEL · EXPOSE · USER · WORKDIR …
2. 문법 버전을 고정한다
# syntax=docker/dockerfile:1
FROM alpine:3.20
이 한 줄이 파서 지시자다 — BuildKit 이 어떤 Dockerfile 문법을 쓸지 정한다
기능마다 요구 버전이 다르다
1.2 RUN --mount=type=cache
1.4 COPY --link · 히어독
1.8 # check= 지시자 (빌드 검사)
1.14 최신 기능들
:1 로 두면 1.x 최신을 따라간다 — 대부분 이렇게 쓴다 없으면 데몬에 내장된 낡은 문법으로 파싱돼 "문법 오류" 가 난다
3. COPY vs ADD
COPY app.jar /app/ # 파일·디렉터리 복사. 이게 기본이다
ADD https://x/y.tar.gz / # 원격 URL 다운로드
ADD archive.tar.gz /app/ # tar 자동 압축 해제
공식 권장 — 기본은 COPY 를 쓴다
ADD 가 위험한 이유
- tar 를 '자동으로 푼다' — 의도치 않게 파일이 흩어진다
- 원격 URL 은 캐시 판정이 애매하고 검증이 없다
- 무엇을 할지 파일 확장자에 따라 달라진다 (예측 불가)
원격 파일이 필요하면 RUN 안에서 명시적으로 받고 체크섬을 검증한다
- RUN curl -fsSL -o x.tgz https://... \
- && echo "abc123 x.tgz" | sha256sum -c -
빌드 컨텍스트와 .dockerignore
docker build . 의 '.' 이 빌드 컨텍스트다 이 디렉터리 전체가 빌더로 전송된다
.git · node_modules · 빌드 산출물이 통째로 넘어가면
- 전송이 느려진다
- COPY . . 로 이미지에 딸려 들어간다 (비밀 유출 위험)
.dockerignore 를 .gitignore 처럼 쓴다 .git node_modules *.log .env
.dockerignore가 없는 프로젝트가 의외로 많다. 이미지에.git이 통째로 들어가 있는 경우도 흔하다 — 커밋 히스토리에 남은 비밀까지 함께 나간다.
4. CMD vs ENTRYPOINT
ENTRYPOINT ["java", "-jar", "/app.jar"] # 항상 실행되는 것
CMD ["--spring.profiles.active=prod"] # 기본 인자 (덮어쓸 수 있다)
docker run myapp → java -jar /app.jar --spring...=prod
docker run myapp --debug → java -jar /app.jar --debug
(CMD 만 교체된다)
docker run --entrypoint sh myapp → sh (ENTRYPOINT 를 바꾼다)
-
ENTRYPOINT — 이 컨테이너가 '무엇인가' 를 정한다
-
CMD — 기본 인자. 사용자가 덮어쓸 것을 전제로 한다
-
CMD 만 쓰면 — docker run myapp bash 로 통째로 교체된다 (디버깅엔 편하다)
-
ENTRYPOINT 만 — 인자를 덧붙이는 형태가 된다
exec form 과 shell form — 시그널이 갈린다
CMD java -jar app.jar # shell form → /bin/sh -c "java -jar app.jar"
CMD ["java", "-jar", "app.jar"] # exec form → 직접 실행
shell form 이면 PID 1 이 sh 가 된다
- 컨테이너 정지 시 SIGTERM 을 sh 가 받는다
- sh 는 그것을 자식에게 전달하지 않는다
- 애플리케이션이 graceful shutdown 을 못 한다 → 강제 종료
배포마다 요청이 잘리는 원인 1순위다 반드시 exec form(대괄호 표기)을 쓴다
5. ENV vs ARG
ARG NODE_VERSION=22 # 빌드 시점에만 존재
ENV NODE_ENV=production # 이미지에 남아 런타임에도 보인다
-
ARG — 빌드 인자. --build-arg 로 넘긴다. 실행 중에는 없다
-
둘 다 비밀을 담으면 안 된다
- ARG 로 넘긴 값도 docker history 에 남는다
- ENV 는 docker inspect 로 그대로 보인다
비밀은 시크릿 마운트로
# syntax=docker/dockerfile:1
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
npm ci
docker build --secret id=npmrc,src=$HOME/.npmrc .
빌드 중에만 파일로 존재하고 레이어에는 남지 않는다 토큰·키가 필요한 빌드는 전부 이 방식으로 간다
ARG 로 토큰을 넘기는 코드를 보면 그 자리에서 고친다
6. 캐시 마운트로 빌드를 줄인다
# syntax=docker/dockerfile:1
RUN --mount=type=cache,target=/root/.m2 \
./mvnw -B package -DskipTests
의존성 캐시를 레이어에 굽지 않고 '빌드 사이에 유지되는 별도 저장소' 에 둔다
- 이미지가 커지지 않는다
- COPY 순서로 캐시를 지켜야 하는 부담이 줄어든다
- CI 에서는 이 캐시를 노드 간에 공유할 수 있어야 효과가 난다
지원 target 예 — /root/.m2 · /root/.gradle · ~/.npm · /go/pkg/mod · ~/.cache/pip
7. 멀티스테이지 빌드
# syntax=docker/dockerfile:1
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /src
COPY pom.xml .
RUN --mount=type=cache,target=/root/.m2 mvn -B dependency:go-offline
COPY src ./src
RUN --mount=type=cache,target=/root/.m2 mvn -B package -DskipTests
FROM eclipse-temurin:21-jre-alpine AS runtime
WORKDIR /app
COPY --from=build /src/target/app.jar ./app.jar
USER 10001:10001
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
빌드 도구(JDK · maven · 컴파일러)는 최종 이미지에 필요 없다
- 빌드 스테이지에서만 쓰고, 산출물만 복사해 온다
효과
- 이미지 크기가 수백 MB → 수십 MB
- 공격 표면이 줄어든다 (컴파일러 · 셸 · 패키지 매니저가 없다)
- 빌드 도구의 취약점이 운영 이미지에 안 딸려 온다
특정 스테이지만 빌드할 수도 있다
- docker build --target build .
8. HEALTHCHECK
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD curl -fsS http://localhost:8080/healthz || exit 1
| 옵션 | 기본값 |
|---|---|
--interval | 30s |
--timeout | 30s |
--start-period | 0s |
--start-interval | 5s |
--retries | 3 |
종료 코드
0 healthy
1 unhealthy
2 예약됨 — 쓰지 않는다
start-period 동안의 실패는 재시도 횟수에 세지 않는다
- 기동이 오래 걸리는 애플리케이션에 필수다
- 이 값이 없으면 기동 중에 unhealthy 로 판정된다
쿠버네티스에서는 이 HEALTHCHECK 를 쓰지 않는다
9. 그 밖에 자주 틀리는 것
EXPOSE 8080
EXPOSE 는 '문서' 다 — 포트를 실제로 여는 것이 아니다 실제 공개는 docker run -p 8080:8080 이 한다 "EXPOSE 했는데 접속이 안 된다" 는 여기서 나온다
WORKDIR /app # 없으면 만든다. cd 대신 이것을 쓴다
RUN cd /app && ... # ✗ 다음 RUN 에서는 원래 위치로 돌아간다
VOLUME /data
- VOLUME 을 선언하면 그 경로에 익명 볼륨이 자동 생성된다
- 이후 레이어에서 그 경로에 쓴 내용이 반영되지 않는다
- 정리되지 않은 익명 볼륨이 쌓인다
- 이미지에 VOLUME 을 넣기보다 실행 시 -v 로 지정하는 편이 통제하기 쉽다
STOPSIGNAL SIGQUIT # nginx 처럼 SIGTERM 이 '즉시 종료' 인 경우
10. 빌드 검사
# syntax=docker/dockerfile:1
# check=error=true
BuildKit 이 Dockerfile 의 흔한 실수를 잡아 준다
- 정의하지 않은 ARG 사용
- JSON 이 아닌 CMD/ENTRYPOINT (shell form 경고)
- 대소문자 불일치 (from → FROM)
- 중복 스테이지 이름
Dockerfile v1.8.0+ 에서 쓸 수 있다 CI 에 넣으면 리뷰에서 반복 지적하던 것들이 자동으로 걸린다
한눈에 정리
- syntax 지시자 — # syntax=docker/dockerfile:1 — 없으면 낡은 문법으로 파싱된다
- COPY vs ADD — 기본은 COPY. ADD 는 자동 압축 해제·원격 URL 이라 예측이 어렵다
- .dockerignore — 빌드 컨텍스트 전체가 전송된다. .git 유출을 막는다
- CMD vs ENTRYPOINT — 무엇인가(ENTRYPOINT) + 기본 인자(CMD)
- exec form — 대괄호 표기. shell form 이면 PID 1 이 sh 가 되어 시그널이 안 간다
- ENV vs ARG — 빌드 전용(ARG) vs 런타임에도 남음(ENV). 둘 다 비밀 금지
- 시크릿 마운트 — RUN --mount=type=secret — 레이어에 남지 않는다
- 캐시 마운트 — RUN --mount=type=cache — 의존성 캐시를 이미지 밖에
- 멀티스테이지 — 빌드 도구를 최종 이미지에서 뺀다. 크기와 공격 표면이 함께 준다
- HEALTHCHECK — interval 30s · timeout 30s · retries 3 · start-period 0s
- 종료 코드 0 healthy · 1 unhealthy · 2 예약
- EXPOSE — 문서일 뿐. 실제 공개는 -p 가 한다
출처 — Docker Docs: Dockerfile reference (18개 명령어 · 파서 지시자 · HEALTHCHECK 기본값) · "Build cache" · "Best practices" · "Build secrets" · "Multi-stage builds" · "Build checks"