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

Docker Alpine、Distroless 与 Scratch:libc、证书、时区和调试取舍

在 Linux 容器中,AlpineDistrolessScratch 常被放在“镜像大小比较”中讨论,但它们解决的并不是同一个问题:

  • Alpine 是一个完整但精简的 Linux 用户空间发行版,默认使用 musl libc,并提供 shell、包管理器和常见诊断工具。
  • Distroless 是去除 shell、包管理器等通用操作系统工具后的运行时镜像,通常仍然包含某种 libc、证书和必要的运行时文件。
  • Scratch 不是发行版,而是一个空的基础镜像起点。它不提供 libc、动态链接器、证书、时区数据库、shell 或任何用户空间程序。

镜像大小只是结果,真正决定应用能否启动和正常工作的,是应用运行时所需文件集合是否完整。

本文限定在 Linux 容器边界内。容器仍然使用宿主机 Linux 内核,但用户空间中的可执行文件、动态链接器、libc、证书和时区数据来自镜像本身,而不是自动来自宿主机。


一、先建立正确模型:容器不是“只有一个程序”

一个 Linux 进程能否在容器中运行,至少涉及以下几层:

应用程序
  │
  ├── 动态链接器,例如 /lib64/ld-linux-x86-64.so.2 或 /lib/ld-musl-x86_64.so.1
  │
  ├── libc,例如 glibc 或 musl
  │
  ├── 其他共享库、NSS 模块、字符集和运行时数据
  │
  ├── CA 证书、时区数据库等文件
  │
  └── Linux 内核提供的系统调用、Namespace、cgroup、网络栈

这里有一个容易被忽略的边界:

宿主机内核通常可以被容器共享,但宿主机的 /lib/usr/share/zoneinfo/etc/ssl/certs 不会自动出现在容器中。

因此,“程序在宿主机能运行”并不能推出“把二进制复制到 Scratch 后也能运行”。

可以把运行时依赖近似表示为:

R=B+L+S+DR = B + L + S + D

其中:

  • BB:应用本身的二进制或脚本;
  • LL:动态链接器和共享库;
  • SS:运行时数据,例如 CA 证书、时区数据库、字体或字典;
  • DD:运行方式所需的系统接口和容器配置,例如端口、用户、环境变量和挂载点。

一个镜像 II 能运行应用的必要条件是:

BLSDIKB \cup L \cup S \cup D \subseteq I \cup K

这里的 KK 表示宿主机 Linux 内核提供的能力。注意,K 不包含用户空间文件。

这个条件解释了三种镜像的核心差异:

  • Alpine 预先提供了大量 LLSS 和诊断工具;
  • Distroless 只保留经过选择的 LL 和部分 SS
  • Scratch 默认只提供一个空文件系统,应用必须自己携带所有需要的用户空间依赖。

二、libc 到底是什么,为什么它会决定镜像能否启动

2.1 libc 不只是“几个库文件”

libc 是 C 运行时库。它通常提供:

  • 内存分配;
  • 字符串和文件操作;
  • 线程与进程相关接口;
  • socket、DNS 等网络接口封装;
  • 用户、组、主机名和服务查询;
  • 与 Linux 内核系统调用之间的兼容层。

即使应用不是用 C 编写,也可能间接依赖 libc:

  • C++ 程序通常依赖 libc;
  • Python、Ruby、Node.js 等解释器通常依赖 libc;
  • Go 在启用 cgo 时可能依赖 libc;
  • 某些语言运行时或原生扩展会加载 libc 及其他 .so 文件。

因此,判断一个二进制是否“静态”,不能只看它的文件扩展名或编译语言。


2.2 动态链接程序启动时发生了什么

假设一个 ELF 程序的头部包含:

Requesting program interpreter: /lib64/ld-linux-x86-64.so.2

启动过程大致是:

  1. Linux 内核读取 ELF 头;
  2. 内核发现程序需要 /lib64/ld-linux-x86-64.so.2
  3. 内核尝试加载这个动态链接器;
  4. 动态链接器读取程序中的依赖信息;
  5. 动态链接器继续加载 libc.so.6libpthread.so.0 等共享库;
  6. 所有依赖满足后,才进入应用的 main 或等价入口。

