"The remote certificate is invalid according to the validation procedure."
如果你在 Azure APIM 自建网关(self-hosted gateway)容器日志里看到这条报错,而且你的后端 API 用的又是自签名证书,那你大概率正卡在一个很常见的坑里:自建网关没法信任这个证书,TLS 握手一直过不去。
我先交代一下我的实际环境。生产环境里,我负责维护的 API 网关是基于 Azure API Management 的自建网关,部署在内网 Kubernetes 集群里。后端服务里有一个 Apache NiFi 做数据接入,HTTPS 端口上挂的是内部生成的 PKCS12 自签名证书。自建网关转发请求到 NiFi 的时候,本来应该验证 NiFi 的服务端证书是否可信,结果每次都在 TLS 握手环节被卡住,日志里的证书校验报错一条接一条。
我一开始觉得这只是个"把证书塞进容器信任库"的小事,结果试遍了网上能找到的常见方案,全部失败。这篇文章不聊那种"一配就成功"的顺利攻略,而是专门把这些不成功方案掰开揉碎讲清楚,再给出我最终验证过、能在生产环境落地的做法。适合正在部署自建网关、又必须对接私有 CA 或自签名证书后端的同学,尤其是 Docker 和 Kubernetes 环境都跑过一遍的实践型读者。
1. 自建网关的证书信任问题,到底发生在哪几个环节?
1.1 数据面与控制面分离,导致证书管理天然分两套
Azure APIM 大家比较熟悉的是托管网关:Azure 云上帮你把整个网关运行环境搞定,你只需要上传证书、配策略、发布 API。但当我们选择自建网关时,数据面就移到了你自己的容器环境里,控制平面仍然留在 Azure 云端。这是理解证书问题的大前提。
自建网关容器本身是"半黑盒"。它有自己独立的操作系统文件系统,TLS 握手、证书链校验这些底层动作,实际上都发生在容器内的各个组件里。系统证书信任库在哪个目录、有哪些 CA 被预装,完全取决于官方镜像的基础操作系统,而不是取决于你在 Azure 门户里配了什么。
这带来的直接后果就是:你通过 Azure APIM 控制面上传的证书,和自建网关容器内实际用来做 TLS 校验的信任库,是两套东西。很多不成功方案,追根溯源都是把它们混为一谈了。
1.2 三个必须区分开的证书校验场景
我在排障的时候,把证书校验拆成了三个方向,这里直接给大家一个速查表。
| 场景 | 方向 | 证书类型 | 默认行为 | 信任库位置 |
|---|---|---|---|---|
| 客户端 → 网关 | 入站 | 网关服务端证书 | 客户端验证网关 | 客户端系统信任库 |
| 网关 → 后端 | 出站 | 后端服务端证书 | 自建网关容器验证后端 | 网关容器系统信任库 |
| 网关 → Azure 控制面 | 出站 | Azure TLS 证书 | 自建网关容器验证 Azure | 网关容器系统信任库 |
三个场景里,最常被提及的是"网关到后端"。自建网关接收客户端请求后,会以 HTTPS 方式向后端发起新连接。如果后端用的是自签名 CA 签发的证书,而网关容器里的系统信任库中没有这个 CA,TLS 校验就会失败。这个场景也是我处理 NiFi 时遇到的情况。
第二个场景"客户端到网关"方向相反。客户端访问自建网关的 HTTPS 端点时,如果网关用的是自签名证书,客户端就会报不信任。这个通常不是往网关容器里塞证书能解决的,而是要把根证书装到客户端那一侧,或者给网关配一张由企业 CA 或公共 CA 签发的正式证书。
第三个场景容易被忽略,但一旦出问题会更隐蔽:自建网关需要与 Azure 控制平面保持长连接,拉取配置、上报状态。正常情况下这个链路用的 Azure 公共 CA 证书,没毛病;但如果企业网络里存在 SSL 拦截、出口代理重新签发证书的情况,网关容器同样需要信任那个内网 CA,否则联调阶段连网关注册成功都看不到。
我在踩坑过程中发现,大多数人(包括我)只盯着"网关到后端"这一个场景,但实际改动容器信任库时,会影响后面两个场景的行为。所以要动手之前,先明确自己到底在解决哪个方向的问题,不然很容易出现"解决了 A,搞坏了 B"的情况。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 不成功方案拆解:看着合理,为什么就是不行?
2.1 方案一:给容器设置 SSL_CERT_FILE 环境变量
这是很多人第一个想到的办法。既然系统不认这个自签名证书,那我就通过环境变量告诉 TLS 库"用这个文件作为 CA 证书"。
我当时也这么干了:
bash复制docker run \
-e SSL_CERT_FILE=/certs/private-ca.pem \
-e SSL_CERT_DIR=/certs/ \
-v $(pwd)/private-ca.pem:/certs/private-ca.pem \
mcr.microsoft.com/azure-api-management/gateway:latest
结果很打脸:在容器里执行 curl https://nifi-server:8443 是通的,但网关转发请求时还是报证书校验失败。
原因在于,自建网关容器内部不是一个单进程应用,而是多个组件协同工作。不同组件使用的 TLS 库不一样,对环境变量的支持程度也不一样。OpenSSL 命令行工具会读取 SSL_CERT_FILE,但网关内部的 .NET 组件、代理组件各自有自己读取系统证书库的逻辑。你设了环境变量,只覆盖了部分进程的信任来源,覆盖不了全部。
这个方案的另一个变体是设置 NODE_TLS_REJECT_UNAUTHORIZED=0 或者类似的"关闭校验"变量。这个更危险,我后面单独说。总之,环境变量方案只能作为临时调试手段,不能作为生产环境的证书信任方案。
2.2 方案二:docker exec 进容器执行 update-ca-certificates
手动操作看起来也很简单:容器启动后,docker exec 进去,把证书放到 /usr/local/share/ca-certificates/,然后执行 update-ca-certificates。
我第一次执行完,再用 openssl verify 验证,证书确实被系统信任了,网关转发请求也正常了。正当我以为搞定的时候,同事把容器重启了一次,所有问题原样复现。
这个方案失败的原因非常基础:容器文件系统是可写层,但不是持久化层。docker exec 进去做的一切修改,在容器重建之后全部丢失。而且自建网关本身是要频繁更新、滚动重启的,你不可能每次重启都手动进去执行一次命令。
也有同学尝试过曲线救国:在 docker run 后面追加一条 shell 命令,希望先执行 update-ca-certificates 再启动网关。但这样做会直接覆盖官方镜像的 entrypoint,导致网关的启动流程没跑起来,容器起来了但网关功能不正常,日志里报各种初始化失败。这个坑比证书问题还难排查。
2.3 方案三:在 APIM 控制面上传 CA 证书并配置后端策略
这个方案在 Azure 门户里操作起来特别顺畅,所以特别容易让人误以为可行。路径是:APIM 实例 → 证书 → 添加证书,上传你的私有 CA 证书,然后在 API 策略里加一段 validate-certificate 或 authentication-certificate。
我在这个方案上浪费了将近半天。上传证书很顺利,策略也配置了,但自建网关还是报后端证书链无效。
原理其实不难理解:你在 Azure 控制面上传的证书,主要服务于托管网关的证书管理、客户端证书认证、以及部分策略里显式引用的场景。对于自建网关容器来说,控制面推送下来的配置里虽然可能有证书引用,但它不会自动把证书安装到容器内的 Linux 系统信任库中。换句话说,控制面有证书不等于数据面容器信任这个证书。
APIM 策略里的 validate-certificate 确实能在某些层面控制后端证书校验行为,比如你可以设置为不校验或者仅警告。但要注意,这种设置改变的是"是否校验"的策略行为,不是"信任哪个 CA"的系统级信任关系。而且不校验这个选项本身在生产环境非常危险,等于是把你和后端之间的 TLS 防护亲手拆了。
结论是:控制面证书和数据面容器信任库,必须分开管理和维护。
2.4 方案四:把 PKCS12 证书直接挂载进 /etc/ssl/certs
这个问题在对接 NiFi 的时候特别典型。NiFi 的 keystore 通常是一个 .p12 或 .pfx 文件,内部包含私钥和证书链。有人图省事,直接把 .p12 文件挂进容器:
bash复制-v $(pwd)/nifi-keystore.p12:/etc/ssl/certs/nifi-ca.p12
挂载之后,系统中并没有任何变化,TLS 握手依然是失败。
原因有两点。第一,Linux 系统证书信任库需要的是 PEM 格式的证书文件,OpenSSL 的信任库加载逻辑并不支持直接读取 PKCS12 格式。第二,即使你转换成 PEM 格式,仅仅把文件放到 /etc/ssl/certs/ 目录里还不够,还需要按 OpenSSL 要求的 hash 命名来建软链,否则部分组件仍然找不到这个 CA。
我遇到过最隐蔽的情况是:.p12 里既有 CA 证书又有叶子证书,不加参数导出时,导出文件里可能包含多个证书块。如果你把叶子证书误当成 CA 放进了信任库,系统虽然能加载,但用它去校验真实的服务端证书时,依然会找不到有效的签发链。
2.5 不成功方案总结
我把上面这些方案整理成一张速查表,方便大家对照自己的情况。
| 方案 | 为什么失败 | 核心教训 |
|---|---|---|
| 设置 SSL_CERT_FILE / SSL_CERT_DIR 环境变量 | 只对部分进程生效,内部组件的 TLS 库不一 | 不能依赖环境变量解决系统信任库问题 |
| docker exec 手动执行 update-ca-certificates | 容器重建后丢失,无法固化 | 必须把证书注入固化到镜像或启动链 |
| 在 APIM 控制面上传证书并配置策略 | 控制面证书没进容器系统信任库 | 控制面不等于数据面 |
| 直接挂载 .p12/.pfx 到 /etc/ssl/certs | 格式不兼容,缺少 hash 软链 | 先转换 PEM,再正确挂载 |
这几个方案失败的共同根源,是大家对一个核心事实理解不够:自建网关容器内的系统信任库,才是决定 TLS 握手是否成功的最终依据。所有绕过这个信任库的方案,要么治标不治本,要么引入更大的安全隐患。
3. 正确思路:把根 CA 注入自建网关容器系统信任库
3.1 动手前先确认两个前提:PEM 格式和 CA 证书
在往容器里注入证书之前,必须确认两件事:证书格式是 PEM,且导出的是 CA 证书而不是叶子证书。
拿 NiFi 的 PKCS12 来举例。假设你手里有 nifi-keystore.p12,先用 OpenSSL 查看里面到底有哪些证书条目:
bash复制openssl pkcs12 -in nifi-keystore.p12 -info -nokeys
执行后会列出文件里的所有证书信息,你能看到证书的 subject、issuer、是否 CA 等关键字段。确认好之后,再用 -cacerts 参数只导出 CA 证书:
bash复制openssl pkcs12 -in nifi-keystore.p12 -cacerts -nokeys -out private-ca.pem
如果只想导出客户端证书(比如后面做双向 TLS 时用),就换成 -clcerts:
bash复制openssl pkcs12 -in nifi-keystore.p12 -clcerts -nokeys -out client-cert.pem
导出的 PEM 文件建议打开检查一下,看到 -----BEGIN CERTIFICATE----- 开头的块才说明导出成功。如果文件里出现了 -----BEGIN ENCRYPTED PRIVATE KEY-----,说明你把私钥也导出来了,要重新操作。
3.2 Docker 环境:用自定义镜像固化证书,一劳永逸
Docker 部署场景下,最稳的方案就是基于官方镜像做一次二次封装,把证书注入固化在镜像构建阶段。这样所有基于该镜像启动的容器,天然自带私有 CA 信任配置,不需要每次手动处理。
Dockerfile 非常简单:
dockerfile复制FROM mcr.microsoft.com/azure-api-management/gateway:latest
COPY private-ca.pem /usr/local/share/ca-certificates/private-ca.crt
RUN update-ca-certificates
这里有个细节:复制到 /usr/local/share/ca-certificates/ 目录下的文件,扩展名必须是 .crt,update-ca-certificates 才会自动处理。构建镜像:
bash复制docker build -t myregistry/apim-gateway:with-private-ca .
然后运行:
bash复制docker run \
-e APIM_ENDPOINT="你的配置端点" \
-e APIM_KEY="你的配置密钥" \
-p 8080:8080 -p 8081:8081 \
myregistry/apim-gateway:with-private-ca
注意,RUN update-ca-certificates 执行之后,CA 就写到了镜像的系统证书库里。如果官方镜像基础环境里没有 update-ca-certificates 命令,可以先执行一次容器进入检查:
bash复制docker run --rm -it --entrypoint sh mcr.microsoft.com/azure-api-management/gateway:latest
进去后运行 which update-ca-certificates 看有没有。如果没有,可以改用下面这个手动方式:
dockerfile复制RUN mkdir -p /etc/ssl/certs && \
cp /usr/local/share/ca-certificates/private-ca.crt /etc/ssl/certs/private-ca.crt && \
cd /etc/ssl/certs && \
ln -sf private-ca.crt $(openssl x509 -in private-ca.crt -hash -noout).0
这段命令的含义是:把证书复制到系统证书目录,然后用 openssl x509 -hash 计算证书的 hash 值,建一个以 HASH.0 命名的软链。OpenSSL 在查找信任库时,会按这个格式查找,没有软链就看不见证书。
两种方式任选其一。我自己优先推荐 update-ca-certificates,因为它是系统级工具,做完了会同步处理好各种细节。
3.3 Kubernetes 环境:使用 initContainer 构造共享信任库
在 Kubernetes 里部署自建网关,情况比 Docker 复杂一点。你不能随便改镜像,或者不想为一次证书配置专门维护一套镜像。这个时候 initContainer 方案非常实用。
核心思路是:用一个临时初始化容器,把官方镜像里原有的系统证书复制到共享卷,再把私有 CA 写进去并生成 hash 软链;主容器启动时,把共享卷挂载到 /etc/ssl/certs,相当于主容器的系统信任库完全由 initContainer 构造好。
完整的 Deployment YAML 片段如下:
yaml复制apiVersion: apps/v1
kind: Deployment
metadata:
name: apim-gateway
labels:
app: apim-gateway
spec:
replicas: 1
selector:
matchLabels:
app: apim-gateway
template:
metadata:
labels:
app: apim-gateway
spec:
initContainers:
- name: cert-loader
image: mcr.microsoft.com/azure-api-management/gateway:latest
command:
- /bin/sh
- -ce
- |
set -e
rm -rf /shared/certs
mkdir -p /shared/certs
# 1. 把镜像原有的系统证书整体复制到共享卷
cp -r /etc/ssl/certs/. /shared/certs/
# 2. 放入我们的私有 CA
cp /config/private-ca.pem /shared/certs/private-ca.pem
# 3. 为所有 PEM/CRT 证书生成 OpenSSL hash 软链
cd /shared/certs
for f in *.pem *.crt; do
[ -f "$f" ] || continue
h=$(openssl x509 -in "$f" -hash -noout)
ln -sf "$f" "$h.0"
done
volumeMounts:
- name: shared-certs
mountPath: /shared/certs
- name: cert-files
mountPath: /config
containers:
- name: gateway
image: mcr.microsoft.com/azure-api-management/gateway:latest
env:
- name: APIM_ENDPOINT
value: "你的配置端点"
- name: APIM_KEY
valueFrom:
secretKeyRef:
name: gateway-config
key: configuration.key
ports:
- containerPort: 8080
- containerPort: 8081
volumeMounts:
- name: shared-certs
mountPath: /etc/ssl/certs
volumes:
- name: shared-certs
emptyDir: {}
- name: cert-files
configMap:
name: private-ca
这里解释几个关键点。
第一,为什么要把原系统证书先复制到共享卷?因为主容器把 /etc/ssl/certs 整个挂载成共享卷之后,镜像里原本预置的公共 CA 证书就看不到了。如果不复制,虽然你自己的 CA 进去了,但 Azure 控制平面使用的公共 CA、其他外部 HTTPS 调用全都会因为缺系统根证书而失败。复制是为了"保留原样,再追加私有 CA"。
第二,emptyDir 卷的生命周期和 Pod 一致。Pod 重建后 initContainer 会重新执行,所以证书配置天然具备可重复性,不用手动干预。
第三,cert-files 这个 configMap 用于存放 PEM 格式的 CA 证书。创建 configMap 的方式:
bash复制kubectl create configmap private-ca --from-file=private-ca.pem=./private-ca.pem
如果你用的是自建网关官方 Helm chart 部署,也可以把 initContainer 和额外挂载通过 extraVolumes、extraInitContainers 之类的参数注入进去,具体字段名以你使用的 chart 版本为准,思路完全相同。
3.4 验证注入是否成功:三个命令排查到底
证书注入完成后,不能只看容器起来了就以为万事大吉,一定要做验证。我常用三个命令。
先在容器内验证系统信任库是否包含目标 CA:
bash复制kubectl exec -it <gateway-pod> -- openssl verify -CAfile /etc/ssl/certs/private-ca.pem /etc/ssl/certs/private-ca.pem
如果输出 private-ca.pem: OK,说明信任库加载没问题。
再用 OpenSSL 模拟一次到后端的 TLS 握手:
bash复制kubectl exec -it <gateway-pod> -- openssl s_client -connect nifi-server:8443 -showcerts
注意看握手输出里的 Verify return code。如果显示 0 (ok),说明从网关容器视角看,后端证书已经可信。如果还是报 self-signed certificate 或者 unable to get local issuer certificate,说明 CA 注入有问题,继续检查。
最后用 curl 做一次实际 HTTPS 请求:
bash复制kubectl exec -it <gateway-pod> -- curl -v https://nifi-server:8443/actuator/health
看到 SSL certificate verify ok 就说明系统级信任已经打通。
3.5 给后端 NiFi 的补充:双向 TLS 时还需要什么
如果你的 NiFi 后端开启了双向 TLS,网关不仅要去验证 NiFi 的服务端证书,还需要向 NiFi 出示自己的客户端证书。这种情况下,光注入 CA 不够,你还要把客户端证书配置进 APIM 策略。
第一步,从 NiFi 的 keystore 里导出客户端证书和私钥。通常你有两个文件:一个包含证书与私钥的 keystore,一个包含 CA 的 truststore。用 OpenSSL 导出 PEM:
bash复制openssl pkcs12 -in client-keystore.p12 -clcerts -nokeys -out client-cert.pem
openssl pkcs12 -in client-keystore.p12 -nocerts -nodes -out client-key.pem
第二步,把这两个 PEM 文件合并成一个证书文件,供 APIM 策略引用。可以把 client-cert.pem 和 client-key.pem 的内容按照"证书在前,私钥在后"的顺序合成:
bash复制cat client-cert.pem client-key.pem > client-cert-bundle.pem
之所以要捆绑,是因为 APIM 的 authentication-certificate 策略通常需要一个同时包含证书和私钥的文件,以便在 TLS 握手时向服务端出示。
第三步,在 APIM 策略中配置:
xml复制<policies>
<inbound>
<authentication-certificate thumbprint="你的证书指纹" certificate-id="my-client-cert" />
<base />
</inbound>
</policies>
注意,这个策略生效的前提是证书已经被上传到 APIM 实例,并且自建网关能访问到对应的证书内容。对于自建网关,证书文件本身仍然需要挂载到容器内或通过其它方式让网关运行时读取,具体路径和格式建议翻一下官方文档的对应版本说明。
4. 常见报错与排障实录
4.1 报错:The remote certificate is invalid according to the validation procedure
这是 .NET 运行时典型的证书校验失败报错。自建网关内部有不止一个 .NET 组件,出站调用后端时如果走的是 .NET 的 HttpClient,就会抛这个异常。
看到这个报错,先别急着查策略,第一反应应该是:网关容器系统信任库里有没有后端 CA?如果是刚部署的网关,十有八九是没注入。按第 3 节的方法把 CA 加进去,这个报错通常就会消失。
如果注入后仍然报这个错,再去检查 CN/SAN 是否匹配。自签名证书的域名、IP 地址需要真的和你调用的后端地址一致,否则会报主机名不匹配。TLS 校验包括两个维度:信任链是否有效、主机名是否匹配,缺一不可。
4.2 报错:SSL certificate problem: self-signed certificate
这个报错更多是 curl 和 OpenSSL 工具族发出来的。它的含义很直接:你访问的服务端证书是自签名的,而且签发它的 CA 不在当前系统的信任库里。
如果出现在网关容器内部,优先怀疑 CA 没注入成功。一个容易忽略的点是:证书文件拷贝到 /etc/ssl/certs/ 后,没有生成正确的 hash 软链。单纯把文件放进目录,OpenSSL 命令行工具和部分运行时未必能发现它。运行下面的命令重新生成索引:
bash复制openssl rehash /etc/ssl/certs
或者删除旧软链后重新执行 update-ca-certificates。
4.3 报错:证书明明挂载了,部分组件还是失败
这个现象最容易让人崩溃:在容器里执行 curl 访问后端一切正常,但网关转发请求还是失败。
原因是容器内部不同组件的信任来源不同。有些组件读取 /etc/ssl/certs 目录,有些组件读取环境变量指定的证书文件,还有的可能使用独立的证书库。你只解决了其中一个来源,所以表现就是"部分组件通过,部分组件失败
