Skip to content

Instantly share code, notes, and snippets.

@windymelt
Created July 15, 2026 17:42
Show Gist options
  • Select an option

  • Save windymelt/54f6a4cf61238a184299f2a7f5b5b960 to your computer and use it in GitHub Desktop.

Select an option

Save windymelt/54f6a4cf61238a184299f2a7f5b5b960 to your computer and use it in GitHub Desktop.
ローカルの Incus で AI エージェント用の使い捨て開発環境を作る

ローカルの Incus で AI エージェント用の使い捨て開発環境を作る

(これは作業内容をLLMにまとめさせたものである)

この記事の目的

AI エージェント(Claude Code など)にコードベースの作業を任せるとき、ホストを汚さず、隔離されていて、短時間で作って捨てられる環境があると都合がよい。この記事では、Proxmox を別ホストに立てる構成ではなく、手元の Linux 開発機に直接 Incus(LXC のコンテナ管理基盤)を導入して同等の環境を作る手順を、実際に踏んだ落とし穴と対処を含めてまとめる。

対象は「開発機が Linux で、単独で使う」ケースである。この条件では、別ホストを常時稼働させる固定費が不要になり、ローカルの Incus が費用対効果の面で優位になる。macOS や Windows を開発機にする場合、複数人や複数マシンで環境を共有したい場合、開発機の電源を切ってもエージェントを動かし続けたい場合は、別ホスト構成のほうが向く。

なお、既存の git worktree による隔離とは用途を分ける。信頼できるコードや小さな変更は worktree のほうが速い。素性の分からないコードを動かす、破壊的な作業を試す、ホストを汚したくない、といった場面で Incus の使い捨てコンテナが向く。

前提環境

  • openSUSE Leap 16.0(カーネル 6.12)
  • ルートファイルシステムが btrfs
  • Docker が同居している(docker0br-* のブリッジが存在する)
  • ネットワークは IPv6 が中心だが、LAN に IPv4 もある
  • コミットの署名と GitHub への push 認証に Yubikey(OpenPGP アプレットの鍵)を使う

環境によって異なる値(Yubikey のシリアル番号、鍵の ID、LAN のリゾルバなど)は、その都度自分の環境の値に読み替えてほしい。

1. Incus の導入

Leap 16.0 の標準リポジトリには Incus が含まれないため、openSUSE Build Service(OBS)のプロジェクトを追加する。incuslego(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 秒で完了する。

2. ネットワークの落とし穴 2 つ

Docker が同居していると、コンテナの通信が Docker のファイアウォール設定に妨げられる。実際に 2 つの問題を踏んだ。

2-1. コンテナ→ホストの DNS が拒否される(firewalld)

症状は「コンテナが 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 --reload

trusted ゾーンは、そのインターフェースからホストへの通信をすべて許可する。使い捨てかつホスト管理下のコンテナ用のブリッジなので、開発用途では許容できる。ポートを絞りたい場合は専用ゾーンを作る。

2-2. コンテナの IPv4 の外部通信が失敗する(Docker)

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 ACCEPT

DOCKER-USER は FORWARD チェーンの先頭で評価されるため、ここで accept すると policy drop に到達しない。送信元アドレスの変換(NAT)は Incus が用意する masquerade のルールで行われる。

なお、この iptables のルールと firewalld のゾーン設定は、Docker やホストの再起動で失われる。恒久化するには、起動時に再適用する systemd の oneshot ユニットや firewalld の direct rule を用意する(この記事の作業時点では未実施)。

3. テンプレートの作成(distrobuilder)

案件ごとにクローンする元になるテンプレートを、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 の鍵取得の問題

最初はフィンガープリントだけを 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: のブロックスカラー(- |)に、それぞれ字下げして貼り付ける。

mise の格納場所とネットワーク管理の問題

初回のビルドでは、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-template

ruby をソースからコンパイルするので数分かかる。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 が初回実行時に取得するため、初回はネットワークが必要である。

4. 設計上の判断

secret(認証情報)の扱い

  • テンプレートには 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 経由)には到達できることを確認した。

push と署名(Yubikey)

  • 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 のユニットとして常駐させる。

5. ライフサイクル

  • 生成: 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 の起動オプション(無人運用での承認の扱い)の判断
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment