证书明明还在有效期内,浏览器却弹「您的连接不是私密连接」,error.log里刷出一行SSL_do_handshake() failed (SSL: error:14094416:SSL routines:ssl3_read_bytes:sslv3 alert certificate unknown: SSL alert number 46)。握手阶段就断了,access.log一条记录都没有,后端服务根本没参与。这类故障先别急着重启nginx,也别去查负载,八成是证书文件本身有毛病。
日志末尾那个alert number才是真正的分诊依据。客户端看到的NET::ERR_CERT_AUTHORITY_INVALID、curl的 (60)、Java的PKIX path building failed全是结果,编号才是原因。
alert number对应到四种情况
46是certificate unknown,客户端不认这张证书,最常见的原因是服务端只发了叶子证书、没带中间证书;45是certificate expired,过期;42是bad certificate,证书被判无效,签名、时间、密钥用法任一不对都往这走;40是handshake failure,协议版本或加密套件谈不下来。日志里那一长串十六进制码可以直接翻回文字:
openssl errstr 14094416 # error:14094416:SSL routines:ssl3_read_bytes:sslv3 alert certificate unknown
遇到没见过的码先翻一下,比凭经验猜快得多。另外如果日志是400的 "The plain HTTP request was sent to HTTPS port",那跟证书无关,是有人用http访问了443端口。
用s_client问一句服务端到底发了什么
openssl s_client -connect www.example.com:443 -servername www.example.com -showcerts
重点看两处:Certificate chain段里出现了几张证书,以及末尾的Verify return code。只有一张BEGIN CERTIFICATE就是链断了,返回20(unable to get local issuer certificate)或21(unable to verify the first certificate)都是同一个意思;返回10是certificate has expired,18是self signed certificate,0才正常。-servername这个参数别省,不开SNI的话拿到的是默认server块的证书,排查会跑偏。
遇到过好几次这种情况:证书在运维自己电脑上打开一切正常,手机、小程序或者Java客户端一访问就炸。原因是浏览器会按证书里的Authority Information Access或CT日志去补中间证书,别的客户端不兜这个底。
补链:顺序不能颠倒
nginx没有单独的证书链指令,ssl_certificate必须指向拼好的那一个文件,叶子在前、中间在后:
cat example.com.crt intermediate.crt > fullchain.crt
ssl_certificate /etc/nginx/ssl/fullchain.crt; ssl_certificate_key /etc/nginx/ssl/example.com.key;
用Let's Encrypt的话直接用fullchain.pem,别拿cert.pem去配,那是纯叶子。certbot落在 /etc/letsencrypt/live/域名/ 下几个文件各有用途,chain.pem是中间、fullchain.pem是叶子加中间,privkey.pem是私钥。改完先nginx -t再nginx -s reload,reload会重新读证书文件,restart没必要。
拼完之后现场复验一次,两个命令都过才算完:
openssl verify -CAfile /etc/ssl/certs/ca-certificates.crt /etc/nginx/ssl/fullchain.crt openssl s_client -connect www.example.com:443 -servername www.example.com 2>/dev/null | grep "Verify return code"
证书和私钥配不上
启动阶段nginx就会报SSL_CTX_use_PrivateKey_file() failed,后面跟着X509_check_private_key:key values mismatch。两个文件的modulus算一下就清楚:
openssl x509 -noout -modulus -in fullchain.crt | openssl md5 openssl rsa -noout -modulus -in example.com.key | openssl md5
两个md5不一样,说明续期时换了私钥却没换证书,或者证书文件跟密钥对不上号。私钥是ECDSA的话第二条换成openssl ec -noout -modulus。顺带说一句,证书文件里要是少了 -----BEGIN CERTIFICATE----- 这一行,报的是PEM routines:get_name:no start line,那是拷贝时漏了首尾行,Winscp或者富文本粘贴最容易干这事。
域名对不上:SAN与默认server块
echo | openssl s_client -connect www.example.com:443 -servername www.example.com 2>/dev/null | openssl x509 -noout -dates -subject -ext subjectAltName
现在只认subjectAltName,Common Name早就不算了。example.com和www.example.com在证书里是两个名字,通配符 *.example.com只覆盖一级,a.b.example.com不在里面。另一种更隐蔽:同一台机器上好几个server块都监听443,请求的域名没匹配上任何server_name,nginx就拿默认server块的证书去握手,客户端看到的自然是域名不匹配。日志里会看到no "ssl_certificate" is defined in server listening on SSL port while SSL handshaking,那就是某个块漏配了ssl_certificate。
时间不对也算过期
证书没到期、链也完整,客户端还是报NET::ERR_CERT_DATE_INVALID,先看服务器时间。从快照恢复的云主机、迁移过的虚拟机,系统时间停在几个月前是常事,TLS握手会直接判定证书尚未生效。用date和timedatectl status确认,必要时chronyd或ntpdate拉一次。反过来也有:客户端机器时间错,服务端怎么改都没用。
反向代理到HTTPS后端那一侧
nginx反代https upstream时默认不校验后端证书,加了proxy_ssl_verify on; 又没给proxy_ssl_trusted_certificate,就会报upstream SSL certificate verify error: (20: unable to get local issuer certificate),同时前端收到502。补上信任链文件,或者把校验先关掉止血:
proxy_ssl_verify on; proxy_ssl_trusted_certificate /etc/nginx/ssl/ca.pem; proxy_ssl_server_name on;
proxy_ssl_server_name别忘,不开的话后端收到的SNI是空的,多域名后端会拿错证书。
协议与套件的版本差异
nginx 1.27.3、1.28.0、1.28.3,这一档的ssl_protocols默认值已经是TLSv1.2与TLSv1.3,1.23.4之前还带着TLSv1、TLSv1.1。老配置里手写了一串ssl_protocols的机器,升级后经常出现老客户端连不上——Java 7、Android 4.4那批设备只支持到TLSv1,被砍掉之后握手直接失败,服务端日志正是40。要兼容就把协议放开,代价是安全评级降档,取舍看业务。加密套件同理,ssl_ciphers里写了ECDHE之外的一堆老算法,服务端和客户端一个都谈不拢时日志会冒出no shared cipher。
Ubuntu 22.04、24.04自带的OpenSSL版本不一样,24.04是3.0.13,一些老算法在OpenSSL 3.x里默认进了legacy provider,套件名看着对但就是握手不过。想确认到底是协议还是套件的问题,用 -tls1_2逐个版本试探最直观:
openssl s_client -connect www.example.com:443 -servername www.example.com -tls1_2
更省事的是跑一遍nmap --script ssl-enum-ciphers -p 443,能列出服务端实际接受的协议与套件清单,比一条条试快。
续期别只靠cron
Let's Encrypt的证书是90天有效期,官方已经公布要逐步缩短,2028年降到45天。靠cron每天跑一次acme.sh --cron或certbot renew是及格线,但失败没人知道照样出事。acme.sh 3.1.2、3.1.3、3.1.6,这几个版本对续期时机的算法不一样:3.1.2及更早默认是到期前60天附近才动,3.1.3起改成30天,并且开始用ARI(ACME Renewal Info)由服务端建议续期时间。证书寿命缩短之后,老版本的固定天数算法很容易踩到「该续的时候还没到点」的空档。certbot 2.9.0、4.1.0,同样有这条分界线,4.1.0才开始支持ARI,Ubuntu 24.04上apt装的还是2.9.0。
实测最省心的办法是两层:一层是客户端自己续,另一层是外部监控,拿openssl直接问剩余天数,少于15天就告警:
echo | openssl s_client -connect www.example.com:443 -servername www.example.com 2>/dev/null | openssl x509 -noout -checkend 1296000
1296000秒是15天,命令的退出码非0就说明证书撑不过去了。这一条挂到Zabbix、Prometheus的blackbox_exporter或者最简单的cron邮件上都行。blackbox_exporter本身就能输出probe_ssl_earliest_cert_expiry,比自己写脚本稳。
还有个容易漏的点:证书续了,nginx没重载,进程里还是老证书。acme.sh --install-cert里的 --reloadcmd要写全,certbot的话确认deploy-hook配了。之前接手的一台机器上,证书文件是3天前更新的,nginx worker却已经跑了200多天,页面上报的还是过期,重载一下就恢复了。
数据截至2026年10月。nginx稳定版为1.28.3(2026年3月24日发布),主线的1.31系列仍在更新;openssl、acme.sh、certbot的版本号取自各自官方发布记录,不同发行版源里的版本会滞后,以机器上openssl version的实际输出为准。