那天早上九点多,用户在群里问"网站是不是挂了",配了一张截图:浏览器全屏红色警告,
NET::ERR_CERT_DATE_INVALID。我第一反应是"服务器挂了",登上去看服务好好的。用 curl 一测才明白——证书过期了。我们当时用的是手动申请的免费证书,90 天有效期,申请的时候在日历上记了提醒,但那次提醒不知道为什么没响。
从发现到修好用了五分钟(重新申请 + 部署),但那五分钟的观感非常糟:用户看到的是"这个网站不安全"的红色警告,比 500 错误严重得多——500 是"坏了",证书过期是"危险"。
这次之后我把证书这件事彻底自动化了。这篇写完整的方案:证书类型怎么选、
acme.sh怎么申请和自动续期、nginx 的 TLS 配置、老设备兼容的坑,以及最重要的——过期监控。TL;DR:用
acme.sh(或 certbot)申请 Let's Encrypt 证书,泛域名必须用 DNS 验证,单域名可以用 HTTP-01。自动续期靠 acme.sh 自带的 cron +--reloadcmd平滑重载 nginx。nginx 侧必配:TLS 1.2/1.3、禁用弱加密套件、OCSP stapling、HSTS(先小max-age试)、ssl_trusted_certificate指向完整链。最重要的不是申请,是监控:证书剩余天数 < 20 天必须告警,并且要有一个"从外部检查"的拨测,不能只信本机 cron。
目录
- 一、先搞清楚证书这块的几个概念
- 二、申请:acme.sh 实战
- 三、自动续期:别再靠日历提醒
- 四、nginx 的 TLS 配置(可直接复制)
- 五、最常见的坑:证书链不完整
- 六、老设备兼容:那些"看起来配置对了但就是打不开"
- 七、监控:最重要的一节
- 八、多机与多域名怎么管
- 九、内网与自签证书
- 十、坑清单
一、先搞清楚证书这块的几个概念
验证级别(决定"贵不贵、快不快"):
| 类型 | 验证内容 | 签发时间 | 价格 | 适合 |
|---|---|---|---|---|
| DV(域名验证) | 证明你控制这个域名 | 秒~分钟 | 免费(Let's Encrypt) | 绝大多数场景 |
| OV(组织验证) | 额外验证企业身份 | 1~3 天 | 几百到几千/年 | 企业站、金融 |
| EV(扩展验证) | 最严格 | 1~2 周 | 更贵 | 曾经显示绿栏,现在浏览器也不突出了 |
结论:DV 就够了。 EV 曾经的"绿色地址栏"卖点在 Chrome/Firefox 早就取消了,现在除了证书详情里能看到组织名,用户感知不到区别。
覆盖范围:
| 类型 | 覆盖 | 说明 |
|---|---|---|
| 单域名 | example.com |
|
| SAN(多域名) | example.com + www.example.com + ... |
最常用,一张证书放多个域名 |
| 泛域名 | *.example.com |
必须用 DNS 验证 |
有效期:现在所有公开 CA 的证书最长 398 天(约 13 个月),Let's Encrypt 是 90 天。短有效期是行业趋势(减少泄露后的风险),代价就是必须自动化——手动续 90 天证书是一定会出事的。
免费 vs 付费:Let's Encrypt 完全够用,唯一的"缺点"是 90 天有效期和没有技术支持。付费证书的价值主要在:OV/EV、更长有效期、保险、支持多域名时更方便。对我们这种技术站点,免费的是最优解。
二、申请:acme.sh 实战
我用的是 acme.sh(纯 Shell 实现,不依赖 Python,比 certbot 轻):
# 安装
curl https://get.acme.sh | sh -s email=you@example.com
source ~/.bashrc
方式一:HTTP-01 验证(单域名,最简单)
原理:ACME 服务器访问 http://你的域名/.well-known/acme-challenge/xxx,能访问到就证明你控制这个域名。
# webroot 模式:指定网站根目录
acme.sh --issue -d example.com -d www.example.com -w /var/www/html
# 或者用 nginx 模式(acme.sh 自动改 nginx 配置)
acme.sh --issue -d example.com --nginx
前提:80 端口必须能从外网访问。如果你的服务器在防火墙后面、或者还没开通 80 端口,这种方式就不行。
方式二:DNS 验证(泛域名必须用)
原理:acme.sh 调 DNS 服务商的 API 添加一条 _acme-challenge 的 TXT 记录。
# 以阿里云 DNS 为例
export Ali_Key="xxx"
export Ali_Secret="xxx"
acme.sh --issue --dns dns_ali -d example.com -d '*.example.com'
acme.sh 支持几十家 DNS 服务商(dns_ali、dns_dp(腾讯云 DNSPod)、dns_cf(Cloudflare)、dns_gd(GoDaddy)……)。用对应变量导出 API 密钥就行,密钥会保存在 ~/.acme.sh/account.conf,注意权限。
DNS 方式的好处:
- 能签泛域名;
- 不要求 80 端口可达(内网机器也能签);
- 支持 DNS 别名模式(CNAME 委派,避免把主域名的 API 密钥交给脚本)。
我的建议:如果只是几个固定域名,用 HTTP-01;如果需要泛域名或者服务器不方便开 80,用 DNS。DNS 方式的 API 密钥权限要最小化(只给 DNS 修改权限)。
部署证书
申请下来的证书在 ~/.acme.sh/example.com/,不要直接把这个目录配给 nginx(目录结构会变,而且权限混乱)。用 --install-cert 复制到固定位置:
mkdir -p /etc/nginx/ssl
acme.sh --install-cert -d example.com \
--key-file /etc/nginx/ssl/example.com.key \
--fullchain-file /etc/nginx/ssl/example.com.crt \
--reloadcmd "systemctl reload nginx"
--fullchain-file 是关键:它包含"站点证书 + 中间证书"的完整链。如果你用 --cert-file(只有站点证书),老设备会因为拿不到中间证书而报"不受信"——这是最常见的坑,下一节细说。
三、自动续期:别再靠日历提醒
acme.sh 安装时会自动加一条 cron:
crontab -l | grep acme
# 0 0 * * * "/root/.acme.sh"/acme.sh --cron --home "/root/.acme.sh" > /dev/null
它每天检查一次,证书剩余不到 30 天就自动续期,然后执行你配置的 --reloadcmd。
验证自动续期真的能跑(很多人配完就不管了,结果续期失败):
# 强制续期一次(--force),看流程是否正常
acme.sh --renew -d example.com --force
# 看日志
tail -50 ~/.acme.sh/acme.sh.log
注意 --reloadcmd 要能真正生效。我一开始写的是 systemctl restart nginx,能用但会短暂中断(虽然很短)。改成 systemctl reload nginx 后是平滑重载,不断连接。
还有一个必须验证的点:续期后 nginx 有没有真的加载到新证书。nginx 的 reload 会重新读证书文件,但如果你用了 ssl_certificate 指向的符号链接,或者文件权限不对,可能加载失败。验证:
# 看证书的实际生效时间(notBefore 应该是续期那天)
echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null \
| openssl x509 -noout -dates
我踩过的一个坑:acme.sh 的 cron 是以 root 跑的,但我们的 nginx 是 www-data 启动的(虽然 master 是 root,读证书没问题)。后来有一次我改了证书目录权限,chmod 700 /etc/nginx/ssl,结果 nginx worker 读不到证书——表现是 reload 成功、nginx -t 通过,但新连接握手失败。
正确的权限:
chmod 755 /etc/nginx/ssl
chmod 644 /etc/nginx/ssl/*.crt
chmod 600 /etc/nginx/ssl/*.key # 私钥必须 600
四、nginx 的 TLS 配置(可直接复制)
这是我们生产在用的(简化版):
# HTTP:全部跳转到 HTTPS(ACME 的 challenge 要放行)
server {
listen 80;
server_name example.com www.example.com;
location /.well-known/acme-challenge/ {
root /var/www/html; # HTTP-01 验证用
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
http2 on;
server_name example.com www.example.com;
# 证书(fullchain!)+ 私钥
ssl_certificate /etc/nginx/ssl/example.com.crt;
ssl_certificate_key /etc/nginx/ssl/example.com.key;
ssl_trusted_certificate /etc/nginx/ssl/example.com.crt; # OCSP stapling 用
# 协议:TLS 1.2 和 1.3(1.0/1.1 已废弃)
ssl_protocols TLSv1.2 TLSv1.3;
# 加密套件(TLS 1.2 用;1.3 的套件由 nginx 自动管理)
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
ssl_prefer_server_ciphers off; # 1.3 时代推荐 off
# 会话复用
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off; # 关掉 ticket(有安全隐患,且多机部署要同步密钥)
# OCSP stapling:把证书状态"钉"在握手响应里,省掉客户端去 CA 查询
ssl_stapling on;
ssl_stapling_verify on;
resolver 8.8.8.8 1.1.1.1 valid=300s;
resolver_timeout 5s;
# HSTS(先小后大,见下文说明)
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# 其他安全头
add_header X-Content-Type-Options nosniff always;
location / {
proxy_pass http://django;
...
}
}
几个要点:
1. ssl_session_tickets off:会话票据需要服务器保存一个密钥,多机部署时如果密钥不同,复用会失败;而且密钥泄露能解密历史流量。关掉代价很小(用 session cache 复用足够)。
2. OCSP stapling:不配的话,客户端(尤其老安卓)会去 CA 的 OCSP 服务器查询证书状态,握手可能慢几百毫秒甚至超时。配上之后服务器每段时间自己去查一次并缓存,握手时直接带上。
验证 stapling 生效:
echo | openssl s_client -connect example.com:443 -servername example.com -status 2>/dev/null | grep -A5 'OCSP Response'
3. HSTS 要谨慎:一旦浏览器收到 Strict-Transport-Security 头,在 max-age 时间内强制用 HTTPS 访问,且用户无法点击"继续访问不安全网站"跳过。如果这时候你的 HTTPS 挂了,用户就彻底打不开了。
安全的上线步骤:
第一周:max-age=300 (5 分钟,出问题能快速恢复)
第二周:max-age=86400 (1 天)
确认稳定后:max-age=31536000 (1 年)
includeSubDomains 要更谨慎——它会应用到所有子域名,如果某个子域名还没上 HTTPS,加上这个头之后它就访问不了了。确认所有子域名都支持 HTTPS 再加。
五、最常见的坑:证书链不完整
现象:浏览器访问正常,但某些老设备(老安卓、老 iOS、Java 客户端、curl 在某些系统上)报"证书不受信"。
原因:证书链长这样:
根证书(Root CA,预装在操作系统里)
└── 中间证书(Intermediate,由根签发)
└── 站点证书(由中间签发)
浏览器为了验证站点证书,需要拿到中间证书。两种途径:
- 服务器在握手时把中间证书一起发(正确做法);
- 客户端自己去下载(很多客户端不会做这件事)。
如果你只配了站点证书(--cert-file 而不是 --fullchain-file),服务器发的链就缺一环,支持 AIA 下载的客户端能补救,不支持的就报错。
这就是"浏览器没问题但 App 报错"的经典原因。
检查:
# 看服务器发了几张证书
echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null \
| grep -c 'BEGIN CERTIFICATE'
# 或者看链的深度
openssl s_client -connect example.com:443 -servername example.com 2>/dev/null \
| openssl x509 -noout -text | grep -A2 'Certificate chain'
# 应该看到 2 张(站点 + 中间),现代配置有时是 2 张
修复:确保 ssl_certificate 指向 fullchain(站点证书在上,中间证书在后,拼接在同一个 PEM 文件里)。
也可以用 SSL Labs 在线检测(ssllabs.com/ssltest),它会明确告诉你 "Chain issues: Incomplete"。
六、老设备兼容:那些"看起来配置对了但就是打不开"
这几个坑我们真遇到过:
1. DST Root CA X3 过期事件(2021 年)
Let's Encrypt 早期的证书链交叉签名了 DST Root CA X3(为了兼容老安卓),这个根证书 2021 年 9 月过期。之后老安卓(Android 7.1 以下)访问所有 Let's Encrypt 站点都报错。
解决办法(当时):
- 换用
ISRG Root X1(老设备不认识); - 或者显式指定
--preferred-chain "ISRG Root X1"; - 或者给老设备用付费证书(有其他根交叉签名)。
现在(2026 年)这个问题基本消失了,但它说明一件事:证书链的选择会影响老设备兼容性。
2. TLS 1.0/1.1 要不要开
2021 年之后主流浏览器全部禁用 TLS 1.0/1.1。现在不要开,开了反而是安全漏洞(而且过不了合规检查)。
代价:非常老的设备(Android 4.x、Windows XP、Java 6)访问不了。我们的判断是:这些设备的用户量已经低于 0.1%,不值得为它们降低安全性。
3. 密钥类型:RSA 还是 ECDSA
| 类型 | 大小 | 兼容性 | 性能 |
|---|---|---|---|
| RSA 2048 | 2048 bit | 最好 | 握手稍慢 |
| ECDSA P-256 | 256 bit | 很好(2015 年后的设备都行) | 握手快 |
acme.sh 默认申请 ECDSA(--keylength ec-256)。如果要最大兼容:
acme.sh --issue -d example.com --keylength 2048 # RSA 2048
我们用的是 ECDSA,兼容性没问题且握手快。
4. 混合内容(Mixed Content)
HTTPS 页面里加载了 http:// 的资源(图片、JS、CSS),浏览器会阻止加载(或者只阻止脚本,取决于类型)。表现是"页面样式乱了"或者"某些功能失效"。
检查:
grep -rn 'http://' templates/ --include='*.html' | grep -v 'http://www.w3.org'
修复:全部改成 https:// 或者协议相对路径 //example.com/x.js(但协议相对路径现在不推荐了,直接用 https)。
Django 里的一个坑:{{ MEDIA_URL }} 或者数据库里存的历史绝对 URL 如果是 http,页面就会混合内容。我们把历史数据批量改了一次,并且新数据入库时统一成 https。
七、监控:最重要的一节
自动续期也可能失败(DNS API 变了、80 端口被墙、磁盘满了、acme.sh 的 cron 没跑)。所以监控是必须的。
1. 本机脚本检查剩余天数
#!/bin/bash
# /opt/scripts/check_cert.sh
DOMAINS=("example.com" "www.example.com")
WARN_DAYS=20
for d in "${DOMAINS[@]}"; do
end=$(echo | timeout 10 openssl s_client -connect "$d:443" -servername "$d" 2>/dev/null \
| openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2)
if [ -z "$end" ]; then
echo "ALERT: 无法获取 $d 的证书"
continue
fi
end_epoch=$(date -d "$end" +%s)
now_epoch=$(date +%s)
days=$(( (end_epoch - now_epoch) / 86400 ))
if [ "$days" -lt "$WARN_DAYS" ]; then
echo "ALERT: $d 证书剩余 $days 天"
else
echo "OK: $d 证书剩余 $days 天"
fi
done
加到 crontab 每天跑一次,有输出就告警。
2. 外部拨测(更可靠)
本机脚本有个盲区:它检查的是"本机认为的证书",而用户访问可能经过 CDN、负载均衡、或者 DNS 解析到了另一台机器——那些地方的证书和本机可能不是同一张。
所以要做外部检查:用一个独立的监控服务(或者另一台机器上的 cron)从外网访问你的域名并检查证书。
我们的做法:监控脚本部署在另一台机器上,每天检查一次所有域名,剩余 < 20 天就告警。这样即使本机的 acme.sh 挂了、或者 CDN 上的证书没更新,也能发现。
3. 把证书过期加到"外部拨测"里
前面那篇监控的文章讲过外部拨测(检查站点可用性)。把证书检查也加进去,一次请求同时验证:
curl -vI https://example.com 2>&1 | grep -E 'subject|expire|issuer'
4. 定期用 SSL Labs 评分
https://www.ssllabs.com/ssltest/analyze.html?d=example.com 会给一个 A+ 到 F 的评分,并指出配置问题(弱套件、链不完整、不支持的协议等)。我每季度跑一次,尤其是改了 nginx 配置之后。
八、多机与多域名怎么管
多域名:一张 SAN 证书放多个域名是最省事的(acme.sh -d a.com -d b.com -d c.com)。超过一定数量(比如 50 个)可以考虑泛域名。
多机:证书要分发到每台机器。三种做法:
| 做法 | 说明 | 适合 |
|---|---|---|
| 在负载均衡层做 TLS 终结 | 只有 LB 上有证书,内部走 HTTP | 推荐,最省事 |
| 每台机器各申请一张 | acme.sh 跑在每台机器上 | 简单,但证书数量多 |
| 一台申请,分发到其余机器 | rsync/scp + reload | 要自己写分发脚本 |
我们的做法:TLS 在 nginx(前置)终结,内部 Django 走 HTTP。这样:
- 证书只有一份(或者两台 nginx 各一份);
- 内部通信不需要管证书;
- 加机器不用管证书。
代价:内部是明文 HTTP(在内网、VPC 内通常可接受,如果要求高就上内网证书或者 mTLS)。
如果用"一台申请 + 分发":
#!/bin/bash
# 在证书机器上跑,分发到其余节点
for host in web2 web3; do
rsync -av /etc/nginx/ssl/ "$host:/etc/nginx/ssl/"
ssh "$host" "nginx -t && systemctl reload nginx"
done
注意 nginx -t 一定要先跑,配置有问题就不要 reload。
九、内网与自签证书
内网服务(比如监控系统、内部 API)不一定需要公网证书。几个选择:
1. mkcert(本地开发首选)
mkcert -install # 安装本地 CA
mkcert localhost 127.0.0.1 ::1 # 生成证书
本地开发用 HTTPS 测试(能避免"本地 http、生产 https"导致的 cookie/混合内容问题),强烈推荐。
2. 自建 CA(内网服务)
用 openssl 建一个简单的 CA,签发内网证书,把 CA 根证书分发到所有客户端(加入系统信任库)。工作量不算大,规模小的时候够用。
3. 内网也用 Let's Encrypt
如果内网机器能解析公网域名且能做 DNS 验证,完全可以签公网证书(DNS-01 不需要 80 端口)。我们有一台内网服务就是这么做的,省去了自建 CA 的麻烦。
十、坑清单
- 手动管理证书 + 日历提醒 → 一定会忘。必须自动化。
- 只配站点证书不配 fullchain → 老设备报"不受信"。用
--fullchain-file。 ssl_certificate指向 acme.sh 的目录 → 目录结构会变,升级后失效。用--install-cert复制到固定路径。- 证书文件权限不对 → nginx worker 读不到,握手失败(且
nginx -t查不出来)。 - 配完自动续期不验证 → cron 失败了也不知道。用
--renew --force测一次。 --reloadcmd用 restart 而不是 reload → 每次续期短暂中断。- HSTS 一上来就 max-age=31536000 → 出问题无法回退。从小到大。
includeSubDomains加得太早 → 某个子域名没上 HTTPS,直接访问不了。- 没配 OCSP stapling → 老客户端握手慢或超时。
- 开着 TLS 1.0/1.1 → 安全合规不过关。
- 页面里有 http:// 资源 → 混合内容,样式/功能失效。
ssl_session_tickets on+ 多机 → 票据密钥不同,复用失败(且密钥要轮换,管理麻烦)。- 只做本机证书检查 → CDN/负载均衡上的证书过期了发现不了。要有外部拨测。
- DNS API 密钥权限过大 → 泄露影响整个域名。只给 DNS 修改权限。
- 泛域名证书用 HTTP-01 申请 → 不支持,必须 DNS 验证。
- 证书私钥进了 Git 或镜像 → 泄露。私钥只在目标机器上,权限 600。
最后说说这次事故之后的改变。
技术上其实没什么高深的:就是"申请 → 自动续期 → 监控"三件事,加起来不到一小时的工作量。但它给我最大的教训是对"会过期的东西"的态度:
任何有时间寿命的东西——证书、域名、API 密钥、第三方服务的授权、云资源的配额——都应该有:
- 自动化续期(能自动的都自动);
- 到期前告警(自动的也可能失败);
- 外部视角的验证(不能只信本机);
- 写进文档(接手的人知道这些存在)。
我们现在有个"到期清单"文档,把所有会过期的东西列出来(证书、域名、DNS、第三方 API 授权、SSL 监控、短信服务余额……),每季度过一遍。这个清单救过我们至少两次——一次是另一个域名(不是主要的那个)的证书,一次是某个第三方服务的 API 配额到期。
最后一句提醒:证书过期不是"服务器坏了",是"服务器看起来不安全"。用户对红色警告的反应比 500 错误强烈得多,而且会扩散("这个网站不安全"的截图比"打不开"更有传播力)。所以这件事值得花一小时做彻底。