SSD Nodes Learn Hosting plans →
指南 Matt Connor作者: Matt Connor · 更新于 2026-08-27

如何在 VPS 上用 systemd 无终端运行 dsh

通过专用用户和固定版本运行 DeepSeek Harness,配置 systemd 自动重启、journalctl 日志,并用 SSH 隧道安全访问 UI,避免 SSH 断开后进程退出。

在 VPS 上以无终端方式运行 dsh

在 VPS 上以无终端方式运行 dsh,只需要一个 systemd 单元文件,以及一个专门用于拥有该服务的用户。dsh 是 DeepSeek Harness 的命令行启动器。DeepSeek Harness 是 DeepSeek 的代理运行时,于 2026 年 8 月以 MIT 许可证发布开发者预览版。Harness 是围绕模型运行的程序,而不是模型本身。因此,交给 systemd 管理的是循环、工具和权限,不是 DeepSeek 的推理过程。快速入门要求您输入 npx @deepseek-ai/dsh web,这本身没有问题,但 SSH(安全 Shell)会话一关闭,该进程也会立即退出。

单元文件可以同时解决四个问题。服务会在重启后自动恢复。输出会写入 journal,而不是不断滚过终端。服务会以非 root 账户运行。服务运行的是您选择的版本。这一点在此处尤其重要,因为上游明确声明:

DeepSeek Harness 当前处于开发者预览版,并且正在快速迭代。将会发生不兼容变更。

本指南假设您已经可以手动运行 dsh。如果还不能运行,请先阅读在 VPS 上安装 DeepSeek Harness,然后在 npx @deepseek-ai/dsh web 可以提供页面后再继续。

先安装 Node,因为 npm 不会发出警告

node -v

Ubuntu 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 up

up 表示 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 检查当前版本,然后固定您决定运行的版本。

全局安装已固定的版本

在单元文件中使用 npx 是错误的做法。它会在进程启动时解析软件包版本。因此,3 个月后重启时,预览阶段代理可能会启动不同的构建版本,而您没有做任何更改。启动时还必须能够访问 npm registry。如果 registry 当天响应缓慢,原本正常的计算机就会出现单元启动失败。请先安装一个明确记录的版本:

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。在单元文件中使用它实际输出的路径。npm ls -g 会输出精确版本。6 周后行为发生变化,而您记不起安装了哪个版本时,这正是需要的信息。如果安装失败,或者之后 command -v dsh 没有输出内容,或者返回的版本不是您指定的版本,请先排查常见的 dsh 安装和版本故障,再编写单元文件。

一个仅用于运行服务的用户

该代理会运行 shell 命令。这是它的工作。以 root 身份运行代理时,每次工具调用都会以 root 身份执行。因此,应为它创建专用账户,并禁用登录 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 会变为 DSH_HOME,这是 dsh 保存配置档的目录。配置档是一个命名的插件包堆栈,其顶部还叠加了您自己的补丁层。首次启动 web 和 headless 配置档时,它们会根据随附模板自动构建。之后添加到该堆栈中的任何内容都会以此用户身份运行,并使用代理自己的文件和 shell 访问权限。因此,安装插件前审核插件与创建账户同样重要。首次启动会写入文件,并且可能下载插件包,因此应在可以监控过程的位置手动执行。

sudo -u dsh env HOME=/var/lib/dsh DSH_HOME=/var/lib/dsh/harness /usr/bin/dsh --profile web

应显式设置 HOME,不要依赖 sudo 对它的处理方式。因为对于非登录命令,sudo 是否重写 HOME,取决于 /etc/sudoers 中的 set_home 设置。设置错误时,首次运行会将缓存目录写入您自己的主目录,并将其所有者设为 dsh,导致服务之后无法找到自己的状态数据。等 curl 检查返回 up 后,使用 Ctrl+C 停止它。

单元文件

写入 /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.target

ExecStart= 使用从 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 在前台运行且从不派生进程。与默认值相比,这样可以获得真正的错误消息。使用 Type=simple 时,systemd 只要完成派生就会将启动判定为成功,此时它甚至还不知道二进制文件是否存在,因此 systemctl start dsh 会正常返回,失败信息只会出现在日志中。使用 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 会在进程以非零退出码退出或收到致命信号时重启服务;进程正常退出后,systemd 不会继续操作该单元。这正是预览版本所需的行为。如果 dsh 因读取了无法处理的配置而以 0 退出,单元会停止并保持停止状态,systemctl status dsh 会显示 inactive (dead),可在其中查看原因。Restart=always 会将同一事件变成重启循环,从远处看似乎运行正常。

