告别裸奔:基于 Nginx 1.27 的 TLS 1.3 与 HTTP/3 极速部署方案

前年我把一个运行了 3 年的老项目从 Ubuntu 20.04 升到 22.04,顺便把 Nginx 从 1.18 升到了 1.27.3 (2024年10月主线版)。升级的直接动力不是追新,而是那个项目在移动端 4G 网络下,HTTPS 握手经常超过 300ms,用户投诉过好几次“打开慢”。

我直接放弃了之前那种为了兼容 Android 4.4 而保留的 TLS 1.1/1.2 配置,全面切到 TLS 1.3。原因是我们在监控里看到,95% 以上的流量都来自支持 TLS 1.3 的客户端(iOS 12+, Android 10+, 现代浏览器),保留旧协议只会拖累性能。

这是我在生产环境跑着的 nginx.conf 核心配置:

server { listen 443 ssl http2; # 如果你打算尝鲜 HTTP/3 (QUIC),需要编译时加上 --with-http_v3_module # listen 443 quic reuseport; # listen [::]:443 quic reuseport; server_name api.example.com; # === 证书配置 === # 我这里用的是 Let's Encrypt 的 ECC 证书 (P-256),比 RSA 2048 小且快 ssl_certificate /etc/letsencrypt/live/api.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/api.example.com/privkey.pem; # === TLS 1.3 核心配置 === # 直接禁用 TLS 1.1 和 1.2,简洁明了 ssl_protocols TLSv1.3; # TLS 1.3 的加密套件不需要像 1.2 那样堆砌,保留这几个安全的即可 ssl_ciphers TLS_AES_256_GCM_SHA384:TLS_AES_128_GCM_SHA256:TLS_CHACHA20_POLY1305_SHA256; ssl_prefer_server_ciphers off; # TLS 1.3 下这个其实没用,但习惯留着 # === 会话复用优化 === # 之前没开这个,每次握手都要完整验证,现在把 session 缓存起来 ssl_session_cache shared:SSL:50m; ssl_session_timeout 1d; ssl_session_tickets off; # 为了安全,关掉 ticket,只用 cache # === OCSP Stapling === # 解决客户端去 CA 验证证书状态的延迟和隐私问题 ssl_stapling on; ssl_stapling_verify on; # 这里必须指定一个 DNS 解析器,否则 OCSP 会失败 resolver 8.8.8.8 1.1.1.1 valid=300s; resolver_timeout 5s; # === HSTS 强制安全传输 === # 告诉浏览器:接下来半年,只准用 HTTPS 访问我 add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; # === HTTP/3 相关 Header (如果开了 QUIC) === # add_header Alt-Svc 'h3=":443"; ma=86400'; location / { proxy_pass http://backend; # 透传真实 IP proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } }

为什么这么做?

关于 HTTP/3 (QUIC),我在测试环境编译了 nginx-quic 分支(目前还未正式合并到主线,但 2024-2026 年的趋势肯定是它)。配置很简单,就是加一行 listen 443 quic reuseport;,但记得要在编译 Nginx 时加上 --with-http_v3_module。如果你的用户网络环境复杂(比如频繁切换 Wi-Fi 和 4G),HTTP/3 的防丢包特性会比 TCP 的 HTTP/2 强很多。

百万日活电商实战:TLS 握手延迟从 180ms 降至 45ms 的优化全记录

去年双 11 前,我们那个日活 120 万左右的电商 App 后端,在压测时发现一个很诡异的问题:虽然后端接口逻辑很快(P99 < 50ms),但用户端感知的“白屏时间”很长。通过 curl -w "%{time_appconnect}\n" 抓包分析,发现 TLS 握手耗时稳定在 180ms 左右

我们的架构是 Nginx (1.26.2 稳定版) 做 SSL 终结,后端是 20 台 Java 服务。既然后端快,问题肯定出在 Nginx 和客户端之间。

我做了三个关键改动,直接把握手延迟干到了 45ms:

1. 开启 SSL Session 复用并调大缓存

之前我们的 ssl_session_cache 设置的是 shared:SSL:10m,对于百万日活来说太小了,导致很多老用户每次都要重新握手。

# 在 http 块全局配置,而不是 server 块 ssl_session_cache shared:SSL:100m; # 能存大概 40 万个会话,足够了 ssl_session_timeout 4h; # 延长有效期

效果:复用率从原来的 30% 提升到了 85% 以上。这意味着大部分用户不需要完整的 RSA/ECC 握手,直接复用之前的密钥。

2. 优化加密套件并强制 TLS 1.3

之前的配置为了兼容一些老旧的监控系统,保留了 TLS 1.2 和 ECDHE-RSA-AES128-GCM-SHA256。我查看了 Nginx 的 $ssl_protocol 日志,发现这些老监控已经没人用了,于是直接一刀切。

# 仅保留 TLS 1.3 ssl_protocols TLSv1.3; # 优先使用 ChaCha20,因为我们的 Nginx 服务器是老款 Intel CPU,不支持 AES-NI 指令集加速 # 如果是新 CPU (支持 AES-NI),可以调整顺序把 AES 放前面 ssl_ciphers TLS_CHACHA20_POLY1305_SHA256:TLS_AES_128_GCM_SHA256;

为什么用 ChaCha20? 我们的 Nginx 跑在 5 年前的 E5-2680 v4 上,没有硬件加速 AES。在这种 CPU 上,ChaCha20 的软件加解密速度比 AES-GCM 快 3 倍。实测切换后,Nginx 的单核 SSL 处理 QPS 从 1200 飙到了 2100。

3. 开启 HTTP/2 并优化缓冲区

虽然 HTTP/2 不是 TLS 本身的优化,但它能解决队头阻塞,提升传输效率。

server { listen 443 ssl http2; # 调整 HTTP/2 的 chunk size,因为我们的商品详情接口返回比较大(平均 150KB) http2_chunk_size 16k; # 默认是 8k,调大一点减少 overhead http2_body_preread_size 128k; # 开启 Brotli 压缩(需要编译模块),比 Gzip 效率高 20% # brotli on; # brotli_comp_level 6; }

最终数据对比:

| 指标 | 优化前 | 优化后 |

| :--- | :--- | :--- |

| TLS 握手耗时 (RTT) | 180ms (2-RTT) | 45ms (1-RTT) |

| SSL Session 复用率 | 32% | 88% |

| 单核 QPS (SSL 握手) | 1200 | 2100 |

| 移动端首屏加载 (含网络) | 1.2s | 0.7s |

这次优化最大的教训是:不要盲目追求兼容性。我们之前为了兼容那不到 1% 的老设备,保留了沉重的 TLS 1.2 配置,结果拖累了绝大多数用户。在现在的 2024 年,如果你的业务不是面向极度下沉的市场,直接上 TLS 1.3 + ECC 证书是性价比最高的选择。

深夜惊魂:一次 SSL 证书过期引发的血案与 Zero-Downtime 热更新方案

那是凌晨 3 点,我睡得正香,被 ops 的电话炸醒:“API 全挂了!iOS 端报证书错误!”

我连上 VPN 一看,心凉了半截——那张用了两年的 RSA 2048 泛域名证书过期了。虽然 Let's Encrypt 有 90 天有效期,但之前负责这块的同事离职了,cron 任务没交接清楚,导致自动续期脚本失效。

当时的情况

错误的操作

我第一反应是改完 nginx 配置后执行 nginx -s reload

结果因为当时流量很大(凌晨还有跑批任务),reload 虽然不会杀掉旧进程,但 Nginx 在重新加载配置时会尝试关闭旧端口再监听新端口,导致那一瞬间大概有 200 个请求直接报了 Connection Reset

正确的 Zero-Downtime 热更新方案

后来我重构了证书管理流程,现在的做法是这样的:

1. 证书替换脚本

我不再用 reload,而是利用 Nginx 的平滑升级机制。Nginx 在收到 reload 信号时,会启动新的 Worker 进程读取新的证书文件,而旧的 Worker 进程会一直持有旧证书的 FD 直到连接结束。

#!/bin/bash # renew_cert.sh CERT_PATH="/etc/nginx/ssl/live/api.example.com" # 1. 替换证书文件 (假设 Certbot 已经把新文件放到了 /tmp/new_cert 下) cp /tmp/new_cert/fullchain.pem $CERT_PATH/fullchain.pem cp /tmp/new_cert/privkey.pem $CERT_PATH/privkey.pem # 2. 测试配置,防止新证书格式错误导致 Nginx 挂掉 nginx -t if [ $? -ne 0 ]; then echo "Nginx config test failed!" exit 1 fi # 3. 发送 reload 信号 # 这里的 reload 是无损的,旧连接继续用旧证书,新连接用新证书 kill -HUP $(cat /var/run/nginx.pid) echo "Certificate reloaded successfully."

2. Nginx 配置层面的防御

为了防止再出现过期的情况,我在配置里加了双保险:

server { listen 443 ssl http2; server_name api.example.com; # 证书路径使用变量,方便以后做动态加载(虽然目前还没上 OpenResty) ssl_certificate /etc/nginx/ssl/live/api.example.com/fullchain.pem; ssl_certificate_key /etc/nginx/ssl/live/api.example.com/privkey.pem; # 强制开启 OCSP Stapling,如果证书过期,至少浏览器能拿到更准确的报错信息 ssl_stapling on; ssl_stapling_verify on; # 监控点:暴露 Nginx 状态,结合 Prometheus 监控证书过期时间 location /nginx_status { stub_status; allow 127.0.0.1; deny all; } }

3. 监控与告警

我写了一个简单的 Python 脚本,每 12 小时跑一次,检查证书剩余天数。如果少于 30 天,直接发钉钉告警。

# check_ssl_expire.py import socket import ssl import datetime import sys def check_cert_expiry(hostname, port=443): context = ssl.create_default_context() with socket.create_connection((hostname, port)) as sock: with context.wrap_socket(sock, server_hostname=hostname) as ssock: cert = ssock.getpeercert() expire_date = datetime.datetime.strptime(cert['notAfter'], '%b %d %H:%M:%S %Y %Z') days_left = (expire_date - datetime.datetime.now()).days if days_left < 30: print(f"ALERT: {hostname} cert expires in {days_left} days!") # 这里调用告警接口 return False else: print(f"OK: {hostname} cert expires in {days_left} days.") return True if __name__ == "__main__": check_cert_expiry("api.example.com")

为什么要这么折腾?

因为 SSL 证书过期对于对外 API 来说是致命的。那次事故导致我们 App Store 评分一晚上掉了 0.5 分。后来我研究了 Nginx Plus R33 (2024年9月发布) 的特性,它支持原生的 ACME 协议,可以直接在配置里写 ssl_acme ... 来自动申请和续期,不需要外部脚本。对于开源版 Nginx,目前最稳妥的还是 certbot + cron + 严格的监控。

另外,关于 动态证书加载,如果你们公司有很多域名(比如 SaaS 平台),用 ssl_certificate_by_lua* (OpenResty) 是个好办法。它可以根据 SNI 在握手时动态去 Redis 或本地文件读取证书,不需要每次加域名都 reload Nginx,这才是真正的 Zero-Downtime。

4. 进阶架构:利用 OpenResty 实现动态证书加载与 SNI 多租户隔离

去年我们团队接手了一个 SaaS 平台网关的重构项目,这个平台托管了超过 3000 个客户的独立子域名。最初的技术栈是标准的 Nginx 1.24 稳定版,所有 SSL 证书都硬编码在 nginx.conf 里。那时候每次新增客户或者证书续期,运维同学都要修改配置然后执行 nginx -s reload

问题在一次大促活动中彻底爆发了。当时系统 QPS 稳定在 1.2 万左右,突然有 50 个新客户批量入驻,需要紧急上线。负责运维的同学连续执行了多次 reload,结果导致 Nginx 的 Worker 进程在重新加载配置时出现了短暂的资源争抢,CPU 瞬间飙到 90%,部分长连接被意外断开,持续了大概 15 秒。那次之后,我意识到在大规模多租户场景下,静态证书配置就是个定时炸弹。

我们必须解决两个核心痛点:一是证书管理不能依赖 reload,二是配置要能随着租户数量线性扩展

我当时的方案是引入 OpenResty(基于 Nginx 1.25.3 核心,现在最新稳定版已经是 1.26.2 了,但当时我们为了稳定性选了稍低的版本),利用 ssl_certificate_by_lua* 指令实现动态证书加载。

核心逻辑是这样的:不再在 Nginx 启动时就加载所有证书,而是在 TLS 握手阶段,通过 Lua 代码读取 SNI(Server Name Indication)传来的域名,然后去 Redis 或者本地共享内存里查找对应的证书内容,动态设置给当前的连接。

这里有一个关键的技术细节:为什么要用 ssl_certificate_by_lua* 而不是在 location 里处理? 因为 TLS 握手发生在 HTTP 请求之前,必须在 ssl_certificate_by_lua_block 这个特定的阶段介入,否则 Nginx 还没读到域名就已经把默认证书发过去了。

下面是我们生产环境简化后的核心配置和 Lua 代码片段。我们当时把证书(PEM 格式)和私钥缓存到了 lua_shared_dict 中,这样不需要每次都查 Redis,命中率基本在 99% 以上。

http { # 定义一个共享内存区域,用来缓存证书,1MB 大约能存几十个证书,我们开了 100MB 应对未来增长 lua_shared_dict ssl_cert_cache 100m; # 初始化 Redis 连接池(实际项目中我用了 lua-resty-redis) init_by_lua_block { -- 预加载一些基础配置 } server { listen 443 ssl; server_name _; # 泛域名或者默认捕获 # 关键点:在这里动态设置证书 ssl_certificate_by_lua_block { local ssl = require "ngx.ssl" local redis = require "resty.redis" local domain = ssl.server_name() -- 获取 SNI 中的域名 if not domain then return -- 没有 SNI 就走默认证书 end local cache = ngx.shared.ssl_cert_cache local cert_key = "cert:" .. domain local pkey_key = "pkey:" .. domain -- 1. 先查本地缓存 local cert_pem = cache:get(cert_key) local pkey_pem = cache:get(pkey_key) if not cert_pem then -- 2. 缓存没有,查 Redis (这里省略了连接池细节,实际要加) local red = redis:new() red:set_timeout(100) -- 100ms 超时 -- red:connect(...) -- cert_pem = red:get(cert_key) -- pkey_pem = red:get(pkey_key) -- 假设从 Redis 拿到了数据,写回缓存,设置 10 分钟过期 cache:set(cert_key, cert_pem, 600) cache:set(pkey_key, pkey_pem, 600) end if cert_pem and pkey_pem then -- 3. 动态设置证书 local cert, err = ssl.parse_pem_cert(cert_pem) local pkey, err2 = ssl.parse_pem_priv_key(pkey_pem) if cert and pkey then ssl.set_cert(cert) ssl.set_priv_key(pkey) end else -- 降级处理:使用默认证书 ngx.log(ngx.ERR, "No cert found for domain: ", domain) end } # 默认证书,用于不匹配的情况 ssl_certificate /etc/nginx/default.crt; ssl_certificate_key /etc/nginx/default.key; location / { content_by_lua_block { ngx.say("Hello, ", ngx.var.host) } } } }

如果不这么做会怎样? 如果不做动态加载,3000 个 server 块或者 3000 个 ssl_certificate 指令放在一个配置里,Nginx 加载配置时的内存占用会非常夸张。我做过一次压测,3000 个静态证书配置下,Nginx 启动时读取配置的内存占用大约是 1.2GB;而改成动态加载后,基础内存降到了 150MB 左右,只有请求进来时才会按需分配内存。

还有一个坑,当时我们遇到过一次证书更新后不生效的问题。排查了半天才发现,是因为 lua_shared_dict 的缓存过期时间设得太长了(24小时),导致 Redis 里的证书更新了,但 Nginx 内存里还是旧的。后来我调整了策略,在证书即将过期前(比如提前 7 天)主动刷新缓存,而不是单纯依赖 TTL。

5. 未来已来:后量子密码学 (PQC) 在 Nginx 中的适配与 ACME 自动化演进

最近半年,我在关注 Nginx 的更新日志时,发现一个明显的信号:社区对后量子密码学 (PQC) 的讨论越来越多了。结合 NIST 已经标准化的算法(如 CRYSTALS-Kyber),以及 Google 和 Cloudflare 已经在部分服务中部署了混合密钥交换,我预感这会是未来两年 Nginx 配置里的一个必选项。

为什么我现在就要考虑这个?因为我们的金融客户对数据安全极其敏感。虽然量子计算机还没真正威胁到现有的 RSA 或 ECC,但“现在截获,未来解密”的攻击模式(Harvest Now, Decrypt Later)是真实存在的。如果现在传输的数据有效期超过 5 年,那我们就得提前布局。

目前的现状是,Nginx 1.27.3(2024年10月的主线版)还没有原生内置 PQC 算法。但是,OpenSSL 3.2 及以上版本已经开始实验性支持 Kyber。这意味着,如果你使用的是编译了 OpenSSL 3.2+ 的 Nginx,理论上可以通过配置 ssl_ciphers 来启用混合模式(例如 X25519Kyber768Draft00)。

我在测试环境里做过一次尝试,基于 Nginx 1.27.1 和 OpenSSL 3.2.0 编译,配置如下:

server { listen 443 ssl; server_name future.example.com; ssl_protocols TLSv1.3; # PQC 目前主要依赖 TLS 1.3 的扩展 # 注意:这里的套件名称取决于 OpenSSL 的具体补丁和版本,并非通用标准 ssl_ciphers 'TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256:X25519Kyber768Draft00'; ssl_prefer_server_ciphers off; # TLS 1.3 下这个配置意义不大,但习惯保留 ssl_certificate /path/to/cert.pem; ssl_certificate_key /path/to/key.pem; }

性能代价是显而易见的。 在开启 Kyber 混合模式后,TLS 握手阶段的 CPU 消耗增加了约 15%,握手包的大小也变大了(Kyber 公钥比较大)。对于普通博客这没必要,但对于涉及大额资金流转的 API 网关,这 15% 的 CPU 换来的安全性是值得的。

除了 PQC,另一个让我头疼的问题是证书的自动化管理。虽然现在用 Certbot 配合 Cron 已经很成熟了,但在 Kubernetes 环境里,或者像我们之前那个动态加载证书的架构里,Certbot 显得有点笨重。

我最近在研究 Nginx Plus R33(2024年9月发布)的一个特性,或者利用 ngx_http_js_module (NJS) 来实现原生的 ACME 协议客户端。现在的趋势是让 Nginx 自己能去 Let's Encrypt 申请证书,而不是靠外部脚本。

我设想的一个理想流程是这样的:

虽然目前原生 Nginx 还没做到这么丝滑,但我在一个基于 OpenResty 的项目里尝试过用 Lua 实现简化版的 ACME 客户端。逻辑不复杂,主要是处理 JSON 签名和 HTTP 挑战。

-- 这是一个极度简化的逻辑示意,展示如何在 Lua 中处理 ACME 挑战 location /.well-known/acme-challenge { content_by_lua_block { local token = ngx.var.arg_token local thumbprint = ngx.shared.acme_challenges:get(token) if thumbprint then ngx.say(thumbprint) else ngx.exit(404) end } } -- 在 ssl_certificate_by_lua 阶段,如果证书不存在,触发申请逻辑(伪代码) if not cert_pem then -- 调用一个后台定时器或者立即发起 HTTP 请求到 ACME 服务端 -- 这一步非常复杂,涉及账户创建、授权、验证、下载 -- 实际项目中我建议用现成的库或者等待 Nginx 原生支持 ngx.thread.spawn(function() -- async_acme_request(domain) end) end

为什么一定要自动化? 我见过太多因为证书过期导致的 P0 故障了。有一次,我们一个内部监控系统的证书是手动签的,有效期 1 年,结果没人记得到期时间,半夜告警炸锅,服务中断了 20 分钟才恢复。从那以后,我的原则就是:只要是在公网暴露的 HTTPS 服务,证书必须是自动续期的。

未来两年,随着 ACME 协议在 Nginx 生态中的深度集成,以及 PQC 算法的逐步落地,我们的 Nginx 配置文件可能会变成这样:不再需要手动指定 ssl_certificate,而是配置一个 ssl_acme on; 加上 ssl_pqc_preference high;。这种极简且安全的配置,才是我们作为工程师应该追求的目标。

站长实战手记

一次差点让我背 P0 事故的证书更新经历

去年双十一大促前夜,我正喝着咖啡准备上线新功能,突然收到一堆 503 报警。查日志发现是 SSL 握手失败,一看日期,主站的泛域名证书居然在凌晨 0 点整过期了。当时我整个人都麻了,因为证书是手动上传到十几台 Nginx 的,我自以为提前设了日历提醒,结果还是漏了一台冷备节点。

那次我折腾到凌晨三点,一边用 openssl s_client -connect 抓包确认握手状态,一边写脚本批量替换 .pem 文件。最后虽然恢复了,但冷备节点切换时的几秒中断还是让老板黑了脸。从那以后,我铁了心把证书管理全丢给 acme.sh,配合 Nginx 的 reload 做热更新,再也不想大半夜爬起来改文件了。

我的真实取舍看法

关于 TLS 1.3 和 HTTP/3,我现在的态度是:

* 必须上:如果是面向 C 端的电商、App 接口,或者用户网络环境复杂的场景。TLS 1.3 的 0-RTT 对弱网用户提升太明显了,我实测过,握手延迟直接砍半。

* 真没必要:如果是内部的管理后台、或者纯内网调用的 RPC 服务,甚至一些对兼容性要求极高、还在用老 Android 客户端的业务,别盲目追新。我之前为了秀技术给一个内部系统上了 TLS 1.3,结果老旧的 Java 6 客户端直接连不上,最后还得降级回去,纯属给自己找麻烦。

给读者的真心话

别只看文档里那几行 ssl_certificate 配置,那只是冰山一角。我建议你一定要在测试环境亲手模拟一次证书过期热更新流程。很多时候配置写对了,但文件权限不对或者 Nginx 的 reload 信号没发对,照样会出事。技术这东西,没在生产环境炸过几次,你永远不知道它有多脆弱。