上一篇《把生词复习工具搬到腾讯轻量服务器》算的是账:要搬哪些文件、哪些依赖得装、服务器磁盘会不会爆。账算完,这篇就是真动手的记录——从体检到上线,以及三个把我卡住的坑。
一句话概括这次的选择:不常驻(不想为了自己一个人用的工具养一个常驻进程),但要打开网页就能连上。
这两件事本来是矛盾的,最后用 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/
八、小结
这次部署最值得记的不是那些命令,而是三个判断:
- “不常驻”和”打开就能用”不是非此即彼——systemd socket 激活正好卡在中间:端口一直有人守,但守着的不是进程。
- “我以为”和”实测”经常不一样:我以为大陆机房连不上微软 TTS 和 GitHub,实测两个都通;我以为 500 是后端崩了,其实是 nginx 读不了密码文件。先测,再下结论。
- 改动要能一条命令回滚:新站点写成独立配置文件、改完先
nginx -t、鉴权失败先看 nginx 错误日志——这套习惯让”在生产服务器上动手”这件事没那么吓人。
现在手机上打开 http://<服务器IP>/,输一次密码,就能在任何地方复习生词了——而服务器平时什么也不占。
本文章永久链接: 把生词复习工具真的搬上服务器(动手篇):按需唤醒、三个坑和部署后的实测

