chroju.dev/blog

the world as code

Claude CodeをDev Containersで動かすのはとても大変だった

Claude Codeに限らずAI Coding Agentはやはり隔離した上で自律動作させたいのだが、今のところ決定打がないようにも感じている。僕の場合はdevcontainer(Dev Containersなのか何なのか正式表記がわからないが)を使って、だいぶ試行錯誤してある程度形になったので、その顛末を書いておく。

なお、今回の対象はファイルシステムの隔離であり、ネットワークの隔離はしていない。

なぜdevcontainerか

Claude Codeの実行環境を隔離する手段はいくつかある。Claude Code自体にも /sandbox コマンドがあるし、 cage のようなサードパーティツールもある。ただ、macOSの /sandbox にはSecurity.frameworkのTLS検証がブロックされてGo製のCLIツール(gh、terraform、kubectl等)が軒並み壊れるという問題がある。

[BUG] macOS sandbox blocks Security.framework TLS verification, breaking Go binaries (gh, terraform, etc.) · Issue #23416 · anthropics/claude-code
Preflight Checklist I have searched existing issues and this hasn't been reported yet This is a single bug report (please file separate reports for different bugs) I am using the latest version of ...
[BUG] macOS sandbox blocks Security.framework TLS verification, breaking Go binaries (gh, terraform, etc.) · Issue #23416 · anthropics/claude-code favicon https://github.com/anthropics/claude-code/issues/23416
[BUG] macOS sandbox blocks Security.framework TLS verification, breaking Go binaries (gh, terraform, etc.) · Issue #23416 · anthropics/claude-code

SREとしてはこれらのツールが使えないのはかなり痛い。devcontainerを選んだのはそういった制約を回避できるのと、プロジェクトごとに環境を定義して .devcontainer/devcontainer.json としてシンプルに管理できるのがよかった。隔離だけが目的なら他の手段のほうが手軽だが、開発環境の定義と隔離を兼ねられるのがdevcontainerの利点だと思っている。

ただ、素朴に ubuntu:24.04 のコンテナを立ち上げてClaude Codeを入れただけでは、いろいろと足りないものがある。Claude Codeの公式ドキュメントにもdevcontainerのページがあり、リファレンスとなる設定一式が公開されている。

code.claude.com
code.claude.com favicon https://code.claude.com/docs/ja/devcontainer

このリファレンスでは認証の永続化やネットワーク制限までカバーされていて参考になるのだが、自分の環境に合わせてカスタマイズしていくとなると、いくつか考えるべきことがあった。

ちなみに冒頭にも書いた通り、ネットワークの隔離は今回スコープ外なのだが、Claude Codeのリファレンスでは init-firewall.sh という、ネットワーク隔離のためのスクリプトが公開されている。iptablesでClaude Codeが必要とするドメイン以外へのアウトバウンドをブロックする仕組みだが、allowlistに含まれないドメインへのアクセスは全部落ちるので、WebSearchやMCP経由の外部サービス接続も使えなくなる。頑張れたらこのあたりもやっていきたい。

著者の環境

参考として書いておくが、著者の環境はmacOS上のGhosttyでtmux -> devcontainer -> Claude Codeという構成で動かしている。コンテナの実行にはrootlessに動けるPodmanを使っており、devcontainerの管理は devcontainers/cli による。

設計の考え方

devcontainerの設定をしていくと、「コンテナ内で設定するもの」「ホストからファイルとして共有するもの」「ホスト側で動的に取得してコンテナへ渡すもの」の3種類に分かれることに気づく。この分類を意識しておくと設定の見通しが良くなるので、それぞれ書いていく。

コンテナ内で設定するもの

ホストから持ち込む必要がない、コンテナ内だけの設定は主に featurescontainerEnv を使うことになる。

"features": {
  "ghcr.io/devcontainers/features/common-utils:2.5.9": {
    "configureZshAsDefaultShell": true,
    "username": "chroju"
  },
  "ghcr.io/devcontainers/features/git:1.8.12": {},
  "ghcr.io/devcontainers/features/github-cli:1.0.17": {},
  "ghcr.io/anthropics/devcontainer-features/claude-code:1.0.5": {}
},
"containerEnv": {
  "TERM": "xterm-256color",
  "COLORTERM": "truecolor",
  "TZ": "Asia/Tokyo"
}

