본문으로 건너뛰기

Docker 배포

이 챕터에서 다루는 것

12-6에서 만든 정적 바이너리를 컨테이너 이미지에 담는다. 이미지를 작게 만드는 법, 빌드를 빠르게 만드는 법, 안전하게 실행하는 법, 그리고 컨테이너에서 Go 런타임이 CPU 개수를 어떻게 보는지.

:::note 이 챕터의 모든 명령은 실제로 실행됐다 docker version: 클라이언트 29.7.2 (darwin/arm64), 서버 29.5.2 (linux/arm64, colima). 이미지 크기와 docker run 출력은 전부 그 실행 결과다. :::

문제 — 나이브한 Dockerfile

FROM golang:1.26.5
WORKDIR /app
COPY . .
RUN go build -o app .
CMD ["./app"]

동작은 한다. 문제는 이것이다.

  • 이미지가 1GB에 가깝다. Go 툴체인, 소스 코드, 빌드 캐시가 전부 들어 있다.
  • 파일 하나만 고쳐도 의존성을 처음부터 다시 받는다. COPY . .이 캐시를 통째로 깬다.
  • 소스 코드가 이미지에 들어간다. 이미지를 받을 수 있는 사람은 소스를 읽을 수 있다.
  • root로 실행된다.
  • 셸과 패키지 매니저가 들어 있다. 침입자에게 도구를 쥐여 주는 셈이다.

Go의 결과물이 정적 바이너리 하나라는 점을 살리면 넷 다 사라진다.

멀티스테이지 빌드

examples/12-production/07-docker/Dockerfile
# ---- 빌드 스테이지 ----
FROM golang:1.26.5-alpine AS build

WORKDIR /src

# go.mod와 go.sum만 먼저 복사해 의존성을 받는다.
# 소스만 바뀌었을 때 이 레이어가 캐시에서 재사용된다.
# COPY . . 한 줄로 끝내면 파일 하나만 고쳐도 매번 전부 다시 받는다.
COPY go.mod go.sum ./
RUN go mod download

# 그다음에 소스를 복사한다.
COPY . .
examples/12-production/07-docker/Dockerfile
# CGO_ENABLED=0으로 정적 링크. distroless/static과 scratch가 요구하는 조건이다.
# -trimpath와 -ldflags는 12-6에서 다룬 그대로다.
ARG VERSION=dev
ARG COMMIT=none
RUN CGO_ENABLED=0 GOOS=linux go build \
-trimpath \
-ldflags "-s -w \
-X example.com/production/06-build-and-cross-compile/buildinfo.Version=${VERSION} \
-X example.com/production/06-build-and-cross-compile/buildinfo.Commit=${COMMIT}" \
-o /out/app ./07-docker
examples/12-production/07-docker/Dockerfile
FROM gcr.io/distroless/static-debian12:nonroot

COPY --from=build /out/app /app

# 비루트로 실행한다. distroless의 nonroot 태그는 이미 그렇게 되어 있지만
# 명시해 두면 베이스 이미지를 바꿔도 유지된다.
USER 65532:65532

두 번째 FROM이 새 이미지를 시작하고, COPY --from=build가 앞 스테이지에서 결과물만 꺼내 온다. 빌드 스테이지에 있던 것은 하나도 최종 이미지에 남지 않는다.

docker build -f 07-docker/Dockerfile \
--build-arg VERSION=v1.2.3 --build-arg COMMIT=abc1234 \
-t p12/app:distroless .

빌드 컨텍스트는 모듈 루트다. Dockerfile이 하위 디렉터리에 있어도 -f로 지정하면 컨텍스트는 마지막 인자(.)가 정한다.

레이어 캐시 — COPY를 두 번 하는 이유

go.mod/go.sum만 먼저 복사하고 go mod download를 돌린 뒤 소스를 복사하는 순서에 값이 있다. 소스 파일 하나를 고치고 다시 빌드한 실제 출력이다.

#8 [build 2/6] WORKDIR /src
#8 CACHED
#9 [build 3/6] COPY go.mod go.sum ./
#9 CACHED
#10 [build 4/6] RUN go mod download
#10 CACHED
#11 [build 5/6] COPY . .
#11 DONE 0.0s
#12 [build 6/6] RUN CGO_ENABLED=0 GOOS=linux go build ...
#12 DONE 4.7s

