처음 쓴 Dockerfile은 대개 동작은 하지만 코드 한 줄만 고쳐도 의존성 설치부터 다시 돌고, 이미지가 1GB를 넘기고, 컨테이너 안 프로세스가 root로 뜬다. 만든 이미지를 다른 서버로 옮기려 해도 레지스트리 주소와 태그 규칙을 몰라 막힌다. 이 글에서는 Python(Flask) 앱과 Go 앱을 예로 Dockerfile 작성, 레이어 캐시를 살리는 명령 순서, 멀티 스테이지 빌드를 다룬다. 이어서 태그를 붙여 레지스트리에 push/pull하는 배포 과정과 save/load로 하는 오프라인 전달까지 Ubuntu 24.04 + Docker 29에서 실행한 결과로 정리한다.
Dockerfile 작성
예제 앱은 호스트 이름과 버전을 JSON으로 돌려주는 Flask 앱이다.
from flask import Flask
import os, socket
app = Flask(__name__)
@app.get("/")
def index():
return {"host": socket.gethostname(), "version": os.environ.get("APP_VERSION", "dev")}flask==3.1.2의존성 목록을 먼저 복사해 설치하고, 자주 바뀌는 소스는 나중에 복사한다. 마지막에 전용 사용자로 전환해 root 실행을 피한다.
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
ENV APP_VERSION=1.0
RUN useradd -r -u 10001 app
USER app
EXPOSE 8000
CMD ["python", "-m", "flask", "--app", "app", "run", "--host", "0.0.0.0", "--port", "8000"]빌드 컨텍스트에서 뺄 파일은 .dockerignore에 적는다. COPY . .를 쓸 때 .git이나 가상환경이 이미지에 들어가는 것을 막는다.
__pycache__/
*.pyc
.git
.venv
Dockerfile
.dockerignore| 명령 | 역할 | 메모 |
|---|---|---|
| FROM | 베이스 이미지 | slim/alpine/distroless 등 작은 변형 우선 |
| WORKDIR | 작업 디렉터리 | 없으면 생성 |
| COPY | 빌드 컨텍스트 → 이미지 복사 | ADD는 URL·tar 자동 해제가 필요할 때만 |
| RUN | 빌드 중 명령 실행, 결과가 레이어로 남음 | 캐시 단위 |
| ENV | 환경 변수 | 실행 시 docker run -e로 덮어쓰기 가능 |
| USER | 이후 명령·실행 사용자 | root 실행 방지 |
| EXPOSE | 사용 포트 문서화 | 실제 공개는 -p |
| CMD / ENTRYPOINT | 기본 실행 명령 | exec 형식(JSON 배열) 권장 |
빌드와 실행
$ docker build -t myapp:1.0 .
...
$ docker images myapp
IMAGE ID DISK USAGE CONTENT SIZE EXTRA
myapp:1.0 4a5c9d462240 198MB 48.3MB
$ docker run -d --name myapp -p 8000:8000 myapp:1.0
adf188f0e6f51f1e4654d98cb8e542e13a138f4856ec674d86527f98a930519c
$ curl -s localhost:8000
{"host":"adf188f0e6f5","version":"1.0"}
$ docker exec myapp id
uid=10001(app) gid=999(app) groups=999(app)
docker history로 레이어별 크기를 보면 용량 대부분이 베이스 이미지와 pip install 레이어다.
$ docker history myapp:1.1 --format '{{.Size}}\t{{.CreatedBy}}' | head -8 | cut -c1-90
0B CMD ["python" "-m" "flask" "--app" "app" "ru…
0B EXPOSE [8000/tcp]
0B USER app
41kB RUN /bin/sh -c useradd -r -u 10001 app # bui…
0B ENV APP_VERSION=1.0
12.3kB COPY app.py . # buildkit
15.3MB RUN /bin/sh -c pip install --no-cache-dir -r…
12.3kB COPY requirements.txt . # buildkit
레이어 캐시와 명령 순서
app.py만 고치고 다시 빌드하면 pip install 레이어까지 CACHED이고 그 뒤 단계만 다시 돈다.
$ echo '# v1.2' >> app.py
$ docker build --progress=plain -t myapp:1.2 . 2>&1 | grep -E '^#[0-9]+ (\[[0-9]/[0-9]\]|CACHED)'
#4 [1/6] FROM docker.io/library/python:3.12-slim@sha256:a6e34c598f2467ed0e9a8d349809fcd8b5c603269512df273a0bb1784edc11b1
#6 [3/6] COPY requirements.txt .
#6 CACHED
#7 [2/6] WORKDIR /app
#7 CACHED
#8 [4/6] RUN pip install --no-cache-dir -r requirements.txt
#8 CACHED
#9 [5/6] COPY app.py .
#10 [6/6] RUN useradd -r -u 10001 app
비교용으로 소스 전체를 먼저 복사하는 Dockerfile을 만들었다. 소스가 바뀔 때마다 COPY . . 캐시가 깨져 의존성 설치가 매번 다시 돈다.
FROM python:3.12-slim
WORKDIR /app
COPY . .
RUN pip install --no-cache-dir -r requirements.txt
CMD ["python", "-m", "flask", "--app", "app", "run", "--host", "0.0.0.0", "--port", "8000"]$ echo '# v3' >> app.py
$ docker build --progress=plain -t bad:3 . 2>&1 | grep -E '^#[0-9]+ (\[[0-9]/[0-9]\]|CACHED)|^#8 DONE'
#5 [1/4] FROM docker.io/library/python:3.12-slim@sha256:a6e34c598f2467ed0e9a8d349809fcd8b5c603269512df273a0bb1784edc11b1
#6 [2/4] WORKDIR /app
#6 CACHED
#7 [3/4] COPY . .
#8 [4/4] RUN pip install --no-cache-dir -r requirements.txt
#8 DONE 4.0s
첫 예제에서도 useradd가 COPY app.py 뒤에 있어 매번 다시 실행된다. 소스와 무관한 단계는 소스 복사보다 앞으로 올리는 것이 원칙이다.
멀티 스테이지 빌드
컴파일 언어는 빌드 도구가 든 이미지에서 바이너리만 만들고, 실행 이미지에는 바이너리만 복사한다. 예제 Go 서버는 다음과 같다.
package main
import (
"fmt"
"net/http"
"os"
)
func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
h, _ := os.Hostname()
fmt.Fprintf(w, "hello from %s\n", h)
})
http.ListenAndServe(":8080", nil)
}단일 스테이지와 멀티 스테이지 Dockerfile을 나란히 만들었다. 정적 바이너리(CGO_ENABLED=0)라 셸도 libc도 없는 distroless 이미지에서 돈다.
FROM golang:1.23
WORKDIR /src
COPY . .
RUN go build -o /hello .
CMD ["/hello"]FROM golang:1.23 AS build
WORKDIR /src
COPY go.mod ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /hello .
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /hello /hello
EXPOSE 8080
ENTRYPOINT ["/hello"]$ docker build -q -f Dockerfile.single -t hello:single .
sha256:c9e98836df49994213ef5758a326b562be59943efbbdf1dfe6a12c15991a31e7
$ docker build -q -t hello:multi .
sha256:ae4bf471f1554a9507c4a00e4359b7834aeb296ef335e6e5b0a4bd73bb5b0248
$ docker images hello --format '{{.Repository}}:{{.Tag}}\t{{.Size}}'
hello:multi 13.5MB
hello:single 1.34GB
$ docker run -d --name hello -p 8080:8080 hello:multi
7fa4500daae2589af0de1930b9e22a423680d5c062a20fd6fdcf6dac68257277
$ curl -s localhost:8080
hello from 7fa4500daae2
같은 앱이 1.34GB에서 13.5MB로 줄었다. 대신 셸이 없어 docker exec로 들어가 디버깅할 수는 없다.
$ docker exec hello sh
OCI runtime exec failed: exec failed: unable to start container process: exec: "sh": executable file not found in $PATH
태그와 레지스트리 배포
이미지 이름은 [레지스트리/]저장소[:태그] 형식이고, 레지스트리를 생략하면 Docker Hub(docker.io)로 간다. 사내 배포를 가정해 로컬 레지스트리 컨테이너를 띄우고 버전 태그와 latest를 함께 올렸다.
$ docker run -d --name registry -p 5000:5000 --restart unless-stopped registry:2
04b85584a51874b63ab6aaef6723268630becf382e055166f81aef0ed4bbd98e
$ docker tag myapp:1.0 localhost:5000/team/myapp:1.0
$ docker tag myapp:1.0 localhost:5000/team/myapp:latest
$ docker push localhost:5000/team/myapp:1.0 2>&1 | tail -3
ecc510c1e359: Pushed
02b3885323a9: Pushed
1.0: digest: sha256:4a5c9d46224092660f8b86677752bcb95a424e6dce819568715c0d9f95e5a5c8 size: 856
$ docker push localhost:5000/team/myapp:latest 2>&1 | tail -2
5b2f6fc223ea: Layer already exists
latest: digest: sha256:4a5c9d46224092660f8b86677752bcb95a424e6dce819568715c0d9f95e5a5c8 size: 856
두 번째 push는 레이어가 이미 있어 태그만 추가됐다. 레지스트리 API로 저장소와 태그를 확인할 수 있다.
$ curl -s localhost:5000/v2/_catalog
{"repositories":["team/myapp"]}
$ curl -s localhost:5000/v2/team/myapp/tags/list
{"name":"team/myapp","tags":["latest","1.0"]}
로컬 이미지를 지우고 레지스트리에서 다시 받는다. 다이제스트(sha256:…)가 push 때와 같으니 내용이 같은 이미지다.
$ docker rmi localhost:5000/team/myapp:1.0 localhost:5000/team/myapp:latest myapp:1.0 > /dev/null
$ docker pull localhost:5000/team/myapp:1.0 2>&1 | tail -3
Digest: sha256:4a5c9d46224092660f8b86677752bcb95a424e6dce819568715c0d9f95e5a5c8
Status: Downloaded newer image for localhost:5000/team/myapp:1.0
localhost:5000/team/myapp:1.0
$ docker image inspect localhost:5000/team/myapp:1.0 --format '{{index .RepoDigests 0}}'
localhost:5000/team/myapp@sha256:4a5c9d46224092660f8b86677752bcb95a424e6dce819568715c0d9f95e5a5c8
다른 서버에서 IP로 접근하면 HTTPS가 아니라는 이유로 거부된다. localhost(127.0.0.0/8)만 기본 예외다.
$ docker pull 192.168.0.46:5000/team/myapp:1.0 2>&1 | tail -1
Error response from daemon: failed to resolve reference "192.168.0.46:5000/team/myapp:1.0": failed to do request: Head "https://192.168.0.46:5000/v2/team/myapp/manifests/1.0": http: server gave HTTP response to HTTPS client
테스트망이라면 받는 쪽 /etc/docker/daemon.json에 insecure-registries를 추가한다. 운영 환경에서는 TLS 인증서를 붙인 레지스트리를 쓴다.
{
"insecure-registries": ["192.168.0.46:5000"]
}$ sudo systemctl restart docker
$ docker info --format '{{.RegistryConfig.InsecureRegistryCIDRs}} {{range $k,$v := .RegistryConfig.IndexConfigs}}{{$k}} {{end}}'
[127.0.0.0/8 ::1/128] 192.168.0.46:5000 docker.io
$ docker pull 192.168.0.46:5000/team/myapp:1.0 2>&1 | tail -2
Status: Downloaded newer image for 192.168.0.46:5000/team/myapp:1.0
192.168.0.46:5000/team/myapp:1.0
Docker Hub도 절차는 같고, 저장소 이름 앞에 계정명을 붙이고 로그인만 추가하면 된다. 이 부분은 계정이 필요해 위 로컬 레지스트리로 대신 검증했다.
docker login -u yourname
docker tag myapp:1.0 yourname/myapp:1.0
docker push yourname/myapp:1.0레지스트리 없이 파일로 전달
인터넷이 막힌 서버에는 save로 tar를 만들어 복사하고 load로 불러온다. 멀티 스테이지로 줄인 이미지라 3MB도 안 된다.
$ docker save -o hello-multi.tar hello:multi
$ ls -lh hello-multi.tar
-rw------- 1 noble noble 2.9M Oct 18 16:48 hello-multi.tar
$ docker rmi hello:multi > /dev/null && docker load -i hello-multi.tar
Loaded image: hello:multi
주의사항
| 문제 | 원인 | 조치 |
|---|---|---|
| 코드 한 줄 수정에도 의존성 재설치 | COPY . .가 의존성 설치보다 앞 | 의존성 파일만 먼저 COPY → 설치 → 소스 COPY |
| 이미지가 1GB 이상 | 빌드 도구·컴파일러가 실행 이미지에 남음 | 멀티 스테이지, slim/distroless 베이스 |
| .git·비밀 파일이 이미지에 포함 | .dockerignore 없음 | .dockerignore 작성. 비밀은 빌드 인자가 아니라 런타임 주입이나 –secret |
| latest만 쓰다 버전 추적 불가 | latest는 “마지막에 push한 것”일 뿐 | 버전 태그와 함께 올리고 배포는 버전 태그나 다이제스트로 |
| server gave HTTP response to HTTPS client | 평문 HTTP 레지스트리 | 테스트는 insecure-registries, 운영은 TLS |
| distroless에서 docker exec sh 실패 | 셸이 없음 | 디버깅은 :debug 변형 태그나 docker cp/로그 사용 |
| CMD 셸 형식에서 SIGTERM이 앱에 안 감 | /bin/sh -c가 PID 1 | CMD/ENTRYPOINT를 JSON 배열(exec 형식)로 |
마무리
좋은 Dockerfile의 핵심은 “덜 바뀌는 것을 위로, 실행에 필요 없는 것은 밖으로”다. 의존성 레이어를 분리해 캐시를 살리고, 컴파일 언어는 멀티 스테이지로 실행 파일만 남기고, 비root 사용자로 실행한다. 배포는 레지스트리/저장소:버전 태그로 push하고 다이제스트로 같은 이미지인지 확인하며, 레지스트리가 없는 곳에는 save/load를 쓴다.