VPSでdshをsystemdでヘッドレス実行する方法
VPSでdshをsystemdサービスとして常駐させる手順です。専用ユーザー、バージョン固定、Restart設定、journalctlのログ確認、SSHトンネルでUIへ接続する方法を解説します。
VPS のターミナルではなく、ヘッドレスで dsh を実行する
VPS で dsh をヘッドレス実行するには、systemd の unit ファイルと、それを所有する専用ユーザーが必要です。dsh は DeepSeek Harness(DeepSeek のエージェントランタイム)をコマンドラインから起動するランチャーです。DeepSeek Harness は MIT ライセンスで公開され、2026 年 8 月に developer preview としてリリースされました。クイックスタートでは 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 にパイプすると、内容を確認していないコードが実行されるためです。
ユニットを書く前に動作を確認する
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 プロファイルがループバックインターフェースで待ち受けていることを示します。これはデフォルトのバインド先です。curl: (7) Failed to connect to 127.0.0.1 port 3080: Connection refused は、待ち受けていないことを示します。最初のターミナルに理由が表示されています。先に進む前に Ctrl+C で手動実行を停止してください。すでに別のプロセスが使用しているポートにバインドしようとするユニットは、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 は unit file 内で使うには適切ではありません。プロセスの起動時にパッケージのバージョンを解決するため、3 か月後に再起動すると、こちらで何も変更していなくても preview-stage agent の別のビルドが起動する可能性があります。また、ブート時に npm registry へ到達できる必要があります。そのため、registry の応答が遅い日には、正常に動作していたマシンが 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/dshcommand -v dsh は、npm が NodeSource からインストールされている場合は /usr/bin/dsh を出力し、Ubuntu 独自のパッケージからインストールされている場合は /usr/local/bin/dsh を出力します。unit file には、実際に出力されたパスを指定してください。npm ls -g は正確なバージョンを出力します。6 週間後に動作が変わり、何をインストールしたか思い出せなくなったときに必要な情報です。
サービス専用で、ほかには何も所有しないユーザー
エージェントはシェルコマンドを実行します。それが役割です。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 がその値をどのように扱うかに依存してはいけません。非ログインコマンドで 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 が実行される前に status=200/CHDIR でユニットが失敗します。
ProtectHome=true はプロセスから /home と /root を隠します。このサービスが操作するすべての対象は /var/lib/dsh 以下にあるため、ここでは安全です。ワークスペースに /home 以下のパスを指定すると、エージェントはそのディレクトリが存在しないと報告します。この行を思い出すまで、原因が分かりにくいエラーになります。ProtectSystem=full により、/usr、/boot、/etc は読み取り専用になります。このサービスがこれらへ書き込む必要はありません。
さらに制限したくなりますが、通常は適切ではありません。ProtectSystem=strict は、kernel の pseudo-filesystem を除くファイルシステム全体を読み取り専用にするため、最初にファイルを書き込むツール呼び出しが 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は READY=1メッセージを sd_notify経由で待機します。メッセージを送信しない Node プロセスでは、同じように処理が停止します。systemd のサービスタイプの詳しい比較では、notifyを設定する価値があるケースなど、残りの内容を説明しています。
大きなエラーとして扱う再起動ルール
Restart=on-failure は、終了ステータスが 0 以外の場合、または致命的なシグナルで終了した場合に再起動し、正常終了した場合は unit をそのまま停止します。これは preview build に適した動作です。dsh が、受け入れられない設定を読み込んだにもかかわらず 0 で終了した場合、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 は次回の再起動後には無効になり、kernel の更新では再起動が必要になります。
systemctl status dshの結果には、Active: active (running)、Main PID、Memory:の行が表示されます。続いて、待ち受け先を確認します。
sudo ss -lntp | grep 3080127.0.0.1:3080になっていることを確認します。0.0.0.0:3080と表示された場合、何らかの設定変更によって bind address が書き換えられており、agent が public internet 上で公開されています。その出力に表示されるプロセス名は node であり、dsh ではありません。dsh binary は Node script であるため、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 -1Specifying 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(アプリケーションプログラミングインターフェース)が agent を操作し、agent はシェルコマンドを実行します。そのため、到達可能なポートは、見つけた人にとって VPS 上のシェルになります。maintainer は、リモート認証が未実装であることを、bind 先を loopback に固定している理由として説明しています。代わりに、自分のマシンからポートを転送します。
ssh -N -L 3080:127.0.0.1:3080 you@203.0.113.10-L 3080:127.0.0.1:3080 により laptop 上の port 3080 が開き、そこに到着したすべての通信が、VPS 上で解決された 127.0.0.1:3080 へ送られます。-N はリモートコマンドを実行しない指定です。そのため、セッションはトンネルを開いたまま保持するだけです。これを実行したままにして、ブラウザーで http://127.0.0.1:3080/ を開きます。ここで DeepSeek API key を Settings、Models の順に開いて入力し、workspace directory も指定します。workspace には /var/lib/dsh/workspace を指定してください。これは service user が所有するディレクトリです。指定しないと、agent の file tools が EACCES: permission denied で失敗します。
laptop で port 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 で別の local port を指定し、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 への唯一の入口になります。そのため、agent を保護するのは SSH daemon です。鍵認証のみを使用し、password authentication は無効にしてください。さらに、VPS の SSH を hardening するための対策を、通常以上に厳格に適用します。
key を unit file に記述してはいけません。Environment= の値は systemctl show dsh -p Environment で出力され、box 上のすべての user が実行できます。インストールした plugin が環境変数に key を必要とする場合は、/etc/dsh.env に mode 600 で保存し、root が所有するようにして、EnvironmentFile=/etc/dsh.env で参照します。systemd は exec 時に root としてそのファイルを読み込み、systemctl show は内容を出力しません。
実行コスト
推論は VPS 上ではなく、DeepSeek の API で実行されます。VPS で動作するのは Node プロセス、提供する UI、そしてエージェントが実行するすべてのコマンドです。最初の 2 つは安定しており、負荷も小さいものです。3 つ目には、この unit ファイルによる上限がありません。
他の環境の数値をそのまま信じず、手元の環境で最低限の使用量を測定してください。
systemctl show dsh -p MemoryCurrent
systemd-cgtop -1 --depth 2MemoryCurrent の単位は bytes です。アイドル中ではなく、エージェントの実行中に値を監視してください。
ツール呼び出しはサービスの子プロセスとして実行されるため、同じ control group に入り、同じ制限の対象になります。エージェントが npm install や workspace 内のテストスイートを実行すると、harness 自体を大幅に上回るメモリを使用することがあります。1 GB VPS では、ここで問題が発生します。kernel がプロセスを 1 つ選んで強制終了し、journalctl -k | grep -i "out of memory" に、選択されたプロセスを示す Out of memory: Killed process 行が表示されます。問題の原因となったプロセスが選ばれるとは限りません。
意図的に設定した limit で対処します。[Service] セクションの MemoryMax= と CPUQuota= により、影響を unit 内に抑えられます。そのため、暴走した build は強制終了され、VPS 全体が応答しなくなる事態を防げます。systemd でメモリと CPU に上限を設定するでは、数値と障害発生時の動作を説明します。DSH_HOME に保存される session history と、エージェントが workspace に書き込むデータによってディスク使用量も増加します。そのため、普段使用しているディスク監視の仕組みに du -sh /var/lib/dsh も追加してください。
接続と切断を繰り返す対話型エージェントが必要なら、service は適した形ではありません。その場合は、永続的な tmux セッションでエージェントを実行する構成が適しています。常時起動し、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 is active (running) だが、ブラウザーに何も表示されない場合。 VPS 上で確認コマンドを実行します。そこで curl -fsS http://127.0.0.1:3080/ -o /dev/null && echo up が up を出力するなら、サービスは正常であり、問題はポートフォワーディングにあります。
意図的なアップグレード
Pinning により、アップグレードを自分で実施するのか、予期せず発生するのかを管理できます。まずリリースノートを読みます。upstream が互換性を損なう変更について警告しているため、Pinning を行います。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 によってエージェントがシェルコマンドを実行でき、手前にリモート認証がないためです。
権限を単純に保つため、dsh を root として実行できますか?
いいえ。harness はコマンドの実行とファイルの書き込みを行うため、サービスが持つ権限をエージェントも持つことになります。useradd --system --shell /usr/sbin/nologin dsh で system account を作成し、/var/lib/dsh をそのアカウントの所有に変更して、NoNewPrivileges=true を unit に追加します。その後 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 に気付かないまま移行する可能性があります。