Ways to Robot 从零到云端机器人

第七章 · 把她送上云端

部署与运维:让一个程序在千里之外活着

你的机器人已经在跑了,但那是别人帮你跑起来的。你大概说不出它为什么会在半夜掉线,也说不清为什么重启之后有时不用扫码、有时又要。这一章要做的事只有一件:把别人替你做的事,变成你能解释、能重做、能修的事。

0. 先看地图:这一章要带你去哪

读完这一章,你应该能回答

  • 为什么一个在你笔记本上跑得好好的程序,搬到服务器上会以各种奇怪的方式坏掉?
  • OWL 的真实配置文件里,每一行分别在解决什么问题?删掉它会看到什么现象?
  • 为什么 NapCat 的容器必须用 host 网络模式,而「容器里的 127.0.0.1」和「服务器的 127.0.0.1」居然不是一回事?
  • 「开机自启」和「崩溃自动重启」这两件事,分别由谁保证?为什么少一个都不行?
  • 进程活着、端口在听、容器健康,为什么机器人还是可能一条消息都收不到?
  • 磁盘被日志写满、内存被内核杀掉,这两件事你在日志里会看到什么字?
  • 给你一个「机器人不回复」的现象,你能不能按顺序自己定位到是哪一层坏了?

学完这一章,你会做这些事

  • 用 Docker 跑起 NapCat,并解释 host 网络模式为什么是必须的
  • 写出一个 systemd 服务单元,让程序开机自启、崩溃自动重启
  • 用日志与端到端健康检查发现「假活」,而不是只看进程还在不在
  • 按分层方法论排查真实故障,并安全地回滚一次
  • 算清成本,并说清风控与合规的真实边界

先说清楚这一章的难度曲线。前面六章,你写错了代码,报错会告诉你第几行错了;这一章不一样:配置写错一个字,程序可能什么都不说,只是安静地不工作。运维的困难不在于知识难,而在于反馈少。所以这一章会反复使用同一个动作:把「现象」和「机制」对齐。你看到某个现象时,脑子里应该立刻浮出「噢,这是哪一层在说话」。

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 内存。这个数字在今天的眼光里小得可笑,所以必须讲清它的真实能力边界,否则你会对它产生错误的期待。

资源真实数字它意味着什么
CPU2 核够用。机器人本体几乎没有计算量,它做的事是「等」:等消息、等 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 上去之后,不要急着装程序。先花十分钟把门锁好。下面五项,前四项几乎是所有公网服务器的共识做法。

  1. 禁止 root 用密码登录,只允许密钥。这是收益最大的一条。做法是编辑 /etc/ssh/sshd_config,把 PermitRootLogin 设成 prohibit-password(允许用密钥登录 root,但禁止密码),或者更保守地设成 no 并改用普通用户加 sudo。改完必须重启 ssh 服务生效。
  2. 加固前先确认你的密钥能登录。顺序极其重要:如果你先把密码登录关了、结果密钥其实没配通,你就把自己锁在门外了——那时只能去云控制台走 VNC 救援。正确的顺序是:先开一扇新门,验证能进去,再关上旧门。
  3. 改 SSH 端口?谨慎考虑。把 22 改成别的端口,能挡掉一部分「无脑扫 22」的脚本。但它带来的安全收益有限(会扫全网的人照样会做端口扫描),而代价是真实的:你以后每次连接都要多写一个端口参数、云安全组也要跟着改、某些工具会因此不工作。我的判断是:对个人项目,用密钥登录 + fail2ban 的收益比改端口高得多,优先级也更高。如果你确实想改,记得先确认新端口在安全组和系统防火墙里都放行了,再重启 ssh。
  4. 装一个 fail2ban。它做的事情很朴素:读日志,发现某个 IP 在短时间内反复登录失败,就调用防火墙把那个 IP 临时封掉。一行命令装上、默认规则就够用,它是「自动化的守门人」,把你从「每天手动看一遍登录日志」里解放出来。
  5. 给云控制台开双因素认证。这一条最容易被忽略,但它防的是一个完全不同的攻击面:前面四条保护的是服务器,这一条保护的是你的账号。而云账号的权限比服务器还大——它能重置服务器密码、能关机、能删盘、能改安全组。如果这个账号被撞了,你的所有加固都会被从更高一层绕过。

