DeepSeek HarnessをDockerで動かす:ホスト隔離と3つの罠
DeepSeekが2026年8月13日にMITライセンスで公開したエージェントハーネス「DeepSeek Harness(dsh)」を、Dockerコンテナで安全に動かすまでの実践記録。エージェントが触れるディレクトリを1つに限定する隔離設計と、実際に踏んだnode-ptyビルド失敗・--host 0.0.0.0拒否・tmpfsのnoexecという3つの罠、その原因特定と解決策をすべて公開します。
DeepSeekが2026年8月13日、自社のエージェントハーネス「DeepSeek Harness(dsh)」をMITライセンスで公開しました。npx @deepseek-ai/dsh web の一行で起動するお手軽さが目を引きますが、これはファイルを読み書きし、コマンドを実行するコーディングエージェントです。素のPCにそのまま入れる前に、まずコンテナへ閉じ込めたい——そう考えるのは自然でしょう。
本記事は、Windows 11 + Docker Desktop環境でdshをコンテナ化し、エージェントが触れるホストのディレクトリを1つだけに限定するまで実際に手を動かした記録です。公式リポジトリのルートにはDockerfileもdocker-composeもdevcontainerも見当たらないため一から書きましたが、道中で3つの罠を踏みました。どれも「エラーメッセージから真因が推測しづらい」たちの悪い部類だったので、原因を特定するまでの切り分け手順ごと共有します。
DeepSeek Harnessとは何か
DeepSeek Harnessは、Cordisカーネルのプラグインシステム上に構築されたエージェントフレームワークです。設計思想は「すべてがプラグイン」。モデル、ツール、スキル、セッション、サンドボックス、ストレージ、ループ、スケジューリング、そしてUIまで、あらゆる能力がプラグインとして差し替え・再構成できます。永続的なbashとstr_replace_editorを持つ2ツール構成のコーディングエージェントが同梱されており、Web UIから操作できます。
実体はNode.jsのpnpmモノレポで、npmパッケージは@deepseek-ai/dsh。執筆時点の最新は0.1.0-rc.6です。起動方法は2通り用意されています。
# npm から
npx @deepseek-ai/dsh web
# ソースから
git clone https://github.com/deepseek-ai/deepseek-harness.git
cd deepseek-harness && pnpm install && pnpm run build && pnpm dsh web
今回はコンテナに載せるだけなので、モノレポ全体をビルドする必要のないnpm配布物を採用しました。
重要なのは、これがまだdeveloper previewであるという点です。リポジトリには互換性を壊す変更が入ると明記されています。バージョンは必ず固定して使うべきでしょう。
事前調査でわかった要件
手を動かす前に、公式ドキュメントとリポジトリから前提条件を洗い出しました。ここを押さえておかないとDockerfileが書けません。
| 項目 | 内容 |
|---|---|
| Node.js | ^22.19.0 \|\| >=24.0.0(CIは22.19 / 24 / 26をカバー) |
| pnpm | 11.7.0固定。corepack enableで有効化 |
| Git | 2.26以上 |
| Web UI | 既定でhttp://127.0.0.1:3080 |
| 状態の保存先 | DSH_HOME(未設定時は~/.dsh) |
特に注意が要るのがpnpmです。開発時だけの依存に見えますが、dshはプロファイルの初期化やプラグイン追加を内部的にpnpmへ委譲するため、実行環境にもpnpmが必要になります。webとheadlessのプロファイルは初回使用時にテンプレートから自動初期化され、そのタイミングでpnpmが依存を取得します。つまり初回起動にはネットワークも必要です。
DSH_HOME配下の構成も把握しておきます。
$DSH_HOME/profiles/<name>/— プロファイル本体(package.jsonとcordis.patch.yml)$DSH_HOME/.credentials.yaml— モデルの資格情報$DSH_HOME/cordis.patch.yml— ホームレベルの設定$DSH_HOME/.env— 環境変数
CLIのフラグと環境変数も確認しました。dsh webが受け付けるのは--host、--port、--trusted-hostの3つ。環境変数にはDSH_HOME、DSH_PERMISSION_MODE、DSH_TOOLS_MODE、DSH_TELEMETRY_DISABLED、NODE_USE_ENV_PROXYなどがあり、APIキーはDEEPSEEK_API_KEY(必要ならDEEPSEEK_BASE_URL)で渡せます。
なぜコンテナに閉じ込めるのか
dshには内蔵のサンドボックス機構があります。Linuxではまずbwrap(Bubblewrap)を試し、使えない場合はLandlock UAPIを使うネイティブバイナリlandlock-runにフォールバックする設計です。WindowsではWRITE_RESTRICTEDな制限付きトークンを使います。
ただし公式の解説によれば、bwrapは「非特権user namespaceが無効化された最小コンテナ」では動作せず、landlock-run側も古いカーネルでは全操作を制御しきれずpartialと報告されます。コンテナ内で動かす前提だと、この内蔵サンドボックスは十分に効かない可能性が高いわけです。
つまり内蔵サンドボックスは「あれば嬉しい」層であって、単体に信頼を預ける設計にはしないほうがよい。ならば隔離境界はコンテナそのものに担わせるのが素直です。方針は明快で、バインドマウントするホストディレクトリを1つに絞り、それ以外は一切見せないことです。
Dockerfileを書く
ベースイメージはnode:22-bookworm-slimを選びました。Alpineを避けたのは、landlock-runがC言語で書かれたバイナリであるためglibc環境のほうが安全だからです。
FROM node:22-bookworm-slim
ARG DSH_VERSION=0.1.0-rc.6
# git: エージェントが使う / ripgrep: コード検索
# bubblewrap: サンドボックス第1候補 / socat: 後述のポート中継
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates git ripgrep bubblewrap socat \
&& rm -rf /var/lib/apt/lists/*
# pnpm をイメージに焼き込む。COREPACK_HOME を固定パスに置き実行時DLを避ける
ENV COREPACK_HOME=/opt/corepack COREPACK_ENABLE_DOWNLOAD_PROMPT=0
RUN corepack enable && corepack prepare [email protected] --activate \
&& chmod -R a+rX /opt/corepack
# npx と同じ配布物を焼き込む。node-pty のビルドツールは同一レイヤで撤去
RUN apt-get update \
&& apt-get install -y --no-install-recommends python3 make g++ \
&& npm install -g @deepseek-ai/dsh@${DSH_VERSION} \
&& npm cache clean --force \
&& apt-get purge -y --auto-remove python3 make g++ \
&& rm -rf /var/lib/apt/lists/*
# 永続データを /data に集約し、named volume 1本で保存できるようにする
ENV HOME=/data DSH_HOME=/data/dsh PNPM_HOME=/data/pnpm \
XDG_CACHE_HOME=/data/cache XDG_CONFIG_HOME=/data/config \
XDG_DATA_HOME=/data/share NPM_CONFIG_UPDATE_NOTIFIER=false
RUN mkdir -p /data /workspace && chown -R node:node /data /workspace
USER node
WORKDIR /workspace
EXPOSE 3081
HEALTHCHECK --interval=30s --timeout=5s --start-period=180s --retries=5 \
CMD node -e "fetch('http://127.0.0.1:3080/').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
CMD ["sh", "-c", "socat TCP-LISTEN:3081,fork,reuseaddr TCP:127.0.0.1:3080 & exec dsh web --host 127.0.0.1 --port 3080"]
いくつか意図を補足します。
npxではなくnpm install -gでイメージに焼き込んでいるのは、起動のたびにパッケージを取得しないためです。配布物は同じものなので、実質的に「npx方式をキャッシュした」状態になります。
corepackで導入したpnpmはCOREPACK_HOMEのキャッシュに実体が入ります。既定では~/.cache/node/corepackですが、ビルド時のHOMEはrootの/root、実行時のHOMEは/dataと食い違うため、固定パス/opt/corepackに置いて全ユーザーから読めるようにしました。
状態の永続化にも注意が必要です。DSH_HOMEを永続化しないと、APIキーもインストール済みプラグインも起動のたびに消えます。そこでHOME=/dataとしてpnpm storeや各種キャッシュごと/dataに集約し、名前付きボリューム1本で保存する形にしました。
ヘルスチェックのstart-periodを180秒と長めに取っているのは、初回起動時にプロファイルの自動初期化(pnpmによる依存取得)が走るためです。
docker-compose.ymlを書く
services:
dsh:
build:
context: .
args:
DSH_VERSION: ${DSH_VERSION:-0.1.0-rc.6}
image: dsh-harness:local
container_name: dsh
init: true
user: "node"
working_dir: /workspace
# ホストの 127.0.0.1 のみに公開(LANからは到達不可)
ports:
- "127.0.0.1:3080:3081"
volumes:
- ./workspace:/workspace # エージェントに触らせる唯一のホストディレクトリ
- dsh-home:/data # DSH_HOME の永続化(ホストFSではなくvolume)
env_file:
- .env
environment:
DSH_TELEMETRY_DISABLED: "1"
# docker.sock は絶対にマウントしない
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
tmpfs:
- /tmp:size=512m,exec # exec が必須(後述)
restart: unless-stopped
volumes:
dsh-home:
ポート公開を127.0.0.1:3080:3081という一見ちぐはぐな形にしている理由と、tmpfsにexecを付けている理由は、次章の罠2・罠3で説明します。
踏んだ3つの罠
罠1:node-ptyのネイティブビルドが落ちる
最初のビルドはあっさり失敗しました。
npm error gyp ERR! cwd /usr/local/lib/node_modules/@deepseek-ai/dsh/node_modules/node-pty
npm error gyp ERR! node-gyp -v v11.5.0
npm error gyp ERR! not ok
依存のnode-ptyにこの環境向けのプリビルドが無く、node-gypがソースからのビルドを試みますが、slimイメージにはpython3もmakeもg++もありません。
解決は単純で、ビルドツールを導入してからインストールし、同じRUNレイヤ内でapt-get purge --auto-removeにより撤去します。マルチステージビルドでも実現できますが、グローバルインストールの配置パスを正確に写す必要があるぶん、この方法のほうが確実でした。撤去してもlibstdc++6のような実行時ライブラリは残るため、ビルド済みの.nodeバイナリは問題なく動作します。
罠2:--host 0.0.0.0は仕様として拒否される
コンテナ内でサービスを公開する定石は0.0.0.0へのバインドです。そのつもりでCMDを書いたところ、コンテナが再起動ループに入りました。ログを見ると理由は明快でした。
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
「ネットワークにリモートコード実行を晒すことになるため、意図的にサポートしていない」。エージェントハーネスの性格を考えれば妥当な判断です。dsh web --helpを確認しても代替フラグは無く、あるのは--host / --port / --trusted-hostの3つだけでした。
そこで発想を変え、dsh本体は127.0.0.1:3080のまま動かし、socatで0.0.0.0:3081へ中継してpublishする構成にしました。
socat TCP-LISTEN:3081,fork,reuseaddr TCP:127.0.0.1:3080 & exec dsh web --host 127.0.0.1 --port 3080
ここでポート番号の対応にひと工夫あります。ホスト側を3080で公開しておくと、ブラウザが送るHostヘッダは127.0.0.1:3080となり、dsh自身が想定するオーソリティと一致します。結果として/apiのbrowser-trust fenceをそのまま通過し、--trusted-hostの追加設定は不要でした。ホスト側を別ポートにすると、この整合が崩れて追加設定が要るはずです。
罠3:tmpfsの既定noexecでHMRプラグインが落ちる
最も時間を取られたのがこれです。ハードニングのつもりでtmpfs: /tmpを足したところ、起動時にこう落ちました。
Error: failed to apply loader entry 1d16b85e (@deepseek-ai/cordis-plugin-hmr):
--expose-internals is required for HMR service
[cause]: Error: --expose-internals is required for HMR service
at new Hmr (.../cordis-plugin-hmr/lib/index.js:107:40)
一見、Nodeの起動フラグが足りないだけに読めます。しかしNODE_OPTIONSで渡そうとすると、Node自身に拒否されます。
node: --expose-internals is not allowed in NODE_OPTIONS
さらに紛らわしいことに、合成後の設定をdsh --profile web --dump-configで確認すると、当のHMRプラグインはdisabled: trueになっていました。設定上は無効なのに、実行時には別IDのエントリとして生成されて落ちている。ここで設定ファイルを追いかけるのをやめ、環境要因の切り分けに切り替えました。
docker runで要因を1つずつ足していきます。
| 条件 | 結果 |
|---|---|
| 素の実行(フラグ・マウントなし) | 起動する |
--host 127.0.0.1 --port 3080を明示 |
起動する |
| workspaceのバインドマウントのみ | 起動する |
--cap-drop ALL + no-new-privilegesのみ |
起動する |
--initのみ |
起動する |
DSH_TELEMETRY_DISABLED=1のみ |
起動する |
--tmpfs /tmp:size=512mのみ |
落ちる |
--tmpfs /tmp:size=512m,exec |
起動する |
犯人はtmpfsでした。Dockerのtmpfsは既定でnoexecが付きます。dshは/tmpから実行ファイルを起動するため、これに阻まれていたわけです。,execを付けるだけで解決しました。
tmpfs:
- /tmp:size=512m,exec
エラー文面と真因がここまで離れているケースは珍しく、「Nodeのフラグ問題」と思い込むと延々と迷います。もし同じエラーに遭遇したら、まず/tmpのマウントオプションを疑ってください。なお、この切り分けの過程で「壊れたプロファイルが原因では」と一度誤った仮説を立てましたが、ボリュームを作り直しても再現したことで否定できました。変数を1つずつ動かすという基本に立ち返るのが、結局は最短でした。
試行の時系列:何を試し、どこで転んだか
ここまでの経緯を時系列で並べると、実際の作業がどう進んだかが見えやすくなります。
| # | 試したこと | 結果 |
|---|---|---|
| 1 | 公式ドキュメントとリポジトリを調査 | Dockerfile・compose・devcontainerのいずれも無いと判明。自作を決定 |
| 2 | node:22-bookworm-slimにnpm install -g @deepseek-ai/dsh |
❌ node-ptyのnode-gypビルドが失敗 |
| 3 | python3 make g++を同一レイヤで導入・撤去 |
✅ ビルド成功 |
| 4 | dsh web --host 0.0.0.0で起動 |
❌ 安全上の理由で拒否され再起動ループ |
| 5 | dsh web --helpで代替フラグを探す |
代替なし(--host / --port / --trusted-hostのみ) |
| 6 | socatで127.0.0.1:3080→0.0.0.0:3081を中継 |
✅ 到達性は確保。ただし別のエラーで起動失敗 |
| 7 | HMRの--expose-internalsエラーをNODE_OPTIONSで回避 |
❌ Node自身が当該フラグを拒否 |
| 8 | --dump-configで合成設定を確認 |
HMRはdisabled: true。設定追跡を断念し環境要因の切り分けへ |
| 9 | 「壊れたプロファイルが原因」と仮説を立てvolumeを作り直し | ❌ 再現。仮説は誤りと判明 |
| 10 | docker runで要因を1つずつ追加(前掲の表) |
✅ tmpfsのnoexecが真因と特定 |
| 11 | tmpfs: /tmp:size=512m,execに修正して再起動 |
✅ Up (healthy)。ホストからHTTP 200 |
振り返ると、9番の遠回りが一番もったいない箇所でした。「直前に失敗ループを起こしていたから状態が壊れたのだろう」というもっともらしい仮説に飛びついた結果、切り分けが一手遅れています。エラーの直前に起きた出来事は、必ずしも原因ではありません。
隔離設計:何を守り、何を諦めるか
最終構成でエージェントがホスト側に触れるのは./workspaceただ1つです。DSH_HOMEは名前付きボリュームに逃がしてあるため、APIキーやプラグインは永続化されつつホストのファイルシステムは汚れません。実行ユーザーは非rootのnode、cap_drop: ALLとno-new-privilegesを適用し、ポートはホストの127.0.0.1にのみ公開してLANからは到達不可としています。
逆に、やってはいけないことも明確です。
/var/run/docker.sockのマウント — ホスト全体を掌握できてしまい、隔離が完全に無意味になります./workspace以外のホストディレクトリをvolumesに追加することnetwork_mode: hostにすること
諦める点も正直に書いておきます。cap_drop: ALLとno-new-privilegesの下ではbwrapは動きません。dsh内蔵のサンドボックスは効かないかpartial止まりになります。これは二重の防御を捨てる判断ですが、境界をコンテナに一本化したうえで外側を厚くするほうが、構成として理解しやすいと考えました。防御層を増やすこと自体より、「どこが境界か」を1つに定めて説明できることのほうが、運用上は重要です。
起動確認と初期設定
docker compose up -d --build
docker compose psがUp (healthy)になり、ホストからのHTTPリクエストが200を返せば成功です。手元ではWeb UIのHTMLが12,109バイト返ってきました。
ブラウザでhttp://127.0.0.1:3080を開いたあとの手順は次の2つです。
- Settings → ModelsでDeepSeek APIキーを登録する。保存するだけで即座に反映され、サーバーの再起動は不要です。なお筆者の環境では、DeepSeek APIキーを直接登録する代わりに、LiteLLMをカスタムプロバイダーとして登録しました。dshのモデル設定はプラグインとして差し替え可能なので、OpenAI互換のエンドポイントを立てていればそちらを向けられます。
- Choose workspaceで
/workspaceを追加して選択する。ここで指定するのはコンテナ内のパスで、C:\...のようなホストパスではありません。
ワークスペースを選択するまでセッションのコンポーザは使えない仕様なので、「UIは開いたのに何も入力できない」と戸惑ったらこの手順を確認してください。
最終的な着地点
最後に、どこへ着地したかを整理しておきます。
構成
- ベースは
node:22-bookworm-slim。pnpm 11.7.0と@deepseek-ai/[email protected]をイメージに焼き込み、起動のたびに取得しない - dsh本体は
127.0.0.1:3080で待ち受け、socatが3081へ中継。ホスト側は127.0.0.1:3080にのみ公開 - ホストのバインドマウントは
./workspaceの1本のみ DSH_HOMEを含む状態は名前付きボリューム/dataに集約- 非rootの
nodeユーザー、cap_drop: ALL、no-new-privileges、tmpfs /tmpはexec付き
確認できたこと
docker compose psがUp (healthy)- ホストから
http://127.0.0.1:3080へのリクエストがHTTP 200を返す - Web UIからのログインとワークスペース選択が可能
残した課題
- dsh内蔵のサンドボックス(bwrap / Landlock)は現構成では効かないか
partial止まり。隔離はコンテナ境界に一本化している landlock-runのプリビルド有無やLandlockの具体的なカーネル要件は未検証- developer previewのため、バージョンは
.envのDSH_VERSIONで固定して運用する前提
「とりあえず動く」から「触らせる範囲を説明できる」までを、一つの構成に収められたのが今回の成果です。
影響・今後
エージェントハーネスがオープンソース化され、モデルもサンドボックスもストレージもプラグイン単位で差し替え可能になったことの意味は小さくありません。交換可能性が上がるほど、「どのモデルを使うか」より「どの実行環境と、どの権限で走らせるか」が設計の主戦場になります。今回の作業でも、時間の大半はdshの機能ではなく、隔離とポートと権限の調整に費やされました。
一方で、developer previewという性格上、プロファイルの構造やプラグインのAPIは今後も変わります。バージョンを固定し、状態を1つのボリュームに集約しておく構成は、破壊的変更に追随するうえでも効いてくるはずです。--host 0.0.0.0を拒否する判断に象徴されるように、dsh自身がエージェント実行のリスクに対して保守的な姿勢を取っている点は、運用側としてはむしろ好ましいと感じました。
よくある質問
DeepSeek Harnessを動かすのにDockerは必須ですか
必須ではありません。Node.jsをインストールしてnpx @deepseek-ai/dsh webを実行すれば、http://127.0.0.1:3080でWeb UIが起動します。ただしdshはファイルの読み書きとコマンド実行を行うコーディングエージェントであり、内蔵サンドボックスも環境によってはpartialまでしか効きません。触らせたくないファイルがあるなら、コンテナに閉じ込めて作業ディレクトリを限定するほうが安全です。
エージェントがPC上の他のディレクトリを触ることはありませんか
コンテナ構成では、バインドマウントしたディレクトリ以外はコンテナから見えないため触れません。この隔離を壊す代表例が/var/run/docker.sockのマウントで、これを許すとホスト全体を操作できてしまいます。他のホストディレクトリをvolumesに追加することや、network_mode: hostも同じ理由で避けるべきです。
APIキーは毎回入力し直す必要がありますか
DSH_HOMEを永続化していれば不要です。dshはモデルの資格情報を$DSH_HOME/.credentials.yamlに保存し、未設定時のDSH_HOMEは~/.dshになります。コンテナではこのディレクトリが揮発するため、名前付きボリュームに載せておく必要があります。永続化していないと、APIキーだけでなく初回起動時にpnpmで導入されたプラグインも毎回消えます。
--expose-internals is required for HMR serviceというエラーが出ます
Nodeの起動フラグの問題に見えますが、/tmpがnoexecでマウントされていることが原因です。Dockerのtmpfsは既定でnoexecが付き、dshは/tmpから実行ファイルを起動するため失敗します。tmpfsの指定にexecを追加してください。NODE_OPTIONS=--expose-internalsはNode自身が拒否するため回避策になりません。
コンテナでWeb UIに接続できません
--host 0.0.0.0でバインドしようとしていないか確認してください。dshはこの指定を安全上の理由で明示的に拒否します。dsh本体は127.0.0.1のまま動かし、socatなどで別ポートへ中継してpublishする必要があります。その際、ホスト側の公開ポートをdshの待ち受けポートと同じ3080に揃えておくと、Hostヘッダが一致してbrowser-trust fenceを追加設定なしで通過できます。
まとめ
DeepSeek Harnessのコンテナ化は、Node 22系のイメージにpnpmとdshを焼き込み、作業ディレクトリだけをバインドマウントし、DSH_HOMEをボリュームに逃がす——という骨格さえ押さえれば難しくありません。詰まるのはむしろ周辺で、node-ptyのビルドツール、--host 0.0.0.0の拒否、tmpfsのnoexecという3点が待ち構えています。いずれもエラーメッセージが真因を素直に指してくれないため、要因を1つずつ足して切り分ける地道な作業が結局は近道でした。
エージェントに実行権限を渡す以上、「どこまで触らせるか」を最初に決めておくことが何より重要です。マウントを1本に絞るというシンプルな方針は、数か月後に設定ファイルを読み返したときにも意図が伝わります。developer previewの荒波に付き合うなら、この程度の慎重さはちょうどよいはずです。
参考ソース
- DeepSeek Harness 公式ページ — https://deepseek.com/harness/en/
- deepseek-ai/deepseek-harness(GitHub) — https://github.com/deepseek-ai/deepseek-harness
- Development Guide(docs/development.md) — https://raw.githubusercontent.com/deepseek-ai/deepseek-harness/master/docs/development.md
- CLI Reference(apps/cli/reference/README.md) — https://github.com/deepseek-ai/deepseek-harness/blob/master/apps/cli/reference/README.md
- Quickstart — https://deepseek-harness.github.io/deepseek-harness/en/guide/quickstart
- DeepWiki: Sandboxing & Security — https://deepwiki.com/deepseek-ai/deepseek-harness/4.3-sandboxing-and-security