数字证书与 TLS 握手的底层机制

要在互联网上建立安全的加密连接,客户端(如浏览器)必须解决两个核心问题:确认对端服务器的真实身份,以及在不可信的网络信道中安全地协商出对称加密密钥。HTTPS 协议通过 TLS(Transport Layer Security)协议实现了这一目标,而 SSL/TLS 证书则是这一信任体系的基础。 TLS 证书本质上是由受信任的证书颁发机构(Certificate Authority, CA)数字签名的电子文件,遵循 X.509 标准。证书内部包含了域名信息、证书所有者的公钥、CA 的身份信息、有效期限以及 CA 使用其私钥生成的数字签名。 当用户在浏览器中输入 https://example.com 时,客户端与服务器之间会触发 TLS 握手流程:

Client                                  Server
|                                       |
|-------- ClientHello (TLS 1.3) ------->|
|                                       |
|<-- ServerHello, Certificate, Key -----| (服务器发送证书与公钥参数)
|    Exchange & Finished                |
|                                       |
| [校验证书链与数字签名]                  |
| [生成会话密钥]                         |
|                                       |
|-------- Finished (Encrypted) -------->|
|                                       |
|<====== 双向对称加密数据传输 ======>|

客户端在收到服务器传来的证书后,并不会无条件信任它,而是通过非对称加密算法执行严格的”证书链校验”。客户端操作系统或浏览器内置了一组根证书列表(Root CA),这些根证书包含权威 CA 的公钥。CA 会使用自己的私钥对服务器的公钥和域名信息进行哈希并签名。客户端使用本地内置的 CA 公钥解密该签名,并将解密结果与证书内容的实际哈希值进行对比;若一致,则证明该证书确实由权威 CA 签发且未被篡改。校验成功后,双方基于 ECDHE 等密钥交换算法协商出临时对称密钥,后续所有的 HTTP 请求和响应均通过该对称密钥进行高效率的加解密传输。 过去,从传统 CA 申请证书是一个高度依赖人工干预的过程。运维人员需要使用 OpenSSL 工具在本地生成私钥和证书签名请求(Certificate Signing Request, CSR),将 CSR 提交给 CA 机构,通过邮件验证或 DNS 记录追加等方式人工证明域名所有权。CA 经过人工或半自动审核后颁发证书,运维人员再手动下载文件并写入服务器配置。这类证书的有效期通常长达 1 至 2 年,一旦私钥泄露或证书过期,极易导致生产事故。

ACME 自动化协议与证书生态格局

为了解决人工申请效率低下、成本高昂且容易出错的问题,互联网安全研究小组(ISRG)推出了 Let’s Encrypt,并主导制定了 RFC 8555 标准——ACME(Automatic Certificate Management Environment)协议。ACME 协议将”身份验证 - 证书签发 - 部署 - 定期续期”的全流程完全 API 化,使得服务器能够以无人值守的方式自动化维护 TLS 证书。 在 ACME 协议架构中,部署在服务器上的客户端(如 Certbot、acme.sh)与 CA 的 ACME 服务端通过 HTTPS REST API 进行通信。当客户端向 CA 请求签发证书时,CA 必须验证请求者对目标域名的控制权。ACME 协议定义了两类核心的验证挑战(Challenge):

  1. HTTP-01 挑战:ACME 服务端生成一个随机 Token,要求客户端将其放置在 Web 服务器的特定路径下:http://<YOUR_DOMAIN>/.well-known/acme-challenge/<TOKEN>。随后 CA 的验证服务器通过公网 HTTP(80 端口)访问该 URL。若能成功读取并匹配密钥指纹,则验证通过。这种方式配置简单,但无法签发泛域名证书(Wildcard Certificate,如 *.example.com),且要求 80 端口对公网开放。
  2. DNS-01 挑战:ACME 服务端要求客户端在域名的 DNS 解析记录中添加一条特定名称的 TXT 记录(通常为 _acme-challenge.<YOUR_DOMAIN>)。CA 查询 DNS 服务器获取该 TXT 记录的值,匹配无误即完成验证。DNS-01 不需要 Web 服务器开放 80 端口,且支持申请泛域名证书,但需要客户端具备操作 DNS Provider API 的权限——第三、四节会分别用 Certbot 和 acme.sh 配合 Cloudflare API Token 完成这一流程。 在当今的证书生态中,免费证书服务方案主要呈现为三种不同的技术路径:
  • Let’s Encrypt:纯粹的开源非营利性 CA,也是 ACME 标准的奠基者。它签发的证书有效期仅为 90 天,以此强制运维体系实施自动化续期机制,从而大幅缩短私钥泄露后的风险窗口。
  • ZeroSSL:同样支持标准的 ACME 协议,可直接作为 Certbot 或 acme.sh 的后端替换选择。其优势在于提供可视化的 Web 管理后台,并在某些特殊网络环境下对老旧设备的兼容性更好,同时提供商业级支持与付费扩展选项。
  • Cloudflare:采用不同的边缘架构。Cloudflare 作为反向代理 CDN 接入时,用户无需在源站直接面对公网签发证书,Cloudflare 在边缘节点统一使用自身的 CA 签发 TLS 证书并完成 HTTPS 卸载。但在边缘节点到源服务器(Origin Server)之间,仍然需要部署源站证书(Origin CA Certificate)以确保端到端的严格加密。本文第三、四节签发的证书就属于这个”源站证书”角色——Cloudflare 只是在这里充当 DNS Provider 来完成 DNS-01 验证,与它自己签发的边缘证书是两回事。

