· Jeff · 教程  · 19 分钟阅读

进程还在,端口已死:一个单线程 HTTP 服务的「假活」陷阱,和我的四层加固

我自建的一个统计面板曾经挂了整整 16 天我才发现:进程从没退出、端口一直在 LISTEN、CPU 占用是零,看上去「健康得不得了」,可所有新连接都拿不到响应,公网一律 504。根因是单线程 HTTPServer 没有 socket 超时,被一条半开连接永久阻塞;更值得记的是第二层——进程监督器只认「进程存活」,这种假活对它完全不可见。这次我给它上了四层加固,也第一次想明白:为什么「重启脚本」这种修复,会被下一次部署悄悄冲掉。

我自建的一个统计面板曾经挂了整整 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() 就会永久阻塞

永久阻塞之后:

步骤发生什么
1serve_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 层)

既然监督器判据太弱,就要补两件事:

  1. 让进程自己「认输」——它才知道自己卡了。卡死时主动 os._exit(1),此时监督器看到的是一次正常的进程退出,自愈链才接得上。
  2. 启动前清残留——让新进程有能力 bind 上那个端口。

五、四层加固(完整可抄)

位置做什么解决的判据缺口
1服务代码单线程 → 每连接一线程,每连接 15s 读写超时一条半开连接不再拖死全局
2服务代码内嵌线程30s 真发一次 HTTP 请求给自己,失败即 os._exit(1)监督器判据太弱 → 由进程自认输
3监督器的 run 脚本启动前 pkill 旧实例 + 检测端口是否有无主 LISTEN inodeAddress already in use
4宿主机 cron10 分钟从外部真发一次请求,异常则自愈兜住 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/tcpstate == 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 本身更值得留:

  1. 修复必须落在不会被下一层覆盖的持久位置。 如果你在「生成脚本」里内联写修复,那等于每次部署都在回滚你的修复——而且它回滚得很安静,没有任何报错。
  2. 判断「修好了没」,不能靠「我改了代码」,要靠「重启一次之后它还在不在」。 我 8/17 那次如果多做一步「重启容器再验一遍」,就能提前一个月发现这个问题。

八、小结

决策选择理由
怎么判断服务可用真发一次请求拿 200能 connect ≠ 能服务
单线程 HTTP 服务改每连接一线程 + 每连接 socket 超时一条半开连接能拖死全局
卡死了谁来自救进程内自检失败后 自己 os._exit(1)监督器只认进程存活,假活对它不可见
启动 bind 失败run 脚本先清旧实例 + 查无主 LISTEN inode僵住的不是进程,是 socket
外部探测频率10 分钟按可接受盲区定,不是按省资源定
修复放哪持久文件,不要内联进生成脚本否则下次部署会安静地回滚它

一句话版本:「端口还在 LISTEN」和「服务可用」是两件事——假活时进程、端口、CPU 全都看着正常,只有「真发一次请求」能戳穿它。而我第一次没修好,是因为我把修复写在了会被下一次部署覆盖的地方。

相关文章:

☕ 如果这篇文章对你有帮助

欢迎请 Jeff 喝杯咖啡,支持我持续分享更多软件技巧~

打赏功能即将上线,先点个赞也是支持 ❤️

返回博客

相关文章

查看全部 »
1.3GB 内存的服务器上,浏览器自动化必然 OOM:我最后用纯 HTTP 重写了一遍签到

1.3GB 内存的服务器上,浏览器自动化必然 OOM:我最后用纯 HTTP 重写了一遍签到

一个每天跑的签到脚本,用 Playwright 时 6 分钟才启动完浏览器、随后必崩。我一度以为是脚本写得不对,换了三种写法都没用。真正的结论是:在 1.3GB 内存、swap 全满的机器上,浏览器自动化注定失败——不是代码问题,是物理问题。更值得记的是第二层翻车:当时得出的「必须借浏览器上下文」这个结论本身就是错的,而它被固化进了代码,白折腾了一周。

自建 AI Agent 记忆系统的冲突消解:软失效、事件账本,和同一个 bug 我修了两次

自建 AI Agent 记忆系统的冲突消解:软失效、事件账本,和同一个 bug 我修了两次

Agent 的记忆越攒越多,新事实和旧事实开始打架。本文记录我给自己那套记忆系统做「冲突消解」的完整实现:为什么不能直接删、软失效 + 事件账本怎么设计、用什么判据判定两条记忆冲突。重点是一个真实的翻车——判据太激进,一条新记忆横扫了 50 条无关事实,误杀 19 条。更值得记的是:同一个 bug,我修了两次。

AI Agent 定时任务为什么静默失败:两个我踩过的坑与实测数据

AI Agent 定时任务为什么静默失败:两个我踩过的坑与实测数据

定时任务连续几天不出活,日志里却看不出错——这是跑 LLM 自动化最常见的一种失败。本文是我排查一个每日定时任务的完整记录,两个真实根因:推理型模型的 token 预算被思维链烧穿(实测 4096 下 2/6 空回复、8192 下 0/6),以及框架的「模型漂移保护」fail-closed 静默跳过任务。附复现方法、修法和一份模型横向压测表。

Google Search Console 从验证到收录:新站接入实操与踩坑清单

Google Search Console 从验证到收录:新站接入实操与踩坑清单

新站被搜索引擎冷落,第一步不是狂发外链,而是把 Google Search Console 接上——它决定你能不能看见「爬虫到底来没来、收录卡在哪」。这篇是完整实操:网域还是网址前缀、四种验证方式怎么选、DNS TXT 验证的准确姿势、验证后必做的三件事、多久有数据,以及我踩过的六个坑,附 GSC / Bing / 百度三平台对照。