도커 기초 강좌 #2 Dockerfile 첫 작성 — FROM, RUN, COPY, CMD
#1 컨테이너란에서는 남이 만든 이미지(hello-world, ubuntu:24.04)를 받아서 돌렸습니다. 이번 글부터는 내 앱을 위한 이미지를 직접 만듭니다. 그 도구가 Dockerfile입니다.
도커 기초 강좌 시리즈에서 이번 글의 위치:
- #1 컨테이너란 — VM과 차이, 도커 생태계
- #2 Dockerfile 첫 작성 — FROM, RUN, COPY, CMD ← 이번 글
- #3 이미지와 컨테이너 — build, run, ps, logs, exec
- #4 볼륨과 네트워크
- #5 레지스트리 — Docker Hub, GHCR, push/pull
- #6
.dockerignore와 빌드 컨텍스트
Dockerfile이 무엇인가 #
Dockerfile은 “이 이미지를 어떻게 만들지” 를 적은 텍스트 파일입니다. 한 줄 한 줄이 명령(instruction) 이고, 위에서부터 차례로 실행돼 이미지가 한 겹씩 쌓여 갑니다.
가장 단순한 Dockerfile은 한 줄짜리도 가능합니다.
FROM ubuntu:24.04이걸 빌드하면 그냥 우분투 이미지의 사본이 만들어집니다. 큰 의미는 없지만, 형식상 유효한 Dockerfile입니다. 실제로는 여기에 “필요한 도구를 깔고, 코드를 넣고, 어떻게 실행할지” 를 더 적어 나갑니다.
이번 글에서 다룰 4개 명령으로도 대부분의 작은 앱은 컨테이너화할 수 있습니다.
| 명령 | 하는 일 |
|---|---|
FROM | 어떤 이미지에서 출발할지 — 모든 Dockerfile의 첫 줄 |
RUN | 빌드 시점에 명령 실행 — 패키지 설치, 컴파일 등 |
COPY | 호스트의 파일을 이미지 안으로 복사 |
CMD | 컨테이너가 시작될 때 기본으로 실행할 명령 |
여기에 보조 명령 WORKDIR, ENV, EXPOSE 까지만 추가하면 한 사이클을 돕니다.
작은 파이썬 앱 하나 #
설명만 늘어놓기보다 실제 앱 하나를 컨테이너로 만들어 봅시다. 디렉터리를 하나 만들고:
mkdir hello-docker && cd hello-docker작은 Flask 앱을 하나 적습니다.
from flask import Flask
app = Flask(__name__)
@app.route("/")
def index():
return "Hello from a container!"
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8000)의존성 명세 하나도 적어두고요.
flask==3.0.3여기까지가 일반적인 파이썬 프로젝트입니다. 이 앱을 컨테이너로 띄우는 게 이번 글의 목표입니다.
FROM — 베이스 이미지 고르기 #
모든 Dockerfile은 FROM으로 시작합니다. 처음부터 OS와 런타임을 깔지 않고, 누군가 이미 만들어둔 이미지에서 출발합니다.
FROM python:3.14-slimpython:3.14-slim의 의미를 풀어보면:
python— Docker Hub의 공식 파이썬 이미지 (hub.docker.com/_/python)3.14— 태그(보통 버전).3.14,3.14.0,3.13처럼 골라 쓸 수 있습니다.-slim— 같은 버전의 가벼운 변형. 디버그 도구,문서가 빠져 용량이 작음.
태그 뒤 변형(variant) 의 흔한 선택지:
| 변형 | 크기(대략) | 언제 |
|---|---|---|
3.14 (full) | ~1 GB | 빠진 게 없음. 처음 볼 때 편함 |
3.14-slim | ~150 MB | 대부분 이걸 추천 — 작고 무난 |
3.14-alpine | ~50 MB | 가장 작음. musl 기반이라 일부 패키지(numpy 등)에서 빌드가 까다로움 |
크기 차이는 무시할 수준이 아닙니다. CI에서 매번 풀(full) 이미지를 받으면 빌드가 분 단위로 길어져요. 시작은 slim이 무난합니다.
Alpine은 매력적이지만 함정이 있습니다. glibc가 아니라 musl libc를 써서, C 확장이 들어간 패키지(
pandas,numpy,psycopg2) 는 wheel이 호환되지 않아 소스 빌드로 떨어지는 일이 잦습니다. 빌드가 느려지고 컴파일러 의존성을 다 설치해야 합니다. 잘 모르겠으면 slim으로 가세요.
RUN — 빌드 시점에 명령 실행 #
FROM만 있으면 빈 파이썬 환경이고, 우리 앱이 의존하는 Flask가 아직 없습니다. RUN으로 깝니다.
FROM python:3.14-slim
RUN pip install --no-cache-dir flask==3.0.3RUN은 빌드하는 순간에 명령을 실행하고, 그 결과 변경된 파일시스템 상태가 다음 레이어에 굳어 들어갑니다. 즉 이미지 안에 이미 Flask가 설치된 상태로 굳어져요. 컨테이너가 뜨고 나서 까는 게 아닙니다.
--no-cache-dir은 pip가 캐시 디렉터리를 만들지 않게 합니다. 어차피 이미지에 캐시는 필요 없고, 용량만 늘어나기 때문입니다. 도커 빌드에서는 거의 관용적으로 붙입니다.
requirements.txt가 있는 경우는 보통 이렇게 씁니다.
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt(이 패턴이 왜 효율적인지는 #6 빌드 컨텍스트 에서 캐시 이야기와 함께 자세히 봅니다.)
shell form vs exec form #
RUN은 두 가지 형식이 있습니다.
RUN apt-get update && apt-get install -y curlRUN ["apt-get", "update"]shell form은 /bin/sh -c로 명령을 실행해서 &&, |, > 같은 셸 기능을 그대로 씁니다. exec form은 셸을 거치지 않고 바로 실행합니다. RUN은 거의 항상 shell form으로 충분하고, exec form은 뒤에서 볼 CMD / ENTRYPOINT에서 더 자주 쓰입니다.
COPY — 호스트 파일을 이미지로 #
이제 우리 코드를 이미지 안으로 넣을 차례입니다.
FROM python:3.14-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .두 가지가 새로 들어왔습니다.
WORKDIR /app— 이후 명령들의 기준 디렉터리를/app으로 잡고, 없으면 만듭니다.cd /app과mkdir -p /app을 합친 것 같은 효과입니다.COPY <src> <dst>— 호스트의 빌드 컨텍스트에 있는requirements.txt를 이미지의WORKDIR안으로 복사.<src>는 빌드 컨텍스트(보통 Dockerfile이 있는 디렉터리) 기준 상대 경로입니다.
COPY와 비슷한 명령으로 ADD가 있는데, ADD는 URL 다운로드와 자동 압축 해제까지 합니다. 그 마법이 가끔 헷갈리는 동작을 만들어서, 요즘은 그냥 단순한 COPY를 쓰는 게 권장됩니다. URL 다운로드가 필요하면 RUN curl ...이 명시적이라 더 좋습니다.
CMD — 컨테이너가 시작될 때 실행할 명령 #
마지막으로 컨테이너가 떴을 때 무엇을 실행할지 적습니다.
FROM python:3.14-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
EXPOSE 8000
CMD ["python", "app.py"]CMD도 두 가지 형식이 있습니다.
CMD ["python", "app.py"]CMD python app.pyCMD는 exec form으로 적는 게 권장됩니다. shell form은 내부적으로 /bin/sh -c "python app.py"가 돼서, 메인 프로세스가 셸이 되고 파이썬은 그 자식이 됩니다. 그러면 docker stop이 보내는 SIGTERM이 셸에서 멈추고 파이썬에 전달되지 않아 그레이스풀 종료가 깨집니다. exec form은 파이썬이 PID 1이 돼서 신호를 직접 받습니다.
EXPOSE 8000은 “이 컨테이너는 8000 포트를 듣겠다” 는 문서용 표시입니다. 실제로 호스트에 포트를 여는 건 #3 에서 볼 docker run -p입니다. EXPOSE 자체는 네트워크 동작에 영향이 없지만, 다른 도구와 사람이 읽을 문서용 표시라 적어 두면 좋습니다.
CMD vs ENTRYPOINT — 짧게만 #
자주 비교되는 한 짝이라 한 단락만 짚고 갑니다.
CMD— 기본 명령.docker run myapp echo hi처럼 뒤에 인자를 주면 덮어씁니다.ENTRYPOINT— 항상 실행될 명령. 뒤에 주는 인자는ENTRYPOINT에 붙는 인자가 됩니다.
ENTRYPOINT ["python"]
CMD ["app.py"]이러면 docker run myapp은 python app.py, docker run myapp other.py는 python other.py가 됩니다. 단일 바이너리처럼 동작하는 이미지를 만들 때 쓰는 패턴인데, 처음에는 그냥 CMD만 써도 충분합니다.
빌드 — docker build
#
Dockerfile이 있는 디렉터리에서:
docker build -t hello-docker .플래그를 풀면:
-t hello-docker— 만들 이미지의 이름(태그). 생략하면 ID만 남고 사용성이 떨어집니다..— 빌드 컨텍스트 경로. 보통 현재 디렉터리. (#6 의 핵심 주제)
처음 빌드는 python:3.14-slim을 받아오느라 시간이 걸리고, 출력이 이렇게 흘러갑니다.
[+] Building 12.3s (10/10) FINISHED
=> [internal] load build definition from Dockerfile
=> [internal] load .dockerignore
=> [internal] load metadata for docker.io/library/python:3.14-slim
=> [1/5] FROM docker.io/library/python:3.14-slim
=> [internal] load build context
=> [2/5] WORKDIR /app
=> [3/5] COPY requirements.txt .
=> [4/5] RUN pip install --no-cache-dir -r requirements.txt
=> [5/5] COPY app.py .
=> exporting to image
=> => writing image sha256:abc123...
=> => naming to docker.io/library/hello-docker각 명령이 레이어 한 장이 됩니다. 두 번째 빌드부터는 변하지 않은 레이어가 캐시에서 재사용돼서 매우 빨라요. (캐시 동작은 #6 에서 깊게.)
실행 — docker run
#
docker run --rm -p 8000:8000 hello-docker--rm— 컨테이너가 종료되면 자동 삭제. 일회성 실행에 편합니다.-p 8000:8000— 호스트의 8000 포트를 컨테이너의 8000 포트로 매핑. 앞이 호스트, 뒤가 컨테이너.
브라우저에서 http://localhost:8000을 열면 Hello from a container!가 보입니다. 우리가 직접 만든 첫 컨테이너입니다.
종료는 터미널에서 Ctrl+C. --rm 덕에 컨테이너가 자동으로 정리됩니다.
보조 명령 — ENV #
환경변수를 박을 땐 ENV를 씁니다.
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1이 두 변수는 컨테이너 안 파이썬에서 거의 관용적으로 켭니다.
PYTHONUNBUFFERED=1—print출력을 즉시 흘림. 컨테이너 로그를 실시간으로 보려면 필수.PYTHONDONTWRITEBYTECODE=1—.pyc캐시 파일을 안 만듦. 어차피 컨테이너는 일회성이라 의미가 없음.
런타임에 환경변수를 주입하고 싶으면 docker run -e KEY=value로 넘기면 됩니다. (DB 비밀번호 같은 건 ENV에 박으면 안 됩니다. -e / 시크릿 매니저로.)
한곳에 모은 Dockerfile #
지금까지 본 명령들을 한 파일로 정리하면:
FROM python:3.14-slim
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
EXPOSE 8000
CMD ["python", "app.py"]작은 Flask 앱을 컨테이너로 띄우는 데 9 줄이면 충분합니다. 이 골격은 FastAPI / Django / Express도 거의 동일합니다. 베이스 이미지와 실행 명령만 바뀝니다.
자주 만나는 함정 #
처음 Dockerfile을 짤 때 자주 걸리는 함정 몇 가지.
COPY . .부터 적고RUN pip install을 그 뒤에 두는 실수. 코드 한 줄만 바꿔도 의존성을 통째로 다시 설치하게 됩니다. 의존성 명세 → 설치 → 코드 복사 순서로 적으세요. (이 캐시 이야기는 #6 에서 본격적으로.)apt-get install다음에apt-get clean안 함. 패키지 인덱스가 그대로 남아 이미지가 부풀어 오릅니다. 관용 패턴:RUN apt-get update && apt-get install -y --no-install-recommends X && rm -rf /var/lib/apt/lists/*.CMD를 shell form으로 적음.docker stop의 SIGTERM이 앱에 닿지 않아 늘 강제 종료(SIGKILL) 됩니다. exec form (["python", "app.py"]) 으로.- 루트로 실행. 기본 이미지는 root로 뜹니다. 운영에선
USER로 비특권 사용자로 떨어트립니다. (도커 고급에서 다룹니다.)
정리 #
이번 글에서 잡은 흐름:
- Dockerfile은 이미지를 어떻게 만들지 적은 텍스트 — 위에서부터 한 줄씩 레이어가 된다
- **
FROM**으로 베이스 이미지를 고르고, 변형(slim,alpine)이 용량과 호환성에 영향을 준다 - **
RUN**은 빌드 시점에 명령을 실행하고 결과를 이미지에 굳힌다 - **
COPY**로 호스트의 파일을 이미지로 복사하고, **WORKDIR**로 작업 디렉터리를 잡는다 - **
CMD**는 컨테이너가 떴을 때 실행할 기본 명령 — exec form으로 적는다 docker build -t name .으로 이미지를 굽고,docker run -p host:container name으로 띄운다
다음 글(#3 이미지와 컨테이너 — build, run, ps, logs, exec)에서는 도커 CLI 명령군을 한 단계 더 깊이 봅니다. docker build의 옵션, docker run의 자주 쓰는 플래그(-d, --name, -e, --rm), 그리고 docker logs / docker exec 같은 운영 명령으로 컨테이너의 라이프사이클을 다루는 방식까지요.