Docker 基础体系 · 第 36/70 篇。示例以现代 Docker Engine、BuildKit 与 Compose 规范为基础,并明确 Linux 容器边界。

Go 应用 Docker 镜像:静态链接、CA、时区、非 root 和优雅退出

Go 程序通常可以编译成一个独立的 Linux ELF 可执行文件,但“只有一个二进制文件”并不等于“运行时不需要任何外部数据”。HTTPS 依赖 CA 证书,时区转换依赖时区数据库,用户身份依赖 /etc/passwd,进程是否能够优雅退出则取决于 PID 1、信号转发和停止超时。

因此,一个可用于生产的 Go 运行时镜像至少要同时回答五个问题:

  1. 二进制是否真的不依赖动态链接器和共享库?
  2. 容器内的 HTTPS 客户端依据什么验证服务器证书?
  3. 时区名称和夏令时规则从哪里读取?
  4. 进程以什么用户运行,文件权限是否仍然正确?
  5. Docker 停止容器时,应用是否能收到信号并完成请求排空?

下文以 Linux 容器、现代 Docker Engine、BuildKit 和 Compose 规范为边界说明。


一、先区分构建环境和运行环境

多阶段构建的核心不是“把 Dockerfile 写成多个 FROM”,而是将编译工具链与运行时文件系统分离。

编译阶段可能需要:

  • Go 编译器;
  • Go module 缓存;
  • Git 或其他依赖获取工具;
  • C 编译器和 libc;
  • 调试符号或测试工具。

运行阶段通常只需要:

  • Go 生成的可执行文件;
  • CA 证书;
  • 时区数据库;
  • 用户和组信息;
  • 应用需要写入的目录。

一个基本结构如下:

# syntax=docker/dockerfile:1

FROM golang:1.24-bookworm AS build

WORKDIR /src