go mod downloadCACHED다. 의존성이 수백 개인 프로젝트에서 이 한 줄이 빌드 시간의 대부분을 결정한다. COPY . .으로 시작했다면 매번 다시 받았을 것이다.

:::tip 도커는 mtime이 아니라 내용으로 캐시를 판단한다 touch main.go만 하고 다시 빌드하면 모든 레이어가 CACHED다. 실제로 확인했다. BuildKit이 파일 내용의 해시를 보기 때문이다. 그래서 CI에서 체크아웃할 때마다 mtime이 갱신돼도 캐시가 살아 있다. :::

.dockerignore도 캐시와 관련이 있다.

examples/12-production/.dockerignore
# 빌드 컨텍스트에서 뺄 것들.
#
# 컨텍스트가 커지면 docker build 시작 전에 그것을 전부 데몬으로 보내느라
# 느려지고, .git이 이미지에 들어가면 히스토리 전체가 유출될 수 있다.
.git
.gitignore
out/
*.pprof
*.trace

.git을 빼는 것이 특히 중요하다. 커밋 히스토리에는 지운 줄 알았던 설정 파일과 키가 남아 있는 경우가 많다.

베이스 이미지 — distroless와 scratch

측정한 결과다.

p12/app:scratch 8.96MB
p12/app:distroless 14.8MB

distroless(gcr.io/distroless/static-debian12)에는 셸도 패키지 매니저도 없고, CA 인증서와 /etc/passwd, tzdata만 들어 있다.

scratch는 정말로 비어 있다.

examples/12-production/07-docker/Dockerfile.scratch
# scratch에 USER 65532를 쓰려면 /etc/passwd에 그 uid가 있어야 하는 것은
# 아니지만, 이름 대신 숫자를 써야 한다. 이름은 해석할 파일이 없다.
# CA 인증서는 빌드 스테이지에서 가져온다. 외부로 HTTPS 요청을 보내는
# 서비스에 이것이 없으면 x509: certificate signed by unknown authority가 난다.
FROM scratch

COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=build /out/app /app

두 이미지 안에서 /를 읽어 봤다.

# distroless
.dockerenv
app
bin
boot
dev
etc
home
lib
proc
root
run
sbin
sys
tmp
usr
var
# scratch
.dockerenv
app
dev
etc
proc
sys

scratch에 남은 dev, proc, sys, .dockerenv는 이미지 내용이 아니라 도커가 런타임에 마운트한 것이다. 실제 내용은 /app/etc/ssl/certs 둘뿐이다.

둘 다 셸이 없다.

docker run --rm --entrypoint sh p12/app:distroless -c "echo hi"
docker: Error response from daemon: failed to create task for container: failed to create shim task: OCI runtime create failed: runc create failed: unable to start container process: error during container init: exec: "sh": executable file not found in $PATH

docker exec으로 들어가서 디버깅할 수 없다는 뜻이기도 하다. 그것이 보안상의 장점이자 운영상의 불편이다. 필요하면 임시로 디버그용 이미지를 붙이거나, 쿠버네티스라면 kubectl debug의 임시 컨테이너를 쓴다.

scratchdistroless/staticalpine
이 예제 이미지 크기8.96MB14.8MB~20MB
CA 인증서직접 복사있음있음
tzdata직접 복사있음직접 설치
/etc/passwd없음있음있음
없음없음있음
libc(정적이므로 불필요)(정적이므로 불필요)musl

기본값으로는 distroless를 권한다. 6MB를 아끼려고 CA 인증서와 tzdata를 직접 관리하는 것은 남는 장사가 아니다. time.LoadLocation("Asia/Seoul")이 scratch에서 실패하는 것을 배포 후에 발견하는 일이 흔하다(Go 1.15부터는 import _ "time/tzdata"로 tzdata를 바이너리에 넣을 수 있다).

alpine을 쓴다면 musl libc 때문에 cgo가 필요한 코드에서 예상 못 한 문제가 생길 수 있다는 점을 기억해 둔다. CGO_ENABLED=0이면 애초에 libc를 안 쓰므로 상관없다.

