容器里访问内部服务时,TLS 握手失败最常见的报错是 certificate signed by unknown authority。这个时候先别急着怀疑证书链有没有拼错,可以进容器执行一下 openssl 版本和证书库路径检查。多数情况下,问题的根源是基础镜像自带的证书信任库只包含公共 CA,公司内部的私有根证书并没有被写进去。容器与虚拟机不同,它不会继承宿主机的 /etc/ssl/certs 内容;镜像构建时 COPY 进去什么、RUN 生成什么,最终运行时才有什么。所以要让私有 CA 在容器信任链中生效,必须显式地完成证书安装和索引更新。

一、容器证书信任链的加载机制
Linux 系统库对 TLS 证书的验证依赖一组受信任的根证书。Debian 和 Ubuntu 通常把聚合文件放在 /etc/ssl/certs/ca-certificates.crt,单个证书软链到 /usr/share/ca-certificates/mozilla/ 等目录;Alpine 则使用 /etc/ssl/cert.pem。这些文件并不是运行时动态扫描所有目录得到的,而是在镜像构建阶段由 update-ca-certificates 命令根据 /usr/local/share/ca-certificates/ 和 /usr/share/ca-certificates/ 下的 PEM 文件重新生成的。很多人以为把 .crt 文件挂载到 /etc/ssl/certs 下就会自动生效,实际上 Debian 系并不会因为新增文件而自动修改 ca-certificates.crt。
这种静态生成机制带来的直接影响是:如果 Dockerfile 里只写了 COPY corp-root-ca.crt /etc/ssl/certs/,但没执行 update-ca-certificates,那么 openssl 和 curl 等工具仍然认为该证书不受信任。Alpine 虽然可以通过追加文本到 /etc/ssl/cert.pem 临时解决,但正规做法还是调用 update-ca-certificates。理解这一点后,后续镜像构建和 Kubernetes 挂载方案就都围绕同一个核心:先让证书文件进入正确目录,再触发索引重新生成。
另一个容易被忽略的细节是,很多语言运行时不直接读取系统 CA 文件。Java 用的是自己的 keystore,Go 在镜像缺少系统证书库时会回退到环境变量,Python 的 requests 库则可能读取 certifi 包自带的 CA 文件。如果只配好了系统信任链,应用仍然可能报错。因此排查容器证书问题时,应当先确认系统库层面是否信任私有 CA,再检查具体运行时的证书加载路径。
二、镜像构建阶段注入私有 CA
把私有 CA 直接烧录进镜像,是最稳定、最适合大规模分发的方式。应用启动后不需要任何额外操作,只要基础镜像不变,证书信任关系就固定下来。对于 Debian 和 Ubuntu 系列,推荐把 PEM 证书复制到 /usr/local/share/ca-certificates/ 目录,然后执行 update-ca-certificates。这个目录专门用于存放本地追加的 CA,不会被系统包更新清掉。
FROM debian:bookworm-slim
# 放置私有根证书
COPY corp-root-ca.crt /usr/local/share/ca-certificates/corp-root-ca.crt
# 更新系统信任库
RUN chmod 644 /usr/local/share/ca-certificates/corp-root-ca.crt \
&& update-ca-certificates
# 可选:构建后验证
RUN openssl verify -CAfile /etc/ssl/certs/ca-certificates.crt /usr/local/share/ca-certificates/corp-root-ca.crt
上面的写法会把私有 CA 合并进 /etc/ssl/certs/ca-certificates.crt,并且保留原始证书文件。为了减少镜像体积,也可以在 update-ca-certificates 之后删除原始 .crt 文件;但后续审计和轮换时,保留原始文件通常更方便查看企业根证书信息。构建时建议把私有 CA 放在构建上下文目录中,并在 .dockerignore 中排除私钥和其他敏感文件,避免误打入镜像历史层。
Alpine 镜像的写入方式略有差异。Alpine 默认没有 update-ca-certificates 命令,需要先安装 ca-certificates 包,再复制证书并更新。示例:
FROM alpine:3.20
RUN apk add --no-cache ca-certificates
COPY corp-root-ca.crt /usr/local/share/ca-certificates/corp-root-ca.crt
RUN chmod 644 /usr/local/share/ca-certificates/corp-root-ca.crt \
&& update-ca-certificates
# 验证路径是 /etc/ssl/cert.pem
RUN grep -q "Corporate Root" /etc/ssl/cert.pem
Alpine 的 update-ca-certificates 会在 /etc/ssl/certs/ 下创建符号链接,并重新生成 /etc/ssl/cert.pem。如果使用 scratch 或其他极简镜像,可能根本没有证书管理工具,这时就需要在构建阶段手动复制 CA 文件并设置某些语言环境变量,或者干脆放弃 scratch 改用 alpine 或 distroless 的带证书版本。对于 distroless 镜像,由于没有 shell 和 update-ca-certificates,建议在上一构建阶段生成好证书库后,再把整个 /etc/ssl/certs 目录复制到最终镜像。
三、Kubernetes 中使用 Secret 挂载私有 CA
不是所有团队都愿意为每个业务镜像单独构建带私有 CA 的版本,尤其是证书频繁轮换时。Kubernetes 下可以通过 Secret 把私有 CA 挂载到 Pod 中,再在容器启动前执行更新操作。这样做的好处是证书内容与镜像解耦,轮换时更新 Secret 并重启 Pod 即可,不用每次重新打镜像。
apiVersion: v1 kind: Secret metadata: name: corp-root-ca type: Opaque data: corp-root-ca.crt: BASE64_ENCODED_CERTIFICATE
创建 Secret 后,在 Deployment 中将它挂载到 Pod 的指定路径。但仅挂载还不够,必须让容器在启动阶段把证书复制到 /usr/local/share/ca-certificates/ 并执行 update-ca-certificates。对于 Debian 系,可以使用启动命令包装:
apiVersion: apps/v1
kind: Deployment
metadata:
name: internal-api
spec:
replicas: 2
selector:
matchLabels:
app: internal-api
template:
metadata:
labels:
app: internal-api
spec:
containers:
- name: app
image: debian:bookworm-slim
command:
- /bin/sh
- -c
- |
set -e
cp /certs/corp-root-ca.crt /usr/local/share/ca-certificates/corp-root-ca.crt
update-ca-certificates
exec /app/server
volumeMounts:
- name: ca-cert
mountPath: /certs
readOnly: true
volumes:
- name: ca-cert
secret:
secretName: corp-root-ca
这种方式的代价是每个 Pod 启动时都会在容器可写层复制一份证书,对运行期几乎无影响,但要求镜像内有 cp 和 update-ca-certificates 命令。如果应用镜像是 Alpine,则在命令中先执行 apk add --no-cache ca-certificates,或者更简单地,在镜像构建时安装好 ca-certificates,挂载后只运行 update-ca-certificates。对于没有 shell 的镜像,比如 distroless 或 scratch,建议放弃动态挂载方案,回到镜像构建阶段处理。
动态挂载还存在证书轮换感知问题。Kubernetes 默认不会因为 Secret 内容变化而自动重启 Deployment,即使 Secret 更新,已经运行的 Pod 仍然使用旧证书。如果私有 CA 到期或撤销,必须通过 kubectl rollout restart deployment 触发滚动更新,或者引入 Reloader 这类控制器自动检测 Secret 变更。轮换后,最好验证新 Pod 的 /etc/ssl/certs/ca-certificates.crt 中是否包含新证书指纹。
四、不同语言运行时的私有信任处理
Java 应用是容器化场景中最常见的证书例外。Java 虚拟机不读取 Linux 的 /etc/ssl/certs,而是使用 $JAVA_HOME/lib/security/cacerts 这一 keystore。只把私有 CA 写入系统信任库,Java 应用依然会抛 PKIX path building failed 或者 unable to find valid certification path to requested target。必须在镜像构建时用 keytool 导入私有 CA,或者在启动参数里指定自定义 truststore。
FROM eclipse-temurin:17-jre
COPY corp-root-ca.crt /tmp/corp-root-ca.crt
RUN keytool -importcert -noprompt \
-alias corp-root \
-file /tmp/corp-root-ca.crt \
-keystore $JAVA_HOME/lib/security/cacerts \
-storepass changeit \
&& rm /tmp/corp-root-ca.crt
示例中的 storepass 是 JRE 默认密码 changeit,生产环境建议修改默认密码并妥善管理。如果不想重新构建 Java 镜像,可以把私有 CA 做成 JKS 或 PKCS12 格式的 truststore,通过 Secret 挂载到 Pod,再设置 JAVA_TOOL_OPTIONS 环境变量:-Djavax.net.ssl.trustStore=/certs/truststore.jks -Djavax.net.ssl.trustStorePassword=changeit。但这种方式需要应用在 JVM 启动时读取环境变量,部分容器编排平台的注入方式可能会有差异,因此统一在镜像里导入仍然是长期更省事的办法。
Go 语言从 1.15 版本开始默认在 Unix 系统上读取系统证书池,但前提是镜像内有可用的 CA 文件。很多 Go 开发者用 scratch 镜像部署静态编译的程序,结果系统里没有证书,导致 x509 报错。此时可以设置 SSL_CERT_FILE 环境变量指向企业 CA 文件,或者设置 SSL_CERT_DIR 指向包含哈希软链的目录。示例:
env:
- name: SSL_CERT_FILE
value: /etc/ssl/certs/corp-root-ca.crt
Python 的 requests 库则依赖 certifi 包提供的 CA bundle,即使系统信任库更新了,requests 默认也可能不读。可以在启动环境里设置 REQUESTS_CA_BUNDLE 或 CURL_CA_BUNDLE,将值指向 /etc/ssl/certs/ca-certificates.crt。Node.js 应用可以通过 NODE_EXTRA_CA_CERTS 变量追加私有 CA,不必替换整个信任库。这些运行时特例说明,容器化后不能只满足于系统级别的 update-ca-certificates,还需要根据应用栈补齐各自的信任配置。
五、私有 CA 安全实践与验证
把私有 CA 放进容器信任链,本质上是扩大了对该 CA 的信任范围。如果私有根 CA 的私钥被泄露,攻击者可以签发任意伪造证书,所有信任它的容器都可能受影响。因此更推荐使用独立的中间 CA 来为内部服务签发证书,根 CA 离线保存并在必要时撤销中间 CA。镜像中只写入中间 CA,这样即使中间 CA 泄露,影响面也小于根 CA。私有 CA 的证书文件本身是公开信息,但构建上下文或 Secret 中绝不能出现对应的私钥,构建后可用 trivy 或 docker scout 扫描镜像层,确认没有 id_rsa、server.key 等私钥文件。
证书轮换时,建议采用滚动更新 Pod 的方式,而不是在运行中的容器内直接替换证书文件。原因在于很多进程在启动时已经把 CA 证书加载进内存,直接改文件不会让旧进程重新加载,还会造成新旧进程信任不一致。对于镜像内置 CA 的场景,轮换意味着重新构建并发布镜像;对于 Secret 挂载的场景,更新 Secret 后需要重启相关 Deployment。无论哪种方式,都应该在发布完成后抽检容器内的证书指纹,确认新的 CA 已经写入系统信任库。
验证容器是否信任私有 CA,可以在容器内执行 openssl s_client -connect internal.ipipp.com:443 -CAfile /etc/ssl/certs/ca-certificates.crt,观察 Verify return code 是否为 0 (ok)。也可以用 curl 直接访问内部接口,比如 curl --cacert /etc/ssl/certs/ca-certificates.crt https://internal.ipipp.com/health。对于 Java 应用,可临时调用一个使用 HttpsURLConnection 的小工具,或在启动日志中确认没有 PKIX 异常。只有同时覆盖系统库、语言运行时和实际应用三个层面的验证,才能确认私有 CA 真正进入了容器信任链。

