Table of contents
- Dockerfile은 레시피가 아니라 역사다
- 가장 단순한 Dockerfile
- FROM — 모든 것은 여기서 시작한다
- RUN — 레이어 하나가 만들어진다
- COPY vs ADD — 일단은 COPY만 쓴다
- WORKDIR — 작업 디렉토리를 명시한다
- CMD와 ENTRYPOINT — 헷갈리는 단짝
- ENV — 환경변수를 박아둔다
- ARG vs ENV — 같아 보이지만 다르다
- EXPOSE — 문서화에 가까운 선언
- USER — 컨테이너 안의 사용자
- .dockerignore — 빌드 컨텍스트에서 빼고 싶은 것
- HEALTHCHECK — 컨테이너 건강 상태 선언
- 레이어 최적화 한 장 요약
- 한번에 정리하는 체크리스트
Dockerfile은 레시피가 아니라 역사다
이미지와 레이어를 이해했으면 이제 이미지를 직접 만들어볼 차례다. 만드는 도구가 바로 Dockerfile이다. 겉보기엔 평범한 텍스트 파일 하나지만, 실제로는 “이 레이어 위에 저 레이어를 쌓고, 그 위에 또 다른 레이어를 쌓아라”라는 명령의 역사를 기록하는 문서다.
flowchart TB
DF["Dockerfile<br/>텍스트 파일"] --> BUILD["docker build"]
BUILD --> CTX["Build Context<br/>(현재 디렉토리 + .dockerignore)"]
CTX --> ENGINE["BuildKit / legacy builder"]
subgraph LAYERS["생성되는 레이어"]
L1["FROM으로 시작된 베이스"]
L2["RUN ... 결과"]
L3["COPY ... 결과"]
L4["COPY src . 결과"]
end
ENGINE --> LAYERS
LAYERS --> IMG["완성된 이미지"]
이번 편에선 자주 쓰는 지시어들을 하나씩 뜯어본다. 모든 Dockerfile에 매번 다 쓰는 건 아니지만, 역할과 차이를 아는 순간 더 좋은 Dockerfile을 쓰게 된다.
가장 단순한 Dockerfile
설명 전에 끝에 만들고 싶은 모습부터 보자. Node.js 앱 하나를 컨테이너화하는 현실적인 예시다.
# syntax=docker/dockerfile:1.7
FROM node:22-slim AS runtime
WORKDIR /app
# 의존성 레이어 — package.json이 그대로면 캐시됨
COPY package*.json ./
RUN npm ci --omit=dev
# 앱 코드 레이어 — 자주 바뀜
COPY . .
ENV NODE_ENV=production \
PORT=3000
EXPOSE 3000
USER node
CMD ["node", "server.js"]
위 12~15줄에 이 편에서 이야기할 지시어가 거의 다 들어 있다. 하나씩 풀어보자.
FROM — 모든 것은 여기서 시작한다
FROM은 베이스 이미지를 지정한다. Dockerfile의 첫 줄(ARG를 빼면)은 반드시 FROM이어야 한다. 여기서 고른 이미지 위에 우리의 레이어가 쌓인다.
FROM node:22-slim
베이스 이미지 선택이 최종 이미지 크기·보안·안정성을 가장 크게 좌우한다. 2편에서 얘기했듯이 slim, alpine, distroless 중 어느 것을 고를지 의식해야 한다.
멀티스테이지 빌드에서는 FROM을 여러 개 쓸 수 있고, 각 스테이지에 이름(AS)을 붙인다.
FROM golang:1.23 AS builder
# ... 빌드 과정 ...
FROM gcr.io/distroless/static-debian12 AS runtime
COPY --from=builder /out/app /app
--from=builder는 이전 스테이지에서 파일을 가져올 때 쓴다. 실행용 이미지엔 빌드 도구가 남지 않는다.
RUN — 레이어 하나가 만들어진다
RUN은 이미지를 빌드하는 중에 명령을 실행한다. 각 RUN은 새 레이어를 만든다. 그래서 “몇 개의 RUN으로 쓰느냐”가 이미지 크기와 빌드 시간에 직접 영향을 준다.
두 가지 형태가 있다.
# shell form — /bin/sh -c 로 실행됨
RUN apt-get update && apt-get install -y curl
# exec form — 쉘 없이 직접 실행
RUN ["apt-get", "install", "-y", "curl"]
shell form은 쉘 기능(&&, |, 환경변수 확장 등)을 쓸 수 있어 편하다. exec form은 쉘을 거치지 않으니 시그널 처리가 더 정확하고, 쉘이 없는 distroless 이미지에서도 동작한다.
2편에서 봤듯 apt-get install과 rm -rf /var/lib/apt/lists/*를 같은 RUN 안에 묶는 게 중요하다. 파일 생성과 정리가 다른 레이어에 들어가면, 지운 파일이 하위 레이어에 남아 이미지가 불필요하게 커진다.
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
curl \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY vs ADD — 일단은 COPY만 쓴다
둘 다 파일을 이미지에 복사하는 지시어인데, 미묘하게 다르다.
- COPY: 호스트의 파일/디렉토리를 이미지 안으로 단순 복사
- ADD: COPY의 모든 기능에 더해 (1) URL에서 파일을 받고 (2) tar.gz 같은 압축 파일을 자동 해제
ADD의 추가 기능은 “은근한 부작용”이 많다. URL 다운로드는 재현성도 낮고 보안 검사도 어렵다. tar 자동 해제는 의도치 않게 파일을 풀어버릴 수 있다.
원칙: 기본적으로 COPY만 쓰고, 꼭 tar를 풀어야 할 때만 ADD를 쓴다. URL에서 받아야 한다면 RUN curl이나 RUN wget으로 명시적으로 하는 편이 낫다.
# 일반적 사용
COPY package.json /app/
# 소유자/권한 지정도 가능
COPY --chown=node:node . /app/
--chown은 복사된 파일의 소유자를 지정한다. 나중에 컨테이너 안에서 이 파일을 특정 사용자가 읽어야 할 때 유용하다.
WORKDIR — 작업 디렉토리를 명시한다
WORKDIR /app은 이후의 명령들이 /app에서 실행되도록 만든다. 안 쓰면 기본값은 /가 되고, 파일이 여기저기 흩어지기 쉽다.
WORKDIR /app
COPY . . # /app에 복사
RUN npm install # /app에서 실행
CMD ["node", "server.js"] # /app에서 실행
WORKDIR은 여러 번 쓸 수 있고, 상대 경로도 허용한다. 디렉토리가 없으면 자동으로 만들어준다. 그래서 mkdir && cd를 직접 쓰는 것보다 깔끔하다.
CMD와 ENTRYPOINT — 헷갈리는 단짝
컨테이너가 실행될 때 무엇을 실행할지를 정하는 지시어다. 둘 다 비슷해 보이는데 정확히는 역할이 다르다.
- ENTRYPOINT: 항상 실행되는 “고정된” 명령
- CMD: ENTRYPOINT에 전달되는 “기본 인자”
조합에 따라 동작이 달라진다.
# 패턴 1: ENTRYPOINT + CMD — 가장 권장
ENTRYPOINT ["node"]
CMD ["server.js"]
# docker run myapp -> node server.js
# docker run myapp app.js -> node app.js (CMD가 교체됨)
# 패턴 2: CMD만 사용
CMD ["node", "server.js"]
# docker run myapp -> node server.js
# docker run myapp bash -> bash (CMD 전체가 교체됨)
# 패턴 3: ENTRYPOINT만 사용 — 인자 하드코딩
ENTRYPOINT ["node", "server.js"]
# docker run myapp some-arg -> node server.js some-arg (인자가 추가됨)
실무 권장: ENTRYPOINT + CMD. ENTRYPOINT엔 실행할 바이너리를 쓰고, CMD엔 기본 인자를 둔다. 그러면 사용자가 docker run으로 인자만 바꿔 쓸 수 있다.
exec form(JSON 배열)과 shell form(문자열) 차이도 중요하다. 항상 exec form을 권장한다.
# 좋은 예 — exec form
CMD ["node", "server.js"]
# 나쁜 예 — shell form
CMD node server.js
shell form은 내부적으로 /bin/sh -c "node server.js"로 실행된다. 그럼 PID 1은 sh가 되고, sh는 시그널을 자식에게 제대로 전달하지 않는다. docker stop을 쳐도 앱이 SIGTERM을 못 받는다. 이 문제는 4편에서 자세히 다룬다.
ENV — 환경변수를 박아둔다
ENV는 이미지에 환경변수를 심는다. 빌드 시점뿐 아니라 컨테이너 실행 시점에도 유효하다는 점이 중요하다.
ENV NODE_ENV=production \
PORT=3000 \
LOG_LEVEL=info
\로 줄바꿈하면 한 레이어에 여러 변수를 묶을 수 있다. 컨테이너에서는 당연히 이 값들이 기본값으로 보인다.
docker run --rm myapp printenv NODE_ENV
# production
docker run --rm -e NODE_ENV=development myapp printenv NODE_ENV
# development <- 런타임에서 덮어쓸 수 있다
민감한 값(비밀번호, API 키)은 ENV에 박지 말자. 이미지 레이어에 그대로 포함되고, docker history로 누구나 볼 수 있다.
ARG vs ENV — 같아 보이지만 다르다
둘 다 “변수”지만 스코프가 다르다.
| 항목 | ARG | ENV |
|---|---|---|
| 유효 시점 | 빌드 중에만 | 빌드 중 + 컨테이너 실행 중 |
docker run에서 접근? | 아니오 | 예 |
docker build에서 값 주입? | --build-arg | 못 함 |
| 이미지에 저장됨? | 아니오 | 예 |
빌드 시점에만 쓰고 컨테이너엔 안 남기고 싶은 값은 ARG, 컨테이너에 기본 환경변수로 남기고 싶은 값은 ENV.
# 빌드 시점에만 쓰는 버전 정보
ARG APP_VERSION=dev
RUN echo "Building version ${APP_VERSION}"
# 컨테이너에도 남길 환경변수
ENV NODE_ENV=production
docker build --build-arg APP_VERSION=1.2.3 -t myapp .
한 가지 주의. ARG를 ENV에 대입하면 그 값은 이미지에 남는다.
ARG DB_PASSWORD
ENV DB_PASSWORD=${DB_PASSWORD} # 이미지에 박힘 — 비밀번호 유출 위험
민감한 값을 빌드 타임에만 쓰고 이미지에 남기지 않으려면 BuildKit의 --secret을 써야 한다.
# syntax=docker/dockerfile:1.7
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ci
docker build --secret id=npmrc,src=$HOME/.npmrc -t myapp .
이렇게 하면 .npmrc가 빌드 중에만 마운트되고, 최종 이미지에는 남지 않는다.
EXPOSE — 문서화에 가까운 선언
EXPOSE 3000은 “이 컨테이너는 3000 포트를 쓸 거야”를 선언할 뿐이다. 포트를 자동으로 열어주지 않는다. 외부에서 접근하려면 docker run -p 3000:3000처럼 실제로 포워딩을 해야 한다.
그럼 왜 쓰는가? 두 가지 용도다.
- 다른 사람이 Dockerfile만 보고 “어떤 포트를 쓰는 이미지구나”를 알 수 있게 문서화
docker run -P(대문자 P) 쓰면EXPOSE된 포트들을 임의 호스트 포트에 자동 매핑
네트워크는 6편에서 본격적으로 다룬다. 여기선 “EXPOSE는 실제 포트 오픈이 아니다”만 기억하자.
USER — 컨테이너 안의 사용자
기본적으로 컨테이너는 root로 실행된다. 편하지만 보안상 좋지 않다. 컨테이너 탈출 취약점이 터지면 호스트에서도 root 권한으로 공격이 이어질 수 있다.
USER로 비-root 사용자를 지정하자.
FROM node:22-slim
WORKDIR /app
COPY --chown=node:node . .
RUN npm ci --omit=dev
USER node
CMD ["node", "server.js"]
node 이미지엔 기본으로 node라는 UID 1000 유저가 있어서 바로 쓸 수 있다. 다른 이미지엔 직접 만들어줘야 한다.
RUN groupadd -r app && useradd -r -g app -u 1001 app
USER app
.dockerignore — 빌드 컨텍스트에서 빼고 싶은 것
docker build .을 치면 현재 디렉토리 전체가 빌드 컨텍스트로 Docker Daemon에 전송된다. node_modules, .git, .env 같은 파일까지 전부 포함되면 전송 속도도 느리고, 실수로 이미지에 들어갈 위험도 있다.
.dockerignore에 빼고 싶은 것들을 적어두면 빌드 컨텍스트에서 제외된다. .gitignore와 문법이 같다.
# 버전 관리
.git
.gitignore
# 의존성 (컨테이너 안에서 새로 설치)
node_modules
npm-debug.log
# 로컬 환경
.env
.env.*
!.env.example
# 에디터/OS
.vscode
.idea
.DS_Store
# 빌드 산출물
dist
build
coverage
!.env.example은 “이건 제외하지 마”라는 의미다. 예시 파일은 이미지에 포함하고 싶을 때 쓴다.
.dockerignore를 제대로 잡아두면 빌드 속도가 눈에 띄게 빨라지고, 캐시 적중률도 올라간다. .git 디렉토리가 빌드 컨텍스트에 들어가면 커밋 하나 할 때마다 컨텍스트가 바뀌어 캐시가 무효화될 수 있다.
HEALTHCHECK — 컨테이너 건강 상태 선언
컨테이너가 살아 있다고 해서 앱이 제대로 동작한다는 보장은 없다. 프로세스는 떠 있지만 DB 연결이 끊겨 응답을 못 하는 상태일 수도 있다. HEALTHCHECK는 주기적으로 검사 명령을 돌려서 컨테이너의 건강 상태를 판단한다.
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
CMD curl -f http://localhost:3000/health || exit 1
interval: 검사 간격timeout: 검사 명령의 타임아웃retries: 몇 번 실패해야unhealthy판정할지
검사가 실패하면 컨테이너 상태가 unhealthy로 찍힌다. Kubernetes나 Docker Compose에서 이 정보를 활용해 자동 재시작 같은 조치를 취할 수 있다.
레이어 최적화 한 장 요약
마지막으로 3편 전체를 레이어 관점에서 한 번에 복습해보자.
flowchart TB
A["1. FROM 가벼운 베이스<br/>(slim / alpine / distroless)"] --> B
B["2. WORKDIR 명시"] --> C
C["3. COPY 의존성 파일 먼저<br/>(package.json, go.mod 등)"] --> D
D["4. RUN 의존성 설치<br/>(캐시 가능)"] --> E
E["5. COPY 나머지 소스<br/>(자주 바뀜)"] --> F
F["6. ENV / EXPOSE / HEALTHCHECK"] --> G
G["7. USER 비-root"] --> H
H["8. ENTRYPOINT + CMD (exec form)"]
이 순서대로 Dockerfile을 쓰면 캐시가 잘 먹고, 이미지가 가볍고, 보안이 나아진다. 완벽한 Dockerfile이란 없지만, 이 흐름에서 크게 벗어나지 않으면 큰 탈도 없다.
한번에 정리하는 체크리스트
Dockerfile을 PR로 올리기 전에 스스로 확인해볼 항목들.
- 베이스 이미지를 가장 작은 걸로 골랐는가?
:latest말고 구체 버전을 박았는가? apt-get/apk설치 후 캐시를 같은 RUN에서 지웠는가?- 의존성 설치 레이어가 소스 복사보다 위에 있는가?
.dockerignore가node_modules,.git,.env를 빼고 있는가?ENTRYPOINT와CMD가 exec form(JSON 배열)인가?- 민감한 정보가
ENV로 박혀 있지 않은가? - 비-root
USER로 실행되는가? HEALTHCHECK가 있는가? (없어도 되지만, 있으면 운영이 편해진다)
다음 편에서는 완성된 이미지를 실행하고 멈추고 재시작하는 이야기로 넘어간다. docker run의 주요 옵션, --restart 정책, SIGTERM으로 깨끗하게 종료되는 컨테이너를 만드는 법, 상태를 어떻게 점검하는지까지.

Loading comments...