如果在 Scratch 中直接复制一个 glibc 动态链接程序,常见结果是:

exec /app/server: no such file or directory

这条错误经常误导人。/app/server 文件可能确实存在,真正缺少的通常是 ELF 头中声明的动态链接器。

可以用以下命令检查:

file ./server
readelf -l ./server | grep 'Requesting program interpreter'
ldd ./server

典型输出可能是:

./server: ELF 64-bit LSB pie executable, x86-64, dynamically linked
      [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]

ldd 适合快速查看动态依赖,但它不是对不可信二进制进行安全分析的工具;对不可信文件不要随意执行由 ldd 触发的分析路径。更稳妥的静态检查包括:

readelf -d ./server
readelf -l ./server

2.3 glibc 与 musl 的关系

Alpine 默认使用 musl libc,而许多 Debian、Ubuntu、Distroless Debian 变体使用 glibc

两者都实现 POSIX 和 Linux 常用接口,但不是可以无条件互换的同一个 ABI。差异会出现在:

  • 动态链接器路径不同;
  • 线程、DNS、用户查询等实现不同;
  • 某些符号、版本化符号和扩展接口不同;
  • locale、正则、DNS 和 NSS 行为不同;
  • 依赖 glibc 特定行为的预编译原生库无法直接在 musl 上运行。

例如,一个在 Debian 中编译的动态链接程序可能需要:

/lib64/ld-linux-x86-64.so.2
libc.so.6

而 Alpine 的典型路径可能是:

/lib/ld-musl-x86_64.so.1
/lib/libc.musl-x86_64.so.1

把 Debian 阶段生成的程序复制到 Alpine,或者把 Alpine 阶段生成的原生依赖复制到 Debian Distroless,并不构成可靠的运行时迁移方案。

正确原则是:

编译阶段和运行阶段必须在 ABI、架构以及原生依赖上保持兼容;“文件复制成功”不等于“程序可执行”。


2.4 Go 的特殊情况:静态不等于所有功能都自带

Go 程序经常被用于 Scratch,因为 CGO_ENABLED=0 时可以生成不依赖 libc 的静态二进制:

CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
  go build -trimpath -ldflags="-s -w" -o server ./cmd/server

但是下面几个条件仍然要分别验证:

  1. 是否真的没有动态链接器依赖;
  2. 是否需要 CA 证书;
  3. 是否需要时区数据库;
  4. 是否依赖系统用户、DNS 或其他外部文件;
  5. 目标架构是否正确。

检查方式:

file server
ldd server

静态 Go 程序通常会显示类似:

not a dynamic executable

但这只说明 ELF 动态库依赖较少或不存在,不代表 HTTPS 和时区功能自动具备。

此外,Go 的 cgo 行为会改变这个结论。例如:

  • 使用 SQLite、某些压缩库或 C 扩展时可能需要 cgo;
  • net、用户查询等功能在不同编译设置下可能采用纯 Go 或 libc 路径;
  • os/user、DNS 等行为可能受到构建标签和环境影响。

所以不能简单写成“Go 就一定能放进 Scratch”。


三、Alpine:完整用户空间带来的兼容性与调试能力

3.1 Alpine 提供了什么

Alpine 是一个发行版,通常提供:

  • musl libc;
  • BusyBox 工具;
  • apk 包管理器;
  • shell;
  • CA 证书包;
  • tzdata 时区数据库;
  • DNS、用户和文件系统相关的常见支持文件。

因此,在 Alpine 中运行程序时,很多依赖已经由基础镜像或 apk add 补齐:

FROM alpine:3.21

RUN apk add --no-cache ca-certificates tzdata

COPY server /usr/local/bin/server
ENTRYPOINT ["/usr/local/bin/server"]

这里的 --no-cache 会避免保留 APK 索引缓存,减少最终层中的无关文件;它不会自动让所有应用变成静态链接,也不会替应用复制其他运行时依赖。


3.2 Alpine 的优点不是只有体积

Alpine 的价值还包括:

  • 出错时可以进入 shell;
  • 可以使用 catenvpswget 等工具进行初步诊断;
  • 可以临时安装包;
  • 可以查看配置文件和证书路径;
  • 适合需要脚本、原生编译器或多种系统工具的运行场景。

