homelab を Ansible でコード化する — fail-closed な多層防御インフラ
自宅サーバを丸ごとコードにした話
自宅サーバ(homelab)で AI エージェント基盤を動かすために、コンテナランタイム、ネットワーク分離、暗号化DNS、カーネル緩和、監査ログまで一通り手で組んだ。動くには動いたのだが、ひとつ不安が残った。OS を入れ直したとき、同じものをもう一度作れる自信が無かったのだ。どこをどう直したか、どのセキュリティ設定を入れ忘れているか、結局は頭の中にしか無い。
それで、構築手順そのものを Ansible に落とした。最終的に9ロール・約3,900行になった。手順をコードにしただけではなくて、「危険な設定なら構築を止める」ところまで踏み込んでいる。空の許可リストや古い Podman で進もうとすると、playbook が走り出す前に assert が落とす。
この記事では、その構成(rootless Podman + Quadlet による多層防御 homelab)を抜粋して紹介する。実環境のホスト名・IP・ユーザ名・トークン・コンポーネント名のたぐいは伏せるか一般化し、コードも要点を示す抜粋にしてある。固有名が絡むところは変数名やファイル名を一般化し、汎用的で機微に関わらないところは実物に近い形で残した。
数字で見た中身
実環境の中身は出せないので、代わりにコードの規模で示す。以下はこの Ansible 構成を実測した値だ。
| 指標 | 値 |
|---|---|
| Ansible ロール数 | 9 |
roles/*/tasks/main.yml 総行数 | 3,898 |
fail-closed な assert 箇所 | 13 |
| 分離ネットワーク(内部用 / 外部egress用) | 2 |
| Quadlet コンテナユニット | 8 |
全体構成
┌─────────────┐ ansible-playbook ┌──────────────────────────────┐
│ 司令ホスト │ ───── over SSH ─────▶ │ 本番ホスト (target) │
│ (operator) │ apply --tags … │ │
└─────────────┘ │ ┌────────────────────────┐ │
│ │ bootstrap: OS/pkg/UFW │ │
│ │ /DoT/helpers │ │
│ ├────────────────────────┤ │
│ │ podman 5.x rootless │ │
│ │ + cgroup v2 + Quadlet │ │
│ ├──────────┬─────────────┤ │
internal-net ◀─────────────────────┼──│ policy / │ notification │ │
(サービス間) │ │ LLM 系 │ / relay │ │
外部 API ◀──────────────────────────┼──│ gateway │ │ │
│ ├──────────┴─────────────┤ │
│ │ audit: ログ収集+署名付死活 │ │
│ └────────────────────────┘ │
└──────────────────────────────┘
役割は9つのロールに分けている(名前は一般化したもの)。
| ロール | 責務 |
|---|---|
bootstrap | ベース OS・パッケージ・UFW・暗号化DNS(DoT)・ヘルパーコマンド |
podman | Podman 5.x・cgroup v2・rootless ランタイム |
auth-gateway | 資格情報を握らせない認可デーモン・プロバイダ allowlist |
policy-gateway | ポリシーゲートウェイ・承認/監査ソケット |
llm-gateway / llm-proxy | LLM ルーティングと内部互換プロキシ |
notification-bot / approval-relay | 通知トランスポート・承認コールバック |
audit-agent | 監査ログ収集・署名付き死活ハートビート |
危険な設定では構築を止める
この構成で一番こだわったのが、前提を満たさない値が来たら playbook をその場で止める、という部分だ。fail-closed な assert が13箇所あって、おかしな入力で進もうとするとそこで落ちる。
たとえば認可デーモンの「許可プロバイダ一覧」。これが空だったり、重複していたり、想定外の文字を含んでいたりすると、後段でおかしな挙動につながりうる。なので走り出す前に形を検証している。以下は実物に近い汎用パターンで、変数名だけ一般化してロジックはそのままだ。
- name: Fail when provider allowlist is unsafe
ansible.builtin.assert:
that:
- allow_providers is sequence
- allow_providers is not string
- allow_providers | length > 0
- allow_providers | reject('match', '^[A-Za-z0-9][A-Za-z0-9_.-]*$') | list | length == 0
- allow_providers | unique | list | length == allow_providers | length
fail_msg: "allow_providers must be a non-empty list of unique provider names."
sequence であること、空でないこと、各要素が想定の文字種だけか、重複が無いか。この5つを1つでも外したら止まる。バージョン依存も同じ考えで止めている。
- name: Fail if Podman version below 5
ansible.builtin.assert:
that:
- podman_ver.stdout is search('podman version 5')
fail_msg: "Podman 5+ required. Got: {{ podman_ver.stdout }}"
前提チェックを fail-open(失敗してもとりあえず通す)にしてしまうと、事故に気づかないまま先へ進んでしまう。検証は失敗したら止める、を基本にしている。
コンテナは Quadlet で宣言的に置く
docker run を手で叩く代わりに、Podman の Quadlet(.container / .network ユニット)をテンプレートから配って systemd に任せている。ネットワークは用途ごとに分けて、多層防御の境界をファイルで表す。配布のタスク自体はこんな形だ(ファイル名は一般化した擬似コード)。
# 擬似コード(ファイル名は一般化)
- name: Deploy internal network unit
ansible.builtin.template:
src: internal-net.network.j2
dest: "/home/{{ svc_user }}/.config/containers/systemd/internal-net.network"
owner: "{{ svc_user }}"
group: "{{ svc_user }}"
mode: '0644'
肝心なのは配られるネットワークユニットの中身で、外部 egress を持たせないことをファイルで宣言している。Podman Quadlet だと Internal=true の1行で済む(ネットワーク名とラベルだけ一般化、要点はそのまま)。
[Unit]
Description=Internal service network (no external egress)
[Network]
NetworkName=internal-net
Driver=bridge
Internal=true # 外部への egress を持たない
Subnet=10.89.0.0/24
Label=zone=internal
Internal=true のネットワークに載せたコンテナは、サービス間でしか通信できない。外に出る必要があるコンポーネントだけ、別の egress 用ネットワークに繋ぐ。どこへ通信できるかをネットワーク定義のレベルで絞っておくと、アプリ側のバグでうっかり外へ出てしまう、という事故を一段下で止められる。
コードから設計書へ辿れるようにする
各ロールの先頭には、対応する設計仕様へのポインタを1行コメントで置いている(パスは一般化した例)。
---
# spec: docs/infra/<NN>-<topic>.md §X.Y
docs/infra/ に番号付き(01〜06)の設計書があって、コードと対応づけてある。地味だが、半年後に見返したときに「なぜこの設定なのか」がコードから辿れるのは大きい。自分で書いた設定でも理由は案外忘れる。
手作業からの移行で変わったこと
apply --tags <段階>で OS のベースからコンテナ基盤、監査までを段階ごとに構築できる。秘密情報はコンソールから投入したうえで、各段階を検証する流れだ。- 構成がコードになっているので、OS を入れ直しても同じ手順で同じものを建て直せる。
- 秘密の置き場は用途で分けている。認可デーモンの秘密は root 所有ファイル、サービスユーザの CLI 認証は別管理、内部プロキシ用に生成する鍵は root 所有の環境ファイル、という具合だ。inventory には接続情報など最小限しか書かない。
- 流す前に preflight スクリプトと playbook 冒頭の
assertで、許可タグや前提条件を機械的にチェックしている。
手作業の SSH から「コードとして検証できるインフラ」に移したことで、構築から属人性が抜けた。設定の入れ忘れは、13箇所の assert が代わりに弾いてくれる。
真似するなら、ここから
Ansible 一式をそのまま真似する必要はない。効くのは「fail-closed な assert をロールの先頭に置く」という習慣のほうだ。空のリスト、古いバージョン、想定外の入力で とりあえず進めてしまう のが事故のもとなので、そこを1行ずつ止めておく。これは既存の Ansible や Terraform にも、少しずつ足していける。
既存インフラの IaC 化、fail-closed な検証の作り込み、rootless コンテナ基盤のセキュア化など、このあたりのご相談は Services からどうぞ。