警告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/QQQQ 登录态、会话数据(实测 158 MB 以上)要重新扫码登录
./data/napcat/app/napcat/configNapCat 自己的配置,包括 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 具体做了这些事,顺序很重要:

  1. 读 docker-compose.yml,解析出服务、镜像、网络、卷。
  2. 检查本地有没有 mlikiowa/napcat-docker:latest 这个镜像。没有就去镜像仓库拉(第一次大约 1–2 分钟)。
  3. 创建容器:把卷挂好、环境变量注入好、按 network_mode 接好网络。
  4. 启动容器,并把它交给 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 的人不许连进来。

这段代码有三个点值得你学:

  1. 它接受两种传 token 的方式。这是照着 OneBot 规范来的:标准做法是 Authorization 头,但有些客户端不方便改头,规范允许退回 ?access_token= 查询参数。兼容规范的好处就是——你不用为每个客户端写一套适配。
  2. 校验失败时它主动关闭连接,并写一条明确的日志。这条日志是排障的灯塔:一旦你在机器人日志里看到 🚫 拒绝未授权连接,你就知道问题不是网络、不是端口、而是两边的 token 不一样。这比「连不上但什么都不说」友好一百倍。
  3. 它在做「最小权限」的思考:即使端口只监听 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(随便,只为区分)没什么后果
URLws://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 隧道(第三节的方法),不要在服务器上找二维码图片。原因很实在:二维码通常两分钟就过期,而「把图片从服务器上取出来、传到手机、再扫」这个流程经常刚好超过两分钟。面板里的二维码会自动刷新,所以直接看着它扫是最省事的。

扫完之后,验证必须按顺序做,因为每一步验证的是不同的层:

  1. 看机器人日志:tail -f /root/owl/data/botdata/bot.log,期望看到 🔗 协议端已连接 (127.0.0.1)。这一行证明「网络层 + 鉴权层」都通了。
  2. 看登录结果:紧接着应该出现 ✅ 机器人已登录: 1876148307 (OWL)。这一行来自 OneBot 的「生命周期事件」,证明 NapCat 确实登录进 QQ 了。
  3. 发一条 /ping:这是端到端的验证,从别人的手机出发,穿过腾讯、NapCat、反向 WS,到达你的代码,再回去。它期望的回复是 pong 🏓。
  4. 发一句真话(比如「我今天很累」),确认 AI 那一段也通。
  5. 关掉你自己的电脑,用手机再发一条。这一步才是「24 小时在线」的真正验收——它验证的是「你不在场时服务照常」,而不是「链路能通」。

第 3 步和第 5 步的区别值得专门想一想:/ping 通了说明链路通,但「关掉自己电脑还能收到回复」说明的是服务独立于你。前者是功能验证,后者是部署验证。只有第 5 步通过,你才真的把程序送上云了。

想一想把上面 8 个步骤重新看一遍,回答一个问题:哪几步是「做错了会报错」的,哪几步是「做错了会安静地不工作」的?把第二类找出来,然后想一想——为什么恰恰是这一类最危险?如果你要给这套流程加一个「自检」环节,你会把它加在哪一步之后?

8. 日志、监控与「假活」:这一章的第二个理论重点

服务跑起来了。现在进入运维的主战场:你怎么知道它还活着?这个问题比它听起来难得多,因为这一节要讲的故障,会让你所有的直觉都失效。

8.1 三种日志,分别属于三层

OWL 的日志有三个来源,它们不是重复,而是各管一层。分不清它们,你就不知道该去哪找答案。

来源怎么读它记录的是什么什么时候该看它
应用日志 bot.logtail -f data/botdata/bot.log你的代码自己写的话:收到谁的消息、调 AI 的耗时、拒绝了谁、登录成功最常用。想知道「业务上发生了什么」时看它
systemd 日志journalctl -u qqbot服务管理层面的事:启动、停止、崩溃、退出码、重启记录程序根本没起来或反复重启时看它。答案往往在 bot.log 里根本没有
容器日志docker compose logs napcatNapCat 进程写到标准输出的内容:登录、被踢、二维码、时间同步怀疑「消息根本没到我的程序」时看它