例如:

docker run --rm -it --entrypoint /bin/sh my-alpine-app

进入容器后,可以检查:

cat /etc/os-release
ls -l /etc/ssl/certs
ls -l /usr/share/zoneinfo
env

但 Alpine 也有边界:

  • 它使用 musl,不是“更小的 glibc”;
  • 某些闭源二进制或预编译原生扩展只支持 glibc;
  • 与 glibc 相关的调试资料和第三方包可能不完全适用;
  • apk add 在运行阶段动态安装包会降低可重复性,并可能引入未固定的版本。

如果应用依赖 glibc 特性,应该优先选择 glibc 兼容的构建和运行基础,而不是为了镜像大小强行迁移到 Alpine。


四、Distroless:仍有运行时,但故意没有通用操作系统工具

4.1 Distroless 的定义

Distroless 不是“静态二进制镜像”的同义词。它通常是一个只包含特定应用所需运行时的镜像,例如:

  • libc 和动态链接器;
  • CA 证书;
  • 时区数据;
  • /etc/passwd/etc/group 等必要文件;
  • 某种语言运行时,例如 Java、Node.js 或 Python。

Distroless 的典型目标是移除:

  • shell;
  • 包管理器;
  • 编译器;
  • 常规网络工具;
  • 文本处理和进程诊断工具。

常见镜像命名会体现运行时和发行版系列,例如:

gcr.io/distroless/static-debian12
gcr.io/distroless/base-debian12
gcr.io/distroless/cc-debian12

具体内容取决于变体和版本,不能只根据镜像名称推断所有文件都存在。使用前应查看对应项目和标签说明,并尽量固定到受控的版本或 digest。


4.2 staticbasecc 不是同义标签

一个常见误解是:使用 distroless/static 就可以运行任何静态程序,或者使用 distroless/base 就能运行所有动态程序。实际情况取决于应用依赖。

可以用下面的思路区分:

  • static:面向不需要常规动态库运行时的程序,通常也包含证书等必要数据;
  • base:为某些动态链接程序提供基础运行时;
  • cc:面向依赖 C/C++ 运行时或更多原生库的程序;
  • 语言专用变体:包含该语言解释器或运行时。

例如,一个使用 glibc 动态链接的程序,选择 static 可能仍然启动失败;一个需要 libstdc++.so.6 的 C++ 程序,也不能只因为它是 Linux ELF 文件就放入任意 Distroless 变体。


4.3 Distroless 的调试版本是另一套镜像

生产 Distroless 镜像没有 shell 时,下面的命令通常会失败:

docker exec -it app /bin/sh

可能得到:

exec: "/bin/sh": stat /bin/sh: no such file or directory

某些 Distroless 项目提供带 :debug 后缀的调试变体。调试变体通常会额外提供 BusyBox shell 或少量工具,例如:

docker run --rm -it --entrypoint /busybox/sh \
  gcr.io/distroless/base-debian12:debug

但要注意:

  1. 调试镜像和生产镜像不是同一个文件系统;
  2. 调试镜像可能有不同 digest;
  3. 调试镜像不能证明生产镜像内一定存在相同工具;
  4. 用调试镜像替换生产镜像可能改变攻击面和行为;
  5. 具体 shell 路径和工具集合必须以该版本实际内容为准。

更可靠的做法是:生产容器保持最小化,问题复现时使用同一构建产物、同一配置和兼容的调试运行时,或者使用外部诊断容器观察目标容器的 Namespace。


五、Scratch:空文件系统,而不是“极简 Linux”

5.1 Scratch 提供什么

FROM scratch 的含义是:从一个空的根文件系统开始构建。

它不提供:

  • /bin/sh
  • libc;
  • 动态链接器;
  • /etc/passwd
  • /etc/resolv.conf 的模板之外的用户空间工具;
  • CA 证书;
  • /usr/share/zoneinfo
  • lscatps 等工具。

Docker 仍然可以为容器注入或生成一些运行时文件,例如容器 DNS 配置,但这不等于 Scratch 自带 DNS 客户端库或诊断命令。

