(これは作業内容をLLMにまとめさせたものである)
AI エージェント(Claude Code など)にコードベースの作業を任せるとき、ホストを汚さず、隔離されていて、短時間で作って捨てられる環境があると都合がよい。この記事では、Proxmox を別ホストに立てる構成ではなく、手元の Linux 開発機に直接 Incus(LXC のコンテナ管理基盤)を導入して同等の環境を作る手順を、実際に踏んだ落とし穴と対処を含めてまとめる。
対象は「開発機が Linux で、単独で使う」ケースである。この条件では、別ホストを常時稼働させる固定費が不要になり、ローカルの Incus が費用対効果の面で優位になる。macOS や Windows を開発機にする場合、複数人や複数マシンで環境を共有したい場合、開発機の電源を切ってもエージェントを動かし続けたい場合は、別ホスト構成のほうが向く。
なお、既存の git worktree による隔離とは用途を分ける。信頼できるコードや小さな変更は worktree のほうが速い。素性の分からないコードを動かす、破壊的な作業を試す、ホストを汚したくない、といった場面で Incus の使い捨てコンテナが向く。
- openSUSE Leap 16.0(カーネル 6.12)
- ルートファイルシステムが btrfs
- Docker が同居している(
docker0やbr-*のブリッジが存在する) - ネットワークは IPv6 が中心だが、LAN に IPv4 もある
- コミットの署名と GitHub への push 認証に Yubikey(OpenPGP アプレットの鍵)を使う
環境によって異なる値(Yubikey のシリアル番号、鍵の ID、LAN のリゾルバなど)は、その都度自分の環境の値に読み替えてほしい。
Leap 16.0 の標準リポジトリには Incus が含まれないため、openSUSE Build Service(OBS)のプロジェクトを追加する。incus は lego(ACME クライアント)に依存するので、lego を提供する network:utilities も追加する。distrobuilder(後述のテンプレート作成に使う)は Virtualization:containers に、debootstrap は標準の OSS リポジトリにある。
sudo zypper addrepo -p 100 https://download.opensuse.org/repositories/Virtualization:containers/16.0/ Virtualization:containers
sudo zypper addrepo -p 100 https://download.opensuse.org/repositories/network:utilities/16.0/ network:utilities
sudo zypper --gpg-auto-import-keys refresh
sudo zypper install -y incus incus-tools distrobuilder debootstrap-p 100 は優先度を下げる指定で、これらの追加リポジトリが標準パッケージを勝手に置き換えないようにするためのものである。追加リポジトリは openSUSE 公式のビルド基盤で作られ GPG 署名もされているが、標準リポジトリほどの安定保証はない点は認識しておく。
サービスを起動し、自分を管理グループに追加する。
sudo systemctl enable --now incus
sudo usermod -aG incus-admin "$USER"グループ追加は現在のログインセッションには反映されないので、一度ログインし直すか、新しいシェルで newgrp incus-admin を実行する。
初期化する。ストレージは btrfs のループファイル(root の btrfs や snapper のスナップショットから分離される)を選ぶと、CoW クローンが差分だけを消費して短時間で量産できる。
incus admin init対話では、クラスタリングなし、新規ストレージプール default をバックエンド btrfs で新規作成、ループデバイスのサイズは 50GiB、ローカルブリッジ incusbr0 を作成、ネットワーク待ち受けはなし、を選んだ。
この時点で、incus launch images:debian/12 <名前> でコンテナを起動でき、incus copy による CoW クローンは 1 個あたり 0.1〜0.2 秒で完了する。
Docker が同居していると、コンテナの通信が Docker のファイアウォール設定に妨げられる。実際に 2 つの問題を踏んだ。
症状は「コンテナが IPv6 のアドレスは取得でき、ping も通るのに、DNS だけ失敗する(systemd-resolved が Access denied を返す)」というものだった。
このホストで実際に動作しているファイアウォールは firewalld である。nftables では、あるチェーンで accept しても、同じ入力フックに登録された別テーブルのチェーンも独立に評価され、そこで reject されればそちらの結果になる。Incus は incusbr0 の DNS(udp/53) を許可するチェーンを持つが、firewalld の入力チェーンが incusbr0 を既定ゾーン(public)として扱い、末尾の reject with icmpx admin-prohibited で拒否していた。public ゾーンは末尾で ICMP だけ許可するため、ping は通っていた。
対処は、incusbr0 を firewalld の trusted ゾーンに入れることである。
sudo firewall-cmd --permanent --zone=trusted --change-interface=incusbr0
sudo firewall-cmd --reloadtrusted ゾーンは、そのインターフェースからホストへの通信をすべて許可する。使い捨てかつホスト管理下のコンテナ用のブリッジなので、開発用途では許容できる。ポートを絞りたい場合は専用ゾーンを作る。
DNS を解決できるようになっても、IPv4 の外部通信だけが失敗した(IPv6 は成功する)。github.com は IPv4 のアドレスしか持たない(AAAA レコードがない)ため、これは後の push で問題になる。
原因は、Docker が作る IPv4 の filter テーブルの FORWARD チェーンが policy drop になっていることである。incusbr0 から出る IPv4 の転送パケットはどの Docker 許可ルールにも一致せず、この drop で破棄されていた。IPv6 側の FORWARD は policy accept なので、IPv6 の通信は成功していた。
対処は、Docker が用意する拡張ポイント DOCKER-USER チェーンに、incusbr0 の転送を許可するルールを入れることである。
sudo iptables -I DOCKER-USER -i incusbr0 -j ACCEPT
sudo iptables -I DOCKER-USER -o incusbr0 -j ACCEPTDOCKER-USER は FORWARD チェーンの先頭で評価されるため、ここで accept すると policy drop に到達しない。送信元アドレスの変換(NAT)は Incus が用意する masquerade のルールで行われる。
なお、この iptables のルールと firewalld のゾーン設定は、Docker やホストの再起動で失われる。恒久化するには、起動時に再適用する systemd の oneshot ユニットや firewalld の direct rule を用意する(この記事の作業時点では未実施)。
案件ごとにクローンする元になるテンプレートを、distrobuilder の宣言的な YAML 定義で作る。Incus には Dockerfile のような宣言的なテンプレート形式がないため、コマンドで作り込んで incus publish する方法もあるが、再現性を重視して distrobuilder を採った。
テンプレートに含める要素は次のとおり。
- Debian 12(bookworm、minbase)を土台にする
miseを導入し、node 24.16.0 と ruby 4.0.5 を事前インストールする- pnpm(corepack 経由)と bundler を導入する
- Docker Engine(
docker.io)を導入する(コンテナ内で foobar の compose を動かすため) - secret は一切含めない(RAILS_MASTER_KEY などの実値はコンテナ生成後に外部から注入する)
YAML の要点を抜粋する(鍵の本体は後述)。
image:
distribution: debian
release: bookworm
architecture: amd64
variant: default
source:
downloader: debootstrap
url: http://deb.debian.org/debian
variant: minbase
keys:
- |
-----BEGIN PGP PUBLIC KEY BLOCK-----
...(Debian の archive 鍵の本体)...
-----END PGP PUBLIC KEY BLOCK-----
- |
-----BEGIN PGP PUBLIC KEY BLOCK-----
...(Debian の security archive 鍵の本体)...
-----END PGP PUBLIC KEY BLOCK-----
packages:
manager: apt
update: true
sets:
- packages: [ca-certificates, curl, git, gnupg, build-essential, openssh-client, netcat-openbsd, docker.io, docker-compose]
action: install
- packages: [dbus, systemd-resolved] # ネットワーク管理のため先に導入
action: install
early: true
- packages: [libssl-dev, libyaml-dev, zlib1g-dev, libreadline-dev, libffi-dev, libgdbm-dev, libncurses5-dev, autoconf, bison, pkg-config] # ruby のビルド依存
action: install
files:
- path: /etc/systemd/network/eth0.network
generator: dump
content: |-
[Match]
Name=eth0
[Network]
DHCP=true
[DHCPv4]
UseDomains=true
UseMTU=true
[DHCP]
ClientIdentifier=mac
actions:
- trigger: post-packages
action: |-
#!/bin/sh
set -eu
# mise 本体を導入
curl https://mise.run | MISE_INSTALL_PATH=/usr/local/bin/mise sh
# システム標準の config パスに tools を書く(実行時に環境変数なしで読まれる)
mkdir -p /etc/mise
cat > /etc/mise/config.toml <<'EOF'
[settings]
minimum_release_age = "7d"
[tools]
node = "24.16.0"
ruby = "4.0.5"
EOF
# root 既定のデータディレクトリへインストールし、shim を生成
mise install
mise exec node@24.16.0 -- corepack enable
mise exec ruby@4.0.5 -- gem install bundler
mise reshim
# 生成した shim を既定 PATH に載せる(素の incus exec で使えるようにする)
for s in /root/.local/share/mise/shims/*; do ln -sf "$s" /usr/local/bin/; done
- trigger: post-packages
action: |-
#!/bin/sh
set -eu
systemctl enable systemd-networkd.service systemd-networkd.socket systemd-resolved.service
systemctl enable docker.service
- trigger: post-files
action: |-
#!/bin/sh
set -eu
umount -l /etc/resolv.conf || true
ln -sf /run/systemd/resolve/stub-resolv.conf /etc/resolv.conf最初はフィンガープリントだけを source.keys に書いたが、distrobuilder が停止済みのキーサーバ(hkps.pool.sks-keyservers.net)から鍵を取得しようとして失敗した。対処は、フィンガープリントの代わりに鍵の本体(armored 形式)を YAML に直接埋め込むことである。Debian 公式から鍵を取得する。
curl -fsSL -o archive-key-12.asc https://ftp-master.debian.org/keys/archive-key-12.asc
curl -fsSL -o archive-key-12-security.asc https://ftp-master.debian.org/keys/archive-key-12-security.asc取得した 2 つの鍵の本体を、上の keys: のブロックスカラー(- |)に、それぞれ字下げして貼り付ける。
初回のビルドでは、node と ruby は /opt/mise に事前インストールされていたが、通常の実行時にその場所が参照されず、incus exec <ctr> -- node でツールが見つからなかった。原因は、非既定の MISE_DATA_DIR をビルド時だけ設定していたことである。上の YAML のように、システム標準の /etc/mise/config.toml(mise が既定で読む)に tools を書き、root 既定のデータディレクトリにインストールし、生成した shim を /usr/local/bin に symlink する方式にすると、環境変数なしの実行で解決できる。
また、minbase はネットワーク管理の設定を持たないため、/etc/resolv.conf にビルドホストの値が残り、コンテナが Incus のブリッジの DNS を使えなかった。上の YAML のように systemd-networkd と systemd-resolved の設定と有効化、/etc/resolv.conf のスタブへの symlink を加えることで解決した。
cd /path/to/distrobuilder
sudo rm -rf /tmp/foobar-agent-build
sudo distrobuilder build-incus foobar-agent.yaml /tmp/foobar-agent-build
incus image import /tmp/foobar-agent-build/incus.tar.xz /tmp/foobar-agent-build/rootfs.squashfs --alias foobar-agent-templateruby をソースからコンパイルするので数分かかる。ssh.service does not exist という警告が出るが、これは openssh-server を入れていないためで、この設計ではコンテナへの SSH ログインは使わない(操作は incus exec で行う)ので無害である。
テンプレートからコンテナを起動し、次を確認する。
incus launch foobar-agent-template verify-tmpl
sleep 12
# 1. ツールが環境変数なしで動くか
incus exec verify-tmpl -- sh -c 'node -v; ruby -v; pnpm -v; bundle -v'
# 2. DNS が解決できるか
incus exec verify-tmpl -- getent hosts deb.debian.org
# 3. resolv.conf がスタブを指すか
incus exec verify-tmpl -- readlink /etc/resolv.conf
incus delete --force verify-tmplいずれも成功した(node v24.16.0、ruby 4.0.5、pnpm、bundler が動作し、DNS も解決でき、/etc/resolv.conf は /run/systemd/resolve/stub-resolv.conf を指す)。pnpm は corepack が初回実行時に取得するため、初回はネットワークが必要である。
- テンプレートには secret を含めない。btrfs の CoW ではテンプレートに含めた内容がすべてのクローンに伝わり、消去も難しいためである。
- コンテナ生成後に、読み取り専用のディスクデバイスとして secret を渡し、そこから
.envを生成する。破棄時にホスト側の secret ディレクトリを削除する。 - 注入する secret は必要最小限(
RAILS_MASTER_KEYと開発用 DB の認証情報など)にとどめ、OIDC や外部 API の鍵は、それを使う作業のときだけ追加する。
注入方式は隔離の強さをほとんど変えない(注入後はコンテナ内から読めるため)。露出を下げる主な手段は「最小化」であるという整理に至った。
- ホストの開発用 DB を共有すると、コンテナでの migration や書き込みがホストに残り、使い捨ての性質が壊れるため採らない。
security.nesting=trueのコンテナ内で Docker を動かし、foobar のcompose.yamlを override 付きで再利用して、MySQL と Valkey だけを起動する。- ID 採番の katsubushi はホストで 1 個だけ動かして共有する(多クライアント向けの設計のため)。ホスト側で katsubushi を起動しておくことが前提になる。
- コンテナからホストの 0.0.0.0 で待ち受けるサービス(bridge IP 10.197.40.1 経由)には到達できることを確認した。
- git の push 認証もコミット署名も、Yubikey の OpenPGP 鍵を使う。認証は gpg-agent が公開する SSH の窓口経由、署名は同じ鍵の署名機能による。
- コンテナには秘密鍵そのものを渡さず、gpg-agent の SSH ソケットだけを転送する。コンテナからは署名や認証の処理を Yubikey 側に転送するだけで、秘密鍵の実データを保持することも持ち出すこともできない。
- 署名は native GPG(
gpg.formatを変えない方式)を採った。既に GitHub に登録済みの OpenPGP 鍵をそのまま使えるためである。 - 実測では、認証も署名も物理的なタッチを求められなかった(鍵のタッチ設定が実質無効のため)。無人で常駐させる場合に処理が止まらない利点がある一方、presence(本人がその場で操作していることの保証)は成立していない点は認識しておく。
- github.com は IPv4 のみのため、2-2 の IPv4 の通信の修復が push の前提になる。
ソケット転送と push 認証の確認は次のように行う(実際の push はしない)。
# 使い捨てコンテナに gpg-agent の SSH ソケットを転送するデバイスを追加
incus config device add <ctr> sshagent proxy \
connect=unix:$SSH_AUTH_SOCK \
listen=unix:/root/gpg-ssh.sock \
bind=container mode=0600 security.uid=0 security.gid=0
# コンテナ内から認証を確認("Hi <user>!" が返れば成功。何も push しない)
incus exec <ctr> -- env SSH_AUTH_SOCK=/root/gpg-ssh.sock \
ssh -o StrictHostKeyChecking=accept-new -T git@github.com
incus config device remove <ctr> sshagent信頼できないコードを扱う作業では、このソケットを転送せず、コンテナはコミットまでにとどめ、push はホスト側で行う、という使い分けにする。
隔離の目的上、エージェント本体はコンテナ内で動かす。ホストから incus exec で本体を動かすとホスト側にプロセスが残り隔離が弱まるため、incus exec はコンテナの生成、初期化、破棄の指揮にとどめる。オーケストレータとの接続が切れてもエージェントが生き残るよう、コンテナ内では systemd のユニットとして常駐させる。
- 生成:
incus copy foobar-agent-template foobar-issue-NNNN(CoW クローン) - 初期化:
incus execで、読み取り専用でマウントしたソースから作業ツリーを作り、secret を注入し、コンテナ内の Docker で MySQL と Valkey を起動し、DB を準備し、エージェントを常駐起動する - 破棄:
incus delete --force foobar-issue-NNNNと、ホスト側の secret ディレクトリの削除
この記事の作業時点で残っている項目を挙げる。
- ネットワーク設定(firewalld の trusted ゾーンと
DOCKER-USERのルール)の恒久化 - 実際の secret の配置と、生成から破棄までの通し運用
- profile をコンテナごとに作るか共通にするかの判断
- 常駐させる Claude Code の起動オプション(無人運用での承認の扱い)の判断