JetBrains Gateway 远程 IDEA 找不到 nvm Node:一次环境变量继承问题的排查记录

tsvico Lv5

JetBrains Gateway 远程 IDEA 找不到 nvm Node:一次环境变量继承问题的排查记录

这次问题的现象并不复杂:Debian 服务器已经通过 nvm 安装 Node.js,SSH 登录后执行 node -v 一切正常,但通过 JetBrains Gateway 打开的远程 IDEA 中,Gradle 或 Java 程序执行 node 时却提示找不到命令。

比较容易误导人的地方是,Java 本身可以正常启动,IDEA Terminal 里的 Node 也是正常的,给人感觉 “环境变量明明没问题”

问题现象

远程开发环境是 Debian13,通过 JetBrains Gateway 连接服务器上的 IntelliJ IDEA。

Node.js 使用 nvm 安装,当前版本为:

1
v24.19.0

实际路径:

1
/home/username/.config/nvm/versions/node/v24.19.0/bin/node

SSH 登录服务器后执行:

1
2
node -v
which node

结果正常:

1
2
v24.19.0
/home/username/.config/nvm/versions/node/v24.19.0/bin/node

手工启动 Bash login shell:

1
/bin/bash -lc 'node -v && which node && echo $PATH'

同样可以找到 Node:

1
2
3
v24.19.0
/home/username/.config/nvm/versions/node/v24.19.0/bin/node
/home/username/.cargo/bin:/home/username/.config/nvm/versions/node/v24.19.0/bin:...

应用环境中还能看到:

1
SHELL=/usr/bin/zsh

这里要区分一个容易混淆的概念:SHELL=/usr/bin/zsh 表示用户配置的登录 Shell 是 zsh,它是一个可以向子进程继承的环境变量,并不能证明当前目标进程就是由 zsh 直接启动的。Linux environ(7)SHELL 的定义也是 “用户登录 Shell 的绝对路径”。(man7.org)

为了确认 zsh 本身有没有问题,再执行:

1
/usr/bin/zsh -lc 'node -v && which node && echo $PATH'

结果:

1
2
3
v24.19.0
/home/username/.config/nvm/versions/node/v24.19.0/bin/node
/home/username/.mimocode/bin:/home/username/.pyenv/shims:/home/username/.pyenv/bin:...:/home/username/.config/nvm/versions/node/v24.19.0/bin:...

到这里能确认的是:

nvm 在 SSH Shell 和 zsh login shell 中工作正常。

但这还不能说明 Gateway Remote Backend 拿到的是同一套环境。

Java 应用启动后,通过:

1
2
3
System.getenv().toSortedMap().forEach { (key, value) ->
println("$key = $value")
}

打印出的运行时环境却是:

1
2
PATH=/home/username/.cargo/bin:/usr/local/bin:/usr/bin:/bin:/usr/games
SHELL=/usr/bin/zsh

其中完全没有:

1
/home/username/.config/nvm/versions/node/v24.19.0/bin

也没有:

1
NVM_DIR

这已经说明 SSH Shell 和 Java 应用得到的环境不是同一套。


Java 为什么还能正常启动

这里曾经有一个很容易带偏排查方向的现象:

1
2
Java 能正常启动
Node 却找不到

实际上两件事没有矛盾。

IntelliJ IDEA 的 Java Run/Debug Configuration 本身就有明确的 JRE/JDK 配置。IDEA 知道应该使用哪个 Java Runtime,并不需要依赖 Java 应用自己的 PATH 再去猜 java 在哪里。JetBrains 官方文档也明确说明,Application Run Configuration 的 JRE 字段决定 IDEA 使用哪个运行环境。(JetBrains)

本次 JVM 实际使用的是:

1
java.home=/home/username/.local/share/mise/installs/java/21.0.2

所以可以把实际情况理解为:

flowchart TD
    A[IDEA Remote Backend] --> B[IDEA 已配置好的 JDK]
    B --> C["/home/username/.local/share/mise/installs/java/21.0.2"]
    C --> D[Java 应用正常启动]

    A --> E["Java 应用继承 PATH"]
    E --> F["/home/username/.cargo/bin:/usr/local/bin:/usr/bin:/bin:/usr/games"]
    F --> G{PATH 中有没有 Node}
    G -->|没有| H["执行 node 失败"]

IDEA 能把 JVM 启动起来,只能证明 IDEA 找得到配置好的 JDK。

它不能证明:

1
PATH 中能找到 java

更不能证明:

1
PATH 中能找到 node

IntelliJ IDEA 对 Gradle 也有独立的 Gradle JVM 选择机制,例如可以从 org.gradle.java.homeJAVA_HOME 或合适的 JDK 中确定 Gradle JVM。(JetBrains)