为什么这三种必须分开理解?因为它们回答的是不同的层上的问题: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 qqbotsystemd 层
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 Keygrep bot/llm.local.json 里有没有 "apiKey": "sk- 这个形状依赖层(外部服务能不能用)

第 8 项容易被忽略但很实用:它是唯一一项检查「外部依赖」的——前七项都在看这台机器自己好不好,而这一项在看「她还有没有能力调用模型」。少了它,一个 Key 文件被误删的机器人会看起来一切正常,直到有人想聊天。

第 4 项的设计思路特别值得讲,因为它是这个脚本里唯一的「聪明」之处,也是作者踩过两次坑才定下来的。脚本的头部注释把踩坑记录写在最前面:

判断登录状态用反向 WS 是否连着这个可靠信号——NapCat 只有在成功登录 QQ 之后才会去连反向 WS。绝对不要用「最近 N 分钟有没有二维码日志」做判据。NapCat 被踢后会无限重印二维码提示,历史日志会一直命中,于是「明明已登录」却被报成「等待扫码」(这个假警报我调了两版才修掉)。被踢下线后会残留「失效」字样,所以那类日志只用来解释原因,不能单独当作当前状态。

这段话里有三条可以迁移到任何监控系统的教训:

  1. 要选一个「状态量」而不是「事件量」做判据。「连接现在是否建立」是状态;「日志里出现过二维码」是事件。历史事件会一直留在日志里,用它判断现在,必然误报。
  2. 日志适合解释原因,不适合判定状态。「身份已失效」这句话很可能是三天前留下的。它告诉你可能的原因是什么,但不能告诉你现在是不是这样。
  3. 告警的误报会杀死告警的可信度。一个天天误报的检查,三天后你就会开始无视它——那时它等于不存在。所以「宁可不报,不可乱报」在这里是对的。

还有一条工程纪律,写在健康检查的注释里,我认为比脚本本身更重要:

设计上刻意不做「发现问题就自动重启」。因为登录失效重启也没用,而且自动重启会掩盖真实问题。它只负责记录 + 提示怎么修。

这句话是对「自动化」的一次清醒的拒绝。很多初学者学到「自动重启」之后会想把它用在所有地方:一发现异常就重启。但一个会自动重启的系统,会把「崩溃」变成「看不见的常态」。你失去了最宝贵的信号——「它曾经坏过」。所以正确的分工是:自动重启只处理「重启能解决」的问题(进程崩溃);而「重启不能解决」的问题(登录失效、磁盘满、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 qqbotfailed 或刚重启过 → 去 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。理由有三条:

  1. 不是所有字段都能热重载。端口、token 这类在启动时就落定的东西,改了必须重启。你不可能每次都记得「这个字段能热载、那个不能」。
  2. restart 让你获得一个确定的起点。热重载是「大概生效了」,重启是「一定从这份配置开始」。排查问题时,确定性比省下的三秒钟值钱得多。
  3. 热重载可能导致前后不一致的状态。比如机器人已经用旧配置积累了一些会话状态,换了新配置后这些状态的语义可能变了。重启让它从干净状态开始。

技巧改完配置,养成「先验证 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 Keybot/llm.local.jsonAI 不可用不需要在服务器上备份——你本机那份才是原件

第三行的判断值得解释一下,因为它体现了「备份不是无脑全备」:登录态又大又短命。它占 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 装了三道:

  1. 入口限流(llm.limits)。单人在群里每分钟最多 6 次、单群每分钟 10 次、全局每分钟 60 次、单次输入超过 400 字直接拒绝。这四道闸门同时挡住了「恶意刷屏」和「输入超长文本」两种烧钱方式。
    顺便说一个你一定会遇到的坑:项目里的配置说明文档给的是旧示例值「4 次」,而线上 bot/config.json 里实际写的是 userPerMinute: 6。文档里的示例值与线上真实值不一致——这不是文档「错了」,而是配置改过、文档没跟着改。所以任何时候要确认行为,都要以配置文件为准;发现两者不一致时,顺手把文档改掉,就是把「技术债」还掉一小笔。
  2. 上下文轮数限制(history.maxTurns)。记住最近 8 轮,而不是记住全部。因为历史会一起发给模型——记得越久,每一句话越贵。这是一个体验与成本之间的旋钮,而它的默认值是经过权衡的。
  3. 输出长度上限。人设里写着「默认 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、排障)是可以学会的;而真正区分一个人做得好不好的,是他能不能诚实地回答「我知道这件事会在什么时候坏、坏了谁会受影响、我为此做了什么准备」。

