
MySQL 8.4 使用公共 CA 证书的 TLS 踩坑复盘环境MySQL 8.4.11、Docker、acme.sh、Let’s Encrypt目标让 MySQL 使用正式公共 CA 证书并支持自动续期与后续 TLS 热加载。整理与技术协助ChatGPTGPT-5.6 Sol给 MySQL 8.4 配 TLS本来以为和 Nginx 差不多把 ACME 签发出来的fullchain.pem和privkey.pem挂进去再指定ssl_cert和ssl_key事情就结束了。结果完全不是这么回事。MySQL 确实能启用 TLS客户端也能通过 SSL 连接但日志里一直出现服务器证书验证失败[MY-015011] Failed to validate certificate ... because unable to get local issuer certificate [MY-015010] Server certificate ... verification has failed同时又能看到[MY-013602] Channel mysql_main configured to support TLS. Encrypted connections are now supported for this channel.也就是说问题不是“TLS 没启动”而是MySQL 8.4 在建立 TLS context 时还会主动验证自己配置的服务器证书而这个验证逻辑不能完全照搬 Web Server 对fullchain.pem的使用方式。这次排查最后确认MySQL 8.4 完全可以使用 Let’s Encrypt 这样的公共 CA 证书但必须给ssl_ca准备一份能够闭合完整信任路径的 CA bundle。从一个看似正常的 TLS 配置开始最初的配置非常常规[mysqld] ssl_cert /certs/fullchain.pem ssl_key /certs/privkey.pem tls_version TLSv1.2,TLSv1.3证书本身完全正常Nginx 使用没有任何问题OpenSSL 也可以验证。MySQL 也能够正常启动 TLS。但 MySQL 自己验证服务器证书时却提示unable to get local issuer certificate最容易产生的误解是既然 Nginx 能用为什么 MySQL 不能用这里真正的差别在于Nginx 的典型工作方式主要是服务器发送 certificate chain ↓ 客户端使用自己的 trust store 验证而 MySQL 8.4 还会在服务器侧检查自己的证书配置。所以TLS 握手能工作和MySQL 自己能完整验证 ssl_cert并不是同一件事。一度怀疑是 ECC 和新证书链的问题最开始用的是 ECC P-256 的 Let’s Encrypt 证书。它经过的是一条多级公开 PKI 链大致可以抽象成Server Certificate ↓ Lets Encrypt Intermediate ↓ ISRG Intermediate / Root ↓ Public Trust Root因为证书链比较新又正好撞上 MySQL 8.4 更严格的证书验证行为所以一开始怀疑会不会是 MySQL 8.4 对新的 ECC 链处理有问题为了把这个变量彻底排除我通过 acme.sh 又申请了一套独立的 RSA 2048 公共证书专门给数据库使用。于是 ACME 变成双轨Web → ECC P-256 Database → RSA 2048acme.sh 本身支持同一个域名同时维护 RSA 和 ECC。典型内部目录/acme.sh/domain_ecc/ → ECC /acme.sh/domain/ → RSAECC 安装时显式使用acme.sh --install-cert\-d$DOMAIN\--ecc\...RSA 则不加--eccacme.sh --install-cert\-d$DOMAIN\...数据库证书被单独安装到/certs/database/包含cert.pem ca.pem fullchain.pem privkey.pem证书确认是RSA 2048证书链也已经和原来的 ECC 链完全不同。然而 MySQL 再次启动后仍然出现Failed to validate certificate ... because unable to get local issuer certificate这一步很重要因为它基本排除了ECC 算法问题 某条特定 intermediate 的问题 RSA/ECC 差异问题只能继续往 MySQL 自己的 trust chain 构造方式上找。真正的问题fullchain.pem不是 MySQL 的 trust store继续拆证书后问题终于清楚了。ACME 输出的fullchain.pem通常是Server Certificate Intermediate CA 上级 Intermediate / Cross-signed CA而ca.pem一般只包含Intermediate CA 上级 CA它们的目的主要是描述“服务器应该发送怎样的链”。但 MySQL 的ssl_ca代表的是另一件事MySQL 自己在验证服务器证书或客户端证书时要信任哪些 CA。换句话说certificate chain和trust store不是同一个概念。这就是前面为什么不断出现一种很迷惑的情况OpenSSL 可以验证 Nginx 可以正常使用 客户端也能建立 TLS MySQL 自己却一直说 issuer 找不到因为系统 OpenSSL 验证时可以把系统 CA trust store作为最终信任锚。而 MySQL 自己不会自动把“你以为它应该信任的系统 Root”补到当前ssl_ca里。最终可用的证书结构最后采用的方式很简单服务器证书继续使用 ACME 正常生成的/certs/database/fullchain.pem私钥继续/certs/database/privkey.pem然后额外构造一个/certs/database/mysql-ca.pem这个文件不是服务器证书链而是专门给 MySQL 使用的 CA trust bundle。逻辑上类似mysql-ca.pem Intermediate CA ↓ Upper CA ↓ Trusted Root CA重点是最后必须包含能够真正闭合整条验证路径的 trusted root。最终 MySQL 配置[mysqld] ssl_cert /certs/database/fullchain.pem ssl_key /certs/database/privkey.pem ssl_ca /certs/database/mysql-ca.pem tls_version TLSv1.2,TLSv1.3 require_secure_transport OFF重新启动 MySQL 后之前两条警告完全消失MY-015011 MY-015010只剩正常的[MY-013602] Channel mysql_main configured to support TLS. Encrypted connections are now supported for this channel.这也最终证明MySQL 8.4 并不是只能使用自签 CA也不是公共 CA 和 MySQL 不兼容。真正缺的是一份适合 MySQL 自身验证逻辑的完整 CA bundle。用 Hook 自动构造 MySQL 专用 CA bundle既然公共证书由 acme.sh 自动续期就不能手工维护mysql-ca.pem。最终做法是把生成逻辑放进 ACME 的 database hook。例如/hooks.d/database/10-mysql-ca.sh核心思路不是写死某个 Root CA而是验证当前 ACME 证书是否能被系统 trust store 正常验证。拆分系统 CA trust bundle。自动寻找能够验证当前 ACME 链的 trust anchor。把 ACMEca.pem和这个 trust anchor 合并。用生成后的mysql-ca.pem再验证一次服务器叶子证书。验证通过后原子替换正式文件。这样未来 Let’s Encrypt 更换 intermediate 或 cross-sign 路径时不需要手工修改 Root CA 名称。脚本如下#!/bin/shset-euCERT_DIR/certs/databaseLEAF${CERT_DIR}/cert.pemACME_CA${CERT_DIR}/ca.pemOUTPUT${CERT_DIR}/mysql-ca.pemecho[MYSQL-CA] Building MySQL CA bundleforFILEin$LEAF$ACME_CA;doif[!-s$FILE];thenecho[MYSQL-CA] ERROR: missing or empty file:$FILEexit1fidoneTRUST_BUNDLEforCANDIDATEin\/etc/ssl/certs/ca-certificates.crt\/etc/pki/tls/certs/ca-bundle.crt\/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pemdoif[-s$CANDIDATE];thenTRUST_BUNDLE$CANDIDATEbreakfidoneif[-z$TRUST_BUNDLE];thenecho[MYSQL-CA] ERROR: system CA trust bundle not foundexit1fiif!openssl verify\-CAfile$TRUST_BUNDLE\-untrusted$ACME_CA\$LEAFthenecho[MYSQL-CA] ERROR: original ACME chain validation failedexit1fiWORKDIR$(mktemp-d/tmp/mysql-ca.XXXXXX)cleanup(){rm-rf$WORKDIR}trapcleanup EXIT INTTERMawk-vdir$WORKDIR /-----BEGIN CERTIFICATE-----/ { n filesprintf(%s/root-%04d.pem, dir, n) } file ! { print file } /-----END CERTIFICATE-----/ { close(file) file } $TRUST_BUNDLETRUST_ROOTforROOTin$WORKDIR/root-*.pem;do[-s$ROOT]||continueifopenssl verify\-CAfile$ROOT\-untrusted$ACME_CA\$LEAF/dev/null21thenTRUST_ROOT$ROOTbreakfidoneif[-z$TRUST_ROOT];thenecho[MYSQL-CA] ERROR: no matching trust anchor foundexit1fiecho[MYSQL-CA] Trust anchor:openssl x509-in$TRUST_ROOT-noout-subject-issuerTMP_OUTPUT${CERT_DIR}/.mysql-ca.pem.tmprm-f$TMP_OUTPUTcat$ACME_CA$TMP_OUTPUTcat$TRUST_ROOT$TMP_OUTPUTchmod644$TMP_OUTPUTif!openssl verify\-show_chain\-CAfile$TMP_OUTPUT\$LEAFthenecho[MYSQL-CA] ERROR: generated MySQL CA bundle cannot verify server certificaterm-f$TMP_OUTPUTexit1fiif[-f$OUTPUT]cmp-s$TMP_OUTPUT$OUTPUT;thenrm-f$TMP_OUTPUTecho[MYSQL-CA] CA bundle unchangedexit0fimv-f$TMP_OUTPUT$OUTPUTchmod644$OUTPUTecho[MYSQL-CA] MySQL CA bundle updated successfully这段脚本的重点不是“拼 PEM 文件”而是先验证 再生成 再验证 最后替换证书相关自动化最好始终遵循这个原则。ACME 双证书里另一个容易踩的坑在增加 RSA 数据库证书时还遇到过一个和 TLS 本身无关、但很容易导致容器无限重启的问题。一开始用if[!-f/acme.sh/${DOMAIN}/${DOMAIN}.conf];then判断证书是否已经存在。但第一次 RSA 签发期间DNS-01 已经完成前半段流程private key 已生成 .conf 已生成 TXT 已添加随后 CA 在 secondary validation 阶段遇到临时 DNSSERVFAIL最终证书并没有签发成功。此时.conf 存在 .cer 不存在容器下一次启动却因为.conf存在而误判Existing certificate found接着install-cert尝试读取domain.cer自然失败并进入 restart loop。正确的判断应该是if[!-s/acme.sh/${DOMAIN}/${DOMAIN}.cer];thenECC 同理if[!-s/acme.sh/${DOMAIN}_ecc/${DOMAIN}.cer];then也就是说判断 ACME 证书是否真正签发成功应该检查证书文件而不是检查状态配置文件。这个坑和 MySQL TLS 无关但如果正在重构 ACME Compose非常值得一起修掉。客户端安全性仍然取决于验证模式服务器证书最终通过 MySQL 自身验证不代表客户端只要写ssltrue就自动达到最高安全等级。例如 MySQL Connector/J 中sslModeREQUIRED保证的是连接必须使用 TLS但完整的服务器身份验证应该使用sslModeVERIFY_IDENTITY它同时完成TLS 加密 CA 验证 hostname / SAN 验证公共 CA 在这里最大的优势就是客户端通常已经通过系统或 JVM trust store 信任公共 Root CA不需要像 Private CA 那样手工分发根证书。所以公共 CA VERIFY_IDENTITY仍然是一个很舒服的方案。最终架构最后整个证书体系变成acme.sh │ ┌────────┴────────┐ │ │ Web ECC Database RSA │ │ Nginx ┌──────┴──────┐ │ │ MySQL MongoDB证书输出/certs/ ├── cert.pem ├── ca.pem ├── fullchain.pem ├── privkey.pem │ └── database/ ├── cert.pem ├── ca.pem ├── fullchain.pem ├── privkey.pem └── mysql-ca.pemWeb 和数据库证书分开维护避免不同服务的证书链需求互相影响。database hook 在证书续期后自动ACME renew ↓ 更新 cert / key / fullchain / ca ↓ 重新构造 mysql-ca.pem ↓ 验证完整 trust path后续如果ALTERINSTANCE RELOAD TLS;在最终配置下验证成功就可以进一步把 TLS reload 接到 hook 中实现证书自动续期 → 自动重建 CA bundle → MySQL 热加载新证书 → 无需重启容器最后总结这次最大的坑不是某一张证书也不是 Let’s Encrypt更不是 RSA 和 ECC。真正容易误导人的地方是Web Server 的 certificate chain 思维不能完全直接套到 MySQL 8.4 的 server certificate validation 上。Nginx 使用fullchain.pem privkey.pem通常就够了。而 MySQL 8.4 在本次环境里最终需要ssl_cert → server full chain ssl_key → server private key ssl_ca → 能够闭合整个 public PKI trust path 的 CA bundle最终从Failed to validate certificate unable to get local issuer certificate走到Channel mysql_main configured to support TLS. Encrypted connections are now supported for this channel.整个问题才算真正解决。如果以后再次遇到类似问题优先区分这三个东西叶子证书 服务器发送的 certificate chain 服务器自身使用的 CA trust store不要再默认把它们看成同一套 PEM 文件。本文问题排查、实验设计与技术整理由作者实际环境验证完成。ChatGPTGPT-5.6 Sol参与了排查思路梳理、证书链分析、脚本设计与文章整理。