Certbot 原生运维实践:HTTP-01 与 certonly 模式

Certbot 是 Let’s Encrypt 官方推荐的 ACME 客户端。在实际部署中,Certbot 主要提供两种模式:自动化托管模式(利用 --nginx--apache 插件自动改写 Web 服务器配置)和 certonly 模式(仅负责申请证书,配置由运维人员完全掌控)。 对于追求可控性与标准化的工程环境,强烈建议使用 certonly 模式。自动化插件对 Web 服务器配置文件的改写往往过于侵入,可能打破原有的模块化配置结构。使用 certonly 配合 Webroot 验证,可以在完全不打扰现有 Nginx 进程运行的情况下,安全地完成证书申请与更新。

环境准备与 Nginx 验证路径配置

首先确保服务器已安装 Nginx,并开放 80 和 443 端口。在 Nginx 配置中配置通用的 .well-known/acme-challenge/ 访问规则,将请求映射至独立的静态文件目录。 编写 /etc/nginx/sites-available/example.com.conf 配置:

server {
    listen 80;
    listen [::]:80;
    server_name example.com www.example.com;
    # 将 ACME HTTP-01 挑战请求映射至统一的静态目录,避免干扰业务逻辑
    location /.well-known/acme-challenge/ {
        alias /var/www/certbot/.well-known/acme-challenge/;
        try_files $uri =404;
    }
    # 其他 HTTP 请求统一重定向至 HTTPS
    location / {
        return 301 https://$host$request_uri;
    }
}

执行配置检查并生效:

# 创建 ACME 挑战验证文件存放目录并设置权限
sudo mkdir -p /var/www/certbot/.well-known/acme-challenge
sudo chown -R www-data:www-data /var/www/certbot
# 检查 Nginx 语法并重新加载配置
sudo nginx -t && sudo nginx -s reload

使用 Certbot certonly 模式申请证书(HTTP-01)

通过 Linux 系统包管理器安装 Certbot 后,使用 certonly --webroot 命令发起签发请求:

sudo certbot certonly \
  --webroot \
  -w /var/www/certbot \
  -d example.com \
  -d www.example.com \
  --email [email protected] \
  --agree-tos \
  --no-eff-email

上述命令的参数定义如下:

  • certonly:指令 Certbot 仅向 CA 获取证书文件,禁止自动修改 Nginx 配置文件。
  • --webroot:指定使用 HTTP-01 验证插件,并将验证 Token 写入现有的 Web 服务器目录。
  • -w /var/www/certbot:指定 ACME 挑战文件写入的根路径。Certbot 会在此目录下创建 .well-known/acme-challenge/ 子目录并生成校验文件。
  • -d:指定需要包含在证书中的域名(Subject Alternative Name, SAN)。
  • --email:指定用于接收证书过期提醒与安全警告的电子邮箱。
  • --agree-tos:显式同意 Let’s Encrypt 服务条款。 成功签发后,证书文件将保存在 /etc/letsencrypt/live/example.com/ 目录中。该目录下包含以下核心文件软链接:
  • fullchain.pem:包含服务器证书以及中间证书链(Intermediate CA)的完整文本。Nginx 的 ssl_certificate 指令必须指向此文件,否则部分客户端(如移动端 SDK)会因缺少中间证书链而报信任错误。
  • privkey.pem:服务器私钥。权限严格受限(仅 root 可读),绝对不能泄露或上传至版本控制系统。

