提示

返回博客列表

证书过期那五分钟:全站 HTTPS 的申请、自动续期与 TLS 配置

那天早上九点多,用户在群里问"网站是不是挂了",配了一张截图:浏览器全屏红色警告,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。

目录

一、先搞清楚证书这块的几个概念

验证级别(决定"贵不贵、快不快"):

类型 验证内容 签发时间 价格 适合
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,由根签发)
         └── 站点证书(由中间签发)

浏览器为了验证站点证书,需要拿到中间证书。两种途径:

  1. 服务器在握手时把中间证书一起发(正确做法);
  2. 客户端自己去下载(很多客户端不会做这件事)。

如果你只配了站点证书(--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 的麻烦。

十、坑清单

  1. 手动管理证书 + 日历提醒 → 一定会忘。必须自动化。
  2. 只配站点证书不配 fullchain → 老设备报"不受信"。用 --fullchain-file。
  3. ssl_certificate 指向 acme.sh 的目录 → 目录结构会变,升级后失效。用 --install-cert 复制到固定路径。
  4. 证书文件权限不对 → nginx worker 读不到,握手失败(且 nginx -t 查不出来)。
  5. 配完自动续期不验证 → cron 失败了也不知道。用 --renew --force 测一次。
  6. --reloadcmd 用 restart 而不是 reload → 每次续期短暂中断。
  7. HSTS 一上来就 max-age=31536000 → 出问题无法回退。从小到大。
  8. includeSubDomains 加得太早 → 某个子域名没上 HTTPS,直接访问不了。
  9. 没配 OCSP stapling → 老客户端握手慢或超时。
  10. 开着 TLS 1.0/1.1 → 安全合规不过关。
  11. 页面里有 http:// 资源 → 混合内容,样式/功能失效。
  12. ssl_session_tickets on + 多机 → 票据密钥不同,复用失败(且密钥要轮换,管理麻烦)。
  13. 只做本机证书检查 → CDN/负载均衡上的证书过期了发现不了。要有外部拨测。
  14. DNS API 密钥权限过大 → 泄露影响整个域名。只给 DNS 修改权限。
  15. 泛域名证书用 HTTP-01 申请 → 不支持,必须 DNS 验证。
  16. 证书私钥进了 Git 或镜像 → 泄露。私钥只在目标机器上,权限 600。

最后说说这次事故之后的改变。

技术上其实没什么高深的:就是"申请 → 自动续期 → 监控"三件事,加起来不到一小时的工作量。但它给我最大的教训是对"会过期的东西"的态度:

任何有时间寿命的东西——证书、域名、API 密钥、第三方服务的授权、云资源的配额——都应该有:

  1. 自动化续期(能自动的都自动);
  2. 到期前告警(自动的也可能失败);
  3. 外部视角的验证(不能只信本机);
  4. 写进文档(接手的人知道这些存在)。

我们现在有个"到期清单"文档,把所有会过期的东西列出来(证书、域名、DNS、第三方 API 授权、SSL 监控、短信服务余额……),每季度过一遍。这个清单救过我们至少两次——一次是另一个域名(不是主要的那个)的证书,一次是某个第三方服务的 API 配额到期。

最后一句提醒:证书过期不是"服务器坏了",是"服务器看起来不安全"。用户对红色警告的反应比 500 错误强烈得多,而且会扩散("这个网站不安全"的截图比"打不开"更有传播力)。所以这件事值得花一小时做彻底。

想亲手试试?用 VidDown 一键解析下载

粘贴视频链接即可解析,多平台支持、网页端即用;下载桌面客户端解锁海外平台本地解析,开通会员更享不限次下载。

顶部