Ways to Robot 从零到云端机器人

第一章 · 看见机器

计算机常识与 Linux:你写的代码落在哪里

你在上海千里之外的一台服务器上跑着 OWL,可你从没见过它。你没听过它的风扇声,没摸过它的机箱,甚至不知道它长什么样。那么你敲下的每一行命令,究竟作用在什么东西上?这一章只做一件事:把那台看不见的机器,在你脑子里变成一个具体的、有形状的东西。

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

序章里我给你看过一张「七个台阶」的地图,最底下两层是「物理层」和「系统层」,后面标注着「第一章」。现在你站在这一层了。这一章不教你写程序,它教你看清程序运行的地方。你会拿到一整套命令,但比命令更重要的是:每敲下一个命令,你都知道自己正在问机器的哪一个部分。

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

  • 「云服务器」到底在哪里?它为什么是一台真实的电脑,而不是某种虚拟的东西?
  • 一条消息到达 OWL 的那几秒里,CPU、内存、硬盘、网络各做了什么?内存为什么比硬盘快几万倍?
  • 为什么 1.6 G 内存会「OOM」,而 Swap 只能救急不能救命?
  • 为什么程序不能直接碰硬件,而必须通过内核和系统调用?(这条决定了你后面所有章节的安全观)
  • 为什么 /root/owl/bot/llm.local.json 的权限必须是 600,而 755 意味着什么?
  • 面对一台刚连上的陌生服务器,你能在八条命令之内说清它的状态吗?
  • 机器人「看起来在跑、实际不回消息」时,你怎么用分层的方法在十分钟内定位到哪一层坏了?

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

  • 用 SSH 登录一台远程服务器,在里面自由移动、查看状态
  • 读懂 ls -l、ps aux、ss -tlnp、df -h 的输出,判断这台机器是否健康
  • 用管道和 grep 从日志里捞出你要的那几行
  • 讲清权限、进程、端口是怎么互相咬合的,并知道为什么私钥文件必须是 600
  • 写一个最简 bash 脚本,把重复操作交给机器

1. 从「一台机器」说起

你要在脑海中建立这样一个画面:在中国上海的某个机房里,有一排一排的铁架子,每个架子上插着几十台扁平的机器,电源灯常亮,风扇在转,空气被空调抽得又冷又干。这些机器没有屏幕,没有键盘,没有鼠标,一辈子不会有一个人坐到它面前。其中一台,是你的。它归你指挥,但它不在你房间里——它在一千公里之外,你这辈子可能都不会去看它一眼。

1.1 你买到的到底是什么

你当初在阿里云上点了几下,付了 68 元,然后拿到一个 IP 地址和一句「系统正在初始化」。这个过程太快了,快到你很难意识到自己买到了什么。

「云服务器」不是一朵云,它是别人的机房里的一台电脑。你买的不是「算力这个概念」,而是这样一个事实:从今天起,某台真实的机器每天 24 小时开机,其中一部分资源归你使用,互联网上有一个地址可以找到它。

你会在控制台里看到几个词,它们各自对应现实里的东西:

云上的词它对应现实里的什么在你的机器上
地域 / 可用区机房在哪个城市。数据不出这个范围,延迟也由它决定华东2(上海)
实例分给你的那一份机器资源。它像一台独立电脑,但物理上寄生在一台更大的宿主机器里一台 2 核 1.6 G 的实例
公网 IP互联网上找到这台机器的门牌号一个形如 47.103.x.x 的地址
镜像装机器时用哪套系统的模板Ubuntu 22.04.5 LTS
安全组 / 防火墙机房门口的门卫:哪些端口可以从外网进来只放行 22(SSH)
带宽门口那条路的宽度几 Mbps,对纯文字聊天绰绰有余

为什么「实例」这个词要特别说明?因为它牵着一个很重要的心理学事实:你的机器其实是共享的。那台宿主机器上有许多这样的实例,它们互相隔离、各自以为自己是完整的电脑。这就是虚拟化:你花 68 元,买到的是「一台真电脑的百分之几」——后面那些看起来奇怪的限制,答案都在这里。

把你那台机器的真实参数列出来,整章都会用到它们:

项目实测值它在现实中的含义
CPU2 核同一时刻真的能并行做两件事,不多不少
内存1608 MB(约 1.6 G)能同时摊在桌面上的东西的总量,关机就全没了
硬盘40 GB断电也还在的东西的总量,目前用掉 8.3 GB(约 23%)
Swap2 GB内存不够时临时挪用的一块硬盘空间
实测内存占用476 MB / 1608 MB机器人本体 + NapCat 容器一起的常驻占用,约三成
费用68 元 / 年折合每天不到两毛钱
续费到期2027-10-01欠费会直接停机,这条要写进日历

顺便说一个你以后一定会撞上的问题:时间是会被搞错的。服务器和容器的时区必须显式设成 Asia/Shanghai,否则日志时间戳会比你手上的时间差几个小时——排查故障时你会因此误判「机器人最后一次活动是什么时候」。这个项目里连代码注释都专门记了这件事(早期用 UTC 输出导致日志慢 8 小时),你会在第七章看到它。

这些数字里只有两个是性能参数:2 核和 1.6 G,其余都是身份信息。我建议你现在就把这张表抄到笔记本上——不是为了背,而是为了以后你敲每一条命令时脑子里有个参照物。没有参照物的人看这些数字,看多少遍都没有感觉。

1.2 你在自己电脑上看到的世界,是同一套东西

现在看看你面前这台电脑。它有 CPU、内存、硬盘、网卡,它跑的也是操作系统(Windows 或者 macOS)。从「它由什么构成」这个角度看,你的笔记本和上海机房里那台机器没有任何本质区别。差别只有四点:

  • 它不在你身边。所以你需要一种远程操作它的方式(第 8 节讲 SSH)。
  • 它没有屏幕。所以它上面的一切交互只能靠文字和命令(这就是为什么服务器不装图形界面)。
  • 它跑的不是 Windows。它是 Linux,而 Linux 和 Windows 在几个关键细节上不一样,这些细节会真的害你(4.2 讲大小写敏感)。
  • 它必须一直醒着。所以它需要有人(程序)在它崩溃时把它拉起来(第七章讲 systemd)。

要点整章里我会反复用一个提问方式:「这条命令作用在哪个东西上?」是 CPU、内存、硬盘、网络,还是那个叫内核的东西?只要你养成了这个习惯,Linux 就不再是一堆需要背的命令,而是一台你能在脑子里拆开的机器。

2. 硬件五角色:OWL 启动的那三秒

光说「有 CPU、有内存」没有用,你记不住。我们要找一件真实发生的事情,让它从这五个零件中间穿过去:服务器重启之后,OWL 醒过来的那几秒。这是服务器整机重启时的真实日志:

[10:09:56] 🤖 OWL 已启动
[10:10:05] 🔗 协议端已连接 (127.0.0.1)
[10:10:05] ✅ 机器人已登录: 1876148307 (OWL)

▲ 开机 9 秒后程序启动,10 秒内整条链路恢复。全程无人干预,也不需要重新扫码。

这九秒里,机器的五个部分各自在干什么?先认识这五个角色。

2.1 五个角色是谁

角色它负责什么用一句人话说断电后会怎样
CPU执行指令:加法、比较、跳转、读写内存唯一会「思考」的部分,但它只会做极其简单的事停止,硬盘上的东西还在
内存(RAM)存放正在运行的程序和正在处理的数据摊在桌面上的书,随手可取,一关灯就没了全部消失
硬盘(磁盘)长期保存文件,关机也不丢书柜,取一本书要站起来走过去内容保留
网络(网卡)把数据送出去、收回来你和外界唯一的通道断连,机器还在
主板 / 电源让以上四者能互相说话,并提供稳定的电神经和心脏全部停止

2.2 那三秒里发生了什么

  1. 电源接通,主板上的固件先醒。它做最基本的自检:内存有多少、硬盘在不在、从哪个设备启动。这一步和操作系统无关,它是出厂就写死在主板芯片里的程序。
  2. 固件把控制权交给硬盘上的引导程序,引导程序再把内核读出来放进内存,然后把 CPU 交给它。这是「硬盘 → 内存」的第一次搬运。
  3. 内核接管整台机器。它先把硬盘上的根文件系统「挂」起来——让那棵目录树变成程序能按路径访问的东西;然后启动一个叫 systemd 的进程,它是这台机器上所有服务的总管(第七章详细拆它)。
  4. systemd 按配置启动两个东西:napcat 服务(其实是一条启动 Docker 容器的命令)和 qqbot 服务(其实是 node bot/bot.js)。注意这一步的实质:「启动一个服务」就是「从硬盘读一个程序进内存,然后让 CPU 开始执行它」。
  5. Node 进程读配置。bot.js 开头在做这件事(以下是简化版,只保留路径与读取逻辑):
    const __dirname = path.dirname(fileURLToPath(import.meta.url));
    const CONFIG_PATH = path.join(__dirname, "config.json");
    const LOG_PATH = process.env.QQBOT_LOG_FILE || path.join(__dirname, "bot.log");
    
    function readJson(file) {
      const raw = fs.readFileSync(file, "utf8").replace(/^\uFEFF/, "");
      return JSON.parse(raw);
    }

    逐行看:第 1 行问「这个脚本自己在硬盘的哪个目录」,得到 /root/owl/bot;第 2、3 行用它拼出配置和日志的绝对位置;第 5 行把文件内容从硬盘读进内存;第 6 行把那段文本解析成 JavaScript 对象。这就是「硬盘 → 内存 → CPU 处理」的完整一次搬运,它在你的程序里每天发生几万次。

  6. 程序申请一个端口,开始等。日志里那句 等待协议端(NapCat)连接… 的意思是:这个进程已经向内核登记了「我要监听 3001 端口」,然后它就睡着了。端口是网络这一侧的事,但它必须挂在一个进程上;没有进程,端口就不存在。至于连接建立之后两边到底用什么格式说话(那条 WebSocket 和它传的 JSON),是第四章《消息如何跨越一千公里》的主题。
  7. 九秒后 NapCat 连过来。数据从网络到达网卡,网卡交给内核,内核根据端口号找到 qqbot 这个进程,把 CPU 从睡眠中叫醒。日志打出「协议端已连接」。

现在你有了一个模板:任何一个程序跑起来,都是「从硬盘读进内存 → CPU 执行 → 需要时申请资源(文件、端口、网络)→ 进入等待」。以后再遇到新东西(数据库、Docker、模型推理),都可以往这个模板里套。

2.3 内存为什么比硬盘快几万倍

内存和硬盘的差别在物理上:内存是电容和晶体管,靠电信号寻址,一次读取大约几十纳秒;机械硬盘要把磁头移动到正确位置、等盘片转到正确的扇区,一次寻道大约几毫秒;固态硬盘没有机械动作,但要走一遍闪存控制器,一次读取大约几十到一两百微秒。放进同一张表,差距是触目惊心的:

层级一次访问的大致耗时如果内存访问是 1 秒,那么它是
CPU 内部缓存约 1 纳秒或更少零点几秒到几秒
内存(RAM)约 50–100 纳秒1 秒(基准)
固态硬盘(SSD)约 50–200 微秒约 10 分钟到 1 小时
机械硬盘随机读约 5–10 毫秒约 1 到 2 天
同一城市的网络往返约 1 毫秒约 3 小时
跨国网络往返约 150–250 毫秒约 1 个多月

(这是数量级表,不是精确测量,不同硬件能差出好几倍。但记住「内存比固态硬盘快三个数量级、比机械硬盘快五个数量级」就够用了。)

速度之外还有第二个反比:同样的钱,硬盘能买到的容量比内存大得多。当下大致是内存几十元一个 GB、固态硬盘几毛钱一个 GB。容量和速度互相矛盾,你不可能两样都要。所以计算机的存储是一座金字塔:越快越小越贵,越慢越大越便宜。CPU 缓存最快,只有几 MB;内存次之,1.6 GB;硬盘最慢,40 GB。整个计算机体系结构的历史,就是「怎么让最常被用的东西待在最快的那一层」的历史——这一句话可以解释你以后遇到的绝大多数性能问题。

2.4 为什么 1.6 G 会 OOM,而 Swap 救急不救穷

内存有限,而程序想要的东西无限。当所有程序要用的内存加起来超过了物理内存总量,Linux 的答案是:杀掉一个进程。内核里有一个叫 OOM Killer(内存耗尽杀手)的机制,它会挑一个「占得最多、又最不重要」的进程,直接给它一个致命信号,把它整个杀掉。不会有任何询问,你的机器人会突然从世界上消失,只剩日志里的一行记录。

算一笔账:机器有 1608 MB 内存,OWL 这套东西实测常驻 476 MB,看起来还很宽松。但服务器上不只是 OWL——Ubuntu 系统本身、Docker 守护进程、内核缓存都要占一部分。危险时刻出现在:有人对你的机器人刷屏,几十条消息同时进入模型调用,每个还在等的请求都要在内存里存一份上下文;或者 NapCat 忽然开始缓存大量图片。内存不是被慢慢吃掉的,通常是在几十秒内被吃光的。

