把生词复习工具真的搬上服务器(动手篇):按需唤醒、三个坑和部署后的实测

把生词复习工具真的搬上服务器(动手篇):按需唤醒、三个坑和部署后的实测

📖 本文共 7,479 字,阅读需要 25 分钟

上一篇《把生词复习工具搬到腾讯轻量服务器》算的是账:要搬哪些文件、哪些依赖得装、服务器磁盘会不会爆。账算完,这篇就是真动手的记录——从体检到上线,以及三个把我卡住的坑。

一句话概括这次的选择:不常驻(不想为了自己一个人用的工具养一个常驻进程),但要打开网页就能连上。

这两件事本来是矛盾的,最后用 systemd 的 socket 激活解决了。

一、先说结果

项目 实测
访问方式 http://<服务器IP>/,浏览器弹原生登录框,输一次密码
冷启动(进程没跑时的第一次访问) 首次 2.1 秒;之后被重新拉起 0.13 秒
热请求 0.002 秒
内存占用 36 MB(闲时进程根本不存在,为 0)
闲置行为 30 分钟没请求就自己退出
磁盘占用 /opt/word-review 共 24 MB
安全组改动 一行都没改
flowchart LR
    A["手机 / 电脑<br/>http://服务器IP/"] -->|80 端口| B["nginx<br/>server_name 写 IP<br/>+ Basic Auth"]
    B -->|反代 127.0.0.1:8765| C["systemd 持有的监听 socket<br/>不占内存、不耗 CPU"]
    C -->|第一个连接到达| D["才拉起 Python 进程"]
    D -->|闲置 30 分钟| E["自己退出"]
    E -.->|下次访问再唤醒| C

二、第一步是体检,不是动手

先看清楚服务器上到底有什么,比照着教程盲装靠谱得多。

项目 实际情况
系统 CentOS 7.9 x86_64,2 核 / 2 GB 内存(无 swap)
磁盘 59 G,剩 30 G
面板 宝塔 + nginx(占着 80/443)+ MySQL
防火墙 firewalld 没跑、没有 ufw → 端口只受腾讯云安全组控制
yum 仓库还活着(腾讯云 Qcloud 镜像)→ yum install 能用

Python 有三份,只有一份能用

Python 位置 能不能用
系统自带 3.6.8 /usr/bin/python3 ❌ pip 是 9.0.3,连 edge-tts 都搜不到;而且 3.6 遇到 from __future__ import annotations 直接语法错误
宝塔面板自己的 3.7.9 /www/server/panel/pyenv ⚠️ 动它可能把面板搞坏
宝塔「Python项目管理器」的 3.12.0 /www/server/pyporject_evn/versions/3.12.0/ ✅ 就用这个:374 MB,自带 OpenSSL 1.1.1o、sqlite3、venv、pip 23.2.1

结论:一行 Python 都不用装。 我原本准备源码编译(gcc 4.8.5、openssl-devel、zlib-devel 全都在,npmmirror 上 Python 源码一秒多就能下完),结果发现面板早就装好了。

差点被忽略的一个坑:这个 Python 没有 CA 根证书

宝塔编译它的时候没装证书,默认路径指向 /usr/local/openssl111/ssl/cert.pem——那个文件不存在。后果是所有 HTTPS 调用直接报:

CERTIFICATE_VERIFY_FAILED: unable to get local issuer certificate

也就是说:调大模型、调有道、调语音接口,一个都通不了,而且报错信息很容易被误判成”网络不通”。

修法就一行(把系统的证书包指给它):

export SSL_CERT_FILE=/etc/pki/tls/certs/ca-bundle.crt

修完实测:有道接口 200 ✅、DeepSeek TLS 握手成功 ✅、微软 edge-tts 真的合成出了 18720 字节音频 ✅。

出站连通性(这个必须单独测)

目标 结果
api.deepseek.com:443 ✅ 通
dict.youdao.com:443 ✅ 通(音标 / 例句 / 真人发音都靠它)
speech.platform.bing.com:443 ✅ 通(原以为大陆机房连不上,实测能)
openspeech.bytedance.com:443 ✅ 通
raw.githubusercontent.com ✅ 通,1.9 秒