Scratch 也不是一个 Linux 内核。容器启动时,Docker 仍然使用宿主机内核和 OCI runtime 创建进程、Namespace、cgroup 以及挂载布局。


5.2 一个可运行的 Scratch 多阶段构建

下面是一个 Go 应用的典型结构:

# syntax=docker/dockerfile:1

FROM --platform=$BUILDPLATFORM golang:1.24-bookworm AS build

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

COPY . .
ARG TARGETOS
ARG TARGETARCH
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH \
    go build -trimpath -ldflags="-s -w" -o /out/server ./cmd/server

FROM scratch

# HTTPS 所需的 CA 根证书
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt

# 时区数据;只有应用确实需要按 IANA 时区加载时才需要
COPY --from=build /usr/share/zoneinfo /usr/share/zoneinfo

COPY --from=build /out/server /server

USER 65532:65532
ENTRYPOINT ["/server"]

这个例子成立,需要同时满足以下前提:

  • ./cmd/server 生成的是目标 Linux 架构程序;
  • CGO_ENABLED=0 确实适用于该应用;
  • 应用不依赖未复制的动态库;
  • 构建阶段的证书路径确实存在;
  • 构建阶段的时区数据路径确实存在;
  • 应用不要求 shell、临时命令或其他外部工具。

golang:1.24-bookworm 中的证书和时区文件可能来自 Debian 包,复制到 Scratch 后只是文件复制,不会把包管理器或整个 Debian 用户空间带进最终镜像。

更稳妥的做法是专门准备一个资产阶段:

FROM alpine:3.21 AS runtime-assets
RUN apk add --no-cache ca-certificates tzdata

FROM --platform=$BUILDPLATFORM golang:1.24-bookworm AS build
WORKDIR /src
COPY . .
ARG TARGETOS
ARG TARGETARCH
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH \
    go build -trimpath -o /out/server ./cmd/server

FROM scratch
COPY --from=runtime-assets /etc/ssl/certs/ca-certificates.crt \
                           /etc/ssl/certs/ca-certificates.crt
COPY --from=runtime-assets /usr/share/zoneinfo /usr/share/zoneinfo
COPY --from=build /out/server /server
USER 65532:65532
ENTRYPOINT ["/server"]

这里使用 Alpine 只负责提供两个数据资产,并不意味着最终 Scratch 镜像使用了 Alpine 的 libc。


六、CA 证书:HTTPS 失败通常不是“网络不通”

6.1 TLS 验证需要什么

HTTPS 客户端验证服务器证书时,通常需要:

  1. 服务器发送证书链;
  2. 客户端拥有可信根 CA;
  3. 客户端校验证书链、域名、有效期和签名;
  4. 客户端能够读取系统时间以检查有效期。

根 CA 通常以 PEM 文件形式存在。常见聚合路径包括:

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

但路径不是所有发行版和语言都统一。程序还可能使用:

  • 系统默认路径;
  • SSL_CERT_FILE
  • SSL_CERT_DIR
  • 语言运行时自己的证书配置;
  • 显式传入的证书池。

因此,Scratch 中没有证书时,典型错误可能是:

x509: certificate signed by unknown authority

或:

unable to get local issuer certificate

这与 DNS 失败、端口不可达不是一回事。


6.2 证书文件与网络连通性的诊断顺序

可以按以下顺序区分故障:

docker inspect app --format '{{json .NetworkSettings.Networks}}'
docker inspect app --format '{{json .Config.Env}}'
docker logs app

如果镜像有 shell,可以进入容器:

docker exec -it app /bin/sh

然后检查:

ls -l /etc/resolv.conf
ls -l /etc/ssl/certs/ca-certificates.crt

如果镜像没有 curlopenssl,不要据此判断网络不可用。没有诊断工具只说明镜像缺工具,不能证明应用的 socket 连接失败。

更准确的故障分类是:

DNS 解析失败
  └── getaddrinfo / resolver / resolv.conf / 网络命名空间

TCP 连接失败
  └── 路由、防火墙、端口、代理、服务端监听

TLS 握手失败
  └── 协议、SNI、时间、证书链、CA、密码套件

HTTP 请求失败
  └── 认证、路径、状态码、应用协议

