在 Gunicorn 里做 HTTPS,本身就有那么点“偏要和别人不一样”的味道——绝大多数团队都会老老实实把 Nginx 或 Caddy 放到前面终结 TLS,Gunicorn 只负责跑 WSGI 应用。但你一旦决定让 Gunicorn 直接面向客户端暴露 HTTPS,或者你所在的部署环境压根就没有反向代理这一层,那么“Let‘s Encrypt 证书自动更新”这件事就会从“顺手配置一下”变成“必须设计一套机制”。这篇东西就是写给那些和我一样,被“Gunicorn 直连 + 证书自动续期”折磨过,或者正准备这么干的同学。我会把为什么 Gunicorn 下做证书续期比 Nginx 下要绕、以及怎么绕过去,全部拆开讲清楚。
先说结论:Gunicorn 本身只是一个 WSGI 容器,它不像 Nginx 那样会自动帮你重读磁盘上的证书文件。你就算把--certfile和--keyfile配好了,Let‘s Encrypt 的 certbot 每 90 天默默帮你把证书续掉,Gunicorn 也浑然不知,它会一直抱着旧证书跑到天荒地老,直到客户端报错、你手动重启进程为止。所以“配置证书自动更新”的真正重点,不在于 certbot 怎么装、怎么配,而在于“Gunicorn 怎么在新证书落地之后,优雅地把新证书加载进内存”。本篇会围绕这个核心坑展开,覆盖从证书申请、Gunicorn 配置、热重载机制到定时续期任务的全链路方案。
1. 架构选型:为什么非要在 Gunicorn 这一层解决证书问题
1.1 三种常见部署形态的取舍
我见过很多人在这一步就已经绕晕了。Gunicorn 官方文档里其实不太鼓励你直接拿它做 HTTPS 服务。它给出的建议是用 Nginx 反代终结 TLS,Gunicorn 只监听 127.0.0.1 的内部端口。这套方案的好处是证书续期根本不用动 Gunicorn——Nginx 会自动重读证书文件,你甚至可以用nginx -s reload实现秒级热切换。
但现实里总有三类场景会让你绕不开“Gunicorn 直连”这条路:
第一种,纯内网工具系统。公司内部某个管理后台、某个数据处理平台,用户量就是几十人,架构师或者老大明确说“别搞那么多层,能跑就行”。这时候 Nginx 就是多余的中间层,Gunicorn 直监听 443 端口的成本最低。
第二种,云原生环境里的轻量部署。容器平台本身已经做了流量入口的负载均衡和证书管理,但某些独立 Pod 或者边缘服务需要自己暴露 HTTPS,又不想为了这个单独引入 Nginx 容器。Gunicorn 直接配 TLS 就成了最务实的选项。
第三种,写 Python 的服务端开发团队,团队里缺少专职运维。让开发去维护一套 Nginx 配置 + upstream + 静态文件规则,出错概率远高于让 Gunicorn 自己多两个参数。我的一个朋友在某创业公司就是这么干的,一行 Nginx 都没写,Gunicorn 把证书一挂,站点就绿了。
不过有一点必须说清楚:Gunicorn 的并发模型是基于 worker 进程的,TLS 握手在 Python 进程内完成,性能比起 Nginx 的 OpenSSL 异步处理确实有差距。如果你的流量已经大到需要纠结 QPS,那就不该看这篇文章,老老实实加一层 Nginx。Gunicorn 直连适合的场景,是中小流量、低并发、追求架构简单和部署可控的服务。
1.2 Gunicorn 直连模式下,证书续期麻烦在哪
先说清楚 Gunicorn 处理证书的方式。Gunicorn 接收--certfile和--keyfile参数后,会在 worker 进程初始化时读取这两个文件,把证书和私钥加载进内存,然后建立 SSL context。问题在于,这个 SSL context 一旦建立就不会主动去磁盘上重新读取证书文件——它不会像一个文件监视器那样在证书更换后自动 reload。
所以你面临的核心矛盾是:certbot 定期续期证书并覆盖同名文件,但 Gunicorn 的 worker 进程还握着一份旧的 SSL context 不放。客户端连上来后,Gunicorn 用的依然是旧证书内容。浏览器会立刻报警告,因为证书有效期已过期或指纹不匹配。
解决思路不外乎两条路:一条是“续期后的新证书文件名或版本信息发生变变化,触发 Gunicorn 重新加载”;另一条是“干脆重启 Gunicorn 进程,让它重新读盘”。这两条路我后面都会详细介绍实现,但先记住一个判断标准:项目允许秒级服务中断,就直接重启;不允许中断,就做热重载。两者在自动化实现上差别不小。
2. Let‘s Encrypt 证书机制:自动化的前提是你得先懂它的游戏规则
2.1 90 天有效性背后的设计逻辑
Let‘s Encrypt 把证书有效期强制设在 90 天,而不是像传统付费证书那样一办一年。这个设计很容易被误解成“麻烦”,但实际是故意为之——短有效期配合自动化续期,才能在密钥泄露时把影响窗口压缩到最小。这套机制也意味着“设置一次、永久自动”是完全可以实现的,但前提是你不能指望手工去续。
90 天听起来宽裕,实际运维中我给一个建议:把续期检查频率设为每天两次。哪怕 certbot 默认只会在证书剩余寿命低于 30 天时才执行续期,每天跑一次系统定时任务也就占用几秒钟 CPU 和一次 ACME 服务器通信,稳得很。
2.2 ACME 协议与两种验证模式
Let‘s Encrypt 用的是自动证书管理环境协议,全称 Automated Certificate Management Environment,通常叫 ACME。certbot 作为 ACME 客户端,代表你向证书颁发机构证明“你确实控制这个域名”,从而获发证书。证明方式最常见的两种:
一种是standalone模式。certbot 自己起一个临时 HTTP 服务,监听 80 端口,然后 ACME 服务器通过http://你的域名/.well-known/acme-challenge/xxx这个路径来验证文件确实存在。这种模式的问题是:80 端口必须空闲,而 Gunicorn 直连模式下通常 80 端口还跑着应用服务,冲突是必然的。
另一种是webroot模式。certbot 不自己起服务,只把验证文件写入你指定的某个 Web 根目录,比如/var/www/html,然后由你现有的 Web 服务器(Nginx / Apache / Gunicorn 应用本身)把这个路径暴露出去供 ACME 服务器访问。这才是 Gunicorn 直连模式下最合理的验证方式,避开端口冲突。
这里有个很多人会忽略的坑:Gunicorn 本身根本不托管静态文件,如果你希望用 webroot 模式,得在 Flask 或 Django 应用里手动加一个路由,把/.well-known/acme-challenge/的请求映射到磁盘目录上。比如 Flask 里加一个静态路由指向/var/www/challenges,Django 里配一个 url.py 的映射。否则 ACME 服务器访问验证路径时会拿到 404,续期就失败了。
2.3 证书文件目录与实际落盘位置
certbot 默认把证书放在/etc/letsencrypt/live/你的域名/下面,里面有四个关键文件:
cert.pem:只包含服务器证书,不包含中间证书。单独用它在某些客户端上可以跑通,但为了兼容最全的客户端校验链,建议使用fullchain.pem。chain.pem:包含中间证书链。fullchain.pem:cert.pem 和 chain.pem 的内容拼接。Gunicorn 的--certfile指向这个文件最稳妥。privkey.pem:私钥。Gunicorn 的--keyfile指向它。
注意/etc/letsencrypt目录的默认权限控制得很严,root 用户才能读取privkey.pem。如果 Gunicorn 是以普通用户身份运行的,比如www-data或者gunicorn用户,你得手动给这个用户授权访问路径,或者把证书复制到应用目录并设置好属主和服务用户相匹配。
3. Gunicorn 侧负载:配置证书并解决热加载问题
3.1 基础配置:certfile 和 keyfile 的参数详解
Gunicorn 启动时直接带参数,CD 到项目目录里后,用类似这样的命令就能跑起来:
gunicorn -w 4 -b 0.0.0.0:443 \ --certfile=/etc/letsencrypt/live/example.com/fullchain.pem \ --keyfile=/etc/letsencrypt/live/example.com/privkey.pem \ app:app如果用的是 systemd 管理,Unit 文件里 ExecStart 那行改成:
ExecStart=/usr/local/bin/gunicorn -w 4 -b 0.0.0.0:443 \ --certfile=/etc/letsencrypt/live/example.com/fullchain.pem \ --keyfile=/etc/letsencrypt/live/example.com/privkey.pem \ app:app配置归配置,真正的问题还是那句:证书续期之后,Gunicorn 会拿新证书吗?答案是不会。你改了磁盘上的文件,worker 进程内存里的 SSL context 不变。这就是我们要解决的核心问题。
3.2 方案一:systemd exec restart,以秒为单位接受中断
最简单粗暴的方式,就是在 certbot 续期成功后,重启 Gunicorn 服务。对于内部工具、管理后台这类允许短暂断连的项目,完全可行。systemd 的 exec restart 类型能保证老进程退出后新进程立刻拉起来,整体中断时间通常不超过 1 秒。
实现方式,写一个 deploy hook 脚本放在/etc/letsencrypt/renewal-hooks/deploy/目录下:
#!/bin/bash # /etc/letsencrypt/renewal-hooks/deploy/gunicorn-restart.sh systemctl restart gunicorn.service然后授予执行权限:
chmod +x /etc/letsencrypt/renewal-hooks/deploy/gunicorn-restart.sh只需在每次续期完成后,certbot 把所有部署钩子依次执行,你的 Gunicorn 就会重启并加载新证书。这套配置的优点是简单可靠,缺点也很明显——服务有瞬时中断。如果你的业务对连接连续性有硬性要求,比如长连接很多、实时任务不能断,就得看下一个方案。
3.3 方案二:信号触发 Gunicorn 热重载,零中断换证书
Gunicorn 支持通过发送 HUP 信号给主进程来执行平滑重载。这个机制原本是为代码更新设计的,代码文件变了之后发个kill -HUP $(cat /var/run/gunicorn.pid),Gunicorn 会重启 worker 进程并重新加载应用。但你会发现,新 worker 启动时同样会重新读取--certfile和--keyfile指定路径下的证书文件,所以这招对证书更新同样有效。
流程拆解:
- 确保 Gunicorn 主进程的 PID 写到了固定路径。启动时加
--pid=/var/run/gunicorn.pid。 - certbot 续期成功后,deploy hook 脚本向这个 PID 发送 HUP 信号。
- Gunicorn 主进程收到 HUP,逐个平滑重启所有 worker,新 worker 在 init 阶段重新加载证书。
部署钩子改为:
#!/bin/bash # /etc/letsencrypt/renewal-hooks/deploy/gunicorn-reload.sh if [ -f /var/run/gunicorn.pid ]; then kill -HUP $(cat /var/run/gunicorn.pid) logger -t certbot "Gunicorn reloaded for new certificate" fi为什么说“零中断”?Gunicorn 的平滑重载机制是:新 worker 先 fork 并完成初始化,然后再杀掉旧 worker,连接会被无缝迁移到新 worker 上。在证书重启这个场景里,你甚至可以理解为“双保险”——新 worker 起来时重新加载证书,旧 worker 退出前还能处理存量连接。实测在几十个并发连接的压力下,重载过程没有出现任何连接拒绝。
但这里有一个非常容易踩的坑:Gunicorn 默认要求应用代码具备 reload 兼容性。如果你的应用里有大量全局状态,比如数据库连接池、内存缓存,平滑重载会导致新旧 worker 短暂共存,全局状态无法跨进程共享,可能引发连接数翻倍或者缓存失效。我的经验是:大多数 WSGI 应用其实都能容忍这种重载,但如果你的应用启动初始化特别重、动辄十秒级别的启动过程,HUP 重载期间服务能力会下降,这时候重启方案反而更靠谱。
3.4 方案三:证书版本检查 + 自动 reload,最优雅但需要写代码
如果你连 HUP 信号都不想手动触发,想让 Gunicorn 自己感知证书文件变化,那就得在应用层写一个小工具。思路是:每次收到 HTTP 请求时,检查磁盘上证书文件的修改时间或哈希值,一旦发现和当前进程持有的版本不一致,就自觉触发 reload。实现方式其实可以利用 Flask/Django 的 before_request 钩子,或者中间件:
# cert_watcher.py import os import signal import hashlib _cert_path = '/etc/letsencrypt/live/example.com/fullchain.pem' _cert_hash = None _pid = os.getpid() def cert_changed(): global _cert_hash with open(_cert_path, 'rb') as f: current_hash = hashlib.sha256(f.read()).hexdigest() if _cert_hash is None: _cert_hash = current_hash return False return current_hash != _cert_hash def check_and_reload(): if cert_changed(): # 向 Gunicorn 主进程发送 HUP 信号 with open('/var/run/gunicorn.pid') as f: master_pid = int(f.read().strip()) # 只让一个 worker 触发,避免重复发送 if os.getpid() == master_pid: return os.kill(master_pid, signal.SIGHUP)这个方案很巧妙,但有个问题是只能检测到 Gunicorn master 进程和 worker 进程都在同一台机器上的场景,并且需要所有 worker 都共享同一个磁盘路径才能准确感知变化。连续两个请求可能由不同 worker 处理,第一个 worker 发现变化发信号,第二个 worker 也发现变化又发一次,HUP 信号重复多了会造成无谓的 worker 重启。我建议在代码里加一个时间戳去重,比如 60 秒内只允许触发一次,否则生产环境会有“幽灵重启”的困扰。
从工程角度看,我仍然推荐方案二。certbot 的 deploy hook 是官方指定的扩展点,代码层面不需要任何改动,可靠性远高于自定义中间件。
4. 自动化续期:定时任务、权限与监控告警
4.1 certbot 续期的正确触发姿势
配置好 deploy hook 之后,整个自动化链条就是:定时任务触发certbot renew,certbot 检查证书是否快过期(剩余寿命低于 30 天才会真正续期)→ 续期成功 → 执行 deploy hook → Gunicorn 重载。
系统定时任务推荐用 systemd timer,而不是 cron,因为 systemd timer 支持随机延迟、持久性标记和更清晰的日志。创建/etc/systemd/system/certbot-renew.timer:
[Unit] Description=Run certbot renew twice daily [Timer] OnCalendar=*-*-* 00:00:00 OnCalendar=*-*-* 12:00:00 RandomizedDelaySec=300 Persistent=true [Install] WantedBy=timers.target然后创建对应的 service 文件/etc/systemd/system/certbot-renew.service:
[Unit] Description=Renew Let's Encrypt certificates [Service] Type=oneshot ExecStart=/usr/bin/certbot renew --quiet --deploy-hook "/etc/letsencrypt/renewal-hooks/deploy/gunicorn-reload.sh"注意我在这里用了命令行--deploy-hook参数,和前面“直接放到 hooks 目录”的方式二选一即可。重复执行的话,同一个续期事件会触发两次 deploy hook,造成两次 HUP 信号。我的经验是用目录方式,因为 certbot 官方文档明确会执行/etc/letsencrypt/renewal-hooks/deploy/下所有可执行文件,这样你将来加其他 hook 直接丢文件进去就行,不用去改 systemd 配置。
启用并查看状态:
systemctl enable certbot-renew.timer systemctl start certbot-renew.timer systemctl list-timers certbot-renew.timer4.2 用户权限:Gunicorn 能读到私钥吗
这是 Gunicorn 直连模式下最常见的问题之一。certbot 写入的/etc/letsencrypt/live/目录默认权限是 700,属主是 root。如果 Gunicorn 以非 root 用户运行,比如gunicorn用户,那么它读取privkey.pem时会直接Permission denied。
解决办法有三种:
第一种,把 Gunicorn 服务改为以 root 跑 master 进程,然后配置--user和--group参数让 worker 降权。Gunicorn 支持 master 进程以 root 启动,然后 fork worker 时切换到指定用户。这样一来,master(负责绑定端口和读证书启动 worker)就能顺利读取私钥,worker 又以低权限运行避免安全风险。
systemd Unit 里这样写:
[Service] User=root Group=root ExecStart=/usr/local/bin/gunicorn -w 4 -b 0.0.0.0:443 \ --user=gunicorn --group=gunicorn \ --certfile=/etc/letsencrypt/live/example.com/fullchain.pem \ --keyfile=/etc/letsencrypt/live/example.com/privkey.pem \ app:app第二种,把/etc/letsencrypt/live路径的访问权限开放给 gunicorn 用户,但为了安全,不推荐直接 chmod 777,而是用 POSIX ACL:
setfacl -m u:gunicorn:rx /etc/letsencrypt setfacl -m u:gunicorn:rx /etc/letsencrypt/live/example.com setfacl -m u:gunicorn:r /etc/letsencrypt/live/example.com/privkey.pem要让这个权限在续期后依然有效,因为 certbot 续期时会重建目录,我们再把 deploy hook 里加上这几行 setfacl 命令,确保持久生效。
第三种,干脆把证书复制到应用自己的目录,比如/var/lib/your-app/certs/,然后用一个单独的定时任务把 certbot 生成的证书同步过去,再把同步任务放进 deploy hook。这套方案会把证书管理的路径撕成两半,不推荐但确实有人这么干。
提示:不管选哪种方式,重点都是确保 Gunicorn worker 进程启动时能顺利读取证书文件。建议部署完成后,先手动执行一次
systemctl restart gunicorn,再看日志确认没有 PermissionError。
4.3 测试续期链路:dry-run 与强制续期
自动化做好后,最怕的就是“看起来配好了,实际从来没触发过”。在真实生产环境,我会强制做一次完整的续期演练,宁可让证书替换一次也不要等到 90 天后才发现钩子脚本权限不对。
先用 dry-run 模式验证 ACME 验证通道:
certbot renew --dry-run这个命令会真正连到 Let‘s Encrypt 的测试环境,走完整个 ACME 验证流程,但不会替换真实证书。只要能输出成功信息,就说明域名验证没问题。
然后手动触发一次真实续期,用来验证 deploy hook 是否执行、Gunicorn 是否成功重载:
certbot renew --force-renewal --deploy-hook "/etc/letsencrypt/renewal-hooks/deploy/gunicorn-reload.sh"跑完后立刻检查证书部署:
openssl s_client -connect example.com:443 -servername example.com </dev/null 2>/dev/null | openssl x509 -noout -dates你会看到新的 notAfter 日期,同时查看 Gunicorn 日志确认 HUP 信号已触发重载。
4.4 日志与监控:知道它没坏,比知道它有多好更重要
自动化续期最大的风险不是“续期失败”,而是“续期失败了你根本不知道”。certbot renew 默认静默运行,失败时只会往 syslog 丢一条信息。我建议至少做三件事:
- 在 deploy hook 里增加日志输出:
echo "$(date) - Certificate renewed, sending HUP to gunicorn" >> /var/log/certbot-renew.log- 使用 journalctl 查看 systemd timer 的执行结果:
journalctl -u certbot-renew.service -n 50 --no-pager- 配置一个外部监控检查:每天定时用 openssl 拉取证书有效期,如果剩余天数低于 14 天,就向团队报警。这可以作为 certbot 机制失效时的兜底防线。
5. 常见坑:我踩过的和你大概率会踩的
5.1 standalone 模式导致续期失败
Gunicorn 直连的架构下,80 端口几乎总是被应用占着,或者被防火墙挡住。如果你安装 certbot 时没有指定 webroot,默认可能选择 standalone 模式。续期时 certbot 尝试绑定 80 端口,发现被占用,直接报错退出。
解决办法就是改用 webroot 模式一模一样,但前提是你的应用能把/.well-known/acme-challenge/路径正确地暴露出来。我记得自己第一次用 Flask 做这个映射时,代码里写了个@app.route('/.well-known/acme-challenge/<filename>'),返回send_from_directory('/var/www/challenges', filename),很顺利就通过了验证。
5.2 HUP 信号发出后证书还是旧的
这是最让人抓狂的一种情况:deploy hook 日志显示执行了,HUP 也发了,但 openssl 查有效期还是旧证书。
排查思路按顺序:先确认 Gunicorn master 进程用的 PID 文件是否新鲜。如果你同时用 systemd 管理 Gunicorn,systemd 的ExecReload可能被配置成别的动作,而你的 deploy hook 是往 PID 文件发信号,但那个 PID 文件可能是旧的或路径不对。
另一个常见原因是 Gunicorn worker 进程的 HUP 信号语义问题。Gunicorn 的 master 收到 HUP 后会先重新加载配置,然后再 reload worker。如果你的应用里用了 gunicorn 配置文件,并且配置文件中写死了证书路径为软链接,而 Let‘s Encrypt 的live目录本身就是软链接结构,就存在极小的概率 worker reload 时读到的还是同一个 inode 下的旧内容。遇到这种情况,最简单的方法是在 deploy hook 里先执行一句sync强制文件系统刷盘,然后再发 HUP。
5.3 证书报错链不完整问题
很多时候你发现 Gunicorn 日志里没有报错,但客户端就是提示“证书链不完整,无法验证”。原因是你把--certfile指向了cert.pem,而不是fullchain.pem。Gunicorn 官方文档里说的certfile是“PEM format of certificate”,很多人想当然地就用了cert.pem,但cert.pem只包含站点证书,中间证书没有被带上。
客户端如果缺中间证书,现代浏览器会自动尝试抓取或者拒绝,而一些命令行工具(curl、wget、部分语言的 SDK)会直接报错。最优解:--certfile=/etc/letsencrypt/live/example.com/fullchain.pem。这样站点证书和中间证书一起发给客户端,校验链完整。
5.4 续期后 Gunicorn 无法启动
这种坑大多来自权限。某次 debian 安全更新把/etc/letsencrypt的权限策略改严了,或者你手动执行 certbot 时用了 sudo,而 Gunicorn 是以普通用户跑的,续期后重建的privkey.pem属主变了。Gunicorn 读取私钥失败,直接启动失败,服务全挂。
建议在 deploy hook 里加一步权限自愈:
#!/bin/bash set -e # 确保 gunicorn 用户能读取证书 if command -v setfacl >/dev/null 2>&1; then setfacl -m u:gunicorn:rx /etc/letsencrypt setfacl -m u:gunicorn:rx /etc/letsencrypt/live/example.com setfacl -m u:gunicorn:r /etc/letsencrypt/live/example.com/privkey.pem fi # reload if [ -f /var/run/gunicorn.pid ]; then kill -HUP $(cat /var/run/gunicorn.pid) fi这样每次续期后权限被重置,都会在 reload 之前被修正,避免服务因权限问题起不来。
5.5 定时器没触发但看不出来
systemd timer 默认情况下,如果上一次服务还在运行,它不会追加重叠执行,但 oneshot 类型很容易在短时间内跑完,你很难意识到底有没有执行。建议设置 Persistent=true 之后,故意改一下系统时间来测试它是否会在下次启动时补齐错过的任务。另外,用systemctl list-timers --all | grep certbot检查 next trigger 时间是否在正常推进。
6. 实操案例:一套可直接抄作业的完整配置
假设域名是app.example.com,应用是 Flask,服务用户是gunicorn,系统是 Ubuntu 22.04。完整配置如下。
6.1 证书首次申请
mkdir -p /var/www/challenges certbot certonly --webroot -w /var/www/challenges -d app.example.com \ --email ops@example.com --agree-tos --no-eff-email如果你的 Flask 应用还没把验证路径映射好,这里会失败。在这之前先在应用里加一条路由:
@app.route('/.well-known/acme-challenge/<path:filename>') def acme_challenge(filename): return send_from_directory('/var/www/challenges', filename)6.2 Gunicorn systemd 单元文件
创建/etc/systemd/system/gunicorn.service:
[Unit] Description=Gunicorn instance for app.example.com After=network.target [Service] User=root Group=root WorkingDirectory=/opt/app Environment="PATH=/opt/app/venv/bin" ExecStart=/opt/app/venv/bin/gunicorn -w 4 \ -b 0.0.0.0:443 \ --user=gunicorn --group=gunicorn \ --pid=/run/gunicorn.pid \ --access-logfile /var/log/gunicorn/access.log \ --error-logfile /var/log/gunicorn/error.log \ --certfile=/etc/letsencrypt/live/app.example.com/fullchain.pem \ --keyfile=/etc/letsencrypt/live/app.example.com/privkey.pem \ app:app [Install] WantedBy=multi-user.target注意这里 master 以 root 跑,worker 降权为 gunicorn 用户,完美避开证书读取权限问题。
6.3 deploy hook 脚本
创建一整个目录结构:
mkdir -p /etc/letsencrypt/renewal-hooks/deploy写/etc/letsencrypt/renewal-hooks/deploy/gunicorn-reload.sh:
#!/bin/bash set -euo pipefail LOG_FILE="/var/log/certbot-renew.log" PID_FILE="/run/gunicorn.pid" echo "$(date -Iseconds) - Certificate renewal event triggered" >> "$LOG_FILE" # 修正权限(续期后可能被重置) if command -v setfacl >/dev/null 2>&1; then setfacl -m u:gunicorn:rx /etc/letsencrypt 2>>"$LOG_FILE" setfacl -m u:gunicorn:rx /etc/letsencrypt/live 2>>"$LOG_FILE" setfacl -m u:gunicorn:rx "/etc/letsencrypt/live/app.example.com" 2>>"$LOG_FILE" setfacl -m u:gunicorn:r "/etc/letsencrypt/live/app.example.com/privkey.pem" 2>>"$LOG_FILE" fi # 发送 HUP 触发 Gunicorn 热重载 if [ -f "$PID_FILE" ]; then kill -HUP "$(cat "$PID_FILE")" echo "$(date -Iseconds) - Sent HUP to gunicorn master pid $(cat "$PID_FILE")" >> "$LOG_FILE" else echo "$(date -Iseconds) - ERROR: gunicorn pid file $PID_FILE not found" >> "$LOG_FILE" exit 1 fi授权:
chmod +x /etc/letsencrypt/renewal-hooks/deploy/gunicorn-reload.sh然后手动跑一次完整链路做验证:
certbot renew --force-renewal tail -f /var/log/certbot-renew.log openssl s_client -connect app.example.com:443 -servername app.example.com </dev/null 2>/dev/null | openssl x509 -noout -dates6.4 定时任务
按前面 4.1 里的 timers 配置写好后,systemctl start certbot-renew.timer && systemctl enable certbot-renew.timer。至此,全链路自动化完成。证书会在剩余 30 天时自动续期,续期后自动发 HUP 信号热加载新证书,整个过程对用户无感。
7. 写在最后的运维体会
把 Gunicorn 直连 HTTPS 这套架构搭过一遍之后,我最大的体会是:证书自动化的难点,从来不在 certbot 配置上,而在于“进程不会帮你重新读文件”这个心智模型。Nginx 之所以简单,是因为它内置了 reload-on-change 的语义;Gunicorn 没有这个语义,你就要自己补一层通知机制。
我个人的偏好是:能直接用 Nginx 就绝不直连 Gunicorn,省心程度差一个量级。但如果你的项目已经走在 Gunicorn 直连的路上了,或者你团队压根没有运维资源去维护 Nginx,那 deploy hook + HUP 热重载这套组合,是我实测下来最稳、成本最低的方案。
最后再分享一个小技巧:deploy hook 里收到续期事件后,不要立刻发 HUP,等一两秒钟。Let‘s Encrypt 的 ACME 服务器偶尔会在证书刚签发后的极短时间内更新文件元数据,虽然概率极低,但等这一下能避免 worker 起来时读到不完整的文件。加一句sleep 2,损失微不足道,但能避免你在凌晨被报警电话叫醒。