Linux 服务器上用 NVM 同时管理 SSH 命令和 systemd 服务

在 Linux 服务器上部署 Node.js,常见路径是:先手动解压一个官方 tar 包,后来版本多了改用 NVM 管理,最后还要让 systemd 服务稳定地跑在某个版本上。本文按这四个阶段整理完整流程:

  1. 手动安装 Node.js
  2. 使用 NVM 管理 Node.js 版本
  3. Node.js 版本升级后的迁移工作
  4. Node.js 的 systemd 文件 PATH 设置规范

1. 手动安装 Node.js

最直接的安装方式是从 nodejs.org 下载官方编译好的 tar 包,解压到 /usr/local

cd /usr/local
curl -fLO https://nodejs.org/dist/v24.14.0/node-v24.14.0-linux-x64.tar.xz
tar -xJf node-v24.14.0-linux-x64.tar.xz

然后把 bin 目录加入 PATH,例如在 /etc/profile/etc/zshenv 中:

export PATH="/usr/local/node-v24.14.0-linux-x64/bin:$PATH"

确认安装:

command -v node
command -v npm
node --version
npm --version

这种方式简单透明,适合只需要单一版本的服务器。但升级时会出现一个典型问题:新的 nodenpm 已经生效,原来通过 npm install -g 安装的命令却“消失”了。

这不是软件被删除,而是 npm 的全局安装目录跟随 Node.js 的安装前缀。Linux 下全局包通常位于:

${prefix}/lib/node_modules

对应的命令入口位于:

${prefix}/bin

node-v24.14.0-linux-x64 切换到 node-v24.15.0-linux-x64 后,全局包目录也跟着切换。旧软件还在旧目录,只是新 PATH 找不到它们。

也不建议把所有旧版本的 bin 都追加到 PATH。每个目录里都有自己的 nodenpm 和全局命令,目录顺序稍有变化就可能混用不同版本,问题会变得更加隐蔽。

可以用下面的命令确认当前状态:

npm config get prefix
npm root --global
npm list --global --depth=0
type -a node npm
echo "$PATH"

当服务器需要维护多个版本、或者需要频繁升级时,就该交给 NVM 管理了。

2. 使用 NVM 管理 Node.js 版本

安装 NVM(从 GitHub API 读取最新版本)

NVM 官方安装脚本需要指定版本号。为了避免写死版本,可以先从 GitHub API 查询 nvm-sh/nvm 的最新 release:

nvm_version="$(curl -fsSL https://api.github.com/repos/nvm-sh/nvm/releases/latest \
  | grep -m1 '"tag_name"' \
  | cut -d '"' -f 4)"

echo "$nvm_version"

curl -o- "https://raw.githubusercontent.com/nvm-sh/nvm/${nvm_version}/install.sh" | bash

重新登录 SSH,或者手工加载:

export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"

确认安装:

command -v nvm
nvm --version

nvm 是 Shell 函数,不是普通二进制文件,所以应使用 command -v nvm 检查,不要依赖 which nvm

配置 default-packages

NVM 支持 $NVM_DIR/default-packages,文件中每行写一个包,之后通过 NVM 安装新的 Node.js 版本时会自动安装这些包。在安装第一个版本前就配置好,可以省去后续手动安装:

cat > "$NVM_DIR/default-packages" <<'EOF'
openclaw
@openai/codex
pnpm
prettier
playwright
agent-browser
@zed-industries/codex-acp
EOF

业务项目依赖不应放在这里,而应继续由项目自己的 package.json 管理。

安装并设置默认版本

不必写死完整版本号,可以用 nvm version-remote 查询某个大版本的最新 release:

new="$(nvm version-remote 24)"

nvm install "$new"
nvm alias default "$new"
nvm use default

node --version
npm --version
nvm current

关于 nvm alias default:首次安装时 NVM 会自动把第一个安装的版本设为 default,此时这行看似多余。但之后安装新版本并不会自动更新 default 别名,所以显式设置一次更稳妥,后续升级时也必须执行它才能让新登录的 Shell 默认使用新版本。