我原本认定”大陆服务器访问 GitHub raw 肯定失败”,所以打算把词库手动拷上去。实测打脸——能连,还挺快。这类事真得测。

三、方案:不常驻,但”打开就能连”

普通”不常驻”为什么不行

服务没跑的时候,没有任何进程在监听那个端口,浏览器连过去会立刻收到 TCP RST:

ERR_CONNECTION_REFUSED    (秒拒,不是"加载中")

而且网页端没法把它叫醒——能叫醒它的唯一条件,就是”有个进程在监听”。

那就让 systemd 替你守着端口

systemd 的 socket 激活能同时满足两个矛盾的需求:

  • systemd 持有 127.0.0.1:8765 的监听(这不是一个守护进程,只是内核里一个 socket,不占内存不耗 CPU)
  • 第一个连接到达时,systemd 才启动真正的服务,并把监听 socket 作为 fd 3 传给进程
  • 进程闲置一段时间就自己退出,回到零占用
sequenceDiagram
    participant U as 手机浏览器
    participant S as systemd
    participant P as Python 进程
    U->>S: 连接 8765
    S->>P: 拉起进程(传 fd 3)
    P->>U: 响应(首次慢 1~2 秒)
    Note over U,P: 之后都是正常速度
    Note over P: 30 分钟无请求
    P->>P: os._exit(0)
    Note over S: socket 仍在监听,下次再唤醒

最大的好处:不会出现 502。 因为监听 socket 一直没关,连接会被内核接住排队,客户端只是”多等 1~2 秒”,而不是收到错误页再手动刷新。

三个文件就够了:

# /etc/systemd/system/xxx.socket
[Socket]
ListenStream=127.0.0.1:8765
Accept=no
[Install]
WantedBy=sockets.target
# /etc/systemd/system/xxx.service
[Service]
Type=simple
WorkingDirectory=/opt/word-review
Environment=SSL_CERT_FILE=/etc/pki/tls/certs/ca-bundle.crt
Environment=IDLE_TIMEOUT=1800
ExecStart=/opt/word-review/.venv/bin/python /opt/word-review/serve_on_demand.py
Restart=no

(注意 service 里故意不写 [Install] 段,这样它永远不会开机自启——开机启的只是 socket 监听。)

第三个是约 60 行的 serve_on_demand.py:接管 fd 3、起一个看门狗线程数闲置时间、超时自杀。

只监听 127.0.0.1,外网碰不到

ListenStream=127.0.0.1:8765 让它只对本机开放,只有 nginx 能触发。外网直接访问 IP:8765 是超时的——安全组本来就没放行这个端口,所以一行安全组都不用改。

四、动手:六步

1. 本地先瘦身

顺手把最占地方、又最不常用的那条朗读通道(本地离线 TTS)整个删掉:代码、前端按钮、配置项、175 MB 的模型、6.6 MB 的音频缓存,全部清掉(删之前留了个 5 KB 的代码备份)。本地 data/ 从 203 MB 降到 23 MB。

2. 打包上传

只带必须的,音频缓存这类”可再生”的看情况带:

tar -czf word-review.tar.gz \
  *.py web \
  data/vocab.json data/paragraphs.json data/progress.json data/online_cache.json \
  data/audio/words data/audio/tts data/audio/doubao

结果:19.5 MB / 253 个文件,scp 上去 30 秒。

解包后先用服务器的 Python 做一次语法自检,这一步能提前拦掉”版本不兼容”这类问题:

cd /opt/word-review && python3.12 -m compileall -q . && echo OK

3. 建 venv 装依赖

python3.12 -m venv /opt/word-review/.venv
.venv/bin/pip install -i https://mirrors.cloud.tencent.com/pypi/simple edge-tts

服务器上只需要这一个 pip 包(其余全是标准库)。用同城的腾讯云内网源,快得很。

4. 配按需唤醒

systemctl daemon-reload
systemctl enable --now word-review.socket     # 只启监听,进程不跑
systemctl is-active word-review.service       # → inactive(符合预期)

5. 建 nginx 站点(一个 80 端口接多个站)

