JetBrains Gateway 远程 IDEA 找不到 nvm Node:一次环境变量继承问题的排查记录
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 | node -v |
结果正常:
1 | v24.19.0 |
手工启动 Bash login shell:
1 | /bin/bash -lc 'node -v && which node && echo $PATH' |
同样可以找到 Node:
1 | v24.19.0 |
应用环境中还能看到:
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 | v24.19.0 |
到这里能确认的是:
nvm 在 SSH Shell 和 zsh login shell 中工作正常。
但这还不能说明 Gateway Remote Backend 拿到的是同一套环境。
Java 应用启动后,通过:
1 | System.getenv().toSortedMap().forEach { (key, value) -> |
打印出的运行时环境却是:
1 | PATH=/home/username/.cargo/bin:/usr/local/bin:/usr/bin:/bin:/usr/games |
其中完全没有:
1 | /home/username/.config/nvm/versions/node/v24.19.0/bin |
也没有:
1 | NVM_DIR |
这已经说明 SSH Shell 和 Java 应用得到的环境不是同一套。
Java 为什么还能正常启动
这里曾经有一个很容易带偏排查方向的现象:
1 | Java 能正常启动 |
实际上两件事没有矛盾。
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.home、JAVA_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 | username 23272 1 /bin/sh /home/username/.cache/JetBrains/RemoteDev/dist/.../bin/remote-dev-server.sh run /home/username/work/auxiliary-classification/dingmi |
从这个进程快照可以确认,当前 Remote Backend 最终运行形态中存在这样一层关系:
1 | /bin/sh |
JetBrains 官方文档也确认,remote-dev-server.sh 是运行 Remote IDE 的主脚本;由 Gateway 安装的远程 IDE 默认位于:
1 | ~/.cache/JetBrains/RemoteDev/dist/ |
不过这里不能进一步写成:
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 | tr '\0' '\n' < /proc/23272/environ \ |
结果:
1 | PATH=/home/username/.cargo/bin:/usr/local/bin:/usr/bin:/bin:/usr/games |
继续检查真正的 IDEA Backend:
1 | tr '\0' '\n' < /proc/23285/environ \ |
结果完全一致:
1 | PATH=/home/username/.cargo/bin:/usr/local/bin:/usr/bin:/bin:/usr/games |
问题到这里已经有了非常直接的证据:
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 | .zshenv |
但这里需要克制一下结论。
仅凭:
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 | tr '\0' '\n' < /proc/<BackendPID>/environ \ |
如果得到:
1 | ZSHENV_LOADED_PROBE=1 |
就可以确认这个 Gateway 启动链路确实获得了 .zshenv 中设置的变量。
zsh 和 bash 到底读取哪些启动文件
Shell 启动文件很容易记混,尤其不能把 Bash 的规则直接套到 zsh 上。
zsh
zsh 不是从:
1 | ~/.zshenv |
里面挑第一个存在的文件。
它会按照 Shell 类型决定需要读取哪些文件。
在没有设置 ZDOTDIR 的普通环境里,可以简化成:
| 文件 | 什么时候读取 |
|---|---|
~/.zshenv | 正常启动的 zsh 都会读取 |
~/.zprofile | login shell |
~/.zshrc | interactive shell |
~/.zlogin | login shell |
一个 interactive login shell 可以依次经过:
1 | ~/.zshenv |
zsh 官方给出的完整规则还包括系统级:
1 | /etc/zshenv |
用户文件实际使用 $ZDOTDIR;没有设置 ZDOTDIR 时才使用 $HOME。(Zsh)
所以:
1 | zsh -lc 'echo $PATH' |
中的 -l 表示 login shell,-c 表示执行给定命令。它不是 interactive shell,因此通常会经过:
1 | .zshenv |
而不会因为是 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 | ~/.bash_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 | SSH Terminal |
而另一个进程:
1 | IDEA Remote Backend |
这不是 nvm 安装失败。
两个进程只是拥有不同的环境。
处理方式
当前 .zshenv 原本只有:
1 | . "$HOME/.cargo/env" |
针对这次问题,一个很直接的处理方式是让 nvm 环境进入 Remote Backend 能继承到的环境。
例如:
1 | . "$HOME/.cargo/env" |
nvm 官方安装脚本使用的初始化方式本身就是:
1 | export NVM_DIR="..." |
官方通常会把它写进 .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 | echo 输出 |
这些逻辑可能意外进入非交互 zsh。
更容易长期维护的方式,是单独准备公共环境文件,例如:
1 | ~/.config/shell-env |
其中只保存环境初始化:
1 | export NVM_DIR="$HOME/.config/nvm" |
.profile:
1 | [ -f "$HOME/.config/shell-env" ] && . "$HOME/.config/shell-env" |
.zshenv:
1 | . "$HOME/.cargo/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 | tr '\0' '\n' < /proc/<BackendPID>/environ \ |
期望看到类似:
1 | NVM_DIR=/home/username/.config/nvm |
Java 应用中再检查:
1 | println(System.getenv("PATH")) |
实际调用一次 Node:
1 | val process = |
能够得到:
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 | Remote Backend |
两者都没有得到 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>/environ、execve()初始环境和父子进程环境继承。(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 进行许可。