这时 Swap 上场了。Swap 是硬盘上划出来的一块空间(这台机器上划了 2 GB):内存不够时,内核把「最近没被用到」的内存页搬到 Swap 里,腾出物理内存给正在忙的程序;需要时再搬回来。所以 Swap 的本质不是「更多的内存」,而是「把硬盘当成内存的溢流区」——代价立刻可见:内存访问是几十纳秒,硬盘是几十微秒到几毫秒,差了三到五个数量级。一个程序一旦开始大量使用 Swap,它不会崩,但会变得极其缓慢。

警告Swap 救急不救穷。它能挡住一次突发流量,给你时间去处理;但如果一台机器的 Swap 长期被占用(free -h 里 swap 那一行常年不是 0),说明内存是真的不够了,你迟早会撞上 OOM。正确的做法是看趋势,不看单点:持续使用 Swap 是病,偶尔用一下是药。

顺便解释一个让人算错的细节:为什么序章说「1.6 G 内存」,实测记录却写「476 MB / 1608 MB」?因为硬件厂商按 1000 进制标容量,操作系统按 1024 进制算,再加上系统会留一小部分给硬件用。它们说的是同一件事,只是单位不同。以后看到内存、磁盘数字时,先在心里统一单位再比较。OOM 的完整排查、Docker 的日志限额怎么防止磁盘被撑满,都在第七章;而「上下文长度直接决定内存占用」会在第八章回到你眼前。顺便记住一个更普遍的教训:这一节所有的数字都是「你这台机器的约束」,而不是「这套方案的约束」。什么样的设计不依赖机器的大小、什么样的设计会被机器大小卡住——这一层判断力是第九章《工程素养》要训练的东西。

2.5 CPU 的核数与「并发不等于并行」

你的机器是 2 核,意思是:在同一瞬间,它最多真的在同时执行两段指令。不是三,也不是「很多」。

那为什么服务器上跑着几十个进程,看起来都在同时运行?因为内核在做一个极快的把戏:把 CPU 的时间切成极细的片,每隔一小会儿就换一个进程上去执行,每秒切换成百上千次。太快了,人完全感觉不到。这个现象叫并发。而并行是另一件事:真的有两段指令在同一时刻被两个核心执行。

并发并行
物理上发生了什么一个核心来回切换,轮流做多个核心同时做
需要几个核心1 个就够至少 2 个
你为什么以为它在同时跑切换速度远超人能感知的极限它真的在同时跑
OWL 里的例子一个 Node 进程一边等模型回复,一边处理下一条消息两个不同的人同时被回复

为什么要在这里讲这个?因为 OWL 的性能瓶颈根本不在 CPU。她的每一次回复,99% 的时间花在等两件事:等网络把消息送过来,等大模型把回答生成完。真正花在 CPU 上的计算少得可怜。这就是为什么一台 2 核机器能伺候一个聊天机器人——它不是算得快,而是等得起。

想一想如果 OWL 的工作有 99% 的时间在等待,那么「给她换一块贵十倍的 CPU」和「让她能同时处理十倍的消息」,哪一件更可能成功?如果一台机器只有一个核心,它还能不能同时处理一百个在等待的请求?先自己想,你的答案会在第五章「事件循环」那里被验证——那时你会发现,「等」这件事是整个 Node.js 的设计基础。

3. 操作系统在做什么:为什么程序不能直接碰硬件

上一节我一直在偷偷使用一个说法:「内核把根文件系统挂起来」「内核根据端口号找到进程」。现在要把这个「内核」说清楚,它是整章最重要的一个概念。

先问一个看似天真的问题:为什么我写的程序不能自己直接去读写硬盘?

3.1 如果让程序自己开车

设想每个程序都能直接指挥硬盘的读写磁头、直接控制网卡发送数据,会发生什么:

  • 两个程序同时往硬盘的同一块位置写数据,互相覆盖,文件变成一堆乱码。
  • 一个程序里的下标写错,把别的程序正在用的内存改掉了,那个程序莫名其妙地崩溃,而且查不出原因。
  • 一个程序往网卡里灌了一堆垃圾数据,把整台机器的网络堵死。
  • 一个恶意程序直接读取另一个程序的内存,把它的密码偷走。

任何一条都足以让一台多任务电脑无法使用。所以现代计算机做了一个约定:硬件不给普通程序碰,只给内核碰。所有程序想读写文件、申请内存、收发网络数据,都必须向内核提出请求,由内核统一安排、排队、检查权限,然后才真正去动硬件。这个约定还有一个更深的理由:内核是唯一知道「这个东西属于谁」的部件——只有它知道文件的所有者是谁、这个进程有没有权限读它、这个端口是不是被别人占着。其他程序都只看得见自己的那一小片世界。

3.2 内核:那个一直在场的管家

内核从机器启动那一刻起就常驻内存,直到关机为止,所有进程加起来也没有它权力大。它负责的事包括:

  • 进程调度:决定下一个时间片给哪个进程。这就是上一节「并发」的幕后机制。
  • 内存管理:给每个进程分配地址空间,并保证它们互相看不见。你的程序以为自己独占整台机器,这是内核制造的错觉。
  • 文件系统:把硬盘上一块块扇区组织成目录和文件,并管住谁能读写它们。
  • 网络协议栈:实现 TCP/IP,把网卡收到的原始数据整理成「某个端口上的一条连接」。
  • 设备驱动:和各种硬件说话。你插一块网卡,需要驱动告诉内核怎么用它。

这里要澄清一个几乎所有人都搞混的词:Linux 只是内核的名字。你装在服务器上的东西叫 Ubuntu,它是一个发行版——内核加上一大堆工具、库、包管理系统和默认配置,打包成可以安装的完整系统。Ubuntu、Debian、CentOS、Fedora 之间最大的差别在内核上面那些东西,它们的内核都叫 Linux。所以「Ubuntu 22.04 的内核版本是 5.15」这类说法不用惊讶:发行版是套餐,内核是那道主菜。

3.3 用户态与内核态:一次系统调用的完整过程

CPU 本身有两种运行模式,这是硬件层面就支持的:用户态是普通程序运行的模式,CPU 会拒绝执行某些危险指令(直接访问硬盘控制器、修改内存映射表、关闭中断);内核态是内核运行的模式,所有指令都允许,所有内存都能访问。

你的 node bot/bot.js 平时跑在用户态。当它需要读配置文件时,它不能自己去碰硬盘,而是执行一条特殊指令,让 CPU 从用户态切进内核态,把「请帮我读这个文件」这个请求交给内核;内核检查权限、去硬盘取回数据放进内存,再把 CPU 切回用户态,把结果交还程序。这个「从用户态进入内核态请求服务」的动作,就叫系统调用。

打个比方(记住它,但要立刻回到机制):用户态是隔着玻璃的客户,内核态是玻璃后面的柜员。你不能自己进金库,只能递一张单子进去;柜员核对身份、替你取钱,然后从窗口递给你。系统调用就是那张单子。

你写的是 JavaScript,什么时候会用到系统调用?每时每刻,只是被包装起来了。上一节那段代码里的 fs.readFileSync(file, "utf8"),在底层就是一次(或几次)系统调用。Node 做的事,是把内核那些又难看又危险的单子,替你包装成人能读的函数。由此有一个你以后一定会撞上的推论:系统调用是昂贵的——它涉及两次模式切换、一次上下文保存与恢复,还要等待相对缓慢的硬件。所以内核和 Node 都拼命减少调用次数:一次读一大块,而不是一个字节一个字节读。你在第五章学「非阻塞 I/O」时,本质上学的是「怎么不让系统调用把进程卡住」。

3.4 进程与文件:操作系统的两大抽象

内核管的东西很多,但对写程序的人来说只需要抓住两个抽象,它们后面所有章节都会反复出现。

第一个是「进程」。内核不直接管理程序文件,它管理的是「程序被加载进内存、正在运行的那一份实例」,再加上它占用的资源(内存、打开的文件、网络连接、权限身份)。这个整体就是进程。同一个程序文件可以被运行很多次,那就是很多个互不相干的进程。程序是菜谱,进程是正在厨房里炒的那盘菜。

第二个是「文件」。硬盘上本来只有磁道和扇区,是内核把它们组织成有名字、有路径、有权限的东西。但 Linux 在这件事上做了一个很大胆的推广:它把很多东西都做成文件的样子,用同一套「打开—读写—关闭」的接口去操作。

3.5 设备即文件:/dev 和 /proc 的存在感

/dev 是设备文件所在的目录,里面每个奇怪的文件都对应某种硬件或抽象设备。比如 /dev/sda 代表第一块硬盘,/dev/null 是一个「黑洞」——写进去的东西全部消失,读它永远得到空。为什么要有它?因为很多命令会输出信息,而你有时根本不关心,只想让它别刷屏:命令 > /dev/null。这不是技巧,这是「一切皆文件」这个设计的自然结果。

/proc 更神奇:它看起来是一个目录,实际上不占任何硬盘空间。你读它里面的文件,读到的是内核此刻的状态。登录服务器后可以自己试:

cat /proc/uptime          # 这台机器开机了多少秒
cat /proc/loadavg         # 最近 1 / 5 / 15 分钟的平均负载
cat /proc/meminfo         # 内存的详细分解(free 命令读的就是它)
ls /proc/1234/            # 进程号 1234 这个进程的全部信息

▲ 这些命令不读硬盘,读的是内核实时算出来的数字。所以 /proc 里的文件是「问内核一个问题」的另一种写法。

你马上会用到它:/proc/<PID>/cmdline 里存着这个进程当时的完整启动命令。排查「这个进程到底是谁启动的、带了什么参数」时,它是最可靠的证据——比猜可靠得多。「一切皆文件」的价值不在于好玩,而在于统一:因为硬盘、设备、内核状态都能用同一套接口访问,那些工具(cat、grep、less)就不需要为每种东西写一个版本。你在第 7 节会看到这套统一接口的威力。

4. 文件、路径与权限:你的代码住在哪里

从这一节开始,你手里要有命令了。但敲命令之前,得先知道这些命令作用在什么东西上——它们作用在一棵树上。

4.1 目录树、绝对路径与相对路径

Linux 的文件系统和 Windows 有一个根本差别:Windows 有很多个根(C:、D:、E:),Linux 只有一个根。你可以想象成一棵树,最顶上那个东西叫根目录,写作一个斜杠 /。所有东西都长在这棵树上:硬盘挂到某个枝丫上,U 盘插上也挂到某个枝丫上,你的项目也是。

下面是 OWL 项目在服务器上的真实位置(data 目录被特意分离出来,第七章会讲这个设计为什么能救命):

/
├─ root/                     ← root 用户的家目录
│  └─ owl/                   ← 项目根目录
│     ├─ bot/
│     │  ├─ bot.js           ← 机器人主程序
│     │  ├─ llm.mjs          ← 调大模型的模块
│     │  ├─ config.json      ← 你要改的配置(改完自动生效)
│     │  └─ llm.local.json   ← DeepSeek API Key(权限 600!)
│     └─ data/
│        ├─ qq/              ← QQ 登录态(删了要重新扫码)
│        ├─ napcat/          ← 反向 WS 配置与 token
│        └─ botdata/
│           ├─ bot.log       ← 机器人日志
│           ├─ history.json  ← 会话上下文
│           ├─ memory.json   ← 对每个人的长期记忆
│           └─ health.log    ← 每 5 分钟一次的健康检查记录
├─ etc/                      ← 系统与服务的配置文件
├─ var/log/                  ← 系统日志
├─ dev/  proc/  tmp/         ← 设备、内核状态、临时文件
└─ home/                     ← 普通用户的家目录(root 不在里面)

▲ 这张图就是「你的代码落在哪里」的答案:它住在 /root/owl/bot/ 这一格。

请你先在这张图上看一件事:代码(bot/)和数据(data/)被刻意放在了两个不同的地方。这不是审美,这是保命的。安装脚本里有一行 Environment=QQBOT_DATA_DIR=$SCRIPT_DIR/data/botdata,它的作用是在启动机器人之前,用环境变量告诉程序「你的日志、会话上下文、记忆都写到这个目录去」。于是当你更新代码、把整个 bot/ 目录覆盖掉、甚至回滚到昨天的版本时,记忆和日志一行都不会丢——因为它们从来就不住在代码目录里。反过来说,如果你的程序把 memory.json 写在代码旁边,那么每一次「重新部署」都会变成一次「把所有人的记忆一起删掉」。这是本书里会反复出现的一个设计原则:会变的东西与不该丢的东西,要分开存放。

路径有两种写法。绝对路径从根开始写全,例如 /root/owl/bot/config.json,它写在哪个位置都指向同一个文件,所以脚本里几乎总是用它;相对路径从「我现在所在的目录」开始算,例如你在 /root/owl 时 bot/config.json 就是指上面那个文件——它更短,但它依赖你此刻站在哪里,这是它所有麻烦的来源。四个特殊写法你必须认识:

写法含义你会怎么用
.当前目录./install.sh 指「执行当前目录下这个脚本」(出于安全,系统默认不允许直接运行当前目录里的东西)
..上一级目录cd .. 从 /root/owl/bot 回到 /root/owl
~当前用户的家目录root 是 /root,普通用户是 /home/某名字;~/.ssh/config 就是这个意思
/根目录(写在开头时)cd / 走到整棵树的最顶端