# 明确安装运行时可能需要复制的系统数据。
RUN apt-get update \
    && apt-get install -y --no-install-recommends ca-certificates tzdata \
    && rm -rf /var/lib/apt/lists/*

# 先复制依赖描述文件,使依赖下载层可以复用。
COPY go.mod go.sum ./
RUN go mod download

COPY . .

RUN CGO_ENABLED=0 \
    GOOS=linux \
    go build \
      -trimpath \
      -ldflags="-s -w" \
      -o /out/app \
      ./cmd/server

# 为 scratch 镜像准备一个普通用户记录。
RUN printf '%s\n' \
    'appuser:x:65532:65532:Application User:/nonexistent:/sbin/nologin' \
    > /out/passwd

这里的 golang:1.24-bookworm 只是示例标签。生产构建应固定到经过验证的具体版本,必要时进一步固定镜像 digest。标签会随着仓库更新而改变,而 digest 标识的是具体镜像内容。

go mod download 放在复制源代码之前,是为了让 Docker 在源代码变化但依赖未变化时复用依赖层。它不能替代依赖版本固定:真正的依赖边界仍由 go.modgo.sum 决定。

.dockerignore 也属于构建输入控制的一部分:

.git
.gitignore
Dockerfile*
docker-compose*.yml
tmp
dist
coverage
*.log

构建上下文会发送给 Docker。上下文过大不仅拖慢构建,还可能把本地密钥、构建产物或测试数据意外带入构建过程。


二、静态链接到底解决了什么问题

1. 动态链接的运行条件

一个动态链接的 ELF 程序通常需要:

  1. ELF 动态加载器,例如 /lib64/ld-linux-x86-64.so.2
  2. 一个或多个共享库,例如 libc.so.6
  3. 某些库依赖的配置和名称解析数据。

如果把这样的程序复制到 scratch,启动时可能得到:

exec /app: no such file or directory

这个错误并不一定表示 /app 不存在。常见原因是 ELF 头部声明的解释器,也就是动态加载器,在镜像中不存在。

2. Go 的纯 Go 静态构建

当应用及其依赖都能使用纯 Go 实现时,可以这样构建:

CGO_ENABLED=0 GOOS=linux go build -o app ./cmd/server

这些变量的含义是:

  • CGO_ENABLED=0:禁用 cgo;
  • GOOS=linux:目标操作系统为 Linux;
  • go build:由 Go 工具链生成目标程序。

对于纯 Go 代码,禁用 cgo 通常会让网络解析、用户查询等功能选择 Go 实现,从而不再依赖 libc 动态库。-trimpath 会去除构建路径,减少构建环境路径对产物的影响;-ldflags="-s -w" 会去掉符号表和调试信息,通常可以减小体积,但也会降低现场调试能力。

静态链接的判断应以检查结果为准,而不是以 Dockerfile 中的参数为准:

file app
ldd app

典型的静态程序可能显示:

app: ELF 64-bit LSB executable, x86-64, statically linked

而动态程序可能显示:

app: ELF 64-bit LSB pie executable, x86-64, dynamically linked

对静态程序执行 ldd 时,系统可能显示 not a dynamic executable。不同发行版的 file 输出格式会略有差异,关键是确认它没有动态加载器依赖。

3. CGO_ENABLED=0 不是无条件成立

以下情况可能要求 cgo:

  • 依赖使用 C 库;
  • 数据库驱动依赖原生客户端;
  • 图像、压缩或加密库调用 C 实现;
  • 应用明确使用 cgo API。

此时强行设置 CGO_ENABLED=0 可能导致编译失败,也可能使某些功能切换到不同实现。可以检查依赖:

go list -deps -f '{{if .CgoFiles}}{{.ImportPath}}: {{.CgoFiles}}{{end}}' ./...

若必须使用 cgo,有两条路线:

  • 使用包含 libc 和动态加载器的运行时镜像,例如 Debian、Ubuntu 或 Alpine;
  • 使用经过验证的真正静态 cgo 工具链。

仅仅添加:

-ldflags "-extldflags -static"

并不能保证所有 cgo 依赖都能正确静态链接。尤其是 libc、名称服务模块和第三方 C 库,可能还需要静态 .a 文件、正确的链接顺序以及相应运行时数据。

4. 静态不等于“没有外部依赖”

即使 ELF 完全静态,程序仍可能读取:

  • /etc/resolv.conf:DNS 配置;
  • /etc/hosts:本地名称映射;
  • /etc/ssl/certs/ca-certificates.crt:系统 CA;
  • /usr/share/zoneinfo:时区规则;
  • /etc/passwd/etc/group:用户查询;
  • 应用自己的配置文件或密钥。

静态链接只解决“机器码如何装载”的问题,不会把所有系统数据编译进二进制。


三、为什么 scratch 中仍然需要 CA 证书

1. CA 证书验证的是谁

当 Go 程序作为 HTTPS 客户端访问服务器时,TLS 握手大致包含以下过程:

  1. 服务端发送证书链;
  2. 客户端验证证书签名是否能追溯到受信任根 CA;
  3. 客户端验证证书的有效期;
  4. 客户端验证请求主机名是否匹配证书;
  5. 验证通过后才建立加密连接。

CA 证书是客户端信任的根或中间证书集合。它不是服务端私钥,也不是服务端证书。

因此:

  • 调用第三方 HTTPS API 的 Go 客户端需要 CA;
  • 对外提供 HTTPS 的 Go 服务端需要自己的证书和私钥;
  • 服务端是否携带 CA,取决于它是否还要验证客户端证书,例如双向 TLS。

scratch 没有系统文件,因此下面代码在某些环境中可能因证书池为空而失败:

resp, err := http.Get("https://example.com")

失败表现通常类似:

x509: certificate signed by unknown authority

2. 将 CA 证书复制到运行时镜像

前面的构建阶段安装了 Debian 的 ca-certificates,其常见证书束路径是:

/etc/ssl/certs/ca-certificates.crt

把它复制进最终镜像:

FROM scratch

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

USER 65532:65532
ENTRYPOINT ["/app"]

路径是常见实现,不是 Go 语言保证的唯一固定路径。Go 的 crypto/x509 会根据目标操作系统使用系统证书池;不同基础镜像可能使用不同路径。因此,换用 Alpine、Distroless 或自定义镜像后,应在目标环境验证,而不能只凭 Debian 路径推断。

3. 私有 CA 不能靠“关闭验证”解决

企业内网常使用私有 CA。正确做法是把明确的私有根证书加入镜像或通过受控配置注入,并让 Go 使用包含它的证书池。

不应在生产代码中使用:

http.DefaultTransport.(*http.Transport).TLSClientConfig =
    &tls.Config{InsecureSkipVerify: true}

InsecureSkipVerify 会关闭服务端证书链和主机名验证。连接仍然可能加密,但客户端失去了“我连接的是谁”的身份保证。它适合受控测试,不是修复 CA 缺失的办法。

4. CA 的更新是运行时供应链问题

CA 证书不会因为 Go 二进制是静态的而自动更新。若证书包修复或根证书轮换,必须重新构建并发布镜像。生产构建应记录:

  • 构建时使用的基础镜像版本;
  • CA 包版本;
  • 镜像 digest;
  • 证书轮换后的回归测试结果。

可以在调试阶段用一个带工具的临时镜像验证 HTTPS,而不要为了诊断永久给生产镜像添加 curl、shell 等工具。


四、时区:UTC、TZ 和 IANA 时区数据库

1. 时间点和时区规则不是一回事

一个时间点本质上是 UTC 时间轴上的瞬时值,例如:

2025-01-01T00:00:00Z

“上海时间”“纽约时间”则是把时间点格式化为当地民用时间所需的规则。规则包括:

  • UTC 偏移;
  • 夏令时切换;
  • 历史变更;
  • 特定日期的过渡边界。

因此固定写死:

time.FixedZone("Asia/Shanghai", 8*60*60)

只能表达当前固定偏移,不能表达所有带夏令时或历史变更的地区。正确方式通常是加载 IANA 时区名称:

loc, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
    return err
}

now := time.Now().In(loc)
fmt.Println(now.Format(time.RFC3339))

2. scratch 没有时区数据库

time.LoadLocation("Asia/Shanghai") 需要找到对应的 zoneinfo 数据。如果运行镜像是 scratch,而没有复制 /usr/share/zoneinfo,可能得到:

unknown time zone Asia/Shanghai

可以只复制应用需要的时区文件:

COPY --from=build /usr/share/zoneinfo/Asia/Shanghai \
                  /usr/share/zoneinfo/Asia/Shanghai

这样比复制整个时区数据库更小,但应用以后若需要 Europe/Berlin,就必须同时复制那个文件。另一种做法是把完整 /usr/share/zoneinfo 复制进去,维护简单但镜像更大。

也可以在编译时使用 Go 的 time/tzdata

import _ "time/tzdata"

该方式把时区数据库嵌入二进制,减少对运行时文件的依赖,但会增大二进制,并且时区规则的更新要通过重新编译应用完成。无论采用文件还是嵌入方式,都应固定并验证 tzdata 版本。

3. TZ 不能替代时区数据

可以设置:

ENV TZ=Asia/Shanghai

TZ 只是告诉程序默认使用哪个时区名称或规则。它不会自动把 Asia/Shanghai 的规则文件放进 scratch

此外,time.Now() 返回的时间值包含一个 Location,但数据库中的时间戳通常应以 UTC 或明确偏移保存。更稳妥的约定是:

  • 数据库存储 UTC;
  • 日志统一输出 UTC,或者明确输出偏移;
  • 面向用户展示时再转换到用户时区;
  • 不把容器宿主机时区当成业务时区来源。

五、非 root 运行:身份、权限和文件系统

1. USER 改变的是内核身份

在 Dockerfile 中:

USER 65532:65532

表示应用进程以 UID 65532、GID 65532 启动。它不依赖运行时镜像是否包含 /etc/passwd

使用非 root 用户可以降低一类风险:如果应用被利用,攻击者首先获得的是受限 UID,而不是容器内的 root 身份。但这不是完整的安全边界,容器权限、Linux capabilities、挂载目录和宿主机配置仍然重要。

2. 数字 UID 和用户名的区别

数字 UID 足以让内核执行权限检查,但程序可能调用:

user.Current()

或日志、审计库需要把 UID 转换成用户名。如果 scratch 中没有 /etc/passwd,纯 Go 实现可能无法返回完整用户信息。

前面的构建阶段生成了:

appuser:x:65532:65532:Application User:/nonexistent:/sbin/nologin

再复制进去:

COPY --from=build /out/passwd /etc/passwd

这只提供名称映射,不赋予该用户额外权限。生产环境中也可以从一个明确来源复制固定的 /etc/passwd,但不应无意中把构建镜像中的全部用户和敏感账户信息带入运行时。

3. scratch 默认几乎没有可写路径

scratch 没有 shell、临时目录和预置用户目录。应用如果需要写文件,必须显式创建可写目录:

RUN mkdir -p /var/lib/app

scratch 没有 RUN 所需的 shell。可以在构建阶段创建目录,再复制进去:

RUN mkdir -p /out/rootfs/var/lib/app \
    && chown 65532:65532 /out/rootfs/var/lib/app

然后:

COPY --from=build /out/rootfs/ /

不过很多服务应把状态数据放到外部数据库、对象存储或显式挂载卷中,而不是依赖容器可写层。若挂载卷由宿主机或编排系统以 root 创建,非 root 应用可能遇到:

open /data/file: permission denied

这不是 Go 的问题,而是挂载点的 UID/GID 与进程 UID 不匹配。应在卷初始化阶段设置正确属主,或使用编排平台提供的权限机制。

4. 非 root 会暴露原本隐藏的错误

下面这类应用可能在 root 下“恰好能运行”,切换后失败:

  • 监听 1~1023 端口;
  • 写入 //root
  • 修改系统时区;
  • 创建设备或修改内核参数;
  • 读取权限过宽的宿主机挂载文件。

通常应让应用监听容器内的高端口,例如 8080,由反向代理或端口映射暴露为外部端口。Docker 的端口映射不要求进程必须监听特权端口。


六、一个完整的 scratch 运行时镜像

假设项目结构如下:

.
├── Dockerfile
├── go.mod
├── go.sum
└── cmd
    └── server
        └── main.go

完整 Dockerfile 可以写成:

# syntax=docker/dockerfile:1

FROM golang:1.24-bookworm AS build

WORKDIR /src

RUN apt-get update \
    && apt-get install -y --no-install-recommends ca-certificates tzdata \
    && rm -rf /var/lib/apt/lists/*

COPY go.mod go.sum ./
RUN go mod download

COPY . .

RUN CGO_ENABLED=0 \
    GOOS=linux \
    go build \
      -trimpath \
      -ldflags="-s -w" \
      -o /out/app \
      ./cmd/server

RUN printf '%s\n' \
    'appuser:x:65532:65532:Application User:/nonexistent:/sbin/nologin' \
    > /out/passwd

FROM scratch

COPY --from=build /out/app /app
COPY --from=build /out/passwd /etc/passwd
COPY --from=build /etc/ssl/certs/ca-certificates.crt \
                  /etc/ssl/certs/ca-certificates.crt
COPY --from=build /usr/share/zoneinfo/Asia/Shanghai \
                  /usr/share/zoneinfo/Asia/Shanghai

ENV TZ=Asia/Shanghai

USER 65532:65532
EXPOSE 8080
STOPSIGNAL SIGTERM
ENTRYPOINT ["/app"]

几个细节需要特别注意:

  • ENTRYPOINT ["/app"] 是 exec form,应用直接成为容器主进程;
  • STOPSIGNAL SIGTERM 明确声明优雅退出使用的信号,虽然 Docker 的默认停止信号通常也是 SIGTERM
  • USER 放在运行时阶段,避免构建过程因权限不足而无法安装依赖;
  • EXPOSE 只是镜像元数据,不会实际发布端口;
  • scratch 没有 shell,因此不能使用依赖 /bin/sh 的启动命令;
  • 复制单个时区文件后,只能保证这个时区名称可用。

构建并运行:

docker build -t example-go:dev .
docker run --rm -p 8080:8080 example-go:dev

若应用监听 0.0.0.0:8080,宿主机可访问:

curl http://127.0.0.1:8080/healthz

若应用只监听 127.0.0.1:8080,端口映射存在,但容器外部无法访问,因为服务只绑定了容器内部回环地址。


七、优雅退出的组件和状态变化

1. Docker 停止容器时发生什么

默认情况下,停止流程可以抽象为:

sequenceDiagram
    participant C as docker stop / Compose
    participant D as Docker Engine
    participant P as 容器 PID 1
    participant H as HTTP Handler

    C->>D: 请求停止,设置超时
    D->>P: 发送 SIGTERM
    P->>P: 接收信号并停止接受新请求
    P->>H: 等待已有请求返回
    P-->>D: 进程正常退出
    D-->>C: 容器停止完成

    Note over D,P: 超时后发送 SIGKILL

状态转移是:

运行
  │ SIGTERM
  ▼
停止接收新工作,等待已有工作
  │ 所有工作完成
  ▼
正常退出

运行
  │ SIGTERM
  ▼
等待已有工作
  │ 超时
  ▼
SIGKILL,立即终止

SIGKILL 无法被捕获、延迟或清理。因此优雅退出的目标不是“收到信号后永不退出”,而是在停止超时内完成必要的收尾。

2. PID 1 为什么重要

Docker 将容器配置的入口进程作为容器内 PID 1。PID 1 具有两个相关职责:

  1. 接收 Docker 发送的停止信号;
  2. 在存在子进程时承担孤儿进程回收职责。

如果使用 shell form:

CMD /app

可能实际由 shell 启动应用。shell 是否转发 SIGTERM 取决于具体启动方式,不能假设一定会转发。

如果使用:

ENTRYPOINT ["/app"]

/app 直接成为 PID 1。Go 程序可以直接接收 Docker 发送的信号。

若容器确实需要启动多个子进程,可使用 Docker 的 init 支持:

docker run --init example-go:dev

Compose 中可以写:

services:
  api:
    image: example-go:dev
    init: true

这通常会加入一个轻量 init 进程,帮助转发信号并回收子进程;它不能替代应用自身的 HTTP 连接排空逻辑。

3. Go HTTP 服务的退出实现

下面是一个可运行的最小服务:

package main

import (
	"context"
	"errors"
	"log"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"
)

func main() {
	mux := http.NewServeMux()

	mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusOK)
		_, _ = w.Write([]byte("ok\n"))
	})

	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		time.Sleep(2 * time.Second)
		_, _ = w.Write([]byte("hello\n"))
	})

	server := &http.Server{
		Addr:              ":8080",
		Handler:           mux,
		ReadHeaderTimeout: 5 * time.Second,
	}

	serveErr := make(chan error, 1)
	go func() {
		err := server.ListenAndServe()
		if !errors.Is(err, http.ErrServerClosed) {
			serveErr <- err
			return
		}
		serveErr <- nil
	}()

	signalCtx, stop := signal.NotifyContext(
		context.Background(),
		syscall.SIGTERM,
		syscall.SIGINT,
	)
	defer stop()

	select {
	case <-signalCtx.Done():
		log.Println("shutdown signal received")

		// Shutdown 不能使用已经因信号而取消的 signalCtx。
		// 它需要一个新的、具有独立超时的上下文。
		shutdownCtx, cancel := context.WithTimeout(
			context.Background(),
			10*time.Second,
		)
		defer cancel()

		if err := server.Shutdown(shutdownCtx); err != nil {
			log.Printf("graceful shutdown failed: %v", err)
			os.Exit(1)
		}

		log.Println("server stopped")

	case err := <-serveErr:
		if err != nil {
			log.Printf("server failed: %v", err)
			os.Exit(1)
		}
	}
}

完整的因果链是:

  1. signal.NotifyContext 注册 SIGTERMSIGINT
  2. Docker 发送 SIGTERM
  3. signalCtx.Done() 关闭;
  4. 调用 server.Shutdown
  5. Shutdown 停止监听新连接,并等待已有连接上的处理完成;
  6. 上下文超过 10 秒后返回错误;
  7. 进程退出,若 Docker 的总停止超时更短,则可能在此之前收到 SIGKILL

Shutdown 不会强制终止所有连接。长连接、WebSocket、HTTP hijacking 连接可能不受普通 HTTP 请求排空机制完全覆盖;这类连接需要应用协议自身实现关闭通知和超时。

4. 不要让信号上下文同时承担业务取消和关闭超时

这是一个容易写错的版本:

<-signalCtx.Done()
_ = server.Shutdown(signalCtx)

收到信号后,signalCtx 已经被取消。把它传给 Shutdown 等价于立即给关闭流程一个已取消的上下文,可能导致请求还没有排空,关闭就直接失败。

正确做法是:

shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = server.Shutdown(shutdownCtx)

信号上下文负责表达“应该开始退出”,独立的关闭上下文负责表达“最多允许退出多久”。


八、停止超时必须覆盖完整关闭路径

应用自己的关闭超时、Docker Engine 的停止超时和 Compose 的停止宽限期必须满足一个基本关系:

应用关闭超时 < 容器停止超时

例如应用需要最多 10 秒排空请求,那么 Compose 可以配置为 15 或 20 秒:

services:
  api:
    image: example-go:dev
    ports:
      - "8080:8080"
    init: true
    stop_signal: SIGTERM
    stop_grace_period: 20s

这样预留了一段时间给:

  • 应用执行 Shutdown
  • 日志刷新;
  • 连接关闭;
  • init 进程回收子进程。

如果应用关闭超时是 30 秒,而 Compose 只有 10 秒,那么理论上的 30 秒没有意义:Docker 会在第 10 秒发送 SIGKILL

命令行中可以显式指定停止等待时间:

docker stop -t 20 <container>

诊断时可以观察应用日志:

shutdown signal received
server stopped

如果只看到容器突然消失,没有 shutdown signal received,应优先检查:

  • /app 是否为 PID 1;
  • 是否使用了 shell form;
  • STOPSIGNAL 是否被改成其他信号;
  • 应用是否注册了对应信号;
  • 停止宽限期是否过短。

九、健康检查和优雅退出不是同一机制

HEALTHCHECK 解决的是“当前实例是否可用”,优雅退出解决的是“实例准备停止时如何减少中断”。

两者的状态变化不同:

健康检查:
starting → healthy
starting → unhealthy

停止流程:
running → draining → stopped

应用收到 SIGTERM 后,如果立刻退出,健康检查即使此前一直为 healthy,也无法挽救已有请求。反过来,健康检查失败也不会自动实现 HTTP 连接排空;是否重启、摘除流量由 Docker 或编排系统决定。

healthcheck 的探针命令必须存在于运行时镜像中。scratch 没有 curlwget 和 shell,因此下面配置不能直接用于 scratch

healthcheck:
  test: ["CMD-SHELL", "curl -f http://localhost:8080/healthz || exit 1"]

如果确实需要容器内探针,可以:

  • 在运行时镜像中加入专用探针二进制;
  • 使用包含探针工具的非 scratch 基础镜像;
  • 由编排平台或外部监控系统执行 HTTP 探测。

不要为了一个探针随意加入完整 shell 和大量工具;应明确诊断便利性与攻击面、体积之间的取舍。

生产流量摘除通常还需要一个“排空状态”:

  1. 收到停止信号;
  2. readiness 返回失败或应用通知服务发现系统;
  3. 等待负载均衡器停止发送新请求;
  4. 调用 Shutdown 等待旧请求完成;
  5. 到时强制退出。

只调用 Shutdown 而不改变 readiness,可能仍有新请求在摘除传播完成前进入实例。


十、如何验证镜像,而不是只验证构建成功

1. 验证运行身份

docker run --rm --entrypoint /app example-go:dev

如果应用没有退出命令,这条命令会持续运行。可以通过应用日志、临时调试端点或外部检查确认 UID。生产应用不应为了验证而增加不必要的身份泄露接口。

使用 Docker inspect 可以确认镜像配置:

docker image inspect example-go:dev \
  --format '{{json .Config.User}} {{json .Config.Entrypoint}} {{json .Config.StopSignal}}'

预期应类似:

"65532:65532" ["/app"] "SIGTERM"

2. 验证 CA 和时区

启动容器后请求健康端点:

docker run -d --name go-example -p 8080:8080 example-go:dev
curl -f http://127.0.0.1:8080/healthz
docker rm -f go-example

HTTPS 和时区最好通过应用的真实业务路径测试,例如:

  • 调用一个受公开 CA 信任的 HTTPS API;
  • 调用一个使用私有 CA 的测试服务;
  • 加载 Asia/Shanghai 并验证已知时间点的格式化结果。

不要仅通过 ENV TZ=... 判断时区有效,因为环境变量存在不代表 zoneinfo 文件存在。

3. 验证优雅退出

先启动容器:

docker run -d --name go-example -p 8080:8080 example-go:dev

并发请求一个需要持续 2 秒的接口:

curl http://127.0.0.1:8080/ &
pid=$!
sleep 0.2
docker stop -t 20 go-example
wait "$pid"

如果客户端收到完整的 hello,并且日志出现:

shutdown signal received
server stopped

说明该请求在停止窗口内完成了排空。

这个测试仍不是完整证明。生产中还应测试:

  • 请求执行时间超过关闭超时;
  • 大量并发请求;
  • keep-alive 连接;
  • WebSocket 或 streaming 连接;
  • 下游数据库连接关闭失败;
  • readiness 摘除延迟;
  • SIGTERM 后再次收到 SIGTERM;
  • 应用启动失败和监听端口被占用。

4. 出现问题时使用调试层

scratch 的缺点是没有 shell 和诊断工具。可以保留一个调试目标:

FROM debian:bookworm-slim AS debug
COPY --from=build /out/app /app
COPY --from=build /etc/ssl/certs/ca-certificates.crt \
                  /etc/ssl/certs/ca-certificates.crt
COPY --from=build /usr/share/zoneinfo/Asia/Shanghai \
                  /usr/share/zoneinfo/Asia/Shanghai
USER 65532:65532
ENTRYPOINT ["/app"]

构建调试镜像时,将最终阶段临时改为:

FROM debug

这样可以使用 sh、证书检查工具或网络诊断工具定位问题。调试镜像不应被误推为生产镜像,因为它的文件数量、工具和攻击面都不同。


十一、常见错误及其真实原因

错误一:scratch 启动时报 no such file or directory

可能原因:

  • 二进制是动态链接的;
  • ELF 动态加载器不存在;
  • 复制路径错误;
  • 架构不匹配。

诊断方式:

file app
ldd app
docker image inspect image

修复方向不是盲目添加 shell,而是确认构建产物是否符合运行时假设。

错误二:HTTPS 报 unknown authority

可能原因:

  • 没有复制 CA 证书;
  • CA 路径与基础镜像约定不同;
  • 使用的是私有 CA;
  • 服务端没有发送完整证书链;
  • 系统 CA 包过旧。

必须区分客户端信任问题和服务端证书配置问题。关闭 TLS 验证只是掩盖问题。

错误三:time.LoadLocationunknown time zone

可能原因:

  • scratch 中没有 zoneinfo;
  • 只复制了 UTC,但应用请求其他时区;
  • 时区文件路径或名称错误;
  • 嵌入的 time/tzdata 未加入程序。

错误四:切换非 root 后无法写文件

可能原因:

  • 目录属于 root;
  • 挂载卷的属主不匹配;
  • 应用写入了只读根文件系统;
  • 应用默认使用 /root 或当前工作目录中的受限路径。

应明确应用需要的写入目录,并在镜像或卷初始化时为 UID 65532 授权,而不是重新改回 root。

错误五:docker stop 后请求被中断

可能原因:

  • 应用没有注册 SIGTERM
  • shell 或 supervisor 没有转发信号;
  • Shutdown 上下文已经取消;
  • Compose 的 stop_grace_period 太短;
  • readiness 没有先摘除;
  • 存在不受 Shutdown 自动管理的长连接。

这类问题需要同时检查 Docker 配置、PID 1、Go 生命周期代码和负载均衡器,而不能只看 HTTP 服务端口是否还在监听。


十二、生产取舍:scratch、Distroless 和完整发行版

scratch 适合以下前提同时成立的应用:

  • 可以使用纯 Go 静态构建;
  • 明确知道需要哪些 CA、时区和系统数据;
  • 不依赖 shell、动态库或系统命令;
  • 有独立的日志、监控和调试流程;
  • 能接受通过重新构建镜像更新系统数据。

如果应用使用 cgo、需要系统命令、需要频繁现场诊断,包含 libc 和工具的发行版镜像可能更合适。镜像更大不自动代表设计更差;关键是运行时依赖是否明确、可更新、可验证。

最终镜像可以是 scratch,也可以是 Distroless 或 Debian。重要的是保持以下条件一致:

构建假设
    = 二进制链接方式
    + CA 来源
    + 时区来源
    + 用户和目录权限
    + 信号与进程模型

只要其中一项与运行时镜像不匹配,问题就会从“构建成功”延迟到启动、HTTPS、时间格式化、文件写入或容器停止阶段才暴露。


系列导航与关联阅读

官方资料

本文依据 Docker、OCI 与 CNCF 官方文档重新梳理;正文与生产检查清单由 WR BLOG 编写。