本节把 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 部署与优雅重启 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。