컨테이너 인지 GOMAXPROCS — 직접 확인

여기가 이 챕터에서 가장 중요한 부분이다.

오랫동안 Go 서비스를 컨테이너에 올릴 때의 정석은 uber-go/automaxprocs를 넣는 것이었다. Go 런타임이 GOMAXPROCS를 호스트의 CPU 개수로 잡는데, cgroup CPU 제한은 그것과 무관해서 "16코어 머신에 0.5코어 할당된 파드"에서 GOMAXPROCS=16이 되는 문제가 있었다. 스케줄러가 16개의 P를 만들어 놓고 실제로는 0.5코어를 나눠 쓰니 컨텍스트 스위치만 늘었다.

Go 1.25부터 런타임이 cgroup CPU 제한을 직접 읽는다. 확인해 본다.

examples/12-production/07-docker/main.go
// 컨테이너 안에서 실제로 어떤 값이 잡히는지가 이 예제의 핵심이다.
log.Info("시작",
"gomaxprocs", runtime.GOMAXPROCS(0),
"numcpu", runtime.NumCPU(),
"uid", os.Getuid(),
"gid", os.Getgid(),
"goos", runtime.GOOS,
"goarch", runtime.GOARCH,
)

이 VM은 4코어다. CPU 제한을 바꿔 가며 /whoami를 불러 봤다.

docker run --rm -d --cpus=2 -p 18080:8080 p12/app:distroless
# 핸들러는 들여쓴 JSON을 주므로, 아래 표시는 jq로 한 줄로 줄인 것이다.
curl -s localhost:18080/whoami | jq -c .
=== no cpu limit ===
{"gid":65532,"gomaxprocs":4,"goversion":"go1.26.5","numcpu":4,"platform":"linux/arm64","uid":65532}
=== --cpus=1.5 ===
{"gid":65532,"gomaxprocs":2,"goversion":"go1.26.5","numcpu":4,"platform":"linux/arm64","uid":65532}
=== GOMAXPROCS env overrides ===
{"gid":65532,"gomaxprocs":8,"goversion":"go1.26.5","numcpu":4,"platform":"linux/arm64","uid":65532}

읽을 것이 셋이다.

  1. 제한이 없으면 GOMAXPROCS = NumCPU = 4. 예전과 같다.
  2. --cpus=1.5GOMAXPROCS=2, NumCPU는 여전히 4. 런타임이 cgroup을 읽어 올림한 값을 썼다. NumCPU()는 물리적 개수를 그대로 보고하므로 둘이 갈린다. NumCPU()로 워커 풀 크기를 정하는 코드는 여전히 틀린다는 뜻이다 — runtime.GOMAXPROCS(0)을 써야 한다.
  3. GOMAXPROCS 환경 변수는 여전히 이긴다. --cpus=2인데 GOMAXPROCS=8을 주면 8이 된다. 자동 감지를 끄고 싶을 때 쓰는 탈출구다.

:::danger automaxprocs는 이제 넣지 않는다 Go 1.25 이전에 쓰인 거의 모든 블로그와 사내 템플릿이 _ "go.uber.org/automaxprocs"를 넣으라고 한다. 지금은 불필요하다. 게다가 그 라이브러리는 내림(floor)을 쓰고 런타임은 올림(ceil)을 쓰는 등 세부가 다르며, 런타임의 주기적 재감지(제한이 실행 중에 바뀌면 따라간다)를 무력화한다.

자동 동작을 끄려면 GOMAXPROCS 환경 변수를 주거나 GODEBUG=containermaxprocs=0,updatemaxprocs=0을 쓴다. 코드에서 다시 켜려면 runtime.SetDefaultGOMAXPROCS()다. :::

12-4에서 본 /sched/gomaxprocs:threads/sched/goroutines/runnable:goroutines를 함께 지표로 내보내면, 컨테이너의 CPU 제한이 실제로 적절한지 그래프로 판단할 수 있다.

비루트로 실행

examples/12-production/07-docker/Dockerfile
USER 65532:65532

/whoami 출력의 "uid":65532가 그 결과다. root가 아니다.

