Docker 基础体系 · 第 36/70 篇。示例以现代 Docker Engine、BuildKit 与 Compose 规范为基础,并明确 Linux 容器边界。
Go 应用 Docker 镜像:静态链接、CA、时区、非 root 和优雅退出
Go 程序通常可以编译成一个独立的 Linux ELF 可执行文件,但“只有一个二进制文件”并不等于“运行时不需要任何外部数据”。HTTPS 依赖 CA 证书,时区转换依赖时区数据库,用户身份依赖 /etc/passwd,进程是否能够优雅退出则取决于 PID 1、信号转发和停止超时。
因此,一个可用于生产的 Go 运行时镜像至少要同时回答五个问题:
- 二进制是否真的不依赖动态链接器和共享库?
- 容器内的 HTTPS 客户端依据什么验证服务器证书?
- 时区名称和夏令时规则从哪里读取?
- 进程以什么用户运行,文件权限是否仍然正确?
- 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.mod 和 go.sum 决定。
.dockerignore 也属于构建输入控制的一部分:
.git
.gitignore
Dockerfile*
docker-compose*.yml
tmp
dist
coverage
*.log
构建上下文会发送给 Docker。上下文过大不仅拖慢构建,还可能把本地密钥、构建产物或测试数据意外带入构建过程。
二、静态链接到底解决了什么问题
1. 动态链接的运行条件
一个动态链接的 ELF 程序通常需要:
- ELF 动态加载器,例如
/lib64/ld-linux-x86-64.so.2; - 一个或多个共享库,例如
libc.so.6; - 某些库依赖的配置和名称解析数据。
如果把这样的程序复制到 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 握手大致包含以下过程:
- 服务端发送证书链;
- 客户端验证证书签名是否能追溯到受信任根 CA;
- 客户端验证证书的有效期;
- 客户端验证请求主机名是否匹配证书;
- 验证通过后才建立加密连接。
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 具有两个相关职责:
- 接收 Docker 发送的停止信号;
- 在存在子进程时承担孤儿进程回收职责。
如果使用 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)
}
}
}
完整的因果链是:
signal.NotifyContext注册SIGTERM和SIGINT;- Docker 发送
SIGTERM; signalCtx.Done()关闭;- 调用
server.Shutdown; Shutdown停止监听新连接,并等待已有连接上的处理完成;- 上下文超过 10 秒后返回错误;
- 进程退出,若 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 没有 curl、wget 和 shell,因此下面配置不能直接用于 scratch:
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:8080/healthz || exit 1"]
如果确实需要容器内探针,可以:
- 在运行时镜像中加入专用探针二进制;
- 使用包含探针工具的非 scratch 基础镜像;
- 由编排平台或外部监控系统执行 HTTP 探测。
不要为了一个探针随意加入完整 shell 和大量工具;应明确诊断便利性与攻击面、体积之间的取舍。
生产流量摘除通常还需要一个“排空状态”:
- 收到停止信号;
- readiness 返回失败或应用通知服务发现系统;
- 等待负载均衡器停止发送新请求;
- 调用
Shutdown等待旧请求完成; - 到时强制退出。
只调用 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.LoadLocation 报 unknown 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 完整学习路线:从镜像与容器到安全、可观测和生产交付
- 上一篇:Docker Alpine、Distroless 与 Scratch:libc、证书、时区和调试取舍
- 下一篇:Java 应用 Docker 镜像:JLink、Class Data Sharing、内存和 JVM 参数
- 延伸:Docker 多阶段构建:最小运行时、依赖固定、调试层和体积优化
- 延伸:Docker 健康检查与优雅关闭:PID 1、Probe、Stop Signal 和超时
官方资料
本文依据 Docker、OCI 与 CNCF 官方文档重新梳理;正文与生产检查清单由 WR BLOG 编写。

评论
0 条讨论