刚登录服务器时,你「站」在 /root。这个「站在哪里」有个正式名字叫工作目录,它是所有相对路径的参照点。所以当你敲的命令报「找不到文件」时,第一个要问的不是「文件是不是丢了」,而是:我现在站在哪儿?文件相对于我在哪儿?pwd 就是回答第一个问题的命令,从第 6 节起你会敲它几百次。

4.2 大小写敏感:一个会真的害你的事故

这是 Linux 和 Windows 最重要的差别之一:在 Linux 上,文件名区分大小写。在 Windows 里 Bot.js 和 bot.js 是同一个文件;在 Linux 里它们是两个完全不同的文件,可以同时存在、互不相干。

这个差别造成的真实事故长这样:你在 Windows 上写了 import { LlmClient } from "./Llm.mjs",本机跑得好好的,因为 Windows 在背后替你忽略了大小写;传到服务器上,Node 报错:

Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/root/owl/bot/Llm.mjs'
imported from /root/owl/bot/bot.js

▲ 这个报错的意思不是「文件不存在」,而是「按你写的这个拼写找不到文件」。而硬盘上那个文件叫 llm.mjs。

排查它的方法非常具体:不要相信自己的眼睛,用命令去看目录里真实的拼写。先 ls -l /root/owl/bot/ 看真实文件名,再 ls /root/owl/bot/LLM.mjs 直接验证那个错误拼写——如果它报 No such file or directory,就确认了:问题只是大小写,改一个字母就好。

警告三种相关的坑,请在部署前一次性检查完:(1)导入路径的大小写要和真实文件名完全一致;(2)Windows 的换行符是 \r\n,Linux 只用 \n,Windows 上编辑过的 Shell 脚本传到服务器后可能报 /bin/bash^M: bad interpreter,那是行尾多出来的 \r;(3)路径分隔符在 Linux 上是 / 而不是 \,在 JavaScript 里永远用 path.join() 拼路径,不要手写字符串。这三条我都建议你写进部署前检查清单。

4.3 权限:rwx、三种人,以及 644 / 755 / 600

Linux 从出生那天起就是多人系统,所以每个文件都要回答一个问题:谁可以拿它做什么?敲一次 ls -l,答案就在第一列:

$ ls -l /root/owl/bot/
-rw-r--r-- 1 root root   3210 Oct  3 21:40 config.json
-rw------- 1 root root     58 Oct  3 21:12 llm.local.json
-rw-r--r-- 1 root root 128744 Oct  4 11:25 bot.log
-rwxr-xr-x 1 root root   4096 Oct  3 20:00 install.sh

▲ 第一列那 10 个字符就是权限;后面的 root root 是「所有者」和「所属组」。

第一个字符 - 表示这是普通文件(d 是目录,l 是软链接)。剩下 9 个字符分成三组,每组 3 位,代表三种人:第 2–4 位是所有者(通常是创建它的人),第 5–7 位是所属组(和所有者同组的其他用户),第 8–10 位是其他人(剩下所有能登录这台机器的人)。每组三位分别是 r(read,读)、w(write,写)、x(execute,执行):位置上有字母就是有,是 - 就是没有。

因为「有/没有」正好两位,每一位都可以当二进制位(有 = 1,没有 = 0),三个位合成一个 0 到 7 的数字:r = 4,w = 2,x = 1。于是 r-- = 4,r-x = 5,rw- = 6,rwx = 7。把三位数字按「所有者 / 组 / 其他人」的顺序拼起来,就是你在教程里到处看到的那些数字:

  • 644 = rw- r-- r--:只有我能改,别人只能看。普通文件最常用的权限,比如 config.json——内容不是秘密,但只有我能改它。
  • 755 = rwx r-x r-x:我能改也能执行,别人能执行但不能改。脚本和目录的常用权限,比如 install.sh。注意目录上的 x 含义不同:对目录来说 x 是「能不能进去(cd)」,r 是「能不能列出里面的文件名」。所以一个 r-- 的目录,你能看见文件名却打不开任何文件——这是新手常遇到的怪事。
  • 600 = rw- --- ---:只有我能读写,其他任何人都碰不到。

4.4 为什么 API Key 文件必须是 600

现在你应该能自己推出答案了。你的 llm.local.json 里装的是一串 sk- 开头的字符串。那不是普通的字符串,那是能花你钱的东西:任何拿到它的人都能用你的账号调用大模型,账记在你头上。

所以它的权限不能是 644。为什么?因为 644 意味着「其他任何人都能读」,而在这台机器上,「其他人」包括任何以后被加上的账号、任何被攻破的低权限进程。你可能觉得「这台机器就我一个用户,无所谓」——但请看这句判断:权限的第一原则是最小权限。不是「现在有没有人看」,而是「假设有人看到了,他能不能用到」。日志文件被读到无所谓,密钥文件被读到是不可逆的损失:攻击者不需要再攻破任何东西,他已经被你授权了。而且文件权限管不住 root——root 可以读任何文件。所以一个密钥的完整防线是多层的:文件权限 600(挡住同机器上的其他账号)→ 不进版本库(.gitignore 里写死 llm.local.json,第三章你会看到这件事有多容易搞砸)→ 不放进部署包(用 SSH 加密通道传)→ 在服务商控制台设消费上限(万一前三条全失守,这一条兜住钱包)。

命令只有一行,但要养成「改完再看一眼」的习惯:

ls -l /root/owl/bot/llm.local.json     # 先看现在是什么权限
chmod 600 /root/owl/bot/llm.local.json # 改成 600:只有所有者可读写
ls -l /root/owl/bot/llm.local.json     # 再看一次,确认改成功

▲ chmod 是 change mode(改权限模式)。为什么要「先 ls 再改」?因为把 600 敲成 666 不会有任何报错,它只会安静地让文件变得更开放。安全上的错误几乎都是沉默的——这就是为什么这个行业有一句话:你不能靠报错发现问题,只能靠检查发现问题。

4.5 软链接:部署时那把能瞬间切换的刀

软链接是一个「指向另一个文件或目录的名字」,类似 Windows 的快捷方式:它自己有名字,内容只是「真正的东西在哪里」。创建它的命令是 ln -s(s 就是 symbolic):

ln -s /root/owl/releases/2026-10-03 /root/owl/current
ls -l /root/owl/current
# lrwxrwxrwx 1 root root 33 Oct  3 21:00 current -> /root/owl/releases/2026-10-03

▲ 注意第一列开头的 l,以及箭头右边指向的真实路径。

它解决一个很尴尬的问题:更新代码时,怎么才能「一秒钟完成切换」,而不是「先删旧的再传新的」中间那半分钟服务是坏的?做法是:每次发布把新版代码解压到带日期的独立目录,全部检查无误后只做一件事——把 current 这个软链接重新指向新目录。切换是原子的(要么指向旧的,要么指向新的,不存在「指向一半」),重启服务后立刻用上新代码;如果新版有问题,把链接指回去就完成了回滚——不需要重新下载、不需要重新解压,只改一个指针。请记住这个思想:能用「换一个指针」解决的事,不要用「搬一堆文件」解决,因为搬文件会失败,而换指针不会一半成功一半失败。

顺便说一句硬链接(ln 不加 -s),因为它是理解文件系统的关键:在 Linux 里,文件的内容和文件的名字是分开的——每份内容有一个编号叫 inode,目录里存的是「名字 → inode」的对应关系。硬链接就是给同一个 inode 多起一个名字,两个名字完全平等;而软链接只是一个存了路径的普通文件。理解 inode 之后,你会明白一件反直觉的事:删除一个文件,删掉的可能只是「一个名字」。

5. 进程与端口:让上一节的东西活起来

前面全是概念,这一节开始,你敲的命令会真的改变机器。所以先把两条规矩放在最前面:看状态的命令可以随便敲,改状态的命令敲之前先想三秒;以及永远先确认你在哪台机器上——很多人一辈子最大的事故,是在以为是测试机、其实是生产机的终端里敲了删除。

5.1 进程也有身份证:PID 与 ps aux

每个进程在出生时都会从内核那里领到一个编号,叫 PID(Process ID,进程标识号)。它在一台机器上唯一,而且会被复用。要看现在有哪些进程在跑:

ps aux | grep node
# root  812  0.3  3.1 1234567 51234 ?  Ssl  10:09  0:12 node /root/owl/bot/bot.js

▲ ps 是 process status:a 显示所有用户的进程,u 用「面向用户」的详细格式,x 连没有终端的进程也显示。

输出的列你需要认识这几栏:USER 是谁启动的(看到陌生用户跑着东西要警惕);PID 是进程号(杀进程、看 /proc/<PID> 都要用它);%CPU / %MEM 是占用(排「机器变慢」时先看这两栏);STAT 是状态(S 睡眠、R 运行、Z 僵尸、D 不可中断等待);START / TIME 是启动时间与累计消耗的 CPU 时间(「它到底重启过没有」在这里能看出来);COMMAND 是完整启动命令。

这里有个细节要特别提醒:上面这条命令的输出里通常还会有一行是 grep 自己,因为它也是一个进程,而且它的命令行里正好包含 node 这个词。等学了第 7 节的管道你会理解为什么,也会学会用 grep -v grep 把它滤掉。这种「自己的动作污染了自己的观察」的现象在工程里到处都是,它叫观察者效应——你在后面看日志、测性能时会反复遇到。

5.2 进程不是孤立的:进程树与父子关系

进程之间有亲缘关系:一个进程可以创建另一个进程,创建者叫父进程,被创建者叫子进程;整棵树往上追,最顶端是开机时内核启动的第一个进程(通常就是 systemd)。这不是抽象知识,它决定了一个非常实际的问题:为什么服务进程的父进程是 systemd,而不是你的终端?

因为 qqbot 是 systemd 启动并管理的:systemd 是它的父进程,负责它死掉之后把它拉起来(Restart=always)。如果你哪天手动 node bot/bot.js 又起了一个,那就是第二个 bot.js,它会和 systemd 管的那个抢同一个 3001 端口,后启动的那个会报端口被占用。这是新手部署时最常见的自伤之一:手动启动一次,就以为自己「修好了」,实际上制造了一个幽灵。看这棵树用 pstree -p(树形,带 PID)或 ps -ef --forest(列表形,带缩进),它们能回答「这个进程是谁拉起来的」「我手动启动的那个幽灵还在不在」。

5.3 前台、后台,以及 kill 到底是什么

在终端里敲一个命令,它默认在前台运行:占用着你的终端,你必须等它结束才能敲下一条。前台进程死掉,终端会告诉你;而终端被关掉,前台进程通常会跟着被终止。这就是为什么「在自己电脑上开着窗口跑机器人」不可靠——你合上笔记本,她就下线了。这正是 OWL 需要搬到服务器上、并且交给 systemd 管理的原因。

后台运行有两种方式:命令末尾加 &(简单但脆弱,终端一关可能就没了),或者用 nohup、screen、tmux(更可靠)。但请记住一个判断:对长期运行的服务,正确的做法既不是前台也不是后台,而是交给 systemd(或 Docker)托管,因为它会处理开机自启、崩溃重启、日志收集。第七章会把这件事完整讲一遍。

然后是 kill。这个名字有误导性:它并不「杀死」,而是向进程发送一个信号。默认发的是 SIGTERM(信号 15),意思是「请你结束吧」;而 SIGKILL(信号 9)的含义是「立刻消失,不给你任何机会」。

对比SIGTERM(kill PID)SIGKILL(kill -9 PID)
进程能收到吗能不能,内核直接把它拆掉
能自己善后吗能:保存文件、关连接、写完日志完全不能
什么时候用永远先用它它无效时的最后手段
风险无正在写文件时被杀,文件可能损坏(比如 history.json 只写了一半)

你的程序其实接住了这个信号。这是 bot.js 真实的最后几行:

function shutdown(sig) {
  log(`👋 收到 ${sig},关闭中…`);
  llm.saveHistory();          // 把会话上下文写回硬盘
  wss.close();
  server.close(() => process.exit(0));
  setTimeout(() => process.exit(0), 1500);   // 兜底:最多等 1.5 秒
}
process.on("SIGINT",  () => shutdown("SIGINT"));
process.on("SIGTERM", () => shutdown("SIGTERM"));

▲ 这就是「优雅退出」:主动告诉程序「要关机了」,让它把内存里的上下文写进硬盘再走。kill -9 会让这一步彻底不发生。

(僵尸进程一句话说清:子进程已经结束,但父进程没读走它的退出状态,它就以 Z 状态挂在进程表上,几乎不占内存和 CPU,只占一个表项。真正要担心的不是僵尸,而是「父进程死了、子进程被甩给 PID 1」的孤儿进程——它们还在监听端口却没人管,你的「幽灵 bot」通常就是这么来的。至于 saveHistory() 到底把上下文写成了什么形状、为什么「记忆」需要单独设计一套生命周期,是第六章《让她记得你》的正题。)

5.4 端口:IP 找机器,端口找程序

网络把消息送到你这台机器靠 IP 地址,但机器上跑着几十个网络程序,消息该交给谁?靠端口。IP 是门牌号,端口是房间号。当一个程序想接收网络连接时,它要向内核登记:「凡是发到 3001 这个房间号的消息,都给我。」这个动作就叫监听。所以日志里那句 等待协议端(NapCat)连接… 的背后,其实是内核对你说:3001 这个房间已经有人了。