前六章给你的是「做得到」的能力。这一章想给你的,是「知道自己在做什么」的能力。

自查:你是不是真的懂了

先自己回答,再展开参考答案。凡是你只能靠「感觉」回答的,说明还要再读一遍。

  1. 为什么 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 拼写和端口号,而真正的错误在「这个地址属于哪台机器」这个层面。

  2. 请分别解释 docker-compose.yml 里的三个字段为什么这么写:ACCOUNT、TZ、restart: always。每一条都要说出「删掉它会出现什么现象」。
    参考答案

    ACCOUNT:镜像的启动脚本见到它就会带 -q <QQ号> 启动,做「快速登录」。删掉它 → 官方镜像默认不自动登录,每次容器重启都要重新扫码,「24 小时在线」变成一句空话。

    TZ=Asia/Shanghai:设定容器内时区。删掉它 → 容器内常为 UTC,NapCat 的日志时间与机器人日志时间相差 8 小时。排查「她什么时候开始不回消息」时,两条相差 8 小时的时间线会让你得出完全错误的结论。

    restart: always:容器退出就重启,Docker 守护进程重启后也把它拉起来。删掉它(默认 no)→ 容器一旦退出就永远停在那里,而且整机重启后不会自动起来。注意细节:重启策略只在容器成功存活至少 10 秒后才生效,这是为了防止「根本起不来的容器」陷入无限重启,反而让真正的错误被刷掉。

  3. 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 那个文件——而文件明明在。于是你会怀疑权限、怀疑部署没传上去、怀疑磁盘坏了,唯一正确的答案(工作目录)却被忽略。记住这条通用规律:「文件明明在,程序却说找不到」,先查工作目录。

  4. 「假活」是什么?为什么 restart=always、systemd 的自动重启、以及 Swap 这三样东西全都救不了它?
    参考答案

    「假活」指:进程活着、容器健康、端口在监听、反向 WebSocket 连接也是 ESTAB,甚至远端还有心跳,但业务已经死了——用户发消息没有任何反应。真因是 QQ 的登录会话被腾讯在服务端作废(或主动踢下线),消息根本到不了 NapCat。

    那三样救不了它,是因为它们解决的都不是这个问题:restart=always 与 systemd 自动重启只在进程退出时触发——而这里进程压根没退出;Swap 解决的是内存不足。它们的共同点是「都在监控进程的存活」,而这个故障恰恰是「进程活着但业务不工作」。所以唯一的恢复方式是人工重新扫码。这也正是「监控进程」与「监控业务」必须分开理解的原因。

  5. 现象:用户说机器人不回消息。你已经确认 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 会不断重印提示,历史日志会一直命中,必然误报。

  6. 现象:机器人每次运行几小时就「自己重启」,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 的作用是安全气囊而不是第二块内存——它让「原本会被杀掉的一次尖峰」变成「稍微慢一下」,代价是硬盘比内存慢几个数量级。

  7. 现象:你改完 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 看文件的修改时间是不是你刚才那次。

  8. 辨析容易混的概念: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、容器状态、端口在听);监控业务问的是「用户发消息,她能不能回」(端到端探针、真实消息验证)。前者便宜且几乎不误报,但测不到「假活」;后者才是用户真正在意的。

  9. 动手题:在你自己的电脑上(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 <容器名>,再新建一个挂同样目录的容器。你会得到同一个结论——这正是「数据卷存在的唯一理由」。

  10. 开放题:这一章的标题是「让一个程序在千里之外活着」。请你用不超过 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,感觉会完全不一样。