NVM 的职责划分建议:

场景 管理方式
SSH 登录后使用 nodenpm NVM 的默认版本
systemd 长期运行的 Node.js 服务 NVM 版本目录中的绝对路径
项目依赖 项目内 package.json、lock 文件和 node_modules
常用全局 CLI NVM 的 default-packages 和包迁移命令

核心原则是:交互式 Shell 可以灵活切换,后台服务必须固定版本。

3. Node.js 版本升级后的迁移工作

第一次从手动安装的nodejs迁移到 NVM

第一次使用 NVM 时,原来的 Node.js 位于 /usr/local/node-*,不属于 NVM 管理范围,不能直接让 NVM 迁移。需要先记录旧版本中的全局包。

为了避免旧 npm 的 shebang 又解析到当前 PATH 中的新 node,可以显式指定旧 Node.js、旧 npm CLI 和旧 prefix:

old_prefix=/usr/local/node-v24.14.0-linux-x64

"$old_prefix/bin/node" \
  "$old_prefix/lib/node_modules/npm/bin/npm-cli.js" \
  --prefix="$old_prefix" \
  list --global --depth=0

然后在 NVM 管理的新版本中重新安装需要的工具:

nvm use 24.15.0

npm install --global \
  openclaw \
  @openai/codex \
  pnpm \
  prettier \
  playwright \
  agent-browser \
  @zed-industries/codex-acp

不要直接复制旧 node_modules。有些包包含原生模块或按平台下载的二进制文件,重新安装更可靠。

后续版本升级:用 NVM 重装全局包

后续版本都由 NVM 管理后,可以在安装新版本时直接重装旧版本中的全局包:

old="$(node -v)"
new="$(nvm version-remote 24)"

nvm install "$new" --reinstall-packages-from="$old"
nvm use "$new"
nvm alias default "$new"

node --version
npm list --global --depth=0
openclaw --version

node -v 输出带 v 前缀(如 v24.15.0),--reinstall-packages-fromnvm uninstall 都能识别这种格式,无需手工去掉前缀。

安装新版本不会自动更新 default 别名,必须显式执行 nvm alias default,否则新登录的 Shell 仍会使用旧版本。

NVM 官方支持 --reinstall-packages-fromnvm reinstall-packages,但这里的“迁移”实际是按清单重新安装,不是直接复制旧目录。

升级后不要立刻卸载旧版本

确认新版本工作正常之前,保留旧版本以便回滚。观察一段时间后再清理:

nvm uninstall "$old"

不要给 NVM 配置自定义 npm prefix

单独使用手动安装的 Node.js 时,可以把 npm prefix 配置成稳定目录,例如 /usr/local/npm-global。但采用 NVM 后不要这样做:

npm config set prefix /usr/local/npm-global

NVM 官方明确说明,它与 npm 的 prefix 配置不兼容。使用 NVM 时应让全局包保留在:

$NVM_DIR/versions/node/<version>/lib/node_modules

可以检查并清理以前遗留的 prefix 配置:

npm config get userconfig
grep -n '^[[:space:]]*prefix[[:space:]]*=' ~/.npmrc

确认无误后再编辑 ~/.npmrc,不要直接删除其中的 registry、proxy 等其他配置。

4. Node.js 的 systemd 文件 PATH 设置规范

不要依赖登录 Shell

systemd 启动服务时不会自动读取 .bashrc,因此下面这种写法不够稳妥:

ExecStart=/bin/bash -lc 'nvm use default && openclaw gateway'

它把服务启动绑定到 Shell 初始化脚本和 NVM alias。某次修改 .bashrc 或切换 default 就可能在下次重启服务时引入未经验证的新版本。

规范一:ExecStart 使用绝对路径

固定 Node.js 和应用入口的绝对路径。以 root 的 systemd user service 为例:

[Unit]
Description=OpenClaw Gateway
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
WorkingDirectory=/root
ExecStart=/root/.nvm/versions/node/v24.15.0/bin/node /root/.nvm/versions/node/v24.15.0/lib/node_modules/openclaw/dist/index.js gateway --port 18789
Environment="PATH=/root/.nvm/versions/node/v24.15.0/bin:/usr/local/bin:/usr/bin:/bin"
EnvironmentFile=-/root/.config/systemd/user/openclaw-gateway.env
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=default.target

规范二:Environment 中的 PATH 与 ExecStart 保持同一版本

这里的 Environment=PATH 不是为了让 systemd 找到 ExecStart,因为 ExecStart 已经是绝对路径;它用于确保服务创建的子进程也使用同一个 Node.js 版本。两者必须指向同一个版本目录,否则主进程和子进程可能混用不同版本。

保存为:

/root/.config/systemd/user/openclaw-gateway.service

然后加载并启动:

systemctl --user daemon-reload
systemctl --user enable --now openclaw-gateway.service
systemctl --user status openclaw-gateway.service
journalctl --user -u openclaw-gateway.service -f

如果用户退出登录后服务也需要继续运行,应当启用 linger:

loginctl enable-linger root
loginctl show-user root -p Linger

如果服务运行在普通用户下,就把示例中的 /root 替换为该用户的 home,并使用对应用户执行 systemctl --user

升级时同步修改 systemd unit

Node.js 升级并验证通过后,修改 unit 中的两个位置:

/root/.nvm/versions/node/v24.15.0/bin/node
/root/.nvm/versions/node/v24.15.0/lib/node_modules/openclaw/...

替换为新版本路径,同时更新 Environment=PATH,再重启:

systemctl --user daemon-reload
systemctl --user restart openclaw-gateway.service
systemctl --user status openclaw-gateway.service
journalctl --user -u openclaw-gateway.service -n 100 --no-pager

回滚同样直接:把 ExecStartEnvironment=PATH 改回旧版本,daemon-reload 后重启服务即可;SSH 侧如需回退则执行 nvm alias default "$old"。这也是不让 systemd 直接使用 nvm alias default 的原因:服务版本切换应当是一次显式、可审查、可回退的部署操作。

规范三:项目型服务使用本地依赖

如果 systemd 运行的是自己的 Node.js 项目,而不是 openclaw 这类全局 CLI,最好不要依赖全局 npm 包。部署时在项目目录中安装:

cd /srv/my-app
npm ci --omit=dev

service 只固定 Node.js 和项目入口:

[Service]
WorkingDirectory=/srv/my-app
ExecStart=/root/.nvm/versions/node/v24.15.0/bin/node /srv/my-app/server.js
Environment="NODE_ENV=production"
Environment="PATH=/root/.nvm/versions/node/v24.15.0/bin:/usr/local/bin:/usr/bin:/bin"
Restart=always

这样 Node.js 版本、应用版本和依赖版本各自有清晰边界。

排查命令

SSH 当前使用的版本:

command -v node
command -v npm
nvm current
npm config get prefix
npm root --global
npm list --global --depth=0

systemd 实际启动了什么:

systemctl --user show openclaw-gateway.service \
  -p ExecStart \
  -p Environment

systemctl --user status openclaw-gateway.service
journalctl --user -u openclaw-gateway.service -n 100 --no-pager

进程正在使用哪个 Node.js:

pid="$(systemctl --user show openclaw-gateway.service -p MainPID --value)"
readlink -f "/proc/$pid/exe"
tr '\0' '\n' <"/proc/$pid/environ" | grep -E '^(HOME|PATH|NVM_DIR)='

总结

  1. 手动安装适合单一版本场景,升级时注意全局包跟随安装前缀。
  2. NVM 负责下载、保留和切换多个 Node.js 版本,安装时可从 GitHub API 读取最新 release。
  3. 升级后通过 --reinstall-packages-fromdefault-packages 重装全局 CLI,不复制旧 node_modules,不配置自定义 npm prefix。
  4. systemd 使用 NVM 版本目录中的绝对路径,Environment=PATHExecStart 保持同一版本;版本切换是显式、可回滚的部署操作。
  5. 新版本验证完成前保留旧版本,以便快速回滚。

这样既保留了 NVM 的便利,也保留了长期服务最重要的可预测性。

参考