Go 服务换了 TLS 证书,但客户端日志里仍然像是在使用旧证书,最容易误判的地方是把“证书文件已经替换”当成“所有连接都会重新握手”。实际上至少有三层状态可能被复用:已经建立的 TCP/TLS 连接、net/http.Transport 的空闲连接池,以及 TLS session ticket 对应的恢复会话。
这篇文章的处理顺序是:先确认是哪一层在复用,再让新握手读取新证书,随后处理 session ticket 和连接池。Go 官方资料入口:https://pkg.go.dev/crypto/tls。
一、先把“旧连接”与“旧会话”分开
如果一条 TLS 连接已经完成握手,后续应用数据不会因为磁盘上的证书文件变化就重新走握手。它继续使用建立连接时协商出的密钥和对端身份。只有连接真正关闭并建立新连接,才有机会读取新证书。
新连接也不一定是完整握手。客户端如果保存了 ClientSessionCache,可能带着 session ticket 请求恢复会话。恢复成功时,tls.ConnectionState.DidResume 会是 true;这条路径的目标是减少握手开销,不能把它当成“再次发送并检查了新证书”。
| 现象 | 实际复用层 | 优先处理 |
|---|---|---|
| 同一个长连接一直不变 | 已建立的 TCP/TLS 连接 | 等待、关闭或排空旧连接 |
| HTTP 请求没有重新拨号 | Transport 空闲连接池 | CloseIdleConnections 或切换 Transport |
| 新 TCP 连接建立但 DidResume=true | TLS session ticket/PSK | 轮换服务端票据密钥,或清理客户端会话缓存 |
| DidResume=false 仍拿到旧证书 | 证书加载或部署实例不一致 | 检查 GetCertificate、SNI 和实例配置 |
二、不要在运行中的 Config 上直接替换 Certificates
tls.Config 交给客户端或服务端使用后,不适合直接修改它的字段。证书轮换可以把当前证书放进原子指针,在 GetCertificate 回调中为新的完整握手取值。这样切换证书与读取证书不会互相覆盖。
package main
import (
"crypto/rand"
"crypto/tls"
"errors"
"sync/atomic"
)
var activeCertificate atomic.Pointer[tls.Certificate]
func newTLSConfig(initial tls.Certificate) *tls.Config {
// 先发布初始证书;回调只读取当前指针,不修改 Config.Certificates。
activeCertificate.Store(&initial)
return &tls.Config{
// 每次完整握手按 SNI 读取当前证书,避免直接改动已在使用的 Config。
GetCertificate: func(*tls.ClientHelloInfo) (*tls.Certificate, error) {
cert := activeCertificate.Load()
if cert == nil {
return nil, errors.New("TLS 证书尚未加载")
}
return cert, nil
},
MinVersion: tls.VersionTLS12,
}
}
func rotateCertificate(cfg *tls.Config, next tls.Certificate) error {
// 先切换证书,后轮换票据密钥;两步完成后新连接才会走新身份。
activeCertificate.Store(&next)
var ticketKey [32]byte
if _, err := rand.Read(ticketKey[:]); err != nil {
return err
}
// 只保留新密钥:旧票据无法解密,新会话会使用当前证书完成完整握手。
cfg.SetSessionTicketKeys([][32]byte{ticketKey})
return nil
}
上面的票据密钥轮换只影响后续尝试恢复的会话,不会主动断开已经建立的连接。生产环境通常还要给连接设置最大存活时间,或在轮换窗口做优雅排空。
三、服务端轮换票据密钥,切断旧恢复路径
Go 的服务端 session ticket key 用于恢复 TLS 会话。SetSessionTicketKeys 的第一个密钥用于生成新票据,传入的所有密钥都可用于解密旧票据,因此如果要让旧票据立即失效,就只保留新生成的密钥。如果希望平滑过渡,可以传入“新密钥在前、旧密钥在后”,待旧票据自然过期后再移除旧密钥。
另一种短时措施是设置 SessionTicketsDisabled: true,让新连接不再使用票据恢复。但这同样不会把已经存在的连接变成新连接,不能代替连接排空。
func forceFullHandshakes(cfg *tls.Config) error {
var fresh [32]byte
// 使用密码学随机数生成新票据密钥,不把固定字符串当作密钥。
if _, err := rand.Read(fresh[:]); err != nil {
return err
}
// 新密钥排在第一位负责签发;只保留它会让旧票据无法恢复。
cfg.SetSessionTicketKeys([][32]byte{fresh})
return nil
}
func transitionalConfig(oldKey, newKey [32]byte) *tls.Config {
// 过渡期允许读取旧票据,但所有新票据都由 newKey 生成。
cfg := &tls.Config{}
cfg.SetSessionTicketKeys([][32]byte{newKey, oldKey})
return cfg
}
注意服务端多实例部署:如果同一域名由多个 Go 实例终止 TLS,票据密钥策略要统一,否则不同实例之间会出现恢复成功率波动。证书文件、票据密钥和实例启动时间也要分开记录,便于判断到底是证书加载不一致还是恢复路径未切断。
四、客户端要同时处理 Transport 和 ClientSessionCache
客户端使用 http.Client 时,最常见的误区是只替换 TLSClientConfig,却继续使用旧的 Transport。旧 Transport 可能还有空闲 TCP/TLS 连接;即使新连接建立,原有 ClientSessionCache 也可能让它恢复旧会话。
func newClient() *http.Client {
// 新建 Transport,同时创建新的会话缓存,避免带入旧的 session ticket。
tr := &http.Transport{
TLSClientConfig: &tls.Config{
MinVersion: tls.VersionTLS12,
ClientSessionCache: tls.NewLRUClientSessionCache(128),
VerifyConnection: func(state tls.ConnectionState) error {
// DidResume 是判断本次握手是否恢复会话的直接信号。
log.Printf("TLS version=%x did_resume=%t", state.Version, state.DidResume)
return nil
},
},
}
return &http.Client{Transport: tr}
}
func replaceClient(old *http.Client) *http.Client {
if tr, ok := old.Transport.(*http.Transport); ok {
// 先关闭空闲连接;正在处理的请求不在这里强制中断。
tr.CloseIdleConnections()
}
// 新 Transport 不共享旧的连接池和客户端会话缓存。
return newClient()
}
这段示例的重点不是每次请求都创建客户端,而是在证书轮换事件发生时一次性替换长期复用的客户端。正常请求路径仍应复用同一个 http.Client,否则会造成连接和缓存抖动。
五、用 DidResume 和证书序列号确认结果
ConnectionState 只有在握手完成后才有意义。排查时至少记录 DidResume、TLS 版本、SNI,以及客户端看到的叶子证书序列号或指纹。不能只看“证书文件修改时间”,也不要只看一次请求是否成功。
func describeConnection(state tls.ConnectionState) string {
leaf := "未读取到叶子证书"
if len(state.PeerCertificates) > 0 {
// 序列号用于关联轮换前后的证书,生产日志不要打印私钥或完整敏感材料。
leaf = state.PeerCertificates[0].SerialNumber.String()
}
return fmt.Sprintf(
"server_name=%s version=%x did_resume=%t leaf_serial=%s",
state.ServerName, state.Version, state.DidResume, leaf,
)
}
如果 DidResume=true,先处理票据和客户端缓存;如果 DidResume=false 仍拿到旧序列号,再检查所有实例是否加载了同一份证书、SNI 是否命中了预期证书,以及负载均衡后端是否仍有旧进程。
六、实际迁移时按这个顺序做
- 提前加载新证书,确认公钥、主机名和有效期符合预期。
- 切换服务端的证书指针,让新完整握手读取新证书。
- 轮换 session ticket key;多实例先统一新旧密钥顺序。
- 客户端切换到新的 Transport 和新的 ClientSessionCache,并关闭旧 Transport 的空闲连接。
- 对服务端长连接设置排空策略,避免旧连接无限期存活。
- 用新建连接分别观察
DidResume=false和新证书序列号,再逐步恢复会话恢复能力。
其中第 6 步是结果确认,不是把线上页面或证书接口当成唯一依据。真正要回答的是:这次请求有没有新握手、握手是不是恢复会话、命中的实例加载了哪张证书。
常见追问
只替换 PEM 文件,Go 会自动重新加载吗?
不会。已经加载进内存的证书不会因为文件内容变化自动替换;需要在应用层加载新证书并让后续握手读取它。
轮换票据密钥会断开正在传输的请求吗?
不会。它影响后续会话恢复;已建立的连接仍需通过连接生命周期策略或优雅排空来结束。
为什么关闭空闲连接后仍看到旧证书?
可能是客户端仍带着旧的 ClientSessionCache,也可能请求落到尚未完成证书切换的后端。先记录 DidResume 和实例标识,再判断是哪一层。
应该一直禁用 TLS 会话恢复吗?
通常不必。禁用恢复会增加完整握手成本;更稳妥的做法是把证书轮换、票据密钥轮换、连接排空和回归观测编排成一次迁移操作。