排除 Gradle Daemon

排查初期也怀疑过 Gradle Daemon。

Gradle Daemon 是一个长期运行的后台 JVM,因此碰到 IDEA、Gradle 运行环境异常时,很自然会执行:

1
./gradlew --stop

但不能简单把问题解释成:

Gradle Daemon 启动得早,所以它永远保留了启动时的旧 PATH。

Gradle 官方文档明确说明,Gradle Client 在发起构建时会向 Daemon 发送构建所需的信息,其中包括环境变量。(Gradle 文档)

所以 ./gradlew --stop 可以作为排障手段,用来排除 Daemon 状态等干扰,但 “Daemon 固化了 nvm use 之前的 PATH” 不是一个准确的根因描述。

这次更关键的证据来自 Java 应用自己:

1
System.getenv("PATH")

结果已经是:

1
/home/username/.cargo/bin:/usr/local/bin:/usr/bin:/bin:/usr/games

问题显然不适合继续只盯着 Gradle,需要向上游查真正启动应用的 IDEA Remote Backend。


检查 Gateway Remote Backend

既然 gradle 没问题,那就可能是远程 IDEA 启动时没加载环境变量配置,先看真正运行的进程。

执行:

1
ps -ef | grep -i '[i]dea'

现场得到:

1
2
3
4
5
username  23272  1      /bin/sh /home/username/.cache/JetBrains/RemoteDev/dist/.../bin/remote-dev-server.sh run /home/username/work/auxiliary-classification/dingmi

username 23285 23272 /home/username/.cache/JetBrains/RemoteDev/dist/.../bin/remote-dev-server run /home/username/work/auxiliary-classification/dingmi

username 23758 23285 /home/username/.cache/JetBrains/RemoteDev/dist/.../bin/fsnotifier

从这个进程快照可以确认,当前 Remote Backend 最终运行形态中存在这样一层关系:

1
2
3
4
5
6
7
/bin/sh

remote-dev-server.sh

remote-dev-server

IDEA Backend

JetBrains 官方文档也确认,remote-dev-server.sh 是运行 Remote IDE 的主脚本;由 Gateway 安装的远程 IDE 默认位于:

1
~/.cache/JetBrains/RemoteDev/dist/

(JetBrains)

不过这里不能进一步写成:

1
Gateway 一定没有经过 zsh

或者:

1
Gateway 一定是 /bin/sh 直接启动的

ps 展示的是当前存活进程,无法完整还原 Gateway 在更早阶段如何建立 SSH 会话、准备环境、调用启动器。

所以前面的:

1
zsh -lc 'node -v'

只是一项 Shell 环境测试。

它证明的是:

1
zsh login shell 能找到 Node

并不等价于:

1
Gateway Remote Backend 能找到 Node

/proc/<pid>/environ 给出了直接证据

Linux 可以通过:

1
/proc/<pid>/environ

检查目标进程启动时通过 execve() 获得的初始环境变量

文件里的变量使用 \0 分隔,因此可以这样查看:

1
2
tr '\0' '\n' < /proc/23272/environ \
| grep -E '^(PATH|SHELL|NVM_DIR)='

结果:

1
2
PATH=/home/username/.cargo/bin:/usr/local/bin:/usr/bin:/bin:/usr/games
SHELL=/usr/bin/zsh

继续检查真正的 IDEA Backend:

1
2
tr '\0' '\n' < /proc/23285/environ \
| grep -E '^(PATH|SHELL|NVM_DIR)='

结果完全一致:

1
2
PATH=/home/username/.cargo/bin:/usr/local/bin:/usr/bin:/bin:/usr/games
SHELL=/usr/bin/zsh

问题到这里已经有了非常直接的证据:

Remote Backend 启动时继承到的 PATH 中,没有 nvm 当前 Node 的 bin 目录。

Linux proc_pid_environ(5) 对这个文件的定义很准确:它保存的是程序通过 execve() 启动时获得的 initial environment,各项使用空字节分隔。程序如果在启动后调用 setenv()putenv() 修改自己的环境,这个文件不保证实时反映后续变化。(man7.org)

所以这次使用 /proc/<pid>/environ 的目的不是查看某种 “永远实时的进程环境”,而是回答一个很具体的问题:

Remote Backend 启动的时候,到底有没有继承到 Node 的 PATH?

答案已经很明确:没有。

这比在另一个 SSH Terminal 中执行:

1
echo $PATH

更有参考价值,因为 SSH Terminal 和 Remote Backend 本来就是两个不同的进程环境。


