《Go 语言编程入门》17.2 多阶段 Docker 镜像

把 TaskAPI 打成最小 Docker 镜像:写多阶段 Dockerfile,构建阶段用 golang:1.27-alpine 编译静态二进制、运行阶段用 alpine 只装产物,配 .dockerignore 与层缓存,用非 root 用户运行,并实测镜像体积、容器日志与端点响应。

本节把 TaskAPI 推进到「可分发」:用多阶段 Dockerfile 把 17.1 编译出的静态二进制装进一个约 21 MB 的最小镜像,以非 root 用户运行,并在本机实跑构建、启动容器、访问端点全流程。
适用版本:Go 1.27(实测 go1.27.0),Docker Engine 29.5.2(本机实测可用)。

17.2 多阶段 Docker 镜像

17.1 产出的静态二进制是「能跑」,但一个裸二进制还不算「可交付」:服务器要有基础系统、要有非 root 运行的用户、要有一致的启动方式。Docker 镜像把这些一次性打包好。本节写一个多阶段 Dockerfile,并用本机 Docker 实测。

实测声明:本节 Dockerfile 在本机 Docker Engine 29.5.2(linux/arm64)上真实构建并运行过,镜像体积、容器日志、端点响应均为实测输出。

17.2.1 单阶段镜像的问题

最直观的写法是把源码拷进 golang 镜像、在容器里 go build、直接跑。问题是:golang:1.27-alpine 基础镜像就有几百 MB,把编译器、Go 源码、模块缓存全带进了最终镜像。一个几 MB 的二进制,配一个 800 MB 的镜像,纯属浪费。

多阶段构建的核心思想:用一个大镜像编译,把产物拷到一个极小的运行镜像里,编译器留在构建阶段被丢弃。

17.2.2 两阶段 Dockerfile

# ---- 构建阶段 ----
FROM golang:1.27-alpine AS build
WORKDIR /src
# 先拷贝依赖清单,利用层缓存
COPY go.mod ./
RUN go mod download
COPY . .
ARG VERSION=dev
RUN CGO_ENABLED=0 go build -trimpath \
    -ldflags "-s -w -X main.version=${VERSION}" \
    -o /out/taskapi .

# ---- 运行阶段 ----
FROM alpine:3.21
RUN adduser -D -u 10001 app
USER app
WORKDIR /app
COPY --from=build /out/taskapi /app/taskapi
EXPOSE 8080
ENTRYPOINT ["/app/taskapi"]

几个要点:

  • AS build 给第一阶段命名,COPY --from=build 从它取产物。
  • COPY go.mod 在前:只要 go.mod 没变,go mod download 这层就被缓存,改源码不会重下依赖。
  • CGO_ENABLED=0:和第 17.1 节一致,产出静态二进制,才能在 alpine 上跑。
  • ARG VERSION 把构建参数透传给 -ldflags,实现构建期注入版本号。
  • ENTRYPOINT 用 exec 形式(JSON 数组),这样 Go 进程能直接收到 SIGTERM——第 12 章的优雅关闭才有效。

17.2.3 为什么运行阶段选 alpine 而不是 scratch

scratch 是空镜像(0 字节),理论上最省。但它没有任何文件,ENTRYPOINT 无法用 shell,排查问题时连 sh 都没有,也没有 CA 证书。alpine:3.21 只有约 8 MB,带 shell、带证书,是静态 Go 服务的甜点区:

运行基础镜像体积取舍
scratch~0最省,但无 shell、无证书、调试困难
alpine~8 MB带 shell 与证书,推荐默认
debian-slim~80 MB需要 glibc 时用
golang(单阶段)~800 MB只适合本地临时调试

TaskAPI 后续要访问 HTTPS 依赖,需要 CA 证书,所以 alpine 是更稳妥的选择。

17.2.4 用 .dockerignore 缩小上下文

docker build 会先把「构建上下文」整个发给 Docker daemon。如果上下文里混着 .git、本地编译出的二进制、测试文件,构建会变慢。用 .dockerignore 排除:

.git
*.md
taskapi
dist

这既提速,也避免把本机的 darwin 二进制误拷进镜像(它根本不能在 Linux 里跑)。

17.2.5 实测:构建镜像

本机执行构建(--build-arg 传入版本号):

docker build --build-arg VERSION=0.1.0 -t taskapi:0.1.0 .
Step 12/14 : COPY --from=build /out/taskapi /app/taskapi
 ---> 246b2cc888cf
Step 13/14 : EXPOSE 8080
 ---> Running in 4628d88031a5
Step 14/14 : ENTRYPOINT ["/app/taskapi"]
 ---> Running in 1c593a59c0b5
 ---> c7a540738515
Successfully built c7a540738515
Successfully tagged taskapi:0.1.0

看镜像体积:

$ docker images taskapi:0.1.0 --format '{{.Repository}}:{{.Tag}} {{.Size}}'
taskapi:0.1.0 21.5MB

21.5 MB——包含约 8 MB 的 alpine 底座、约 6 MB 的静态二进制,以及 alpine 的运行时基础件。对比单阶段方案的 800 MB,少了 97%。

17.2.6 实测:运行容器

