Nginx-10 HTTPS 与 TLS 配置
TLS 协议本身在网络系列讲过,这一篇专注于Nginx 侧的配置和性能优化。
1. 最小可用配置
server {
listen 443 ssl;
http2 on; # 1.25.1+ 的新写法,老版本是 listen 443 ssl http2;
server_name example.com;
ssl_certificate /etc/nginx/ssl/example.com/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/example.com/privkey.pem;
location / {
root /var/www/dist;
}
}
# HTTP 跳转到 HTTPS
server {
listen 80;
server_name example.com;
return 301 https://$host$request_uri;
}
1.1 证书链必须完整
ssl_certificate 要用完整链(fullchain),即「服务器证书 + 中间 CA 证书」按顺序拼接,根证书不需要(客户端本地已有)。
fullchain.pem 的内容:
-----BEGIN CERTIFICATE-----
(你的服务器证书)
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
(中间 CA 证书,可能有多个)
-----END CERTIFICATE-----
只放服务器证书的后果:桌面浏览器可能正常(它们会尝试自动补全中间证书,或者本地缓存过),但移动端和某些客户端会报证书错误。这是最典型的「我这里能访问,用户说不行」的场景。
验证证书链:
# 看链是否完整
openssl s_client -connect example.com:443 -servername example.com -showcerts < /dev/null
# 更直接的方式
echo | openssl s_client -connect example.com:443 -servername example.com 2>&1 \
| grep -E "Verify return code|verify error"
# Verify return code: 0 (ok) ← 正常
# verify error:num=20:unable to get local issuer certificate ← 缺中间证书
# 检查本地证书文件里有几张证书
grep -c "BEGIN CERTIFICATE" /etc/nginx/ssl/fullchain.pem
# 应该 ≥ 2
# 确认私钥和证书匹配(两个 md5 必须相同)
openssl x509 -noout -modulus -in cert.pem | openssl md5
openssl rsa -noout -modulus -in privkey.pem | openssl md5
# 看证书信息和有效期
openssl x509 -in cert.pem -noout -text | head -20
openssl x509 -in cert.pem -noout -dates
线上验证推荐 SSL Labs,能给出 A+ 到 F 的评级和详细问题清单。
2. 协议与加密套件
2.1 推荐配置(兼顾安全与兼容)
http {
# 只用 TLS 1.2 和 1.3。
# TLS 1.0/1.1 已被所有主流浏览器弃用,且不符合 PCI DSS 要求
ssl_protocols TLSv1.2 TLSv1.3;
# TLS 1.2 的套件(TLS 1.3 的套件由 OpenSSL 内部决定,这里配了也不影响)
# 只保留 ECDHE(前向保密)+ AEAD(GCM/CHACHA20)
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:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
# TLS 1.3 下让客户端选(客户端更了解自己的硬件加速能力)
# TLS 1.2 下建议 on,让服务端按 ssl_ciphers 的顺序选
ssl_prefer_server_ciphers off;
# ECDH 曲线。X25519 最快且安全,放第一位
ssl_ecdh_curve X25519:prime256v1:secp384r1;
}
2.2 为什么这样选
只留 ECDHE:ECDHE 提供前向保密(Forward Secrecy)——每次握手生成临时密钥,即使服务器私钥泄露,攻击者也无法解密之前抓到的流量。静态 RSA 密钥交换没有这个性质。
只留 AEAD 套件(GCM、CHACHA20-POLY1305):CBC 模式的套件有一系列已知攻击(BEAST、Lucky13、POODLE),RC4 已被完全破解。
ssl_prefer_server_ciphers off 在 TLS 1.3 下是官方推荐:客户端知道自己有没有 AES-NI 硬件加速。有 AES-NI 的设备(现代 x86/ARM)用 AES-GCM 更快;没有的(老手机、嵌入式)用 ChaCha20-Poly1305 更快。让客户端选比服务端猜要好。
X25519 曲线比 prime256v1(P-256)快约 20-30%,且实现上不易受时序攻击。
2.3 Mozilla 的三档配置
不想自己纠结的话,直接用 Mozilla 的 SSL Configuration Generator 生成:
| 档位 | 兼容性 | 协议 |
|---|---|---|
| Modern | Firefox 63+、Chrome 70+、iOS 12.2+ | 只 TLS 1.3 |
| Intermediate | Firefox 27+、Chrome 31+、iOS 9+、Android 4.4+ | TLS 1.2 + 1.3 |
| Old | 支持到 IE8、Android 2.3 | TLS 1.0-1.3 |
绝大多数场景选 Intermediate。Modern 只支持 TLS 1.3,会挡掉一些还在用的老设备。
3. TLS 性能优化
TLS 握手是 Nginx 上最昂贵的单项操作。一次完整握手涉及非对称加密运算(RSA 签名或 ECDSA 签名),比整个 HTTP 请求处理都贵。
各种优化的收益量级:
| 优化项 | 收益 |
|---|---|
| 会话复用(session cache/ticket) | 省掉整个非对称运算,收益最大 |
| ECDSA 证书替代 RSA | 握手 CPU 降 3-5 倍 |
| TLS 1.3 | 握手往返从 2-RTT 降到 1-RTT |
| OCSP Stapling | 省掉客户端到 OCSP 服务器的一次往返 |
| HTTP/2 | 复用连接,减少握手次数 |
| TLS 1.3 Early Data | 0-RTT,但有重放风险 |
3.1 会话复用
这是最重要的优化。原理是让客户端复用之前协商好的主密钥,跳过非对称运算。
两种机制:
Session Cache(服务端存储)
http {
# shared:名字:大小 —— 所有 worker 共享
# 1MB 约存 4000 个会话
ssl_session_cache shared:SSL:50m;
# 会话有效期。默认只有 5 分钟,太短了,建议 1 天
ssl_session_timeout 1d;
}
ssl_session_cache 必须用 shared 而不是 builtin。builtin 是每个 worker 独立的 OpenSSL 内部缓存,客户端第二次请求落到另一个 worker 上就复用不了。多 worker 下 builtin 的命中率极低。
# ❌ 常见错误:命中率很低
ssl_session_cache builtin:1000;
# ❌ 更糟:默认值 none,完全没有复用
# (不写 ssl_session_cache 就是 none)
# ✅
ssl_session_cache shared:SSL:50m;
Session Ticket(客户端存储)
ssl_session_tickets on; # 默认 on
ssl_session_ticket_key /etc/nginx/ssl/ticket1.key; # 当前 key(第一个用于加密)
ssl_session_ticket_key /etc/nginx/ssl/ticket2.key; # 旧 key(只用于解密)
服务端用一个对称密钥加密会话状态,交给客户端保存在 ticket 里。服务端不用存任何东西,天然支持多机共享(只要 key 一样)。
多台 Nginx 必须共享 ticket key,否则客户端的 ticket 在另一台机器上解不开:
# 生成 ticket key
openssl rand 80 > /etc/nginx/ssl/ticket1.key
chmod 600 /etc/nginx/ssl/ticket1.key
# 分发到所有 Nginx 节点
for host in nginx-1 nginx-2 nginx-3; do
scp /etc/nginx/ssl/ticket1.key $host:/etc/nginx/ssl/
done
Ticket key 必须定期轮换(建议每天)。因为 ticket 里的会话密钥是用它加密的,key 长期不换等于削弱了前向保密——攻击者拿到 key 就能解密所有用它加密过的会话。
轮换脚本:
#!/bin/bash
# rotate-ticket-key.sh,每天凌晨跑
cd /etc/nginx/ssl
mv ticket2.key ticket3.key 2>/dev/null
mv ticket1.key ticket2.key
openssl rand 80 > ticket1.key
chmod 600 ticket1.key
rm -f ticket3.key
nginx -s reload
配置里保留两个 key:新 key 用于加密新会话,旧 key 仍能解密上一周期的 ticket,实现平滑过渡。
验证会话复用是否生效
# 用 -reconnect 测试复用
openssl s_client -connect example.com:443 -servername example.com -reconnect < /dev/null 2>&1 \
| grep -E "Reused|New"
# 第一次: New, TLSv1.3, Cipher is TLS_AES_128_GCM_SHA256
# 后续: Reused, TLSv1.3, Cipher is TLS_AES_128_GCM_SHA256 ← 复用成功
# 分别测两种机制
openssl s_client -connect example.com:443 -no_ticket -reconnect < /dev/null 2>&1 | grep -c Reused
# 禁用 ticket,测 session cache
# 看 ticket 是否返回
openssl s_client -connect example.com:443 < /dev/null 2>&1 | grep -A2 "TLS session ticket"
3.2 ECDSA 证书
RSA 2048 的签名运算比 ECDSA P-256 慢 3-5 倍。换成 ECDSA 能显著降低握手 CPU。
参考数据(单核):
| 证书类型 | 握手/秒 |
|---|---|
| RSA 2048 | 1000-2000 |
| RSA 4096 | 200-400 |
| ECDSA P-256 | 5000-8000 |
RSA 4096 是常见的过度配置:安全性提升有限(2048 位到 2030 年前都够用),但性能损失 5 倍。除了合规要求,不要用 4096。
双证书配置(ECDSA + RSA 兼容老客户端)
Nginx 1.11.0+ 支持同时配置多张证书,会根据客户端的能力自动选择:
server {
listen 443 ssl;
http2 on;
# ECDSA(现代客户端优先用,更快)
ssl_certificate /etc/nginx/ssl/ecdsa/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/ecdsa/privkey.pem;
# RSA(老客户端降级用)
ssl_certificate /etc/nginx/ssl/rsa/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/rsa/privkey.pem;
# 套件顺序里 ECDSA 在前,让支持的客户端优先选它
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:...;
}
选择逻辑在 TLS 握手时完成:客户端在 ClientHello 里声明支持的签名算法,Nginx 挑一张匹配的证书。支持 ECDSA 的客户端走 ECDSA(快),只支持 RSA 的走 RSA(兼容),两头都占到。
生成 ECDSA 证书:
# 自签测试用
openssl ecparam -genkey -name prime256v1 -out ecdsa.key
openssl req -new -x509 -key ecdsa.key -out ecdsa.crt -days 365 \
-subj "/CN=example.com"
# Let's Encrypt 签发 ECDSA
certbot certonly --key-type ecdsa --elliptic-curve secp256r1 -d example.com
3.3 OCSP Stapling
浏览器需要验证证书没被吊销。默认做法是浏览器自己去访问 CA 的 OCSP 服务器——这多了一次 DNS 解析 + TCP 连接 + HTTP 请求,可能增加几百毫秒延迟,而且泄露了用户访问了哪个网站。
OCSP Stapling 让 Nginx 预先向 CA 查询好吊销状态,把签名的响应「订」(staple)在 TLS 握手里发给客户端。
http {
ssl_stapling on;
ssl_stapling_verify on;
# 用于验证 OCSP 响应签名的证书链(通常是中间 CA + 根 CA)
# 如果 ssl_certificate 里已包含完整链,某些情况下可以省略
ssl_trusted_certificate /etc/nginx/ssl/chain.pem;
# Nginx 需要解析 OCSP 服务器的域名,必须配 resolver
resolver 1.1.1.1 8.8.8.8 valid=300s ipv6=off;
resolver_timeout 5s;
}
resolver 必须配,否则 Nginx 无法解析 OCSP 服务器地址,Stapling 静默失效。这是最常见的配置遗漏。
验证:
openssl s_client -connect example.com:443 -servername example.com -status < /dev/null 2>&1 \
| grep -A 20 "OCSP response"
# 成功的输出:
# OCSP Response Status: successful (0x0)
# Cert Status: good
# 失败的输出:
# OCSP response: no response sent
注意 Stapling 的首次请求:Nginx 启动后第一个请求时还没有 OCSP 响应(要等它异步去查),所以第一次验证可能显示 “no response sent”。等几秒再试。
Nginx 1.27.4+ 引入了 ssl_certificate_cache 和更好的 OCSP 预取机制,可以避免这个冷启动问题。
3.4 TLS 1.3 与 0-RTT
ssl_protocols TLSv1.2 TLSv1.3;
# 0-RTT(Early Data):客户端在握手完成前就发送应用数据
ssl_early_data on;
# 开启后必须处理重放风险
proxy_set_header Early-Data $ssl_early_data;
TLS 1.3 的改进:
- 握手从 2-RTT 降到 1-RTT:省一个往返,跨国访问能省 100-300ms
- 会话恢复 0-RTT:客户端直接带数据,零往返
0-RTT 的重放攻击风险:Early Data 里的请求可以被攻击者截获并重复发送,服务端无法区分。所以:
- 只能用于幂等请求(GET/HEAD)
- 非幂等请求必须在应用层拒绝或做幂等处理
Nginx 会设置 $ssl_early_data 变量(值为 1 表示这个请求来自 Early Data),把它传给后端让业务层判断:
// Go 后端
if r.Header.Get("Early-Data") == "1" && r.Method != http.MethodGet {
// RFC 8470 定义的状态码:要求客户端重发(不用 Early Data)
w.WriteHeader(http.StatusTooEarly) // 425
return
}
保守建议:除非确实需要极致的首字节延迟,不要开 ssl_early_data。 收益(省 1-RTT)和引入的复杂度、风险相比不划算。
3.5 减少握手次数
# 客户端长连接开长一些,减少重新握手
keepalive_timeout 75s;
keepalive_requests 1000;
# TLS 记录大小。
# 默认 16KB 意味着要凑够 16KB 才能解密第一个字节,影响首字节延迟。
# 4KB 让浏览器更快开始渲染,代价是稍多的协议开销。
ssl_buffer_size 4k;
ssl_buffer_size 的权衡:
- 大(16k):吞吐更高,适合大文件下载
- 小(4k):首字节延迟更低,适合网页
网页服务用 4k,文件下载服务用 16k。
4. HSTS 与安全增强
server {
listen 443 ssl;
# HSTS:强制浏览器在指定时间内只用 HTTPS 访问本站
# max-age=63072000 是 2 年(提交到 preload list 的要求)
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
}
HSTS 的作用:浏览器记住这个站点只能用 HTTPS,即使用户输入 http:// 也会在本地直接改成 https,不发出明文请求。这挡住了 SSL 剥离攻击(攻击者在中间把 HTTPS 降级成 HTTP)。
上线 HSTS 的注意事项:
includeSubDomains会影响所有子域名。如果有子域名还在用 HTTP(比如内部系统),加了这个参数后浏览器会拒绝访问它们。max-age从小开始。先设300(5 分钟)观察一周,确认所有子域名和路径的 HTTPS 都正常,再逐步加大到 2 年。preload是不可逆的。提交到 hstspreload.org 后,HSTS 规则会内置到浏览器里,移除要等好几个月的浏览器版本迭代。确认长期用 HTTPS 再提交。
HSTS 不要加在 HTTP 的 server 块上——HTTP 响应里的 HSTS 头会被浏览器忽略(因为明文可被篡改),只在 HTTPS 响应里有效。
5. Let’s Encrypt 自动化
5.1 首次申请
# 装 certbot
apt install certbot python3-certbot-nginx
# 方式一:certbot 自动改 Nginx 配置(快速上手)
certbot --nginx -d example.com -d www.example.com
# 方式二:只签发证书,自己管配置(推荐,配置可控)
certbot certonly --webroot -w /var/www/html \
-d example.com -d www.example.com \
--key-type ecdsa
# 方式三:DNS 验证(泛域名证书必须用这个)
certbot certonly --manual --preferred-challenges dns \
-d "*.example.com" -d example.com
5.2 Nginx 配合 webroot 验证
# snippets/letsencrypt.conf
location ^~ /.well-known/acme-challenge/ {
root /var/www/html;
default_type "text/plain";
allow all;
# 注意:不能被上面的 "location ~ /\." 规则拦住
}
server {
listen 80;
server_name example.com www.example.com;
include snippets/letsencrypt.conf; # 必须在跳转规则之前
location / {
return 301 https://$host$request_uri;
}
}
顺序很重要:ACME 验证路径的 location 必须在 return 301 之前,否则验证请求会被跳转到 HTTPS,而此时证书还没签发,验证失败。用 ^~ 也能保证优先级。
另外注意常见的「屏蔽隐藏文件」规则会拦掉 ACME 验证:
# ⚠️ 这条规则会拦掉 /.well-known/acme-challenge/
location ~ /\. {
deny all;
}
# ✅ 排除 .well-known
location ~ /\.(?!well-known) {
deny all;
}
5.3 自动续期
# certbot 装好后会自动创建 systemd timer
systemctl status certbot.timer
systemctl list-timers | grep certbot
# 手动测试续期流程(不真的续)
certbot renew --dry-run
# 续期成功后 reload nginx
# 写在 /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
cat > /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh <<'EOF'
#!/bin/bash
nginx -t && nginx -s reload
EOF
chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
证书过期是最常见的线上事故之一。一定要加监控:
#!/bin/bash
# check-cert-expiry.sh —— 加到 crontab 每天跑
DOMAINS="example.com api.example.com www.example.com"
THRESHOLD=15 # 15 天内过期就告警
for d in $DOMAINS; do
end=$(echo | 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 "ERROR: cannot fetch cert for $d"
continue
fi
end_ts=$(date -d "$end" +%s)
now_ts=$(date +%s)
days=$(( (end_ts - now_ts) / 86400 ))
if [ "$days" -lt "$THRESHOLD" ]; then
echo "WARNING: $d expires in $days days ($end)"
# 这里接告警:企业微信/钉钉/PagerDuty
else
echo "OK: $d expires in $days days"
fi
done
5.4 acme.sh(更轻量的替代)
curl https://get.acme.sh | sh
~/.acme.sh/acme.sh --set-default-ca --server letsencrypt
# 签发(支持各种 DNS API 自动验证)
export Ali_Key="xxx"
export Ali_Secret="yyy"
~/.acme.sh/acme.sh --issue --dns dns_ali -d example.com -d "*.example.com" \
--keylength ec-256
# 安装到指定位置并自动 reload
~/.acme.sh/acme.sh --install-cert -d example.com --ecc \
--key-file /etc/nginx/ssl/example.com/privkey.pem \
--fullchain-file /etc/nginx/ssl/example.com/fullchain.pem \
--reloadcmd "nginx -t && nginx -s reload"
acme.sh 是纯 shell 脚本,无 Python 依赖,DNS API 支持比 certbot 全(几十家 DNS 服务商),泛域名证书自动续期更方便。
6. HTTP/2
server {
listen 443 ssl;
http2 on; # 1.25.1+ 的写法
# 1.25.1 之前:listen 443 ssl http2;
# 单连接最大并发 stream 数
http2_max_concurrent_streams 128;
# HTTP/2 连接的空闲超时
keepalive_timeout 75s;
# 1.19.7 之前有 http2_push,现在已移除
# (Server Push 实践效果差,浏览器已全部弃用,改用 103 Early Hints)
}
6.1 HTTP/2 的收益与注意点
收益:
- 多路复用:一个 TCP 连接跑所有请求,消灭了 HTTP/1.1 的队头阻塞和 6 连接限制
- 头部压缩(HPACK):重复的 header 只传一次索引
- 二进制分帧:解析更高效
- 服务端可以设置 stream 优先级
注意点:
-
域名分片(domain sharding)反成负优化。HTTP/1.1 时代把资源拆到多个域名以突破浏览器的 6 连接限制;HTTP/2 下这会造成多次 TLS 握手,应该合并回一个域名。
-
CSS Sprite 和文件合并的收益变小。HTTP/2 下多个小请求的开销很低,过度合并反而影响缓存粒度(改一个图标要重新下载整张雪碧图)。
-
TCP 层的队头阻塞仍然存在。HTTP/2 解决了应用层的队头阻塞,但一个 TCP 包丢了,整个连接上所有 stream 都要等重传。这正是 HTTP/3 用 QUIC(基于 UDP)要解决的问题。
-
limit_conn效果变弱。一个 HTTP/2 连接能并发几百个请求,靠限连接数挡不住,必须用limit_req+http2_max_concurrent_streams。 -
Server Push 已死。所有主流浏览器都移除了支持,Nginx 1.25.1 也删掉了
http2_push。替代方案是103 Early Hints(Nginx 目前需要 Lua 或等官方支持)。
6.2 验证 HTTP/2
curl -I --http2 https://example.com
# HTTP/2 200 ← 生效
# 看 ALPN 协商
openssl s_client -connect example.com:443 -alpn h2 < /dev/null 2>&1 | grep ALPN
# ALPN protocol: h2
# nghttp 详细查看帧
nghttp -nv https://example.com
7. HTTP/3 与 QUIC
Nginx 1.25.0+ 正式支持 HTTP/3(需要用支持 QUIC 的 SSL 库编译,如 BoringSSL 或 OpenSSL 3.2+ / quictls)。
./configure --with-http_v3_module --with-openssl=../quictls ...
server {
# HTTP/3 走 UDP 443
listen 443 quic reuseport;
# 同时保留 HTTP/2 走 TCP 443(降级用)
listen 443 ssl;
http2 on;
server_name example.com;
ssl_certificate /etc/nginx/ssl/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/privkey.pem;
# QUIC 要求 TLS 1.3
ssl_protocols TLSv1.3;
# 告诉客户端「我支持 HTTP/3,下次用 UDP 443 来」
add_header Alt-Svc 'h3=":443"; ma=86400' always;
# QUIC 地址验证(防放大攻击),建议开
quic_retry on;
# QUIC 的 GSO 优化(需要内核支持)
quic_gso on;
}
别忘了防火墙放开 UDP 443:
ufw allow 443/udp
# 或
iptables -A INPUT -p udp --dport 443 -j ACCEPT
7.1 HTTP/3 的价值
QUIC 基于 UDP,解决了 TCP 的固有问题:
| 问题 | TCP + HTTP/2 | QUIC + HTTP/3 |
|---|---|---|
| 队头阻塞 | 一个包丢了阻塞所有 stream | 每个 stream 独立,互不影响 |
| 握手 | TCP 3 次握手 + TLS 1.3 1-RTT = 2-RTT | 合并成 1-RTT,恢复时 0-RTT |
| 连接迁移 | IP 变了连接就断(WiFi 切 4G) | Connection ID 不变,连接保持 |
| 拥塞控制 | 内核实现,升级慢 | 用户态实现,可快速迭代 |
收益最明显的场景是弱网和移动端:丢包率高时 HTTP/3 的优势非常大;地铁里 WiFi 切蜂窝网络时连接不断。
代价:
- CPU 开销比 TCP 高(加解密在用户态、没有网卡硬件卸载)
- 部分企业防火墙和运营商会阻断 UDP 443
- 生态还在成熟
建议:面向公网的 C 端服务值得开(浏览器会自动降级,风险低);纯内网服务没必要。
7.2 验证 HTTP/3
# curl 需要用支持 HTTP/3 的版本编译
curl -I --http3 https://example.com
# 或者用在线工具
# https://http3check.net/
8. 多域名与 SNI
8.1 SNI 的作用
一个 IP 上托管多个 HTTPS 站点,需要在 TLS 握手时就知道客户端要访问哪个域名(因为要选对应的证书)。SNI(Server Name Indication) 就是 ClientHello 里的一个扩展,明文携带目标域名。
# 三个域名,三张证书,共用一个 IP 和 443 端口
server {
listen 443 ssl;
server_name a.example.com;
ssl_certificate /etc/nginx/ssl/a/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/a/privkey.pem;
}
server {
listen 443 ssl;
server_name b.example.com;
ssl_certificate /etc/nginx/ssl/b/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/b/privkey.pem;
}
# 必须有兜底!否则不带 SNI 的客户端会拿到第一个 server 的证书
server {
listen 443 ssl default_server;
server_name _;
ssl_certificate /etc/nginx/ssl/default/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/default/privkey.pem;
ssl_reject_handshake on; # 1.19.4+:直接拒绝握手,不用配假证书
return 444;
}
ssl_reject_handshake on 是 1.19.4 引入的好东西:不需要配一张假证书就能拒绝未知 SNI 的握手。之前必须放一张自签证书占位。
8.2 用变量动态加载证书
证书特别多(几千个域名,比如 SaaS 平台的自定义域名)时,写几千个 server 块不现实:
server {
listen 443 ssl;
server_name ~^(?<domain>.+)$;
# 按域名动态查找证书文件
ssl_certificate /etc/nginx/ssl/$domain/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/$domain/privkey.pem;
# 1.27.4+:缓存证书,避免每次握手都读磁盘
ssl_certificate_cache max=1000 inactive=20s valid=1m;
}
注意性能:用变量后每次握手都要读磁盘文件(1.27.4 之前没有缓存)。域名多的话建议用 OpenResty 的 ssl_certificate_by_lua_block 从共享内存或 Redis 取:
ssl_certificate_by_lua_block {
local ssl = require "ngx.ssl"
local host = ssl.server_name()
-- 从共享内存/Redis 取证书,支持热更新,无需 reload
local cert, key = get_cert_from_cache(host)
ssl.clear_certs()
ssl.set_der_cert(cert)
ssl.set_der_priv_key(key)
}
9. mTLS(双向认证)
客户端也要出示证书,服务端验证。用于内部服务间调用、API 开放平台、金融级安全要求。
server {
listen 443 ssl;
server_name api.internal.com;
ssl_certificate /etc/nginx/ssl/server/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/server/privkey.pem;
# ---------- 客户端证书验证 ----------
ssl_client_certificate /etc/nginx/ssl/ca.crt; # 签发客户端证书的 CA
ssl_verify_client on; # on / off / optional / optional_no_ca
ssl_verify_depth 2; # 证书链验证深度
# 吊销列表(客户端证书泄露后要能吊销)
ssl_crl /etc/nginx/ssl/client.crl;
location / {
# 把客户端证书信息传给后端做细粒度授权
proxy_set_header X-Client-DN $ssl_client_s_dn;
proxy_set_header X-Client-Serial $ssl_client_serial;
proxy_set_header X-Client-Verify $ssl_client_verify;
proxy_set_header X-Client-Fingerprint $ssl_client_fingerprint;
proxy_pass http://backend;
}
}
ssl_verify_client 的四个值:
| 值 | 行为 |
|---|---|
on |
必须提供有效客户端证书,否则拒绝连接 |
off |
不要求(默认) |
optional |
可选,但如果提供了就必须有效;结果在 $ssl_client_verify |
optional_no_ca |
可选,且不验证签发 CA(由后端应用自己验证) |
optional 模式很实用:内网证书免密、外网走 Token:
ssl_verify_client optional;
location / {
# 有有效客户端证书的直接放行
if ($ssl_client_verify = SUCCESS) {
proxy_pass http://backend;
break;
}
# 没有的走正常鉴权
auth_request /auth;
proxy_pass http://backend;
}
9.1 生成测试用的客户端证书
# 1. 生成 CA
openssl req -x509 -newkey rsa:2048 -nodes -days 3650 \
-keyout ca.key -out ca.crt -subj "/CN=Internal CA"
# 2. 生成客户端私钥和 CSR
openssl req -newkey rsa:2048 -nodes \
-keyout client.key -out client.csr -subj "/CN=service-a/O=MyOrg"
# 3. 用 CA 签发客户端证书
openssl x509 -req -in client.csr -CA ca.crt -CAkey ca.key \
-CAcreateserial -days 365 -out client.crt
# 4. 打包成 p12(浏览器导入用)
openssl pkcs12 -export -in client.crt -inkey client.key \
-out client.p12 -name "My Client Cert"
# 5. 测试
curl --cert client.crt --key client.key https://api.internal.com/
10. 完整的 TLS 配置片段
# /etc/nginx/snippets/ssl-common.conf
# 在每个 HTTPS server 块里 include 这个文件
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:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
ssl_prefer_server_ciphers off;
ssl_ecdh_curve X25519:prime256v1:secp384r1;
# 会话复用
ssl_session_cache shared:SSL:50m;
ssl_session_timeout 1d;
ssl_session_tickets on;
ssl_session_ticket_key /etc/nginx/ssl/ticket1.key;
ssl_session_ticket_key /etc/nginx/ssl/ticket2.key;
# OCSP Stapling
ssl_stapling on;
ssl_stapling_verify on;
ssl_trusted_certificate /etc/nginx/ssl/chain.pem;
# 缓冲区(网页服务用 4k 降低首字节延迟)
ssl_buffer_size 4k;
# HSTS
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
# nginx.conf 的 http 块里配一次
http {
resolver 1.1.1.1 8.8.8.8 valid=300s ipv6=off;
resolver_timeout 5s;
include /etc/nginx/conf.d/*.conf;
}
使用:
server {
listen 443 ssl reuseport;
listen [::]:443 ssl;
http2 on;
server_name example.com;
ssl_certificate /etc/nginx/ssl/example.com/ecdsa/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/example.com/ecdsa/privkey.pem;
ssl_certificate /etc/nginx/ssl/example.com/rsa/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/example.com/rsa/privkey.pem;
include snippets/ssl-common.conf;
include snippets/security-headers.conf;
# ... location 配置
}
11. 排查清单
| 现象 | 原因 | 处理 |
|---|---|---|
| 移动端报证书错误,PC 正常 | 证书链不完整 | 用 fullchain.pem |
SSL_ERROR_NO_CYPHER_OVERLAP |
客户端和服务端没有共同的套件 | 放宽 ssl_ciphers/ssl_protocols |
| 握手 CPU 100% | 没开会话复用,或用了 RSA 4096 | 配 ssl_session_cache shared,换 ECDSA |
| SSL Labs 评分低 | 协议/套件过时,缺 HSTS | 用 Mozilla generator 重新生成配置 |
| OCSP Stapling 不生效 | 没配 resolver |
加 resolver |
| 多台机器会话复用率低 | ticket key 不一致 | 同步 ticket key 文件 |
| 首字节延迟高 | ssl_buffer_size 太大 |
网页服务设 4k |
| 证书过期导致全站不可用 | 没有续期监控 | 加过期监控脚本 + 告警 |
| ACME 验证失败 | 验证路径被跳转或被 location ~ /\. 拦住 |
调整 location 顺序和规则 |
| HTTP/3 不生效 | 防火墙没开 UDP 443,或缺 Alt-Svc |
开 UDP,加 Alt-Svc 头 |
快速自检脚本:
#!/bin/bash
D=${1:-example.com}
echo "=== 证书链 ==="
echo | openssl s_client -connect "$D:443" -servername "$D" 2>&1 | grep -E "Verify return code"
echo "=== 协议与套件 ==="
echo | openssl s_client -connect "$D:443" -servername "$D" 2>&1 | grep -E "Protocol|Cipher"
echo "=== 会话复用 ==="
openssl s_client -connect "$D:443" -servername "$D" -reconnect < /dev/null 2>&1 | grep -cE "^Reused"
echo "=== OCSP Stapling ==="
echo | openssl s_client -connect "$D:443" -servername "$D" -status 2>&1 | grep -E "OCSP Response Status|no response"
echo "=== HTTP/2 ==="
curl -sI --http2 "https://$D" | head -1
echo "=== 有效期 ==="
echo | openssl s_client -connect "$D:443" -servername "$D" 2>/dev/null | openssl x509 -noout -dates
echo "=== HSTS ==="
curl -sI "https://$D" | grep -i strict-transport
12. 面试题
Q:Nginx 配 HTTPS 时 ssl_certificate 应该放什么?只放服务器证书会怎样?
要放完整链(fullchain):服务器证书 + 中间 CA 证书,按顺序拼接,不需要根证书。只放服务器证书时,桌面浏览器可能因为本地缓存或自动补全而正常显示,但移动端和很多客户端库会报「无法验证签发者」的错误——这是典型的「我这里能访问但用户不行」。
Q:TLS 握手为什么贵?怎么优化?
贵在非对称加密运算(RSA/ECDSA 签名),一次完整握手的 CPU 开销比整个 HTTP 请求处理都大。优化按收益排序:(1) 会话复用(ssl_session_cache shared + ssl_session_tickets),直接跳过非对称运算,收益最大;(2) 用 ECDSA 证书替代 RSA,快 3-5 倍;(3) TLS 1.3,握手从 2-RTT 降到 1-RTT;(4) OCSP Stapling 省掉客户端查询吊销状态的往返;(5) 加长 keepalive 减少握手次数。
Q:ssl_session_cache builtin 和 shared 有什么区别?
builtin 是 OpenSSL 的内部缓存,每个 worker 独立,客户端第二次请求落到另一个 worker 上就复用不了,多 worker 下命中率极低。shared 是 Nginx 的共享内存,所有 worker 共享,命中率高。生产必须用 shared。另外不配这个指令时默认是 none(完全没有复用)。
Q:Session Cache 和 Session Ticket 有什么区别?多台 Nginx 怎么处理?
Session Cache 在服务端存会话状态、给客户端一个 ID;Session Ticket 把加密后的会话状态交给客户端保存,服务端不存任何东西。多台 Nginx 时:Cache 无法跨机共享(除非用第三方模块接 Redis),Ticket 只要各机器的 ssl_session_ticket_key 一致就能跨机复用。所以多机部署要重点保证 ticket key 同步,并定期轮换(保留新旧两个 key 实现平滑过渡)。
Q:为什么 ticket key 要轮换?
Ticket 里的会话密钥是用 ticket key 对称加密的。key 长期不变的话,攻击者一旦拿到它,就能解密所有用这个 key 加密过的历史会话——相当于削弱了 ECDHE 提供的前向保密。建议每天轮换,配置里保留两个 key(新的加密、旧的仍可解密)。
Q:OCSP Stapling 是什么?为什么必须配 resolver?
浏览器验证证书是否被吊销时默认要自己访问 CA 的 OCSP 服务器,多一次 DNS + TCP + HTTP 往返(可能几百毫秒),还泄露用户访问了哪个站点。Stapling 让 Nginx 预先查好并把 CA 签名的响应「订」在 TLS 握手里发出去。Nginx 需要解析 OCSP 服务器的域名,所以必须配 resolver——不配的话 Stapling 会静默失效,这是最常见的配置遗漏。
Q:什么是前向保密?Nginx 怎么配置?
前向保密(Forward Secrecy)指即使服务器私钥泄露,攻击者也无法解密之前抓取的流量。实现方式是每次握手用 ECDHE/DHE 协商临时密钥,私钥只用于身份签名而不参与密钥交换。配置上就是 ssl_ciphers 里只保留 ECDHE- 和 DHE- 开头的套件,剔除静态 RSA 密钥交换的套件。
Q:TLS 1.3 的 0-RTT 有什么风险?
Early Data 里的请求可以被攻击者截获后重复发送,服务端无法区分是重放还是正常请求。所以只能用于幂等请求。Nginx 会设置 $ssl_early_data 变量,应该传给后端(Early-Data 头),让业务层对非幂等方法返回 425 Too Early 要求客户端重发。保守做法是不开 ssl_early_data——省 1-RTT 的收益不值得引入这个复杂度。
Q:SNI 解决了什么问题?没有 SNI 会怎样?
TLS 握手时服务端要先选证书,但此时还没收到 HTTP 请求(不知道 Host)。SNI 是 ClientHello 里的扩展,明文携带目标域名,让服务端能选对证书。这使得一个 IP 能托管多个 HTTPS 站点。没有 SNI 的客户端(很老的设备)会拿到 default_server 的证书,导致域名不匹配错误。配 ssl_reject_handshake on(1.19.4+)可以直接拒绝这类握手而不用放一张假证书。
Q:HTTP/2 相比 HTTP/1.1 的改进?还有什么问题没解决?
改进:二进制分帧、多路复用(消灭应用层队头阻塞和浏览器 6 连接限制)、HPACK 头部压缩、stream 优先级。没解决的:TCP 层的队头阻塞——一个 TCP 包丢了,整个连接上所有 stream 都要等重传。这是 HTTP/3 用基于 UDP 的 QUIC 来解决的。另外 HTTP/2 下域名分片和文件过度合并成了负优化,limit_conn 的限制效果也大幅削弱。
Q:HSTS 的 preload 要注意什么?
提交到 hstspreload.org 后,规则会内置进浏览器源码,移除需要等好几个月的浏览器版本迭代,基本不可逆。上线前必须确认:所有子域名(如果加了 includeSubDomains)都已支持 HTTPS、长期不会回退到 HTTP。建议 max-age 从 300 秒开始逐步加大,观察无误再提交 preload。
上一篇:Nginx-09 限流限连与安全防护 | 下一篇:Nginx-11 日志与监控
xingliuhua