本文首发于 note.sichenai.cc,原文链接:note.sichenai.cc/articles/li... ,作者:斯晨
先说结论。把一个 Node 应用搬到国内小内存服务器上,有三条保命经验:
- **服务器只收成品。**npm install、npm ci 这类依赖解析的活,在开发机干完,让服务器直接收整包。
- **node_modules 可以整机搬,但原生包要按清单补。**纯 JavaScript 的包随便搬,带平台二进制的包要分「构建期用」和「运行期用」两批另补。
- **SSH 会话里启动的后台任务,默认活不过你的断开连接。**要它长命,用 systemd-run。
这三条是我用一个晚上换来的。下面按实际发生的顺序讲,每件事都拆成现象、真因、处置三段,报错原文一律保留,不为「完美教程」修剪。
实测环境:腾讯云轻量应用服务器 2核4G(2026-09 购入,188 元/年档),Ubuntu 26.04,npm 9.2,Docker 29.8.1,大陆家用网络。实测日期 2026-09-20(服务器初始化)与 2026-09-24(应用上机)。
开工前先撞两堵墙
机器到手,第一件事是装 Docker 跑两个自用的中间层容器,结果两堵墙挨个撞上。
**墙一:Docker Hub 拉不动。**国内服务器直连 Docker Hub,镜像拉取长时间无响应最后超时。解法是个梯度,从省事到费事依次试:装 Docker 本体走云厂商内网源;拉镜像配厂商的镜像加速地址;个别加速也拿不到的镜像,在本机下载再传上去。
**墙二:镜像名是错的。**我照着教程写的名字拉镜像,报 not found。到 Docker Hub 一搜才发现教程把名字写错了(rsshub/rsshub 这个仓根本不存在,真名是 diygod/rsshub)。教程会过时,转述会走样,用镜像之前先去 registry 查一眼全名,十秒钟的事,能省半小时。
顺手立了安全基线的规矩:SSH 只留密钥登录、禁 root,公网防火墙只开 22;后来起的每个服务都只绑 127.0.0.1,管理界面走 SSH 隧道看。这一段没有坑,但值得抄。
第一幕:npm ci 跑了四十分钟,我投降了
代码上服务器,按肌肉记忆敲了 npm ci,起身倒水。回来一看,CPU 顶着 100% 已经四十分钟,没跑完。
翻 debug 日志:25 万行,最后停在这一句:
text
silly placeDep ROOT next@16.3.6 REPLACE for: @opennextjs/aws
翻译一下。npm 在解析依赖时需要在 next 15 和 next 16 之间反复横跳:我的 package.json 声明 next ^15,而另一个依赖的 peerDependencies 又放行了 next 16。npm 处理这种冲突的方式,是把版本组合挨个试过去,组合数是指数增长的,术语叫版本回溯。开发机上这个爆炸可能几秒就过去了,2 核弱机上每步慢十倍,直接原地去世。
坦白说,我没回头试 --legacy-peer-deps 这类缓解参数,当时的心思是让服务赶紧跑起来,没心思给 npm 会诊。我直接换了条路线,事后看也是更对的路线:让服务器只收成品,不做任何解析。
具体三步:
- 代码用 git ls-files 列出受管文件清单,rsync 按清单同步,不碰 node_modules 也不带本地杂物;
- node_modules 在开发机打成 tar 包,ssh 一条命令灌过去(791M);
- 从此服务器上不出现任何 npm install。
第二幕:依赖搬过去了,缺了几块骨头
整包搬运的隐患马上显形。node_modules 里的包其实分两种。一种是纯 JavaScript 写的,放到哪台机器都能干活。另一种捆着编译好的二进制文件,这些二进制跟操作系统和 CPU 架构绑定:你在 Mac 上装的包,里面的二进制说 macOS 话,搬到 Linux 服务器上就成了哑巴。
这次连炸两回,位置还不重样。
**第一回炸在类型检查。**next build 走到一半报错:
text
error: Cannot find module '@tarojs/cli'
这个有意思。我的仓里除了 Next 主应用,还有个小程序子工程(Taro 框架),根目录 tsconfig 的 include 写的是 **/*.ts,把子工程也扫了进来。类型检查要去找子工程的依赖,而 Taro 的包装在 Mac 上,服务器上没有。为什么开发时从没炸过?因为那台 Mac 恰好装过。这种「单机从不暴露」的配置耦合,换台机器准炸。修法是把子工程加进根 tsconfig 的 exclude。
**第二回炸在运行期。**build 过了,定时任务首跑又挂:
text
generateBinPath (.../esbuild/lib/main.js)
这回是 esbuild。跑脚本的工具链要靠它,安装时 npm 会按当前平台下对应的二进制,Mac 上装的是 darwin 版,服务器要的是 linux 版。
两回合修法统一:原生包按绑定时机分两批补。
- 构建期绑定(build 时才用到):@tailwindcss/oxide-linux-x64-gnu、lightningcss-linux-x64-gnu
- 运行期绑定(服务跑起来每次都要用):@esbuild/linux-x64、better-sqlite3
补法:从 registry 的元数据里查到准确版本的 tarball 直链(用 npmmirror 镜像记得加 -L 跟随 302 跳转),下载解压到 node_modules 里对应位置,版本跟你已有的一模一样。
有个运气成分要交代:better-sqlite3 我原样直传过去居然能用,
bash
node -e "require('better-sqlite3')"
实测通过。不同版本的打包策略不一样,别默认它能活,搬完务必跑一句这个验证。
第三幕:后台任务两次无声死亡
依赖齐了,构建也过了,起服务、挂定时任务,然后开始见鬼:SSH 里跑的长任务,过一阵去看,进程没了,日志停在半截,没有报错。
第一反应是内存爆了被系统干掉。dmesg 翻了,没有 OOM 记录;journalctl 翻了,没有任何击杀日志。它就是消失了,连句遗言都没有。
把进程死亡时间和我的操作时间对了一遍,规律出来了:**进程死的时间,跟我断开 SSH 的时间吻合。**我的推断是这样:会话断开时,系统把会话里启动的进程连带清理了。这段机制我没有日志层面的直接证据,算推断;但下面这个解法是实打实验证过的。setsid、nohup 都试过,躲不过。
正解是 systemd-run:
bash
sudo systemd-run --uid=ubuntu --unit=migrate-job bash -c 'long-task.sh && touch /tmp/migrate-job.done'
它把任务放进系统作用域,跟你的 SSH 会话从此没关系。任务完没完成,轮询那个 done 标记文件就行。
**花絮一枚,值回票价。**有天晚上想杀掉卡住的 npm ci,我敲了 pkill -f "npm ci",结果自己的 SSH 会话当场断成 exit 255。pkill 的 -f 是对整条命令行做匹配,而我这条 pkill 命令自己身上就写着「npm ci」几个字,成功自杀。一晚上干了两次。正确姿势:先 ps 查 PID,再按 PID 杀。
最后装好的样子
应用本体由 systemd 常驻,定时任务三件套(数据抓取、备份、巡检)挂进 crontab,部署当天首轮抓取全部成功。合上 MacBook,服务照常跑,这台 2 核 4G 的小机器从此就是它的家。
复盘一张表:
| 坑 | 症状 | 真因 | 处置 |
|---|---|---|---|
| 服务器跑 npm ci | CPU 满载 40 分钟不收敛,debug log 25 万行 | peer 依赖版本回溯指数爆炸,弱机放大 | 依赖解析回开发机,node_modules 打包直传 |
| 类型检查炸 | Cannot find module '@tarojs/cli' | 根 tsconfig include 扫进子工程,其依赖只在 Mac 上有 | exclude 加子工程 |
| 运行期炸 | generateBinPath 报错(esbuild) | 平台二进制,Mac 装的是 darwin 版 | 按构建期/运行期清单补 linux 包 |
| 后台任务蒸发 | 进程消失、日志半截、无 OOM 记录 | 推断为 SSH 会话断开连带清理(推断,已标注) | systemd-run 脱离会话 |
| pkill 自杀 | SSH 会话 exit 255 | -f 匹配到自身命令行 | 先查 PID 再杀 |
| Docker Hub 拉不动 | 拉取超时 | 国内直连不通 | 内网源、镜像加速、本机中转,三级梯度 |
| 镜像名讹传 | pull 报 not found | 教程转述的名字是错的 | 用前去 registry 查全名 |
我跑通的路径,和你能照抄的路径
三件事说清楚,免得你照着踩坑:
- **我的环境有特殊性。**镜像加速地址是腾讯云机器专用的,别家云有各自的地址;「构建期/运行期」的原生包清单要按你自己的项目列,我的清单(oxide、lightningcss、esbuild、better-sqlite3)只是长这样的一个样例。
- **两处没走到底,如实交代。**npm 回溯我没试 --legacy-peer-deps 之类的缓解参数,是直接换的路,没验证过「撑下去」是否可行;better-sqlite3 直传能用有运气成分,你的版本未必。
- **一处没记录。**791M 打包直传的耗时我没记,家用宽带上行各不相同,你那边多快我给不了参考数。
数据来源与视角
- 本篇为本人部署实录(2026-09-20 服务器初始化、2026-09-24 应用上机),报错原文未润色
- 「SSH 会话清理」机制一节为推断,已就地标注
- 文中价格与版本号为 2026-09 时点
- 2核4G 足够跑这类单体小应用,这个结论本身也来自本次实测