这里有个认知要纠正:http://服务器IP/ 不会自动进到某个域名站点。

nginx 是靠 HTTP 请求头里的 Host 来分流的。实测同一台机器、同一个 80 端口,只改 Host 头:

请求带的 Host 结果 落到哪
1.2.3.4(IP) 200(默认站的 404 页) 默认站
haoyelaiga.com 301 跳 https 那个站点
随便乱写的.com 200(同上) 还是默认站

第 1 行和第 3 行结果完全一样——IP 和”乱域名”在 nginx 眼里是同一回事:没匹配上任何 server_name,于是都落到默认站(该端口配置里的第一个 server 块)。

所以要让 IP 进新工具,正确做法是新写一个 server_name 就是 IP 的站点(而不是去改现有站点,也不是把它设成默认站):

server {
    listen 80;
    server_name 1.2.3.4;

    auth_basic "Word Review";
    auth_basic_user_file /opt/word-review/.htpasswd;

    client_max_body_size 32m;         # 要上传词库文件

    location / {
        proxy_pass http://127.0.0.1:8765;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_read_timeout 300s;       # 生成段落可能要几十秒
    }
}

好处是精确匹配优先于默认站:用 IP 访问进新工具,别人用乱域名扫你的 IP 仍然看到原来那个 404 页,互不打扰。

改完先 nginx -t 验语法再 reload,出问题一条命令回滚。

6. 密码访问

http 站点没有 TLS,但 Basic Auth 挡”扫站脚本”足够了。生成密码不需要装 htpasswd:

PW=$(head -c 18 /dev/urandom | base64 | tr -d '/+=' | head -c 20)
printf '%s' "$PW" | openssl passwd -apr1 -stdin > /opt/word-review/.htpasswd.tmp
sed -i 's/^/review:/' /opt/word-review/.htpasswd.tmp

密码本身写进一个只有 root 能读的文件,别打印到日志里:

printf '%s\n' "$PW" > /opt/word-review/access.txt
chmod 600 /opt/word-review/access.txt

五、三个真实的坑

坑 1:ThreadingHTTPServer 的第一个参数不是 handler

我信心满满地写了:

httpd = ThreadingHTTPServer(app.Handler, bind_and_activate=False)

启动直接失败:

TypeError: TCPServer.__init__() missing 1 required positional argument: 'RequestHandlerClass'

因为它的签名是 (server_address, RequestHandlerClass, bind_and_activate=True)——handler 是第二个参数。正确写法:

sock = socket.socket(fileno=3)          # systemd 传进来的监听 socket
addr = sock.getsockname()
httpd = ThreadingHTTPServer(addr, app.Handler, bind_and_activate=False)
httpd.socket = sock                     # 换成继承来的 socket
httpd.server_address = addr
httpd.server_activate()                 # 只 listen(),不重新 bind

坑 2:.htpasswd 权限不对,返回的是 500 而不是 401

配好密码后测试:

  • 不带密码 → 401 ✅(还带着 WWW-Authenticate 头)
  • 带密码 → 500 ❌,而且只用 0.001 秒

看 nginx 错误日志才明白:

[crit] open() "/opt/word-review/.htpasswd" failed (13: Permission denied)

nginx 是以 www 用户运行的,而我把 .htpasswd 设成了 600 root:root——它读不到,于是报 500。

有意思的是不带密码时不会碰这个文件(直接回 401),所以一开始看起来”鉴权是对的”,很容易误判成后端问题。

修法:

chown root:www /opt/word-review/.htpasswd
chmod 640 /opt/word-review/.htpasswd

(密码文件本身仍然保持 600 root,只有 .htpasswd 需要给 www 读。)

坑 3:诊断脚本自己卡死了

我写了个诊断脚本,里面有用 ausearch 查 SELinux 拒绝记录的一行,结果整个脚本跑了 5 分钟没返回,SSH 直接超时。

后来改成每条可能慢的命令都套一层 timeout 5,脚本就老实了。远程诊断脚本里凡是查日志/审计/包管理的命令,都该加超时。

(顺带一说,这台机器 SELinux 是 Disabled,所以那个排查方向本身也是白费。)