在生产环境中,不应通过“关闭证书校验”修复 CA 缺失。那会把“可信根未安装”的配置错误变成“任何伪造证书都可能被接受”的安全问题。


6.3 Alpine、Distroless 和 Scratch 的证书处理

Alpine

RUN apk add --no-cache ca-certificates

这通常会安装证书包并生成或提供系统证书文件。若需要自定义企业 CA,应在构建阶段明确加入,并通过应用支持的机制使用它。

Distroless

许多 Distroless 运行时已经包含 CA 证书,但不同变体和版本应实际验证:

docker run --rm --entrypoint /busybox/sh \
  gcr.io/distroless/base-debian12:debug \
  -c 'ls -l /etc/ssl/certs'

如果使用非 debug 变体,不能假设可以执行 shell;可以在构建阶段检查文件,或在应用启动时提供明确的错误信息。

Scratch

必须复制证书:

COPY --from=runtime-assets \
  /etc/ssl/certs/ca-certificates.crt \
  /etc/ssl/certs/ca-certificates.crt

还需要确认应用确实读取这个路径。不同语言的默认路径可能不同,最可靠的是查看该语言运行时文档或在测试环境执行真实 HTTPS 请求。


七、时区:TZ=Asia/Shanghai 不是时区数据库

7.1 时区名称和时区规则不是一回事

环境变量:

TZ=Asia/Shanghai

只提供了一个时区名称。应用要根据这个名称计算历史和未来时间,通常还需要 IANA tzdata 数据库中的文件:

/usr/share/zoneinfo/Asia/Shanghai

如果数据库不存在,应用可能出现:

  • 无法加载时区;
  • 回退到 UTC;
  • 使用固定偏移而忽略历史规则;
  • 在不同语言运行时中产生不同错误。

时区数据不是简单的“当前 UTC+8”。历史日期、夏令时和地区规则都可能改变偏移。


7.2 在 Scratch 中复制 tzdata

构建阶段安装时区数据库:

FROM alpine:3.21 AS runtime-assets
RUN apk add --no-cache tzdata ca-certificates

FROM scratch
COPY --from=runtime-assets /usr/share/zoneinfo /usr/share/zoneinfo
COPY --from=runtime-assets /etc/ssl/certs/ca-certificates.crt \
                           /etc/ssl/certs/ca-certificates.crt
COPY server /server
ENV TZ=Asia/Shanghai
ENTRYPOINT ["/server"]

这里必须区分两个概念:

  • TZ 决定应用尝试使用哪个时区;
  • tzdata 提供这个时区名称对应的规则文件。

只设置前者,不保证后者存在。

对于 Go,还可以选择把时区数据库编译进程序:

import _ "time/tzdata"

这减少了对 /usr/share/zoneinfo 的文件依赖,但会增加程序自身的内容,并且时区数据版本由 Go 模块版本决定。无论采用外部文件还是内嵌方式,都应在构建和发布过程中固定数据来源。


7.3 生产环境是否应该设置非 UTC 时区

容器内使用 UTC 往往更容易进行日志排序、跨区域关联和故障排查:

ENV TZ=UTC

这不是因为其他时区“不能用”,而是因为时间戳的记录和展示可以分离:

  • 数据库和日志保存 UTC;
  • 前端或报表按用户地区转换;
  • 必须进行本地业务计算时,再加载对应 IANA 时区。

如果业务确实要求本地日历日、营业时间或夏令时规则,就必须测试 tzdata 存在、版本正确,并验证跨 DST 切换和历史日期的行为。


八、三种基础的构建方式

8.1 Alpine 运行时:工具完整,兼容性取决于 musl

FROM golang:1.24-alpine AS build

RUN apk add --no-cache build-base
WORKDIR /src

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

ARG TARGETOS
ARG TARGETARCH
RUN CGO_ENABLED=1 GOOS=$TARGETOS GOARCH=$TARGETARCH \
    go build -trimpath -o /out/server ./cmd/server

FROM alpine:3.21
RUN apk add --no-cache ca-certificates tzdata

COPY --from=build /out/server /usr/local/bin/server
USER 65532:65532
ENTRYPOINT ["/usr/local/bin/server"]

