Docker 이미지의 작성과 배포 방법

처음 쓴 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 1CMD/ENTRYPOINT를 JSON 배열(exec 형식)로

마무리

좋은 Dockerfile의 핵심은 “덜 바뀌는 것을 위로, 실행에 필요 없는 것은 밖으로”다. 의존성 레이어를 분리해 캐시를 살리고, 컴파일 언어는 멀티 스테이지로 실행 파일만 남기고, 비root 사용자로 실행한다. 배포는 레지스트리/저장소:버전 태그로 push하고 다이제스트로 같은 이미지인지 확인하며, 레지스트리가 없는 곳에는 save/load를 쓴다.

참고

답글 남기기