说个真事。我们团队做了一个监控服务叫StatusDude,现在每分钟处理几千次检查请求,跨多个区域跑着工作节点,一天部署好几次。整套基础设施就是Docker Compose加HAProxy,没有Kubernetes,没有etcd半夜三点爬起来修。请求零丢失,服务零中断。

但最开始我们用的不是HAProxy。我们选了Traefik,那个在Docker圈子里几乎成了标配的反向代理。配置看起来简单,用标签自动发现服务,还有个挺漂亮的仪表盘。文档翻一翻,感觉半小时就能搞定。结果上线四个小时,我们就把它换掉了。

当时的需求很常规:用Docker Compose启动一个新版本的服务实例,等它健康检查过了,再把流量切过去,旧的关掉。我们在配置文件里定义了一个叫backend_new的服务,和正在运行的backend服务并排跑。两个服务配置了相同的Traefik路由标签——同一个Host规则,同一个服务定义。逻辑上这条路是通的,滚动更新嘛,新旧同时接流量,切完了把旧的撤掉。

Traefik不认这个逻辑。它的Docker Provider把每个Compose服务当成独立的配置源,两条相同的路由规则一出现,直接报"Service defined multiple times",所有请求返回404。没有回退机制,不会自动合并,就是硬生生拒绝路由任何流量。这个坑踩得极其干脆。

我们赶紧换了思路。不单独定义新服务,改用docker compose --scale backend=4命令把实例数从2个扩到4个,2个旧的加2个新的,等新的健康了再把旧的缩掉。标签冲突的问题确实解决了,但下一个问题立刻冒出来。

滚动部署的缩容环节出了问题。当我们从4个实例缩回2个时,Traefik内部的路由表更新根本跟不上节奏。容器已经开始走关闭流程了,Traefik还在把请求往正在关闭的实例上转发。结果就是每几个请求里就有一个撞上502错误。路由状态比Docker的实际状态滞后了好几秒——在每秒几百个请求的场景里,这几秒已经够你丢一大波流量了。

我们试过加延时等路由表刷新。试过在停容器之前先把它们从网络中断开,让健康检查能干净地报失败。试过开被动健康检查,结果误判率太高,开了马上就回滚了。折腾了一圈,没有一个方案让人觉得干净利落。

但真正让我们决定放弃Traefik的,是另一个更致命的问题:当一个请求打到正在关闭的后端实例上时,Traefik不会把它重试到另一个健康的实例上。这个问题在GitHub上挂了很久,编号是issue 2723,不少开发者都知道,但就这么悬着。

场景是这样的:docker stop发SIGTERM信号给容器,Uvicorn开始优雅关闭,中间有一个很短的时间窗口。在这个窗口里,已经到达的请求还没处理完,新的请求又被路由表送过来了。当这些请求撞上正在死亡的实例时,连接就断了。客户端收到的不是一个友好的错误码,而是空响应、连接重置,或者半个响应体。

我们做的监控服务,请求丢不起。一条检查打出去,拿到的如果是半截响应或者连接直接炸了,下游的告警逻辑就会乱套。这不是能用延时脚本缝缝补补解决的问题,这是架构层面的缺陷。

切回HAProxy的决策没花多长时间。两小时的折腾足够让我们看清一件事:Traefik在单实例或者非关键服务上用着挺好的,但一碰到需要零中断滚动更新的场景,它的自动发现机制和路由更新策略就开始掉链子。Docker Compose本来就不复杂,加一个Kubernetes来管部署是杀鸡用牛刀。但恰恰因为不用K8s,反向代理这一层必须得稳住——不能比你的应用先崩。

现在的方案跑了好几个月。HAProxy用Unix socket做健康检查,stop脚本先把旧实例从后端列表摘掉,等活跃连接处理完再真正停容器。整个过程没有花哨的标签自动发现,没有酷炫的仪表盘,但每次部署请求数曲线一条直线拉过去,报警一条没响。这就是我们想要的。