完整 Nginx HTTPS 站点配置

获取证书后,修改 Nginx 配置文件启用 TLS:

server {
    listen 443 ssl http2;
    listen [::]:443 ssl http2;
    server_name example.com www.example.com;
    # 配置 Let's Encrypt 签发的证书链文件与私钥
    ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
    # TLS 协议及加密套件优化,停用存在安全隐患的旧协议
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
    ssl_prefer_server_ciphers off;
    # TLS 会话缓存,减少重连时的非对称加密计算开销
    ssl_session_cache shared:SSL:10m;
    ssl_session_timeout 1d;
    ssl_session_tickets off;
    # 业务根路径
    location / {
        root /var/www/html;
        index index.html;
    }
}

配置文件更新完成后,运行 sudo nginx -t && sudo nginx -s reload 使 HTTPS 生效。

证书自动续期与 Hook 机制

Let’s Encrypt 证书有效期只有 90 天,推荐在证书到期前 30 天内触发续期。Certbot 提供了 renew 命令,该命令会自动扫描 /etc/letsencrypt/renewal/ 目录下存储的所有证书配置,仅对即将过期的证书重新发起 ACME 挑战。 在更新证书后,已在内存中运行的 Nginx 进程并不会自动加载磁盘上的新文件,必须执行 nginx -s reload。这可以通过 Certbot 的 --deploy-hook 自动化处理:

# 测试续期流程(dry-run 仅演练,不真正向 ACME 发起签发,也不触发 Hook)
sudo certbot renew --dry-run
# 配置 Cron 定时任务,每日凌晨 03:00 检查并自动续期
# 编辑 /etc/crontab 或运行 crontab -e 追加以下行:
0 3 * * * root certbot renew --quiet --deploy-hook "nginx -s reload"

当证书成功续期后,--deploy-hook 指定的命令才会被触发,从而在无感知的情况下完成服务热重载。

用 DNS-01 签发泛域名证书:Certbot + Cloudflare API Token

4.1 为什么需要 DNS-01

HTTP-01 需要 CA 能通过公网 80 端口访问到你的服务器,这在两种场景下行不通:签发泛域名证书 *.example.com(HTTP-01 协议本身就不支持),或者源站根本不打算对公网开放 80 端口(例如源站只通过 Cloudflare Tunnel 或内网访问)。DNS-01 用一条 TXT 记录替代了”能被公网访问到”这个前提,因此这两种场景都能覆盖。 代价是客户端需要有权限替你在 DNS 里写记录,所以下一步是发一个只能做这一件事的凭证,而不是把整个 Cloudflare 账号的钥匙交出去。

4.2 创建最小权限的 Cloudflare API Token

不要使用 Cloudflare 后台里那个”Global API Key”——它等于账号全部权限的万能钥匙,一旦泄露,比如误提交进了 Git 仓库,攻击者能操作账号下所有域名和产品。正确做法是在 My Profile → API Tokens → Create Token 里新建一个范围收窄到最小的 Token:

  • 权限(Permissions):Zone / DNS / Edit
  • 资源(Zone Resources):Include / Specific zone / 选中你要签发证书的那个域名,而不是 All zones
  • 可选:在 Client IP Address Filtering 里把 Token 锁定到签发证书的这台服务器的出口 IP,进一步收窄可用范围。

创建后 Cloudflare 只会显示一次 Token 字符串,立刻保存好;这张 Token 之后要么交给 Certbot 的凭证文件,要么交给 acme.sh 的环境变量,本身不需要、也不应该被写进任何会被提交到版本控制的文件。

4.3 安装 certbot-dns-cloudflare 插件并配置凭证

# Debian/Ubuntu
sudo apt install python3-certbot-dns-cloudflare
# RHEL 系
sudo dnf install python3-certbot-dns-cloudflare

创建凭证文件,只存放上一步生成的 API Token:

sudo mkdir -p /root/.secrets/certbot
sudo tee /root/.secrets/certbot/cloudflare.ini > /dev/null <<'EOF'
dns_cloudflare_api_token = 你的CloudflareAPIToken
EOF
sudo chmod 600 /root/.secrets/certbot/cloudflare.ini