这个版本适合:

  • 需要 shell 和常用工具;
  • 依赖 Alpine/musl 生态;
  • 需要在容器内进行基本诊断;
  • 应用的原生依赖能够在 musl 上正确编译和运行。

如果 CGO_ENABLED=1,最终阶段必须包含所需的动态链接器和共享库。仅复制 /out/server 不够。


8.2 Distroless 运行时:保留运行时,去除交互工具

FROM golang:1.24-bookworm AS build

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

RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
    go build -trimpath -o /out/server ./cmd/server

FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/server /server
ENTRYPOINT ["/server"]

这个版本适合不需要 shell、包管理器和常规诊断工具的静态程序。nonroot 变体通常会提供非 root 用户配置,但应用仍应验证:

  • 监听端口是否大于 1024;
  • 写入目录是否具有权限;
  • 证书和时区文件是否存在;
  • 应用是否需要动态库。

对于依赖 glibc 动态库的程序,应选择对应的 Distroless 运行时,而不是盲目使用 static


8.3 Scratch 运行时:最少文件,但责任全部转移给构建者

FROM golang:1.24-bookworm AS build

WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
    go build -trimpath -ldflags="-s -w" -o /out/server ./cmd/server

FROM alpine:3.21 AS assets
RUN apk add --no-cache ca-certificates tzdata

FROM scratch
COPY --from=build /out/server /server
COPY --from=assets /etc/ssl/certs/ca-certificates.crt \
                  /etc/ssl/certs/ca-certificates.crt
COPY --from=assets /usr/share/zoneinfo /usr/share/zoneinfo
USER 65532:65532
ENV TZ=UTC
ENTRYPOINT ["/server"]

Scratch 适合依赖闭包非常清晰的程序,尤其是:

  • 真正静态链接的 Go、Rust 或 C 程序;
  • 不需要 shell 和系统工具;
  • 能通过日志、指标和外部诊断完成运维;
  • 团队有能力验证所有运行时资产。

九、为什么构建阶段不能随便复制依赖

一个常见错误是:

FROM debian:bookworm AS build
COPY . /src
RUN make -C /src

FROM alpine:3.21
COPY --from=build /src/app /app
ENTRYPOINT ["/app"]

失败的原因可能包括:

  1. 构建产物动态依赖 glibc;
  2. Alpine 只提供 musl;
  3. ELF 动态链接器路径不存在;
  4. 构建阶段链接了未复制的 .so
  5. 程序运行时还需要 NSS、CA 或时区文件。

可以把复制策略写成一个闭包条件。设应用的直接依赖为 D0D_0,某个依赖加载后还会引入其他依赖,则:

Dn+1=Dndeps(Dn)D_{n+1} = D_n \cup \operatorname{deps}(D_n)

最终需要复制的是不动点:

D\*=n=0DnD^\* = \bigcup_{n=0}^{\infty} D_n

实际工程中,这个集合不仅包含 ELF 共享库,还可能包括:

动态链接器
libc
libgcc / libstdc++
NSS 模块
CA 证书
时区数据库
字体和 locale
配置文件
非 root 用户与组
应用需要写入的目录

因此,多阶段构建的正确目标不是“只复制一个二进制”,而是:

在不复制整个构建环境的前提下,复制运行时闭包。


十、如何验证一个最小镜像,而不是凭经验判断

10.1 构建阶段检查 ELF

FROM build AS verify
RUN file /out/server && \
    (ldd /out/server || true) && \
    readelf -l /out/server | grep -E 'interpreter|LOAD' || true

这样可以在构建日志中确认:

  • 是否为目标架构;
  • 是否动态链接;
  • 是否声明了动态链接器;
  • 是否出现明显的共享库依赖。

ldd 输出需要结合构建环境解释。最终镜像不是构建阶段镜像,构建环境里存在的库不代表最终阶段也存在。


10.2 检查镜像配置和文件边界

docker image inspect myapp:latest \
  --format '{{.Config.User}} {{json .Config.Entrypoint}} {{json .Config.Cmd}}'

可以查看:

  • 默认用户;
  • Entrypoint;
  • Cmd;
  • 工作目录;
  • 环境变量。
