如何在 VPS 上用 systemd 无终端运行 dsh
配置 dsh DeepSeek Harness 的 systemd 服务:使用专用用户、固定版本和 Restart 规则,通过 journalctl 查看日志,并用 SSH 隧道访问 UI。
在 VPS 上以无终端方式运行 dsh
要在 VPS 上以无终端方式运行 dsh,只需创建一个 systemd 单元文件,并为其配置专用用户。dsh 是 DeepSeek Harness 的命令行启动器。DeepSeek Harness 是 DeepSeek 的代理运行时,于 2026 年 8 月以 MIT 许可证发布了开发者预览版。快速入门指南要求您输入 npx @deepseek-ai/dsh web,这一步是正确的;但关闭 SSH(安全 Shell)会话后,进程也会立即退出。
单元文件可以同时解决以下四个问题。服务会在重启后恢复运行。其输出会写入 journal,而不是从终端滚动过去。服务会以非 root 账户运行。服务运行的是您选择的版本。这一点在这里比平时更重要,因为上游明确警告:
DeepSeek Harness 当前处于开发者预览版阶段,正在快速迭代。将会出现不兼容的变更。
本指南假设您已经可以手动运行 dsh。如果还不能,请先阅读在 VPS 上安装 DeepSeek Harness,并在 npx @deepseek-ai/dsh web 可以提供页面后再继续。
先安装 Node,因为 npm 不会发出警告
node -vUbuntu 24.04 自带的软件包提供 Node 18(截至 2026 年 8 月为 18.19.1)。对于今年发布的软件包来说,这个版本过旧。@deepseek-ai/dsh 不发布 engines 字段,因此 Node 版本过低时,npm 不会打印 EBADENGINE 警告。相反,问题会在运行时才出现,表现为语法错误或缺少内置功能。等到此时才发现问题,处理起来会困难得多。请从 NodeSource 安装当前的长期支持(LTS)版本:
curl -fsSL https://deb.nodesource.com/setup_22.x -o /tmp/nodesource_setup.sh
less /tmp/nodesource_setup.sh
sudo -E bash /tmp/nodesource_setup.sh
sudo apt install -y nodejs
node -v现在,node -v 应打印 v22 版本。之所以加入 less 行,是因为将远程脚本直接通过管道传给 bash 会执行你没有查看过的代码。
在编写 unit 之前证明服务可以运行
npx @deepseek-ai/dsh@0.1.0-rc.7 web保持该进程运行。在第二个 SSH 会话中执行:
curl -fsS http://127.0.0.1:3080/ -o /dev/null && echo upup 表示 Web 配置正在 loopback 上监听,因为这是它默认绑定的地址。curl: (7) Failed to connect to 127.0.0.1 port 3080: Connection refused 表示它没有监听,前一个终端已经说明了原因。继续之前,使用 Ctrl+C 停止手动运行:尝试绑定已被其他进程占用的端口时,unit 会因 Error: listen EADDRINUSE: address already in use 127.0.0.1:3080 而失败。
0.1.0-rc.7 是 2026 年 8 月 18 日发布的版本。使用 npm view @deepseek-ai/dsh version 检查当前版本,然后固定要运行的版本。
全局安装固定的版本
在 unit 文件中使用 npx 是错误的做法。进程启动时,它会解析软件包版本。因此,3 个月后重启时,预览阶段代理可能启动不同的构建版本,而您没有做任何更改。启动时还必须能够访问 npm registry。如果 registry 当天响应缓慢,原本正常运行的计算机就会变成启动失败的 unit。请一次性安装您记录的版本:
sudo npm install -g @deepseek-ai/dsh@0.1.0-rc.7
command -v dsh
npm ls -g --depth=0 @deepseek-ai/dsh如果 npm 来自 NodeSource,command -v dsh 会输出 /usr/bin/dsh;如果来自 Ubuntu 自带的软件包,则输出 /usr/local/bin/dsh。在 unit 文件中使用它实际输出的路径。npm ls -g 会输出确切版本。6 周后行为发生变化,而您想不起安装了哪个版本时,这就是您需要的信息。
A user that owns the service and nothing else
The agent runs shell commands. That is its job. Running it as root makes every tool call a root tool call, so give it its own account with no login shell.
sudo useradd --system --create-home --home-dir /var/lib/dsh --shell /usr/sbin/nologin dsh
sudo install -d -o dsh -g dsh -m 750 /var/lib/dsh/harness /var/lib/dsh/workspace
id dsh/var/lib/dsh/harness becomes DSH_HOME, the directory dsh keeps profiles in. A profile is a named stack of plugin bundles with your own patch layer on top, and the web and headless profiles build themselves from shipped templates the first time you boot them. That first boot writes files and may fetch bundles, so do it by hand where you can watch it.
sudo -u dsh env HOME=/var/lib/dsh DSH_HOME=/var/lib/dsh/harness /usr/bin/dsh --profile webSet HOME explicitly rather than trusting what sudo does with it, because whether sudo rewrites HOME for a non-login command depends on the set_home setting in /etc/sudoers. Get it wrong and the first run drops cache directories into your home directory owned by dsh, and the service later cannot find its own state. Stop it with Ctrl+C once the curl check returns up.
单元文件
编写 /etc/systemd/system/dsh.service:
[Unit]
Description=DeepSeek Harness (dsh) web profile
Documentation=https://github.com/deepseek-ai/deepseek-harness
After=network-online.target
Wants=network-online.target
StartLimitIntervalSec=300
StartLimitBurst=5
[Service]
Type=exec
User=dsh
Group=dsh
WorkingDirectory=/var/lib/dsh/workspace
Environment=HOME=/var/lib/dsh
Environment=DSH_HOME=/var/lib/dsh/harness
ExecStart=/usr/bin/dsh --profile web
Restart=on-failure
RestartSec=5s
TimeoutStopSec=30s
SyslogIdentifier=dsh
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ProtectHome=true
[Install]
WantedBy=multi-user.targetExecStart= 使用从 command -v dsh 获取的绝对路径。systemd 会在固定的路径列表中搜索不带路径的命令名,但该列表不是 shell 的 PATH,因此使用绝对路径可以避免猜测。
WorkingDirectory= 是相对路径解析的目录,也是工具调用在不带参数运行 ls 时的起始目录。将它指向提供给代理的工作区。如果目录不存在,或服务用户无法进入该目录,单元会在 dsh 完全启动之前因 status=200/CHDIR 失败。
ProtectHome=true 会对进程隐藏 /home 和 /root。这里这样做是安全的,因为服务访问的所有内容都位于 /var/lib/dsh 下。将工作区指向 /home 下的路径后,代理会报告该目录不存在;如果忘记这一行,就会觉得这个错误很难解释。ProtectSystem=full 会使 /usr、/boot 和 /etc 变为只读,服务不需要向其中写入内容。
继续收紧限制很诱人,但通常是错误的做法。ProtectSystem=strict 会使整个文件系统变为只读状态,内核伪文件系统除外,因此第一个写入文件的工具调用会因 EROFS: read-only file system 失败。如果确实需要这种级别的限制,请在同一次编辑中添加 ReadWritePaths=/var/lib/dsh。
此处应使用哪种 Type=
Type=exec,因为 dsh 在前台运行,且从不 fork。与默认设置相比,这样可以直接看到实际的错误消息。使用 Type=simple 时,systemd 在完成 fork 后就会将启动视为成功,而不会确认二进制文件是否存在。因此,systemctl start dsh 会正常返回,只有在 journal 中才能看到失败原因。使用 Type=exec 时,systemd 会等待 execve() 成功,因此 ExecStart= 中的拼写错误会直接导致刚输入的命令失败,你可以立即看到错误。
另外两个错误答案都会导致启动挂起。Type=forking 会让 systemd 等待父进程退出,而 dsh 永不退出,因此启动会一直阻塞,直到 TimeoutStartSec 超时(默认 90 秒),随后报告 Job for dsh.service failed because a timeout was exceeded.。Type=notify 会等待通过 sd_notify 发送的 READY=1 消息,而永远不发送该消息的 Node 进程也会造成同样的停滞。systemd 服务类型的完整比较还介绍了其余内容,包括何时值得配置 notify。
大声报告失败的重启规则
Restart=on-failure会在进程以非零退出码退出或收到致命信号时重启服务,并在进程正常退出后保持单元停止。这正是预览构建所需的行为。如果 dsh 因读取到不接受的配置而以 0 退出,单元会停止并保持停止状态,systemctl status dsh会显示 inactive (dead),您可以在那里查看相关信息。Restart=always会将同一事件变成重启循环,从远处看似乎运行正常。
速率限制是经常被遗漏的部分。systemd 的默认值是在十秒内最多启动五次。使用 RestartSec=5s 后,服务永远不会在十秒窗口内达到五次启动,因此启动时崩溃的单元会无限重启,只有日志中会记录这一情况。StartLimitIntervalSec=300 配合 StartLimitBurst=5 使用时,五分钟内失败五次就足够触发限制:systemd 会放弃重启,并将单元置于 failed,同时记录 Start request repeated too quickly.。修复原因后,使用 sudo systemctl reset-failed dsh 清除该状态。这两个设置应放在 [Unit] 中,而不是 [Service] 中;如果放在错误的区段,systemd 会静默忽略它们。
启动服务,然后检查
sudo systemctl daemon-reload
sudo systemctl enable --now dsh
systemctl status dshenable --now有两个作用。enable负责让服务在重启后恢复运行,--now负责在本次启动中启动服务。单独执行systemctl start只在下次重启前有效,而内核更新会要求重启。
systemctl status dsh应显示Active: active (running)、Main PID和Memory:行。然后确认服务的监听地址:
sudo ss -lntp | grep 3080应看到127.0.0.1:3080。如果看到0.0.0.0:3080,说明某些配置已修改绑定地址,您的代理已暴露在公网中。该输出中的进程名称是node,而不是dsh,因为dsh二进制文件实际是 Node 脚本,所以pgrep -x dsh找不到进程。请改用systemctl show -p MainPID dsh。
然后重启一次。服务如果从未成功经受重启,就还不能算配置完成。
sudo reboot重新连接后运行systemctl is-active dsh。该命令会输出active。
使用 journalctl 读取日志
dsh 写入 stdout 和 stderr 的所有内容,都会以该单元名称写入 journal。
journalctl -u dsh -f
journalctl -u dsh -n 200 --no-pager
journalctl -u dsh --since "10 min ago" -p err-f 跟踪新增行,-n 显示最后 N 行,-p err 按优先级筛选。单元中的 SyslogIdentifier=dsh 会使这些行标记为 dsh,而不是 node。首次读取未按单元筛选的 journal 输出时,这一点很重要。
请在需要之前确认 journal 可在重启后保留:
journalctl -u dsh -b -1如果输出 Specifying boot ID or boot offset has no effect, no persistent journal was found,说明 journal 存储在 /run 中,每次重启都会被删除。创建该目录并重启 daemon:
sudo mkdir -p /var/log/journal
sudo systemctl restart systemd-journald通过 SSH 隧道访问 UI,不要开放公网端口
dsh 在 127.0.0.1:3080 上提供 Web UI(用户界面),不会在其他地址提供服务。请求 --host 0.0.0.0 时,它会显示:
error: --host 0.0.0.0 is intentionally not supported yet for safety: it would expose remote code execution to the network; use 127.0.0.1 instead这不是可以绕过的限制。Web API(应用程序编程接口)负责驱动 agent,而 agent 会执行 shell 命令。因此,任何人发现这个可访问的端口后,都相当于获得了 VPS 上的 shell。维护者给出的原因是远程身份验证尚未实现,所以绑定地址固定为 loopback。请从您自己的计算机转发该端口:
ssh -N -L 3080:127.0.0.1:3080 you@203.0.113.10-L 3080:127.0.0.1:3080 会在您的笔记本电脑上开放端口 3080,并将到达该端口的所有内容转发到 127.0.0.1:3080;其中的解析操作发生在 VPS 上。-N 表示不执行远程命令,因此该会话只负责保持隧道打开。让它持续运行,然后在浏览器中打开 http://127.0.0.1:3080/。您可以在 Settings,然后是 Models 中输入 DeepSeek API key,并选择 workspace 目录。将 workspace 指向 /var/lib/dsh/workspace,也就是服务用户拥有的目录,否则 agent 的文件工具会因 EACCES: permission denied 而失败。
如果笔记本电脑上的端口 3080 已被占用,ssh 会显示:
bind [127.0.0.1]:3080: Address already in use
channel_setup_fwd_listener_tcpip: cannot listen to port: 3080使用 ssh -N -L 3081:127.0.0.1:3080 you@203.0.113.10 选择其他本地端口,然后访问 http://127.0.0.1:3081/。将这条命令保存到您自己的计算机上的 ~/.ssh/config 中:
Host dsh-vps
HostName 203.0.113.10
User you
LocalForward 3080 127.0.0.1:3080此后,ssh -N dsh-vps 就是完整命令。该隧道现在是访问 agent 的唯一入口,因此保护它的关键在于 SSH daemon:仅允许密钥身份验证,禁止密码身份验证;通常的 在 VPS 上加固 SSH 措施在这里更为重要。
不要将 key 放入 unit 文件。Environment= 值会由 systemctl show dsh -p Environment 打印,计算机上的任何用户都可以运行该命令。如果您安装的插件需要环境变量中的 key,请将其放入 /etc/dsh.env,并设置为由 root 拥有且权限为 600,然后通过 EnvironmentFile=/etc/dsh.env 引用该文件。systemd 会在执行时以 root 身份读取该文件,而 systemctl show 不会打印其内容。
运行成本
推理在 DeepSeek 的 API 上执行,不在您的 VPS 上执行。您的服务器只需承担 Node 进程、提供 UI 的服务,以及 agent 决定运行的每条命令所消耗的资源。前两项通常稳定且占用较小。第三项不受此 unit 文件中的任何限制约束。
请在自己的服务器上测量最低资源占用,不要直接采用他人服务器上的数据:
systemctl show dsh -p MemoryCurrent
systemd-cgtop -1 --depth 2MemoryCurrent 的单位是字节。请在 agent 工作时观察它,不要只在空闲时观察。
工具调用属于该服务的子进程,因此会进入同一个 control group,并受到相同的限制。agent 在工作区中运行 npm install 或测试套件时,使用的内存可能远高于 harness 本身。在 1 GB VPS 上,问题通常会在这里暴露:内核选择一个进程并将其终止,journalctl -k | grep -i "out of memory" 会显示包含所选进程名称的 Out of memory: Killed process 行。该进程通常不是引发问题的进程。
解决方法是主动设置限制。在 [Service] 部分设置 MemoryMax= 和 CPUQuota=,可将影响限制在该 unit 内。这样失控的构建会被终止,而不是导致整台服务器锁死。使用 systemd 限制内存和 CPU介绍具体数值及故障行为。磁盘占用也会增长,原因是 DSH_HOME 下的会话历史记录,以及 agent 写入工作区的内容。因此,请将 du -sh /var/lib/dsh 纳入您现有的磁盘监控方案。
如果您需要的是可以连接和断开的交互式 agent,那么服务并不适合这种模式;在持久 tmux 会话中运行 agent更合适。需要始终运行并可通过隧道访问时,再将 dsh 作为 unit 运行。
故障模式及你将看到的字符串
status=203/EXEC。 systemd 无法运行该文件,日志中显示 Failed to locate executable /usr/local/bin/dsh: No such file or directory。ExecStart= 中的路径与 command -v dsh 输出的路径不一致。这就是 Type=exec 在 systemctl start 时报告的故障,而不是将其隐藏。
status=217/USER。 User= 中的账户不存在。使用 id dsh 确认。
status=200/CHDIR。 WorkingDirectory= 缺失,或服务用户无法进入该目录。sudo -u dsh ls /var/lib/dsh/workspace 可直接复现此问题。
Error: listen EADDRINUSE: address already in use 127.0.0.1:3080。 某个进程已占用该端口,通常是另一个终端中运行的 npx 仍未退出。sudo ss -lntp | grep 3080 可显示占用该端口的进程。
EACCES: permission denied 后跟路径。 /var/lib/dsh 下的所有权不正确,通常是因为首次运行时使用了 root,或使用了错误的 HOME。sudo chown -R dsh:dsh /var/lib/dsh 可修复此问题。
Start request repeated too quickly. 单元触发了启动速率限制,systemd 放弃重试。真正的错误位于它上方的日志行中。再次尝试前运行 sudo systemctl reset-failed dsh。
单元状态为 active (running),但浏览器没有显示内容。 在 VPS 上运行检查:如果 curl -fsS http://127.0.0.1:3080/ -o /dev/null && echo up 在 VPS 上输出 up,说明服务运行正常,问题出在端口转发。
有计划地升级
版本固定意味着升级由您主动执行,而不是被动发生。先阅读发行说明,因为上游关于兼容性破坏性变更的警告正是固定版本的全部原因。备份状态目录,然后切换版本:
sudo systemctl stop dsh
sudo tar czf /root/dsh-home-$(date +%F).tgz -C /var/lib/dsh harness
sudo npm install -g @deepseek-ai/dsh@0.1.0-rc.7
sudo systemctl start dsh
journalctl -u dsh -n 50 --no-pager回滚操作相同,只需将 npm install -g 替换为旧版本,并恢复该 tarball;但前提是您之前创建过它。预览阶段的 agent runtime 正是这类软件:升级可能会在您未察觉的情况下重写配置格式。
FAQ
为什么关闭 SSH 会话后 dsh 会停止?
因为 npx @deepseek-ai/dsh web 是由登录会话拥有的前台进程,会在会话结束时被终止。systemd 单元由 init 系统拥有,因此断开连接后仍会继续运行,并在重启后再次启动。sudo systemctl enable --now dsh 是同时实现这两点的两个步骤:enable 负责重启后的启动,--now 负责本次启动。
dsh 应使用 Type=simple 还是 Type=exec?
Type=exec。dsh 在前台运行且不会 fork,因此两者都可以使用,但 Type=exec 会让 systemd 等待 execve() 成功后,再将启动标记为成功。ExecStart= 中的路径错误随后会使 systemctl start 失败,并直接显示 status=203/EXEC。使用 Type=simple 时,同样的错误会返回成功,并隐藏在日志中。Type=forking 和 Type=notify 在这里都不正确,二者都会一直挂起,直到 90 秒后 TimeoutStartSec 超时。
如何从笔记本电脑打开 dsh Web UI?
通过 SSH 转发端口:ssh -N -L 3080:127.0.0.1:3080 you@your-vps,然后在浏览器中打开 http://127.0.0.1:3080/。不要尝试将服务绑定到公网地址。dsh 会拒绝 --host 0.0.0.0,并显示 error: --host 0.0.0.0 is intentionally not supported yet for safety: it would expose remote code execution to the network; use 127.0.0.1 instead,因为 Web API 可以让代理执行 shell 命令,且其前面没有远程身份验证。
可以使用 root 运行 dsh,以简化权限管理吗?
不可以。该 harness 用于执行命令和写入文件,因此服务拥有的权限也会由代理拥有。使用 useradd --system --shell /usr/sbin/nologin dsh 创建系统账户,让该账户拥有 /var/lib/dsh,并将 NoNewPrivileges=true 添加到单元中。如果之后遇到 EACCES: permission denied,通常是因为之前以 root 运行时留下了由 root 拥有的文件,使用 sudo chown -R dsh:dsh /var/lib/dsh 即可清理这些文件。
应在单元中固定哪个 dsh 版本?
使用设置服务时 npm view @deepseek-ai/dsh version 报告的版本,通过 npm install -g @deepseek-ai/dsh@<that version> 安装,并记录在易于查找的位置。0.1.0-rc.7 在 18 August 2026 仍是当前版本。重点不在版本号,而在于不带版本号的 npx 会在启动时解析软件包,因此无人值守的重启可能会在不知情的情况下切换到配置格式不同的构建版本。