features はdevcontainer向けのpackageのような仕組みであり、開発に必要なツール群が様々提供されている。Claude Codeについては、ありがたいことにAnthropics公式のfeatureがあるのでこれを活用している。

containerEnv もコンテナ内の設定。タイムゾーンのように静的でコンテナ全体に効かせたい値はここに入れている。似たものに remoteEnv があるが、こちらはシェルセッションにしか適用されない。 TZ みたいな設定はバックグラウンドプロセスにも効いてほしいので containerEnv のほうが適切だと思っている。

またdevcontainer-cliには --dotfiles-repository というオプションがあり、指定したdotfilesリポジトリをコンテナ作成後に自動でクローンして、リポジトリ内の install.sh を実行して設定までしてくれるという仕組みがある。シェルを始め各種設定ファイルの持ち込みや、コマンドによる初期設定はここでやっており、Claude Code featureでインストールされるバージョンは最新とは限らないので、この install.sh の中で npm i -g @anthropic-ai/claude-code を実行して最新版に上書きしたりもしている。コンテナ内の環境を自分好みにする処理はだいたいdotfilesに集約されている。

ホストからファイルとして共有するもの(bind mount)

ホスト側に存在するファイルをコンテナと共有するのがbind mount。ここが今回一番面倒だった。

公式のリファレンスでは ~/.claude ディレクトリ全体をnamed volumeとしてコンテナへマウントしている。設定やセッション履歴がコンテナの再作成をまたいで保持されるので便利な方式だ。

"mounts": [
  "source=claude-code-config-${devcontainerId},target=/home/node/.claude,type=volume"
]

ただ、macOSをホストにしている場合、 ~/.claude の中身をそのまま共有するのは問題がある。Claude Codeの認証情報の保存先はプラットフォームによって異なり、macOSではKeychainに保存されるが、Linuxでは ~/.claude/.credentials.json というファイルに保存される。macOS上にも .credentials.json は存在するのだが、中身は空のまま使われていない。ホストとコンテナで認証方式が異なるので、 ~/.claude を丸ごと共有すると認証まわりで不整合が起きうる。

そこで ~/.claude 全体ではなく、必要なファイルを個別にbind mountする方式にした。最終的にはこういう構成になった。

"mounts": [
  "source=${localEnv:HOME}/.claude/projects,target=/home/user/.claude/projects,type=bind",
  "source=${localEnv:HOME}/.claude/sessions,target=/home/user/.claude/sessions,type=bind",
  "source=${localEnv:HOME}/.claude/.credentials-devcontainer.json,target=/home/user/.claude/.credentials.json,type=bind",
  "source=${localEnv:HOME}/.claude/settings.json,target=/home/user/.claude/settings.json,type=bind,readonly",
  "source=${localEnv:HOME}/.claude/history.jsonl,target=/home/user/.claude/history.jsonl,type=bind",
  "source=${localEnv:HOME}/.claude.devcontainer.json,target=/home/user/.claude.json,type=bind"
]

認証情報については、ホスト側に ~/.claude/.credentials-devcontainer.json というdevcontainer専用のファイルを用意して、コンテナ内の ~/.claude/.credentials.json としてマウントしている。最初にどれか1つのコンテナ内で claude にログインすると認証情報がこのファイルに書き込まれ、bind mountなので以降は別のプロジェクトのdevcontainerでもログイン不要になる。ホスト側の空の .credentials.json とは別ファイルなので、macOS側のClaude Codeと干渉する心配もない。

~/.claude.json に関しては、ホスト側のほうがより制約を厳しく、devcontainer内では緩めにと分けたかったので、devcontainer用に ~/.claude.devcontainer.json という別ファイルをホスト側に作って、コンテナ内では ~/.claude.json としてマウントするようにした。

sessionsの共有には制約がある

sessionsのマウントには注意が必要で、これでホストとコンテナの間でセッションが完全に共有されるわけではない。Claude Codeのセッションはカレントディレクトリの絶対パスに紐づいている。ホスト上では /Users/chroju/project だったパスが、コンテナ内では /workspaces/project になるので、パスが一致せずセッションは引き継がれない。