ss -tlnp
# Netid State  Local Address:Port  Peer Address:Port  Process
# tcp   LISTEN 127.0.0.1:3001       0.0.0.0:*          users:(("node",pid=812,fd=22))
# tcp   LISTEN 127.0.0.1:6099       0.0.0.0:*          users:(("napcat",pid=1204,fd=8))
# tcp   LISTEN 0.0.0.0:22           0.0.0.0:*          users:(("sshd",pid=700,fd=3))

▲ -t 只看 TCP,-l 只看监听状态,-n 显示数字而不做域名反查,-p 显示是哪个进程。

这三行里藏着整个项目的安全设计,一行一行读:127.0.0.1:3001 是机器人只在本机回环地址上听,只有这台机器自己能连它,公网上的任何地址都连不上;127.0.0.1:6099 是 NapCat 的网页面板也只在本机听,那个面板里能扫码登录 QQ,权限极高,绝不能暴露;0.0.0.0:22 是 SSH 在「本机所有网卡」上听,也就是公网可以访问——这是唯一一个对外开着的口,因为你需要从外面进来。

5.5 为什么 OWL 只监听 127.0.0.1,以及 NapCat 凭什么连得上

127.0.0.1 是「本机回环地址」:一个永远指向自己的特殊地址,你发给它的数据包不会离开这台机器,直接被内核转回本机。所以「监听 127.0.0.1」就等于「只接受本机内部的连接」。这在安全上叫减少攻击面:你的机器人本来就不需要被公网访问(真正需要连公网的是 NapCat 去连 QQ、以及机器人去调 DeepSeek),把它的门从「对全世界开」改成「只对家里开」,就白捡了一层安全。更别说如果 3001 暴露在公网,别人可以直接冒充协议端给你的机器人发消息,甚至套出她记得的所有人的隐私。

第二个问题更有意思:NapCat 跑在 Docker 容器里,它凭什么连得上宿主机的 127.0.0.1:3001?答案是因为它用了 network_mode: host(主机网络模式)。这是真实配置文件里的一行,也是整个部署里最关键的一行之一,原文注释写得比我好:

# 为什么用 host 网络模式:
#   机器人本体跑在宿主机上(监听 127.0.0.1:3001),NapCat 作为客户端反向连过来。
#   host 模式下容器内的 127.0.0.1 就是宿主机,能直连;
#   如果换成 bridge 模式,容器里的 127.0.0.1 是容器自己,会连不上。
services:
  napcat:
    network_mode: host

▲ 你会在第七章系统学 Docker,这里先拿到一个直觉:容器默认有自己的「网络世界」,它看到的本机不是你的本机。

讲细一点,因为这正是「我以为它应该能连上,可它就是连不上」这类故障的典型来源:默认的 Docker 网络模式叫 bridge,容器里有自己的虚拟网卡和独立 IP(通常 172 开头),它眼里的 127.0.0.1 指的是容器自己——所以容器里的程序去连 127.0.0.1:3001,实际上是在敲自己家的门,而机器人住在隔壁。改成 host 模式后,容器不再有自己的网络命名空间,它直接使用宿主机的网络栈,于是容器里和服务器上的 127.0.0.1 变成了同一个东西。

想一想如果为了「省事」,把机器人改成监听 0.0.0.0:3001,那么 bridge 模式下的 NapCat 也能连上(改用宿主机的内网 IP 就行)。这看起来更方便,但代价是什么?请具体列出至少两条你会失去的东西,再想:一个已经被验证安全的配置,和一个「也能跑」的配置,你该怎么选?

6. Shell 与第一组命令

你已经用过命令了,但还没问过一件很基本的事:我敲下的这行字,是谁在执行?

6.1 终端、Shell 与 bash

三个经常被混着用的词,分工其实很清楚:终端(terminal)是「提供文字输入输出界面」的东西,它自己不理解任何命令;Shell 是真正读你的命令、理解它、然后执行的程序——你 SSH 连上服务器后,服务器分给你的就是这么一个程序;bash 是 Shell 的一种具体实现(Bourne Again Shell),是 Linux 上最默认的那一个。

为什么值得说清楚?因为它决定了一个你一定会遇到的现象:Shell 会抢在命令之前解释你的输入。*、$、~、|、>、空格、引号——这些符号在你按下回车的那一刻先由 Shell 处理一遍,结果才交给真正的命令。所以很多「命令报错」其实错在 Shell 这一步,和命令本身没关系。你以后写的脚本报错,十次里有三次是引号和变量展开的锅。Shell 的本职工作可以概括成一个永不停止的循环:打印提示符 → 读一行 → 解析 → 执行 → 打印结果。所以「用命令行」就是「和一个非常听话、但完全不猜你心思的仆人对话」。

6.2 一条命令的三要素

命令      选项        参数
tail   -f  -n 100   /root/owl/data/botdata/bot.log

▲ 命令是要做什么(tail = 看文件尾部),选项是细节怎么调(-f 持续跟随,-n 100 先显示最后 100 行),参数是作用在谁身上(那个日志文件)。

选项有两种写法:短选项用单个减号,通常可以合起来写(ss -tlnp 等于 ss -t -l -n -p);长选项用两个减号,更好读(ls --all)。参数有时可以省略(pwd 不需要参数),有时可以有好几个(cp 源文件 目标位置)。这里有一个必须提前说的坑:选项的位置是有讲究的。绝大多数命令要求选项写在参数前面;写在参数后面,命令可能理解不了,也可能更糟——把它当成文件名。

6.3 当你不记得一个命令怎么用

  • man 命令名:读手册页(manual page),最权威,写清了每个选项的精确含义。缺点是很长很枯燥。进去后按空格翻页,按 q 退出,按 /关键字 搜索。
  • 命令名 --help:多数命令支持的快速帮助,直接打印常用选项,适合救急。不是所有命令都有(cd 是 Shell 内建的,得用 help cd)。
  • tldr 命令名:社区项目的「简明手册」,只给五六个最常用的例子,不解释全部选项。当你只想赶紧用起来时,它比 man 有用得多。它不一定预装在服务器上,需要自己装(Ubuntu 上可以 apt install tldr,也有 npm 版本)。

技巧先记住一条万能救命键:Ctrl + C,它会给当前前台运行的命令发送中断信号。当你误敲了 tail -f 或某个命令看起来卡住了,第一反应就应该是它。另一个是 Tab 键:敲路径时按一下会自动补全,连按两下会列出所有可能。Tab 补全不只是省打字——它是「避免拼错路径」最有效的手段,而路径拼错正是新手最常见的错误来源。

6.4 必学命令表(每个都配真实场景)

