[2026.06.15] #INFRA #DEVOPS

homelab を Ansible でコード化する — fail-closed な多層防御インフラ

著者: Ichiburn EST. 2 分で読了

自宅サーバを丸ごとコードにした話

自宅サーバ(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)・ヘルパーコマンド
podmanPodman 5.x・cgroup v2・rootless ランタイム
auth-gateway資格情報を握らせない認可デーモン・プロバイダ allowlist
policy-gatewayポリシーゲートウェイ・承認/監査ソケット
llm-gateway / llm-proxyLLM ルーティングと内部互換プロキシ
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 からどうぞ。