docker inspect myapp-container \
  --format '{{json .Mounts}}'

可以检查证书、配置和数据目录是否被意外挂载覆盖。

对于 Scratch 或 Distroless,不能依赖 docker exec app ls /,因为 ls 可能不存在。应在构建阶段使用临时检查阶段,或使用镜像导出工具检查文件:

docker create --name inspect-only myapp:latest
docker export inspect-only | tar -tf - | sed -n '1,80p'
docker rm inspect-only

这个命令只查看镜像文件系统,不会启动应用。它不能证明动态链接、权限或网络功能正确,但能确认预期文件是否被复制进去。


10.3 运行真实功能测试

只测试进程“能启动”不够。至少应分别验证:

启动
  ├── ELF 装载
  ├── 用户和权限
  ├── 配置读取
  ├── 监听端口
  ├── DNS 解析
  ├── HTTPS 证书验证
  ├── 时区加载
  └── 信号处理和优雅退出

例如:

docker run -d --name myapp-test -p 8080:8080 myapp:latest
docker inspect myapp-test --format '{{.State.Status}} {{.State.ExitCode}}'
docker logs myapp-test
curl --fail http://127.0.0.1:8080/healthz
docker stop --time 10 myapp-test

每一步的意义不同:

  • docker inspect 检查容器状态和退出码;
  • docker logs 检查应用自身报告的错误;
  • curl 验证从容器外部访问的实际路径;
  • docker stop --time 10 验证 SIGTERM 和退出流程。

如果应用必须访问 HTTPS 服务,应增加真实 HTTPS 测试;如果应用必须加载 Asia/Shanghai,应测试具体时区而不是只测试默认 UTC。


十一、没有 shell 时如何调试

11.1 docker exec 的前提

docker exec 并不是 Docker 自带一个 shell,而是在目标容器的 Namespace 和文件系统中启动一个新进程:

docker exec -it app /bin/sh

它要求目标容器内存在 /bin/sh。因此:

  • Alpine 通常成功;
  • Distroless 生产变体通常失败;
  • Scratch 必然失败,除非应用镜像自己复制了 shell。

如果容器已经退出,普通 docker exec 也不能使用,因为它需要一个正在运行的目标容器。


11.2 外部诊断容器的思路

容器的进程、网络、挂载等资源由 Namespace 隔离。调试时可以启动一个工具容器,并让它加入目标容器的某些 Namespace:

docker run --rm -it \
  --pid=container:app \
  --network=container:app \
  alpine:3.21 /bin/sh

这个命令的含义是:

  • --pid=container:app:共享目标容器的 PID Namespace;
  • --network=container:app:共享目标容器的网络 Namespace;
  • Alpine 自己提供 shell 和工具。

但它不是无风险的“进入生产容器”:

  • 工具容器拥有观察目标进程的能力;
  • 共享网络后可能改变排查路径;
  • 如果使用更高权限或主机挂载,风险会显著扩大;
  • 不同容器的根文件系统仍然不同,工具容器看到的 / 不是目标容器的 /

可以进一步使用 PID、网络和挂载相关工具,但具体权限取决于 Docker 配置、内核能力和安全策略。生产环境应限制调试容器的镜像来源、权限和生命周期,并在完成后删除。


11.3 调试镜像与生产镜像的边界

推荐将调试能力设计为构建产物的一部分,而不是运行时临时修改:

同一源码和构建参数
        │
        ├── 生产目标:Distroless 或 Scratch
        └── 调试目标:Alpine 或 Distroless debug

调试目标应尽量保持:

  • 相同应用二进制;
  • 相同配置;
  • 相同架构;
  • 相同证书和时区数据;
  • 尽可能相同的 libc 和动态库布局。

否则,调试镜像中观察到的现象可能只是由于“换了基础环境”而产生,不能代表生产容器。


十二、常见误解与实际失败路径

误解一:镜像越小,启动越快、性能越好

镜像体积主要影响拉取、存储和缓存成本,不直接决定应用运行性能。相同应用二进制放入 Scratch、Distroless 或 Alpine 后,CPU 和内存表现不一定按镜像大小变化。

