· Jeff · 教程 · 19 分钟阅读
进程还在,端口已死:一个单线程 HTTP 服务的「假活」陷阱,和我的四层加固
我自建的一个统计面板曾经挂了整整 16 天我才发现:进程从没退出、端口一直在 LISTEN、CPU 占用是零,看上去「健康得不得了」,可所有新连接都拿不到响应,公网一律 504。根因是单线程 HTTPServer 没有 socket 超时,被一条半开连接永久阻塞;更值得记的是第二层——进程监督器只认「进程存活」,这种假活对它完全不可见。这次我给它上了四层加固,也第一次想明白:为什么「重启脚本」这种修复,会被下一次部署悄悄冲掉。
进程还在,端口已死:一个单线程 HTTP 服务的「假活」陷阱,和我的四层加固
我有一台小服务器,上面跑着自己写的一个统计面板:一个 Python 服务,读几个数据库,把使用情况画成图表。链路不复杂——公网请求进来,经过面板的 nginx 反代,再由宿主机上的一个 TCP 转发进程送进容器,最后到达容器里那个监听高位端口的 Python 服务。
它平时很省心,一件小事。所以第一次它「挂」的时候,我是很久之后才发现的。
这篇记录三件事:这个假活状态到底长什么样、为什么进程监督器救不了它,以及我最后那套四层加固。中间还有一个我自己都笑了的细节:第一次修复,被第二次部署冲掉了。
环境:一个容器里跑的单进程 Python HTTP 服务,前面有反代和一层 TCP 转发,用 s6 做进程监督。下文把具体端口、域名和容器名做了泛化,代码片段保留了原结构,不影响结论。
一、先看现象:它「活着」,但不应答
发现的时候,面板已经挂了 16 天。连续观察下来,失败形态非常固定,而且每一条单看都像「正常」:
| 观察点 | 表现 |
|---|---|
| 公网访问 | 一律 504,不是 502、不是超时页面 |
| 进程状态 | 进程一直在,从未退出,PID 都没变 |
| 端口状态 | ss / netstat 显示该端口处于 LISTEN |
| CPU / 内存 | 几乎为 0,看起来「很闲、很健康」 |
| 新连接 | 全部停在 SYN-RECV |
| 尝试重启进程 | bind: Address already in use |
| 已挂时长 | 16 天(更早还发生过一次,那次挂了一天) |
最关键的是第 2、3、4 行同时成立。 进程在、端口在 LISTEN、CPU 不忙——这三条凑在一起,任何人都会先排除「服务挂了」这个方向。而真相恰恰相反:监听的记录还在,但已经没有任何人在 accept 它。
一句话判据:端口能连,不等于服务可用。 假活时 TCP 三次握手照样能完成——你连上的是一个「没人接听的空号」。
更麻烦的是第 6 行:想手动重启时,新进程连端口都绑不上。这说明卡在端口上的不是「一个活着的进程」,而是内核里一条无主的 LISTEN socket 残留。
二、复现:别信端口,真发一次请求
「能连」和「能应答」是两件事,所以探测手段必须区分它们。下面这段约 10 行的探测,把三种状态一刀切开:
import socket, time
def probe(host, port, timeout=8):
"""返回: 'refused' 端口没人监听 / 'open-no-response' 假活 / 'ok' 真正可用"""
s = socket.socket()
s.settimeout(timeout)
try:
s.connect((host, port))
except ConnectionRefusedError:
return 'refused' # 端口没监听 —— 最常见、也最容易被监督器发现
except socket.timeout:
return 'syn-no-answer' # 包发出去了没人回 —— 中间层被占满
# 连上了,再要求它真的回一句话
try:
s.sendall(b'GET / HTTP/1.0\r\nHost: probe\r\n\r\n')
data = s.recv(64)
return 'ok' if data.startswith(b'HTTP/') else 'open-no-response'
except socket.timeout:
return 'open-no-response' # ★ 假活:握手成功,读不到任何字节
finally:
s.close()open-no-response 就是这次的状态:connect 成功、一个字节都读不到、直到超时。
curl 也能用,但要记得带超时并同时看两个指标,否则会误判:
# 只看 HTTP 码不够 —— 假活时 curl 会一直挂着,直到 -m 超时
curl -s -o /dev/null -m 8 -w 'http=%{http_code} time=%{time_total}\n' http://127.0.0.1:8787/
# 假活表现:http=000 time=8.000...(打满超时时间)
# 健康表现:http=200 time=0.00x必须用真实请求探测,不能靠「端口开着」下结论。 这是我这次学到的第一件事,也是后面四层加固里每一层的判据基础。
三、根因一:单线程服务 + 没有 socket 超时 = 一条坏连接拖死全局
原代码是很朴素的写法:
from http.server import HTTPServer, SimpleHTTPRequestHandler
# 单线程;且没有对连接设任何超时
server = HTTPServer(('0.0.0.0', PORT), Handler)
server.serve_forever()HTTPServer 是单线程、逐连接串行的。serve_forever() 的循环大致是:accept() 一个连接 → 读请求 → 处理 → 写响应 → 关掉 → 再 accept() 下一个。
问题出在「读请求」这一步上:它没有超时。如果某条连接处于半开状态——客户端进程被强杀、NAT 表项过期、或者中间那层 TCP 转发只把连接建起来却从不回 FIN——那么这次 read() 就会永久阻塞。
永久阻塞之后:
| 步骤 | 发生什么 |
|---|---|
| 1 | serve_forever 卡在这一条连接的 read() 上,不再回到 accept() |
| 2 | 后面所有新连接被内核放进 accept 队列,排队 |
| 3 | 队列排满后,新连接连队列都进不去,停在 SYN-RECV |
| 4 | 公网侧等不到响应 → 504 |
| 5 | 进程本身毫无异常:不占 CPU(在等 IO)、不退不崩 |
注意第 5 行:这类故障在「进程」这一层完全没有症状。 它不报错、不重启、不占 CPU——因为它在等一个永远不会来的字节。
修法(第 1 层)
两处最小改动:改成每连接一个线程,并给每条连接设读写超时。这样一条坏连接最多拖死它自己那条线程。
import socket
from http.server import ThreadingHTTPServer
SOCKET_TIMEOUT = 15 # 秒
class TimeoutThreadingHTTPServer(ThreadingHTTPServer):
daemon_threads = True # 线程守护化:主线程退出即可回收
allow_reuse_address = True
def get_request(self):
conn, addr = super().get_request()
conn.settimeout(SOCKET_TIMEOUT) # ★ 关键一行:半开连接 15s 后自动断开
return conn, addr顺带说:allow_reuse_address = True 也在这一层,但它解决不了本次的 Address already in use——原因见下一节,那是另一回事。
四、根因二(更值得记):进程监督器只认「存活」,假活对它完全不可见
这是整件事里我认为最值得写下来的部分。
我用 s6 做进程监督。它做得很标准:进程退出就拉起来,退出码非零就记一笔。系统里还有别的服务也是同样的模式。
但它监控不了这次的故障,原因很直接:
监督器的判据是「PID 还在不在」。而假活时,PID 一直在。
于是整条自愈链在第一步就断了:
| 环节 | 判据 | 假活时的结果 |
|---|---|---|
| 进程监督器 | 进程存活? | 通过(进程确实活着)→ 永不介入 |
| 人工发现 | 用户打开面板? | 只有在我主动去看的时候才知道,于是盲了 16 天 |
| 手动重启 | 重启能救吗? | 新进程 bind 失败,重启这条路也堵了 |
而来「重启失败」的原因,是这次故障的另一个反直觉点:
僵在那里的不是进程,是 socket。
那条旧的 LISTEN socket 在内核里成了「无主残留」——原先持有它的进程已经死了(或处于不可中断状态),但内核中的监听记录还在,且没有 owner 可以回收。此时新进程想 bind 同一个端口,内核看到「已占用」直接拒绝。你 ss 看到的 LISTEN 是真的,但它已经没有任何用处。
所以「端口还开着」这件事,在假活场景下是纯粹的误导信息。 它既不是服务可用的证据,也不是端口空闲的证据。
修法思路(第 2 层 + 第 3 层)
既然监督器判据太弱,就要补两件事:
- 让进程自己「认输」——它才知道自己卡了。卡死时主动
os._exit(1),此时监督器看到的是一次正常的进程退出,自愈链才接得上。 - 启动前清残留——让新进程有能力 bind 上那个端口。
五、四层加固(完整可抄)
| 层 | 位置 | 做什么 | 解决的判据缺口 |
|---|---|---|---|
| 1 | 服务代码 | 单线程 → 每连接一线程,每连接 15s 读写超时 | 一条半开连接不再拖死全局 |
| 2 | 服务代码内嵌线程 | 每 30s 真发一次 HTTP 请求给自己,失败即 os._exit(1) | 监督器判据太弱 → 由进程自认输 |
| 3 | 监督器的 run 脚本 | 启动前 pkill 旧实例 + 检测端口是否有无主 LISTEN inode | Address already in use |
| 4 | 宿主机 cron | 每 10 分钟从外部真发一次请求,异常则自愈 | 兜住 1–3 全部失效的情况 |
第 2 层:进程内自检看门狗
def _self_watchdog():
import time, urllib.request
time.sleep(20) # 启动宽限,别在还没起来时自杀
while True:
time.sleep(30)
try:
with urllib.request.urlopen(
f'http://127.0.0.1:{PORT}/api/stats', timeout=20
) as r:
if r.status != 200:
raise RuntimeError(f'status={r.status}')
except Exception as e:
# ★ 注意:必须由进程自己退出。监督器只认「进程存活」,
# 卡死的进程在它眼里是健康的,只有主动退出才能触发重启。
print(f'[self-watchdog] 自检失败({e}),主动退出交给监督器重启', flush=True)
os._exit(1) # 不能用 sys.exit(只退主线程,覆盖不到卡死的循环)
import threading
threading.Thread(target=_self_watchdog, daemon=True, name='self-watchdog').start()两个容易写错的点:
- 判据必须是「真发一次 HTTP 并拿到 200」,不能是「端口能 connect」。假活时 TCP 照样握得上手。
- 退出要用
os._exit(1),不是sys.exit()。sys.exit只结束当前线程,而卡死的是主线程的serve_forever,进程会活着不动——自愈链还是断的。
第 3 层:启动前清掉无主监听残留
run 脚本在启动服务前,先读 /proc/net/tcp 找 state == 0A(LISTEN)且端口匹配的记录,拿着 inode 去 /proc/*/fd 里反查归属:
if not listeners:
print('[stats-panel] 端口无监听残留,直接启动'); sys.exit(0)
for inode in listeners:
owner = inode_owned(inode) # 遍历 /proc/<pid>/fd 找 socket:[inode]
if owner is None:
print(f'端口存在无主僵尸监听 inode={inode},等待内核回收')
else:
print(f'端口由 PID {owner} 持有(已被 pkill 清理,等待退出)')
time.sleep(1)配合启动前的一句 pkill -9 -f 'python3 server.py',新进程才能干净地 bind 成功。
第 4 层:宿主机侧的外部探测(这是唯一能发现 16 天盲区的那层)
code=$(curl -s -o /dev/null -m 8 -w '%{http_code}' "http://127.0.0.1:${PORT}/")
[ "$code" = "200" ] && exit 0 # 健康:静默退出,不写日志
# 异常才进入自愈:① 转发层不在就拉起 ② 容器内 kill 服务,交监督器重启
pgrep -f "socat TCP-LISTEN:${PORT}" >/dev/null 2>&1 || \
nohup socat TCP-LISTEN:${PORT},fork,reuseaddr TCP:${BACKEND_IP}:${PORT} >/dev/null 2>&1 &
for p in $(docker exec -u root "$CONTAINER" pgrep -f 'python3 server.py'); do
docker exec -u root "$CONTAINER" kill -9 "$p"
done频率为什么定 10 分钟,而不是每天
这一条是被问过之后专门想清楚的,我觉得比代码本身重要:
| 频率 | 健康路径成本 | 最长盲区 |
|---|---|---|
| 每天一次 | 一次本地 curl(约 5ms) | 24 小时 |
| 每 10 分钟 | 一次本地 curl(约 5ms) | 10 分钟 |
9/10 那次事故正是挂着过了一整天才被发现。 每天跑一次,等于把这个盲区主动设计出来。而成本对比根本不是同一个数量级的事:省下的是每天 143 次本地 curl(总计不到 1 秒 CPU),换来的是把盲区压缩两个数量级。
探测频率应该按「你能接受多长的盲区」来定,而不是按「看起来省不省资源」来定。
六、我的排查清单(可直接抄)
| 步骤 | 动作 | 判据 |
|---|---|---|
| 1 | 别用「端口能连」下结论 | 必须真发一次请求拿到 200 |
| 2 | 看失败形态 | 能连 / 零响应 / 打满超时 → 假活 |
| 3 | 看进程 | PID 在不在——它不代表可服务 |
| 4 | 看 socket 归属 | LISTEN 的记录是否还有 owner(反查 /proc/*/fd) |
| 5 | 重启报 Address already in use | 不是端口被占,是内核残留 socket 无主 |
| 6 | 问「监督器为什么没救它」 | 判据是进程存活 → 假活不可见,须进程自认输 |
| 7 | 补外部探测 | 频率按可接受盲区定,不按省资源定 |
第 1 步和第 6 步是治本的两条:第 1 步决定你能不能发现它,第 6 步决定自愈链能不能接上。剩下五步都是止损。
七、最该记住的:我的修复被下一次部署冲掉了
第一次事故(8/17)之后我就做过修复:加了个「启动时重建监督脚本」的逻辑——容器每次重启,启动脚本会重新生成那份 run 脚本。当时看着挺完整,我甚至专门在注释里写了「这次重启脚本会自愈」。
9/10 又挂了一次。查下来才发现问题出在哪:
那份启动脚本里是「内联重写」run 脚本的,写的是朴素版。
也就是说,我 8/17 那次把加固逻辑放进了一个会被覆盖的位置。容器一重启,加固就被它自己的启动脚本冲掉了——我写的「自愈」,自愈的是那个没有加固的朴素版。
于是这次的修法换了个方向:
| 8/17 的修法(失败) | 这次的修法 | |
|---|---|---|
| 加固逻辑放在哪 | 启动脚本里内联生成 run 脚本 | 落成持久文件,如 stats-panel-run.sh |
| 启动脚本的职责 | 生成内容 | 只做 cp 复制持久文件 |
| 容器重启后 | 加固被冲掉,回到朴素版 | 加固随持久文件一起复制回来 |
| 保险措施 | 无 | 注释里写明「不要内联重写」,并在缺失时告警回落 |
从这件事我提炼出两条,比这次的 bug 本身更值得留:
- 修复必须落在不会被下一层覆盖的持久位置。 如果你在「生成脚本」里内联写修复,那等于每次部署都在回滚你的修复——而且它回滚得很安静,没有任何报错。
- 判断「修好了没」,不能靠「我改了代码」,要靠「重启一次之后它还在不在」。 我 8/17 那次如果多做一步「重启容器再验一遍」,就能提前一个月发现这个问题。
八、小结
| 决策 | 选择 | 理由 |
|---|---|---|
| 怎么判断服务可用 | 真发一次请求拿 200 | 能 connect ≠ 能服务 |
| 单线程 HTTP 服务 | 改每连接一线程 + 每连接 socket 超时 | 一条半开连接能拖死全局 |
| 卡死了谁来自救 | 进程内自检失败后 自己 os._exit(1) | 监督器只认进程存活,假活对它不可见 |
| 启动 bind 失败 | run 脚本先清旧实例 + 查无主 LISTEN inode | 僵住的不是进程,是 socket |
| 外部探测频率 | 10 分钟 | 按可接受盲区定,不是按省资源定 |
| 修复放哪 | 持久文件,不要内联进生成脚本 | 否则下次部署会安静地回滚它 |
一句话版本:「端口还在 LISTEN」和「服务可用」是两件事——假活时进程、端口、CPU 全都看着正常,只有「真发一次请求」能戳穿它。而我第一次没修好,是因为我把修复写在了会被下一次部署覆盖的地方。
相关文章:
- AI Agent 定时任务为什么静默失败——同一类教训:没报错不等于成功,链路每一环都可能安静地返回空结果
- 1.3GB 内存的服务器上,浏览器自动化必然 OOM——另一种「看起来在跑、实际已经不行了」的失败形态,以及一个被代码固化下来的错误结论
- Nginx 缓存导致的「改了没生效」排查——同样属于「中间层让现象与事实不一致」,判断时必须绕开它去看源站
☕ 如果这篇文章对你有帮助
欢迎请 Jeff 喝杯咖啡,支持我持续分享更多软件技巧~
打赏功能即将上线,先点个赞也是支持 ❤️