.cargo/bin 为什么偏偏存在

Remote Backend 的 PATH 还有一个很有意思的细节:

1
/home/username/.cargo/bin:/usr/local/bin:/usr/bin:/bin:/usr/games

Node 没有,Cargo 却有。

查看当前:

1
cat ~/.zshenv

文件内容只有:

1
. "$HOME/.cargo/env"

而 Rust 安装产生的:

1
$HOME/.cargo/env

恰好会把:

1
$HOME/.cargo/bin

加入 PATH。

这个现象与 Remote Backend 实际拿到的 PATH 高度吻合:

1
2
3
4
5
.zshenv

source ~/.cargo/env

~/.cargo/bin

但这里需要克制一下结论。

仅凭:

1
.zshenv 中有 ~/.cargo/env

和:

1
Remote Backend PATH 中有 ~/.cargo/bin

还不能严格证明:

Gateway 启动 Remote Backend 时一定读取了 .zshenv

因为 $HOME/.cargo/bin 也可能由更上游的登录环境或其他初始化过程加入,再由 Gateway 继承。

如果要把这件事做成实证,可以临时在 .zshenv 中增加一个没有任何输出的探针:

1
export ZSHENV_LOADED_PROBE=1

重新创建 Remote Backend,再检查:

1
2
tr '\0' '\n' < /proc/<BackendPID>/environ \
| grep '^ZSHENV_LOADED_PROBE='

如果得到:

1
ZSHENV_LOADED_PROBE=1

就可以确认这个 Gateway 启动链路确实获得了 .zshenv 中设置的变量。


zsh 和 bash 到底读取哪些启动文件

Shell 启动文件很容易记混,尤其不能把 Bash 的规则直接套到 zsh 上。

zsh

zsh 不是从:

1
2
3
~/.zshenv
~/.zprofile
~/.zshrc

里面挑第一个存在的文件。

它会按照 Shell 类型决定需要读取哪些文件。

在没有设置 ZDOTDIR 的普通环境里,可以简化成:

文件什么时候读取
~/.zshenv正常启动的 zsh 都会读取
~/.zprofilelogin shell
~/.zshrcinteractive shell
~/.zloginlogin shell

一个 interactive login shell 可以依次经过:

1
2
3
4
~/.zshenv
~/.zprofile
~/.zshrc
~/.zlogin

zsh 官方给出的完整规则还包括系统级:

1
2
3
4
/etc/zshenv
/etc/zprofile
/etc/zshrc
/etc/zlogin

用户文件实际使用 $ZDOTDIR;没有设置 ZDOTDIR 时才使用 $HOME。(Zsh)

所以:

1
zsh -lc 'echo $PATH'

中的 -l 表示 login shell,-c 表示执行给定命令。它不是 interactive shell,因此通常会经过:

1
2
3
.zshenv
.zprofile
.zlogin

而不会因为是 login shell 就自动读取 .zshrc

普通:

1
zsh -c 'echo $PATH'

既不是 login shell,也不是 interactive shell,正常情况下主要涉及:

1
.zshenv

这也是排查远程开发、CI、非交互命令时 .zshenv 特别值得关注的原因。

zsh 官方还明确建议,全局执行频率很高的 zshenv 应尽可能保持精简。(Zsh)

bash

Bash 的规则不同。

Bash 以 login shell 启动时,会先读取:

1
/etc/profile

接着按这个顺序查找:

1
2
3
~/.bash_profile
~/.bash_login
~/.profile

只读取其中第一个存在且可读的文件。

这条规则来自 GNU Bash 官方手册。(GNU)

因此:

1
~/.bash_profile

和:

1
~/.profile

同时存在时,Bash login shell 不会因为读完 .bash_profile 就自动继续读 .profile

如果希望继续加载,通常需要自己在 .bash_profile 中写:

1
[ -f "$HOME/.profile" ] && . "$HOME/.profile"

但这个 “三选一” 规则只适用于 Bash login shell,不能理解成 Bash 永远只读取这三个文件。

普通 interactive non-login Bash 通常读取:

1
~/.bashrc

GNU Bash 手册还专门描述了 sshd 等远程 Shell daemon 场景:Bash 判断自己通过远程网络连接以非交互方式运行时,也可能读取 ~/.bashrc。(GNU)

所以远程开发环境里,仅靠 “Bash 登录时读取哪个文件” 往往解释不了全部问题。


nvm 为什么让这个问题更隐蔽

nvm 和通过 apt 安装 Node 的行为有明显区别。

假设通过系统包安装 Node,最终可能得到:

1
/usr/bin/node

只要应用的 PATH 中有:

1
/usr/bin

