VPSでdshをsystemd常駐させる方法
VPSでdshをsystemdサービスとして常駐させる手順です。専用ユーザー、バージョン固定、Restart設定、journalctlログ、SSHトンネルによるUI接続を解説します。
VPS 上で dsh を端末から切り離して実行する
VPS 上で dsh を端末から切り離して実行するには、systemd の unit ファイルを1つ用意し、dsh の所有者となる専用ユーザーを作成します。dsh は DeepSeek Harness のコマンドラインランチャーです。DeepSeek Harness は DeepSeek のエージェントランタイムで、MIT ライセンスの下、2026年8月に developer preview として公開されました。harness はモデルそのものではなく、モデルを取り囲むプログラムです。そのため、systemd で管理するのは ループ、ツール、権限であり、DeepSeek の推論処理ではありません。quickstart では npx @deepseek-ai/dsh web と入力するよう案内されています。これは正しい方法ですが、SSH(secure shell)セッションを閉じると同時に終了します。
unit ファイルを使うと、4つの問題を同時に解決できます。再起動後もサービスが復旧します。出力は画面を流れ続けるのではなく、journal に記録されます。root ではないアカウントで実行できます。また、指定したバージョンを実行できます。今回はこの点が通常以上に重要です。upstream は次のように大文字で明記しています。
DeepSeek Harness は現在 developer preview であり、急速に変更されています。互換性を破壊する変更が発生します。
このガイドでは、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 から現行の long term support (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 -vnode -v は v22 バージョンを表示するはずです。less 行を挟んでいるのは、リモートスクリプトをそのまま bash にパイプすると、内容を読まずにコードを実行することになるためです。
unit を作成する前に、実行できることを確認します
npx @deepseek-ai/dsh@0.1.0-rc.7 webこのプロセスは実行したままにします。2 つ目の SSH セッションで、次を実行します。
curl -fsS http://127.0.0.1:3080/ -o /dev/null && echo upup は、Web プロファイルが loopback で待ち受けていることを示します。デフォルトでは、ここに bind します。curl: (7) Failed to connect to 127.0.0.1 port 3080: Connection refused は待ち受けていないことを示します。最初のターミナルに理由が表示されています。先に進む前に Ctrl+C で手動実行を停止します。すでに別のプロセスが保持しているポートに bind しようとする unit は、Error: listen EADDRINUSE: address already in use 127.0.0.1:3080 で失敗します。
0.1.0-rc.7 は 18 August 2026 時点の公開バージョンです。npm view @deepseek-ai/dsh version で現在のバージョンを確認し、実行するバージョンを決めたら固定します。
固定したバージョンをグローバルにインストールする
npxは unit ファイル内では適切なツールではありません。プロセスの起動時にパッケージのバージョンを解決するため、3 か月後に再起動すると、こちらで何も変更していなくても、プレビュー段階のエージェントが別のビルドで起動する可能性があります。また、ブート時に npm レジストリへ接続できる必要があります。そのため、レジストリの応答が遅い日に、正常に動作していたマシンが failed の unit になります。バージョンを記録したうえで、1 回だけインストールします。
sudo npm install -g @deepseek-ai/dsh@0.1.0-rc.7
command -v dsh
npm ls -g --depth=0 @deepseek-ai/dshnpm が NodeSource 由来の場合、command -v dshは/usr/bin/dshを出力します。Ubuntu 独自のパッケージ由来の場合は/usr/local/bin/dshを出力します。unit ファイルには、実際に出力されたパスを使用してください。npm ls -gは正確なバージョンを出力します。6 週間後に動作が変わり、何をインストールしたか思い出せない場合に必要になる情報です。インストールに失敗した場合、またはその後に command -v dshが何も出力しない場合、あるいは取得したバージョンが指定したものと異なる場合は、unit ファイルを作成する前にdsh の通常のインストールおよびバージョン関連の失敗を確認してください。
サービスだけを所有するユーザー
エージェントはシェルコマンドを実行します。それが役割です。root で実行すると、すべてのツール呼び出しが root 権限で実行されるため、ログインシェルを持たない専用アカウントを割り当てます。
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 がプロファイルを保存するディレクトリである DSH_HOME になります。プロファイルは、独自のパッチ層を上に重ねた、名前付きのプラグインバンドルのスタックです。web プロファイルと headless プロファイルは、初回起動時に同梱テンプレートから自動的に構成されます。後からそのスタックに追加したものは、エージェント固有のファイルアクセスとシェルアクセスを持つこのユーザーとして実行されます。そのため、インストール前にプラグインを検証することは、アカウント作成と同じ作業の一部です。初回起動ではファイルが作成され、バンドルが取得される場合もあるため、状況を確認できる場所で手動実行してください。
sudo -u dsh env HOME=/var/lib/dsh DSH_HOME=/var/lib/dsh/harness /usr/bin/dsh --profile webHOME は明示的に設定してください。sudo が HOME をどのように扱うかに依存しないためです。非ログインコマンドで sudo が HOME を書き換えるかどうかは、/etc/sudoers の set_home 設定によって決まります。設定を誤ると、初回実行時に dsh が所有するキャッシュディレクトリが自分のホームディレクトリに作成され、後でサービスが自身の状態を見つけられなくなります。curl の確認結果が up になったら、Ctrl+C で停止してください。
unit ファイル
/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 はコマンド名だけが指定された場合に固定のパス一覧を検索しますが、その一覧はシェルの PATH ではありません。絶対パスを指定すれば、この不一致を避けられます。
WorkingDirectory= は相対パスの基準となるディレクトリです。また、引数なしで ls を実行するツール呼び出しの開始地点でもあります。エージェントに渡すワークスペースを指定します。このディレクトリが存在しない場合や、サービスユーザーがそこへ移動できない場合、dsh が実行される前に unit は 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= の入力ミスが、入力したコマンド自体の失敗として返され、すぐに確認できます。
誤った2つの指定は、どちらも処理が停止したように見えます。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は、ゼロ以外の終了コードまたは致命的なシグナルで終了した場合に再起動し、正常終了した場合は unit をそのまま停止します。プレビュー版には、この動作が適しています。dsh が設定ファイルを読み込んだ結果、ゼロで終了してしまった場合、unit は停止したままになります。systemctl status dshで inactive (dead)を確認できるため、停止理由を把握できます。Restart=alwaysを設定すると、同じイベントが再起動ループになり、遠目には正常に動作しているように見えます。
レート制限は省かれがちな設定です。systemd のデフォルトは、10 秒以内に5回の起動です。RestartSec=5sでは10秒間に5回の起動に達しないため、起動時にクラッシュする unit は無期限に再起動し、journal だけがその事実を記録します。StartLimitIntervalSec=300に StartLimitBurst=5を組み合わせると、5分間に5回失敗した時点で十分です。systemd は処理をあきらめ、unit を 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には 2 つの役割があります。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 が表示される場合、何らかの設定変更によってバインドアドレスが変更され、エージェントがパブリックインターネット上で待ち受けています。この出力に表示されるプロセス名は dsh ではなく node です。dsh バイナリは Node スクリプトであるため、pgrep -x dsh では何も見つかりません。代わりに systemctl show -p MainPID dsh を使用します。
次に、1 回再起動します。再起動後も動作したことがないサービスは、まだサービスとして完成していません。
sudo reboot再接続して、systemctl is-active dsh を実行します。active が表示されます。
journalctl でログを読む
dsh が stdout と stderr に出力する内容はすべて、unit 名の下で 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 は優先度で絞り込みます。unit 内の SyslogIdentifier=dsh により、これらの行には node ではなく dsh のタグが付くため、unit で絞り込んでいない 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-journaldSSH トンネル経由で 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(アプリケーションプログラミングインターフェース)がエージェントを動かし、エージェントはシェルコマンドを実行します。そのため、到達可能なポートは、見つけた人にとって VPS 上のシェルになります。リモート認証がまだ実装されていないため、バインド先をループバックに固定しているというのがメンテナーの説明です。移動を試す前に、起動出力に表示される 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 はノート PC の 3080 番ポートを開き、そこに到着した通信を、VPS 上で解決される 127.0.0.1:3080 へ送ります。-N はリモートコマンドを実行しない指定です。そのため、セッションはトンネルを開いたまま保持するだけです。実行したままにして、ブラウザーで http://127.0.0.1:3080/ を開きます。DeepSeek API key は Settings、Models の順に開いた画面で入力します。ワークスペースのディレクトリもここで選択します。ワークスペースには /var/lib/dsh/workspace を指定してください。これはサービスユーザーが所有するディレクトリです。そうしないと、エージェントのファイルツールが EACCES: permission denied で失敗します。
ノート PC の 3080 番ポートが使用中の場合、ssh は次のように通知します。
bind [127.0.0.1]:3080: Address already in use
channel_setup_fwd_listener_tcpip: cannot listen to port: 3080ssh -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 がコマンド全体になります。このトンネルがエージェントへの唯一の入口です。そのため、エージェントを保護するのは SSH デーモンです。鍵のみを使用し、パスワード認証は無効にしてください。その他の VPS の SSH を強化する方法も、通常以上に重要になります。VPS がデータベースやステージング用ホストを背後に持つ小規模なプライベートネットワークへ発展した場合は、サブネットルーターでそれらのアドレスを tailnet に広告する方法を使うと、サービスごとの転送が不要になります。ただし、dsh はループバックにバインドされるため、UI 自体は引き続きトンネル経由でアクセスします。
key を unit ファイルに記述してはいけません。Environment= の値は、どのユーザーでも実行できる systemctl show dsh -p Environment が表示します。インストールしたプラグインが環境変数に key を必要とする場合は、/etc/dsh.env に mode 600 を設定し、所有者を root にして保存します。そのうえで、EnvironmentFile=/etc/dsh.env で参照します。systemd は実行時に root としてそのファイルを読み込み、systemctl show は内容を表示しません。各設定がディスク上のどのファイルに保存されるか、また DeepSeek の API ではなくローカルの Ollama endpoint を dsh に指定したときに何がマシン外へ送信されるかについては、dsh の key、model、endpoint を設定する方法で説明します。
実行コスト
推論は VPS 上ではなく、DeepSeek の API で実行されます。サーバーで負担するのは、Node プロセス、配信する UI、そしてエージェントが実行する各コマンドです。最初の 2 つは負荷が安定しており、小さいものです。3 つ目には、この unit file による制限がありません。
他人の環境で測った数値を信用せず、自分のサーバーで最低限の負荷を測定します。
systemctl show dsh -p MemoryCurrent
systemd-cgtop -1 --depth 2MemoryCurrent の単位は bytes です。アイドル中ではなく、エージェントの処理中に監視してください。
Tool call はサービスの子プロセスなので、同じ control group に入り、同じ制限の対象になります。エージェントが npm install や workspace 内の test suite を実行すると、harness 自体を大幅に上回るメモリを使用することがあります。1 GB の VPS で問題になるのはこの状況です。kernel がプロセスを 1 つ選んで kill し、journalctl -k | grep -i "out of memory" には選択されたプロセスを示す Out of memory: Killed process 行が表示されます。そのプロセスが問題の原因とは限りません。
意図的に limit を設定して対処します。[Service] section の MemoryMax= と CPUQuota= により、影響を unit 内に収められます。そのため、暴走した build を kill でき、サーバー全体が停止する事態を防げます。systemd でメモリと CPU に上限を設定するでは、数値と障害発生時の動作を説明します。DSH_HOME 配下の session history や、エージェントが workspace に書き込むデータによって disk 使用量も増加します。そのため、普段使用している disk 監視の仕組みに du -sh /var/lib/dsh を追加してください。
接続と切断を繰り返す interactive agent が必要なら、service は適した構成ではありません。永続的な tmux session で agent を実行する構成の方が適しています。常時起動し、tunnel 経由で到達可能にしたい場合は、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. unit が起動回数のレート制限に達し、起動を断念しました。本当のエラーは、その前の行にあります。再試行する前に sudo systemctl reset-failed dsh を実行します。
Unit は active (running) ですが、ブラウザーに何も表示されません。 VPS 上で確認コマンドを実行します。そこで curl -fsS http://127.0.0.1:3080/ -o /dev/null && echo up が up を出力する場合、サービスは正常であり、問題はポート転送にあります。
意図的にアップグレードする
バージョンを固定すると、アップグレードを受け身で行うのではなく、計画して実施できます。まずリリースノートを読みます。互換性を損なう変更について upstream が示す警告が、バージョンを固定する理由そのものだからです。state directory をバックアップしてから、バージョンを切り替えます。
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 を復元するだけです。ただし、事前に tarball を取得している場合に限ります。preview-stage の agent runtime は、アップグレードによって設定形式が書き換えられる可能性がまさに高いソフトウェアです。
FAQ
dsh は SSH セッションを閉じると、なぜ停止するのですか?
npx @deepseek-ai/dsh web はログインセッションが所有するフォアグラウンドプロセスだからです。そのため、セッションが終了するとプロセスも終了します。一方、systemd unit は 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 では、同じ間違いでも成功として返り、journal に埋もれます。Type=forking と Type=notify は、この用途ではどちらも誤りです。どちらも TimeoutStartSec が 90 秒後に切れるまでハングします。
ノート PC から dsh の Web UI を開くにはどうすればよいですか?
SSH 経由でポートを転送します。ssh -N -L 3080:127.0.0.1:3080 you@your-vps を実行し、その後ブラウザーで http://127.0.0.1:3080/ を開きます。サービスをパブリックアドレスに bind しないでください。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 コマンドを実行できる一方、その前段にリモート認証がないためです。
権限を簡単にするため、dsh を root として実行できますか?
いいえ。harness はコマンドの実行とファイルの書き込みを担うため、サービスに付与した権限はそのままエージェントの権限になります。useradd --system --shell /usr/sbin/nologin dsh で system account を作成し、/var/lib/dsh をそのアカウントの所有に変更して、unit に NoNewPrivileges=true を追加してください。その後に EACCES: permission denied が発生した場合、通常の原因は、以前 root で実行した際に root 所有のファイルが残っていることです。sudo chown -R dsh:dsh /var/lib/dsh で解消できます。
unit では dsh のどのバージョンを固定すべきですか?
サービスを設定した時点で npm view @deepseek-ai/dsh version が報告するバージョンを固定してください。npm install -g @deepseek-ai/dsh@<that version> でインストールし、後から確認できる場所に記録します。0.1.0-rc.7 は 18 August 2026 時点の現行バージョンでした。重要なのは番号ではありません。バージョンを指定しない npx は起動時にパッケージを解決するため、無人再起動によって、異なる設定形式の build に気付かないまま移行する可能性があります。