真正可能改变运行行为的是:

  • libc 实现;
  • DNS 和 NSS 路径;
  • 文件系统缓存;
  • 原生库版本;
  • 默认用户和权限;
  • 时区、locale 和证书配置。

误解二:Distroless 就是 Scratch

Distroless 往往仍然包含运行时库和数据文件;Scratch 默认什么都没有。

因此:

静态程序 + Scratch

和:

动态程序 + Distroless base

是两种不同的依赖模型。前者减少镜像文件,后者保留必要的系统运行时。


误解三:加上 TZ 就解决了时区问题

反例:

FROM scratch
ENV TZ=Asia/Shanghai
COPY server /server
ENTRYPOINT ["/server"]

如果 server 需要通过 IANA 名称加载时区,而镜像没有 /usr/share/zoneinfo/Asia/Shanghai,应用可能无法加载该时区或回退到 UTC。

修复方法是复制 tzdata,或者使用语言运行时支持的内嵌时区数据库。


误解四:HTTPS 失败就是容器网络问题

反例:

dial tcp: lookup api.example.com: no such host

这更接近 DNS 或网络配置问题。

而:

x509: certificate signed by unknown authority

更接近 CA 证书缺失或不信任链问题。

而:

context deadline exceeded

可能出现在 DNS、TCP、TLS 或应用层任意阶段,不能仅凭错误文本判断。应结合应用日志、阶段性超时和外部网络观测进行定位。


误解五:给动态程序加 chmod +x 就能在 Scratch 中运行

chmod 只改变文件权限,不会提供:

  • ELF 动态链接器;
  • libc;
  • 其他共享库;
  • NSS 模块;
  • CA 证书;
  • 时区数据库。

如果错误来自装载器缺失,权限修改没有因果作用。


十三、如何在三者之间做取舍

可以按应用的真实依赖选择,而不是按镜像大小选择。

选择 Alpine 的条件

选择 Alpine 通常意味着你需要:

  • 容器内 shell;
  • 常见诊断工具;
  • apk 安装能力;
  • musl 兼容环境;
  • 运行时脚本或动态辅助程序。

代价是必须验证所有原生依赖与 musl 的兼容性。

选择 Distroless 的条件

选择 Distroless 通常意味着:

  • 应用运行时依赖明确;
  • 不需要在容器内安装软件;
  • 不依赖 shell 执行启动脚本;
  • 证书、时区、用户和动态库需求可以提前验证;
  • 调试通过日志、指标、外部工具或专用 debug 镜像完成。

它通常在运行时最小化和兼容性之间取得平衡。

选择 Scratch 的条件

选择 Scratch 通常要求:

  • 应用是可验证的静态程序,或者已经完整复制动态依赖;
  • 不需要 shell、包管理器和常用工具;
  • 证书和时区等文件已经显式纳入;
  • 用户、权限、写目录和信号处理都经过测试;
  • 团队接受“容器内没有任何诊断工具”的运维模型。

Scratch 的风险不在于它“太小”,而在于依赖闭包容易被错误估计。


十四、一个可执行的决策顺序

面对一个待容器化的程序,可以按这个顺序判断:

  1. filereadelf 检查 ELF 架构、动态链接状态和解释器;
  2. 列出原生共享库、NSS、字体、locale 等文件依赖;
  3. 确认程序使用 glibc 还是 musl,避免跨 libc 直接复制;
  4. 验证 HTTPS 是否需要系统 CA;
  5. 验证命名时区是否需要 tzdata;
  6. 确认是否依赖 shell、启动脚本或外部命令;
  7. 确认应用是否以非 root 用户运行;
  8. 设计 Alpine、Distroless debug 或外部诊断容器的调试路径;
  9. 用真实网络、证书、时区和优雅退出测试验证最终镜像;
  10. 固定基础镜像版本或 digest,并在更新时重复验证。

最终可以用一句更准确的规则概括:

Alpine 提供完整的精简用户空间;Distroless 提供经过裁剪的应用运行时;Scratch 只提供空的文件系统起点。libc、证书、时区和调试能力是否存在,都必须从应用的运行时依赖闭包中逐项推导,而不能从镜像名称或体积大小猜测。


系列导航与关联阅读

官方资料

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