多数程序都可以直接执行:

1
node

nvm 则是一个 per-user、per-shell 的 Node 版本管理器。nvm 官方 README 就是这样定义它的。(GitHub)

当前 Node 实际位于:

1
~/.config/nvm/versions/node/v24.19.0/bin/node

执行:

1
nvm use 24

并不是向:

1
/usr/bin/node

安装一个新的系统级 Node。

核心动作之一是修改当前 Shell 所使用的 PATH,让:

1
~/.config/nvm/versions/node/v24.19.0/bin

排在合适的位置。

所以完全可能出现:

1
2
3
4
5
6
7
SSH Terminal

加载 nvm

PATH 有 Node

node -v 正常

而另一个进程:

1
2
3
4
5
6
7
IDEA Remote Backend

启动时没有拿到 nvm Node 路径

PATH 没 Node

执行 node 失败

这不是 nvm 安装失败。

两个进程只是拥有不同的环境。


处理方式

当前 .zshenv 原本只有:

1
. "$HOME/.cargo/env"

针对这次问题,一个很直接的处理方式是让 nvm 环境进入 Remote Backend 能继承到的环境。

例如:

1
2
3
4
. "$HOME/.cargo/env"

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

nvm 官方安装脚本使用的初始化方式本身就是:

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

官方通常会把它写进 .bashrc.bash_profile.zshrc.profile 等 profile 文件。(GitHub)

把完整 nvm 初始化放进 .zshenv 可以解决某些非交互环境无法获得 Node PATH 的问题,但它更适合视为针对当前 Gateway 环境的一种工程处理,而不是 nvm 的通用最佳配置。

因为 .zshenv 的影响范围很大。

如果现有 .profile 已经专门用于维护纯环境变量,也可以在 .zshenv 中:

1
[ -f "$HOME/.profile" ] && . "$HOME/.profile"

这种做法也需要控制 .profile 的内容。

如果未来在 .profile 里加入:

1
2
3
4
echo 输出
交互式命令
启动某些 agent
依赖 Bash 特性的复杂脚本

这些逻辑可能意外进入非交互 zsh。

更容易长期维护的方式,是单独准备公共环境文件,例如:

1
~/.config/shell-env

其中只保存环境初始化:

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

.profile

1
[ -f "$HOME/.config/shell-env" ] && . "$HOME/.config/shell-env"

.zshenv

1
2
3
. "$HOME/.cargo/env"

[ -f "$HOME/.config/shell-env" ] && . "$HOME/.config/shell-env"

alias、提示符、zinit 插件等交互式配置继续放在 .zshrc


为什么修改配置后还要重启 Remote Backend

环境变量有很明显的进程继承关系。

Linux 创建新程序时,execve() 会把环境传给新程序;子进程通过 fork() 创建时,也会继承父进程环境的副本。(man7.org)

可以简单理解成:

flowchart LR
    A[Gateway 启动环境] --> B[Remote Backend]
    B --> C[IDEA Run Configuration]
    C --> D[Java Application]

如果 Remote Backend 已经启动:

1
PATH=/home/username/.cargo/bin:/usr/local/bin:/usr/bin:/bin:/usr/games

此时再修改:

1
~/.zshenv

不会让已经运行的:

1
remote-dev-server

凭空获得新的 PATH。

所以环境配置调整后,需要真正停止旧 Remote Backend,让 Gateway 创建一个新的 Backend 进程。

重新连接后查看:

1
ps -ef | grep -i '[i]dea'

找到新的 Backend PID,再执行:

1
2
tr '\0' '\n' < /proc/<BackendPID>/environ \
| grep -E '^(PATH|NVM_DIR|NVM_BIN)='

期望看到类似:

1
2
NVM_DIR=/home/username/.config/nvm
PATH=/home/username/.config/nvm/versions/node/v24.19.0/bin:/home/username/.cargo/bin:/usr/local/bin:/usr/bin:/bin:/usr/games

Java 应用中再检查:

1
println(System.getenv("PATH"))

实际调用一次 Node:

1
2
3
4
5
6
val process =
ProcessBuilder("node", "-v")
.redirectErrorStream(true)
.start()

println(process.inputStream.bufferedReader().readText())

能够得到:

1
v24.19.0

这才构成完整的修复验证。


整个排查路径