六、验证:从里到外都测一遍

内部(在服务器上)

测试 结果
冷启动第一次访问 HTTP 200,2.13 秒
紧接着第二次 HTTP 200,0.024 秒
进程内存 RSS 36 MB
把闲置阈值临时改成 20 秒 唤醒后 27 秒自动退出 ✅
退出后再访问 HTTP 200,3.07 秒(二次唤醒正常)✅
最终状态 socket = active,service = inactive(正是想要的状态)

外部(从我本机)

测试 结果
http://<IP>/ 401 + WWW-Authenticate: Basic realm="..."
/api/status 401(所有接口都在鉴权后面)
另外两个域名的站点 仍然 301 跳 https,完全没受影响
乱域名(Host 瞎写) 仍然 200 走默认站,没有被劫持
IP:8765 直连 超时(安全组挡着,符合预期)

业务链路(最有说服力的一步)

curl -u "review:密码" -H "Host: <IP>" -X POST \
  -d '{"size":4,"mode":"smart"}' http://127.0.0.1/api/lesson

真的返回了一段生成的英文短文 + 中文翻译,也就是说 nginx → 按需唤醒 → 大模型出站 这条链全通;再测 /api/audio?word=abandon 拿到 11757 字节的真人发音,有道出站也通。

七、部署后的总结

成本清单

维度 代价
磁盘 24 MB(代码 + 词库 + 段落缓存 + 音频缓存)
内存 使用时 36 MB,闲置时 0
安全组 一行没改(80 早就为其他站点开着了)
开机自启 只有”监听”自启,进程不自启
维护 不需要重启服务器、不需要守护脚本、不需要定时任务

一个功能损失,要如实说明

http + IP 访问时,浏览器不认为是安全上下文,于是 navigator.clipboard 不可用——段落页那个「复制段落」按钮会失效。其他(生成、认词、进度、星标、朗读、发音、词库上传)全部正常。

其他都不受影响的原因

页面里所有请求都是同源相对路径(/api/...),所以不存在跨域或混合内容问题;音频由后端返回,也不依赖浏览器的安全上下文。只有剪贴板这类 API 有这个限制。

安全上的实话

  • 8765 只监听 127.0.0.1,外网连不上,只有 nginx 能触发唤醒
  • 但 http 下 Basic Auth 的密码是明文传输的(没有域名签不了证书)。防”随手扫到你 IP 的人”足够了,防不了同网络窃听。真在意可以自签证书走 https,或者安全组只放行自己常用的 IP
  • 密码文件 chmod 600 只给 root 看,不写进任何日志、也尽量不经过聊天工具传输——需要时自己 SSH 上去 cat 一下

日常维护命令(收藏备用)

# 看日志
journalctl -u word-review -f

# 改密码
PW='新密码'; printf '%s' "$PW" | openssl passwd -apr1 -stdin | sed 's/^/review:/' > /opt/word-review/.htpasswd
chown root:www /opt/word-review/.htpasswd && chmod 640 /opt/word-review/.htpasswd

# 彻底关掉
systemctl disable --now word-review.socket

# 数据更新(词库在本地导出后推上去,刷新页面即可,不用重启服务)
scp data/vocab.json root@<IP>:/opt/word-review/data/

八、小结

这次部署最值得记的不是那些命令,而是三个判断:

  1. “不常驻”和”打开就能用”不是非此即彼——systemd socket 激活正好卡在中间:端口一直有人守,但守着的不是进程。
  2. “我以为”和”实测”经常不一样:我以为大陆机房连不上微软 TTS 和 GitHub,实测两个都通;我以为 500 是后端崩了,其实是 nginx 读不了密码文件。先测,再下结论。
  3. 改动要能一条命令回滚:新站点写成独立配置文件、改完先 nginx -t、鉴权失败先看 nginx 错误日志——这套习惯让”在生产服务器上动手”这件事没那么吓人。

现在手机上打开 http://<服务器IP>/,输一次密码,就能在任何地方复习生词了——而服务器平时什么也不占。

发表评论

您的邮箱地址不会被公开。 必填项已用 * 标注

滚动至顶部