第七章 · 把她送上云端
部署与运维:让一个程序在千里之外活着
你的机器人已经在跑了,但那是别人帮你跑起来的。你大概说不出它为什么会在半夜掉线,也说不清为什么重启之后有时不用扫码、有时又要。这一章要做的事只有一件:把别人替你做的事,变成你能解释、能重做、能修的事。
0. 先看地图:这一章要带你去哪
读完这一章,你应该能回答
- 为什么一个在你笔记本上跑得好好的程序,搬到服务器上会以各种奇怪的方式坏掉?
- OWL 的真实配置文件里,每一行分别在解决什么问题?删掉它会看到什么现象?
- 为什么 NapCat 的容器必须用
host网络模式,而「容器里的 127.0.0.1」和「服务器的 127.0.0.1」居然不是一回事? - 「开机自启」和「崩溃自动重启」这两件事,分别由谁保证?为什么少一个都不行?
- 进程活着、端口在听、容器健康,为什么机器人还是可能一条消息都收不到?
- 磁盘被日志写满、内存被内核杀掉,这两件事你在日志里会看到什么字?
- 给你一个「机器人不回复」的现象,你能不能按顺序自己定位到是哪一层坏了?
学完这一章,你会做这些事
- 用 Docker 跑起 NapCat,并解释
host网络模式为什么是必须的 - 写出一个 systemd 服务单元,让程序开机自启、崩溃自动重启
- 用日志与端到端健康检查发现「假活」,而不是只看进程还在不在
- 按分层方法论排查真实故障,并安全地回滚一次
- 算清成本,并说清风控与合规的真实边界
需要的前置:这一章是全书对「环境」最敏感的一章,所以它需要你已经有过第一章(计算机常识与 Linux)里的东西:文件与路径、进程与端口、权限、SSH、管道与重定向。它还需要你知道第四章(网络与协议)里那件最关键的事——反向 WebSocket 是谁连谁。如果你已经忘了「为什么叫反向」,请先回去看那一节,否则本章第三节之后你会一直晕。
这一章会为后面铺路:第八章(Prompt、RAG 与多模态)里那些关于 token、温度、上下文长度的调参,最终都会变成「钱」和「延迟」两个数字,而这两个数字要在这一章的监控里才看得见;第九章(工程素养与远方)会把这一章末尾那份安全清单和这套排障方法,抽象成一套可以迁移到任何项目的工程习惯。
回头看看:序章 2.1 的那张四层架构图,当时我说「看不懂是正常的」。现在请翻回去再看一眼——这一章会把那张图里的每一个方框,都换成一个你可以亲手敲出来的文件、命令和配置行。另外,第四章(网络与协议)里那条反向 WebSocket 与第五章(Node.js 与异步世界)里那个「一直醒着等人敲门」的服务,在这一章都会第一次真正跑在别人的机器上;而第六章(数据库与记忆)里那些 history.json 与 memory.json,会在这里变成「必须被备份、且不能被代码更新覆盖」的具体文件。
先说清楚这一章的难度曲线。前面六章,你写错了代码,报错会告诉你第几行错了;这一章不一样:配置写错一个字,程序可能什么都不说,只是安静地不工作。运维的困难不在于知识难,而在于反馈少。所以这一章会反复使用同一个动作:把「现象」和「机制」对齐。你看到某个现象时,脑子里应该立刻浮出「噢,这是哪一层在说话」。
1. 本地与服务器:两种完全不同的世界
我们从一个具体的场景开始。你在自己的 Windows 电脑上双击了 2-start-napcat.cmd,又双击了 1-start-bot.cmd,两个黑窗口亮着,你在手机上发一句「你好」,机器人回了你。你关掉窗口,一切结束。这套流程你已经跑得很熟了。
现在把这套东西原封不动搬到一台上海的服务器上。同样两份程序,同样的配置,同样的网络。结果是:它可能根本起不来,可能起来以后连不上 QQ,可能连上了第二天早上就掉线了,可能一切正常但你不知道它到底有没有在正常工作。
差别不在代码里。差别在环境里。
1.1 你原来拥有哪些「免费的帮助」
我们先诚实地清点一下,在本地跑的时候,环境到底偷偷替你做了多少事。
| 你拥有的条件 | 它替你解决了什么 | 搬走之后会怎样 |
|---|---|---|
| 你人就坐在电脑前面 | 窗口一黑你就看到了,报错一眼就能读 | 没人看屏幕,程序崩了你也不知道 |
| 随时可以按 Ctrl+C、双击图标重开 | 重启的成本接近零,心态也放松 | 你不在服务器旁边,重启要走 SSH;而且你未必知道该重启哪个 |
| 程序崩了无所谓 | 不回复消息这件事,最坏后果是「今天不聊了」 | 有真实的人在等你回话,尤其是深夜;一次掉线就是一次「她不在」 |
| 磁盘几乎不会满 | 日志想写多大写多大,图片缓存随便堆 | 40 G 的盘会被慢慢吃掉,满了之后容器起不来 |
| 内存没人跟你抢 | 你的电脑有 16 G,程序吃 500 M 是零头 | 服务器只有 1.6 G,一次内存突发就可能被内核直接杀掉 |
| 网络是家宽,掉一下自己会好 | 丢几个包,你没感觉 | 云网络同样会抖,而长连接一断,程序未必能自己长回来 |
| Windows 帮你管着「开机启动」这类琐事 | 你不必知道什么是服务、什么是守护进程 | Linux 上你必须明确告诉系统:这个程序该怎么活着 |
注意最后一行。它不是技术细节,它是这一章的主题。在本地,是「你」在扮演那个让程序活着的角色;在服务器上,这个角色必须交给系统。你不能再靠「我盯着」这件事了,因为你不在了。
1.2 一句话点题:写代码是与逻辑打交道,部署是与环境打交道
这句话值得你抄在本子上,因为它是这一整章的地基。
写代码的时候,你面对的是一个封闭的世界:输入是确定的,规则是你定的,错就是错,对就是对。变量叫 a 还是 userName,函数返回值是对象还是数组,这些都由你决定。你在和逻辑打交道。逻辑是可以被推理的,所以你可以「想明白」。
部署的时候,你面对的是一堆你没参与制定的规则:内核什么时候杀进程、磁盘什么时候满、腾讯什么时候踢你下线、Docker 的默认网络是 bridge 还是 host、systemd 的重启有没有次数限制。这些东西没有对错,只有事实。你在和环境打交道,而环境不能被推理出来,只能被观察出来。
要点这两件事对应两种完全不同的能力:逻辑能力让你写出正确的程序,观察能力让你知道正确的程序为什么在这里不工作。很多人在第一章到第六章练的都是前者,所以一到第七章就觉得「我明明都懂了,为什么还是搞不定」——因为这里考的是另一种能力。
1.3 环境差异具体差在哪:一张对照表
把抽象的话落到具体的差异上。下面每一行,都是你搬家过程中会真实撞到的东西。
| 维度 | 本地(你的 Windows) | 服务器(阿里云 Ubuntu) |
|---|---|---|
| 操作系统 | Windows,图形界面,路径用反斜杠 | Linux,只有命令行,路径用正斜杠,大小写敏感 |
| 谁启动程序 | 你双击,进程挂在你这个登录会话下面 | systemd 启动,进程独立于任何登录会话 |
| 终端关了会怎样 | 程序跟着窗口一起没了 | 程序继续活着(因为它不是你启动的) |
| 时区 | 跟着你的系统设置走 | 默认常是 UTC,必须显式设置,否则日志时间差 8 小时 |
| 资源上限 | 16 G 内存,几百 G 硬盘,随便用 | 1.6 G 内存,40 G 硬盘,每一项都要精打细算 |
| 暴露面 | 你在路由器后面,别人找不到你 | 有公网 IP,全世界都能扫你的端口 |
| 失败成本 | 你自己不方便 | 真实用户受影响,而且你可能几小时后才知道 |
看第四行。这一行看起来最不起眼,却造成过一次真实的排查浪费——我们后面在讲日志时会看到它的原样:早期代码用 toISOString() 输出的是 UTC 时间,而服务器时区是 Asia/Shanghai,于是日志整体慢了 8 小时。你拿着一条错误的时间线去推理「她最后一次活着是什么时候」,会得出完全错误的结论。
这就是「与环境打交道」的典型样子:不是代码错了,是你对你的程序所在的这个世界判断错了。
想一想回想你在第一章学过的「进程」——进程有父进程,父进程退出时子进程会怎样?现在想一个问题:你在 SSH 窗口里敲下 node bot.js,然后关掉窗口,这个 node 进程应该活着还是死掉?先给出你的判断,再看下一节。这个问题的答案会决定你为什么必须学 systemd。
2. 云服务器:租一台别人的电脑
要让程序 24 小时活着,你得有一台 24 小时开着的机器。有两种办法:把自己家的电脑永远开着,或者租一台。前者的问题是电费、噪音、家宽的公网 IP 不固定、以及你一旦搬家或断电就全停;后者叫云服务器(Cloud Server),本质是「机房里有台机器,你按时间付钱用」。
2.1 买之前要决定的四件事
打开任何一家云厂商的售卖页面,你会看到一堆选项。对 OWL 这个项目来说,只有四个选项真正重要。
第一,地域。OWL 选的是华东2(上海)。为什么是上海?两个理由,缺一不可。
- 必须在国内。协议端要连的是腾讯的 QQ 服务器。从一个海外 IP 去登录一个国内常用账号,行为特征会很突兀,风控判定为异常的概率明显更高。部署文档里对这一条写得很直接:不要选香港或海外节点。这不是玄学,是「你的行为看起来像不像一个正常用户」的问题。
- 要离你的用户近。OWL 面向的是国内的高中生。网络延迟的本质是物理距离除以光速再加中间设备的排队时间,上海到国内大部分地区是一个很平衡的位置,大家访问她都不慢。
第二,配置(CPU 与内存)。OWL 的真实配置是 2 核 CPU、1.6 G 内存。这个数字在今天的眼光里小得可笑,所以必须讲清它的真实能力边界,否则你会对它产生错误的期待。
| 资源 | 真实数字 | 它意味着什么 |
|---|---|---|
| CPU | 2 核 | 够用。机器人本体几乎没有计算量,它做的事是「等」:等消息、等 API 返回。真正吃 CPU 的是 NapCat 里那个完整的 QQ 客户端内核,但它也大多在待机。 |
| 内存 | 1608 MB(约 1.6 G) | 这是真正的瓶颈。实测常驻占用约 476 MB,其中包括 NapCat 的 QQ 内核(约 300 MB)、Docker 本身(约 150 MB)和机器人本体(约 60 MB)。剩下约 1.1 G 是缓冲。 注意两个「1.6 G」和「2 G」的关系:账单上的商品名往往写「2 核 2G」,而系统里真正可用的内存是 1608 MB——少的那些被内核、固件和虚拟化层拿走了。以 free -m 看到的数字为准,不要以商品名为准。 |
| 磁盘 | 40 GB | 实测占用约 8.3 GB,约 23%。剩下大部分空间不是给你用的,是给日志和 NapCat 的图片缓存用的。 |
| 带宽 | 3 Mbps | 只传文字,绰绰有余。一条几百字的回复只有几百字节。带宽在你这里的风险不是不够用,而是「被图片流量吃满」。 |
这四条里,只有内存会真的咬人。1.6 G 意味着「够用但不宽裕」——平时没问题,一旦某个进程短时间申请了大量内存(比如模型返回了一大段文本、日志缓冲区在刷、Docker 在做某件吃内存的事),内核就可能立刻动手杀掉一个进程来救自己。这就是OOM(Out Of Memory,内存耗尽)杀进程,它的表现极其反直觉:程序突然没了,日志里一句「内存不足」都没有。
要点顺带说一件你在自己维护项目时一定会遇到的事:不同时间点的实测数字会不一样。这台机器在部署当天的记录是「内存 437 MB / 1608 MB,磁盘 8.2 GB / 40 GB」,而几天后的另一次记录是「内存 476 MB,磁盘 8.3 GB」。两次都是真的——内存占用随运行时长、缓存量和在线的会话数波动,磁盘则在慢慢长。所以:看到两个数字不一致时,第一个该问的不是「哪个是错的」,而是「它们分别是什么时候测的、测的时候系统在做什么」。把测量时间一起记下来,比记住一个精确数字有用得多。
警告不要把「2 核 1.6 G 能跑起来」理解成「2 核 1.6 G 很够」。它够,是因为我们后面要做的每一件事(加 Swap、限制容器日志大小、卡片式地控制内存)都在替它兜底。如果你把这几件事都省掉,同一台机器会以「半夜莫名掉线」的方式把账要回来。
第三,磁盘与带宽。这两个是「够用就好」的典型。注意一个反直觉的点:磁盘不是被你的代码填满的,是被日志和缓存填满的。一个每天写几兆日志、缓存几十张表情包图片的程序,一年可以悄悄吃掉几个 G。40 G 的盘配上「容器日志限额 10 MB × 3」和「每周清理无用镜像」这两条纪律,才是一个完整的方案。少了纪律,磁盘满是一个必然会发生、而不是可能会发生的事故。
第四,镜像选择。OWL 用的是 Ubuntu 22.04 LTS。为什么是它?
- LTS 是「长期支持」。LTS(Long Term Support,长期支持版)意味着这个版本会被持续提供安全更新很多年,你不必频繁重装系统。对一个「装好就不想再动」的服务来说,稳定比新功能重要得多。
- 生态最厚。你在网上搜到的 Linux 教程、报错解法、一键脚本,默认参照物几乎都是 Ubuntu / Debian 系。选它等于免费继承了一大批别人的经验。第一条部署指南里就写着:别选 CentOS 或 Windows。
- 包管理是
apt。你在第一章学过的apt(Advanced Package Tool,Debian 系的软件包管理器)在这里直接可用,装 Node、装 Docker、装ufw都是一行命令。 - 它的默认目录结构、权限模型、命令名,和所有教程一致。这一点在排障时的价值最大——当你出问题时,你能在三十秒内搜到十条和你现象完全一样的结果。
2.2 计费:为什么是 68 元一年
OWL 的服务器是「轻量应用服务器」(Lightweight Application Server),一年 68 元,折算下来一个月不到 6 元。
这里有一个初学者最容易混淆的点,值得专门说清:服务器的计费方式和 API 的计费方式完全不同。
| 云服务器 | 大模型 API | |
|---|---|---|
| 计费方式 | 按时间(包年包月 / 按量付费),开机就花钱 | 按用量(token 数),不用就不花钱 |
| OWL 的实际支出 | 68 元 / 年,固定 | 聊天场景每月几毛到几块钱 |
| 费用失控的样子 | 忘记续费,到期直接停机 | 被刷屏或陷入重试循环,短时间内烧掉大量额度 |
| 对应的纪律 | 记下到期日,别等停服才想起来 | 在控制台设置消费上限;在代码里限流 |
第二行那个「忘记续费」不是玩笑。部署手册的第一页就写着到期时间,并且专门加了一句提醒。一个跑了半年都很稳的机器人,最可能的死法不是崩溃,而是「你没续费」。运维里很大一部分工作,是在对抗「遗忘」而不是对抗「故障」。第八章还会继续算这笔账,那时我们会把 token 换算成钱。
2.3 安全组:只开 22 端口,这是第一道防线
服务器买好之后,你会看到控制台里有一个叫「安全组」或「防火墙」的东西。它的作用是规定:公网上的哪些 IP、可以访问你这台机器的哪些端口。
在第一章我们讲过端口:一台机器上有很多「门」,每个程序占一扇。端口本身就是攻击面——每多开一扇门,就多一个可能被人推开的入口。所以云服务器的第一原则非常简单:
安全组只放行 22(SSH),其余一律不放行。
不是因为别的端口一定危险,而是因为你现在想不出它的用途,那么它被打开的收益是零,风险却是正的。
为什么这条规则特别重要?因为我们的系统里有两个「很想被打开」的端口:
3001:机器人本体监听的端口。它接收的是协议端推来的消息,并且带着 token 鉴权。如果有人能连上它,并且猜到了 token,他就能冒充协议端,让你的机器人替他说话。6099:NapCat 的网页面板。它是一个能登 QQ、能改配置的可视化控制台。这是整个系统里权限最高的东西——比 API Key 还高,因为它能直接操作你的 QQ 号。
如果这两个端口开在公网上,你就把「机器人」和「控制台」同时摆在了全世界的扫描器面前。而互联网上确实一直有人在扫——这不是恐吓,是任何一个有公网 IP 的机器都会在几小时内收到的日常。
那我们自己怎么用它们呢?答案是第三节的主角:不让它们出现在公网上,而是通过一条加密的隧道,把它们「借」到本地来用。
技巧养成一个习惯:买完服务器、装完系统、还没装任何业务之前,先只留 22。之后每想开一个端口,都问自己一句「有没有办法不开也能用」。OWL 的答案是有,而且用了三次(3001、6099,以及未来的任何面板),方法都是同一个:下一节的 SSH 隧道。
3. 从零连上它:密钥、加固与隧道
现在服务器在机房里,你人在这里,中间隔着互联网。你要做的第一件事是拿到一个能进去的入口。回忆一下第一章:这个入口叫 SSH(Secure Shell,安全外壳协议),它把「在远方机器上敲命令」这件事变成了一条加密的会话。
3.1 密码登录为什么不够,密钥对是什么
最常见的登录方式是用户名加密码。它有一个无法回避的弱点:密码是可以被猜、被撞、被撞库的。一台开着 22 端口的公网机器,每天会收到成百上千次来自自动化脚本的密码尝试,这在服务器圈子里是背景噪音级别的日常。
更好的方式是密钥对。你在第一章见过它,但那时的重点在「非对称加密是什么」;现在我们关心的是它怎么用。
用一句话说清:私钥是留在你电脑上的那把「钥匙」,公钥是放上服务器的那个「锁」。你登录时,服务器给你出一道只有持有私钥的人才能答对的题,你答对了,就进去了。整个过程里,密码从不出现,也不存在「猜」这个动作——因为这道题不是猜出来的,是算出来的。
生成与使用的完整流程(在你自己的电脑上做):
# 1) 在你的电脑上生成一对密钥(-t 指定算法,-C 是备注,随便写)
ssh-keygen -t ed25519 -C "owl-server"
# 执行后它问你:密钥文件存哪里?(直接回车用默认位置)
# Windows 默认: C:\Users\你的用户名\.ssh\id_ed25519
# Linux/Mac: ~/.ssh/id_ed25519
# 然后问你:要不要给私钥加一个口令(passphrase)?
# 加了更安全(私钥被偷走也用不了),代价是每次登录要输一次。
# 零基础阶段建议加一个你能记住的口令。
# 2) 把公钥复制到服务器上(注意:复制的是 .pub 那个,不是私钥)
# ssh-copy-id 是 Linux/Mac 的便捷命令;Windows 上没有,用下面第 3 步的方式。
# 3) Windows 手动方式:把公钥内容追加到服务器的 ~/.ssh/authorized_keys
# 先看一眼公钥内容(一长串以 ssh-ed25519 开头的文本)
type $env:USERPROFILE\.ssh\id_ed25519.pub
▲ 密钥对的三步:本地生成、公钥上传、之后用私钥登录。注意 .pub 结尾的是公钥,可以随便传;没有后缀的那个是私钥,任何情况下都不能发出去、不能提交进 Git。
关于本地私钥的权限,有一个非常具体、非常容易踩的坑必须提前说:
在 Linux 和 macOS 上,私钥文件的权限必须是「只有我能读」。如果你把权限放开成 644(所有人可读),SSH 会直接拒绝使用这把钥匙,并报一句大意是「权限过于开放」的错。这不是它矫情,而是它在替你防止一件事:别人偷看你的私钥。用第一章学过的 chmod 修一下就好:
# Linux / macOS:把私钥收紧到「只有属主可读写」
chmod 600 ~/.ssh/id_ed25519
# 而公钥和 authorized_keys 可以是 644,因为公钥本来就是公开的
chmod 644 ~/.ssh/id_ed25519.pub
▲ 权限在 SSH 里不是建议,是硬性检查。看到「Permissions are too open」这类报错时,第一反应就是 chmod 600 私钥,而不是去改 SSH 的配置绕过检查。
在 Windows 上情况略有不同:Windows 版 OpenSSH 也会检查权限,但它的判断依据是文件 ACL 而不是简单的 Unix 权限位。项目里真实的做法是直接把密钥放在部署目录(qq-bot/deploy/server-key.pem),用 -i 参数指定:
# 先把密钥路径记成一个变量,后面所有命令都用它(省得每次敲一长串)
# 把 <你的密钥目录> 换成你机器上的实际位置(Windows 用反斜杠,Linux/macOS 用斜杠)
$key = "<你的密钥目录>/server-key.pem"
# 阿里云等云厂商通常给你一个 .pem 文件,直接用它登录
ssh -i $key root@<你的服务器 IP>
▲ 用变量代替绝对路径有两个原因:一是每台机器的用户名和目录都不同,写死了别人抄不走;二是它顺便教了一个好习惯——把经常重复的长字符串提取成一个变量。
关于路径算不算秘密:路径本身不算秘密,泄露它最多让人知道「这个文件大概叫什么」;文件内容才是秘密。所以你可以大方地说「密钥放在 deploy/server-key.pem」,但那个文件里那几十行 -----BEGIN ... PRIVATE KEY----- 一个字都不能外传——它才是能开门的东西。判断标准很简单:这段内容能不能直接拿来登录?能,就是秘密。
服务器已经禁用了密码登录,所以如果漏掉 -i,你会看到 Permission denied (publickey)——这个报错的意思是「你给的钥匙不对或者没给钥匙」,而不是「服务器坏了」。
每次都要敲这么一长串很烦。所以下一步是写配置文件起别名。SSH 会读你本地的 ~/.ssh/config(Windows 上是 C:\Users\你\.ssh\config),你可以在这里起一个短名字:
# ~/.ssh/config —— 注意这个文件本身也要 chmod 600
Host owl
HostName <你的服务器 IP> # 真实 IP
User root # 登录用户名
IdentityFile ~/.ssh/id_ed25519 # 用哪把私钥
ServerAliveInterval 60 # 每 60 秒发一次心跳,防止空闲太久被中间设备掐断
ServerAliveCountMax 3 # 连续 3 次没回应就本地断开,而不是一直卡着
▲ 起别名之后,ssh owl 就等于那一长串命令。后两行是长连接的经验值:不加它们,你开着窗口去看一眼手机,回来发现会话被无线路由器或云网络掐断了,而你会误以为是服务器挂了。
技巧~/.ssh/config 里可以写很多台机器。以后你有第二台、第三台服务器,都只是多一个 Host 段落。这个文件加上一把 Protocol 2 级别的私钥,就是现代开发者管理「自己那一堆机器」的标准方式。
3.2 首次登录后的加固清单
第一次 SSH 上去之后,不要急着装程序。先花十分钟把门锁好。下面五项,前四项几乎是所有公网服务器的共识做法。
- 禁止 root 用密码登录,只允许密钥。这是收益最大的一条。做法是编辑
/etc/ssh/sshd_config,把PermitRootLogin设成prohibit-password(允许用密钥登录 root,但禁止密码),或者更保守地设成no并改用普通用户加sudo。改完必须重启ssh服务生效。 - 加固前先确认你的密钥能登录。顺序极其重要:如果你先把密码登录关了、结果密钥其实没配通,你就把自己锁在门外了——那时只能去云控制台走 VNC 救援。正确的顺序是:先开一扇新门,验证能进去,再关上旧门。
- 改 SSH 端口?谨慎考虑。把 22 改成别的端口,能挡掉一部分「无脑扫 22」的脚本。但它带来的安全收益有限(会扫全网的人照样会做端口扫描),而代价是真实的:你以后每次连接都要多写一个端口参数、云安全组也要跟着改、某些工具会因此不工作。我的判断是:对个人项目,用密钥登录 +
fail2ban的收益比改端口高得多,优先级也更高。如果你确实想改,记得先确认新端口在安全组和系统防火墙里都放行了,再重启ssh。 - 装一个
fail2ban。它做的事情很朴素:读日志,发现某个 IP 在短时间内反复登录失败,就调用防火墙把那个 IP 临时封掉。一行命令装上、默认规则就够用,它是「自动化的守门人」,把你从「每天手动看一遍登录日志」里解放出来。 - 给云控制台开双因素认证。这一条最容易被忽略,但它防的是一个完全不同的攻击面:前面四条保护的是服务器,这一条保护的是你的账号。而云账号的权限比服务器还大——它能重置服务器密码、能关机、能删盘、能改安全组。如果这个账号被撞了,你的所有加固都会被从更高一层绕过。
警告OWL 的服务器上,防火墙规则是用 ufw 写的,而且写得很短:默认拒绝所有入站、允许所有出站、只放行 22。顺序也是安全的一部分——先 ufw reset 清掉历史规则,再设默认策略,最后加放行规则,避免残留一条你忘了的 allow。
3.3 SSH 隧道:把服务器上的端口「搬」到本地
现在到了这一节最有用的技能。我们要一边遵守「只开 22」这条纪律,一边使用服务器上的 6099 面板。
做法叫 SSH 隧道(SSH tunnel)。它的原理很好理解:既然 22 端口这条加密通道是通的,那我们可以把别的流量塞进这条通道里传。SSH 提供三种转发方式,我们只关心其中一种——本地转发(-L)。
它的语义是:在本地开一个端口,把所有连到它上面的流量,通过 SSH 连接转发到服务器的某个地址上去。用真实的命令看最清楚:
# 本地转发:-L 本地端口:目标地址:目标端口 用户@服务器
# $key 是上面定义的密钥路径变量;别忘了先写 $key = "<你的密钥目录>/server-key.pem"
ssh -i $key \
-L 6099:127.0.0.1:6099 \
root@<你的服务器 IP>
▲ 逐段读这条命令:-i ... 指定私钥;-L 6099:127.0.0.1:6099 表示「在我本机开 6099 端口」;冒号后面的 127.0.0.1:6099 是从服务器的视角看到的目标地址;最后是登录目标。执行后这个窗口不会给你命令行提示符,它变成了一个「活的通道」,关掉窗口隧道就断了。
最关键的一句话在这里:冒号后面的 127.0.0.1 是在服务器上解析的,不是在你本地解析的。这一点和下一节 Docker 的那个坑是完全同一个坑——先记住它,我们马上会看到它的第二次出现。
隧道建好之后,在你本机的浏览器里打开:
http://127.0.0.1:6099/webui?token=<你的面板口令>
▲ 这个请求发出时,浏览器以为它在访问本机的 6099;实际上数据被 SSH 加密送到上海那台机器,由它在本机内部转给 6099 上的 NapCat 面板。所以:整个过程里 6099 从未在公网上出现过一秒。那个 token 是面板自己的登录口令,可以从 docker compose logs napcat | grep -i token 里查到。
为什么这个技巧值得单独讲一节?因为它同时解决三个问题:
| 问题 | 直接把端口开到公网 | 用 SSH 隧道 |
|---|---|---|
| 谁可以访问 | 全世界,包括扫描器 | 只有你自己,而且必须先有私钥 |
| 传输是否加密 | 取决于你自己有没有配 HTTPS,通常没有 | 是,套在 SSH 的加密通道里 |
| 认证靠什么 | 面板自己的口令(可能很弱、可能是默认值) | 面板口令 + SSH 密钥,两道门 |
| 用完怎么办 | 端口一直开着,忘了就是长期风险 | 关掉窗口就没了,天然是「用完即走」 |
最后一行是一个容易被低估的优点:隧道让「暴露」变成了一个临时状态,而不是一个永久状态。你只有在需要的时候才把它打开。对比一下「开一个端口,然后忘了它」——后者是你半年后收到一条「服务器被入侵」通知时最常见的起因。
想一想如果 -L 6099:127.0.0.1:6099 里的第二个地址之所以是 127.0.0.1,是因为「它在服务器上被解析」,那我把目标写成 localhost:6099 行不行?写成 0.0.0.0:6099 又会怎样?请先自己推理,再动手试。这个练习会让你第一次真正「看见」地址是在哪台机器上生效的。
4. Docker 与容器:这一章的第一个理论重点
从这一节开始,我们进入 OWL 真正跑起来的方式。你会先看到一个问题,然后看到一个解决它的方案,最后把这个方案的真实配置文件一行一行拆开。
4.1 它到底解决什么问题:「在我电脑上能跑」
假设你要把 NapCat 直接装在服务器上(不用 Docker)。你需要什么?一个 QQ 的 Linux 版本、它依赖的一堆系统库、若干原生动态库文件、正确的目录结构、正确的启动参数、正确的工作目录。只要有一样不对,现象通常是这样的:
Error: The specified module could not be found: wrapper.node
▲ 这是一个真实出现过的报错(在本机 Windows 上搭建部署包时遇到的)。它没有告诉你缺什么,只说「找不到某个模块」。而缺的其实是 crypto.dll / ssl.dll 这类从 QQ 安装目录里复制出来的原生依赖。
这个报错的形状很典型:依赖地狱。你要的东西 A 依赖 B 的 2.3 版,B 又依赖 C 的某个特定编译选项,而你的系统里装的是另一个版本。你在 Windows 上装好了,换到 Ubuntu 上一切重来。
这就是环境一致性问题:同一个程序,在不同机器上表现不同,因为「机器」不只是 CPU 和内存,还包括几百个你看不见的库和配置。容器就是给这个问题的一个答案:把程序连同它需要的整个运行环境,一起打包。
对 NapCat 这种「需要一整个 QQ 客户端内核加一堆原生库」的程序,容器几乎是唯一舒服的方案。你不是在「装一个软件」,你是在「搬一整个环境」。
4.2 四个概念,各用一句话说清
Docker 的术语不多,但初学者最容易把它们搅在一起。先记住这四句话,后面每个都会展开。
镜像(Image):一个只读的模板,是「环境的快照」。它不能跑,只能被拿来创建容器。
容器(Container):镜像跑起来之后的一个实例。它是可读写的、有生命的,可以启动、停止、删除。
数据卷(Volume):一块挂在容器外面(宿主机上)的存储。它的唯一使命是让数据活得比容器久。
网络(Network):决定容器看到的世界长什么样——它能看见谁、别人能不能看见它。
如果用一句话把四者的关系串起来:用镜像启动一个容器,给它挂上数据卷,把它接进某个网络。这就是 docker-compose.yml 里那几行在做的事,也是你在配置里能控制的一切。
4.3 容器不是虚拟机:它们差在哪儿
这是本章第一个必须讲透的概念,因为它决定了你对「容器里能看到什么」的全部直觉。
很多人第一次听到 Docker,会把它理解成「轻量一点的虚拟机」。这个类比不算完全错,但它会在最关键的地方误导你。
| 虚拟机(VM) | 容器(Container) | |
|---|---|---|
| 里面跑的是什么 | 一整套完整的操作系统,包括它自己的内核 | 只是一个(组)进程,共用宿主机的内核 |
| 启动要多久 | 几十秒到几分钟(要引导整个系统) | 秒级,甚至更快(就是启动一个进程) |
| 资源开销 | 每个 VM 都要分走一份内存和 CPU | 几乎没有额外开销,容器内进程的开销就是真实开销 |
| 隔离靠什么 | 虚拟化硬件,隔离非常强 | Linux 的命名空间(Namespace)+ 控制组(cgroup),隔离的是「视图」和「配额」 |
| 在 1.6 G 的机器上 | 基本不可能同时跑两个 | 很轻松 |
第三行和第五行解释了为什么我们选了容器:OWL 的服务器只有 1.6 G 内存,虚拟机那套「每个环境都带一个完整操作系统」的做法在这里根本负担不起。而容器只是让 NapCat 这个进程在一个被隔离的「视图」里运行。
而第一行是那个会误导你的地方,值得说两遍:容器共享宿主机的内核,所以容器内的 uname -r 显示的版本和宿主机一模一样。它隔离的不是「有没有内核」,而是「你能看见什么」——你能看见哪些进程、哪些网络接口、哪些文件、以及最多能用多少 CPU 和内存。这就是为什么容器能做「秒级启动」:它根本没有「启动一个操作系统」这个动作。
要点用一句准确的话记住它:虚拟机模拟的是一台计算机;容器隔离的是一个进程所能看到的世界。这句话的后半段,会在下一小节的网络问题上直接兑现。
4.4 网络模式:为什么 NapCat 必须用 host
现在讲这一章最容易让人栽跟头的那个坑。请慢读。
先把我们的结构说一遍(回忆序章 2.1 的那张图):机器人本体是服务器(Server),NapCat 是客户端(Client)。NapCat 主动去连 ws://127.0.0.1:3001/,把 QQ 的消息推给机器人。这个方向不能反——因为机器人不可能知道 NapCat 在哪,而 NapCat 知道机器人在哪。这就是「反向 WebSocket」里「反向」二字的全部含义。
现在有两条路可以走:
| bridge 模式(默认) | host 模式 | |
|---|---|---|
| 容器有没有自己的 IP | 有,由 Docker 在虚拟网桥上分配(通常是 172.17.x.x 之类) | 没有,直接共用宿主机的网络栈 |
容器里的 127.0.0.1 是谁 | 是容器自己(它自成一个网络世界) | 是服务器本机 |
容器能连上宿主机的 127.0.0.1:3001 吗 | 不能。宿主机的那扇门只在宿主机内部开着,容器在另一个网络里,够不着 | 能。因为「宿主机内部」对它来说就是「本机」 |
端口映射 -p 有用吗 | 有用(这是它的主要用法) | 会被忽略,并打印一条警告 |
第二行就是那个坑的全部。我希望你用一句具体的话把它钉住:
在 bridge 模式下,容器里的 127.0.0.1 指的是容器自己,不是宿主机。所以 NapCat 在容器里连 127.0.0.1:3001,它连的是它自己——而它自己身上根本没有程序在听 3001。于是它连不上、不断重连,而你在两边日志里翻半天也看不出到底谁错了。
这个道理不只对 Docker 成立。Docker 官方文档在讲容器 DNS 时有一句特别值得抄下来的说明:--dns=127.0.0.1 指的是容器自己的回环地址。同一个规则,同一个原因:每个网络命名空间里,127.0.0.1 都是一句「我自己」,而「我自己」在不同命名空间里是不同的东西。
那为什么 OWL 不去解决「让容器访问宿主机」这件事(比如用特殊的域名指向宿主机的网关 IP),而要直接用 host?因为 host 模式有一个对我们极其重要的副作用:它让容器里的网络配置和你在服务器上敲命令时看到的完全一致。你在服务器上 ss -tn | grep :3001 能看到的那条连接,就是 NapCat 建立的那条,中间没有经过任何地址转换。排障时这一点价值极大——你不会再问「这条连接到底是从容器里出来的还是从宿主机出来的」。
代价也要说清楚,不能只讲好处:
- 省掉了网络隔离。容器不再是「网络上一座独立的小岛」,它能直接看到宿主机的网络。对一个从第三方拉下来的镜像,这意味着你要更信任它。
- 端口不能映射,也就没有「只暴露我想暴露的那个端口」这种保护。容器里监听的端口,相当于直接监听在宿主机上。所以 NapCat 的面板(6099)必须靠别的方式保护——这正是我们上一节学
SSH隧道的原因。 - 端口冲突变成了真冲突。如果宿主机上已经有别的程序占了 6099,容器里的 NapCat 会起不来,而报错可能只说「地址已被使用」。
- 它基本只在 Linux 上可用。官方的 host 网络驱动在 Linux 上原生支持;Docker Desktop 上要较新版本手动开启,而且特性受限。这也是为什么生产环境是 Linux 服务器,而不是你的 Windows 笔记本。
想一想如果你坚持要用 bridge 模式(比如因为你想给 NapCat 加一层网络隔离),你会怎么让容器里的 NapCat 找到宿主机上的机器人?至少想出两种方案,然后想一想每一种方案会给你的排障带来什么新的麻烦。这个问题的答案没有标准解,但它会让你真正理解「网络模式」这个词在选什么。
4.5 数据卷:为什么登录态、配置必须挂出来
第二条必须钉住的规律是这条:容器里的一切都会随容器消失。
这不是「可能会」,而是设计如此。还记得上一小节的对比吗——容器只是一个可读写的层,叠在只读的镜像之上。当你执行 docker compose down 并且删掉容器,那一层里所有新写的东西(QQ 的登录态、NapCat 的反向 WS 配置、缓存的图片)一起消失。下次你用同一个镜像起一个新容器,它是一台全新的、什么都没记过的机器。
对 OWL 来说,这会直接导致两件很痛的事:
- QQ 登录态没了 → 必须重新扫码,而且是在「腾讯刚把你踢下线」这种最难受的时刻。
- NapCat 的反向 WS 配置没了 → 两边再也连不上,机器人日志会一直停在
等待协议端(NapCat)连接…。
解决方式就是数据卷的另一种常见形态:绑定挂载(bind mount)——把宿主机上的一个目录,「挂」到容器里的某个路径上。之后容器对那个路径的读写,实际上都落在宿主机的硬盘上。容器可以死一百次,数据都还在。
OWL 挂了两处,每一处都有明确的理由:
| 宿主机路径 | 容器内路径 | 里面是什么 | 丢了会怎样 |
|---|---|---|---|
./data/qq | /app/.config/QQ | QQ 登录态、会话数据(实测 158 MB 以上) | 要重新扫码登录 |
./data/napcat | /app/napcat/config | NapCat 自己的配置,包括 OneBot 反向 WS 的地址与 token | 要重新配一次反向 WS |
注意第一行那个数字:158 MB 的登录态。这不是「一个小文件」,这是一整套会话凭证。它是这个容器里唯一无法从镜像重新生成的东西——镜像可以再拉,配置可以再写,但登录态只能靠扫码再换一次。
顺手解释「镜像到底是怎么来的」,因为它解释了为什么容器能秒级启动。镜像是一层一层叠出来的:底层的操作系统文件是一层,装运行时的命令是一层,复制程序文件是一层。描述「每一层做什么」的那个文本文件叫 Dockerfile,把它交给 docker build 就得到一个镜像。分层的好处是复用:你改了最后一层,前面几层不必重新下载;两个基于同一个底层的容器,那个底层在硬盘上只存一份。这就是为什么「容器只比进程多一点开销」不是宣传,而是「共享内核」加「镜像分层」这两件事共同的结果。
要点一条可以套用到所有容器项目的判断标准:凡是「重新生成需要人工介入」的数据,都必须挂出来。登录态要扫码,所以必须挂;配置要有人来填,所以必须挂;而程序代码本身可以从镜像里重新拉,就不必挂。这个标准比死记「要挂哪些目录」有用得多。
4.6 逐行拆解真实的 docker-compose.yml
现在把真实文件摆出来。这是一个 28 行的文件,我们一行一行读。请对照着看你自己的这份文件。
# NapCat 协议端(Docker)
#
# 为什么用 host 网络模式:
# 机器人本体跑在宿主机上(监听 127.0.0.1:3001),NapCat 作为客户端反向连过来。
# host 模式下容器内的 127.0.0.1 就是宿主机,能直连;
# 如果换成 bridge 模式,容器里的 127.0.0.1 是容器自己,会连不上。
#
# 持久化目录(升级/重建容器不会丢数据):
# ./data/qq QQ 登录态、会话数据
# ./data/napcat NapCat 自己的配置(含 onebot11 的反向 WS 配置)
services: # ← Compose 的顶层键:下面描述「这个项目由哪些服务组成」
napcat: # ← 服务名。它同时决定了默认的容器名和网络别名
image: mlikiowa/napcat-docker:latest
# ← 用哪个镜像。冒号后面是标签(tag),latest 表示"最新"
container_name: napcat # ← 显式把容器命名为 napcat。不写的话名字是"项目名_服务名_序号"
restart: always # ← 重启策略:容器退出就再拉起来;Docker 守护进程重启后也拉起来
network_mode: host # ← 网络模式。这一行是整份文件的重点,理由见上面的注释
environment: # ← 环境变量,会在容器启动时注入进程
- NAPCAT_UID=${NAPCAT_UID:-0}
# ← 容器内进程以哪个用户 ID 跑。0 就是 root。
# ${VAR:-默认值} 是 shell 语法:变量没设时用冒号后面的默认值
- NAPCAT_GID=${NAPCAT_GID:-0}
# ← 同上,这是用户组 ID
- TZ=Asia/Shanghai # ← 时区。不设的话容器内常是 UTC,日志时间会差 8 小时
# ACCOUNT 是镜像 entrypoint 认的变量:设了它就会带 -q <QQ号> 启动,
# 即"快速登录"——会话有效时直接恢复登录,不再要求扫码。
# 不设的话每次重启容器都要重新扫码,那样 24 小时在线就是假的。
- ACCOUNT=${BOT_QQ:-1876148307}
# ← 要登录的 QQ 号。这是"重启后不用扫码"的关键
volumes: # ← 数据卷(这里是绑定挂载),把宿主机目录挂进容器
- ./data/qq:/app/.config/QQ
# ← 宿主机 ./data/qq ←→ 容器 /app/.config/QQ
- ./data/napcat:/app/napcat/config
# ← 宿主机 ./data/napcat ←→ 容器 /app/napcat/config
▲ 以上是 OWL 真实的 docker-compose.yml。共 28 行,其中 11 行是注释——而注释本身值得读,因为它们记的正是「为什么这么写」。
下面把几个容易看漏的点单独拎出来。
关于 restart: always。Docker 的重启策略一共有四个取值,官方文档写得清楚:
| 取值 | 含义 | 适合什么场景 |
|---|---|---|
no | 不自动重启(默认值) | 一次性的任务容器 |
on-failure | 只有以非零退出码「出错退出」时才重启,可以限制最多重试几次;Docker 守护进程重启时不会拉起它 | 跑批处理任务、失败需要重试的作业 |
always | 只要容器停了就重启;就算你手动停掉它,Docker 守护进程重启后还是会把它拉起来 | 必须一直活着的服务(我们的选择) |
unless-stopped | 和 always 几乎一样,区别是:你手动停掉之后,即使 Docker 重启也不会拉起它 | 你在开发机上跑、希望手动停了就真停了的服务 |
我们选 always,是因为对 NapCat 来说「我手动停过它」这件事不该有记忆——整机重启后我们要的是它无条件回来。
还有一条只有官方文档里才写着的细节,非常值得知道:
要点重启策略只在容器成功启动过之后才生效。所谓「成功启动」,指容器至少活了 10 秒、并且 Docker 已经开始监控它。这条规则是为了防止一个根本起不来的容器进入无限重启循环。换句话说:如果你的 NapCat 因为配置错误在启动后 2 秒就崩了,它不会疯狂重启,而是安静地停在那里——这恰恰是你想要的(否则日志会被刷爆,真问题也会被淹没)。
关于 ACCOUNT 这一行,它是整份文件里最值钱的一行。官方 NapCat 的 Docker 镜像默认不会自动登录。也就是说,如果你不设这个变量,每次容器重启后你都必须重新扫码——「24 小时在线」就成了一句空话,因为每次服务器重启、每次容器重建,你都得爬起来找手机。
设了 ACCOUNT 之后,镜像的启动脚本会带上 -q <QQ号> 参数启动,也就是「快速登录」:如果本地还存着有效的会话,它就直接恢复登录,不需要扫码。这就是实测记录里那句话的来源:
整机重启测试:
[10:09:56] 🤖 OWL 已启动 ← 开机 9 秒后
[10:10:05] 🔗 协议端已连接 (127.0.0.1) ← 10 秒内
[10:10:05] ✅ 机器人已登录: 1876148307 (OWL)
→ 无需扫码、无需人工干预
▲ 这段实测记录是「重启后 10 秒全自动恢复」这个说法的原始证据。请特别注意:这套能力建立在「登录态还有效」这个前提上。如果腾讯已经把会话作废了,快速登录会失败——我们第七节会专门讲那种情况。
关于 TZ。这一行看起来最无聊,却直接对应我们前面讲过的那个真实坑。容器默认时区常常是 UTC,而服务器在 Asia/Shanghai。时区不一致的后果是:NapCat 的日志时间和你的日志时间差 8 小时。当你在排查「她什么时候开始不回消息」时,两个相差 8 小时的时间线足以让你得出完全错误的结论。
关于 NAPCAT_UID / NAPCAT_GID。这两个变量决定容器内的进程以哪个用户身份运行,默认 0(也就是 root)。写成 ${NAPCAT_UID:-0} 这种形式的好处是:你可以在服务器上建一个 .env 文件覆盖它,不必改这份 compose 文件。这就是「最小权限原则」在这里的抓手——先把「怎么改」准备好,再考虑「要不要改」。
4.7 docker compose up -d 到底做了什么
现在解释那句你会在文档里看到十几次的命令。
docker compose up -d
▲ up 是「把这份 compose 文件描述的所有服务,变成正在运行的容器」;-d 是 detached,意思是「在后台跑,不要把日志刷在我的终端里」。
up 具体做了这些事,顺序很重要:
- 读
docker-compose.yml,解析出服务、镜像、网络、卷。 - 检查本地有没有
mlikiowa/napcat-docker:latest这个镜像。没有就去镜像仓库拉(第一次大约 1–2 分钟)。 - 创建容器:把卷挂好、环境变量注入好、按
network_mode接好网络。 - 启动容器,并把它交给
restart: always这条策略看管。
第 2 步值得多说一句,因为它解释了一个常见困惑:up 并不会自动更新镜像。如果你在 latest 已经指向新版本之后跑 up -d,本地有旧镜像时它会直接用旧的。想真的更新,得先 docker compose pull。这是「为什么我升级了还是老版本」这类问题最常见的答案。
4.8 容器常用命令表
下面这几条命令覆盖了 95% 的日常操作。每一条我都写清「为什么需要它」和「出错时它告诉你什么」——因为命令的意义在于它回答什么问题,而不是它的语法。
| 命令 | 它回答什么问题 | 典型输出 / 出错时你会看到什么 |
|---|---|---|
docker compose ps | 这个项目的容器现在是什么状态? | Up 20 hours / Restarting (1) 3 seconds ago / 什么都没有(= 容器根本没创建) |
docker compose logs -f --tail=100 napcat | 容器里到底在说什么? | -f 是持续跟随(像 tail -f),--tail=100 是只先给我最后 100 行。不加 --tail 会把全部历史刷出来 |
docker compose exec -it napcat sh | 进到容器内部去看一眼(它真的有那个文件吗?网络通吗?) | 成功会给你一个容器里的 shell;失败会报 container is not running |
docker compose restart napcat | 重启容器(配置改了但不想 down 再 up) | 注意:这只重启容器,不会重新读 compose 文件里改动的部分 |
docker compose down | 停止并删除容器(数据卷挂在宿主机上,不会丢) | down 之后再 up -d 得到的是一个全新的容器,但它读同一份挂载数据 |
docker compose up -d | 按 compose 文件创建并启动(后台) | 若端口被占用,会报 address already in use;host 模式下这会直接让 NapCat 起不来 |
docker compose pull | 把镜像更新到远端最新(升级的正确第一步) | 失败通常是网络问题——国内服务器需要镜像加速 |
docker inspect -f '{{.State.Status}}' napcat | 只问状态字段(适合写进脚本) | 输出 running / exited / missing(容器不存在) |
最后一条正是健康检查脚本里用的形式。如果你不熟悉 -f '{{...}}' 这种写法,可以把它理解为「不要给我一大坨 JSON,我只要这个字段」。它的价值在于可以被程序读取——健康检查不能靠人眼看,它要靠脚本判断。
技巧排障时有一个组合非常有用:先 docker compose ps 看状态,再 docker compose logs --tail=50 napcat 看它最后说了什么。这两条命令几乎可以定位「容器层」一半以上的问题。而它们的输出,也是你在网上提问时最该贴出来的东西。
5. NapCat 协议端:它到底做了什么
到目前为止,我们一直在讲「怎么让它跑起来」。但有一件事比这个更根本:NapCat 到底是什么?如果你不清楚它在做什么,那你就永远无法判断它的某个行为是「正常」、「可以修」还是「本来就修不了」。
5.1 它不是「破解」,它是在模拟一个 QQ 客户端
先说清楚一件必须说清楚的事,因为它同时是技术问题也是合规问题。
NapCat 是一个第三方实现的 QQ 协议端。它做的事情是:在本地运行一套 QQ 的客户端内核,由它来实现腾讯的通信协议,从而收发真实的消息。
用一句话说清它在架构里的位置:它一头连着腾讯的服务器(用 QQ 自己的协议),另一头连着你的机器人(用 OneBot 协议)。它是一个翻译层——把 QQ 的世界翻译成程序能读的 JSON,再把你的命令翻译回 QQ 的动作。
腾讯 QQ 服务器
▲ │
QQ 协议 │ │ (你无法控制的这一层:风控、踢人、验证)
│ ▼
┌─────────────────────┐
│ NapCat │ ← 装在 Docker 容器里
│ 维持 QQ 在线 │ ← 用你的登录态
│ 收发 QQ 消息 │
└──────────┬──────────┘
│ OneBot v11 反向 WebSocket(ws://127.0.0.1:3001/)
▼
┌─────────────────────┐
│ OWL 本体(Node.js)│ ← 你写的程序
│ 决定这条消息怎么办 │
└──────────┬──────────┘
│ HTTPS
▼
api.deepseek.com
▲ 把这个图和序章 2.1 的图对照看:它们讲的是同一件事。区别是现在你应该能读出每一条边分别是什么协议、由谁实现。
它不是「破解了 QQ」——它没有绕过密码,也没有伪造凭证,它用的是你真实的账号和你真实的登录。但它也不是腾讯官方的客户端,所以它处在一个不受腾讯支持、且腾讯有明确反机器人机制的位置上。这个位置决定了它所有的「不稳定」都是结构性的,而不是 bug。
5.2 登录态持久化在哪,为什么它这么重要
你已经知道它挂在 ./data/qq。现在说说里面那个 158 MB 是什么,以及为什么它既是「省事的关键」也是「故障的关键」。
「登录态」是一整套凭证:证明「这个客户端此刻是已登录的本人」所需的一切。扫码是这套凭证的获取过程。
它有两个独立的生命周期,这正是所有困惑的来源:
- 本地这份文件的生命周期。由你掌控——只要你不删
data/qq、不重建容器不挂载,它一直在。 - 服务端那份会话的生命周期。由腾讯掌控——它随时可以在它那边判定这次登录不再有效。
当两者一致时,一切正常;当本地文件还在、但服务端已经不认了,就会出现本章第八节要详细复盘的那个最阴险的现象:「假活」——所有本地指标都正常,但消息根本进不来。
想一想如果「本地文件还在」并不能保证「服务端还认」,那么一个只检查「文件存不存在」的监控,和一个检查「消息能不能真的送达」的监控,它们会在什么时候给出不同的答案?这个问题是本章第八节的核心。先自己想一遍,再往下读。
5.3 扫码与快速登录:两条不同的路径
这两件事经常被混为一谈,但它们差别很大。
| 扫码登录 | 快速登录 | |
|---|---|---|
| 什么时候用 | 第一次部署;登录态失效之后 | 每次进程/容器/服务器重启 |
| 谁发起 | 你在 NapCat 面板里点「QQ 登录」→ 手机 QQ 扫 | 容器启动时自动(靠 ACCOUNT 环境变量) |
| 需要人工吗 | 需要,而且必须在二维码失效前完成 | 不需要,只要本地登录态还有效 |
| 失败会怎样 | 过一会儿二维码过期,要重新出码 | 日志出现「身份已失效」,此时只能回到扫码 |
第四行后半句很重要,值得重复一遍:快速登录失败之后,没有办法「重试到成功」。因为失败的原因不在你的机器上,而在腾讯那边——它已经决定这个会话不算数了。改配置、重启、改环境变量都不会有用,只有重新扫码能解决。这一条认知能帮你省下大量无效折腾的时间。
警告还有一条极其重要的纪律,我们会在第七节再强调一次:绝对不要在服务器运行时,又在别处(比如你的笔记本)启动第二个 NapCat 登录同一个 QQ 号。两个实例会互相抢同一个会话,而这极可能直接导致腾讯把登录态作废——你会亲手制造出那个「假活」故障。要在本地调试,就先把云端停掉,或者换一个小号。
5.4 它的配置文件:反向 WS 到底配了哪些字段
NapCat 的 OneBot 配置有两个东西要分开看:面板里的图形界面,和它背后真正写下的 JSON。图形界面只是那个 JSON 的编辑器。理解了 JSON,你就不会再被界面迷惑,也能直接检查它到底写成了什么。
真实文件(data/napcat/onebot11_1876148307.json,为了可读做了缩进整理)长这样:
{
"network": {
"httpServers": [], // 不用 HTTP 服务端模式:我们的机器人不主动去拉消息
"httpClients": [], // 不用 HTTP 客户端模式:HTTP 太麻烦,且不好做长连接
"websocketServers": [], // 不用正向 WebSocket:那是"NapCat 当服务端等人来连"
"websocketClients": [ // ★ 我们用的是这一个:反向 WebSocket,NapCat 当客户端
{
"name": "local-bot", // 配置的名字,只用来在面板里区分
"enable": true, // 必须为 true。false 时它不会去连,而机器人只会一直等
"url": "ws://127.0.0.1:3001/", // ★ 要连的目标。host 网络模式下这就是宿主机
"messagePostFormat": "array", // 消息段用"数组"格式而非 CQ 码字符串
"reportSelfMessage": false, // 不上报自己发的消息。为 true 会造成自我循环的风险
"reconnectInterval": 3000, // 断线后每 3000 毫秒重连一次(3 秒)
"token": "<你的 server.token>",
// ★ 鉴权口令。必须和 bot/config.json 的 server.token 完全一致
"debug": false, // 关掉调试输出,否则日志会被刷屏
"heartInterval": 30000 // 心跳间隔 30 秒,用来探测连接是否还活着
}
],
"plugins": []
},
"musicSignUrl": "",
"enableLocalFile2Url": false, // 不允许把本地文件路径转成 URL(安全考虑)
"parseMultMsg": true // 解析合并转发的消息
}
▲ 三个带 ★ 的字段就是「连不上」的全部原因所在:websocketClients 必须被启用、url 必须指向机器人、token 必须两边一致。剩下的字段决定的是「连上之后体验如何」。
警告这里故意不写真实值。上面那个 token、后面面板要用的访问口令、以及服务器的公网 IP,我全部换成了占位符。原因很直接:凡是能直接用来登录或鉴权的东西,都不该出现在任何文章、截图或聊天记录里。这不是洁癖——一个 token 泄露就等于有人能冒充协议端给你的机器人喂消息;一个面板口令泄露就等于有人能登你的 QQ;一个公网 IP 泄露就等于把扫描器的目标从「全网」缩小到「这一台」。你要在自己写笔记、发帖求助、给朋友看配置时,养成同一个习惯。
关于 token,它对应 OneBot v11 规范里的 access token。规范写得很明确:配置了 token 之后,反向 WebSocket 客户端在建立连接时会带上一个请求头 Authorization: Bearer <你的 token>。而机器人这一侧的校验逻辑也是按这个约定写的:
wss.on("connection", (ws, req) => {
// 鉴权:云端部署时 3001 端口可能对公网开放,没有 token 等于谁都能冒充协议端。
// OneBot 约定:token 通过 Authorization: Bearer xxx 或 ?access_token=xxx 传入。
const token = cfg.server?.token || "";
if (token) {
const auth = req.headers["authorization"] || "";
let provided = auth.replace(/^Bearer\s+/i, "").trim();
if (!provided) {
// 没走请求头的话,再试一下 query 参数 ?access_token=xxx(规范里的备用方式)
provided = new URL(req.url || "/", "http://localhost")
.searchParams.get("access_token") || "";
}
if (provided !== token) {
log(`🚫 拒绝未授权连接(token 不匹配),来源 ${req.socket.remoteAddress}`);
ws.close(1008, "unauthorized"); // 1008 = 策略违规,直接关掉
return; // ← 注意:直接返回,后面的连接逻辑根本不会执行
}
}
// ……通过了鉴权,才开始处理消息
});
▲ 以下是真实代码的摘录(bot/bot.js,省略了少量分支与注释)。它的设计意图很直接:没有 token 的人不许连进来。
这段代码有三个点值得你学:
- 它接受两种传 token 的方式。这是照着 OneBot 规范来的:标准做法是
Authorization头,但有些客户端不方便改头,规范允许退回?access_token=查询参数。兼容规范的好处就是——你不用为每个客户端写一套适配。 - 校验失败时它主动关闭连接,并写一条明确的日志。这条日志是排障的灯塔:一旦你在机器人日志里看到
🚫 拒绝未授权连接,你就知道问题不是网络、不是端口、而是两边的 token 不一样。这比「连不上但什么都不说」友好一百倍。 - 它在做「最小权限」的思考:即使端口只监听 127.0.0.1,也要有鉴权。代码注释里写了理由——万一以后端口被暴露了,token 是最后一道门。这个思路值得你抄到自己的项目里:不要依赖「它不会被暴露」这种假设来做安全设计。
5.5 它的日志在哪,怎么看
NapCat 的日志要通过 Docker 看,因为它运行在容器里:
# 最常用:持续跟随 NapCat 日志,先给最后 100 行
docker compose logs -f --tail=100 napcat
# 找 WebUI 的登录 token(要进面板时用)
docker compose logs napcat | grep -i token
# 只看最近的,并确认容器是不是在反复重启
docker compose logs --tail=50 napcat
docker compose ps
▲ 注意 logs 与「容器内某个文件的日志」是两件事。容器日志是进程写到标准输出/标准错误的那部分,由 Docker 接住;而 NapCat 自己可能还往容器内某个文件写日志。用 logs 是最省事、也最常用的一层。
顺便解释一下「容器日志」这个设计,因为它和我们后面的磁盘事故直接相关:Docker 默认把容器日志存成 JSON 文件,放在宿主机上,而且默认没有大小上限。这就是为什么「磁盘被日志写满」是一个真实会发生的生产事故——一个疯狂刷日志的容器可以在几个小时里吃掉几十 G。所以我们在部署时加了两道限制(第八节细讲):
{
"log-driver": "json-file",
"log-opts": { "max-size": "10m", "max-file": "3" }
}
▲ 写进 /etc/docker/daemon.json。含义:每个容器日志最多 10 MB,最多保留 3 个文件(含当前文件),也就是单个容器的日志最多占 30 MB。超过就轮转,老的自动删除。这一行 8 个字符的配置,抵得上一次磁盘事故。
5.6 风险与合规边界:必须诚实说清楚的部分
这一小节不写代码,但它是整个第五章到第七章里最重要的部分之一。因为它回答的不是「怎么做」,而是「这么做的代价是什么」。
项目里有一份专门的文档记录这件事,标题就很直白。它的结论一句话:反复掉线不是配置错误,是第三方协议端的固有限制——腾讯会主动把这类登录踢下线。
文档里记了两次真实的掉线,证据都是日志原文:
| 时间 | 现象 | 日志证据 |
|---|---|---|
| 第一天 21:59 | 登录态失效 | 快速登录错误:你的用户身份已失效,为保证账号安全,请你重新登录。 |
| 第二天 11:25 | 被主动踢下线 | [KickedOffLine] [下线通知] 你的账号当前登录已失效,请重新登录。 |
第二行是决定性的,因为它是腾讯服务端主动发过来的通知。同一时期的健康指标全部正常:容器连续运行 20 小时零重启、没有 OOM、内存充足、磁盘只用了 23%。所以这次掉线和服务器、网络、代码都无关。实测的两次掉线间隔约 13 小时。
为什么会这样?因为 NapCat 不是官方客户端,而腾讯有明确的反机器人机制,会检测并处理这类登录。这不是「你的用法有问题」那么简单,也不是「换个参数就能绕过去」——NapCat 官方仓库里就有这个已知问题,社区里的常见表现是:能登录、能用一段、然后被踢;重扫之后过几小时到一天又被踢。
文档里列了三条路,并给了判断,我认为那个判断是对的,值得完整地讲给你:
| 方案 | 成本 | 代价 | 判断 |
|---|---|---|---|
| A. 继续用,接受掉线 | 0 元 | 平均每天要重新扫一次码;有学生深夜需要人说话时她可能刚好不在 | 当前状态。它是一个合理的工程选择,但必须以「已知的、被接受的」方式存在,而不是自欺欺人地假装它不会发生 |
| B. 换腾讯官方机器人平台 | 要注册开发者、过审核 | 功能受官方限制(一对一私聊自由度较低,目前主要是群聊场景) | 推荐的长期方案。完全合规、不会被踢、不会封号 |
| C. 用规避手段降低被检测概率 | 看具体手段 | 例如把 QQ 密码交给容器,或用旧版 QQ | 不值得做。安全风险远大于「每天扫一次码」,而且规避风控可能把「被踢」升级成「被限制登录」甚至「封号」 |
第三行那个判断里有一句我认为你应该记住的话:把 QQ 密码交给容器,风险远大于每天扫一次码。这是一个典型的工程权衡——你为了省一点麻烦,去承担一个不可逆的风险。工程判断力的核心,就是能分辨「哪种麻烦值得忍」和「哪种便宜不能占」。
还有一条更硬的提醒,来自同一份文档:
反复被风控的账号,有被「限制登录」甚至「封号」的风险。如果这个 QQ 号对你重要,不要长期拿它跑协议端。两个选择:专门用一个小号(能承受被封的号),或者改用官方平台。
这句话应该让你停下来想一分钟。你正在做的事,是在一个别人定规则的地方运行程序。你能控制的是代码质量、频率、日志和降级方案;你不能控制的是腾讯的判定。把「我不知道它什么时候会掉」变成「我知道它一定会掉,所以我把恢复成本压到五分钟」——这是运维思维和「祈祷它别坏」之间的分界线。
要点顺便说一句为什么这里要专门提醒「用小号」。这不是技术问题,是责任问题。如果那个 QQ 号是你的私人号、绑着支付、连着一堆关系和记录,那你就是在拿一个不可替代的东西去承担一个可预期的风险。工程决策从来不只是技术决策。
6. systemd:把程序变成「系统的一部分」
现在 NapCat 这一头解决了。剩下机器人本体——一个普通的 Node.js 程序,怎么让它在服务器上一直活着?
6.1 为什么不能靠 nohup node bot.js &
你在第一章学过:命令后面加 & 可以让它到后台去跑。那么问题来了:
nohup node bot.js &
▲ 这行命令的意思大致是「忽略挂断信号(nohup),把它放到后台跑(&)」。很多人第一次让程序在服务器上「常驻」,用的就是它。
它确实能解决一个具体问题:你把 SSH 窗口关掉,程序不会跟着死。但它解决不了三个更大的问题,而这三个问题恰好就是运维的全部内容:
| 问题 | nohup ... & 会怎样 | 为什么这是致命的 |
|---|---|---|
| 服务器重启了 | 程序不会回来。它是「你手动启动的一个进程」,系统根本不知道它的存在 | 云服务器会因维护、迁移、欠费停机、内核升级而重启。每次都意味着你要手动登上去敲一遍命令——而你未必知道它重启过 |
| 程序自己崩了 | 没有东西会拉起它。它死了就是死了 | 一个依赖第三方协议和外部 API 的程序,崩溃是常态而不是意外。你的机器人会在某个深夜安静地消失 |
| 你想知道它现在怎样 | 你得自己 ps、自己找 PID、自己看日志文件在哪 | 没有任何统一入口。三个月后你甚至不记得当初是用哪条命令启动的 |
换句话说,nohup node bot.js & 是把「让程序活着」这件事继续留在你脑子里。而服务器上的第一原则是:你不在场,所以这件事必须交给系统。这个「系统」在 Linux 上叫 systemd,而它用来描述「怎么活着」的文件叫服务单元(unit)。
你可能已经想到了:在第五章(Node.js 与异步世界)里,「进程」是你的程序在机器上的形状;而到了生产环境,「进程」开始需要一份「由谁看管」的说明。这份说明就是接下来要逐行读的东西。
6.2 逐字段拆解真实的 qqbot.service
真实文件是部署脚本生成的,路径在 /etc/systemd/system/qqbot.service。下面是它的完整内容,我们逐字段读。
[Unit] # ← 这一节回答"你是谁、你和别人的关系"
Description=OWL QQ Bot (OneBot v11 reverse WebSocket server)
# ← 人类可读的描述。systemctl status 会把它显示在顶部
After=network-online.target
# ← 顺序依赖:等网络"真正可用"之后再启动我
Wants=network-online.target
# ← 弱依赖:我想要网络在线,但就算它没起来也别拦着我
[Service] # ← 这一节回答"这个程序怎么跑"
Type=simple # ← 启动方式:ExecStart 启动的就是主进程,fork 出来就算启动完成
WorkingDirectory=$SCRIPT_DIR/bot
# ← 工作目录。★ 为什么必须写:程序里所有相对路径(config.json、
# bot.log、history.json)都是相对这个目录解析的。
# 不写的话工作目录是 /,于是"找不到 config.json"
Environment=QQBOT_DATA_DIR=$SCRIPT_DIR/data/botdata
# ← 注入一个环境变量,告诉程序"你的数据该放哪"
Environment=NODE_ENV=production
# ← 约定俗成的"我是生产环境"标记,很多库会据此调整行为
ExecStart=$(command -v node) $SCRIPT_DIR/bot/bot.js
# ← ★ 真正要执行的命令。用 node 的绝对路径而不是"node",
# 因为 systemd 的 PATH 和你登录时的 PATH 不一样
Restart=always # ← ★ 崩溃自动重启:无论退出码是什么,都拉起来
RestartSec=5 # ← 重启前等 5 秒。不加的话崩溃循环会把日志和 CPU 刷爆
StandardOutput=append:$SCRIPT_DIR/data/botdata/bot.log
# ← 标准输出追加写入这个文件。这就是"日志去哪了"的答案
StandardError=append:$SCRIPT_DIR/data/botdata/bot.err.log
# ← 标准错误单独一个文件。出错信息和普通输出分开,排障时省事
[Install] # ← 这一节回答"开机时该怎么处理我"
WantedBy=multi-user.target
# ← 表示"当系统进入多用户模式(也就是正常可用状态)时,把我带起来"。
# 这是 systemctl enable 能生效的前提
▲ 真实的 qqbot.service($SCRIPT_DIR 是部署脚本自动填入的实际路径)。去掉分段标题和注释,这个文件里一共只有 13 行有效配置,但它同时做到了「开机自启」「崩溃自拉起」「日志落盘」「数据与代码分离」四件事。
下面把几个最值得琢磨的字段单独讲。
关于 WorkingDirectory:为什么它是初学者最常踩的坑。程序里那些「相对路径」——比如 readJson("config.json")、fs.appendFileSync("bot.log")——它们到底相对于谁?答案是:相对于进程的工作目录。你在终端里手动跑 node bot.js 时,工作目录就是你 cd 到的地方,所以一切都对。但 systemd 启动你的进程时,默认工作目录可能是 /。于是同一个程序、同一份代码,报出的错却是「找不到 config.json」。
这类问题的形状很典型:现象看起来像「文件丢了」,实际原因是「找的地方不对」。当你以后遇到任何「明明文件在,程序却说找不到」的情况,第一个该查的就是工作目录。
关于 Environment=QQBOT_DATA_DIR:为什么要把数据独立出去。这一行背后是一条很朴素但极有价值的工程纪律:代码和数据要分开,因为它们的生命周期完全不同。
想想你的更新流程:改完代码后,你把新的 .mjs 文件覆盖到服务器的 bot/ 目录里。如果聊天历史(history.json)和对每个人的记忆(memory.json)也放在 bot/ 里,那么「覆盖代码」和「保留记忆」这两件事就会打架——你一个 rsync --delete 或者一个 git clean,就能把几百个人的记忆清空,而且不会有任何报错。
这个程序设计得很清楚:程序读一个环境变量 QQBOT_DATA_DIR,如果设了就把所有数据写到那里,没设就退回程序自己所在目录(方便本地开发)。看真实的代码:
这里其实正是第六章(数据库与记忆)那件事的运维侧面:那一章讲「记忆怎么存、怎么查、活多久」,这一节讲「记忆的文件放在哪个目录,才不会被一次代码更新带走」。
const HISTORY_FILE = "history.json";
const MEMORY_FILE = "memory.json";
const DATA_DIR = process.env.QQBOT_DATA_DIR || null; // ← 没设就退回程序目录
▲ 摘自 bot/llm.mjs。这三行就是「同一份代码,本地开发和生产部署都能用」的实现方式:用环境变量把「配置」从「代码」里抽出来。这个思路你在第五章见过一次,现在它有了具体的用途。
再看部署脚本里那句注释,它把这个意图写得很白:把 history/memory 放到 data/botdata,这样更新代码不会丢记忆。所以你现在应该能理解那句文档里的话了——data/botdata 是特意分离出来的。
关于 Restart=always 与 RestartSec=5。这两个要一起看。Restart=always 保证崩溃后被拉起来;RestartSec=5 保证它不会「崩—起—崩—起」地高速循环。一个真实的例子:假如你把 config.json 改坏了(漏了一个引号),程序启动时会解析配置失败并退出。没有 RestartSec 的话,systemd 会在几秒钟内重启它上千次,日志被同一句报错刷满,你连「它为什么崩」都看不清。
要点一个容易被忽略的推论:「自动重启」会掩盖问题。一个每分钟崩溃一次的机器人,靠 Restart=always 看起来「一直在运行」,但它的实际可用率可能只有 90%,而你可能一个月都不会发现。这就是为什么后面必须配健康检查和日志——自动重启是止血,不是诊断。
关于日志那两行。StandardOutput=append:... 表示「把进程写到标准输出的内容,追加到指定文件」。这里有两个设计决定值得学:
- 为什么分成
bot.log和bot.err.log两个文件?因为普通日志和错误日志的信噪比完全不同。你日常tail -f bot.log想看的是「谁说了什么」;而当你怀疑出了问题时,bot.err.log通常更短、更集中。分开写等于天然做了一次分类。 - 为什么用
append:而不是覆盖?因为你需要历史。一个已经重启过的服务,如果每次重启都清空日志,那你就永远只能看到「重启之后」的世界,看不到导致重启的那个瞬间。
6.3 enable 与 start:两件完全不同的事
这是 systemd 里最容易被合并成一件事的两个命令。请把它们彻底分开记。
| 命令 | 它做的事 | 管的是哪种「活着」 |
|---|---|---|
systemctl start qqbot | 现在就启动它 | 这一次运行 |
systemctl enable qqbot | 建立开机时的启动链接(依据 [Install] 段的 WantedBy) | 下一次开机 |
systemctl disable qqbot | 取消开机启动,但不影响当前正在运行的实例 | 下一次开机 |
systemctl restart qqbot | 停掉再启动(改完配置后用这个) | 这一次运行 |
systemctl is-active qqbot | 只回答「现在是不是 active」,输出可以直接被脚本判断 | 用于健康检查 |
systemctl is-enabled qqbot | 只回答「开机自启有没有开」 | 用于审计 |
所以「开机自启 + 崩溃自动重启」这两句话,其实分别由两个完全不同的机制保证:
开机自启由 systemctl enable(配合单元里的 [Install] WantedBy=multi-user.target)保证。它管的是「系统启动时要不要拉起这个服务」。
崩溃自动重启由单元里的 Restart=always + RestartSec=5 保证。它管的是「服务在运行期间死掉之后要不要拉回来」。
两者互不替代。只 enable 不 Restart,它崩了就永远躺下了;只 Restart 不 enable,它跑得好好的,一重启服务器就再也不出现。
还有一条纪律必须记住:改了单元文件的任何内容,都要先 systemctl daemon-reload。因为 systemd 读到内存里的是一份缓存,你改文件它不会自动知道。忘了这一步的典型现象是:你改了 RestartSec,重启服务,发现行为一点没变——因为 systemd 还在用旧的那份定义。这个坑的可怕之处在于它不报错,只是安静地不生效。
6.4 napcat.service:用 docker compose 当服务
NapCat 那一侧的情况不同:它不是一个可以直接执行的前台进程,而是「先执行 docker compose up -d 把容器启动起来,然后命令就返回了」。这种「执行一下就结束」的服务,在 systemd 里要用另一种写法:
[Unit]
Description=NapCat (QQ protocol end, docker compose)
Requires=docker.service # ← 强依赖:没有 Docker 我就没法工作,它起不来我也别起
After=docker.service network-online.target
# ← 顺序:等 Docker 和网络就绪之后再执行我
[Service]
Type=oneshot # ← ★ 关键:这个服务的"主进程"执行完就退出,不是常驻的
RemainAfterExit=yes # ← ★ 关键:命令退出之后,仍然把本服务记为 active
WorkingDirectory=$SCRIPT_DIR # ← docker compose 要在有 docker-compose.yml 的目录里执行
ExecStart=/bin/sh -c 'cd $SCRIPT_DIR && $DC up -d'
# ← 启动动作:拉起容器。注意要显式 cd,不靠 WorkingDirectory 兜底
ExecStop=/bin/sh -c 'cd $SCRIPT_DIR && $DC down'
# ← 停止动作:systemctl stop napcat 时把容器也停掉、删掉
TimeoutStartSec=0 # ← 不限启动超时:首次拉镜像可能要一两分钟,别被判超时失败
[Install]
WantedBy=multi-user.target
▲ 真实的 napcat.service。$DC 是部署脚本探测出来的 compose 命令(docker compose 或 docker-compose)。
这里最值得理解的是 Type=oneshot 和 RemainAfterExit=yes 这对组合,因为它们的搭配不是可选的,而是必须的。
先看 systemd 官方对 Type=oneshot 的定义:它的行为类似 exec,但「服务管理器会在主进程退出之后认为这个单元已启动」。它特别适合 RemainAfterExit=。而官方同时警告了一件事——如果用了 oneshot 却不设 RemainAfterExit,那么这个服务永远不会进入 active 状态,它会直接从 activating 走到 dead。
这句话翻译成现象就是:你 systemctl start napcat,容器明明起来了、跑得好好的,但 systemctl status napcat 却告诉你它是死的(inactive/dead)。这不是 bug,这是缺少 RemainAfterExit=yes 的必然结果。而这个现象极其误导——你会以为服务没启动,于是反复 start,实际上容器每次都被重新拉了一遍。
要点这里有一个诚实的说明必须给你。Docker 官方文档其实不建议把 Docker 的重启策略和宿主机的进程管理器(比如 systemd)混用,原话是「这会造成冲突」,并且推荐只用重启策略。而 OWL 的做法是:容器里写 restart: always,外面又套了一个 oneshot 的 systemd 单元。严格来说,它确实踩在这条建议的边上。
那为什么还这么写?因为这两者在这里各管一半,并没有真正打架:restart: always 负责「容器进程意外退出后拉回来」,systemd 的 oneshot 单元负责「开机时把这份 compose 项目带起来」和「关机时优雅地把它停掉」。systemd 单元并不监控容器的运行状态,所以它不会和 Docker 的重启策略抢方向盘。但要清楚这是「权衡后的选择」,不是「最佳实践」。如果你要在一个更严肃的系统里做这件事,更干净的做法是让 systemd 直接管理容器(一个容器一个 unit),把重启的责任只留给一边。
6.5 常用命令表
| 命令 | 它回答什么问题 | 你会看到什么 |
|---|---|---|
systemctl status qqbot | 这个服务现在活着吗?它最近发生了什么? | 顶部是 Active: active (running) 或 failed;下面会附上最近几行日志(这就是「一眼看个大概」的入口) |
systemctl start / stop / restart qqbot | 现在启动 / 停止 / 重启 | 没有输出就是成功(Unix 哲学:沉默即正常) |
systemctl enable / disable qqbot | 要不要开机自启 | 成功的提示是「Created symlink /etc/systemd/system/multi-user.target.wants/qqbot.service」 |
systemctl daemon-reload | 「我改了单元文件,重新读一遍」 | ★ 改完单元文件必须执行,忘了会安静地不生效 |
systemctl is-active qqbot | 只回答一个词,方便脚本判断 | active / inactive / failed。健康检查脚本里用的就是 is-active --quiet |
journalctl -u qqbot -n 50 | 看这个服务的日志(最近 50 行) | 带时间戳、进程号、优先级的结构化日志 |
journalctl -u qqbot -f | 持续跟随(相当于 tail -f) | 适合「我现在就要看着它做事」的场景 |
journalctl -u qqbot --since "10 min ago" | 只看最近十分钟 | ★ 比 tail -n 更准:按时间而不是行数筛选 |
journalctl -u qqbot -b | 只看本次开机以来的日志 | 排查「重启之后发生了什么」时的第一选择 |
技巧注意最后三条:journalctl 支持按时间范围和按启动会话过滤,这是 tail 做不到的。tail -n 100 回答的是「最后 100 行是什么」,而 --since "10 min ago" 回答的是「十分钟里发生了什么」。排障时你真正想问的通常是后者。
7. 上线全流程:把 install.sh 讲成一个故事
前面六节把每个零件都拆开了,现在把它们按真实顺序拼回去。这一节的结构是:每一步做什么 → 为什么是这个顺序 → 如果这步错了,你会看到什么现象。第三列最重要,因为它才是你在真正出错时能用的东西。
7.1 第 0 步:拿到一台空服务器
你买了一台 Ubuntu 22.04 的轻量服务器,安全组只开了 22,然后用密钥登录进去了。现在你面对的是一个空系统:没有 Node,没有 Docker,什么都没有。
第一件事不是装软件,而是想清楚你要把部署包放在哪个目录。OWL 选的是 /root/owl。这个选择会在后面的每一处配置里出现(WorkingDirectory、日志路径、挂载路径),所以从一开始就定下来,不要中途搬。搬家意味着所有绝对路径都要改,而漏改一处就是一个「安静不工作」的故障。
7.2 第 1 步:装 Node.js 20,并检查版本
# 添加 NodeSource 的软件源,然后从系统包管理器安装
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt-get install -y nodejs
# 装完立刻验证:不要相信"装完了",要看到版本号
node -v # 期望 v20.x
▲ 这两条命令在真实脚本里被拆成了「先检查有没有、没有才装」的形式,因为脚本要求可重复执行(幂等)。你手动做的时候照抄这两行即可。
为什么必须是 20?不是强迫症。项目代码里用了较新的语法(例如 ??= 空值赋值、顶层 await、.mjs 模块),旧版本 Node 会直接抛语法错误。真实脚本里的判断是:低于 18 直接拒绝执行,推荐 20。这个「版本门槛」你要养成为习惯——它是环境一致性的第一道闸。
这步错了会看到什么:如果 Node 没装,脚本第一句就 die(打印红色提示并退出),还会贴出安装命令给你。如果版本过低,你会看到「Node 版本过低(当前 v16.x),需要 18 以上,推荐 20」。而如果你跳过检查直接跑程序,典型现象是一句语法错误或者「不支持的特性」。
7.3 第 2 步:装 Docker 与 compose 插件
# 官方一键脚本装 Docker(含 compose 插件)
curl -fsSL https://get.docker.com | sudo sh
# 国内服务器如果慢,用阿里云镜像
curl -fsSL https://get.docker.com | sudo sh -s docker --mirror Aliyun
# 验证三件事,缺一不可
docker --version
docker compose version # 注意是 "docker compose",中间是空格(新版插件)
systemctl is-active docker # Docker 守护进程本身在跑吗
▲ 真实脚本还多做了一件事:写 /etc/docker/daemon.json 配置镜像加速,并顺手加上容器日志限额。这两件事必须在拉镜像之前做,否则第一次拉 NapCat 镜像可能慢到让你以为卡死了。
为什么要配镜像加速?因为镜像默认从境外的镜像仓库(Docker Hub)拉取,国内服务器直连常常很慢甚至超时。加速的做法是在 daemon.json 里列几个国内的镜像地址,让 Docker 优先从那里取。这不是「优化」,在很多国内机器上它是「能不能拉下来」的前提。
为什么脚本要花力气探测 docker compose 还是 docker-compose?因为这是两个不同的东西:新版是 Docker 的插件,命令带空格(docker compose);老版是独立的 Python 程序,命令带横杠(docker-compose)。写脚本时两者都要兼容,否则在别人的机器上就会「明明装了 Docker 却说缺少 compose」。
这步错了会看到什么:没有 Docker,脚本会打印安装命令然后退出。有 Docker 但没有 compose 插件,会提示「缺少 docker compose 插件。请安装 docker-compose-plugin」。而如果 Docker 装了但守护进程没启动,docker ps 会报「Cannot connect to the Docker daemon」——这句话的意思是「客户端在,但服务端没在听」,和你第一章学的「端口没人监听」是同一类诊断。
7.4 第 3 步:把服务器时区调对(这一步不能省)
回忆第一节那张环境差异表里的一行:服务器默认时区常常是 UTC,而你在东八区。这一行看起来最不重要,但它是「日志时间差 8 小时」这个真实坑的源头。所以装完环境、还没启动服务之前,先把时区调对:
# 查看当前时区(大概率显示 UTC,或者 Etc/UTC)
timedatectl
# 设置成东八区
sudo timedatectl set-timezone Asia/Shanghai
# 再验证一次:Time zone 应该变成 Asia/Shanghai (CST, +0800)
timedatectl
date
▲ timedatectl 是 systemd 提供的时间管理工具:不带参数时它只报告状态(当前时间、时区、NTP 同步是否开启),带 set-timezone 时才改。改完必须再看一遍——这是「验证」这个动作的最小形态。
为什么容器里的 TZ 不能替代这一步?因为它们是两个不同的时钟视图:
| 设置位置 | 管谁 | 不管谁 |
|---|---|---|
宿主机的 timedatectl set-timezone | 整台服务器的本地时间:systemd 日志、date、ls -l 显示的时间、cron 的触发时刻 | —— |
容器的 TZ=Asia/Shanghai | 只有那个容器里进程看到的本地时间 | 宿主机;以及其他没设 TZ 的容器 |
所以两者都要设:timedatectl 是根治(让整个系统的时间线一致),容器里的 TZ 是补漏(防止某个镜像的默认时区把你带偏)。少了前者,你的 journalctl 和 bot.log 会各说各话;少了后者,NapCat 的日志会和你差 8 小时。而你排查「她什么时候开始不回消息」时,靠的正是把这几条时间线对齐。
这步错了会看到什么:时区没设时,程序本身照样跑,不会有任何报错——它只会在你最需要日志的时候,给你一条偏移了 8 小时的时间线。这也是为什么它属于那种「做错了会安静地不工作」的步骤:它不制造故障,它制造的是「你排查故障时的错误依据」。
7.5 第 4 步:配 API Key
机器人要调 DeepSeek,需要一把 Key。OWL 的设计是把它单独放在 bot/llm.local.json,而不是塞进 config.json。脚本会检查它,但故意不帮你创建——因为它不该知道你的 Key。
# 在服务器上创建(把 sk-xxx 换成你自己的 Key)
cat > /root/owl/bot/llm.local.json <<'EOF'
{
"apiKey": "sk-你的key"
}
EOF
# ★ 立刻收紧权限:只有属主可读写
chmod 600 /root/owl/bot/llm.local.json
▲ 脚本的检查逻辑很宽松但有用:文件不存在就警告「AI 将无法使用」;文件在但内容里没有 "apiKey": "sk- 这个形状,就警告「看起来不像有效值」。它只做形状检查,不做真伪检查——因为验证 Key 是不是真的有效,得花一次 API 调用。
chmod 600 为什么必须做?因为这台机器上可能有别的进程、别的用户。权限 600 的含义是「只有属主能读写,同组和其他人什么都做不了」。回忆第一节的对照表:在本地,Key 泄露最多是你自己多花点钱;在服务器上,一个能被别人读到的 Key 等于「一个陌生人可以用你的钱包」。
这步错了会看到什么:如果没配 Key,机器人能启动、能连上协议端、/ping 也能回,但一旦有人想聊天,日志里会出现 🧠 AI 状态: 未启用,机器人只会回复静态话术。注意这个现象非常容易误判——你会以为「机器人坏了」,实际上它只是不能聊天。这正是「分层定位」的价值:能回 /ping 就说明链路是好的,问题在 AI 这一层。
7.6 第 5 步:装依赖(npm ci 而不是 npm install)
cd /root/owl/bot
npm ci --omit=dev --no-audit --no-fund
▲ 真实脚本的写法是:有 package-lock.json 就优先 npm ci,失败再退回 npm install。
这三个参数各有理由,不是随手加的:
ci而不是install:npm ci会严格按照package-lock.json里锁定的版本安装,并且在装之前先删掉node_modules。它的语义是「给我一模一样的一份依赖」,而install的语义是「给我一份能满足要求的、尽量新的依赖」。生产环境要的是前者——你在本地测过的是那一份,服务器上装的也必须是那一份。这就是「锁定版本」在真实场景里的样子。--omit=dev:不装开发依赖。服务器上只跑程序,不需要测试框架、不需要打包工具。少装几十兆,也少几十个潜在的漏洞来源。--no-audit --no-fund:关掉「安全审计」和「求赞助」两段额外输出。它们不影响安装,但会让脚本输出变乱、变慢——而在一键部署脚本里,输出越干净,出问题时你越快看到关键信息。
这步错了会看到什么:依赖装不上时,程序启动会报 Cannot find module 'ws'(找不到模块)。注意这个报错的形状:它说的是「找不到某个模块」,而不是「网络不通」。所以看到它时你要查的是 node_modules 到底有没有装上,而不是去查网络。
7.7 第 6 步:建数据目录
cd /root/owl
mkdir -p data/qq data/napcat data/botdata
▲ 三个目录对应三份「不能丢的数据」:QQ 登录态、NapCat 配置、机器人自己的记忆与日志。-p 的含义是「父目录不存在就一起建,已经存在也不报错」——这正是让脚本可以重复执行的关键细节。
为什么这一步必须在启动之前?因为 Docker 的绑定挂载如果指向一个宿主机上不存在的目录,不同版本的 Docker 行为不完全一致:有的会自动创建(以 root 身份),有的会报错。而自动创建出来的目录属主可能是 root,之后容器里的非 root 进程就写不进去——这会在你改成非 root 运行时突然咬你一口。手动先建好,是最省事的做法。
这步错了会看到什么:目录缺失时,容器可能起不来,或者起来了但登录态写不进去——表现是「每次重启都要重新扫码」。而你在日志里可能什么都看不到。
7.8 第 7 步:写两个 service 并启动
# 前面的脚本实际做的事,等价于:
sudo tee /etc/systemd/system/qqbot.service > /dev/null # ← 内容见第 6 节
sudo tee /etc/systemd/system/napcat.service > /dev/null # ← 内容见第 6 节
sudo systemctl daemon-reload # ★ 让 systemd 重新读取单元文件
sudo systemctl enable qqbot napcat # 开机自启
sudo systemctl restart napcat # 先起协议端
sudo systemctl restart qqbot # 再起机器人
▲ 注意启动顺序和本机调试时相反。本机调试的文档里说「先 bot 后 napcat,反过来也行,NapCat 会每 3 秒自动重连」——这个容错能力来自前面配置里的 reconnectInterval: 3000。顺序不敏感,但知道「为什么不敏感」很重要。
这步错了会看到什么:写错单元文件时,systemctl status 会显示 failed,并且原因往往写在日志里(比如「ExecStart 里的可执行文件不存在」)。忘了 daemon-reload 时,最阴险:一切看起来正常,只是新写的配置不生效。
7.9 第 8 步:配 NapCat 的反向 WS
这一步是整个部署里最容易出错、也最值得认真做的一步。手工做的话,是在 NapCat 面板里点「网络配置 → 新建 → WebSocket 客户端」,然后填四个字段:
| 字段 | 填什么 | 填错的后果 |
|---|---|---|
| 名称 | local-bot(随便,只为区分) | 没什么后果 |
| URL | ws://127.0.0.1:3001/ | 填错地址或端口 → 机器人日志永远停在 等待协议端(NapCat)连接… |
| Token | 和 bot/config.json 的 server.token 完全一致 | 不一致 → 机器人日志出现 🚫 拒绝未授权连接,连接被立刻关闭 |
| 消息格式 | array | 格式不对 → 消息能收但解析失败,表现可能是「收到了但没有回复」 |
| 启用 | 必须勾上 | 不勾 → 和 URL 填错一样,安静地不连接 |
但真实部署里没有手工点。脚本选择直接把这个 JSON 写到挂载目录里,理由是「更可靠,也不怕填错 token」。看它的做法:
# 从机器人配置里读出 token 和端口,保证两边天然一致
TOKEN=$(node -e "console.log(require('/root/owl/bot/config.json').server.token)")
PORT=$(node -e "console.log(require('/root/owl/bot/config.json').server.port)")
# 然后把它们写进 NapCat 的 OneBot 配置里
# (完整 JSON 见第 5 节,这里只显示关键的两行)
# "url": "ws://127.0.0.1:${PORT}/"
# "token": "${TOKEN}"
▲ 这是一个值得学的小技巧:不要在两处手写同一个值,而是让一处从另一处派生。token 只需要在一个地方正确(config.json),另一边自动跟着走。这样「两边不一致」这个故障类型就被从设计上消除了。
技巧如果你是自己手动配的,配完一定要做一件事:把两边的 token 打印出来对比。一条命令就够:docker compose logs napcat | grep -i token 和直接看 bot/config.json 里的 server.token。人眼比对虽然原始,但它比「猜哪里错了」快十倍。
7.10 第 9 步:扫码登录,然后验证
扫码要走 SSH 隧道(第三节的方法),不要在服务器上找二维码图片。原因很实在:二维码通常两分钟就过期,而「把图片从服务器上取出来、传到手机、再扫」这个流程经常刚好超过两分钟。面板里的二维码会自动刷新,所以直接看着它扫是最省事的。
扫完之后,验证必须按顺序做,因为每一步验证的是不同的层:
- 看机器人日志:
tail -f /root/owl/data/botdata/bot.log,期望看到🔗 协议端已连接 (127.0.0.1)。这一行证明「网络层 + 鉴权层」都通了。 - 看登录结果:紧接着应该出现
✅ 机器人已登录: 1876148307 (OWL)。这一行来自 OneBot 的「生命周期事件」,证明 NapCat 确实登录进 QQ 了。 - 发一条
/ping:这是端到端的验证,从别人的手机出发,穿过腾讯、NapCat、反向 WS,到达你的代码,再回去。它期望的回复是pong 🏓。 - 发一句真话(比如「我今天很累」),确认 AI 那一段也通。
- 关掉你自己的电脑,用手机再发一条。这一步才是「24 小时在线」的真正验收——它验证的是「你不在场时服务照常」,而不是「链路能通」。
第 3 步和第 5 步的区别值得专门想一想:/ping 通了说明链路通,但「关掉自己电脑还能收到回复」说明的是服务独立于你。前者是功能验证,后者是部署验证。只有第 5 步通过,你才真的把程序送上云了。
想一想把上面 8 个步骤重新看一遍,回答一个问题:哪几步是「做错了会报错」的,哪几步是「做错了会安静地不工作」的?把第二类找出来,然后想一想——为什么恰恰是这一类最危险?如果你要给这套流程加一个「自检」环节,你会把它加在哪一步之后?
8. 日志、监控与「假活」:这一章的第二个理论重点
服务跑起来了。现在进入运维的主战场:你怎么知道它还活着?这个问题比它听起来难得多,因为这一节要讲的故障,会让你所有的直觉都失效。
8.1 三种日志,分别属于三层
OWL 的日志有三个来源,它们不是重复,而是各管一层。分不清它们,你就不知道该去哪找答案。
| 来源 | 怎么读 | 它记录的是什么 | 什么时候该看它 |
|---|---|---|---|
应用日志 bot.log | tail -f data/botdata/bot.log | 你的代码自己写的话:收到谁的消息、调 AI 的耗时、拒绝了谁、登录成功 | 最常用。想知道「业务上发生了什么」时看它 |
| systemd 日志 | journalctl -u qqbot | 服务管理层面的事:启动、停止、崩溃、退出码、重启记录 | 程序根本没起来或反复重启时看它。答案往往在 bot.log 里根本没有 |
| 容器日志 | docker compose logs napcat | NapCat 进程写到标准输出的内容:登录、被踢、二维码、时间同步 | 怀疑「消息根本没到我的程序」时看它 |
为什么这三种必须分开理解?因为它们回答的是不同的层上的问题:NapCat 那一层的问题是「被腾讯踢下线」「还在等扫码」,只能去容器日志里看;机器人那一层的问题是「拒绝未授权连接」「AI 调用超时」,在应用日志里;而 systemd 那一层的问题是「进程崩了」「重启了 38 次」,它根本不会出现在应用日志里。
▲ 排障的核心动作就是:先判断问题在哪一层,再去那一层看日志。而不是一上来就把三个日志都翻一遍——那样你会在几百行无关信息里淹死。
8.2 tail / grep / --since 的组合用法
回忆第一章学过的管道:把上一条命令的输出,变成下一条命令的输入。日志分析就是管道最有价值的战场。
# 1) 实时跟随,并只显示包含某个关键词的行
tail -f data/botdata/bot.log | grep "协议端"
# 2) 在最近 500 行里找"没连上"的痕迹(-n 会带上行号,方便回看上下文)
tail -n 500 data/botdata/bot.log | grep -n "等待协议端"
# 3) 数一数某个事件出现了多少次(判断"是偶发还是持续")
grep -c "拒绝未授权连接" data/botdata/bot.log
# 4) 看最后 20 条"已经回复"的记录,回答"她最后一次正常工作是什么时候"
grep -a "已回复" data/botdata/bot.log | tail -20
# 5) 按时间范围看 systemd 层(比 tail -n 精准得多),-p err 只看错误级别
journalctl -u qqbot --since "1 hour ago" --no-pager
journalctl -u qqbot -p err -f
# 6) 看容器最近 10 分钟里和登录相关的输出(-i 忽略大小写,-E 启用扩展正则)
docker compose logs --since 10m napcat | grep -iE "登录|踢|失效|token"
▲ 六个组合,每一个都在回答一个具体的问题。第 3 条的 -c 特别有用:「出现了多少次」这个数字本身就是重要信息——出现 1 次可能是一次意外,出现 300 次说明存在一个持续的重试循环。第 6 条的 -iE 几乎总是成对出现,因为你不可能记得日志里那个词是大写还是小写。
技巧养成一个习惯:看到可疑的一行日志,立刻 grep 那个关键短语,统计它的次数和时间分布。「这个错出现了几次?集中在什么时间?」这两个问题的答案,通常比那行错误本身更能告诉你根因。
8.3 「假活」完整复盘:这一章最重要的一段
现在讲那个真实发生过的故障。症状:用户发消息,机器人毫无反应。于是你开始做所有你学过的检查,而每一项都正常,正常得让人绝望:
systemctl is-active qqbot → active (进程活着)
docker inspect napcat → running (容器健康,零重启、无 OOM)
ss -tn | grep 3001 → ESTAB (反向 WS 连接还在!)
NapCat 日志 → 每小时还有 [ServerTime] 时间同步
(证明 QQ 核心进程也活着)
▲ 这是运维手册里记录的真实检查结果。请盯着第三行:反向 WebSocket 连接是 ESTAB(已建立)状态。也就是说,NapCat 和你的程序之间那条管道确实连着。
一个初学者读到这里会得出什么结论?「一切都正常,那就是玄学。」一个运维工程师会得出什么结论?「链条上每一段单独看都活着,那么问题一定在某个『没被检查的地方』。」那个地方是NapCat 与腾讯之间。而证据其实就在容器日志里,只是它长得不像一个错误:
10-03 21:59:08 正在快速登录 1876148307
10-03 21:59:08 快速登录错误:你的用户身份已失效,为保证账号安全,请你重新登录。
▲ 真因。本地登录态还在,但服务端已经不认了。所以 NapCat 保着一条通向你的连接(它对自己内部的会话是乐观的),但腾讯那边再也不会给它推任何消息。
这个故障有一个绰号:「假活」——看起来在跑,实际上什么都没做。它最可怕的地方在于:所有「检查进程是否活着」的手段,都会告诉你「一切正常」。进程活着、容器健康、端口在听、连接建立、甚至远端心跳还在,唯独业务是死的。它需要人工重新扫码才能恢复,而 restart=always、systemd 自启、Swap——全都救不了它。
要点「监控进程」和「监控业务」是两件完全不同的事。前者问的是「那个程序还在吗」,后者问的是「这个人发消息,她能不能回」。前者便宜、简单、几乎从不误报,但它测不到这一类故障;后者才是用户真正在意的,但它需要你真的走一遍完整链路。
8.4 端到端探针:健康检查在做的事
既然「监控业务」才是目标,那怎么测?答案是端到端探针(probe):不是去问「你在吗」,而是真的走一遍用户走过的路,看结果对不对。
OWL 用了两个不同层次的探针,它们配合起来才能给出确定结论。
探针一:冒充协议端发一条 /ping。项目里有一个脚本,它直接连上反向 WebSocket,假装自己是 QQ 核心,发一条 /ping 进去:
cd /root/owl
node tools/_probe-reverse.mjs
▲ 注意这个脚本必须在服务器上跑,因为 3001 只监听 127.0.0.1。它绕过了腾讯和 NapCat,直接测「机器人本体能不能正常工作」。
它的输出有一个非常漂亮的二值判断逻辑:有响应(收到 pong)说明机器人本体、鉴权、消息处理链路全通,那么问题一定在 NapCat 与 QQ 之间(登录会话失效或被风控);无响应说明机器人这半条链就是坏的,去看 bot.log 和 API Key。实测输出如下:
✅ WebSocket 已连接
← 机器人调用: send_private_msg {"text":"pong 🏓"}
===== 结论 =====
✅ 机器人有响应 → 机器人本体正常。问题在 NapCat 与 QQ 之间(登录会话失效/被风控)。
▲ 一个探针,把「可能出了问题的两个半区」直接砍掉一半。这就是好的诊断工具的形状:它不告诉你根因,但它把搜索空间缩小一半。
探针二:健康检查脚本。它一共检查八项,覆盖了每一层的存活信号——注意最后一项跳出了「这台机器」,去检查「她还能不能用模型」:
| 检查项 | 怎么判断 | 它在测哪一层 |
|---|---|---|
| 1. 机器人进程 | systemctl is-active --quiet qqbot | systemd 层 |
| 2. NapCat 容器 | docker inspect -f '{{.State.Status}}' napcat | 容器层 |
| 3. 反向 WS 连接 | ss -tn | grep ':3001 ' | 网络层(收消息的唯一通道) |
| 4. QQ 登录状态 | 以反向 WS 是否连着为准,再用日志解释原因 | 业务层(最接近端到端的那一项) |
| 5. QQ 核心存活 | 日志里有没有 ServerTime 同步记录 | 辅助判断 |
| 6. 磁盘 | df / 的使用率是否 ≥ 85% | 资源层 |
| 7. 内存 | free -m 的可用内存是否 < 120 MB | 资源层 |
| 8. API Key | grep bot/llm.local.json 里有没有 "apiKey": "sk- 这个形状 | 依赖层(外部服务能不能用) |
第 8 项容易被忽略但很实用:它是唯一一项检查「外部依赖」的——前七项都在看这台机器自己好不好,而这一项在看「她还有没有能力调用模型」。少了它,一个 Key 文件被误删的机器人会看起来一切正常,直到有人想聊天。
第 4 项的设计思路特别值得讲,因为它是这个脚本里唯一的「聪明」之处,也是作者踩过两次坑才定下来的。脚本的头部注释把踩坑记录写在最前面:
判断登录状态用反向 WS 是否连着这个可靠信号——NapCat 只有在成功登录 QQ 之后才会去连反向 WS。绝对不要用「最近 N 分钟有没有二维码日志」做判据。NapCat 被踢后会无限重印二维码提示,历史日志会一直命中,于是「明明已登录」却被报成「等待扫码」(这个假警报我调了两版才修掉)。被踢下线后会残留「失效」字样,所以那类日志只用来解释原因,不能单独当作当前状态。
这段话里有三条可以迁移到任何监控系统的教训:
- 要选一个「状态量」而不是「事件量」做判据。「连接现在是否建立」是状态;「日志里出现过二维码」是事件。历史事件会一直留在日志里,用它判断现在,必然误报。
- 日志适合解释原因,不适合判定状态。「身份已失效」这句话很可能是三天前留下的。它告诉你可能的原因是什么,但不能告诉你现在是不是这样。
- 告警的误报会杀死告警的可信度。一个天天误报的检查,三天后你就会开始无视它——那时它等于不存在。所以「宁可不报,不可乱报」在这里是对的。
还有一条工程纪律,写在健康检查的注释里,我认为比脚本本身更重要:
设计上刻意不做「发现问题就自动重启」。因为登录失效重启也没用,而且自动重启会掩盖真实问题。它只负责记录 + 提示怎么修。
这句话是对「自动化」的一次清醒的拒绝。很多初学者学到「自动重启」之后会想把它用在所有地方:一发现异常就重启。但一个会自动重启的系统,会把「崩溃」变成「看不见的常态」。你失去了最宝贵的信号——「它曾经坏过」。所以正确的分工是:自动重启只处理「重启能解决」的问题(进程崩溃);而「重启不能解决」的问题(登录失效、磁盘满、Key 过期)必须变成人能看到的告警。
8.5 用定时任务做健康检查
探针写好了,谁来决定「多久跑一次」?有两个选择:cron 和 systemd timer。你在第一章见过定时任务的基本概念,这里看真实用法。
OWL 用的是 cron,每 5 分钟跑一次,结果追加到日志文件:
# /etc/cron.d/owl-healthcheck
*/5 * * * * root cd /root/owl && bash tools/healthcheck.sh >> /root/owl/data/botdata/health.log 2>&1
▲ 逐段读:*/5 * * * * 是「每 5 分钟」(分 时 日 月 周 五个字段);root 是以哪个用户身份跑;后面是命令;>> 是追加输出到文件;2>&1 是把标准错误也并到标准输出里——这一句很关键,少了它,脚本的报错信息会丢进邮件系统(你永远看不到)而不是日志文件。
顺手还有一个每周清理悬空镜像的任务:
# /etc/cron.d/owl-docker-prune
0 4 * * 0 root docker image prune -f >/dev/null 2>&1
▲ 每周日 4:00 执行。docker image prune -f 会删掉那些「没有容器在用的悬空镜像」——每次升级 NapCat 都会留下一个旧的镜像层,一年下来能吃掉不少磁盘。
那什么时候该用 systemd timer 而不是 cron?两条经验:
- 如果你需要「错过的任务要不要补跑」。服务器在凌晨 4 点关机了,cron 会直接跳过那一次;而 systemd timer 有
Persistent=true,可以在开机后补跑一次。 - 如果你需要「日志统一进 journald」。timer 的输出会进
journalctl,和你的服务日志在一起,查的时候不用再记多个文件路径。
对 OWL 这种「只是记录一下状态」的需求,cron 足够了。而这一点本身就是一条有价值的判断:不要因为「有个更强大的工具」就去用它。选择工具的理由应该是「我现在这个需求它解决不了」。
8.6 日志轮转:为什么 40 G 会被写满
现在讲一个「听起来不可能、但真实会发生」的事故:磁盘被日志写满。
为什么会满?因为默认情况下,日志是没有上限的。而一个 24 小时运行的服务,日志量可能远超你的想象:
| 日志来源 | 增长方式 | 为什么它会长得很快 |
|---|---|---|
应用日志 bot.log | 每收一条消息、每次 AI 调用、每次拒绝都追加一行 | 主要风险是「重试循环」:一句报错每秒写一次,一天就是几万行 |
| 容器日志(Docker json-file) | NapCat 的每一行输出,包括被踢后无限重印的二维码提示 | 这是最危险的一个:NapCat 在被踢之后会持续重印提示,没人管的话会一直写 |
| NapCat 的图片缓存 | 每个群每天几百张表情包和图片 | 它不是日志,但它是磁盘占用的另一个大头,实测清理前会明显堆积 |
应对方式有两类,OWL 两类都用了。
第一类:给容器日志设硬上限。这是最省事、性价比最高的一招,因为它只需要在 daemon.json 里写一次,对所有容器生效:
{
"log-driver": "json-file",
"log-opts": { "max-size": "10m", "max-file": "3" }
}
▲ 含义:单个容器的日志文件最大 10 MB,最多保留 3 个。也就是给每个容器封顶 30 MB。写完之后需要 systemctl restart docker,而且这个设置只对之后新建的容器生效——已有的容器需要重建(down 再 up -d)。
第二类:给应用日志做轮转。应用日志是 systemd 用 StandardOutput=append: 直接写的文件,Docker 管不到它,所以要自己轮转。标准工具是 logrotate:
# /etc/logrotate.d/owl
/root/owl/data/botdata/bot.log
/root/owl/data/botdata/bot.err.log {
daily # 每天轮转一次
rotate 7 # 保留 7 份历史(也就是最近一周)
maxsize 20M # 但如果一天内就长到 20M,也提前轮转
missingok # 文件不存在不报错(服务刚部署时可能还没有日志)
notifempty # 文件是空的就不轮转
compress # 旧日志压缩,能省下大量空间
copytruncate # ★ 关键:先复制再清空原文件,而不是重命名
}
▲ 逐项读:daily 每天轮转一次;rotate 7 保留 7 份历史(也就是最近一周);maxsize 20M 表示一天内就长到 20 MB 也提前轮转;missingok 是文件不存在不报错(刚部署时可能还没有日志);notifempty 是空文件不轮转;compress 把旧日志压缩,能省下大量空间。
最后一个选项 copytruncate 必须专门说,因为它是一个典型的「不知道就会踩」的坑。默认的轮转方式是「把 bot.log 重命名成 bot.log.1,再创建一个新的空文件」。但正在运行的程序手里还握着旧文件(现在叫 bot.log.1)的文件描述符,它会继续往那个被重命名的文件里写。结果是你看到一个空的 bot.log,而所有日志都跑进了 bot.log.1——你会以为程序不再写日志了。copytruncate(复制截断)的做法是「先复制一份,再把原文件内容截断清零」:文件本身(inode)没变,所以程序手里那个描述符仍指向正确的、被清空的文件。
警告如果你发现「bot.log 是空的,但机器人明明在正常工作」,先别怀疑代码。执行 ls -lh data/botdata/ 看看有没有别的 bot.log.1、bot.log.2.gz 正在快速变大。这几乎总是日志轮转配置的问题,而不是程序的问题。
除了这两类「防止写满」,还要有一个「写满了怎么办」的应急动作。NapCat 的图片缓存是可以安全清理的:
df -h / # ① 先确认是不是磁盘问题
du -sh /root/owl/data/* | sort -h # ② 找出是哪个目录在吃空间
cd /root/owl && pwd # ③ 先确认自己站在哪里(下面要删东西)
docker compose down # ④ 停掉容器(缓存文件被占用时删不干净)
rm -rf data/qq/NapCat/temp/* # ⑤ 清掉临时图片缓存
docker compose up -d # ⑥ 重新起来
▲ 第 ② 步的 du -sh ... | sort -h 是「找出谁在吃磁盘」的标准组合:du -sh 逐目录统计大小,sort -h 按人类可读的单位排序。不要凭猜删东西,先让数据告诉你。
警告第 ⑤ 步那个 rm -rf 是这一章里唯一一条不可逆的命令,所以把它的规矩写清楚:
(a) 先确认位置。cd /root/owl && pwd——你要看到 /root/owl 才算站对了地方。rm -rf 的路径是相对的,站在哪里决定了你删掉什么。
(b) 它没有回收站。路径里多一个空格、拼错一个字符,结果就完全不同:rm -rf data/qq/NapCat/temp/* 和 rm -rf data/qq/NapCat/temp /*(temp 后面多一个空格)是两条完全不同的命令,后者会去删根目录下所有能删的东西。删之前把命令再读一遍,尤其是空格和 * 的位置。
(c) 更安全的替代做法:先改名,不删。把它挪到临时目录,观察一天:
mv data/qq/NapCat/temp /tmp/napcat-temp-$(date +%F)
如果一天之后一切正常(缓存会自动重建),再删那个目录;如果发现问题,你还能把它搬回来。这个「先移走、后删除」的习惯,能让你所有的清理动作都变成可回滚的。
(d) 只清缓存,不要清错对象。这条命令清理的是「临时图片缓存」,它会自动重建。但如果 du 显示大头在 data/qq 里的登录态(实测 158 MB 以上),那就不该删——删了它你就要重新扫码。判断标准永远是第 ② 步 du 的输出,不是「感觉这个目录很大」。
8.7 OOM:1.6 G 内存的真实边缘
第二个资源类事故是内存。它的表现比磁盘满更隐蔽,因为进程被杀的时候,可能什么日志都不留。
回顾一下数字:服务器 1608 MB 内存,实测常驻占用约 476 MB。看起来还有 1.1 G 的余量,为什么还会 OOM?
因为「常驻占用」是一个平均值,而 OOM 是突发事件。Linux 的内存管理有一个策略:它会把暂时不用的内存拿去做文件缓存(因为闲置内存是浪费),所以「可用内存」看起来总是很多。当某个进程在瞬间申请一大块内存(比如一次很大的响应体、一批并发请求、Node 的垃圾回收还没跟上),内核会先尝试回收缓存;如果回收也来不及,它就会杀掉一个进程来救整个系统。
这个动作叫 OOM Killer(内存耗尽杀手)。它挑谁杀?通常挑「内存占用最大且最不重要」的那个。在 OWL 这台机器上,最大的那个是 NapCat 的 QQ 内核。所以你看到的现象会是:
# 机器人日志里出现(或者干脆没有)
[2026-10-03 03:14:07] 🤖 OWL 已启动 ← 它自己重启了
# 而且 systemd 会告诉你原因
systemctl status qqbot
# Active: active (running) since ...
# 主进程已退出,code=killed, status=9/KILL
▲ status=9/KILL 里的 9 就是 SIGKILL 信号(你在第一章的进程一节见过信号的概念)。它的特征是:该进程无法捕获、无法做清理、无法写日志。所以「程序被杀了但它什么都没说」是正常现象,而不是日志配置有问题。
要确认是不是 OOM,最直接的证据在内核日志里:
# 找内核层关于 OOM 的记录
journalctl -k | grep -i "out of memory\|oom-kill"
# 或者看当时的资源情况
free -m # 看可用内存和 swap 使用量
dmesg -T | grep -i oom
▲ journalctl -k 是只看内核(kernel)的日志。这类信息不在你的应用日志里,所以「我的程序日志里什么都看不到」并不等于「什么都没发生」——它发生在另一层。
那怎么防?OWL 用的是 Swap,而且加了 2 G。Swap 是什么,用第一章那个比喻来说:内存是书桌,硬盘是书柜;Swap 就是把书柜的一小块地方腾出来,当书桌的延伸用。当书桌放不下时,暂时不用的东西先搬到书柜上。
它的作用和代价都很明确:
- 作用:给突发内存需求一个缓冲。原本会触发 OOM 杀进程的一次尖峰,现在变成「稍微慢一下」。对一个聊天机器人来说,慢一下完全可以接受,被杀死则不可接受。
- 代价:硬盘比内存慢几个数量级。如果系统开始大量使用 Swap,一切都变慢。所以 Swap 应该被看作安全气囊(平时不用,撞车时救你),而不是第二块内存(指望它扩容)。
另外,脚本还把 vm.swappiness 从默认的 60 提到了 80。这个参数的含义是「内核有多愿意把内存页换到 Swap 去」,值越高越愿意。这里的取舍是:用一点点速度换取更低的被杀风险。对一个 1.6 G 的机器来说,这笔交易划算。
想一想健康检查里那条内存判据是「可用内存低于 120 MB 就告警」。现在请你想两个问题:(1)为什么不用「已用内存超过某个值」来告警,而要看「可用」?(2)为什么阈值定在 120 MB 而不是 12 MB 或 500 MB?提示:想清楚「告警的目的是让你有时间做点什么」,答案就出来了。
9. 排障方法论:一份可操作的手册
前面讲了很多具体的坑。这一节要做的是把它们组织成一套方法——因为现实中你会遇到没写在任何教程里的新故障,而方法比清单更能带你走出去。
9.1 分层定位法:从外到内,五步走
这套方法的思路来自第一节那个分层图。核心原则是:从最外层开始,逐层往里走,每一层都问一个能用命令回答的问题。在你确定外层是好的之前,不要跳进内层。
| 顺序 | 要问的问题 | 怎么问(命令) | 结论怎么用 |
|---|---|---|---|
| 1 | 网络通不通?服务器还在线吗? | 从你本机 ping 服务器IP;再 ssh 上去 | 连不上 → 别查代码了,先去云控制台看实例状态、安全组、是否欠费停机 |
| 2 | 端口在不在听?谁在听? | ss -tlnp | grep 3001;ss -tn | grep :3001 | 没有 LISTEN → 机器人没启动或启动失败;有 LISTEN 但没有 ESTAB → 协议端没连过来 |
| 3 | 服务进程在不在?它最近重启过吗? | systemctl is-active qqbot;systemctl status qqbot | failed 或刚重启过 → 去 journalctl -u qqbot 看退出原因 |
| 4 | 应用日志在说什么? | tail -50 data/botdata/bot.log | 看到 拒绝未授权连接 → token 问题;看到「等待协议端」→ 去 NapCat 那层 |
| 5 | 业务链路真的通吗?(端到端) | node tools/_probe-reverse.mjs;再让人真发一条消息 | 探针有响应但真实消息没反应 →「假活」,问题在 NapCat 与 QQ 之间 |
为什么必须从外到内而不是反过来?因为内层的症状往往是外层问题的结果,而人是会被症状骗的。举个具体的例子:
要点假设有人把服务器欠费停机了。你去查的话会看到什么?机器连不上。但如果你先去看代码——你会看到「日志在三个小时前突然断了」,于是你可能开始怀疑「是不是我的程序崩了」「是不是配置变了」。你会花两个小时去排查一个压根不存在的程序问题。从外到内的唯一目的,是在你花时间之前,先把不可能的原因排除掉。
9.2 三条铁律
方法之上还有纪律。这三条比任何命令都重要,因为它们防止你把「一个小问题」变成「两个大问题」。
铁律一:一次只改一个变量。
这是排障里最重要的纪律,没有之一。当你同时改了 token、重启了容器、又把 Node 升了级,然后问题消失了——你并不知道是哪一个改动的功劳。这意味着下次同样的问题出现时,你依然不会修。
更糟的情况是问题没消失:你现在有两倍的复杂度要去排查,而且你不再确定「原来的状态是什么」。这也是为什么部署脚本要写成幂等的:重复执行的结果和一次执行一样。幂等让你可以放心地「只改一个变量、再跑一遍」。
铁律二:先复现,再修。
一个没法复现的问题,你没法知道它有没有被修好。所以在动手之前,先做一件事:找到一个能稳定触发的动作。
比如「机器人偶尔不回消息」这句话几乎没有信息量。但如果你发现「私聊时不回,群里 @ 时回」,那你已经复现了它——而且这个现象本身就指向了配置里的 llm.trigger(私聊是否开启)。再比如「发 /ping 有回复,发普通消息没回复」——这说明链路是好的,问题在 AI 那一层(Key、额度、超时)。
技巧把你观察到的现象写成固定句式:我做了 X,期望 Y,实际得到 Z。这句话看起来简单,但它逼你把模糊的「它坏了」变成三个可验证的具体事实。写在纸上,你会发现有些问题在这一步就已经解决了。
铁律三:修完要验证,而且要在「正确的层」验证。
你改了 token,然后 systemctl restart qqbot,看到 active (running)——你没修好,你只是重启成功了。真正的验证是看到 🔗 协议端已连接,然后真的发一条消息收到回复。
这一条之所以重要,是因为「服务是 active 的」是一个太容易被满足的假信号。我们在第八节已经看过它的极端形态:所有层的信号都是绿的,只有业务是死的。
9.3 现象对照表:这一节的实用核心
下面这张表是这一章最有实用价值的东西。用法是:在左列找到你的现象,然后按「先看什么 → 再看什么」的顺序走,不要跳步。
| 现象 | 先看什么 | 再看什么 | 最可能的原因 |
|---|---|---|---|
| 机器人完全不回复 | systemctl is-active qqbot;docker compose ps |
node tools/_probe-reverse.mjs |
进程挂了 / 容器挂了 → 看日志;两者都正常但探针无响应 → 机器人本体或 AI 链路;探针有响应但真实消息无反应 → 登录会话失效(假活) |
| 回复失败(有反应但发不出去) | tail -50 data/botdata/bot.log,找调用 API 的那几行 |
看是否有 retcode 非 0 或 timeout |
NapCat 与 QQ 的连接已经不可写;或消息内容触发了 QQ 的限制;或回复太长被截断 |
| 一直重连 | tail -f data/botdata/bot.log 看连接记录的时间间隔 |
NapCat 日志 docker compose logs --tail=50 napcat;ss -tlnp | grep 3001 |
若间隔约 3 秒 → 配置里的 reconnectInterval: 3000,说明根本没连上(URL 错、端口没监听、host 网络模式没写);若看到 🚫 拒绝未授权连接 → token 不一致 |
| 收到重复消息 | NapCat 的 websocketClients 里有几条配置? |
reportSelfMessage 是不是 true? |
同时开了两个 WebSocket 客户端(比如手工加了一个、脚本又写了一个),每条消息被推两遍;或者机器人上报了自己发的消息造成回环 |
| 内存被杀(进程莫名重启) | systemctl status qqbot 看退出码是不是 killed, status=9/KILL |
journalctl -k | grep -i "out of memory";free -m |
OOM Killer 动手了。应对:确认 Swap 生效(swapon --show)、检查是否有内存泄漏、有没有并发量激增 |
| 磁盘满 | df -h / |
du -sh /root/owl/data/* | sort -h;docker system df |
容器日志没限额;NapCat 图片缓存堆积;悬空镜像堆积。处理见第 8.6 节;长期方案是配置日志轮转 + 定期清理 |
| 二维码过期 | 用 SSH 隧道打开面板(让二维码自动刷新) |
docker compose logs napcat | grep -i token 拿面板 token |
不要在服务器上找二维码图片文件。二维码约 2 分钟过期,「取图 → 传到手机 → 扫」这个流程经常来不及。看着面板扫是最可靠的。 |
| 端口被占用 | ss -tlnp | grep 6099(或 3001)看是谁占了 |
docker compose logs napcat 里有没有「地址已被使用」 |
host 网络模式下端口不能映射,容器和宿主机抢同一批端口。常见原因是「本地也起了一个 NapCat」或旧容器没删干净(docker compose down) |
| 改了配置没生效 | 改的是 config.json 还是 *.service? |
若是 unit 文件:有没有 systemctl daemon-reload?若是 config:有没有真的保存成功? |
unit 文件必须 daemon-reload + restart;config.json 通常支持热重载(fs.watch)但也建议 restart 确认;还可能你改的是本机文件而没同步到服务器 |
| 更新后行为变了 | git diff / 对比你传上去的文件和本机文件 |
看日志里显示的启动时间与配置摘要 | 传了旧文件;npm ci 没跑导致依赖版本没跟上;或者你改的是 persona-source.txt 但没执行写进 config.json 的那一步 |
| 机器人回复变成固定话术 | tail -30 data/botdata/bot.log,找 🧠 AI 状态 |
grep -q '"apiKey"' bot/llm.local.json && echo ok 检查 Key 文件 |
AI 状态: 未启用 → Key 文件缺失/格式不对,或者 llm.enable 是 false,或者 Key 文件权限/路径不对 |
| 群里不回,私聊正常 | 看 llm.trigger 里的 at 是不是 true |
群里是不是真的 @ 了她 | 这是设计如此,不是故障。群里必须 @ 才回应,否则她会插嘴所有对话。要改就改配置,但要先想清楚代价 |
表里有一行我想单独拎出来强调,因为它是唯一一行「不是故障」的:「群里不回,私聊正常」。把它放进故障表里是有意的。运维工作里有一个常见错误:把「设计决定」当成「故障」去修。所以在动手之前,先问一句「这是 bug,还是它本来就这样」。判断的依据不该是你的印象,而该是配置文件和文档。
9.4 重新扫码的正确姿势
掉线之后要重新扫码,但「怎么扫」这件事有对错之分。错误做法的代价是浪费十分钟在一个注定失败的流程上。
错误做法:去服务器上找二维码图片文件(比如 cache/qrcode.png),然后想办法把它取出来看。
为什么它经常失败?因为二维码的有效期很短(大约两分钟),而这条路径上有好几个耗时步骤:进目录、找到文件、scp 下载到本机、打开图片、掏手机、打开 QQ、扫码。任何一步慢一点,你扫的就是一张已经过期的图。更麻烦的是,NapCat 被踢下线后会不断重新生成二维码,你以为自己在扫最新那张,其实不是。
正确做法:开隧道,看着面板扫。面板里的二维码会自动刷新,所以你不用担心过期——你只需在它刷新的间隙扫一下。
# 第 1 步:在你自己的电脑上开隧道(窗口别关)
ssh -i $key \
-L 6099:127.0.0.1:6099 root@<你的服务器 IP>
# 第 2 步:在服务器上(另开一个窗口)取面板 token
cd /root/owl && docker compose logs napcat | grep -i "webui token"
# 第 3 步:在你本机浏览器打开(把 token 换成上一步查到的)
# http://127.0.0.1:6099/webui?token=<你的面板口令>
# 第 4 步:点「QQ 登录」→ 手机 QQ 扫码 → 手机上点「同意登录」
# 第 5 步:验证(第 4 步之后不要马上关隧道,先确认真的好了)
ssh -i $key root@<你的服务器 IP> "cd /root/owl && bash tools/healthcheck.sh"
▲ 完整的恢复操作卡。第 5 步是关键:看到 ✓ QQ 登录有效 才算结束。不要以「我扫了码」作为完成标准,要以「健康检查通过」作为完成标准。这一节的方法论会在第九章(工程素养与远方)被抽象成通用的排障与复盘习惯。
警告扫码之前,先确认一件事:没有第二个 NapCat 正在登录同一个 QQ 号。如果你三天前在笔记本上调试过一个实例、它还在后台跑着,那你现在扫码会引发两个实例争抢会话——很可能导致腾讯直接把登录态作废,让你刚扫的码立刻失效。一个「怎么扫都扫不上」的诡异现象,一半以上来自这个原因。
最后,把整套恢复流程压成一句话,因为掉线时你可能在半夜、可能很烦、可能只想赶紧弄好:
开隧道 → 打开面板 → 扫码 → 跑健康检查 → 看到「QQ 登录有效」。
五步,五分钟以内。这套流程的价值不在于它有多聪明,而在于它在你不聪明的时候也能用——凌晨三点、刚被叫醒、脑子里什么都没有,照着做就能恢复。这套方法论在第九章(工程素养与远方)会被抽象成通用的排障与复盘习惯。
10. 更新与回滚:改坏东西之后你能怎么办
前面九节讲的是「怎么让它跑起来、怎么知道它坏了」。这一节讲第三件事:怎么安全地改它,以及改坏了怎么退回去。
这件事的重要性可能超出你的预期。在第三章(Git 与版本控制)里我说过一句话:一旦你知道随时可以退回去,你就敢做实验了。部署阶段把这句话兑现的方式,就是让「回滚」变成一个三分钟内能完成的动作。
10.1 代码怎么上服务器:两条真实的路
有两种常见做法,各有适用场景。
方式一:scp 直接传文件(OWL 实际用的方式)。它是加密的,适合把几个文件传到服务器上。
# 在你本机的 PowerShell 里执行
# $key 就是第三节里定义的那个变量:指向你本机的 server-key.pem
$key = "<你的密钥目录>/server-key.pem"
$IP = "root@<你的服务器 IP>"
# 传机器人代码(只传 .mjs,不动数据)
scp -i $key "<你的项目目录>\qq-bot\bot\*.mjs" "${IP}:/root/owl/bot/"
# 传配置(改人设时只传这一个文件)
scp -i $key "<你的项目目录>\qq-bot\bot\config.json" "${IP}:/root/owl/bot/"
# 让改动生效
ssh -i $key $IP "systemctl restart qqbot"
▲ 三条命令,就是 OWL 的真实更新流程。注意它的精确性:只传 *.mjs 和 config.json,从不复制整个目录。这不是为了省时间,而是为了安全——如果传的是整个目录,就很可能会顺手覆盖掉服务器上不该覆盖的东西。
这里有一条纪律必须写下来,因为它对应一个真实的灾难场景:scp 覆盖的目标目录,绝不能包含数据文件。还记得第六节讲的 QQBOT_DATA_DIR 吗?数据被挪到 data/botdata 正是为了让这种操作变安全。如果不挪,「更新代码」和「保留记忆」会是同一件事的两面——而它们本该完全独立。
方式二:git pull(代码在仓库里时更合适)。
# 在服务器上执行
cd /root/owl
git status # ★ 先看有没有本地未提交的改动,别盲目 pull
git pull # 拉取最新代码
# 如果依赖变了(package.json / package-lock.json 被改动过)
cd bot && npm ci --omit=dev
# 然后重启
sudo systemctl restart qqbot
▲ git pull 的关键前置动作是 git status。如果服务器上有你直接手改过、又没提交的文件,pull 可能会冲突或者把你的改动冲掉——而你正在改的可能正是修 bug 的关键一行。
那什么时候用哪个?我的判断是:
- 小改动、改人设、改配置 → 用
scp。快、直观、不需要服务器上有仓库。OWL 的更新节奏就是这样,一次改一两个文件。 - 整套代码更新、多人协作、需要清楚的版本历史 → 用
git。你会有明确的「这次更新包含哪几个提交」,回滚也变成一条git checkout。
顺便说一条两地踩过的坑:如果服务器上做过 vim 直接编辑,那它就有了本地改动。这时候 git pull 会变得麻烦,而你往往已经忘了自己改过什么。所以更好的纪律是:改动永远在本地做,服务器只接收。服务器上只做「读日志、重启、看状态」这几件事。
还有一个介于两者之间的工具值得知道:rsync。它和 scp 一样是同步文件,区别是它只传变化的部分——同一个目录里改了一个文件,它不会把另外几百个文件重传一遍。所以当你要同步的是一个不断长大的目录(比如日志、或者几百兆的缓存)时,rsync 会明显更快。它的常用形式是 rsync -avz --exclude=...:-a 保留权限和时间、-v 显示过程、-z 传输时压缩、--exclude 排除不想同步的目录。而 --exclude 这一项,正是保护 data/ 不被覆盖的关键。
10.2 改了配置为什么要 restart
这个问题看起来 trivial,但它背后有一个重要的机制问题:程序是什么时候读配置的?
有三种可能的答案,对应的操作完全不同:
| 读取时机 | 改完要做什么 | OWL 的情况 |
|---|---|---|
| 只在启动时读一次 | 必须 restart | 部分配置(如 server.port、server.token)属于这一类——监听端口已经建立了,改配置不可能让它换端口 |
| 每次用到时重新读 | 什么都不用做 | 大部分业务配置的效果接近这一类 |
| 监听文件变化,自动重载 | 什么都不用做(但行为可能不完整) | config.json 是这一类:真实代码里用 fs.watch 做了热重载 |
真实代码里的热重载大概长这样(简化版):
// 热重载 config.json(改完即生效,不用重启)
let reloading = false;
fs.watch(CONFIG_PATH, { persistent: false }, () => {
if (reloading) return; // ← 去抖:防止一次保存触发多次
reloading = true;
setTimeout(() => {
reloading = false;
try {
cfg = loadConfig(); // 重新读一遍配置
log("♻️ 配置已热重载");
} catch (e) {
log("⚠️ 配置热重载失败,继续用旧配置:", e.message);
}
}, 200);
});
▲ 以下是简化版(省略了错误分支与日志细节)。这里有两个值得学的设计:去抖(避免编辑器一次保存触发多次重载)和失败时保留旧配置(配置写错了不至于让机器人崩掉)。
但即使有热重载,实践上我仍然建议改完配置就 restart。理由有三条:
- 不是所有字段都能热重载。端口、token 这类在启动时就落定的东西,改了必须重启。你不可能每次都记得「这个字段能热载、那个不能」。
restart让你获得一个确定的起点。热重载是「大概生效了」,重启是「一定从这份配置开始」。排查问题时,确定性比省下的三秒钟值钱得多。- 热重载可能导致前后不一致的状态。比如机器人已经用旧配置积累了一些会话状态,换了新配置后这些状态的语义可能变了。重启让它从干净状态开始。
技巧改完配置,养成「先验证 JSON 合法性、再重启、再看日志」的三步。验证 JSON 只要一行:node -e "JSON.parse(require('fs').readFileSync('bot/config.json','utf8'));console.log('JSON OK')"。在一个 JSON 文件上花三秒钟,能省下你「改了这个文件之后机器人就崩了」的十分钟。
10.3 回滚:改坏了怎么退回去
回滚是「安全实验」的另一半。它由两部分组成,缺一不可:代码能回退,数据能恢复。
代码回退。如果你用 Git,回滚是一次非常明确的操作:
# 在服务器上:先看最近几次提交,找到"上一个好版本"
cd /root/owl && git log --oneline -10
# 方式 A:临时回到某个提交(不改历史,适合"先退回来看看到底是不是这次改坏的")
git checkout <上一个好版本的哈希>
# 方式 B:撤销最近一次提交带来的改动(前提是你的每次提交是原子的)
git revert --no-edit HEAD
# 回退之后,别忘了重启
sudo systemctl restart qqbot
▲ 关键在 git log --oneline -10:回滚的前提是你能一眼看出「哪个版本是好的」。这就要求提交信息写得像人话——这是第三章强调过的事情,现在它有了实际代价。
如果你用 scp(像 OWL 这样),回滚就靠你本地留着上一版文件:
# 在你本机,把上一版代码重新传回去
scp -i $key "<你的项目目录>\qq-bot\bot\llm.mjs.bak" "${IP}:/root/owl/bot/llm.mjs"
ssh -i $key $IP "systemctl restart qqbot"
▲ 这个做法明显更脆弱:它依赖你「记得备份」「找得到那个 .bak」。这就是为什么我建议你尽早用 Git——回滚的可靠性不应该依赖于你的记忆。
数据恢复。这一部分比代码回滚更容易被忽略,也更致命。因为代码可以重写,记忆丢了就是丢了。
要备份的东西不多,但每一样都不能少:
| 备份什么 | 路径 | 丢了会怎样 | 频率建议 |
|---|---|---|---|
| 记忆、会话上下文 | data/botdata/ | 记忆丢失。对使用者是不可逆的伤害——她再也记不住你说过的话 | 每周一次;改代码前必须做一次 |
| 人设与配置 | bot/config.json | 人设、限流、关键词全没 | 每次改动后(它很小,且总在变) |
| QQ 登录态 | data/qq | 要重新扫码(可以接受,但很烦) | 可选。它很大(158 MB+),且失效后备份也没用 |
| API Key | bot/llm.local.json | AI 不可用 | 不需要在服务器上备份——你本机那份才是原件 |
第三行的判断值得解释一下,因为它体现了「备份不是无脑全备」:登录态又大又短命。它占 158 MB,而它随时可能被腾讯作废——你备份了一份,明天它可能就无效了。存它等于定期搬运一堆会过期的垃圾。所以对它的正确态度是「接受要重新扫码」,而不是「花力气备份」。
第一行就完全相反:data/botdata 很小(都是 JSON 和日志文本),而它的价值随时间增长——一个人跟你聊了三个月,那份记忆是不可替代的。低成本、高价值、不可再生,这三个条件同时满足的东西,才值得认真备份。
# 一个够用的备份命令(在你本机执行,把远端数据拉到本地)
scp -i $key -r "${IP}:/root/owl/data/botdata" ".\backup\botdata-$(Get-Date -Format yyyyMMdd)"
▲ 目录名带上日期,这样你自然就有了「多个版本」。没有日期的备份等于只有一个备份——下一次覆盖就没有退路了。
10.4 改人设前先在本地试聊
更新流程里最常用、也最需要纪律的一个场景,是改人设。因为它看起来「只是改文字」,所以最容易直接在线上动手——而这恰恰是最容易出事的地方。
项目里有一份专门讲这件事的文档,它的核心思路只有一句话:本地改 → 本地试聊 → 满意了再推给线上。千万别直接在线上瞎改。
三步法,按顺序走:
# 第 1 步:改人设源文件(纯文本,好读好改)
# 改 qq-bot/persona-source.txt
# 注意:不要手改 bot/config.json 里的人设——理由见下面的说明
# 第 2 步:本地试聊(不碰线上、不用扫码)
cd qq-bot
node tools\chat-preview.mjs
# 它会读 persona-source.txt,在临时副本里起一个实例,
# 自动跑 7 个固定场景:打招呼 / 考砸了 / 说爱好 / 问身份 / 想抄作业 / 为什么学 / 情绪低落
# 第 3 步:满意了再推给线上
node tools\_set-prompt.mjs persona-source.txt # 本地:把源文件写进 config.json
scp -i $key bot\config.json "${IP}:/root/owl/bot/" # 上传
ssh -i $key $IP "systemctl restart qqbot" # 重启生效
▲ 三步法:改源文件、本地试聊、推上线。第 1 步里那个「不要手改 config.json」的提醒很关键,理由见下段。
人设的唯一权威来源是 persona-source.txt,不要直接改 config.json 里的人设。因为 config.json 里人设是一整行超长字符串(4000 多字),手改容易改坏 JSON;而且下次执行 _set-prompt.mjs 时,你手改的部分会被覆盖掉。这就是「单一真源」原则——同一个信息只在一个地方是权威的,其他地方都是它的副本。
第 2 步的「本地试聊」值得强调它的三个优点:
- 不碰线上。它在临时副本里起实例,不碰线上的 config、日志、记忆。你可以在里面随便乱改,改到满意为止。
- 不连 QQ、不用扫码。它只真调一次 DeepSeek 生成回复,把整条 AI 链路走通,但不涉及协议端。所以它既真实(真的调模型)又安全(不碰真实用户)。
- 可对比。固定跑那 7 个场景,所以你改前改后可以直接对比同一批问题下的回答差异。这比「感觉好像变好了」可靠得多。
文档里还给了一份「跑完检查四个点」的清单,我认为它比任何技术细节都更能体现这个项目的态度:
1. 有没有说「加油 / 你已经很棒了」这类套话?
2. 提问是不是太多?
3. 情绪场景有没有把可爱收住?
4. 有没有开始说教?
注意这四个问题都是否定的形式——它们问的不是「她做得好不好」,而是「她有没有踩到我们明确不想踩的线」。这是一个面向真实使用者的项目应有的验收方式:先把底线守住,再谈精彩。
想一想假如你把改人设的流程反过来做:直接在服务器上 vim bot/config.json,改完重启,然后用真实用户来测试效果。请具体列出这样做可能造成的三种后果,并说明哪一种是你最不能接受的。提示:想一想你面对的是什么样的用户,以及「试错」这件事在这里的成本是多少。
11. 安全清单:一份可以勾选的东西
前面十节散落着很多安全决定。这一节把它们收成一份清单。用法很直接:部署完成之后,逐条对照,能勾的勾上,不能勾的写下「为什么我现在不勾」。
我把它分成四组。前三组是「必须做」,最后一组是「做了更好」。
11.1 网络暴露面(这一组全是必须做)
- ☐ 安全组 / 防火墙只放行 22。其余端口一律不开。这是第一道防线,也是收益最高的一条。
- ☐ 确认 3001 只监听
127.0.0.1。验证方式:ss -tlnp | grep 3001,输出里应该是127.0.0.1:3001而不是0.0.0.0:3001。0.0.0.0意味着「本机所有网卡」,也就是公网也能连。 - ☐ 6099(NapCat 面板)不对公网开放,只走
SSH隧道。面板能登 QQ、能改配置,它的权限比 API Key 还高。 - ☐ 反向 WS 带 token 鉴权,且两边的 token 一致。它是防止别人冒充协议端的唯一手段。
- ☐ NapCat 面板登录后改掉默认的访问口令。默认值等于没有密码。
11.2 身份与凭据
- ☐ 服务器禁用密码登录,只用密钥。并且先验证密钥能登录,再关密码——顺序错了会把自己锁在门外。
- ☐ 私钥文件权限收紧(
chmod 600),且永不提交进 Git。它应该出现在.gitignore里。 - ☐
bot/llm.local.json权限 600,且不在部署包里。它被单独放在一个文件里的原因之一,就是为了能单独设权限、单独排除。 - ☐ 云控制台开启双因素认证。服务器加固保护不了你的云账号,而云账号能重置服务器的一切。
- ☐ 在模型服务商的控制台设置消费上限。这一条是「万一 Key 泄露」的止损线——它把最坏后果从「被刷爆」降级为「被刷到上限」。
11.3 运行权限与数据
- ☐ 不用 root 跑业务。OWL 的
NAPCAT_UID/NAPCAT_GID就是为这个留的口子。现在默认是 0(root),这是一个已知的、可以改进的地方——把它写成变量而不是写死,本身就是「为将来改进留余地」的做法。 - ☐ 数据目录权限合理。
data/下面有登录态和用户记忆,不该是「所有人可读」。 - ☐ 日志里不打印隐私。这是一个容易被忽略的泄露渠道:日志会被备份、会被贴到论坛求助、会被同步到别的机器。项目在这一点上做得很克制——日志记的是「谁发了消息」这类元信息,不把对话原文整段写进去。你要在自己写日志时继承这个纪律:能定位问题的信息已经够了,不需要把内容抄一遍。
- ☐ 定期备份
data/botdata。记忆是不可再生的数据,而它的备份成本极低。 - ☐ 部署用的私钥、Key 文件用完妥善保管或删除,不要长期散落在桌面和聊天记录里。
11.4 稳定性即安全
这一组是我自己加上去的,因为很多人不会把「稳定」和「安全」放在一起想。但在这个项目里它们确实是一回事。
- ☐ 容器日志限额配置好(
max-size: 10m、max-file: 3)。磁盘满是可用性问题,而一个磁盘满的服务器上,所有别的机制都会开始失效——日志写不进去、备份做不了、容器起不来。 - ☐ 日志轮转配置好(
logrotate或 systemd 侧)。同上。 - ☐ Swap 已启用并且写进了
/etc/fstab。否则重启后 Swap 就没了,而你不会发现——直到某次内存突发。 - ☐ 健康检查定时任务在跑,并且你有办法看到结果。一个没人看的检查等于没有检查。
- ☐ 限流配置合理(
llm.limits)。限流同时是成本控制和安全机制:它挡住的不只是刷屏的人,还有「某段代码陷入重试循环」这种自己造成的雪崩。
这份清单里有一条我想额外说明,因为它是唯一一条「已知不完美」的:不用 root 跑业务。OWL 目前是以 root 运行的,它的改进路径已经预留好了(那两个环境变量)。我把这条留在清单上并标上未完成,是因为一份诚实的安全清单应该有未勾选项。一份全是勾的清单,通常意味着写清单的人在骗自己。
要点这一份清单在第九章(工程素养与远方)会以另一种面貌出现——那里会把它抽象成「任何项目都该过的几道关」。现在你只需要知道:安全不是一个功能,是一组持续的习惯。它不是「我加了一个 token 就安全了」,而是「我每次加一个新端口、一个新依赖、一行新日志时都问一句『这会不会让攻击面变大』」。
12. 成本与风控:必须诚实的部分
最后一节正文。这一节不写代码,它要给的是判断——因为这一章讲的所有东西,最终都要回答一个很现实的问题:这件事值不值得做,以及要做到什么程度。
12.1 算一遍真实的账
先把钱说清楚。OWL 的真实成本是两张表:
| 项目 | 费用 | 计费方式 | 失控的样子 |
|---|---|---|---|
| 云服务器(轻量,账单商品名写 2 核 2G,系统内实际可用 1608 MB 内存) | 68 元 / 年 | 按时间,开机就花钱 | 忘记续费 → 到期直接停机(不是「慢」,是「没了」) |
| DeepSeek API | 每月几毛到几块钱 | 按量,按 token 计费 | 被刷屏、或代码陷入重试循环 → 短时间内烧掉大量额度 |
| 合计 | 约 70–100 元 / 年 | — | — |
一年不到一百块。这个数字低到会产生一个错觉:「这么便宜,那我不用管它。」这个错觉很危险,因为这两种费用的失控方式完全不同,而其中一种的速度快得多。
服务器的费用是线性、可预测、上限明确的:68 元一年,无论你怎么折腾都是 68 元。它唯一的风险是「你忘了它」。
API 的费用是非线性、取决于使用方式、上限不明确的。它便宜是因为「聊天场景每次只花几厘钱」,但如果用量突然放大一百倍呢?如果某段代码把「调用失败」写成了「立刻重试」呢?如果有人在群里 @ 她一百次呢?
所以成本控制的核心不在「省」,而在给不可预测的东西装上刹车。OWL 装了三道:
- 入口限流(
llm.limits)。单人在群里每分钟最多 6 次、单群每分钟 10 次、全局每分钟 60 次、单次输入超过 400 字直接拒绝。这四道闸门同时挡住了「恶意刷屏」和「输入超长文本」两种烧钱方式。
顺便说一个你一定会遇到的坑:项目里的配置说明文档给的是旧示例值「4 次」,而线上bot/config.json里实际写的是userPerMinute: 6。文档里的示例值与线上真实值不一致——这不是文档「错了」,而是配置改过、文档没跟着改。所以任何时候要确认行为,都要以配置文件为准;发现两者不一致时,顺手把文档改掉,就是把「技术债」还掉一小笔。 - 上下文轮数限制(
history.maxTurns)。记住最近 8 轮,而不是记住全部。因为历史会一起发给模型——记得越久,每一句话越贵。这是一个体验与成本之间的旋钮,而它的默认值是经过权衡的。 - 输出长度上限。人设里写着「默认 70 字以内」,单次上限 800 tokens。输出通常比输入贵,所以限制回复长度是最直接的省钱手段。
然后,在代码之外再加一道:在服务商控制台设置消费上限。这是一条「无论如何都不会被刷爆」的底线。它的意义不在于省钱,而在于把最坏情况的后果变得可承受。
技巧估算成本的方法:一次对话大概消耗多少 token,乘以你的预期日活次数,再乘以单价。你会发现「一个月几毛钱」的前提是「每天几十次对话」。所以养成习惯:每次要加一个「自动调用模型」的功能时,先算一遍它的最坏用量。第八章(Prompt、RAG 与多模态)会继续讲这件事,因为 token 怎么算在那时才会真正清楚。
12.2 风控的现实:为什么「接受掉线」是一个合理的工程选择
这一节的核心判断,我在第五节已经铺垫过,现在把它完整地说出来。
现实是这样的,没有粉饰的余地:
- NapCat 是第三方协议端,腾讯会检测并处理这类登录。
- 实测记录:两次被踢,间隔约 13 小时。证据都是腾讯服务端主动发的下线通知。
- 同一时期健康指标全部正常——所以这和你的服务器、网络、代码都无关。
- 这不是 bug,不会因为「换个参数」或「等一个修复版本」而消失。
- 反复被风控的账号,有被「限制登录」甚至「封号」的风险。
面对这样一组事实,有三种可能的反应:
| 反应 | 它背后的想法 | 后果 |
|---|---|---|
| 对抗 | 用更激进的手段绕过检测(把密码交给容器、降级旧版 QQ、频繁重登) | 把「被踢」升级成「被限制」或「封号」,同时引入新的安全风险。拿不可逆的风险去换一点便利,是坏交易。 |
| 否认 | 「应该是我配置不对,我再调调」 | 把大量时间花在一个结构性问题上的调参,最后必然失败,并且会对自己的能力产生错误怀疑 |
| 接受并管理 | 「它一定会掉。所以我要做的是把恢复成本压到最低」 | 这是唯一可持续的态度。它把问题从「如何不出故障」转成「如何快速恢复」——而后者是可以工程化解决的 |
第三行就是 OWL 现在的状态。它的具体体现是:健康检查每 5 分钟跑一次、明确区分「被踢下线」和「等待扫码」、掉线时打印出可照做的恢复步骤、恢复流程压成五步五分钟。这些工作的总和,就是「接受掉线」这个决定的技术含量。
我想让你注意一个更普遍的判断:「接受一个已知缺陷」不是放弃,而是一个需要认真设计的决定。它的前提是:你知道它会发生、你知道它发生时你能看出来、你知道发生了怎么处理、你还知道它的最坏后果是什么。如果这四件事你都答不出来,那「接受」就只是「没管」。
要点那么长期方案是什么?文档里的判断很清楚:换腾讯官方机器人平台。它完全合规、不会被踢、不会风控、不会封号,代价是需要开发者入驻审核,而且功能受官方限制——目前主要是群聊场景,一对一私聊自由度较低。
而这里有一个当初的设计决定,现在体现了它的价值:现有代码里 AI、人设、安全机制(危机识别、价值观、记忆)都是独立模块,换协议层不需要重写。这就是分层架构的回报——它让「换掉最下面一层」从「重做项目」变成「替换一个模块」。你今天做出的架构选择,决定的是你明天还有没有退路。
12.3 合规与伦理:判断,而不是口号
最后一段,讲一件比技术更难的事。
你的机器人登录的是一个真实的 QQ 账号。它会出现在真实的对话里,会被真实的人当作一个「存在」。这带来几条不是技术问题的边界。
第一条:不骚扰,不群发。这是风控的底线,也是做人的底线。判断标准很朴素:如果这个行为由一个人来做,会不会让人反感?批量加群、给陌生人发消息、群发通知——这些行为无论由人还是由程序做,本质都是骚扰。而程序的可怕之处在于它「不知疲倦」,所以它把骚扰的规模放大了几个数量级。
第二条:控制频率,主动克制。OWL 的 llm.trigger 里有一条设置:群里必须 @ 她才回应。这不是技术限制,这是一个选择——她选择不插嘴。在一个几十人的群里,一个每条消息都会接话的机器人,会让这个群变得没法聊天。同理,llm.limits 里的限流不只是防刷,它也是在说「我不打算回应每一次呼唤」。
第三条,也是最重要的一条:用真实账号跑协议端之前,先想清楚代价由谁承担。
如果被封的是你的私人号——绑着支付、连着几年的聊天记录、是朋友找你的方式——那么「可能被封」这个风险就落在一个你无法承受的地方。而如果这个机器人是给真实的高中生用的,那风险还包括另一层:在她本来该在的深夜,她不在。
所以文档里那两条建议——「专门用一个小号」和「改用官方平台」——不是技术建议,是责任分配的建议。它们回答的问题是:这件事如果出错了,谁会受伤,以及这个伤是不是必要的。
我要给出的判断是这一句:做这个机器人,技术上最难的部分(容器、systemd、排障)是可以学会的;而真正区分一个人做得好不好的,是他能不能诚实地回答「我知道这件事会在什么时候坏、坏了谁会受影响、我为此做了什么准备」。
前六章给你的是「做得到」的能力。这一章想给你的,是「知道自己在做什么」的能力。
自查:你是不是真的懂了
先自己回答,再展开参考答案。凡是你只能靠「感觉」回答的,说明还要再读一遍。
-
为什么 NapCat 的容器必须用
network_mode: host?如果改成默认的 bridge 模式,你会看到什么现象,以及为什么那个现象容易让人误判?参考答案
因为机器人本体作为 WebSocket 服务端监听在宿主机的
127.0.0.1:3001,而 NapCat 作为客户端要反向连过来。host模式下容器与宿主机共用网络栈,容器里的127.0.0.1就是宿主机,能直连;bridge 模式下容器有自己的 IP(172.x 网段),它里面的127.0.0.1指向它自己,于是它连的是自己身上并不存在的 3001 服务。现象:NapCat 按
reconnectInterval: 3000每 3 秒重连一次,机器人日志一直停在「等待协议端(NapCat)连接…」。容易误判的原因是:报错信息里通常只有「连接被拒绝」这类通用文本,不会告诉你「你连的是你自己」。所以人会去反复检查 URL 拼写和端口号,而真正的错误在「这个地址属于哪台机器」这个层面。 -
请分别解释
docker-compose.yml里的三个字段为什么这么写:ACCOUNT、TZ、restart: always。每一条都要说出「删掉它会出现什么现象」。参考答案
ACCOUNT:镜像的启动脚本见到它就会带-q <QQ号>启动,做「快速登录」。删掉它 → 官方镜像默认不自动登录,每次容器重启都要重新扫码,「24 小时在线」变成一句空话。TZ=Asia/Shanghai:设定容器内时区。删掉它 → 容器内常为 UTC,NapCat 的日志时间与机器人日志时间相差 8 小时。排查「她什么时候开始不回消息」时,两条相差 8 小时的时间线会让你得出完全错误的结论。restart: always:容器退出就重启,Docker 守护进程重启后也把它拉起来。删掉它(默认no)→ 容器一旦退出就永远停在那里,而且整机重启后不会自动起来。注意细节:重启策略只在容器成功存活至少 10 秒后才生效,这是为了防止「根本起不来的容器」陷入无限重启,反而让真正的错误被刷掉。 -
systemd单元里的WorkingDirectory为什么必须显式写上?如果漏了它,你会在哪里看到什么样的报错,而那个报错为什么会把你带偏?参考答案
因为程序里所有相对路径(
config.json、bot.log、history.json)都是相对于进程的工作目录解析的。你手动cd到bot/目录再跑node bot.js时一切正常,但 systemd 启动进程时的默认工作目录不是那里。报错会出现在应用日志或
journalctl -u qqbot里,内容通常是「找不到 config.json」或ENOENT: no such file or directory。带偏你的地方在于:这个报错的形状是「文件不存在」,而不是「目录不对」,所以你的第一反应会是去ls那个文件——而文件明明在。于是你会怀疑权限、怀疑部署没传上去、怀疑磁盘坏了,唯一正确的答案(工作目录)却被忽略。记住这条通用规律:「文件明明在,程序却说找不到」,先查工作目录。 -
「假活」是什么?为什么
restart=always、systemd 的自动重启、以及 Swap 这三样东西全都救不了它?参考答案
「假活」指:进程活着、容器健康、端口在监听、反向 WebSocket 连接也是 ESTAB,甚至远端还有心跳,但业务已经死了——用户发消息没有任何反应。真因是 QQ 的登录会话被腾讯在服务端作废(或主动踢下线),消息根本到不了 NapCat。
那三样救不了它,是因为它们解决的都不是这个问题:
restart=always与 systemd 自动重启只在进程退出时触发——而这里进程压根没退出;Swap 解决的是内存不足。它们的共同点是「都在监控进程的存活」,而这个故障恰恰是「进程活着但业务不工作」。所以唯一的恢复方式是人工重新扫码。这也正是「监控进程」与「监控业务」必须分开理解的原因。 -
现象:用户说机器人不回消息。你已经确认
systemctl is-active qqbot是 active、docker compose ps显示容器 Up、ss -tn | grep :3001有 ESTAB。请写出你接下来的三步动作,并说明每一步能排除什么。参考答案
合理顺序:(1)跑
node tools/_probe-reverse.mjs(必须在服务器上跑,因为 3001 只监听 127.0.0.1)。它冒充 QQ 核心发一条/ping:有pong→ 机器人本体正常,问题在 NapCat 与 QQ 之间;无响应 → 问题在机器人本体或 AI 链路。(2)看 NapCat 日志docker compose logs --tail=100 napcat | grep -iE "踢|失效|二维码|token"——找「被踢下线 / 身份已失效」这类证据,它负责解释原因。(3)跑bash tools/healthcheck.sh做一次完整检查(八项),重点是确认QQ 登录有效还是「已被腾讯踢下线」。每一步排除的东西:第一步把「机器人这半条链」和「NapCat 到 QQ 那半条链」分开,等于把搜索空间砍一半;第二步在剩下的那半条链里找具体原因(会话失效 / 还在等扫码 / token 不一致);第三步确认结论并给出恢复指引。注意:不要用「最近日志里有没有二维码」这类历史事件做判据——被踢后 NapCat 会不断重印提示,历史日志会一直命中,必然误报。
-
现象:机器人每次运行几小时就「自己重启」,
systemctl status qqbot显示status=9/KILL。请判断这是哪一类故障,并写出你要看的两个地方的命令。参考答案
status=9/KILL里的 9 是SIGKILL——该进程无法捕获、无法清理、无法写日志。所以「程序被杀了但应用日志里什么都没有」是正常现象,不代表日志功能坏了。最常见的解释是 OOM Killer(内存耗尽)在 1.6 G 的机器上动手了。要看的两个地方:(1)内核日志——
journalctl -k | grep -i "out of memory\|oom-kill"(或dmesg -T | grep -i oom)。这条信息不在你的应用日志里,它在另一层。(2)当时的资源——free -m看可用内存与 Swap 使用量、swapon --show确认 2 G Swap 真的启用着(它还必须写进/etc/fstab才能在重启后保留)。顺带说明「为什么 476 MB 的常驻占用还会 OOM」:常驻是平均值,OOM 是突发事件;Swap 的作用是安全气囊而不是第二块内存——它让「原本会被杀掉的一次尖峰」变成「稍微慢一下」,代价是硬盘比内存慢几个数量级。
-
现象:你改完
bot/config.json里的限流参数,systemctl restart qqbot之后发现行为一点没变。请列出至少三种可能的原因,并说明怎么逐一排除。参考答案
可能原因:(1)你改的是本机文件,没同步到服务器——用
md5sum或直接比对文件内容确认;(2)你改错了地方,比如改的字段名拼错、改到了注释里、或者改的其实是另一个文件(例如改了persona-source.txt却没执行_set-prompt.mjs把它写进config.json);(3)JSON 被改坏了,程序解析失败后回退到旧配置或默认值——真代码里loadConfig()若抛错就会用上一次的cfg,所以你「改坏了」的表现可能恰好是「什么都没变」;(4)如果你改的是.service单元文件,漏了systemctl daemon-reload。排除方法:先
node -e "JSON.parse(require('fs').readFileSync('bot/config.json','utf8'));console.log('JSON OK')"验证 JSON;再grep一下你改的那个字段确认它真的在文件里;然后systemctl restart qqbot并tail -30 data/botdata/bot.log看启动时打印的配置摘要与时间戳;必要时用stat看文件的修改时间是不是你刚才那次。 -
辨析容易混的概念:
systemctl start与systemctl enable;docker compose up -d与docker compose start;「监控进程」与「监控业务」。请各用一句话说清它们分别管什么。参考答案
start管「这一次运行」——现在把它跑起来;enable管「下一次开机」——按[Install] WantedBy建立开机启动链接。所以「开机自启」由enable负责,「崩溃自动重启」由单元里的Restart=always负责,这两件事互不替代。up -d是「按docker-compose.yml创建并启动」——它会读配置、必要时拉镜像、创建容器;start是「把已经存在的容器再启动起来」——它不会读你刚改的 compose 文件。所以改了 compose 必须up -d(必要时down再up -d),只想让停掉的容器继续跑才用start。监控进程问的是「那个程序还在吗」(
is-active、容器状态、端口在听);监控业务问的是「用户发消息,她能不能回」(端到端探针、真实消息验证)。前者便宜且几乎不误报,但测不到「假活」;后者才是用户真正在意的。 -
动手题:在你自己的电脑上(Windows 或 macOS 都行),用 Docker 起一个容器,并挂一个数据卷进去,然后证明「容器删了,数据还在」。请写出你实际敲的命令序列。
参考答案
核心是三步:跑起来 → 写数据 → 删掉容器再重建,看数据还在不在。
# 1) 建一个宿主机目录,作为"挂出来的数据" mkdir $HOME/owl-vol-test # 2) 起一个容器,把这个目录挂到容器里的 /data # -it 交互、--rm 退出后自动删除容器(正好用来验证"容器没了数据还在") docker run -it --rm -v "$HOME/owl-vol-test:/data" alpine sh # 3) 在容器内部(提示符变成 / #)写一个文件 echo "remember me" > /data/note.txt exit # 4) 容器已经没了(因为 --rm),但数据应该还在宿主机上 cat $HOME/owl-vol-test/note.txt # 期望输出 remember me # 5) 再起一个新容器,挂同一个目录,确认它"记得" docker run -it --rm -v "$HOME/owl-vol-test:/data" alpine cat /data/note.txt这就是
./data/qq:/app/.config/QQ的本质:冒号左边是宿主机,右边是容器;容器可死一百次,左边的数据不受影响。如果你在第 4 步看不到文件,检查三件事:挂载路径写错了(左边必须是你真实存在的目录)、你在容器里写的不是/data、以及 Windows 上的路径展开是否被 PowerShell 正确传递(必要时用绝对路径并加引号)。进阶一步:把
--rm去掉,用-d后台跑,然后docker rm -f <容器名>,再新建一个挂同样目录的容器。你会得到同一个结论——这正是「数据卷存在的唯一理由」。 -
开放题:这一章的标题是「让一个程序在千里之外活着」。请你用不超过 200 字回答:如果明天腾讯把这种第三方协议端的风控再收紧一倍(比如从 13 小时掉一次变成 3 小时掉一次),你会对你的部署做哪些改变?哪些做法是不管风控多严都值得保留的?
参考答案(开放题,只给评价标准)
这道题没有标准答案,但有明确的评分标准,你可以拿它给自己的答案打分。及格线是:答案里出现了「改变的是恢复成本,不是掉线本身」这个认识——因为风控的强度不由你决定,你能改的只有「掉线之后多快恢复」和「掉线期间别人会看到什么」。
好的答案会落到具体动作上,例如:把恢复流程再压缩(把扫码步骤写成一页操作卡、确认隧道的自动重连、把面板地址与 token 存在离手最近的地方);把「她不在」这件事变得对使用者可见(掉线时给一个自动回复或状态提示,而不是让人对着沉默猜);提高检查频率(从每 5 分钟改成每 1 分钟)但要避免刷爆日志;认真评估换官方平台的工作量——因为当掉线频率高到某个点,「接受掉线」这个决定就不再成立了,那时应该重新做决策而不是加倍硬扛。
不管风控多严都值得保留的:数据与代码分离(记忆不受更新影响)、结构化日志与分层排障方法、最小暴露面(只开 22 + 隧道 + token)、端到端探针、以及「先验证再关旧门」这类操作纪律。它们的共同点是:它们不针对某一个具体故障,而是针对「未知故障」的通用能力。风控收紧只是未知故障的一种。
不及格的答案是:只列「改成规避手段」(把密码交给容器、频繁重登)。它没有区分「能改的」和「不能改的」,而且把不可逆的风险当成了解法。
自问自答:把知识变成你自己的
下面这些问题没有标准答案,有些甚至没有答案。请不要在页面上浏览,拿一张纸写下来。写的过程就是思考的过程。
- 「写代码是与逻辑打交道,部署是与环境打交道」——回想你最近一次卡住,那次卡住的是逻辑问题还是环境问题?你是怎么分辨的?
- 我为什么会觉得「配置应该由 AI 帮我写好」?如果 AI 写好了而我读不懂,那么当它坏掉时,我手里还剩什么?
- 「接受掉线」和「懒得管掉线」的区别到底在哪几件事上?如果要把这个区别写成一张检查表,我会写哪几条?
- 我在这套系统里最不希望丢的数据是什么?为什么是它?我现在为它做了什么?
- 如果我的机器人有一天要服务一百个人而不是十个,这一章里哪些做法会先撑不住?是磁盘、内存、限流,还是「扫码」这件事本身?
- 「自动重启会掩盖问题」这句话,在我自己的学习过程中有没有类似的版本?(比如:反复重看教程,是不是也在掩盖「其实没懂」?)
- 安全清单里有一条我没勾上。我不勾它的真实原因是「不需要」「不会做」,还是「懒得做」?这三者的区别重要吗?
- 把「监控进程」和「监控业务」的区别用在我自己身上:我平时是怎么判断「我今天学得还行」的?我监控的是过程还是结果?
- 如果我必须向一个完全不懂技术的人解释「为什么凌晨三点她可能不在」,我会怎么说?我会不会觉得难以启齿?
- 这一章里最让我不适的那个判断是哪一个?我为什么会对它不适?
小结
这一章说了三件事。
一、环境是一等公民。本地和服务器是两个不同的世界:前者由你扮演「让程序活着」的角色,后者必须把这个角色交给系统。所以有了 Docker(把环境一起打包)、数据卷(让数据比容器活得久)、host 网络模式(让容器里的 127.0.0.1 就是宿主机)、以及 systemd(让程序成为系统的一部分)。这一整套东西的共同目的只有一个:在你不看着它的时候,它还能活着;而当你回来时,你还能查出它这段时间经历了什么。
二、反馈要靠自己造出来。这一章真正的难点不是配置语法,而是「反馈少」:配置写错一个字,程序可能什么都不说。所以你需要三种日志分层去读、需要把「监控进程」升级成「监控业务」、需要端到端探针、需要能区分「被踢下线」和「等待扫码」的健康检查、需要日志轮转和 Swap 这些「防止小问题变成事故」的兜底。最后,你需要一张现象对照表——因为真正的排障能力,是「看到一个现象就知道下一步该敲什么」。
三、诚实是一种工程能力。68 元一年、每月几毛钱的 API、以及一个每天可能掉一次线的协议端——这些数字都要被正面说出来,而不是被「应该没事吧」盖住。风控的现实不会因为你不喜欢它而改变,「接受掉线」是一个需要设计的决定(恢复五分钟、告警可见、代价已知),而不是一次放弃。同样诚实的还有那份安全清单:它有一条没勾上,而那条没勾上的存在本身就是它的可信度。
如果这一章只留下一句话,我希望是这一句:你写的程序,最终要在一个你不看着的、别人的机器上,替你做一件对真人有用的事。而「部署与运维」这个词的全部含义,就是让你有资格为那件事负责。
从下一章开始,我们会回到「她说什么」这件事上——但你会发现,所有关于 token、温度、上下文窗口的讨论,最后都要落回这一章的两个数字:钱和延迟。你在这里打下的地基,会在那里开始承重。
延伸:可以去哪里继续
顺带一句:这一章里出现的、以及你还没遇到过的所有术语,都收在术语总表里,可以搜索、可以按分类筛选。这一章新增的那一批(探针、端到端、镜像分层、复制截断……)也会出现在那里。
网站
- Docker 官方文档——学容器的唯一权威来源。这一章里关于
host网络模式、重启策略、端口映射的每一句话,最终都能在这里的原文档里找到出处。什么时候看:当你想知道「某个配置项到底还有哪些取值」时——比如你读到这里想知道restart除了always还有什么,直接搜 start containers automatically 那一页。英文,但句式简单,值得硬读一次。 - 《Docker——从入门到实践》(中文开源书)——如果官方文档让你觉得太快,这本是中文世界里最经典的那一本。它从「为什么需要容器」讲起,配图和命令都很扎实。什么时候看:现在就可以挑「镜像」「容器」「数据卷」三章看,正好和这一章互相印证。免费、开源、长期可访问。
- systemd 官方手册(systemd.service)——这一章里
Type=、RemainAfterExit=、Restart=的准确含义全都出自这里。什么时候看:当你需要写一个不是「跑一个前台程序」的服务时(比如要 fork、要 socket 激活、要 notify 就绪)。它很枯燥,但它是「事实」而不是「某人的说法」——这正是运维里最需要的东西。 - NapCat 官方文档——协议端的安装方式与配置字段的权威来源。注意它的安装方式更新很快(Docker、Shell 脚本、Linux Launcher、AppImage、甚至 Termux 都有),所以不要照抄某篇旧博客的安装命令,去看官方这一页。什么时候看:部署前扫一遍安装方式,配置反向 WS 时对照「网络通讯」那一节。
- OneBot v11 规范——反向 WebSocket 的连接方式、
Authorization: Bearer的鉴权约定、事件与 API 的 JSON 结构,都在这里。第四章(网络与协议)讲过它的事件格式,这一章用到了它的通信与鉴权部分。什么时候看:当你要自己写一个协议端或客户端、或者想搞清楚「为什么 token 有两种传法」时。 - 阿里云帮助文档——服务器、安全组、SSH 密钥、快照与备份、到期续费这些「平台侧」的事情,各家厂商的界面和名词都不一样,所以必须看你自己那家的文档。什么时候看:买服务器之前,以及每次遇到「控制台里的某个按钮是干什么的」时。
值得读的书(三本,够你用很久)
- 《鸟哥的 Linux 私房菜·服务器架设篇》(鸟哥)——这一章的很多「常识」在它那里有成体系的讲法:权限、服务管理、日志、防火墙、定时任务、磁盘与配额。什么时候读:当作手册,不要通读。你在这一章遇到任何一个「为什么会这样」的问题时,去里面找那一节。它讲得慢、很啰嗦,但正因为慢,它适合零基础的人反复查。需要你已经走过第一章。
- 《SRE:Google 运维解密》(Betsy Beyer 等)——如果你想知道「专业的人怎么做运维」,读这本。它提出了 SLO、错误预算、可观测性、事后复盘(postmortem)这些概念,也解释了为什么「追求 100% 可用」是错误的目标。什么时候读:不要现在读。等你把这一章的东西亲手做过一遍、至少经历过一次真实的「假活」和一次真实的掉线之后,再读它——那时你会发现书中那些抽象名词,其实都在描述你已经踩过的坑。它默认你有一定的工程经验。
- 《UNIX 与 Linux 系统管理手册》(Evi Nemeth 等)——系统管理领域的百科式教材,从进程、文件系统、网络一直讲到备份与安全。厚重、贵、不适合入门通读。什么时候读:当你想从「会照着文档做」走到「知道为什么该这么做」的时候,把它放在手边当参考书。它给你的是体系,而不是命令清单。
提醒不要收藏了就算看过。这一章只要求你做一件事:在自己电脑上装好 Docker,然后完成自查第 9 题那个实验——起一个容器、挂一个目录、写一个文件、删掉容器、证明数据还在。整个过程二十分钟,但它会让你第一次真正「看见」数据卷在做什么。做完这一步,你再去读 docker-compose.yml 里那两行 volumes,感觉会完全不一样。