flowchart TD
    A["Java / Gradle 中找不到 node"] --> B["SSH 执行 node -v / which node"]
    B --> C["Shell 中 Node 正常"]

    C --> D["Java 打印 System.getenv(PATH)"]
    D --> E["Java PATH 中没有 nvm Node"]

    E --> F["查找 IDEA Remote Backend PID"]
    F --> G["读取 /proc/PID/environ"]

    G --> H["Backend 启动时 PATH 同样没有 Node"]
    H --> I["问题定位到 Backend 上游环境"]

    I --> J["检查 Shell 初始化文件"]
    J --> K["发现 .zshenv 只有 cargo 环境"]

    K --> L["让 Node 环境进入 Backend 可继承的环境"]
    L --> M["重新创建 Remote Backend"]
    M --> N["再次检查 /proc/PID/environ"]
    N --> O["Java 实际执行 node -v 验证"]

整个过程中最有效的一步,其实不是反复执行:

1
echo $PATH

而是确定目标进程 PID,再看:

1
/proc/<pid>/environ

因为 “我当前终端的环境” 与 “目标程序启动时的环境” 完全可能是两回事。


本次事故结论

Node 本身没有安装问题,nvm 在用户的 zsh login shell 中工作正常:

1
/home/username/.config/nvm/versions/node/v24.19.0/bin/node

JetBrains Gateway 创建的 Remote IDE Backend 在启动时得到的 PATH 是:

1
/home/username/.cargo/bin:/usr/local/bin:/usr/bin:/bin:/usr/games

其中没有 nvm 当前 Node 的:

1
/home/username/.config/nvm/versions/node/v24.19.0/bin

Spring Boot 应用打印出的 PATH 与 Remote Backend 一致,因此当前现场至少已经证明了这一条继承链:

1
2
3
Remote Backend

Java Application

两者都没有得到 nvm Node 路径。原始运行日志中的 Remote Development 环境和 Java 运行信息也与这个判断一致。

.zshenv 中存在:

1
. "$HOME/.cargo/env"

而 Backend PATH 中恰好有:

1
/home/username/.cargo/bin

这是一条很强的环境初始化线索,但在没有额外探针或 Gateway 启动日志的情况下,不应该把它扩大成 “Gateway 一定读取了 .zshenv” 这样的结论。

因此,本次问题更准确的根因描述是:

JetBrains Remote Backend 启动时获得的环境中缺少 nvm 当前 Node 的 bin 目录,由 Remote Backend 启动的 Java 应用也继承了这套缺少 Node 的 PATH。

SSH 中 node -v 正常并不能否定这个结论,因为 nvm 本身就是 per-user、per-shell 的版本管理器。一个 Shell 能找到 Node,不代表另一个已经启动的进程具有完全相同的 PATH。(GitHub)

处理方向也随之变得清楚:让 Node 环境进入 Remote Backend 能继承到的启动环境,重新创建 Backend,再以 /proc/<pid>/environ、Java 的 System.getenv("PATH") 和实际 node -v 执行结果完成验证。

这次问题最值得留下来的排查习惯,大概就是把:

1
“我的终端里 node 明明能用”

换成:

1
“真正启动应用的那个进程,到底拿到了什么环境?”

很多看起来莫名其妙的 PATH、JDK、nvm、SDKMAN、pyenv 问题,到了这个层面就没那么神秘了。

参考资料

  • Zsh 官方文档 Startup/Shutdown Files:说明 .zshenv.zprofile.zshrc.zlogin 的加载条件以及 ZDOTDIR。(Zsh)
  • GNU Bash Reference Manual Bash Startup Files:说明 login shell 的 /etc/profile.bash_profile.bash_login.profile 规则,以及 .bashrc 和远程 Shell daemon 的特殊处理。(GNU)
  • nvm 官方 README:说明 nvm 是 per-user、per-shell 的 Node.js 版本管理器,并给出标准初始化方式。(GitHub)
  • Linux proc_pid_environ(5)environ(7):说明 /proc/<pid>/environexecve() 初始环境和父子进程环境继承。(man7.org)
  • JetBrains Remote Development 文档:说明 remote-dev-server.sh 是 Remote IDE 主启动脚本,并给出 Gateway Backend 默认目录。(JetBrains)
  • JetBrains IntelliJ IDEA 文档:说明 Application Run Configuration 的 JRE 配置以及 Gradle JVM 的选择方式。(JetBrains)
  • Gradle 官方 Daemon 文档:说明 Gradle Client 与 Daemon 的关系,以及 Client 会向 Daemon 发送包括环境变量在内的构建信息。(Gradle 文档)
  • 标题: JetBrains Gateway 远程 IDEA 找不到 nvm Node:一次环境变量继承问题的排查记录
  • 作者: tsvico
  • 创建于 : 2026-08-31 10:01:33
  • 更新于 : 2026-08-31 10:01:33
  • 链接: https://blog.tbox.fun/2026/1786314747.html
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。
评论