docker run -d --name taskapi-test -p 18080:8080 -e PORT=8080 taskapi:0.1.0
$ docker ps --filter name=taskapi-test --format '{{.Names}} {{.Status}} {{.Ports}}'
taskapi-test Up 1 second 0.0.0.0:18080->8080/tcp, [::]:18080->8080/tcp
$ curl -s http://127.0.0.1:18080/healthz
ok
$ curl -s http://127.0.0.1:18080/tasks
[{"id":1,"title":"写 Dockerfile","done":true}]

容器日志里是第 16 章那个结构化日志(这里用了文本格式的简化版):

$ docker logs taskapi-test
2026/10/09 23:36:24 taskapi listening on :8080

确认它以非 root 运行——这是镜像安全的关键:

$ docker exec taskapi-test id
uid=10001(app) gid=10001(app) groups=10001(app)

uid=10001 而不是 0,说明 USER app 生效了。万一容器被攻破,攻击者拿到的也只是无特权用户。

17.2.7 在 Dockerfile 里加健康检查

Docker 自己也能做健康检查,靠 HEALTHCHECK 指令定期调用第 16 章的 /healthz:

HEALTHCHECK --interval=10s --timeout=2s --retries=3 \
  CMD wget -qO- http://127.0.0.1:8080/healthz || exit 1

wget 在 alpine 里自带(busybox 提供),所以不用额外装 curl。加了它之后 docker ps 的 STATUS 列会显示 (healthy) 或 (unhealthy),docker inspect 也能读到。注意这跟 Kubernetes 探针是两套机制:Docker 的 HEALTHCHECK 只在 docker run 场景生效,K8s 下应改用 livenessProbe。

17.2.8 用 compose 编排本地环境

本地起 TaskAPI 加一个依赖(比如 PostgreSQL)时,写 docker-compose.yml 比敲一长串 docker run 清楚:

services:
  taskapi:
    build:
      context: .
      args: { VERSION: "0.1.0" }
    ports: ["8080:8080"]
    environment:
      PORT: "8080"
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8080/healthz"]
      interval: 10s
    depends_on:
      db:
        condition: service_healthy
  db:
    image: postgres:17-alpine
    environment:
      POSTGRES_PASSWORD: example
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s

depends_on 配 condition: service_healthy 保证 TaskAPI 在数据库真正就绪后才启动——这正好呼应第 16.3 节 readiness 的思想。

17.2.9 资源限制与镜像扫描

容器默认能用光宿主机资源。生产上要设上限:

docker run -d --name taskapi \
  --memory=128m --cpus=1 \
  --read-only --tmpfs /tmp \
  taskapi:0.1.0

--read-only 把根文件系统挂成只读,--tmpfs /tmp 给需要临时写的路径一个内存盘——Go 服务大多不需要写盘,这条能显著缩小攻击面。上生产前还应扫一遍已知漏洞:

docker scan taskapi:0.1.0

或等价的 trivy image taskapi:0.1.0。基础镜像越精简,能报出来的漏洞越少,这也是选 alpine 而非 golang 的又一理由。

17.2.10 多平台镜像:buildx

服务器若是 x86_64,而你在 Apple Silicon 上构建,默认会得到 arm64 镜像,跑不起来。用 buildx 一次产出多架构:

docker buildx build --platform linux/amd64,linux/arm64 \
  -t taskapi:0.1.0 --push .

--push 是必需的,因为多平台清单无法直接 load 进本地 daemon。若只是本地测试单平台,指定目标架构即可:

docker buildx build --platform linux/amd64 -t taskapi:0.1.0 --load .

17.2.11 运行时配置:端口与信号

  • EXPOSE 8080 只是文档,真正映射靠 docker run -p 或编排配置。
  • PORT 环境变量:TaskAPI 的监听端口从环境变量读(第 15 章配置加载),所以 -e PORT=8080 能覆盖。
  • 信号:ENTRYPOINT 用 exec 形式,docker stop 发的 SIGTERM 会直达进程,触发第 12 章的优雅排空;用 shell 形式(ENTRYPOINT /app/taskapi)则会被 /bin/sh 拦截,导致超时后强杀。

17.2.12 常见坑

  • 把 COPY . . 放在 go mod download 之前:任何源码改动都会让依赖层缓存失效,构建从几分钟变成每次全量。
  • 忘了 CGO_ENABLED=0:alpine 用 musl,glibc 动态链接的产物会报 no such file or directory。
  • 以 root 运行:默认就是 root,必须显式 USER。
  • 镜像里混入 darwin 二进制:.dockerignore 排除本地产物,或干脆只拷源码让容器内编译。
  • ENTRYPOINT 用 shell 形式:SIGTERM 收不到,优雅关闭失效。
  • 多平台构建忘了 --push:buildx 会报错说无法 load 多平台清单。

小结

  • 多阶段构建:golang 镜像编译,产物拷进 alpine,编译器不进最终镜像。
  • COPY go.mod 在前利用层缓存;CGO_ENABLED=0 产出静态二进制;ARG VERSION 透传版本号。
  • 运行阶段用非 root 用户,ENTRYPOINT 用 exec 形式保证信号直达。
  • 本机实测:镜像 21.5 MB,容器以 uid 10001 运行,/healthz 与 /tasks 均正常响应。
  • 跨架构用 buildx --platform,多平台清单必须 --push。

镜像有了,但「怎么把它安全地换到服务器上、换的过程中不丢请求」还没解决。下一节写部署脚本与优雅重启:健康门控、信号顺序、零停机切换。

阅读导航:上一节:17.1 交叉编译与静态构建 · 下一节:17.3 部署与优雅重启 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练