业务全站 mTLS 在香港服务器上的零中断证书自动化更新:完整部署教程、实战坑位与优化技巧

那天凌晨 3 点,香港机房的服务器告警信息在手机上连续响了三次,都是“TLS handshake failed”。我们把业务全面切到 mTLS 已经两个月了,服务侧和调用侧都要双向验证证书。日志一看是客户端证书即将过期导致的大面积握手失败预警。好消息是系统提前 7 天告警;坏消息是我还没把“证书自动化更新 + 服务零中断”流程打磨到完全可托底的程度。于是,我端起机房的纸杯咖啡,决定把这件事一次性拉到生产级别:在CentOS 7 环境下,完成从 私有 CA、证书签发、自动轮转 到 前置代理零中断热替换 的全链路方案,并把一路遇到的坑和优化方法记下来。
目标与边界
目标:在香港机房的生产环境中,为“全站 mTLS”业务建立证书自动化更新机制,并确保证书轮换期间零中断。
适用场景:裸金属/虚机 + CentOS 7,前置代理(HAProxy 或 Nginx),后端多服务(容器或进程),需要服务端证书 + 客户端证书双向校验。
核心技术选型:
- 私有 CA:Smallstep step-ca
- 前置代理:HAProxy 2.8+(支持运行时证书热更新) 或 Nginx(平滑 reload)
- 自动化:systemd timer + 脚本,配合监控报警
- 证书分层:公网域名证书(可 ACME) + 私有 CA 发放的客户端证书
机房与硬件/网络基线(实测)
| 项 | 配置/数值 | 备注 |
|---|---|---|
| 机型 | 2× Intel Xeon Silver 4210R / 64 vCPU | 香港机房托管裸金属 |
| 内存 | 128 GB DDR4 | |
| 系统盘 | 2× NVMe 1.92TB(RAID1) | 业务与证书仓分开目录 |
| 网卡 | 2×10GbE(bond) | 外网/内网双平面 |
| OS | CentOS 7.9(3.10 内核) | 统一基线方便维护 |
| OpenSSL | 1.1.1(自备) | CentOS7 自带 1.0.2k,升级以启用 TLS1.3 |
| 代理 | HAProxy 2.8.x(官方稳定版) | 支持 Runtime API 热更新证书 |
| 时钟 | chrony 对时(内外 NTP 双源) | 减少证书“未生效/已过期”误报 |
| 机房延迟 | 香港本地 <1ms;沪深广 25–45ms | 便于评估证书拉取与热替换的影响 |
设计思路(文字拓扑)
[client pods / SDK / job] <--mTLS--> [HAProxy/Nginx 边界]
|---> svc-a (mTLS)
|---> svc-b (mTLS)
|---> svc-c (mTLS)
[step-ca 私有 CA 节点] <---安全通道---> [证书自动更新器(脚本/sidecar)]
- 私有 CA(step-ca):颁发客户端证书和内部服务证书,TTL 短(例如 24h~7d),通过 JWK/ACME provisioner 自动化签发与续期。
- 边界代理:对外使用公网证书(可 ACME/商用证书),同时要求客户端证书来自私有 CA;对内各服务之间也启用 mTLS。
- 自动化更新:证书到期前自动续期并热加载到代理与服务进程;HAProxy 优先用运行时 API 热更新(不 reload 进程),Nginx 走平滑 reload。
证书策略与参数(可落地)
| 证书类型 | 颁发方 | 用途 | 建议 TTL | 更新阈值 | 备注 |
|---|---|---|---|---|---|
| 公网服务端证书 | Let’s Encrypt/商用CA | 公开域名 HTTPS | 90 天 | < 20 天 | acme.sh 或 certbot,post-hook 热加载 |
| 客户端证书 | step-ca(私有) | 访问边界代理/服务 | 7 天 | < 2 天 | 批量签发,短 TTL 降低泄露风险 |
| 内部服务证书 | step-ca(私有) | 服务间 mTLS | 24–72 小时 | 50% 生命周期 | 频繁轮换+自动化热加载 |
| 中间/根证书 | step-ca | 信任链 | 1–3 年 | 提前 60 天 | 根极少轮换,中间按计划滚动 |
一、准备环境(CentOS 7)
# 基础工具
yum install -y epel-release wget curl git jq chrony
# OpenSSL 1.1.1(示例:自编译或安装发行包,略)
# 确保 `openssl version` 输出 >= 1.1.1
# 时间同步(双源)
systemctl enable chronyd --now
chronyc sources -v
坑 1:时钟漂移
mTLS 对证书的 NotBefore/NotAfter 很敏感。香港机房里如果 NTP 只指向一个外部源,切换 BGP/丢包时会抖。做双源并监控 chrony 的偏移,我把阈值设为 ±100ms 告警。
二、部署 step-ca 私有 CA
1) 安装与初始化
# 安装 step & step-ca(参考官方 RPM/二进制)
# 这里假设放到 /usr/local/bin/step
useradd -r -s /sbin/nologin stepca
mkdir -p /opt/step/{config,secrets,data}
chown -R stepca:stepca /opt/step
# 初始化 CA(交互式,也可无头)
sudo -u stepca step ca init \
--name "HK-Prod-Private-CA" \
--dns "ca.internal.hk" \
--address ":9000" \
--provisioner "ops" \
--provisioner-password-file /opt/step/secrets/prov.pass \
--password-file /opt/step/secrets/ca.pass \
--deployment-type standalone \
--with-ca-url "https://ca.internal.hk:9000" \
--root "/opt/step/config/certs/root_ca.crt"
# 生成 systemd service
cat >/etc/systemd/system/step-ca.service <<'EOF'
[Unit]
Description=Smallstep Certificate Authority
After=network.target
[Service]
User=stepca
Group=stepca
ExecStart=/usr/local/bin/step-ca /opt/step/config/ca.json --password-file /opt/step/secrets/ca.pass
Restart=always
LimitNOFILE=65536
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable step-ca --now
ss -lntp | grep 9000
2) 启用 ACME / JWK Provisioner
在 ca.json 中配置 provisioners(省略完整 JSON),同时保管好 prov.pass。我采用JWK + ACME 双通道:
JWK 用于服务端/客户端脚本自动申请证书;
ACME 方便某些组件用原生 ACME 客户端续期。
坑 2:CA 端口暴露与访问控制
CA 不建议裸露公网。我的做法是仅内网可达,外机通过 WireGuard 进 VPN 发起申请。HAProxy/服务容器里跑的续期脚本走内网访问 https://ca.internal.hk:9000。
三、边界代理(HAProxy 方案:首选,真正“热更新”)
1) 安装 HAProxy 2.8+
# 推荐使用官方稳定版或社区仓库,略
haproxy -vv | grep -E 'OpenSSL|TLSv1.3'
2) 目录结构
/etc/haproxy/
├── haproxy.cfg
├── certs/ # 公网证书 *.pem(含链)
├── ca-clients/ # 私有CA根/中间,用于校验"客户端证书"
├── runtime/ # 运行时 API socket、动态证书存放
└── renew/ # 签发脚本与 state
3) haproxy.cfg(要点)
global
daemon
stats socket /etc/haproxy/runtime/admin.sock mode 660 level admin
stats timeout 30s
tune.ssl.default-dh-param 2048
ssl-default-bind-ciphersuites TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256
ssl-default-bind-options ssl-min-ver TLSv1.2 no-tls-tickets
defaults
mode http
timeout connect 5s
timeout client 60s
timeout server 60s
option http-keep-alive
frontend fe_https
bind :443 ssl crt-list /etc/haproxy/certs/crt-list.txt \
ca-file /etc/haproxy/ca-clients/ca.crt verify required crt-ignore-err all
http-response set-header X-SSL-Client-Serial %[ssl_c_serial,hex]
http-response set-header X-SSL-Client-SAN %[ssl_c_s_dn(cn)]
default_backend be_apps
backend be_apps
balance leastconn
server s1 10.0.10.11:8443 ssl verify required ca-file /etc/haproxy/ca-clients/ca.crt check
server s2 10.0.10.12:8443 ssl verify required ca-file /etc/haproxy/ca-clients/ca.crt check
关键点:
- crt-list 管理多个证书条目;
- ca-file ... verify required 强制客户端必须带私有 CA签发的证书;
- 后端同样走 mTLS(server ... ssl verify required);
- Runtime API 打开后可以在线替换证书内容,无需 reload。
4) 在线热更新证书(零中断)
更新单张证书(示例域名 api.example.com.pem):
# 1. 续期拿到新证书(见第四章脚本)
# 2. 通过 Runtime API 在线更新
echo -e "set ssl cert /etc/haproxy/certs/api.example.com.pem <<\n$(cat /etc/haproxy/certs/api.example.com.pem)\n" \
| socat stdio /etc/haproxy/runtime/admin.sock
# 3. 提交并生效
echo "commit ssl cert /etc/haproxy/certs/api.example.com.pem" \
| socat stdio /etc/haproxy/runtime/admin.sock
更新 crt-list 新增/移除条目也可以通过 Runtime API 完成,老连接不受影响,真正 0 丢连。
坑 3:PEM 顺序与链
必须保证 PEM 包含服务器证书 + 中间证书链 + 私钥,顺序错或链不完整会导致客户端握手失败。上线前用 openssl s_client -connect host:443 -showcerts 验证。
四、证书自动化续期与部署(脚本 + systemd timer)
1) 申请/续期(step CLI,JWK)
以服务端证书为例(供 HAProxy 使用):
# renew/issue_server_cert.sh
#!/usr/bin/env bash
set -euo pipefail
DOMAIN="api.example.com"
CA_URL="https://ca.internal.hk:9000"
PROV="ops"
CRT_DIR="/etc/haproxy/certs"
TMP="/etc/haproxy/renew/.tmp-${DOMAIN}-$$"
mkdir -p "$TMP"
# 申请新证书(含私钥)
step ca certificate "$DOMAIN" \
"$TMP/${DOMAIN}.crt" "$TMP/${DOMAIN}.key" \
--provisioner "$PROV" --ca-url "$CA_URL" \
--root /opt/step/config/certs/root_ca.crt \
--not-after 720h # 30 天
# 打包 PEM(证书 + 链 + 私钥)
cat "$TMP/${DOMAIN}.crt" /opt/step/config/certs/intermediate_ca.crt "$TMP/${DOMAIN}.key" \
> "$TMP/${DOMAIN}.pem"
# 原子替换
install -m 640 -o root -g haproxy "$TMP/${DOMAIN}.pem" "$CRT_DIR/${DOMAIN}.pem"
# 运行时热更新
echo -e "set ssl cert $CRT_DIR/${DOMAIN}.pem <<\n$(cat $CRT_DIR/${DOMAIN}.pem)\n" \
| socat stdio /etc/haproxy/runtime/admin.sock
echo "commit ssl cert $CRT_DIR/${DOMAIN}.pem" \
| socat stdio /etc/haproxy/runtime/admin.sock
rm -rf "$TMP"
systemd timer 定时跑(留有更新余量):
# /etc/systemd/system/renew-haproxy-cert.service
[Unit]
Description=Renew HAProxy Server Cert (step-ca)
[Service]
Type=oneshot
ExecStart=/bin/bash /etc/haproxy/renew/issue_server_cert.sh
# /etc/systemd/system/renew-haproxy-cert.timer
[Unit]
Description=Timer for Renew HAProxy Server Cert
[Timer]
OnCalendar=*-*-* 03:00:00
RandomizedDelaySec=900
Persistent=true
[Install]
WantedBy=timers.target
systemctl daemon-reload
systemctl enable --now renew-haproxy-cert.timer
2) 客户端证书批量续期
对接 SDK/任务进程时,我做了发放控件(init 容器/预启动脚本):
step ca certificate "client-$HOSTNAME" \
/etc/ssl/client/client.crt /etc/ssl/client/client.key \
--provisioner ops --ca-url "$CA_URL" \
--root /opt/step/config/certs/root_ca.crt \
--not-after 168h # 7 天
客户端证书路径通过 Kubernetes Secret / 配置管理或Ansible分发;
进程内使用inotify监听证书文件变更,或通过 SIGHUP 触发重新加载信任材料(下节有 Go 示例)。
五、Nginx 备用方案(平滑 reload,近零中断)
如果你更熟 Nginx,也可以这样做(我在部分站点用了它做二级入口):
核心配置:
server {
listen 443 ssl http2;
server_name api.example.com;
ssl_certificate /etc/nginx/certs/api.example.com.crt; # 含链
ssl_certificate_key /etc/nginx/certs/api.example.com.key;
ssl_client_certificate /etc/nginx/ca-clients/ca.crt;
ssl_verify_client on; # 必须带客户端证书
ssl_protocols TLSv1.2 TLSv1.3;
location / {
proxy_pass https://backend_pool;
proxy_ssl_certificate /etc/nginx/certs/internal.crt;
proxy_ssl_certificate_key /etc/nginx/certs/internal.key;
proxy_ssl_trusted_certificate /etc/nginx/ca-clients/ca.crt;
proxy_ssl_verify on;
}
}
续期后执行 nginx -s reload,Nginx 会优雅替换 worker,一般无感。
注意:reload 期间新建连接切到新 worker,极端高并发下P99 可能抖动 1–2ms,但业务层面可接受。
六、后端服务的 mTLS 与热加载(Go 例)
很多微服务用 Go 写,我统一了一个证书热加载模式:
// tlsconfig.go
package tlsconfig
import (
"crypto/tls"
"crypto/x509"
"io/ioutil"
"sync/atomic"
"time"
)
type Reloader struct {
certPath string
keyPath string
caPath string
cert atomic.Value // *tls.Certificate
pool atomic.Value // *x509.CertPool
}
func NewReloader(cert, key, ca string) (*Reloader, error) {
r := &Reloader{certPath: cert, keyPath: key, caPath: ca}
if err := r.reload(); err != nil {
return nil, err
}
go r.watch()
return r, nil
}
func (r *Reloader) watch() {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for range ticker.C {
_ = r.reload() // 失败重试
}
}
func (r *Reloader) reload() error {
cert, err := tls.LoadX509KeyPair(r.certPath, r.keyPath)
if err != nil { return err }
caBytes, err := ioutil.ReadFile(r.caPath)
if err != nil { return err }
pool := x509.NewCertPool()
pool.AppendCertsFromPEM(caBytes)
r.cert.Store(&cert)
r.pool.Store(pool)
return nil
}
func (r *Reloader) TLSConfig(requireClientCert bool) *tls.Config {
cfg := &tls.Config{
MinVersion: tls.VersionTLS12,
GetCertificate: func(*tls.ClientHelloInfo) (*tls.Certificate, error) {
c := r.cert.Load().(*tls.Certificate)
return c, nil
},
ClientCAs: r.pool.Load().(*x509.CertPool),
}
if requireClientCert {
cfg.ClientAuth = tls.RequireAndVerifyClientCert
}
return cfg
}
服务启动时用 reloader.TLSConfig(true),证书文件被脚本原子替换后,无需重启进程即可生效。
七、监控与告警(必须做)
证书有效期:Prometheus 自定义 exporter 或 cron 脚本输出 gauge:tls_cert_not_after_seconds{cn="api.example.com"}。阈值:
公网证书:< 20 天告警
私有证书:< 2 天告警
握手错误:按 haproxy_frontend_ssl_handshake_failure_total / Nginx SSL_do_handshake() failed 统计 + 突增告警。
Chrony 偏移:|offset| > 100ms 告警。
运行时 API 操作失败:脚本返回码/日志关键字监控。
八、灰度轮换与回滚
灰度:HAProxy 中先把新证书加载到备用域名或低权重 VIP,少量流量验证 SNI/客户端兼容性后,再覆盖主域名。
回滚:保留上一个 PEM 的备份,运行时 API 可以即时回写旧证书并 commit。
信任链滚动(中间证书替换):先在客户端下发新 + 旧双链的信任,再逐步切换服务端链,最后移除旧链。
九、现场真实“坑位”与解法
CentOS 7 的 OpenSSL 太旧
解法:业务面统一到 1.1.1+,Nginx/HAProxy 使用自行编译或带新 OpenSSL 的发行包。
PEM 权限与属组
解法:install -m 640 -o root -g haproxy,避免 world-readable;SELinux 若开启需布置 context。
客户端证书命名与吊销
解法:CN/SAN 带唯一标识(host、pod、appId),step-ca 启用 CRL/OCSP;被盗即吊销并下发新证书。
ACME 与私有 CA 混用
解法:外部只用公有证书,mTLS 客户端校验只信任私有 CA,分层清晰避免链乱。
大并发下 reload 抖动(Nginx)
解法:高峰使用 HAProxy 运行时热更新;Nginx 仅做静态站,核心接口走 HAProxy。
时区与 CRON 混乱
解法:全部使用 UTC 定时 + 日志;界面展示再转时区,避免跨机房“昨天/今天”的错觉。
十、外网证书(Let’s Encrypt)一并自动化(可选)
我在另一台边界(仅公网 HTTPS,不做 mTLS)用 acme.sh:
acme.sh --issue -d www.example.com --nginx
acme.sh --install-cert -d www.example.com \
--fullchain-file /etc/haproxy/certs/www.example.com.pem \
--key-file /etc/haproxy/certs/www.example.com.key \
--reloadcmd 'echo -e "set ssl cert /etc/haproxy/certs/www.example.com.pem <<\n$(cat /etc/haproxy/certs/www.example.com.pem)\n" | socat stdio /etc/haproxy/runtime/admin.sock; echo "commit ssl cert /etc/haproxy/certs/www.example.com.pem" | socat stdio /etc/haproxy/runtime/admin.sock'
通过 --reloadcmd 直接热更新到 HAProxy。
十一、最终检查清单(上线前 10 条)
- 根/中间证书已同步到所有客户端与服务端信任仓。
- HAProxy Runtime API socket 权限安全可用。
- 证书 PEM 顺序正确、链完整。
- NTP 双源,偏移告警正常。
- systemd timer 正常触发,脚本幂等。
- 监控项(有效期、握手失败、API 错误)有阈值与通知。
- 回滚预案:上一版 PEM 随时可 commit。
- 压测下热更新无明显 P99 抖动。
- 灰度域名验证通过再覆盖主域名。
- 安全:证书私钥最小权限、审计续期日志。
从“值班惊魂”到“可复制的标准件”
回到那天凌晨,第一轮把后端服务证书先换成 48 小时短证、配合 Go 的热加载,第二轮再把 HAProxy 的服务器证书接入运行时热更新。第三天早上,告警面板安静了,我在机房门口看了一会儿透过玻璃墙的晨光,心里清楚:这套“私有 CA + 自动续期 + 运行时热替换 + 全链路监控”已经成了我们香港业务的标准件。每一次证书轮换,都是一次对可用性工程的致敬。