숫자로 쓴 것이 의도다. USER nonroot처럼 이름을 쓰면 런타임이 /etc/passwd를 찾아 해석해야 하는데, scratch에는 그 파일이 없다. 그리고 쿠버네티스의 runAsNonRoot: true는 이미지의 USER가 숫자여야 검증할 수 있다 — 이름이면 "루트가 아닌지 확인할 수 없다"며 파드를 띄우지 않는다.

비루트로 실행하면 딸려 오는 제약이 하나 있다. 1024 미만 포트에 바인드할 수 없다. 컨테이너 안에서는 8080을 쓰고, 외부 포트 매핑으로 80을 노출하면 된다.

셸 없는 이미지의 HEALTHCHECK

HEALTHCHECK CMD curl -f http://localhost:8080/healthz는 distroless에서 동작하지 않는다. curl도 셸도 없기 때문이다. 해법은 바이너리 자신에게 헬스체크 모드를 넣는 것이다.

examples/12-production/07-docker/main.go
// distroless와 scratch에는 셸도 curl도 없다. 그래서 도커의
// HEALTHCHECK가 실행할 것이 바이너리 자신밖에 없다. 같은 바이너리에
// 헬스체크 모드를 넣어 두는 것이 표준적인 해법이다.
//
// HEALTHCHECK CMD ["/app", "-healthcheck"]
if len(os.Args) > 1 && os.Args[1] == "-healthcheck" {
os.Exit(healthcheck("http://127.0.0.1:8080/healthz"))
}
examples/12-production/07-docker/main.go
// healthcheck는 자기 자신에게 요청을 보내고 종료 코드를 정한다.
//
// 0이면 건강, 1이면 아니다. 도커와 쿠버네티스 모두 종료 코드로 판단한다.
func healthcheck(url string) int {
client := &http.Client{Timeout: 2 * time.Second}
resp, err := client.Get(url)
if err != nil {
fmt.Fprintln(os.Stderr, "healthcheck:", err)
return 1
}
examples/12-production/07-docker/Dockerfile
# 셸이 없으므로 HEALTHCHECK는 바이너리 자신을 부른다.
# CMD를 문자열로 쓰면 /bin/sh -c로 감싸려 하다 실패한다.
# 반드시 exec 형식(JSON 배열)이어야 한다.
HEALTHCHECK --interval=10s --timeout=3s --start-period=3s --retries=3 \
CMD ["/app", "-healthcheck"]

실제로 컨테이너를 띄우고 14초 뒤 상태를 확인했다.

docker inspect --format '{{.State.Health.Status}} / 로그: {{range .State.Health.Log}}exit={{.ExitCode}} {{end}}' p12h
healthy / 로그: exit=0

종료 코드가 계약이라는 것을 테스트로 못 박아 둔다.

examples/12-production/07-docker/main_test.go
func TestHealthcheckReturns1WhenNothingListens(t *testing.T) {
// 아무도 듣고 있지 않은 포트로 보내면 실패해야 한다.
// HEALTHCHECK가 종료 코드로 판단하므로 이 값이 계약이다.
if got := healthcheck("http://127.0.0.1:1/healthz"); got != 1 {
t.Fatalf("종료 코드 %d, 1을 기대", got)
}
}

:::note 쿠버네티스에서는 HEALTHCHECK를 쓰지 않는다 쿠버네티스는 Dockerfile의 HEALTHCHECK를 무시하고 livenessProbe/readinessProbe를 쓴다. 그 프로브는 kubelet이 컨테이너 밖에서 HTTP 요청을 보내므로 셸 문제가 없다. 그래도 HEALTHCHECK를 넣어 두면 로컬 docker compose나 단독 실행에서 값을 한다. 헬스와 레디니스의 차이는 12-8에서 다룬다. :::

신호 처리 — ENTRYPOINT를 exec 형식으로

docker stop은 PID 1에 SIGTERM을 보내고, 15초(기본값) 안에 끝나지 않으면 SIGKILL한다. Go 프로그램이 그 신호를 받으려면 PID 1이어야 한다.

CMD ./app # 셸 형식 — /bin/sh -c "./app"이 되어 셸이 PID 1
ENTRYPOINT ["/app"] # exec 형식 — 앱이 PID 1

셸 형식이면 sh가 PID 1이 되고, shSIGTERM을 자식에게 전달하지 않는다. 그러면 graceful shutdown 코드가 있어도 절대 실행되지 않고 15초 뒤 SIGKILL로 죽는다. 처리 중이던 요청은 전부 끊긴다.

예제는 exec 형식이라 docker stop이 즉시 끝난다(측정: 0초). 종료 처리 자체는 12-8에서 자세히 다룬다.

흔한 실수

COPY . .을 먼저 한다. 캐시가 무의미해진다.

.dockerignore가 없다. .git이 컨텍스트로 들어가고 이미지에 섞인다.

CMD ./app처럼 셸 형식을 쓴다. 신호가 전달되지 않는다.

latest 태그를 베이스로 쓴다. FROM golang:latest는 어제와 오늘의 빌드가 다른 Go 버전을 쓴다는 뜻이다. 12-6의 재현 가능한 빌드가 깨진다. 다이제스트로 고정(FROM golang:1.26.5-alpine@sha256:...)하면 더 확실하다.

루트로 실행한다. USER를 안 쓰면 기본이 root다.

automaxprocs를 넣는다. 이제 불필요하다.

NumCPU()로 워커 수를 정한다. 컨테이너에서 틀린 값이다. GOMAXPROCS(0)을 쓴다.

scratch를 쓰면서 CA 인증서를 안 넣는다. 외부 HTTPS 요청이 x509: certificate signed by unknown authority로 실패한다.

docker build에 비밀을 --build-arg로 넘긴다. ARG 값은 이미지 히스토리에 남는다. docker history로 볼 수 있다. 비밀이 필요하면 BuildKit의 --mount=type=secret을 쓴다. 예제의 VERSION, COMMIT처럼 비밀이 아닌 값에는 ARG가 맞다.

정리

  • 멀티스테이지 빌드. 툴체인과 소스는 빌드 스테이지에 두고 바이너리만 꺼낸다.
  • COPY go.mod go.sumgo mod downloadCOPY . . 순서. 캐시가 살아난다.
  • CGO_ENABLED=0으로 정적 링크. distroless/static과 scratch의 전제 조건이다.
  • 기본은 distroless. scratch는 6MB 더 작지만 CA 인증서와 tzdata를 직접 챙겨야 한다.
  • USER는 숫자로. 쿠버네티스 runAsNonRoot가 검증할 수 있고 scratch에서도 동작한다.
  • ENTRYPOINT는 exec 형식. 신호가 전달되어야 graceful shutdown이 돈다.
  • 셸 없는 이미지의 HEALTHCHECK는 바이너리 자신을 부른다.
  • 컨테이너 CPU 제한은 런타임이 알아서 읽는다. --cpus=1.5GOMAXPROCS=2를 직접 확인했다. automaxprocs는 넣지 않는다.
  • NumCPU()가 아니라 GOMAXPROCS(0). 컨테이너에서 둘은 다르다.

연습문제

  1. 12-4의 pprof 서버를 컨테이너에 넣어 보자. 관리 포트(6060)를 EXPOSE하지 않고 docker run에서도 매핑하지 않으면, 같은 도커 네트워크의 다른 컨테이너에서는 접근할 수 있는가? EXPOSE가 실제로 하는 일이 무엇인지 확인해 보자.

  2. --cpus=0.5로 컨테이너를 띄우면 GOMAXPROCS가 얼마가 되는가? 예측한 뒤 확인해 보자. 그리고 --cpuset-cpus=0,1은 어떤가? 두 방식의 차이가 런타임에 어떻게 보이는지 생각해 보자.

  3. 이미지를 두 번 빌드해 docker inspect --format '{{.Id}}'가 같은지 확인해 보자. 같지 않다면 무엇이 다른가? 12-6에서 다룬 재현 가능한 빌드를 컨테이너 이미지 수준까지 밀고 가려면 무엇이 더 필요한지 찾아보자. (힌트: SOURCE_DATE_EPOCH, --output type=image,rewrite-timestamp=true)