命令它做什么在 OWL 里的真实场景
pwd打印当前所在目录报「找不到文件」时第一件事就是敲它,确认自己站在哪
cd切换工作目录cd /root/owl,每天的第一条命令
ls列出目录内容(-l 详细、-a 含隐藏文件)ls -l bot/llm.local.json 检查密钥文件权限
mkdir创建目录(-p 连父目录一起建)建数据目录:mkdir -p data/{qq,napcat,botdata}
cp复制(-r 复制目录)改配置前备份:cp bot/config.json bot/config.json.bak
mv移动或改名把改坏的配置换回来:mv bot/config.json.bak bot/config.json
rm删除。没有回收站删日志:rm bot/bot.log.1——见 6.5 的保命习惯
cat把整个文件打到屏幕上看小文件:cat bot/config.json(小心:llm.local.json 里有 Key,别在别人面前敲)
less分页看文件(空格翻页、/ 搜、q 退出)看几百行的 config.json,比 cat 好得多
head / tail看开头 / 结尾若干行,tail -f 跟着新内容滚tail -f data/botdata/bot.log:这一个命令你会用几千次
grep在文本里找匹配的行从日志里捞错误:grep "LLM 请求失败" data/botdata/bot.log
find按名字、时间、大小在目录树里找文件找谁占了大空间:find data -size +100M
chmod改权限(第 4 节讲过 644 / 755 / 600)chmod 600 bot/llm.local.json:保护 API Key
chown改所有者与所属组把文件交给运行服务的那个用户:chown root:root bot/config.json
ln创建链接(-s 建软链接)4.5 那套「部署原子切换」
du统计目录占了多少磁盘(-sh 汇总)du -sh data/*:找出是 QQ 登录态还是缓存把盘吃了
df看各分区还剩多少空间df -h:磁盘满之前你唯一能提前看到的信号
free看内存和 Swap 的使用情况free -h:怀疑 OOM 时的第一条命令
uptime看开机多久、当前负载、几个用户在线登录后的第二眼(第一眼是 whoami,见 8.6)

技巧把这张表分成三组来记,比按字母背有效得多:看(pwd、ls、cat、less、head、tail、grep、find、du、df、free、uptime、ps、ss)、改(mkdir、cp、mv、chmod、chown、ln)、删(rm,以及一切带 -f 的操作)。「看」的命令随便敲,「改」的命令敲完要复查,「删」的命令敲之前先想三秒。这个分类不是知识,是纪律。

6.5 危险命令:三条保命习惯

最危险的命令长这样:rm -rf 某个路径。-r 是递归(连目录里的所有东西一起删),-f 是强制(不问我、不报错、直接删)。两个加在一起的意思是:从你指的那个位置开始,往下所有东西,一个不留,永不恢复。想象 rm -rf /(删根目录,机器报废)和 rm -rf /root/owl/data(删掉全部数据:QQ 登录态、所有人的记忆、所有日志)——前者会让整台机器报废,后者会让 OWL 忘掉所有人,而这两条命令只差几个字符。

警告保命习惯一:先 ls,再删。永远不要把 rm 当成第一个动作。先用 ls 完整列出你要删的东西,看清列表,确认没问题,再把同一个路径复制到 rm 后面。你要做的是「确认我删的是我以为的那个东西」,而唯一能确认的方式就是让机器把清单打给你看。

警告保命习惯二:不要让通配符和变量组合决定你删什么。rm -rf $DIR/* 有一个致命特性:如果 $DIR 恰好是空的(变量没设置成功、拼错了名字),这行命令就变成 rm -rf /*——它不会报错,它会开心地把整台机器删掉。规则很简单:删除命令里不出现 $变量。如果实在必须用,先 echo $变量 看它到底是什么;同理,rm -rf /root/owl/data/* 里的 *,要先 ls /root/owl/data/ 看它匹配到了什么。

警告保命习惯三:重要操作前先备份,并且把备份放在删除范围之外。改 config.json 之前先 cp bot/config.json bot/config.json.bak;重装之前先把 data/ 打包一份到别的地方。注意最后半句:如果备份和原文件在同一个目录里,rm -rf 会把它们一起删掉。备份的意义不是「有一个副本」,而是「有一个不在危险范围内的副本」。

除了 rm -rf,还有两个必须知道的「静默杀手」。第一个:> 文件 会清空文件。这个符号的本意是「把输出写进这个文件」,而实现方式是「打开文件、把内容截断为 0」。所以一条看起来无害的 echo "" > bot/config.json,会把配置变成一个空文件;更隐蔽的版本是:tail -100 bot.log > bot.log——Shell 会先按 > 把日志清空,然后才开始执行 tail,于是 tail 读到的是一个空文件,你的日志就这样没了。规则:永远不要用重定向把输出写回它正在读的那个文件。想追加要用 >>(第 7 节讲)。第二个:mv 会静默覆盖同名文件。mv a.json b.json 如果 b.json 已存在,它不会问,直接覆盖。而 mv config.json.bak config.json 和 mv config.json config.json.bak 是两个方向完全相反的操作,打错的后果是「把好的覆盖成坏的」。

想一想有人说「只要我小心一点,就不需要备份」。请用这一节的三个案例反驳他,并回答一个更难的问题:如果「小心」是一种人的状态,那么在人最累、最急、最想赶紧搞完去睡觉的那个深夜,它还在吗?工程上所有的保护机制,本质上都是在假设「人会犯错」的前提下设计的。想一想:你自己的项目里,哪些地方现在只靠「你会记得小心」撑着?

7. 管道、重定向与文本搜索

如果整章只允许你带走一个思想,应该是这个:Linux 的命令不是孤立的工具,它们可以被串起来。前一个命令的输出,直接变成后一个命令的输入,中间不需要任何文件。这个设计来自 Unix 哲学的一条原则:每个程序只做一件事,并且做好。grep 只会找文本,sort 只会排序,wc 只会数数——它们单独看都很弱小,但串起来之后,你可以用三行命令完成一个有实际价值的数据分析任务。这就是本节最后要带你做的那件事。

7.1 输入输出的三个通道

每个进程启动时,内核都会给它三个「通道」(正式名字叫文件描述符,你可以先理解成三条管子):标准输入(stdin,编号 0)默认来自键盘;标准输出(stdout,编号 1)默认通向屏幕,装着命令正常的结果;标准错误(stderr,编号 2)默认也通向屏幕,装着报错信息。

「正常结果」和「报错」是两个独立的通道,这一点极其重要,因为它解释了无数个「我明明把输出存到文件里了,为什么屏幕上还在刷错?」——因为报错走的是 2 号通道,你的重定向只处理了 1 号。

7.2 重定向:把输出换个地方去

ls -l /root/owl/bot/ > /tmp/list.txt      # 把正常输出写进文件(覆盖!)
ls -l /root/owl/bot/ >> /tmp/list.txt     # 追加到文件末尾,不覆盖
ls /tmp/不存在的目录 > /tmp/out.txt 2>&1  # 正常输出和报错都写进同一个文件
find / -name "*.log" 2> /dev/null         # 只看结果,把报错(无权限的目录)丢掉

▲ > 覆盖、>> 追加、2> 单独处理错误通道、2>&1 的意思是「让 2 号通道去 1 号通道现在所在的地方」。

最后那条 find / -name "*.log" 2> /dev/null 你几乎每天都会用。原因很实际:在根目录下找文件时会撞上大量系统目录,它们拒绝普通用户读取,于是屏幕上刷出几百行 Permission denied,把真正有用的结果淹没。把错误通道丢给 /dev/null(第 3 节那个黑洞),你就能只看自己想看的东西。另外 2>&1 里的顺序不能反:数字是通道编号,> 是「到哪里去」,& 是「这是一条通道而不是一个文件」。

7.3 连接命令的三种方式

cd /root/owl && bash tools/healthcheck.sh   # 前一条成功,才执行后一条
cd /root/owl ; ls data/       # 前一条无论成败,都执行后一条
ps aux | grep node            # 管道:把前一条的输出,喂给后一条当输入

▲ && 是「而且」,; 是「然后不管怎样」,| 是「连起来」。

&& 和 ; 的差别值得单独说,因为它是很多人脚本出错的原因。用 && 时,如果 cd 失败(目录不存在、打错字),后面的健康检查不会执行;换成 ; 它照样执行,但会在错误的目录里执行,于是它检查的文件全都找不到,给你一份完全错误的报告。记住这条判断:凡是「后一步依赖前一步的位置或结果」的,就用 &&。

然后是 管道 |,本节的主角。它的含义一句话就够:把左边命令的标准输出,直接接到右边命令的标准输入上。数据从内存里流过去,不落硬盘、不留临时文件。所以 ps aux | grep node 的意思是「列出所有进程,把这份文本当作输入交给 grep,让它只留下含 node 的行」。

7.4 文本处理工具,以及它们各自只会一件事

命令只会这一件事典型用法
grep从输入里挑出匹配的行grep "🛟" bot.log:捞出所有危机命中记录
wc数行数、词数、字符数(-l 只数行)grep "LLM 请求失败" bot.log | wc -l:数一共失败了几次
sort排序(默认按文本升序)sort | uniq -c 是固定搭配
uniq去掉相邻的重复行(-c 顺便计数)sort | uniq -c | sort -rn:统计词频并从多到少排
awk按列切分每一行,做提取、过滤、计算awk '{print $1, $2}':只打印每行的前两列
cut按固定分隔符取某几列处理格式非常规整的文本时比 awk 更省事

这里面 uniq 有一个必须说清的陷阱:它只能去掉「相邻的」重复行。所以 uniq -c 之前必须先 sort,否则同类内容散落在文件各处,统计出来是碎的。sort | uniq -c 因此成了所有日志统计的起手式,你会在下面两分钟里见到它三次。再说 awk,它常被新手当成一门语言来害怕,其实你暂时只需要它的一个用法:把每一行按空格切开,然后打印第几列——awk '{print $1}' 里的 $1 是第一列,$2 是第二列,$NF 是最后一列。

7.5 一次真实的日志分析任务

现在做一件真事。假设你发现昨天有人在对 OWL 说很重的话,你想知道:今天危机信号命中了几次?分别是什么级别?AI 调用失败的原因分布是什么?先看日志长什么样(bot.js 和 llm.mjs 里的真实格式):

[2026-10-04 10:25:13] 📩 [private] 小明(10001): 我最近什么都学不进去
[2026-10-04 10:25:13] 🛟 危机信号命中: high(累计 3 次)
[2026-10-04 10:25:14] ❌ LLM 请求失败: 超时
[2026-10-04 11:02:41] 🛟 危机信号命中: mid(累计 4 次)
[2026-10-04 11:02:41] 🧭 价值观时机: 反说教
[2026-10-04 11:03:02] ❌ LLM HTTP 429: 请求过于频繁
[2026-10-04 12:15:30] 🛟 危机信号命中: critical(累计 5 次)

▲ 每行都是「时间戳 + 事件 + 细节」这个形状。这个形状是刻意的,正是为了让下面这些命令能用。

任务一:今天危机信号命中了几次?

grep "^\[2026-10-04" data/botdata/bot.log | grep "🛟" | wc -l

▲ 逐段读:grep "^\[2026-10-04" 挑出所有以这个日期开头的行(^ 表示行首,\[ 里的反斜杠是转义,因为 [ 在正则里有特殊含义);接着的 grep "🛟" 在这批行里只留危机命中的那些;最后的 wc -l 数出有几行。答案就是行数——因为一条事件正好占一行。

任务二:命中分别是什么级别?各几次?

grep "🛟" data/botdata/bot.log | awk '{print $3}' | sort | uniq -c | sort -rn

▲ awk '{print $3}' 取每行第 3 列,也就是 high / critical / mid 这类词;sort 把它们排在一起;uniq -c 把相邻同类合并并计数;最后的 sort -rn 按数量从大到小排(-r 反向,-n 按数字而不是按字母)。输出会是「3 high、2 critical、1 mid」,第一列是次数。

任务三:AI 调用失败的原因分布。

grep "LLM" data/botdata/bot.log | grep -v "^\[.*\] 📩" | awk '{$1=""; $2=""; print}' | sort | uniq -c | sort -rn | head -10

▲ grep -v 表示反选,排除那些只是用户消息里恰好含 LLM 的行;awk '{$1=""; $2=""; print}' 把前两列(日期和时间)清空,只留事件描述;后面依旧是「排序 → 统计 → 从多到少 → 只看前十名」。你会得到类似「12 次超时、3 次 HTTP 429、1 次响应解析失败」的结果。这三行的含义完全不同:超时是网络或模型太慢,429 是撞上了限流,解析失败是代码或接口格式的问题。一份「原因分布」能立刻告诉你该去修哪里,而「日志里有报错」这句话什么也告诉不了你。

最后再加一个你可能立刻想用的命令,用来实时盯着机器人在干什么:

tail -f data/botdata/bot.log | grep --line-buffered -E "🛟|❌|✅"

▲ tail -f 持续输出新日志,管道转给 grep,于是屏幕上只会滚出你关心的那几类事件。--line-buffered 是必须的:它让 grep 收到一行就立刻输出,否则它会攒够一批才显示,你会以为日志卡住了。

想一想上面三个任务,如果只能用眼睛在 less 里翻日志,你要花多久?如果日志有 20 万行呢?请你自己设计一条命令,回答这个问题:昨天消息最多的是哪一个小时?提示:时间戳的第 1 列里含着小时,而你需要先把「哪一列」切出来。写出来之后,你就已经拥有了「用命令做数据分析」的能力。

8. SSH 与软件安装:你在这台机器上的第一天

前面七节,我一直假设你已经站在那台机器上了。现在回到最开始的问题:你的电脑和上海那台机器之间,那条线是怎么连起来的?

8.1 SSH 在做什么

SSH 的全称是 Secure Shell(安全外壳协议)。它做的事很简单,却极其强大:在你的电脑和服务器之间建立一条加密通道,然后把服务器上的一个 Shell 接到你的终端上。你的键盘输入被加密送到服务器,服务器上的 Shell 执行它,结果再加密送回你的屏幕。全程你的电脑只做两件事——发字节、收字节;真正干活的那台机器从头到尾都是服务器。

这就是为什么你的电脑从此只是一台终端:它没有算力,没有数据,它只是你的手和眼睛。你合上笔记本,服务器上的一切照常运行——这也是 OWL 能 24 小时在线的全部原因。顺便说清一个常被混淆的点:SSH 加密的是这条通道,不是「你的命令」;路上的任何设备都看不到你发了什么,也无法修改它。这也是为什么传 API Key 必须走 SSH/SFTP——第 4 节那条安全链里的一环就是它。

8.2 密码登录与密钥登录

密码登录密钥登录
你提供什么一串密码用私钥做一次数学证明
秘密会不会留在服务器上会:服务器上留下了可复用的凭据,机器一旦被人读到,口令或它的哈希就跟着被拿走私钥永远不离开你的电脑,服务器只验证签名
能被暴力破解吗能,公网上的机器人每天在撞 22 端口基本不能
OWL 的选择已禁用唯一方式

密钥登录的原理值得用一句话说清,因为它是你理解的第一个非对称加密应用:你手里有一把私钥(只有你有),服务器上放着对应的公钥。登录时服务器发给你一段随机数据,你用私钥变换它,把结果发回去;服务器用公钥验证这个结果。整个过程不需要传输任何秘密。这就是它比密码强的地方——网络上没有「能被偷的字符串」在流动。

OWL 的服务器已经禁用了密码登录,所以连它时必须带上私钥,真实长相是:

ssh -i "C:\你的项目目录\qq-bot\deploy\server-key.pem" root@你的服务器IP

▲ -i 是 identity(身份文件)。省略它就会去找 ~/.ssh/id_rsa,找不到就报 Permission denied (publickey)——那个报错的意思不是「密码错了」,而是「你没能证明你是你」。

警告私钥文件等于你的钥匙,而且是不可撤销的钥匙。三条具体规矩:一,私钥文件的权限必须收紧到「只有你能读」——在 Linux / macOS 上就是 600,做不到 SSH 会直接拒绝使用它(它比你还谨慎),Windows 上的做法见下面;二,绝不能提交到 Git 仓库、不能放进部署包、不能贴给别人(第三章会专门讲 Git 怎么把密钥泄露出去,那是最经典的事故);三,如果它泄露了,唯一正确的补救是在服务器上删掉对应的公钥,而不是祈祷。

但上面第一条要分系统说清楚,因为你的私钥就放在 Windows 上:

  • Linux / macOS:用 chmod 600 server-key.pem。这两个系统的权限模型就是第 4 节讲的 rwx,SSH 会直接检查那几位。
  • Windows:chmod 在这里几乎不起作用——Windows 没有 rwx 那套权限位,用的是 NTFS 的访问控制列表(ACL)。所以你的私钥是否「受保护」,要看的是谁被允许读这个文件,而不是它显示成 644 还是 600。查看用 icacls "路径\server-key.pem",它会把有权限的账户列出来。正确的期望是:只有你自己、SYSTEM 和 Administrators 能读;如果列表里出现了 Users、Everyone、Authenticated Users 这类宽泛的组,就说明别人也能拿走你的钥匙。
  • 当你看到 UNPROTECTED PRIVATE KEY FILE 或 Permissions for 'xxx.pem' are too open:说明本机 OpenSSH 认定这个文件太开放、拒绝加载它。此时你不需要改密钥内容,只需要收紧它的 ACL——最省事的办法是把 .pem 挪进你的用户目录(例如 C:\Users\你\.ssh\),Windows 上新建在用户目录里的文件默认只对你自己开放;仍然报错时,用 icacls 文件名 /inheritance:r /grant:r "%USERNAME%:R" 去掉继承权限、只留你自己可读。
  • 反过来,如果在 Windows 上敲 chmod 600 之后「什么反应都没有」,那不是命令失败,也不是你已经安全了,而是这个命令在 Windows 的权限模型里没有对应物。别把「没有报错」当成「已经修好」——这正是第 4 节那句话的另一面:安全上的问题几乎都是沉默的,你只能靠检查发现它。

8.3 起个别名:~/.ssh/config

每次登录都敲那一长串 -i 加路径加 IP 很快就会烦。SSH 允许你把连接信息写进 ~/.ssh/config,然后起一个短名字:

Host owl
    HostName 你的服务器IP
    User root
    IdentityFile C:\你的项目目录\qq-bot\deploy\server-key.pem
    ServerAliveInterval 30

▲ 写完之后登录只需要 ssh owl。ServerAliveInterval 30 表示每 30 秒发一次心跳,避免连接因闲置太久被中途设备掐断——你会需要它的,因为 SSH 隧道必须一直开着。

这个文件还有一个更重要的价值:它把你的连接信息集中到一处。以后服务器换了 IP、换了密钥路径,你只需要改这里的一行,而不是去翻记过的十几条命令。这就是「配置和命令分离」,它是第七章、第九章里会反复出现的一个工程习惯的最小版本。

8.4 传文件:scp 与 rsync

部署的本质是「把本机改好的代码送到服务器上」。最基础的工具是 scp(secure copy),语法和 cp 几乎一样,只是路径可以带一个「远端」:

# 本机 → 服务器(把机器人代码传上去)
scp -i $K "C:\...\qq-bot\bot\*.mjs" "root@你的服务器IP:/root/owl/bot/"

# 服务器 → 本机(把日志拉下来慢慢看)
scp -i $K "root@你的服务器IP:/root/owl/data/botdata/bot.log" .\bot.log

▲ 冒号前面是「哪台机器」,冒号后面是「那台机器上的路径」。没有冒号的路径就是本机。

rsync 是它的进阶版,差别在于增量:scp 每次都把整个文件重传一遍,rsync 会比较两边差异、只传变化的部分,还能保留权限和时间戳,也可以让两边完全一致(--delete,注意它会删东西):

rsync -avz --delete -e "ssh -i $K" ./bot/ root@你的服务器IP:/root/owl/bot/

▲ -a 归档模式(保留权限等属性)、-v 显示过程、-z 压缩传输。

什么时候用哪个?偶尔传一两个文件用 scp,反复同步一个目录用 rsync。但两者都只做「复制文件」,它们不负责重启服务、不检查版本、不回滚。所以真实的部署流程里,传完文件之后还有一步 systemctl restart qqbot——第七章会把这套流程串成一个完整脚本。

8.5 SSH 隧道:把不对外开放的东西搬到本地

这是 SSH 里最漂亮的功能,也是你项目安全设计的支柱。先回忆第 5 节:NapCat 的面板只监听 127.0.0.1:6099,而安全组只放行 22。这两个结论合起来意味着:从公网上,你打不开那个面板。这是有意为之——那个面板里能扫码登录 QQ,等于整个机器人的控制台。可是你确实需要用它,怎么办?

ssh -L 6099:127.0.0.1:6099 owl

▲ 读法是「-L(local,本地):把本机的 6099 端口,接到远端那台机器的127.0.0.1:6099 上」。三段分别是:本地端口 : 目标主机(从服务器视角看): 目标端口。

执行之后这条 SSH 连接会一直开着。此时你在自己电脑的浏览器里访问 http://127.0.0.1:6099/webui,请求会:进入你本机的 6099 → 被 SSH 加密 → 送到服务器 → 由服务器上的 SSH 服务转交给它本机的 6099。整个过程中,6099 从来没有对公网开放过一秒。

对比一下「图省事」的做法:在安全组里放行 6099,然后访问 http://服务器IP:6099/webui。那条路有三层问题:暴露面——公网扫描器每天扫完整个 IPv4 地址空间的所有端口,你的面板会在几小时内被发现并被尝试登录;认证强度——面板的认证只是一个 token,而 SSH 这一层是密钥加加密通道;后果——一旦有人进了面板,他能扫码登录你的 QQ 号、改反向 WS 配置、读你全部配置。这不是「被看到」,这是「被接管」。这就是为什么运维手册里反复强调「面板走隧道」:你自己敲那条命令时,要知道自己在做一个安全上的正确决定,而不是在照抄咒语。(顺带一提,-R 能把本机端口反向暴露到服务器上,-D 能把 SSH 变成本地代理;你现在用不到,但知道「隧道是可逆的」会有用。)

8.6 第一次登录服务器后的十个体检命令

现在把前面所有东西组合成一件可以立刻执行的事。假设你刚 ssh owl 连上一台陌生的服务器(新买的,或者别人交给你的),你应该按顺序问它十个问题。这些命令不改变任何东西,它们只回答「这台机器现在是什么状态」。

#命令你问它什么你要看什么
1whoami我现在是谁是不是 root。这一步是防止「在错误的身份下敲危险的命令」
2uptime机器开了多久,负担重不重开机天数(重启过吗)、三个负载数字
3free -h内存还剩多少,Swap 有没有在用available 那一列;Swap 那行常年非 0 就是危险信号
4df -h磁盘还剩多少根分区使用率,超过 85% 就该处理了
5ip -brief addr我有几张网卡、各自的 IP 是多少、状态是 UP 还是 DOWN回环 lo 之外的网卡是否为 UP;有没有拿到预期的内网地址
6ip route默认路由指向哪里——也就是「我要出网,先交给谁」default via … 那一行。它缺失,就意味着这台机器出不了网
7ss -tlnp谁在听端口,听在哪个地址上有没有 0.0.0.0 的意外暴露
8systemctl status qqbot机器人服务是活的吗,重启过几次Active: active (running)、运行时长、最近几条日志
9docker ps容器都在跑吗,跑了多久STATUS 里的 Up x hours——只有几分钟说明它在反复重启
10tail -f /root/owl/data/botdata/bot.log它现在正在经历什么最真实的一手信息。Ctrl + C 退出

第 5、6 两条是这次新加的,因为「网络」是一整层,不能只用端口代表它。第 7 条 ss 回答的是「这台机器上哪个程序在等谁敲门」(端口);第 5、6 条回答的是「这台机器自己有没有联网、走的哪条路」(网卡与路由)。两者经常一起坏、却各自坏在不同地方:网卡 DOWN 或默认路由丢了,表现是「机器人收不到消息」,而 ss -tlnp 看起来一切正常——这正是第 9 节那种「每个局部指标都说自己很好」的故障的另一个版本。要知道自己的公网 IP,可以在这些命令之后单独问一次外网:curl -s ifconfig.me。

为什么是这十个?因为它们正好覆盖了第 2 节那五个硬件角色加上「服务」这一层:内存(3)、硬盘(4)、网络(5、6)、端口(7)、进程(8、9)、系统整体(2)、当前活动(10),以及最容易被忽略的身份(1)。这些命令你不需要背——你只需要记住「五角色 + 身份」这个清单,命令自然就跟着出来了。把它们写进一小段脚本(就像 OWL 项目里那个每 5 分钟跑一次的健康检查),每次怀疑出问题就跑一次:这不是偷懒,它保证你每次都检查同样的项目,而不是「想到什么查什么」——人的注意力在有压力时会变窄,而脚本不会。

8.7 软件是怎么装上去的:apt 与仓库

现在机器已经在你手上了,接下来是「怎么在它上面装东西」。在 Windows 上你的习惯是「去官网下载一个 exe,双击」,在 Linux 服务器上基本不这么做。Linux 的发行版自带一个包管理器,Ubuntu 用的是 apt。它和手机的 App Store 是同一类东西:你不知道也不需要知道软件的下载地址,你只说「我要装 nginx」,剩下的它去做。

它为什么会知道去哪下载?因为它有一份软件仓库清单:/etc/apt/sources.list 和 /etc/apt/sources.list.d/ 里列着一串服务器地址,每个地址背后是一个仓库,仓库里放着成千上万个已经打包好、且互相兼容的软件。于是那三个总被搞混的命令,其实是三步完全不同的动作:

命令它做什么类比什么时候跑
apt update去仓库下载最新的软件清单(哪个包最新版是几、从哪下)。它不安装任何东西刷新商店的目录页装东西之前,尤其是刚改过源
apt install 包名真正下载并安装某个软件,连同它依赖的其它包下单并送达你要装东西的时候
apt upgrade把已经装上的软件全部升到清单里的新版本把家里所有东西都换成新版定期做,且要留神

警告两个真实的坑。第一,很多人以为 apt update 是「升级系统」,于是一边抱怨「怎么还是旧版本」一边反复跑它。它只刷新目录,不升级任何东西。第二,apt upgrade 是一次「改了很多东西」的操作,它可能顺带重启某些服务、引入不兼容的新版本。正确做法是先看一眼它打算改什么:apt list --upgradable,确认没有意外再决定。这就是「一次只改一个变量」,这条原则在第 9 节会以更重要的形式出现。

那为什么还有人「从源码安装」?因为仓库里的软件有两个天生的限制:版本旧(发行版追求稳定,一个 LTS 版本发布后里面的软件版本基本冻结)和不在仓库里(新项目、小众工具、需要特定编译选项的软件)。代价很具体:你要自己装编译器、自己解决所有依赖、自己编译(可能几十分钟)、自己想办法升级。所以有一条不写在任何文档里但很值得遵守的判断:能用包管理器解决的,就不要自己编译。包管理器替你处理了依赖、升级、卸载和文件归属;自己编译得到的是一堆「在硬盘上但你不知道装了哪些文件、也不知道怎么卸干净」的东西。这不是懒,这是把「可维护性」当成成本算进去了。

8.8 Node 怎么装,以及为什么服务器不装图形界面

你的机器人跑在 Node.js 20 上,而 Ubuntu 22.04 自带的 Node 版本偏旧。这就是「仓库里的版本太旧」那个限制的真实案例,解决办法是添加一个额外的仓库——NodeSource 提供官方的 Node.js 软件包:

# 1) 添加 NodeSource 的 20.x 仓库(它会把源文件写进 /etc/apt/sources.list.d/)
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -

# 2) 让 apt 知道有了新仓库,然后安装
sudo apt-get install -y nodejs

# 3) 验证:这两条输出是你以后排查环境问题的第一步证据
node -v        # 应该显示 v20.x
npm -v

▲ 这就是 install.sh 里检测到没有 Node 时打印给用户的那几条命令(真实内容)。

这段代码里有几个细节值得停下来看。curl -fsSL 地址 | sudo -E bash - 是一整条管道:把脚本下载下来,直接交给 bash 执行。-f 是「HTTP 出错就失败」(防止把一段错误页面当脚本执行),-s 不显示进度,-S 出错时仍显示错误,-L 跟随重定向;而 sudo -E bash - 的意思是「用 root 权限执行从标准输入读进来的脚本」。这里面有一个你必须在心里记下的风险:你在用一个 root 权限执行一段从网上刚下载、你没读过、而且随时可能被改的脚本。它是这个生态里最普遍的安装方式,同时也是最典型的供应链风险。工程上的正确态度是:对知名项目、官方文档给出的地址可以用;对来源不明的「一键脚本」,不要用。最后,node -v 这一步不是形式主义——你以后遇到的「本机好好的、服务器上报语法错误」,一半以上是版本不一致。养成习惯:在任何一台新机器上,先用 -v 把关键运行时的版本打出来看一眼。

另有一条路是 nvm(Node Version Manager):它把 Node 装在你的家目录里,允许你同时装好几个版本、随时切换,更适合开发机。但用包管理器装的 Node 有一个 nvm 给不了的好处:它对整个系统可见,systemd 能稳定地按固定路径找到它。install.sh 里那句 ExecStart=$(command -v node) ... 之所以能工作,前提就是 node 在 systemd 也能看到的路径上。这就是这个项目选 NodeSource 而不是 nvm 的原因——服务器上的原则是「可预测」优先于「灵活」。

最后回答一个看起来只是习惯的问题:为什么服务器不装图形界面?图形界面(Windows 上你熟悉的桌面、开始菜单、鼠标指针那一整套)在服务器上通常不装,有四个硬理由:它要吃资源——一套桌面动辄几百 MB 内存,你的机器只有 1.6 G,而 OWL 全套只占 476 MB,装一个桌面等于凭空多养一个比机器人还重的客人;它带来大量可以被攻击的代码——桌面服务、字体渲染、图片解码历史上出过不少漏洞,每一个你不需要的软件都是一个你不需要承担的风险;你在服务器上没有屏幕和鼠标——就算装了也只能隔着一条加密通道传「画面」,而传一幅画面的带宽够传几千行文字;图形界面无法被脚本化——这一点最重要:你不可能让脚本去「点一下那个按钮」,但你可以让脚本执行一条命令。所以「服务器不装图形界面」不是苦修,而是一个结论:在这台机器上,文字是唯一的、也是最高效的接口。你这一章学的所有东西,都是围绕着这句话长出来的。

9. 一次真实的排障复盘:从「假活」学方法论

前面八节给你的都是「正常状态下的知识」。可是你真正会用到这一章的时刻,是 OWL 坏掉的那一刻。所以这一节我们不开新概念,只做一件事:把一次真实的故障从发现到结论完整走一遍。你要学的不是这个故障本身,而是它背后那套可以迁移到任何地方的方法。

9.1 现象:一切指标都正常,但用户没有收到任何回复

这是 2026 年 10 月 3 日真实发生过的事。有人给 OWL 发消息,她没有回。于是登上去,敲了四条命令,得到的结果是:

systemctl is-active qqbot   → active      (进程活着)
docker inspect napcat       → running     (容器健康,零重启、无 OOM)
ss -tn | grep 3001          → ESTAB       (反向 WS 连接还在!)
NapCat 日志                  → 每小时还有 [ServerTime] 时间同步

▲ 这叫「假活」:每一个局部指标都在说「我很好」,但整件事已经坏了。

真因写在 NapCat 的日志里,藏在几千行输出中间:

10-03 21:59:08 正在快速登录  1876148307
10-03 21:59:08 快速登录错误:你的用户身份已失效,为保证账号安全,请你重新登录。

▲ 也就是说:本地登录态还在,服务端不认了。QQ 侧把这次登录作废了,所以 NapCat 手里的会话变成了一张废票,消息根本到不了它这里。

9.2 第一件事:先确认现象,不要急着猜原因

新手在听到「机器人不回消息」之后的第一个动作,通常是重启。这是错的,而且错得很典型:重启会破坏现场。进程重启之后,内存里的状态没了,日志被新的启动信息顶掉,你原本能查到的东西变得查不到了。更糟的是,如果重启真的让服务恢复了,你就永远不知道它为什么坏——而它会以同样的方式再坏一次。

正确顺序的第一步永远是确认现象,把「不回消息」这句含糊的话变成可验证的事实:是自己一个人没收到,还是所有人?是私聊还是群聊?是完全没有回应,还是回了但内容不对?消息是刚才发的还是一小时前的?每回答一个这样的问题,你排除掉的可能性就多一批。

第二步才是问「哪一层坏了」。而要做好这一步,你需要在动手之前就在脑子里画出一张链路图。

9.3 分层假设:把一条链切成四段

一条消息从别人手机到 OWL 的一句话,中间至少经过四层,每一层都会坏,而且坏起来的表现几乎一样——都是「没反应」:

层这一段里可能坏的东西怎么单独验证
QQ ↔ 腾讯服务器QQ 号被风控、被封、被限制登录用你自己的号发一条,看它有没有到达服务器
腾讯服务器 ↔ NapCat登录会话失效、协议端被踢下线看 NapCat 日志里的登录状态与下线通知
NapCat ↔ 机器人本体反向 WS 断了、token 不匹配被拒绝ss -tn | grep 3001;日志里找「拒绝未授权连接」
机器人 ↔ DeepSeekAPI Key 失效、超时、限流、余额不足bot.log 里找 ❌ LLM ... 那几行

为什么必须这样切?因为一次只能改一个变量。如果你同时重启 NapCat、重启机器人、重新扫码,然后它好了,你其实什么都没学到:你不知道是哪一步救了它,下一次坏了你还得把这三件事再做一遍(而其中两件是白做的,甚至是有害的)。把系统切成互相独立的段,是为了让每次实验的结论唯一。

那应该从哪一层开始查?这里有一条很实用的经验判断:从最外层开始——也就是离用户最近的那一层。因为越靠外的东西越容易坏,也因为外层的故障会伪装成内层的故障。消息没进来,你就算把机器人代码读一百遍也没用。

9.4 逐层验证:一个能直接给出结论的探针

先查连接层:ss -tn | grep 3001 显示 ESTAB,意思是「NapCat 和机器人之间那条 WebSocket 连接还活着」。这一条信息很关键,它一次性排除了两层:机器人本体在跑(不然没人能建立连接),NapCat 到机器人的通道也是通的。所以嫌疑立刻被压缩到「NapCat 与 QQ 之间」。

但「嫌疑」不是「结论」。这时候需要的是一个能直接验证机器人本身是否正常的手段。这个项目里有一个专门为此写的小工具(tools/_probe-reverse.mjs),它的做法非常聪明:假装自己是 NapCat,连上反向 WebSocket,冒充 QQ 核心发一条 /ping 进去。

cd /root/owl
node tools/_probe-reverse.mjs

✅ WebSocket 已连接
← 机器人调用: send_private_msg  {"text":"pong 🏓"}
===== 结论 =====
✅ 机器人有响应 → 机器人本体正常。问题在 NapCat 与 QQ 之间(登录会话失效/被风控)。

▲ 这个工具的价值不在于那条命令,而在于它的设计思路:用一个已知输入,去测试某一段是否正常工作。

这就是「分而治之」在排障里的样子。请仔细看它给出的两种结果各自意味着什么,因为这才是你要带走的东西:

  • 有响应 → 机器人本体正常(它能连、能收、能回)→ 那么问题一定在 NapCat ↔ QQ 之间。这时候再去 NapCat 日志里找登录状态,你就知道该找什么了。
  • 无响应 → 连一个最简单的 /ping 都没有回应 → 问题在机器人本体或它下面的东西(进程死了?端口没起?配置解析失败?)→ 那就回去看 bot.log 和 systemctl status qqbot。

一个测试、两种结果、每条都指向一个唯一的下一步。这就是一个「好实验」的形状。以后你自己写排查脚本时,请用这个标准要求自己:如果一条命令的输出无法把可能性一刀切成两半,那它就不是一个好探针。

顺着这条线查下去,最终在 NapCat 日志里找到了那句真正的证据:「你的用户身份已失效,为保证账号安全,请你重新登录。」结论到此确定:这不是服务器的问题,不是代码的问题,不是网络的问题,不是内存的问题,而是腾讯把这次登录作废了。恢复方式只有一种:通过 SSH 隧道打开 NapCat 面板,重新扫码。

9.5 结论与教训:活着不等于可用

复盘到这里,真正值得记住的是下面这几条。

第一,「进程活着」和「服务可用」是两件不同的事。在这个故障里,进程活着、TCP 连接活着,但端到端的功能已经死了。原因是:每一层的「健康」都是局部的——systemd 只知道自己的进程还在,Docker 只知道容器没退出,内核只知道 socket 还连着。没有任何一个局部指标能代表整条链路,所以真正的健康检查必须从链路的一端发起,而不是分别去看每一段的自述。

第二,什么时候不该自动重启。这个项目明明有能力检测故障,却刻意不做「发现问题就自动重启」,理由是:登录失效时重启没有任何用(重启一百次,作废的会话也不会变有效),而自动重启会掩盖真实问题——服务被反复拉起来,日志里全是重启记录,你反而更难看清发生了什么。这是这一章里最成熟的一个工程判断:自动化之前,先确认自动化真的能解决问题。

第三,问题不一定在代码里。这个故障的根因在别人的系统里(腾讯的风控与协议端限制),你能做的只有重新登录、控制频率、以及长期考虑换成官方平台。但是——请注意这个「但是」——你依然要为它做工程决策:因为系统的目的不是「进程活着」,而是「用户能收到回复」。所以正确做法是把「随时可能被踢下线」当作一个已知的环境事实去设计:每 5 分钟检查一次登录状态、把结果写进日志、掉线时给出明确的恢复指引(而不是让你对着黑屏发呆)。

这套方法论,抽出来只有五句:

一、先确认现象,把「坏了」翻译成可验证的事实,并且不要用重启破坏现场。

二、先画出链路图,把系统切成互不重叠的层,让每次实验的结论唯一。

三、从最外层往下问,离用户最近的层最容易坏,也最会伪装成别人的问题。

四、一次只改一个变量,用「一个测试、两种结果、各自指向唯一下一步」的方式做探针。

五、把结论写成文档,因为同一个故障一定会再来一次——而下次你希望自己只需要读三行字。

最后一句是关于你自己的:这次排查真正省时间的地方,不是那几条命令,而是在敲命令之前,脑子里已经有了那张四层的图。第七章会把这张图扩展到整个部署体系(Docker、systemd、日志、安全组),并且带你亲手把它画出来。到那时候,你会发现这一节的每一条原则都还能用,只是图变大了。

10. Linux 没有礼貌,但它可以被复现

回头看这一章,你可能有一个印象:Linux 好像很难。命令多、选项怪、动不动就把东西删了。

我想把它换一个说法:Linux 不难,它只是没有礼貌。

它不问你「确定要删除吗」,不劝你「这样可能不太好」,不在你敲错的时候说「你是不是想敲 xx」。你说什么,它就做什么,一个字都不多。这种性格在生活里会让人觉得冷漠,但在机器里,它是最大的优点:没有礼貌,意味着没有意外;没有意外,意味着结果只取决于你说的话。

而这就引出了本章最后、也是最重要的一个判断:命令行是「可复现」的载体。

想一想图形界面。你用鼠标点菜单完成一件事,这件事的过程存在于你的动作里,而不存在于任何文本里。第二天你想再做一遍,只能凭记忆再点一遍;你想让别人也做一遍,只能写一段说明,而说明永远说不清(「点那个设置,就是上面第三个」——哪一个上面?)。图形界面的操作,天生是模糊的、一次性的、依赖人手的。

命令行不一样。你今天敲的每条命令都是一行文本;把它们写进一个文件,它们就变成了脚本;脚本可以放进 Git 仓库、可以被别人审阅、可以改一行再跑一次、可以在你没醒着的时候由机器自己跑。你这一章学的东西,和第七章要做的部署,本质上是同一件事:把「我做过什么」从记忆里搬出来,变成一段谁都读得懂、谁都能重放的文字。

下一章我们要开始写代码了——从最简单的变量开始,一行一行地写。你可能会觉得那比这一章容易,也可能觉得更抽象。但无论哪种,请记住一件事:你写下的每一行 JavaScript,最后都会变成今天这台机器上的一个进程、一次系统调用、一段落在硬盘上的文字。今天你看的是它的家;下一章开始,你会亲手往里面放东西。

11. 自查:你是不是真的懂了

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

  1. 用你自己的话说清「实例」和「一台真电脑」的关系。为什么一台 2 核 1.6 G 的机器在技术上算「一台完整的电脑」,但你不能指望它像你的笔记本一样快?
    参考答案

    关系在于虚拟化:机房里有一台更大的宿主机,它的 CPU 和内存被切成若干份,每一份装上一套完整的操作系统,对外表现为一台独立的电脑。说它「完整」,是因为在操作系统和程序看来,它有 CPU、内存、硬盘、网卡,有自己的内核、有自己的进程表——它和真电脑在结构上完全一样。说它「慢」,不是因为它残废,而是因为它的资源额度小:2 核意味着真的同时只能跑两段指令,1.6 G 内存意味着所有程序加起来不能超过这个数。判断的关键是:虚拟化改变的是「资源有多少」,不是「结构像不像」。常见的错误答案是「云服务器是虚拟的所以不存在实体」——它当然存在,只是那台实体机器在替很多个你服务。

  2. 内存和硬盘:请说出它们分别在「速度」和「容量价格」上的关系,并解释为什么不能取消硬盘、把所有东西都放在内存里。
    参考答案

    速度上内存快得多:内存访问约 50–100 纳秒,固态硬盘约 50–200 微秒,机械硬盘约 5–10 毫秒,相差三到五个数量级。容量价格上是反比:内存大约几十元一个 GB,固态硬盘大约几毛钱一个 GB,同样的钱硬盘能买到的容量大得多。所以不能取消硬盘:一是容量成本(要给 40 G 的日志、记忆、登录态都配内存,成本会涨几十倍),二是不持久(内存断电就全丢,而 QQ 登录态、所有人的记忆、日志都必须活过重启)。这道题的错误答法是「内存只是更快的硬盘」——它们连「会不会断电消失」这一点都不同,不是同一个东西的两个型号。

  3. 为什么 Swap 被描述为「救急不救穷」?请给出一条能区分「健康的偶尔使用」和「危险的长期使用」的判断方法。
    参考答案

    Swap 是硬盘上的一块空间,内存不够时内核把不活跃的内存页搬过去。它能避免一次突发流量把进程 OOM 杀掉,所以「救急」;但它的速度是内存的三到五个数量级之后,一旦程序真的开始依赖它,整个服务会变得极其缓慢,而且说明物理内存根本不够——所以「不救穷」。判断方法是看趋势而不是单点:偶尔(比如应对流量尖峰时)出现一点 Swap 使用然后回落,属于正常;如果 free -h 里 swap 那一行长期不是 0、或者 available 常年很低,就是病。进一步的做法是持续记录(比如每 5 分钟一次的健康检查把 free 的结果写进日志),几天后看曲线。「我打开 free 看了一次,swap 是 0,所以没问题」这种答法不完整——单点数值说明不了趋势。

  4. 辨析:并发和并行。如果你的服务器只有 1 个 CPU 核心,一个 Node.js 程序还能不能同时处理 10 个在等待模型回复的请求?为什么?
    参考答案

    能。并发不等于并行:并发是「一个核心在极短的时间片之间来回切换,让多件事看起来同时进行」,并行是「多个核心真的在同一瞬间执行不同指令」。这 10 个请求的绝大部分时间都在「等网络」,不需要 CPU;单核完全可以在它们之间来回调度,谁的数据到了就处理谁。这也是第五章事件循环的基础。常见的错误答法是「一个核心就只能一件事一件件做」——那描述的是「同步阻塞」,而不是并发。不过要补充一个边界:如果这 10 个请求里有大量 CPU 密集型计算(比如加密、图像处理),单核就真的会成为瓶颈,因为此时没有「等」可以利用。

  5. 为什么程序不能直接读写硬盘,而必须通过内核?请用人话解释「系统调用」,并说明 Node.js 里 fs.readFileSync 和它是什么关系。
    参考答案

    因为硬件如果任由程序直接操作,会出现互相覆盖数据、越界改写别的程序的内存、灌满网络、偷读别人数据这些无法收拾的后果;而且只有内核知道「这个文件属于谁、这个进程有没有权限」。所以约定:硬件只给内核碰,程序要读写文件、申请内存、收发网络,都必须向内核提出请求。这个「从用户态切进内核态请求服务」的动作就是系统调用——用户态是隔着玻璃的客户,内核态是柜员,系统调用就是那张单子。fs.readFileSync 是 Node 给你的一层包装,它底层就是一次(或几次)系统调用,把内核那个难用又危险的接口变成了人能读的函数。错误答法是「系统调用就是一种函数调用」:它确实长得像函数调用,但本质区别是会发生 CPU 特权级的切换,因此昂贵得多——这正是第五章要处理「不让它把进程卡住」的原因。

  6. 实操:/root/owl/bot/llm.local.json 现在权限是 -rw-r--r--。请写出你要敲的命令,并解释 -rw-r--r-- 和 600 各自意味着「谁可以做什么」。
    参考答案

    命令是 chmod 600 /root/owl/bot/llm.local.json,改完用 ls -l 复查一次。-rw-r--r-- 等于 644:所有者可读写,所属组的其他人可读,其他所有人也可读——也就是「谁都能看这个 API Key」。600 是 -rw-------:只有所有者能读写,组和其他人完全碰不到。原因是这个文件里是能花你钱的密钥,所以要用最小权限。要补充的是这条命令不是完整的答案:文件权限管不住 root,所以还需要 .gitignore 保证它不进仓库、不是明文传出去、以及去服务商控制台设消费上限。只答「chmod 600 就安全了」说明你只看到了四层防线里的一层。

  7. 排查题:你敲 node bot/bot.js 报错 Error: listen EADDRINUSE: address already in use 127.0.0.1:3001。请写出你的排查步骤,并说明这个错误的含义和5.2「进程不是孤立的:进程树与父子关系」里那个「幽灵进程」的关系。
    参考答案

    含义:你要求程序监听 3001,但内核告诉它「这个端口已经有人在听」。步骤:(1)先确认是谁在听:ss -tlnp,找到 3001 那一行和它的 PID,看清楚是 node 还是别的进程;(2)看这个进程是谁启动的:ps -ef --forest 或 ps aux | grep node,注意它的父进程是 systemd(服务的正常形态)还是你的终端(你手动起的幽灵);(3)如果是 systemd 管理的那个,那就说明服务本来就在跑,你根本不该再手动起一个——正确做法是用 systemctl restart qqbot;(4)如果确实是残留的幽灵进程(它的来龙去脉见 5.2 那一节),先用 kill PID(SIGTERM)请它退出,无效再用 kill -9。错误答法是「改端口换一个就好了」——那只是把冲突藏起来,你会得到两个机器人抢同一个 QQ 账号,或者一个「看起来起来了、其实没人连」的假象。

  8. 排查题:用户说「@了机器人但没反应」。请按第 9 节的方法论写出你的排查清单:先做什么、把系统分成哪几层、每层用什么命令验证,最后说明为什么不能一上来就 systemctl restart qqbot。
    参考答案

    顺序:(0)先确认现象——群里 @ 是她完全不回,还是回了但很慢?私聊是否正常?(1)画链路:QQ ↔ 腾讯服务器 / 腾讯服务器 ↔ NapCat / NapCat ↔ 机器人(3001)/ 机器人 ↔ DeepSeek。(2)从最外层往下验证:先在 NapCat 日志里看登录状态与有没有「身份已失效」「被踢下线」;再 ss -tlnp 看 3001 在不在听、ss -tn | grep 3001 看反向连接是否 ESTAB;再用 node tools/_probe-reverse.mjs 冒充协议端发 /ping,有响应就把问题锁在 NapCat 与 QQ 之间,无响应就去看 bot.log 里的 ❌ LLM 或 systemctl status qqbot。(3)最后看 tail -f data/botdata/bot.log,确认消息有没有进来。不能一上来就重启的原因:重启会清掉现场(内存状态、还在日志里的证据),而且如果是登录会话失效,重启根本无效——你会陷入「重启后好一会儿、又坏」的循环,却一直不知道原因。

  9. 排查题:df -h 显示根分区使用率 96%,du -sh /root/owl/data/* 显示 qq 目录占了 30 G。请说出至少两条判断和一个处置动作,并说明为什么「先删点东西」不是一个好答案。
    参考答案

    判断一:qq 是 QQ 登录态与会话数据,删掉就要重新扫码,而且反复删可能触发风控,所以它属于「可以清理但要付代价」的东西,不是垃圾。判断二:真正该先看的是「为什么它会涨到 30 G」——大概率是 NapCat 缓存的图片或临时文件堆积(对应的处置是清理缓存目录、给容器日志设限额),也可能是别人的聊天文件被缓存下来。处置动作的合理顺序:(1)du -sh 逐层往下钻,找到真正的大头;(2)先备份关键数据(登录态、记忆、配置);(3)清理确定是缓存的部分,而不是整个 qq 目录;(4)如果是长期趋势,考虑扩盘或加日志轮转。「先删点东西」不好的原因:磁盘满是一个症状,删掉症状之后你会失去唯一能告诉你原因的现场,而它会在几天后再满一次——这次你连线索都没有了。

  10. 动手题:请你自己写出(不要抄)一条命令,回答这个问题:今天 AI 调用的失败原因各出现了多少次,从多到少排。写出你的命令,并逐段解释每个管道的作用。假设日志格式就是第 7 节里那个格式,文件是 data/botdata/bot.log。
    参考答案

    一个可接受的答案:grep "^\[$(date +%F)" data/botdata/bot.log | grep "❌ LLM" | awk '{$1=""; $2=""; print}' | sort | uniq -c | sort -rn。逐段解释:grep "^\[$(date +%F)" 用当天日期(格式 2026-10-04)筛出今天的行(^\[ 表示行首的方括号,方括号在正则里要转义);第二个 grep "❌ LLM" 只留模型相关的报错;awk '{$1=""; $2=""; print}' 清掉前两列(日期和时间),只保留原因部分,这样同类原因才能相减比较;sort 把相同原因排到一起;uniq -c 合并相邻重复并计数;sort -rn 按次数从多到少排。如果你的答案最后没有 uniq -c,或者uniq 前面没有 sort,都会得到一份错的统计——这正是这道题真正想检查的东西。

  11. 动手题:在一个有 root 权限的测试机器(不要用生产服务器!)上,用三条命令完成这件事:查看一个文件现在的权限、给它加上「所属组可读」、再复查一次。写出命令,并说清哪一步用错了会产生什么后果。
    参考答案

    示例:ls -l data/botdata/bot.log → chmod 640 data/botdata/bot.log → ls -l data/botdata/bot.log。640 是 rw- r-- ---,即所有者可读写、同组可读、其他人都不能。第一步「先看」的意义在于:你必须知道它原来是什么,才能判断自己改的方向是否正确。第三步的价值在于:chmod 不会给你任何成功或失败的提示(除了文件不存在),所以你只能复查。用错的后果举例:如果是 chmod 666,你就让这台机器上任何人都能改这个日志文件——而日志是排障的证据,一个能被别人随意修改的证据没有意义;如果是给密钥文件 chmod 644,那是把能花钱的 Key 公开出去。还要注意:绝对不要在生产服务器上练习 chmod,尤其不要对目录用 -R,因为递归改权限会把 .ssh 目录也改掉,直接导致你自己登不上去。

  12. 开放题:这一章讲的所有东西里,你认为哪一件是「没有它就其它全会垮掉」的基础?请给出你的判断和理由,并指出一个「看起来很基础、其实可以晚点学」的东西作为对照。
    参考答案

    没有唯一答案,判断依据是「它被多少其它知识依赖」。合理的论证方式举例:选「内核与系统调用」——因为权限、进程、端口、文件、Docker 隔离、甚至大模型的资源和成本限制,全部建立在这一层上;理解了它,你后面每一章都会多一层「底下发生了什么」的视角。也可以选「分层与可复现」这种更抽象的东西,只要你能说明它如何支撑后面的内容。对照物(可以晚点学的)例如:awk 的高级用法、vim 的快捷键、systemd 的完整单元文件语法、网络抓包——它们是「遇到具体问题再查」的知识,不是地基。这道题真正检查的是:你有没有形成一个「依赖关系」的判断标准,而不是在背「什么重要」。

12. 自问自答:把知识变成你自己的

这些问题没有标准答案。请不要在页面上浏览,拿一张纸写下来。

  • 如果那台服务器明天被回收了,我会失去什么?把这些东西按「可以重建 / 无法重建」分两栏——这个答案会告诉你,你的项目里真正的资产是什么。
  • 我今天敲的每一条命令,如果三个月后要在另一台机器上重做一遍,我能想起来几条?想不起来的那些,我应该用什么方式记住它?
  • 「Linux 不问你就执行」这件事,在我的性格里是安心还是不安?如果是不安,我该怎么用「先看再改」这类习惯去补偿,而不是靠更紧张?
  • 这一章里我遇到的哪一个概念,是「我以为我懂了,其实只是记住了词」?我是怎么发现这一点的?
  • 我的服务器上现在有哪些东西是「只靠我小心」撑着的?(比如从没备份过的记忆文件、从没检查过的权限、从没记录过的配置改动)我能不能今天就拆掉一颗雷?
  • 第九节那个故障里,「自动重启」被刻意去掉了。如果换成是我,我会不会忍不住加一条自动重启?我是在解决用户的问题,还是在解决自己的焦虑?
  • 我有没有一个「别人交给我的服务器」的处境(比如学校社团、朋友的项目)?如果我明天就要接手一台完全陌生的机器,我该做的第一件事是什么?
  • 图形界面和命令行之间,我以前的偏好是什么?读完这一章之后,我更愿意把「值得重复做的事」放在哪一边?为什么?
  • 「可复现」这个词,除了技术之外,对我的学习还意味着什么?我现在每天的学习过程,有没有任何一部分是被记录下来、可以重放的?
  • 下一章要开始写代码了。我希望自己在写第一行 JavaScript 时,脑子里已经带着这一章的哪一句话?

13. 小结

这一章说了三件事。

一、你的代码落在哪里。它落在一台真实的、远在上海机房里的电脑上。那台机器由 CPU、内存、硬盘、网络、主板构成,跑着一个叫 Linux 的内核,内核上面是 Ubuntu 这套发行版,而你的程序是其中一个进程,占着一点内存、听着一个端口、在等着别人说话。

二、操作系统为什么要存在。因为硬件不能给普通程序碰。内核统一管理进程、内存、文件、网络和权限,程序只能通过系统调用向它提出请求。权限、隔离、安全这些东西不是「额外的讲究」,而是操作系统存在的理由本身。

三、怎么和它说话。用 Shell,用命令,用管道把命令串起来,用 SSH 隔着网络操作它,用分层的方法在它坏掉的时候找到坏在哪一层。以及一条纪律:Linux 不给你后悔的机会,所以「先看再改、先备份再删、一次只改一个变量」。

序章里我说,这门课要给你的第一样东西是「一台机器的完整心智模型」。这一章是它的地基。你以后每一次敲命令、每一次看日志、每一次对着一个不明所以的报错发呆,脚下踩着的都是今天这些东西。

而在所有具体知识之上,这一章真正想让你带走的是一个姿态:Linux 没有礼貌,但它可以被复现。它不问你、不劝你、不给你后悔的机会,你说什么它就做什么——这听起来冷漠,却意味着结果只取决于你说的话。而你说过的话是文本:文本可以写进脚本、存进仓库、被别人审阅、在你睡着的时候由机器自己重放。你今天敲的每一条命令,和第七章要写的部署脚本,是同一件事的两端。

14. 延伸:可以去哪里继续

网站

  • 《计算机教育中缺失的一课》(MIT The Missing Semester 中文版)——讲学校里不教、但每天都要用的东西:命令行、编辑器、调试、版本控制。它的第一讲几乎和本章重合,可以作为第二遍复习。建议在本章读完后的三天内看完第一讲。
  • explainshell——把你复制来的一整条命令粘进去,它会把每个参数拆开解释。用法是「看不懂别人给的命令时先去这里」,非常适合你以后抄运维命令的场景。
  • LabEx 的 Linux 入门课程——在浏览器里直接给你一台 Linux 环境和分步练习,不需要自己准备服务器。当你想「先随便练手、不怕搞坏」时用它;注意它是交互式课程站,速度可能慢,页面也可能随版本变化。
  • Filesystem Hierarchy Standard(FHS)——为什么 /etc 放配置、/var 放会变的数据、/home 放用户目录的官方定义。它在英文环境里读起来有点枯燥,泛读一遍目录章节就够,能让你以后看到任何路径都不慌。
  • Ubuntu 发布周期与支持年限——查清楚你正在用的版本什么时候停止安全更新。这件事值得每年确认一次,因为它直接决定你的服务器还能不能安全地联网。
  • NapCatQQ(GitHub)——OWL 的协议端项目。本章你只用到「它监听 6099、它用 host 网络模式」这两件事;等看完第七章,再回来读它的 README,你会有完全不同的感受。

值得读的书(三本,够你用很久)

  • 《计算机是怎样跑起来的》(矢泽久雄)——薄薄一本,一整天可以读完。它回答的正是第 2 节那条链:一台计算机从通电到运行程序,究竟发生了什么。如果你只读一本,读它。
  • 《鸟哥的 Linux 私房菜·基础学习篇》(鸟哥)——中文世界里最系统的 Linux 入门书,讲得细、例子多,适合当参考书而不是通读本。权限、进程、文件系统这几章,正是本章的展开版。卡住的时候翻对应章节。
  • 《深入理解计算机系统》(Randal E. Bryant、David R. O'Hallaron,通常简称 CSAPP)——不要现在通读,它需要 C 语言基础。但请记住它的名字:等你学完第五章、对「进程、内存、I/O」有了实感之后,这本书会把你今天所有的直觉变成可以推导的知识。提前知道它存在,也是一种准备。

提醒不要收藏了就算看过。这一章只要求你做一件事:登录你的服务器,把 8.6 那十个一登录就该跑的体检命令亲手敲一遍,并把输出抄进笔记本。抄写的过程会让你发现「我以为我懂了」的地方——比如你会发现自己不确定 free -h 里哪一列才是「真正还能用的内存」。那一刻的困惑,比读完这一章更有价值。