一次普通部署,Traefik 报出约两分钟的“无可用服务器”。他一天要部署好几次,按这个节奏,一个上午就能把 99.99% 可用性允许的 4.3 分钟月度停机预算全部花光。
GiggleGigs 是他运营的零工招聘板:后端 Django + DRF,前端 React + Vite 放在 Vercel,Celery 跑后台任务,全部用 Docker Compose 装在一台 2 GB 的 DigitalOcean droplet 上,前面是 Traefik,再前面是 Cloudflare。
上一轮他已经把镜像构建从服务器挪到 GitHub Actions,服务器不再在部署时卡死。但站点照样会下线。所以目标不是“更快的部署”,而是完全不掉线的部署,把那 4.3 分钟留给真正控制不了的事。
先量,再改
他的测量方法很朴素:笔记本上跑一个循环,每 5 秒请求一次 API 健康检查端点,同时请求一个始终在线的站点做对照。对照列很关键——他自己的网络当天断过两次,没有对照,这两次断网就会被误记成站点故障。
旧流程用 docker compose up -d:停掉唯一的 Django 容器,启动新容器,而新容器的启动脚本要先跑 collectstatic,gunicorn 才开始监听。在这之前,Traefik 没有任何可转发的目标。这就是那两分钟。
第一步:把静态文件收集挪到构建时
collectstatic 不需要数据库,也不需要真实密钥,只需要设置模块能导入。于是它被写进 Dockerfile,在 CI 里用占位环境变量执行。启动脚本从约 25 行缩到一行 gunicorn 命令。
效果:新容器在本机 13–20 秒通过健康检查,在 droplet 上约 43 秒,此前约两分钟。顺带一个意外发现——他试着在构建时跑 django-compressor 的 compress,报错找不到模板标签,而没有任何模板用到它。把工作挪到构建阶段,正好能暴露那些从来没起过作用的步骤。
第二步:健康检查加第二个容器
滚动部署的思路是:先用新镜像起第二个 Django 容器,等它健康,再停掉旧的。新容器挂上 Docker 健康检查,指向已有的 /healthz/ 端点。镜像里没有 curl,检查用 Python 写;Host 头必须在 ALLOWED_HOSTS 里,/healthz/ 必须在 SECURE_REDIRECT_EXEMPT 里,否则检查会拿到 301 而失败。
部署脚本用了 Compose 的 --scale 和 --no-recreate 参数——大多数人从没碰过这两个。新容器若始终不健康,脚本删掉它并以非零码退出,旧容器继续服务,GitHub Actions 里显示部署失败。
他在本地彩排过:生产镜像放在 Traefik 后面,4 个并行请求循环,连续两次部署,5,624 个请求全部返回 200。故意做坏的镜像被拒绝,旧容器照常服务。于是他上线了。
第一次线上部署还是掉了请求
521 是预期内的,那是 Cloudflare 在报告 Traefik 因自身配置变更重启了一次。503 不在预期内——它们发生在新 Django 容器启动期间,而这恰恰是滚动部署本该处理的部分。
本地彩排为什么没发现?因为他的笔记本快,新容器几乎一启动就绪。droplet 慢。Compose 可以限制容器 CPU,他把 Django 压到 0.3 核重跑彩排,问题立刻复现。
两个 bug,一个原因:Traefik 配置里通过单个 URL 路由到 Django,而 django 是 Docker DNS 名。两个容器同时运行时,它解析到两个,包括还在启动的那个。gunicorn 在 worker 就绪前就接受连接,于是请求在新容器队列里干等;Traefik 的健康探测有时也打到新容器,超时后把整个 django 服务标记为下线,旧容器被一起拖下水。这就是 503。他加的 Docker 健康检查只告诉了部署脚本,Traefik 根本看不到。
第三步:只路由到健康容器
Traefik 的 Docker provider 直接解决这个问题:它会跳过健康检查仍处于 starting 或 unhealthy 的容器。Django 改为通过标签被发现,不再写死 URL。路由、TLS 和中间件仍留在文件 provider 里,只有服务定义搬了家。
关于挂载 Docker socket:只读挂载让 socket 文件只读,但限制不了 Docker API,Traefik 仍能做 Docker 能做的任何事。Traefik 一旦被攻破,主机也就没了。在自己独占的单机上他接受了这个取舍,更严格的做法是用只允许列出容器的 socket 代理。
换成 Docker provider 后,503 和启动挂起都消失了。在慢 CPU 彩排里还剩 6 次失败,全部发生在旧容器停止的那一刻。
第四步:先摘流量,再停容器
docker stop 发出 SIGTERM,gunicorn 停止接受连接。但 Traefik 在旧容器退出前仍认为它健康,于是那几秒里还在往一个拒收请求的服务器上发流量。
修法是先把它移出轮转,再发 SIGTERM。Traefik 本来就跟随健康检查,于是健康检查变成了开关:检查脚本先看 /tmp/draining 是否存在,存在就直接退出 1。部署脚本里,等 5 秒让 Traefik 发现新容器,在旧容器里 touch 这个文件,轮询到 Docker 报告不健康,再等 5 秒让 Traefik 摘掉它,最后才 docker stop,90 秒宽限期让在途请求跑完。他的 gunicorn 超时设成 90 秒,因为有些请求要等 LLM。
读容器日志时他还撞见一个 gunicorn 26 的新问题:每次启动都报控制服务器权限错误,路径落在 $HOME 下,而 cookiecutter-django 的 django 系统用户没有家目录。什么都没坏,加 --no-control-socket 关掉即可。
结果
下一次部署是第一次完全跑在最终配置上的,内容很普通:修 Browse Jobs 页面的一个查询。uptime 循环零失败探测,Traefik 也没重启,因为它的配置没变。
作为对比:当天早上用原配置的部署有约两分钟“无可用服务器”,过渡期那次部署在三个爆发点上有 8 次失败探测。要说清这个测量能看到什么——每 5 秒一次探测,看不见短于 5 秒的抖动。彩排补上了这个缺口:两个请求循环持续打向限速 0.3 核的 Django,五次滚动共 12,957 个请求零失败。
部署任务从 1 分 47 秒变成 2 分 53 秒。多出的一分钟主要花在等新容器健康、等旧容器排空上。用这个换零停机,他觉得划算。
99.99% 还缺什么
- 一个独立监控。curl 循环看单次部署够用,但要声称 99.99%,需要来自自己网络之外、整月每分钟检查的东西。
- 向后兼容的迁移。旧容器在新迁移执行期间继续服务,加列没问题,改名或删列要分两次部署:先上线不再使用它的代码,再移除。
- 一台主机终究是一台主机。虚拟机重启、内核更新或内存耗尽都会让站点下线,滚动部署帮不上忙。在单台 droplet 上,你能做到不自己制造停机,但保证不了不停机。
他总结的几条:测量要带对照,否则自家 Wi-Fi 会冒充站点故障;彩排要用和生产一样慢的硬件,启动快的容器会藏住所有时序 bug;路由到 DNS 名不等于路由到健康容器;Docker 健康检查只有被代理读取才有用;SIGTERM 之前先排空;改变路由的那次部署跑在旧路由上,预期会有一次难看的部署;把工作挪到构建时,容器启动更快,还能看出哪些步骤从来没干过活。
热门跟贴