chmod 600 是必须的一步:这个文件里就是明文 Token,权限过宽会让同一台机器上的其他账户也能读到它。

4.4 执行 DNS-01 签发

sudo certbot certonly \
  --dns-cloudflare \
  --dns-cloudflare-credentials /root/.secrets/certbot/cloudflare.ini \
  --dns-cloudflare-propagation-seconds 30 \
  -d example.com \
  -d '*.example.com' \
  --email [email protected] \
  --agree-tos \
  --no-eff-email

--dns-cloudflare-propagation-seconds 让 Certbot 在提交 TXT 记录后先等待一段时间再触发验证,给 Cloudflare 的 DNS 传播留出余量,避免 CA 查询时记录还没生效。签发结果同样落在 /etc/letsencrypt/live/example.com/,续期方式和第三节完全一样——certbot renew 会自动识别这本是用 dns-cloudflare 插件签发的,重新走一遍相同的验证方式,无需额外配置。

acme.sh:更轻量的 ACME 客户端

acme.sh 是一个纯 Shell 实现的 ACME 客户端,不依赖 Python 或系统包管理器,安装即用,尤其适合在资源受限的小型服务器或者不想引入额外运行时依赖的场景。

5.1 安装

curl https://get.acme.sh | sh -s [email protected]
source ~/.bashrc

安装脚本会把 acme.sh 放进 ~/.acme.sh/,同时自动注册一条 cron 任务定期检查续期,不需要像 Certbot 那样手动配置定时任务。

5.2 HTTP-01:webroot 方式签发

acme.sh --issue -d example.com -w /var/www/certbot

效果与 Certbot 的 --webroot 相同:把验证文件写进现有 Web 目录,不需要额外开放端口,也不支持泛域名证书。

5.3 DNS-01:用同一个 Cloudflare Token 签发泛域名证书

acme.sh 内置了 Cloudflare 的 DNS API 钩子(dns_cf),复用 4.2 节创建的同一个 API Token 即可,不需要单独的凭证文件——直接通过环境变量传入:

export CF_Token="你的CloudflareAPIToken"
export CF_Account_ID="你的Cloudflare账号ID"   # 在 Cloudflare 域名概览页右侧可以找到

acme.sh --issue --dns dns_cf -d example.com -d '*.example.com'

CF_Token/CF_Account_ID 只在这次签发时读取一次并写入 acme.sh 自己的账号数据目录(~/.acme.sh/account.conf),后续 acme.sh --renew 会自动复用,不需要每次都重新导出。

5.4 部署证书到 Nginx

acme.sh 默认把证书存放在自己的内部目录结构里,不建议直接引用;推荐用 --install-cert 把证书复制(并在续期时自动更新)到 Nginx 实际使用的路径,同时绑定重载命令:

sudo mkdir -p /etc/nginx/ssl/example.com
acme.sh --install-cert -d example.com \
  --key-file       /etc/nginx/ssl/example.com/privkey.pem \
  --fullchain-file /etc/nginx/ssl/example.com/fullchain.pem \
  --reloadcmd      "systemctl reload nginx"

之后 Nginx 的 ssl_certificate/ssl_certificate_key 就指向 /etc/nginx/ssl/example.com/ 下这两个固定路径;每次自动续期成功后,acme.sh 会自动重新执行一遍 --install-cert 的复制与 --reloadcmd,全程无需人工干预。

5.5 验证自动续期

crontab -l | grep acme.sh   # 确认安装时自动写入的定时任务存在
acme.sh --cron              # 手动触发一次检查,用于验证整条链路是否工作正常

Docker 化容器环境部署实践

在现代云原生架构中,组件通常以容器化方式隔离运行。将 Nginx 与 Certbot 分别部署在独立容器中,并通过 Docker Shared Volumes 共享证书文件与 HTTP-01 验证路径,是生产环境的最佳实践。 为了避免复杂的依赖,可以直接使用 Compose 来编排容器。

目录结构规划

/opt/ssl-stack/
├── docker-compose.yml
├── nginx/
│   └── conf.d/
│       └── default.conf
└── data/
    ├── certbot/
    │   ├── conf/       # 挂载至 Certbot 的 /etc/letsencrypt,保存证书文件
    │   └── www/        # 挂载至 ACME 验证路径
    └── webroot/        # 业务静态资源路径

编排文件配置

创建 docker-compose.yml 配置文件:

version: '3.8'
services:
  nginx:
    image: nginx:1.25-alpine
    container_name: production_nginx
    restart: always
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/conf.d:/etc/nginx/conf.d:ro
      - ./data/certbot/conf:/etc/letsencrypt:ro
      - ./data/certbot/www:/var/www/certbot:ro
      - ./data/webroot:/usr/share/nginx/html:ro
  certbot:
    image: certbot/certbot:v2.8.0
    container_name: production_certbot
    restart: unless-stopped
    volumes:
      - ./data/certbot/conf:/etc/letsencrypt:rw
      - ./data/certbot/www:/var/www/certbot:rw
    # 每隔 12 小时自动运行一次 check,若证书需续期则自动完成 renew
    entrypoint: "/bin/sh -c 'trap exit TERM; while true; do certbot renew; sleep 12h & wait $${!}; done'"

引导阶段配置(Solving the Chicken-and-Egg Problem)

在首次启动时存在一个经典的”先有鸡还是先有蛋”逻辑困局:Nginx 启动时如果配置文件中引用了 TLS 证书文件,而证书尚未签发,Nginx 进程会因找不到文件而直接崩溃退出;但如果 Nginx 不启动,ACME HTTP-01 校验请求又无法被正确处理。 解决方案是采用两步引导法。 第一步,编写适用于初始签发的纯 HTTP Nginx 配置文件 ./nginx/conf.d/default.conf

server {
    listen 80;
    server_name example.com;
    location /.well-known/acme-challenge/ {
        root /var/www/certbot;
    }
    location / {
        return 200 'Initializing SSL...';
        add_header Content-Type text/plain;
    }
}

第二步,启动 Nginx 容器并首次运行 Certbot 容器发起证书签发请求:

# 1. 启动 Nginx 提供 HTTP-01 校验服务
docker compose up -d nginx
# 2. 运行一次性 Certbot 容器申请证书
docker compose run --rm certbot certonly \
  --webroot \
  --webroot-path=/var/www/certbot \
  -d example.com \
  --email [email protected] \
  --agree-tos \
  --no-eff-email

第三步,证书生成完毕后,修改 ./nginx/conf.d/default.conf,写入完整的 HTTPS 代理或静态资源配置:

server {
    listen 80;
    server_name example.com;
    location /.well-known/acme-challenge/ {
        root /var/www/certbot;
    }
    location / {
        return 301 https://$host$request_uri;
    }
}
server {
    listen 443 ssl http2;
    server_name example.com;
    ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
    ssl_protocols TLSv1.2 TLSv1.3;
    location / {
        root /usr/share/nginx/html;
        index index.html;
    }
}

最后,热重载 Nginx 容器并启动常驻的 Certbot 定时任务容器:

# 重载 Nginx 加载 SSL 配置
docker compose exec nginx nginx -s reload
# 启动包括 Certbot 定时检查在内的全量服务
docker compose up -d

在此方案中,常驻的 Certbot 容器会在后台每 12 小时执行一次 certbot renew。一旦证书在磁盘挂载卷中被刷新,可以通过宿主机的 Cron 触发 Nginx 容器平滑重载:

# 在宿主机 Crontab 中添加,每周定期通知 Nginx 重新加载内存中的证书
0 4 * * 0 docker exec production_nginx nginx -s reload

工程抉择与信任演进

理解 PKI 信任链、TLS 握手过程以及 ACME 协议的校验机制,是掌握现代网络安全架构的必经之路。在工程落地的选择上,虽然各种技术工具形态不同,但底层原理高度统一: 对于单机部署或简单运维场景,基于宿主机的 certbot certonly --webroot 配套 Nginx 与 Cron,是入侵性最小、逻辑最清晰的方案;如果目标是泛域名证书,或者源站根本不想对公网开放 80 端口,--dns-cloudflare 配合一个权限收窄到单个 zone 的 API Token 是更合适的路径。偏好少一层 Python 依赖、又不想手动管理 cron 的场景,acme.sh 用同一张 Cloudflare Token 提供了几乎等价的 DNS-01 能力,--install-cert 还顺带解决了续期后自动复制、重载的问题。而在云原生容器化的运维体系中,利用 Docker Compose 隔离配置与证书持久化挂载,能够提供极其优雅的平滑部署与自动化续期体验。 只要彻底解耦”证书签发”与”服务配置”两个阶段,就能抹平基础设施的复杂性,建立起一套高可靠、零人工干预的自动化 HTTPS 运维体系。