このマウントの目的は、コンテナを再作成しても会話履歴が消えない(永続化)ことと、同じワークスペースパスを持つ複数のdevcontainer間での共有に限られる。ホストの会話の続きをコンテナで、みたいな使い方はできないので、そこは割り切っている。 workspaceFolder をホスト側と同じパスに設定すれば一致させられるかもしれないが、今のところそこまではしていない。

Podman / SELinux環境への配慮

Podman + SELinux環境だとbind mountがSELinuxのラベル不一致で拒否されるという問題がある。これに対しては runArgs--security-opt label=disable を入れることで対処した。このオプションはDocker Desktopでも無害なので、環境を問わず無条件で付与するようにしている。SSH agent forwardingのマウントに関しても relabel=shared を付けており、これも同じ対策。

ホスト側で動的に取得してコンテナへ渡すもの

GH_TOKEN やSSH agent socketのパスのように、ホスト側の状態に依存する値はbind mountでは渡せない。これには initializeCommand--env-file を組み合わせて対処している。

initializeCommand はコンテナ起動前にホスト側で実行されるコマンド。僕は devcontainer-init というスクリプトを用意して、ここで以下のようなことをやっている。

#!/usr/bin/env bash
set -euo pipefail

echo "GH_TOKEN=$(gh auth token)" > .devcontainer/.env.devcontainer
echo "SSH_AUTH_SOCK=/home/chroju/.ssh-agent.sock" >> .devcontainer/.env.devcontainer

# Podman VM越しにSSH agentをフォワード
if ! pgrep -f "podman machine ssh.*ssh-agent.sock" > /dev/null 2>&1; then
    podman machine ssh -- -R /tmp/ssh-agent.sock:"${SSH_AUTH_SOCK:-}" -N &
fi

# .env.devcontainer.local でプロジェクト固有の環境変数を追加
if [ -f .devcontainer/.env.devcontainer.local ]; then
    cat .devcontainer/.env.devcontainer.local >> .devcontainer/.env.devcontainer
fi

# bind mountに必要なファイルの存在を保証
touch "$HOME/.claude/.credentials-devcontainer.json"
if [ ! -f "$HOME/.claude.devcontainer.json" ]; then
    echo '{"hasCompletedOnboarding": true}' > "$HOME/.claude.devcontainer.json"
fi

gh auth token でホストのGitHub認証トークンを取得して .env.devcontainer に書き出し、 runArgs--env-file でコンテナ起動時に環境変数として注入する。Podmanを使っている場合はVM越しのSSH agent forwardingもここでセットアップしている。

.env.devcontainer.local という仕組みも入れていて、プロジェクト固有の環境変数(APIキーなど)があればここに書いておくと .env.devcontainer にマージされる。 .env.devcontainer 自体は毎回生成し直されるので、gitignoreに入れておけばシークレットがリポジトリに入る心配はない。

また、bind mountのターゲットとなるファイルが存在しないとコンテナの起動に失敗するので、 touch や初期値の書き込みで存在を保証するのもこのスクリプトの役割。

Agent Skillとdotfilesへの集約

ここまで書いたような設定を毎回手で書くのは面倒なので、Agent Skillとしてまとめた。一応汎用的に使えるskillにはなっているはずである。

GitHub - chroju/skills
Contribute to chroju/skills development by creating an account on GitHub.
GitHub - chroju/skills favicon https://github.com/chroju/skills
GitHub - chroju/skills

devcontainerの設定はプロジェクトごとに .devcontainer/devcontainer.json が必要になる。認証情報やSSH鍵のマウントといった共通部分は使い回せるのだが、プロジェクトによって必要なdevcontainer featuresが違う(Node.jsが要るのか、Pythonが要るのか、Terraformが要るのか……)し、JSONをそのままコピーするだけでは済まない。そこでskill化してセットアップしやすくしておいた。このskillと、先述した devcontainer-init を含め、個人的な設定はdotfiles内に整備することで、環境は成り立っている。

余談

今回devcontainerでClaude Codeの隔離を試みたが、最適解だったかはよくわからない。僕は普段からdevcontainerを使っているわけではなかったので、Claude Code用にゼロから生成するこの方式が成り立っているが、すでにdevcontainerがセットアップされたプロジェクトで開発する場合などはちょっと手間になりそうだ。skillを頑張れば、既存設定とmergeして上手いことやらせる、といったことも考えられるかもしれない。