速率限制是容易遗漏的部分。systemd 的默认值是 10 秒内最多启动 5 次。使用 RestartSec=5s 时,服务永远不会在 10 秒窗口内达到 5 次启动,因此启动时崩溃的单元会无限重启,只有日志中会记录这一情况。StartLimitIntervalSec=300 配合 StartLimitBurst=5 后,5 分钟内失败 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 dsh

enable --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 中,每次重启都会将其删除。创建该目录并重启守护进程:

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。在尝试修改前,建议阅读启动输出中的 127.0.0.1:3080 实际表示什么。请从您自己的计算机转发该端口:

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措施在这里比通常更重要。如果该 VPS 逐渐发展成一个小型私有网络,后面还有数据库或预发布服务器,那么通过使用子网路由器向 tailnet 发布这些地址,可以避免为每项服务分别设置端口转发。不过,dsh 绑定到 loopback,因此 UI 仍需通过隧道访问。

不要将 key 放入 unit 文件。Environment= 的值由 systemctl show dsh -p Environment 输出,计算机上的任何用户都可以运行该命令。如果您安装的插件需要通过环境变量提供 key,请将其写入 /etc/dsh.env,设置文件模式为 600,并将所有者设为 root,然后使用 EnvironmentFile=/etc/dsh.env 引用它。systemd 会在执行时以 root 身份读取该文件,而 systemctl show 不会输出文件内容。每项设置实际写入磁盘上的哪个文件,以及将 dsh 指向本地 Ollama endpoint 而不是 DeepSeek API 时哪些内容会离开您的计算机,将在配置 dsh 的 key、模型和 endpoint中介绍。

运行成本

推理过程发生在 DeepSeek 的 API 上,而不是您的 VPS 上。您的服务器需要为 Node 进程、它提供的 UI,以及代理决定执行的每条命令支付资源成本。前两项的消耗稳定且较小。第三项不受此 unit 文件中的任何限制约束。

请在自己的服务器上测量最低资源占用,不要直接采用他人服务器上的数据:

systemctl show dsh -p MemoryCurrent
systemd-cgtop -1 --depth 2

MemoryCurrent 的单位是字节。代理工作时监控它,不要在代理空闲时监控。

工具调用是该服务的子进程,因此它们属于同一个 control group,并受相同限制约束。代理在工作区内运行 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 下保存会话历史,并且代理会在工作区内写入文件。因此,请将 du -sh /var/lib/dsh 纳入您现有的磁盘监控方案。

如果您需要的是可以连接和断开的交互式代理,那么服务不是合适的运行方式,在持久化 tmux 会话中运行代理更适合这种场景。只有在您希望 dsh 始终运行,并通过隧道访问它时,才应将 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.。该单元触发了启动速率限制,因而放弃启动。真正的错误位于上方的日志行中。再次尝试前运行 sudo systemctl reset-failed dsh。

单元状态为 active (running),但浏览器没有显示内容。 在 VPS 上运行检查命令:如果 curl -fsS http://127.0.0.1:3080/ -o /dev/null && echo up 在那里输出 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 在前台运行且不会派生进程,因此两者都可以使用;但 Type=exec 会等待 execve() 成功后,才将启动标记为成功。此时,ExecStart= 中的错误路径会使 systemctl start 失败,并通过 status=203/EXEC 直接显示错误。使用 Type=simple 时,同样的错误会返回成功,并隐藏在日志中。Type=forking 和 Type=notify 在这里都不正确,并且都会一直挂起,直到 TimeoutStartSec 在 90 秒后超时。

如何从笔记本电脑打开 dsh Web UI?

通过 SSH 转发端口:ssh -N -L 3080:127.0.0.1:3080 you@your-vps,然后在浏览器中打开 http://127.0.0.1:3080/。不要尝试将服务绑定到公网地址。dsh 会因 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 而拒绝 --host 0.0.0.0,因为 Web API 可以让代理运行 shell 命令,且前面没有远程身份验证。

可以以 root 身份运行 dsh,以简化权限管理吗?

不可以。该执行框架用于运行命令和写入文件,因此服务拥有的权限也会由代理拥有。使用 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> 安装,并记录在便于查找的位置。2026 年 8 月 18 日,0.1.0-rc.7 是当前版本。重点不在版本号,而在于不带版本的 npx 会在启动时解析软件包,因此无人值守重启可能会悄悄切换到配置格式不同的构建版本。