オートマトン(自動化羊)

自動化したり統計したり

メインPCをFedoraにする

グッバイWindows

個人で開発等に利用するノートPCを使用しているのだが、最近ふと「これWindowsである必要はないな」と感じたのでそれならLinux Desktopとか使いたいなと思い立ったのがきっかけ。

PCつけたときにセットアップ画面に連れて行かれM365に入らないかっていう勧誘をちょいちょい受けて嫌気が差した

それでどのディストリビューションにしようかなと色々考えた結果、Linusが使っているらしいFedoraがよさそう!となった。(ミーハー)

Hello fedora

ブートメディアを作ろう

FedoraがFedora Media Writerとかいう使い勝手のよいブートメディア作成ツールを出してくれているのでこれを活用。

こんな感じで日本語にも対応してくれている。

fedora media writer

早速起動!

dynabookは起動時にF2でBIOSに入れます。

Bootのメニューからブートの順番を変えてUSBが1番になるようにします。

よく言われている要注意ポイントですが、Secure Bootを落としておかないとUSBからのブートがうまくいかなかったです。

Secure Bootをオフにする

USBからブートしてインストールができたらOKです。

GUIインストール画面

なんかVMWareのコンソールのちっさい窓からおんなじ画面見たことあるけど、実際手元のノートPCでこれを見るのはまた違った趣きがありますな。

日本語環境をつくるぞ

Fedoraをインストールしたら次は日本語環境を整備していきます。

というのはデフォルトだと、若干キーバインドが違う?とかでWindowsから乗り換えるには少し使いづらい入力だと感じています。

なのでFcitx5をインストールします。この手のものはインターネットを漁ればいっぱいありますが、一応コマンド載せときます。

sudo dnf install fcitx5 fcitx5-mozc

インストールしたらあとはGUIの設定で日本語をデフォルトに設定します。

Fcitx5の設定

これでいいかんじ。

Google Chromeに気をつけろ

ブラウザは慣れたものを使いたかったのでdnfでGoogle Chromeを入れたのですが、 ChromiumはなんかWaylandとの相性よくないらしく、入力時に予測のボックスがずれるなどが発生してすごく使いづらい状態になってしまいました。

なので設定でX11の互換で動くようにします。

まずはシステム側の.desktopをコピー。

cp /usr/share/applications/google-chrome.desktop ~/.local/share/applications/

そしてExecの部分全てに--ozone-platform=x11のオプションを追記する。

Exec=/usr/bin/google-chrome-stable --ozone-platform=x11 %U

これでGoogle Chromeでも問題なく日本語入力での予測変換表示をできるようになりました。

ということでこれで大体Windowsと遜色ない環境ができました。

開発系のものはもともとWSL2でしかやっていなかったので、移行は全く問題なしでした。

最近はGitHubやGoogle Driveをはじめとしたクラウドストレージのお世話になっているため、もうローカルにしかないものも少なくて移行が捗りますね。

皆様もぜひLinux Desktopに切り替えてみませんか。最近のはだいぶ快適です。

【格闘】ClabernetesをK3sで作ってみよう

【格闘】⇒この記事内にはこの書き出しで筆者がハマったところを残しています。

Clabernetesとは?

コンテナ仮想化を用いてネットワーク機器の検証環境をIaCで作成できるOSSのContainerlabをKubernetesを用いてマルチノードで扱えるようにするものがClabernetesです。

containerlab.dev

自宅のProxmoxでマルチノードContainerlabを作るぞ!

というわけで早速Clabernetesを構築してみようとおもうのですが自宅環境でお安く動作検証をしたいので、Proxmox上に立てたVM三台のK3sクラスター構成を作成し、そこで作業していこうと思います。

非常に雑な概念図としてこんな感じ

【格闘】各MTUは上げとかないと、ラボの検証がうまく進まないかつ切り分けがめっちゃ面倒になるのでいっそ9000にしてしまおう。

準備

VMを立てる

ProxmoxにVM(Debian 13)を3つ立てます。 特に工夫も何もないので割愛します。

(Nested Virtualizationは動くようにしないといけないのでそこだけ注意! Nested Virtualization - Proxmox VE

K3sを入れる

【格闘】Clabernetesは各Containerlab Nodeに対してデフォルトで下記のポートを公開するように構成するので、K3sがデフォルトで具備しているServiceLBではポート競合によりKubernetes ノードより多くのContainerlab Nodeを構築することができません。 なのでMetalLBなどに切り替えます。 インストールする際もServiceLBはデプロイしないようにします。

【格闘】またK3sのデフォルトCNIのflannelはVXLANでPod間をつなぎますがこれがContainerlabとの食い合わせが悪いのでVXLANを使用しないhost-gwでデプロイします。(すべてのノードが同一のL2NWに属している必要があります。)

マスターノード

# curl -sfL https://get.k3s.io | sh -s - \
  --disable servicelb \
  --flannel-backend=host-gw
# kubectl apply -f https://raw.githubusercon\tent.com/metallb/metallb/v0.15.3/config/manifests/metallb-native.yaml

MetalLBでExternal IPとして使用するIPアドレスのプールを作成しておきます。

# cat <<EOF | kubectl apply -f -
apiVersion: metallb.io/v1beta1
kind: IPAddressPool
metadata:
  name: default-pool
  namespace: metallb-system
spec:
  addresses:
  - 192.168.0.200-192.168.0.250 # ここは使用するネットワーク次第で変えてください。
---
apiVersion: metallb.io/v1beta1
kind: L2Advertisement
metadata:
  name: default
  namespace: metallb-system
EOF

このあとワーカーノードを登録する際に使用する/var/lib/rancher/k3s/node-tokenの内容を参照してコピーしておく

# cat /var/lib/rancher/k3s/node-token
<node-tokenの内容>

ワーカーノード

# curl -sfL https://get.k3s.io | K3S_URL=https://<master nodeのIP>:6443 K3S_TOKEN=<コピーしておいたnode-tokenの内容> sh -

Helmのインストール(マスターノードのみ)

これも公式をそのままなぞります。

Installing Helm | Helm

# curl -fsSL -o get_helm.sh https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-4
# chmod 700 get_helm.sh
# ./get_helm.sh

Clabernetes入れるぞ!

ClabernetesにはNext.js製のWeb UIの存在がリポジトリからは確認できます。 また、公式のコマンドでHelm経由でのInstallを行った場合にもこのUIのコンテナが起動します。

ですが、なぜか一切の言及がなく恐らくアルファ版的な位置づけであると予測します。

Web UIからはデプロイしたラボのトポロジーを視覚的に確認できるので、動くようにしておきます。

デフォルトのURLがui.clabernetes.containerlab.dev、Ingress Classがnginxとなっているのでこれを良しなに変更します。

【格闘】Ingress Hostを変更←デフォルトのui.clabernetes.containerlab.devcontainerlab.devはContainerlabの公式ページ等で使用されているドメインなのでこれは自分が使いたいものに変えておきましょう。 IngressClassをtraefik(K3sのデフォルト)に変更。

# helm upgrade clabernetes oci://ghcr.io/srl-labs/clabernetes/clabernetes \
  --namespace c9s \
  --set ui.ingress.host=clabernetes.lab.internal\
  --set ui.ingress.ingressClass=traefik

/etc/rancher/k3s/k3s.yaml~/.kube/configにコピーされていないとこんなエラーがでるので注意

Error: kubernetes cluster unreachable: Get "http://localhost:8080/version": dial tcp [::1]:8080: connect: connection refused

インストールが完了したら下記コマンドでデプロイできているかを確認します。

# kubectl get -n c9s pods -o wide
NAME                                  READY   STATUS    RESTARTS   AGE   IP           NODE                   NOMINATED NODE   READINESS GATES
clabernetes-manager-56b785d49-8j7ks   1/1     Running   0          11h   10.42.0.27   clabernetes-master     <none>           <none>
clabernetes-manager-56b785d49-dn8fr   1/1     Running   0          11h   10.42.3.19   clabernetes-worker-1   <none>           <none>
clabernetes-manager-56b785d49-gzs87   1/1     Running   0          11h   10.42.1.18   clabernetes-worker-2   <none>           <none>
clabernetes-ui-77d9645689-4sk9z       1/1     Running   0          11h   10.42.0.28   clabernetes-master     <none>           <none>
clabernetes-ui-77d9645689-nwr5z       1/1     Running   0          11h   10.42.1.17   clabernetes-worker-2   <none>           <none>
clabernetes-ui-77d9645689-x7mm2       1/1     Running   0          11h   10.42.3.18   clabernetes-worker-1   <none>           <none>

Web UIを確認します。

# kubectl get -n c9s ingress
NAME             CLASS     HOSTS                 ADDRESS          PORTS   AGE
clabernetes-ui   traefik   clabernetes.lab.internal   192.168.0.200   80      2d

上記コマンドでIPを確認した後、ブラウザアクセスを行う端末側のhostsファイルでclabernetes.lab.internalの名前解決をできるようにします。

そのうえで任意のブラウザからアクセスしてみましょう。

下記のメニュー画面が表示されればOKです。

clabernetes メニュー画面

これでClabernetesを構築できました。

Clabverterのインストール

ClabverterはContainerlabのトポロジーを示すYAMLファイルをClabernetes用に変換するツールです。 公式では別途Dockerコンテナを起動してコマンドの実行を行っています。

ただKubernetesだとホスト側で持っているファイルをコンテナにバインドするのが結構面倒なのでビルドしてしまいます。

ビルドにはGO言語のランタイムが必要なのでリポジトリのクローンと合わせてインストールしておきます。

# git clone https://github.com/srl-labs/clabernetes.git
# apt install golang

リポジトリに移動した後、go言語の依存関係を解決します。

# cd clabernetes
# go mod tidy

ビルドする

# CGO_ENABLED=0 go build -o /usr/local/bin/clabverter ./cmd/clabverter/

/usr/local/bin/にはPATH通しておいてください。

ラボをデプロイ

QuickStartにあるサンプルのラボをデプロイしてみます。

# git clone --depth 1 https://github.com/srl-labs/srlinux-vlan-handling-lab.git \
  && cd srlinux-vlan-handling-lab

【格闘】この時にvlan.clab.ymlのlinksの順番を入れ替えます。

topology:
  ...
  links:
    # links between client1 and srl1
    - endpoints: [client1:eth1, srl1:e1-1]

    # links between client2 and srl2
    - endpoints: [srl2:e1-1, client2:eth1] # ここのリンクを2番目に持ってくる

    # inter-switch link
    - endpoints: [srl1:e1-10, srl2:e1-10]

claverterを使用してデプロイ

# clabverter --stdout --naming non-prefixed | kubectl apply -f - 

デプロイできたかどうかをコマンドとブラウザから確認します。

# kubectl get pods  -n c9s-vlan -o wide

私の環境ですが、きちんと分散してコンテナが建てられていそうです。

NAME                      READY   STATUS    RESTARTS   AGE    IP           NODE                   NOMINATED NODE   READINESS GATES
client1-fd4fc556c-qfv29   1/1     Running   0          129m   10.42.2.11   clabernetes-worker-2   <none>           <none>
client2-898969d86-pwfxh   1/1     Running   0          129m   10.42.1.12   clabernetes-worker-1   <none>           <none>
srl1-55644785fd-mvvgb     1/1     Running   0          129m   10.42.0.26   clabernetes-master     <none>           <none>
srl2-8699dd6d95-z7xl2     1/1     Running   0          129m   10.42.0.27   clabernetes-master     <none>           <none>

ブラウザではc9s-vlanのラボがTopologiesのリストに追加されています。

VisualizerではKubernetesのリソースを視覚的に確認することができます。

この画面を見るとよくわかるのですが、ClabernetesのラボはTopologyというCRDをルートに各ノードがDeploymentととして定義される形でデプロイされています。

本当に各ノードがつながっているかを公式にあるやり方をなぞって確認します。

まずはsrl1にログインしてLLDPを確認。

# NS=c9s-vlan POD=srl1; \
kubectl -n $NS exec -it \
  $(kubectl -n $NS get pods | grep ^$POD | awk '{print $1}') -- \
    docker exec $POD sr_cli show system lldp neighbor
  +---------------+-------------------+----------------------+---------------------+------------------------+----------------------+---------------+
  |     Name      |     Neighbor      | Neighbor System Name | Neighbor Chassis ID | Neighbor First Message | Neighbor Last Update | Neighbor Port |
  +===============+===================+======================+=====================+========================+======================+===============+
  | ethernet-1/10 | 1A:98:00:FF:00:00 | srl2                 | 1A:98:00:FF:00:00   | an hour ago            | 5 seconds ago        | ethernet-1/10 |
  +---------------+-------------------+----------------------+---------------------+------------------------+----------------------+---------------+

次にclientとして作成されているコンテナ同士でPingが通るかを確認。

# NS=c9s-vlan POD=client1; \
kubectl -n $NS exec -it \
  $(kubectl -n $NS get pods | grep ^$POD | awk '{print $1}') -- \
    docker exec -it $POD ping -c 2 10.1.0.2
PING 10.1.0.2 (10.1.0.2) 56(84) bytes of data.
64 bytes from 10.1.0.2: icmp_seq=1 ttl=64 time=4.23 ms
64 bytes from 10.1.0.2: icmp_seq=2 ttl=64 time=0.888 ms

--- 10.1.0.2 ping statistics ---
2 packets transmitted, 2 received, 0% packet loss, time 1001ms
rtt min/avg/max/mdev = 0.888/2.560/4.232/1.672 ms

これで無事にラボが動いていることも確認できました。

まとめ

QuickStartの例ではKindでしたが、今回K3sでマルチノードのClabernetesを構築できました。

これでかなり大きな構成のラボも作ることができそうです。

とはいえ格闘がかなりしんどくてClaudeがいなかったらだいぶ手前で挫折していたような気がします。 結局デッカイマシン作ってそのうえでDocker ComposeでContainerlab動かすのが一番楽で良い!

NetBoxのCustomScriptでProxmoxのVM作成を自動化してみよう

NetBoxはNetBoxLab社が開発しているDCIM/IPAMツールです。

NetBoxにはCustomScriptEventRuleといった自動化に繋げられる要素が搭載されています。

これらを組み合わせて仮想基盤(Proxmox)へのVM自動払い出しを実装してみます。

※ NetBoxやProxmoxのインストール方法や基本的な使用方法については割愛します。

NetBoxからProxmoxへのVM払い出しの流れ

全体図としては下記のような流れを目指します。

graph LR
subgraph NetBox
  NbVm[VirtualMachine]
  NbE[EventRule]
  NbCS[CustomScript]
  NbVm --> |Created| NbE
  NbE --> |登録しているスクリプトを呼び出し| NbCS
end

subgraph Proxmox
  VM
end

NbCS --> |VM作成リクエスト| Proxmox

つまり、NetBox上で仮想マシンが作成されたときにProxmox上にもVMの払い出しが完了している状態を目指します。

CustomScriptについて

NetBoxでは特定のタイミングで発火可能なPythonスクリプトを作成することができます。

CustomScriptはextras.scripts.Scriptを継承したクラスのrunメソッドがエントリーポイントとなります。

from extras.scripts import Script

class SampleScript(Script):
  def run(self, data, commit):
    """ここがエントリーポイント
    Args:
      data: スクリプトに対して渡される辞書データ(呼び出し元次第で中身は変わる)
      commit: データベースへのコミットが行われているかどうかを示すフラグ
    """

CustomScriptではNetBoxの内部モジュールを使用することができます。 外部のライブラリを使用する場合はNetBoxを起動している仮想環境へ追加をする必要があります。

Dockerで立ち上げている場合はrequirements-container.txtにライブラリ情報を追加してbuild.shを実行することでカスタマイズされたコンテナを作成することができます。

EventRuleについて

EventRuleではNetBox内のオブジェクトが作成・変更・削除された際に特定のアクションをとることを設定できます。

EventRuleからつなげることができるアクションは下記の3種類。

  • Webhook
  • CustomScript
  • Notification(通知)

ここでCustomScriptを選択することで、NetBox内の仮想マシンオブジェクトの作成に対応してCustomScriptを起動することができます。

実装

Proxmox APIを準備

Proxmox側でまずAPIユーザーを作成します。

ここでは手順のみ記載し、各要素の詳細については説明しません。

# 操作画面 操作内容
1 データセンター > アクセス権限 > ユーザー APIユーザーを作成
2 データセンター > アクセス権限 > APIトークン 作成したAPIユーザーのトークンを生成
3 データセンター > アクセス権限 > ロール API用ロールを作成
4 データセンター > アクセス権限 APIトークンのアクセス権限を作成(パス: /、上記のトークンとロールを紐づけ)

手順3で作成するロールには以下の権限を付与します。

Datastore.Allocate Datastore.AllocateSpace Datastore.Audit SDN.Use VM.Allocate VM.Audit VM.Clone VM.Config.* VM.Migrate VM.PowerMgmt

こちらを参考にさせていただきました。

Proxmox VE APIを使ってみる

Proxmox APIを呼び出すスクリプトを作成

Proxmox APIを扱うためのproxmoxerライブラリがあるのでこれを利用して呼び出しを行います。

EventRuleでオブジェクトの作成時にScriptを呼ぶ場合は、作成されたオブジェクトをシリアライズしたデータがrunメソッドのdataに入ってくるため、これを利用して作成するVMの情報を作ります。

ざっくりデータフローは下記の形です。

graph LR
  u((User)) --ブラウザ--> webf((NetBox Webフォーム))
  webf --VM作成リクエスト--> db[(NetBox DB<br/>トランザクション commit)]
  db --post_save trigger--> er{EventRule}
  er --match--> cs{{CustomScript<br/>実行}}
  cs --VM作成リクエスト<br/>Proxmox APIで送信--> api(Proxmox API)
  api --VM作成完了--> cs

NetBoxのVirtualization/VirtualMachineで設定できる項目からNetBoxからProxmoxのREST APIに渡して渡せそうな情報をまとめます。 参考

  • name - VMの識別名
  • cluster - VMが所属するクラスタ情報
  • platform - VMのOS情報
  • vcpus - vCPU数
  • memory - メモリサイズ(MB)
  • disk - ディスクサイズ(MB)

デフォルトの項目からは大体上記の内容くらいかなと、この辺はCustomFieldなどでいい感じに拡張できそうです。

一応工夫として、仮想マシンを乗せるクラスタはクラスタタイプで識別できるのでこれがProxmoxの時だけProxmox APIを呼び出すようにしてみます。

from extras.scripts import Script
from proxmoxer import ProxmoxAPI
from virtualization.models import Cluster

PROXMOX_CONFIG = {
    "host": "proxmox.mylab",
    "user": "api-user@pve",
    "token_name": "api-token",
    "token_value": "xxxxxxxxxxxxxxxxxxxxxx",
    "verify_ssl": False,
}


class ProxmoxScript(Script):
    class Meta:
        name = "Proxmox VM create script"
        description = "Create a vm for the Proxmox cluster."
        commit_default = True

    def run(self, data, commit):
        """
        ProxmoxのVMを作成するスクリプト
        Args:
            data (dict): 仮想マシンの情報を含む辞書
            commit (bool): 変更を保存するかどうかのフラグ
        """
        # 仮想マシンがProxmox環境のものかを判定
        cluster_id = data.get("cluster", {}).get("id")
        cluster_name = data.get("cluster", {}).get("name")
        cluster = Cluster.objects.filter(pk=cluster_id).first()
        if cluster is None:
            return "仮想マシンが配置されているクラスターの情報が取得できませんでした。"
        cluster_type_slug = cluster.type.slug

        if cluster_type_slug == "proxmox":
            # Proxmoxの場合
            cfg = PROXMOX_CONFIG
            proxmox = ProxmoxAPI(
                cfg["host"],
                user=cfg["user"],
                token_name=cfg["token_name"],
                token_value=cfg["token_value"],
                verify_ssl=cfg.get("verify_ssl", False),
            )
            vm_id = proxmox.cluster.nextid.get()
            vm_name = data.get("name")
            vm_platform = data.get("platform", {}).get("slug")
            vm_vcpus = str(data.get("vcpus")).split(".")[
                0
            ]  # 小数点以下を切り捨てて整数部分のみを取得
            vm_memory = data.get("memory")
            vm_disk = data.get("disk")
            # MB単位からGB単位に変換
            vm_disk = int(vm_disk) // 1024

            if vm_platform == "ubuntu-24-04":
                iso_file = "ubuntu-24.04.2-live-server-amd64.iso"
            else:
                return f"指定されたプラットフォーム {vm_platform} はサポートされていません。"

            vm_config = {
                "vmid": vm_id,
                "name": vm_name,
                "cpu": "x86-64-v2-AES",
                "cores": 2,
                "vcpus": vm_vcpus,
                "memory": vm_memory,
                "sockets": 1,
                "net0": "virtio,bridge=vmbr0",
                "ide2": f"local:iso/{iso_file},media=cdrom",
                "ostype": "l26",
                "scsihw": "virtio-scsi-pci",
                "scsi0": f"local-lvm:{vm_disk},discard=on",
            }

            proxmox.nodes(cluster.name).qemu.create(**vm_config)

            return f"VM: {vm_name} を Cluster:{cluster_name} 上に作成しました。"
        elif cluster_type_slug == "vmware":
            # VMwareの場合は、VMwareのAPIを使用してVMを作成するコードをここに追加。
            pass
        return f"{cluster_name}は自動化の対象ではありません。"

動かしてみる

NetBoxのフォームから仮想マシンを作成します。

NetBoxフォーム
NetBoxフォームその2

Proxmoxの仮想マシンが無事に作成されていることを確認します。

Proxmox VM一覧

NetBoxのジョブを確認すると、スクリプトが正常に動いた結果が取得できます。

ジョブ結果

まとめ

NetBoxのEventRuleとCustomScriptを使用することで、イベント駆動の外部連携を実装することができました。

スクリプトの方をもっと改善すればProxmox上のVM作成をcloud-initを利用する形にしたり、VMWareやAWSなどの基盤にも対応したりとかなり拡張できる気がします。

余談:管理と自動化の関係

  • 管理
    • ネットワーク機器やサーバ機器、VMなど管理台帳を作成して管理していた一連の業務を想定。
  • 自動化
    • 機器やVMへの設定作業にツールやスクリプトなどを用いて行うということを想定。

インフラ周りには管理に重点を置いたツールと自動化に重点を置いたツール、その両方に強みを持つツールがあります。

できることならツールチェーンはない方がいいので管理と自動化は一つのツールで収まるようにしたいところですが、 既に運用が始まってしまっているところにツールを後から乗せる際は様々な要件から管理と自動化で別々のツールが採用される場合がほとんどというのが体感です。

管理と自動化が一体になっているツールは対象機器が特定ベンダーに寄っていたりなどで将来的な自由度を狭めるところから不採用になりがち

管理と自動化で異なるツールを採用したとします。

そうした場合、管理ツールと自動化ツールでの連携を行う必要が出てきます。 それぞれのツールをどのような流れで連携させていくか?ユースケースの始点は管理ツールか自動化ツールか、これはツールの強み弱みもあるが業務の性質にも関係があると考えています。

管理 -> 自動化

管理ツールでの変更が自動化ツールへ影響し、機器へと反映されるパターン。

このパターンでは運用者の作業起点が管理ツールになります。運用者にとっては「台帳を更新している」という意識で操作を始めることになりますが、その裏では自動化が走り実機への変更が発生します。

ITインフラ管理の現場を想像してみると業務の流れ的にはおおよそ作業 -> 記録という形が多いと感じています。

ですが、このパターンはその逆を行きます。 従来の管理台帳は「記録」の場であり、記載を間違えても台帳を直せば済みました。しかし管理ツールから自動化が連動している場合、台帳への記載が即座に実機への操作になります。運用者が「記録しているだけ」という意識のまま操作していると、意図しない変更が本番環境に反映されるリスクがあります。

このギャップを解消するためには様々なアプローチはあるかと思います。 簡単なところでいえば管理ツールの操作が機器への変更に繋がることを運用者にアナウンスすることです。 とはいえ仕組みで制限した方が良さそうです。 管理ツールの権限設計を丁寧に行い、変更が可能なユーザーと閲覧のみのユーザーをきっちり分けて運用していくことができれば、人の努力に頼らない方向で解決できます。

これまでの運用体系と異なる流れとなることから、メンバーのメンタルモデルを組み替えるための教育・浸透を行う必要があるため、すぐの導入は中々難しいのかなと感じています。

このパターンは管理ツールに定義される状態を中心に連携を組むため、自動化ツールがとるべき動きは固定化されてきます。そのため、ツールチェーンの自由度は低くなり安定した運用が期待できます。

sequenceDiagram
    Actor 運用者
    Participant 管理ツール
    Participant 自動化ツール
    Participant 機器

    運用者 ->> 管理ツール :設定の変更
    管理ツール ->> 自動化ツール :変更の連携
    自動化ツール ->> 機器 :変更の反映

自動化 -> 管理

自動化ツールが機器の変更と管理ツールの変更を連携させるパターン。

これは作業 -> 記録というパターンに沿った流れとなるため、導入が容易であると感じています。

自動化ツールの自由度が高いため、運用をしていく中で機能追加などを行う場合の開発方針を明確に打ち出さないと保守しづらいものが積みあがってしまう危険性もあります。

sequenceDiagram
    Actor 運用者
    Participant 自動化ツール
    Participant 管理ツール
    Participant 機器

    運用者 ->> 自動化ツール :作業シナリオを実行
    自動化ツール ->> 機器 :変更の反映
    自動化ツール ->> 管理ツール :変更内容を記録

余談まとめ

どちらのパターンにも言えますが、ツールチェーンを利用しているときに特に気を付けなければならないこととして、連携しているツールのどこかでエラーがでたときにどのようにデータの整合性を保つかは考えなければなりません。

個人的にはマイクロサービスにおけるサーガパターンはこの問題を解決するための考え方として有用であると考えています。

ITインフラを自動化するためのツールチェーンはマイクロサービスアーキテクチャにおける各サービスの隔離と似たものがあり、マイクロサービスアーキテクチャにて用いられるナレッジがもっと活用できると考えています。

Ansible Navigatorを使うぞ!

こちらは エーピーコミュニケーションズ Advent Calendar 2025 7日目の記事です!

【懺悔】Ansible Navigator活用できてません

2021年にansible-navigatorがリリースされてもう4年程年月が経ちましたが、私はいまだにansible-playbookの方を使ってしまっています。

なぜか?

  • 設定が面倒
    • 公式のconfigurationのドキュメントの長さに挫折
    • 必要に迫られているわけではなかったので余計に面倒に感じた
  • uvの登場
    • 個人的にネックになっていたPython仮想環境を整える面倒くささが改善された

とはいえ、このままだとAnsibleの進歩に置いて行かれる気がしたのでここらでansible-navigatorに慣れていきたい!

そんな、ansible-playbookコマンド愛用者に送る備忘録。

これが分かれば使える!

mode

私個人の感想ですが、stdout一択です。 ansible-playbookに慣れ親しみすぎたためTUIである必要性は今のところ感じていません。

---
ansible-navigator:
  mode: stdout

execution-environment

とりあえず、扱うにあたって押さえておきたいのはexecution-environmentの設定項目です。

これがansible-navigatorを使う最大のメリットなのでこの設定を改めて覚えて活用したいと思います。

設定例

---
ansible-navigator:
  execution-environment:
    enabled: true
    container-engine: docker
    image: quay.io/ansible/awx-ee:latest
    pull:
      policy: missing

こんな感じです。 とりあえず押さえておきたい設定項目としては下記の4つです(他にも便利そうな設定項目はあるけどキャッチアップできてない、、、)。

  • enabled
  • container-engine
  • image
    • コンテナをpullする際のパスを指定
    • ローカルでビルドしたイメージももちろん指定可能
  • pull
    • コンテナをpullする際の設定
    • policyでコンテナをpullする条件を指定
    • argumentでpullするときのオプションを指定できる
      • podmanなら--tls-verify=falseなどはここで指定できる
      • dockerだとあんまり使わないかも?

playbook-artifact

これが分かるとansible-navigatorサイコーになれる設定です。 Playbookを実施する際のログを細かくとることができます。

設定例

---
ansible-navigator:
  playbook-artifact:
    enable: true
    save-as: ./playbook-artifacts/{playbook_name}/{playbook_status}-{time_stamp}.json

設定値は下記の2つです。

  • enable
    • artifactの取得を行うかを決める設定値
    • Defaultがtrueなのでこの設定を意識せずにPlaybookを実行して、何かログみたいなのが勝手に増えてる!?ってなった記憶
  • save-as
    • artifactの保存先を決める設定値
    • 動的な値を使用可能
      • {playbook_dir}
      • {playbook_name}
        • Playbookファイルのbasename
      • {playbook_status}
        • Playbookの実行結果のステータス
        • successfulfailed(もしかしたら他にもあるかも)
      • {time_stamp}
        • Playbookを実行したときの時刻
        • 便利だけどtime stampは長いので視認性は下がる
        • ただこれを入れないと、実行ごとにartifactを分けることができないので実質必須
    • Defaultは{playbook_dir}/{playbook_name}-artifact-{time_stamp}.json

Artifactいいね!

ansible-navigatorを使う良さはこのArtifactにあると私は感じています。

ログを後からゆっくり見返すことができます。 個人的におすすめなのは {playbook_name}ディレクトリを分割してそこに{playbook_status}-{time_stamp}.jsonを置く形です。

---
ansible-navigator:
  playbook-artifact:
    save-as: ./playbook-artifacts/{playbook_name}/{playbook_status}-{time_stamp}.json

こうしておくと、Playbookごとにartifactがまとまって便利です。

Artifactのplays.tasksではtaskの実行時間やモジュールの変数の値など結構細かい情報が見れます。

このタスクを行ったときに

    - name: Get user from jsonplaceholder
      uri:
        url: https://jsonplaceholder.typicode.com/users/1
        method: GET
        return_content: yes
      register: user_response

こんなのが見えます

{
    "__changed": false,
    "__duration": "1s",
    "__host": "localhost",
    "__number": 0,
    "__result": "Ok",
    "__task": "Get user from jsonplaceholder",
    "__task_action": "uri",
    "duration": 0.669066,
    "end": "2025-12-06T17:03:30.041794+00:00",
    "event_loop": null,
    "host": "localhost",
    "play": "localhost",
    "play_pattern": "localhost",
    "play_uuid": "695c9853-f1a2-d479-9ec4-000000000001",
    "playbook": "/path/to/playbook/demo.yaml",
    "playbook_uuid": "ab54aadf-5d19-42f7-9e4e-8a52d2138340",
    "remote_addr": "127.0.0.1",
    "res": {
        "_ansible_no_log": false,
        "accept_ranges": "bytes",
        "access_control_allow_credentials": "true",
        "age": "28305",
        "alt_svc": "h3=\":443\"; ma=86400",
        "cache_control": "max-age=43200",
        "cf_cache_status": "HIT",
        "cf_ray": "9a9d5b4daa6548c0-NRT",
        "changed": false,
        "connection": "close",
        "content": "{\n  \"id\": 1,\n  \"name\": \"Leanne Graham\",\n  \"username\": \"Bret\",\n  \"email\": \"Sincere@april.biz\",\n  \"address\": {\n    \"street\": \"Kulas Light\",\n    \"suite\": \"Apt. 556\",\n    \"city\": \"Gwenborough\",\n    \"zipcode\": \"92998-3874\",\n    \"geo\": {\n      \"lat\": \"-37.3159\",\n      \"lng\": \"81.1496\"\n    }\n  },\n  \"phone\": \"1-770-736-8031 x56442\",\n  \"website\": \"hildegard.org\",\n  \"company\": {\n    \"name\": \"Romaguera-Crona\",\n    \"catchPhrase\": \"Multi-layered client-server neural-net\",\n    \"bs\": \"harness real-time e-markets\"\n  }\n}",
        "content_length": "509",
        "content_type": "application/json; charset=utf-8",
        "cookies": {},
        "cookies_string": "",
        "date": "Sat, 06 Dec 2025 17:03:26 GMT",
        "elapsed": 0,
        "etag": "W/\"1fd-+2Y3G3w049iSZtw5t1mzSnunngE\"",
        "expires": "-1",
        "invocation": {
            "module_args": {
                "attributes": null,
                "body": null,
                "body_format": "raw",
                "ca_path": null,
                "ciphers": null,
                "client_cert": null,
                "client_key": null,
                "creates": null,
                "decompress": true,
                "dest": null,
                "follow_redirects": "safe",
                "force": false,
                "force_basic_auth": false,
                "group": null,
                "headers": {},
                "http_agent": "ansible-httpget",
                "method": "GET",
                "mode": null,
                "owner": null,
                "remote_src": false,
                "removes": null,
                "return_content": true,
                "selevel": null,
                "serole": null,
                "setype": null,
                "seuser": null,
                "src": null,
                "status_code": [
                    200
                ],
                "timeout": 30,
                "unix_socket": null,
                "unredirected_headers": [],
                "unsafe_writes": false,
                "url": "https://jsonplaceholder.typicode.com/users/1",
                "url_password": null,
                "url_username": null,
                "use_gssapi": false,
                "use_netrc": true,
                "use_proxy": true,
                "validate_certs": true
            }
        },
        "json": {
            "address": {
                "city": "Gwenborough",
                "geo": {
                    "lat": "-37.3159",
                    "lng": "81.1496"
                },
                "street": "Kulas Light",
                "suite": "Apt. 556",
                "zipcode": "92998-3874"
            },
            "company": {
                "bs": "harness real-time e-markets",
                "catchPhrase": "Multi-layered client-server neural-net",
                "name": "Romaguera-Crona"
            },
            "email": "Sincere@april.biz",
            "id": 1,
            "name": "Leanne Graham",
            "phone": "1-770-736-8031 x56442",
            "username": "Bret",
            "website": "hildegard.org"
        },
        "msg": "OK (509 bytes)",
        "nel": "{\"report_to\":\"heroku-nel\",\"response_headers\":[\"Via\"],\"max_age\":3600,\"success_fraction\":0.01,\"failure_fraction\":0.1}",
        "pragma": "no-cache",
        "redirected": false,
        "report_to": "{\"group\":\"heroku-nel\",\"endpoints\":[{\"url\":\"https://nel.heroku.com/reports?s=bPhj6La0YC%2B3R7rXVC9ERQ77znxwjpqxfTUsHOoecEI%3D\\u0026sid=e11707d5-02a7-43ef-b45e-2cf4d2036f7d\\u0026ts=1761228021\"}],\"max_age\":3600}",
        "reporting_endpoints": "heroku-nel=\"https://nel.heroku.com/reports?s=bPhj6La0YC%2B3R7rXVC9ERQ77znxwjpqxfTUsHOoecEI%3D&sid=e11707d5-02a7-43ef-b45e-2cf4d2036f7d&ts=1761228021\"",
        "server": "cloudflare",
        "server_timing": "cfCacheStatus;desc=\"HIT\", cfEdge;dur=3,cfOrigin;dur=0",
        "status": 200,
        "url": "https://jsonplaceholder.typicode.com/users/1",
        "vary": "Origin, Accept-Encoding",
        "via": "2.0 heroku-router",
        "x_content_type_options": "nosniff",
        "x_powered_by": "Express",
        "x_ratelimit_limit": "1000",
        "x_ratelimit_remaining": "999",
        "x_ratelimit_reset": "1761228067"
    },
    "resolved_action": "ansible.builtin.uri",
    "start": "2025-12-06T17:03:29.372728+00:00",
    "task": "Get user from jsonplaceholder",
    "task_action": "uri",
    "task_args": "",
    "task_path": "/path/to/playbook/demo.yaml:6",
    "task_uuid": "695c9853-f1a2-d479-9ec4-000000000003",
    "uuid": "51c06d52-1058-4b61-945f-a68ae8521a79"
},

AIでさらに飛躍

昨今のAI開発技術の進歩により、ちょっと作ってみようかなって思ったときに目指せる距離が伸びた気がします。 ということで、ansible-navigatorのArtifactいい感じだし、整形されたものがブラウザで確認出来たらもっと便利だろうな~

ってことで簡単なツールを作ってみました。

トップ

詳細

タスク詳細

いい感じ!

Vibeで作ったから参照するディレクトリとかはべた書きですが、これをデータベースとかに格納するようにしたら後からプロジェクトとか追加できそうです。 そしたらいっそのことansible-navigatorコマンドをブラウザ経由で実行できるようにすればログも回収できるし、実行もできるのでは?

あれ、こんな感じのやつあったような、、、

_人人人人人_
> AWX <
 ̄Y^Y^Y^Y ̄

最後に

Ansible Navigatorは設定が面倒ですが、覚えてしまえば強力なツールです!

是非この機に乗り換えましょう。

Ansible NEC IXコレクションを2年越しにアップデートしました。

NEC IXルーター向けコレクションにOSPFとStatic RouteのNetwork Resource Moduleを追加しました!

galaxy.ansible.com

今回のアップデートでは下記のモジュールの追加を行いました。

  • rucdev.ix.ix_ospfv2
  • rucdev.ix.ix_ospfv3
  • rucdev.ix.ix_ospf_interfaces
  • rucdev.ix.ix_static_routes

前回の更新は2023年2月なのでおよそ2年8か月振りの更新となります。 前回の更新

OSPFモジュールを使ってみる

ここでは私の担当分であるOSPF周りについて少し触れます。

今回のアップデートで追加したモジュールはすべてcisco.iosコレクションを参考にしています。
なので基本的には、Ciscoモジュールの書き方と合わせる形で作成しています。

簡単なOSPFの設定例

---
- name: Ix ospf setting
  hosts: ix
  gather_facts: false
  tasks:
    - name: OSPF setting
      rucdev.ix.ix_ospfv2:
        config:
          processes:
            - process_id: 1
              areas:
                - area_id: 0
              network:
                - address: 192.0.2.128/25
                  area: 0

今回は以前よりもドキュメント周りを頑張ったので詳しくはAnsible GalaxyのDocsを見てください!

galaxy.ansible.com

Network Resource Moduleへの挑戦

今回のモジュール追加開発ではAnsibleのNetwork Resource Moduleの形での開発に挑戦しました。

Network Resource Modules

Developing network resource modules

AnsibleのNetwork Resource Moduleには開発するためのボイラープレートを作成してくれるResource module builderというツールがあり、 基本的にこれを利用して開発を進めていきます。

しかし、道は険しくNetwork Resource Moduleそのものの理解やネットワークOS固有のパラメータやバリデーションを吸収するためにはかなり詳しいネットワークOSへの理解が必要となり、ここにかなりの時間を割くことになりました。

色々あって2年ほどの時間を要することとなりましたが、それでも他のNetwork Resource Moduleのレベルにも達することができなかったので世のNetwork Resource Moduleの開発者には頭が上がりません。

まとめ

かなり久々の更新となってしまいましたが、AnsibleのNEC IX向けコレクションを更新しました! 皆様、使ってみてください。至らぬ点は多いかと思いますがフィードバックを雑多に投げてください(投げ先⇒https://github.com/Rucdev/ix_ansible/issues

そして一緒に開発を手伝ってくれた nakayumc0278 (nakayumc) · GitHub に感謝を
本当にありがとう!!

gemini-cliとtmuxを使った最先端のコーディングを体験する

CLIエージェント時代

AIによるコーディングが流行りだしてから、あっという間についていけないところまで行ってしまったという感覚があります。

最近はClaude CodeのMaxプランが登場し、私の観測範囲では猫も杓子もClaude CodeでVibe Codingしてるんじゃないか?というレベルで流行ってます。

そこでちょっと気になるものがこのtmuxを使用したこれ

これやりたい。

でもこういうのはトークンを消費しまくるからかなり及び腰でしたが、先日リリースされたgemini-cliでならなんとかなりそう!

cloud.google.com

なんとこれ、個人アカウントだと1日1000リクエストまで無料とのこと。 太っ腹すぎる。

ということで、gemini-cliとtmuxで並列実行での開発を体験していきます。

環境準備

ここからはWSL Debian12で作業を進めていきます。

gemini-cli

まずはgemini-cliをインストールします。

インストール方法はGitHubにあるリポジトリのREADME.mdを参照します。

公式ではNode.jsのver18以上を推奨していますが、私は個人的にbunが好きなのでbun addでいれます。

# インストール
$ bun add -g @google/gemini-cli

# 起動
$ gemini

初回起動すると認証を求められます。 APIキーを入力する方法とGoogleアカウントでの認証と主に2つの方法があります。 今回は個人アカウントで使いたいのでGoogleアカウントでの認証にしました。認証はブラウザ経由でできるのでここは非常にありがたいです。

これでgemini-cliは準備できました。

tmux

次にtmuxを入れていきます。

$ sudo apt install tmux

tmuxは~/.tmux.confvimのように設定を記述することができます。

今回はtmuxのコマンドはほぼAIが使うので特に設定はしません。

tmuxのコマンドについて

これを読めば大体OK

とほほのtmux入門 - とほほのWWW入門

今回重要なのはペインを割るコマンドと、別ペインにキー入力を送るコマンド。

キー 説明
Ctrl + b -> " ペインを縦方向に分割
Ctrl + b -> % ペインを横方向に分割
tmux send-keys -t %N ~ ペイン番号Nのペインにキー入力を送る

tmux + gemini-cliでコーディングをやっていく!

ツールの準備が完了したら次はgemini-cliがtmuxを効果的に使用してくれるようにルールを整備していきます。

tmuxで複数ペインでのAIエージェント実行は上司と部下的な組み合わせで行うのが、巷で話題のパターンなのでこれを体験してみます。

ミニマムなパターンで体験していく

まずは上司としてのmanagerペインと部下としてのdeveloperペインの2つである程度動かせるものかを試していきます。

この形でうまくいくなら、それを拡張していくことで夢が広がるってスンポーです。

初期プロンプトを作る

それぞれのペインで最初に読ませるプロンプトとなるファイルを作ります。

manager.md

このファイルを読んだ後はユーザーからの指示を待ってください。
# managerの役割

managerはユーザーから受けた指示をタスク単位で分解して`task.md`に書き出します。
タスクはチェックボックス形式で書き出し、進捗状況を記録できるようにしてください。
タスクは並列実行可能な単位でステップを区切ってください。
`task.md`に書き出したタスクを、developer対して割り当てて実装をさせます。
並列実行可能なタスクはdeveloperを複数利用して、並走させてください。
`task.md`が既に存在している場合は削除して再度新しく作成してください。

タスクはなるべく具体的な実装の方針に至るまで細かく分解してください。

mangerは自らコーディングをおこないません。

developerがmanagerに対して報告をしてきますので、報告を受けたらその内容を確認して次の指示を出します。

## developerへの指示について

developerは1, 2, 3と3つのエージェントが動いています。
managerはこれらを並行して指示します。
`task.md`の内容に基づき指示を送ります。
指示を送るときは必ず`task.md`を参照して、現在の進捗状況を確認してから指示を出します。
指示を送ったら、送り先のdeveloper番号を`task.md`に追記し、そのタスク内容をどのdeveloperに割り振ったのか確認できるようにしてください。

内容を送ってからenterを送るまでは1秒待機することを徹底してください。
pane番号はdeveloperがいるpaneの指定とdeveloperに対しての通知を兼ねています。

managerは以下の形式でtmuxコマンドを実行して、developerに指示を送信します。

バッククォートをはじめとする記号を指示に含める際は必ずエスケープしてください。

```
tmux send-keys -t <pane番号> "[task:to:<pane番号>]指示内容"
sleep 1
tmux send-keys -t <pane番号> Enter
```


## developerからの報告について

developerからmanagerへの報告は`[report:from:<pane番号>]`の接頭辞を持つ形で入力されます。
報告を受け取ったら`task.md`を確認し、developerには次のタスクを指示してください。

# ユーザーからの指示を完了した場合の処理

`task.md`に書き出したユーザーからの要件を達成した場合は作業内容をまとめてユーザーに返してください。

このファイルを読んだ後はmanagerからの指示を待ってください。

DeveloOerの役割

managerから具体的な指示をされるのでそれに合わせてコーディングを行ってください。

コーディングの進捗については

複数回同じ事象に遭遇するなど、解決が難しいと思われる課題についてはmanagerに報告を行い指示を仰いでください。

managerからdeveloperへの指示は[task]の接頭辞を持つ形で入力されます。

報告を行う際のコマンドについて

内容を送ってからenterを送るまでは1秒待機することを

報告内容を送った後にenterを押して報告を確定させます。

tmux send-keys -t 0 "[report][from:pane1]報告内容"
sleep 1
tmux send-keys -t 0 Enter
`developer.md`

このファイルを読んだ後はmanagerからの指示を待ってください。

DeveloOerの役割

managerから具体的な指示をされるのでそれに合わせてコーディングを行ってください。

複数回同じ事象に遭遇するなど、解決が難しいと思われる課題についてはmanagerに報告を行い指示を仰いでください。

developerは3体いるので自分が何番目のdeveloperであるかはmanagerからの指示を見て確認してください。

managerからの指示について

managerからdeveloperへの指示は[task:to:<pane番号>]の接頭辞を持つ形で入力されます。

<pane番号>は自身が何番目のdeveloperであるかを示す番号です。

報告を行う際のコマンドについて

内容を送ってからenterを送るまでは1秒待機することを徹底してください。

tmux send-keys -t 0 "[report:from:<pane番号>]報告内容"
sleep 1
tmux send-keys -t 0 Enter
### tmuxのペインとgemini-cliを用意する

tmuxのペインの構成とgemini-cliの起動までは人の手でやっていく必要がありそうです。(スクリプト化をして短縮はできそう。)

まずはtmuxを準備

```
$ tmux
```

起動したら`tmux splitw -h"で画面を横に分割できます。これを繰り返して、全部で4つの画面に分割しましょう。
分割した画面はCtrl + b -> Spaceで自動調整できるのでいい感じのレイアウトにしてください。
<figure class="figure-image figure-image-fotolife" title="4分割したtmux">[f:id:Ruc_4130:20250714085137p:plain]<figcaption>4分割したtmux</figcaption></figure>

ここからすべてのペインでgemini-cliを起動します。

```
# このコマンドを両方のペインで実施
gemini --yolo
```

gemini-cliが起動出来たら、pane0のmanagerペインでは`@prompts/manager.md`を実施してマネージャーとしての役割を認識させます。
pane1~3のdeveloperペインでは`@prompts/developer.md`を実施してデベロッパーとしての役割を認識させます。

```
# managerのpane(pane:0)
@prompts/manager.md
```

```
# developerのpane(pane:1~3)
@prompts/developer.md
```

mdファイルに記載した通り、読んだら指示を待ってくれます。
<figure class="figure-image figure-image-fotolife" title="指示待ち">[f:id:Ruc_4130:20250714090204p:plain]<figcaption>指示待ち</figcaption></figure>

準備が整うとこんな感じになります。
<figure class="figure-image figure-image-fotolife" title="準備OK">[f:id:Ruc_4130:20250714090849p:plain]<figcaption>準備OK</figcaption></figure>

あとは、manager役のpane0に作ってみたいものの指示を出してみましょう。
いい感じにdeveloper側のpaneを動かしながら物を作ってくれたりします。

# 結局

面白いは面白いんだけどpaneを連携させることによる効果はそこまで感じられなかったかなという印象。
タスク次第ではdeveloper1台だけしか動いてなくて他2台が暇してるとか、manager <-> developer間でタスクが延々とループしてしまったりとなかなかうまくいかない瞬間も多かったです。
ここら辺はLLMの性能による可能性もあります。Claudeならもっとよく動くのかな?

Terraformでサーバレスアプリケーション構築の挑戦

Terraformでサーバレスを作る

クラウドの自動化などでTerraformを使うときはVPC、EC2を構築することが多くサーバレスの部分に触れる機会が少なかったので、 Terraformを使ってAWS上にEC2の仮想マシンを使用しないサーバレスの形のアプリケーション作成に挑戦してみようと思います。

公式にチュートリアルがあったのでそれをTerraformで作る形に書き直してみます。

docs.aws.amazon.com

構成

インフラ

チュートリアルの構成だと

アプリケーション

  • フロントはHTMLのペライチ
  • Lambdaで代用するバックエンドはNode.js

実行環境の用意

AWSのCloudShellを実行環境とします。
CloudShellではデフォルトでAWS CLIが入っている関係でTerraformを使う上で必要な認証をスキップできます。これが地味に嬉しい。

まずはCloudShellを開いてTerraformを入れます。

CloudShellはAWSコンソールのバーかシェルのマークをクリックすると起動できます。

AWS Console

下記のスクリプトでCloudShellにTerraformをインストールできます。
AWSのCloudShellはAmazon Linuxベースなので、公式のAmazon Linux用の手順を参照します。

developer.hashicorp.com

sudo yum install -y yum-utils
sudo yum-config-manager --add-repo https://rpm.releases.hashicorp.com/AmazonLinux/hashicorp.repo
sudo yum -y install terraform

これで実行環境の用意ができました。

AWSのCloudShellでは一定時間でホームディレクトリ以外は再利用されてしまうので、上記のTerraform導入部分はスクリプトファイルにしておくと時間を空いた際にも再開しやすいです。

tfファイルを作成する

Terraformでの構築で利用するtfファイルを作成します。

今回は特にモジュールなどは使用せずにAWSプロバイダーとルートモジュールのみで構築します。

ということで、まずはプロバイダー周りの設定を入れます。

main.tfファイルを作成し、Terraformの基本設定部分を入れます。
今回はAWSの東京リージョンを使っていきます。

terraform {
  required_version = ">=1.9.7" # terraformのバージョンを固定
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "5.70.0" # AWSプロバイダーのバージョンを固定
    }
  }
}

provider "aws" {
  region = "ap-northeast-1" # 東京リージョンを指定
}

AWSリソースを作成

今回は下記の構成を作っていきます。

AWS構成図イメージ

S3からCloudFrontへ伸ばせるとより良い形になると思います。
ですが、簡単に始めるには重いのでここでは省きます。

この構成を用いて簡単なタスク管理のアプリケーションを作っていきます。

DynamoDB

まずはアプリケーションで利用するデータのためのDBを作ります。

パーティションキーはTerraformではhash_keyとなります。

# DynamoDB
resource "aws_dynamodb_table" "main" {
  name         = "http-crud-tutorial-items"
  billing_mode = "PROVISIONED"
  read_capacity = 1
  write_capacity = 1
  hash_key     = "id"

  attribute {
    name = "id"
    type = "S"
  }
}

上記をmain.tfに追記します。

Lambda

続いてDynamodbを操作するLambda関数を作成します。

ランタイムはNode.jsを使用します。 Node.jsのコードは公式のものをそのまま利用します。
チュートリアルに記載のものindex.mjsとして保存しておきます。

リソースとしてはaws_lambda_functionとLambdaに適用するaws_iam_role、DyanmoDBを操作するためのポリシーと、そのアタッチメントを作成します。

また、Lambdaはコードを配置する際にzip化もしくはS3においておく必要がありますが、Terraformのarchive_fileのデータリソースでzip化が可能なのでそれを採用しています。

# Lambdaにあてるポリシーステートメント
data "aws_iam_policy_document" "lambda_assume_role_policy" {
  statement {
    actions = ["sts:AssumeRole"]
    principals {
      type        = "Service"
      identifiers = ["lambda.amazonaws.com"]
    }
  }
}

# Lambdaに設定するiam role
resource "aws_iam_role" "lambda_role" {
  name               = "http-crud-tutorial-role"
  assume_role_policy = data.aws_iam_policy_document.lambda_assume_role_policy.json
}

# DynamoDBへのアクセスを許可するポリシーステートメント
data "aws_iam_policy_document" "dynamodb_policy" {
  statement {
    actions = [
      "dynamodb:PutItem",
      "dynamodb:GetItem",
      "dynamodb:UpdateItem",
      "dynamodb:DeleteItem",
      "dynamodb:Scan",
    ]
    resources = [
      aws_dynamodb_table.main.arn
    ]
  }
}

# DynamoDBへのアクセス許可ポリシー
resource "aws_iam_policy" "dynamodb_policy" {
  name        = "http-crud-tutorial-dynamodb-policy"
  description = "Policy to allow Lambda to access DynamoDB"

  policy = data.aws_iam_policy_document.dynamodb_policy.json
}

# DynamoDBへのアクセス許可をLambdaのiam roleへアタッチ
resource "aws_iam_role_policy_attachment" "attach_policy" {
  role       = aws_iam_role.lambda_role.name
  policy_arn = aws_iam_policy.dynamodb_policy.arn
}

# Lambdaで使用する関数をファイルをzip化
data "archive_file" "lambda" {
  type = "zip"
  source_file = "index.mjs"
  output_path = "function_payload.zip"
}

# Lambda
resource "aws_lambda_function" "main" {
  function_name = "http-crud-tutorial-function"
  handler       = "index.handler"
  runtime       = "nodejs20.x"

  role = aws_iam_role.lambda_role.arn
  filename = data.archive_file.lambda.output_path
}

API Gateway

ステップ3

API Gatewayをまずは作成します。

# API Gateway
resource "aws_apigatewayv2_api" "main" {
  name="http-crud-tutorial-api"
  protocol_type = "HTTP"
}

上記をmain.tfに追記。

API GatewayとLambdaを統合する

LambdaとAPI Gateway、この2つのリソースを統合する設定をmain.tfに追記します。
ちょっと長いですが下記となります。

# Lambdaに統合するための統合設定とルート設定
resource "aws_apigatewayv2_integration" "main" {
  api_id = aws_apigatewayv2_api.main.id
  connection_type = "INTERNET"
  integration_method = "POST"
  integration_uri = aws_lambda_function.main.invoke_arn
  integration_type = "AWS_PROXY"
  payload_format_version = "2.0"
}

resource "aws_apigatewayv2_route" "get_all" {
  api_id = aws_apigatewayv2_api.main.id
  route_key = "GET /items"
  target = "integrations/${aws_apigatewayv2_integration.main.id}"
}

resource "aws_apigatewayv2_route" "put_item" {
  api_id = aws_apigatewayv2_api.main.id
  route_key = "PUT /items"
  target = "integrations/${aws_apigatewayv2_integration.main.id}"
}

resource "aws_apigatewayv2_route" "get_item" {
  api_id = aws_apigatewayv2_api.main.id
  route_key = "GET /items/{id}"
  target = "integrations/${aws_apigatewayv2_integration.main.id}"
}

resource "aws_apigatewayv2_route" "delete_item" {
  api_id = aws_apigatewayv2_api.main.id
  route_key = "DELETE /items/{id}"
  target = "integrations/${aws_apigatewayv2_integration.main.id}"
}

# API Gatewayのデプロイ設定
resource "aws_apigatewayv2_stage" "main" {
  api_id      = aws_apigatewayv2_api.main.id
  auto_deploy = true # 自動デプロイを有効にする
  name        = "$default"
}

# LambdaのAPI Gatewayからの呼び出し許可設定
resource "aws_lambda_permission" "lambda_permission" {
  statement_id  = "AllowExecutionFromAPIGateway"
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.main.function_name
  principal     = "apigateway.amazonaws.com"

  source_arn = "${aws_apigatewayv2_api.main.execution_arn}/*/*"
}

これでチュートリアルに記載されているリソースをTerraformで記述することができました。

main.tf

terraform {
  required_version = ">=1.9.7" # terraformのバージョンを固定
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "5.70.0" # AWSプロバイダーのバージョンを固定
    }
  }
}

provider "aws" {
  region = "ap-northeast-1" # 東京リージョンを指定
}

# DynamoDB
resource "aws_dynamodb_table" "main" {
  name           = "http-crud-tutorial-items"
  billing_mode   = "PROVISIONED"
  read_capacity  = 1
  write_capacity = 1
  hash_key       = "id"

  attribute {
    name = "id"
    type = "S"
  }
}

# Lambdaにあてるポリシーステートメント
data "aws_iam_policy_document" "lambda_assume_role_policy" {
  statement {
    actions = ["sts:AssumeRole"]
    principals {
      type        = "Service"
      identifiers = ["lambda.amazonaws.com"]
    }
  }
}

# Lambdaに設定するiam role
resource "aws_iam_role" "lambda_role" {
  name               = "http-crud-tutorial-role"
  assume_role_policy = data.aws_iam_policy_document.lambda_assume_role_policy.json
}

# DynamoDBへのアクセスを許可するポリシーステートメント
data "aws_iam_policy_document" "dynamodb_policy" {
  statement {
    actions = [
      "dynamodb:PutItem",
      "dynamodb:GetItem",
      "dynamodb:UpdateItem",
      "dynamodb:DeleteItem",
      "dynamodb:Scan",
    ]
    resources = [
      aws_dynamodb_table.main.arn
    ]
  }
}

# DynamoDBへのアクセス許可ポリシー
resource "aws_iam_policy" "dynamodb_policy" {
  name        = "http-crud-tutorial-dynamodb-policy"
  description = "Policy to allow Lambda to access DynamoDB"

  policy = data.aws_iam_policy_document.dynamodb_policy.json
}

# DynamoDBへのアクセス許可をLambdaのiam roleへアタッチ
resource "aws_iam_role_policy_attachment" "attach_policy" {
  role       = aws_iam_role.lambda_role.name
  policy_arn = aws_iam_policy.dynamodb_policy.arn
}

# Lambdaで使用する関数をファイルをzip化
data "archive_file" "lambda" {
  type        = "zip"
  source_file = "index.mjs"
  output_path = "function_payload.zip"
}

# Lambda
resource "aws_lambda_function" "main" {
  function_name = "http-crud-tutorial-function"
  handler       = "index.handler"
  runtime       = "nodejs20.x"
  role          = aws_iam_role.lambda_role.arn
  filename      = data.archive_file.lambda.output_path
}

# API Gateway
resource "aws_apigatewayv2_api" "main" {
  name          = "http-crud-tutorial-api"
  protocol_type = "HTTP"
  cors_configuration {
    allow_headers = ["content-type"]
    allow_methods = ["*"]
    allow_origins = ["*"]
  }
}

# Lambdaに統合するための統合設定とルート設定
resource "aws_apigatewayv2_integration" "main" {
  api_id                 = aws_apigatewayv2_api.main.id
  connection_type        = "INTERNET"
  integration_method     = "POST"
  integration_uri        = aws_lambda_function.main.invoke_arn
  integration_type       = "AWS_PROXY"
  payload_format_version = "2.0"
}

resource "aws_apigatewayv2_route" "get_all" {
  api_id    = aws_apigatewayv2_api.main.id
  route_key = "GET /items"
  target    = "integrations/${aws_apigatewayv2_integration.main.id}"
}

resource "aws_apigatewayv2_route" "put_item" {
  api_id    = aws_apigatewayv2_api.main.id
  route_key = "PUT /items"
  target    = "integrations/${aws_apigatewayv2_integration.main.id}"
}

resource "aws_apigatewayv2_route" "get_item" {
  api_id    = aws_apigatewayv2_api.main.id
  route_key = "GET /items/{id}"
  target    = "integrations/${aws_apigatewayv2_integration.main.id}"
}

resource "aws_apigatewayv2_route" "delete_item" {
  api_id    = aws_apigatewayv2_api.main.id
  route_key = "DELETE /items/{id}"
  target    = "integrations/${aws_apigatewayv2_integration.main.id}"
}

# API Gatewayのデプロイ設定
resource "aws_apigatewayv2_stage" "main" {
  api_id      = aws_apigatewayv2_api.main.id
  auto_deploy = true # 自動デプロイを有効にする
  name        = "$default"
}

# LambdaのAPI Gatewayからの呼び出し許可設定
resource "aws_lambda_permission" "lambda_permission" {
  statement_id  = "AllowExecutionFromAPIGateway"
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.main.function_name
  principal     = "apigateway.amazonaws.com"
  source_arn    = "${aws_apigatewayv2_api.main.execution_arn}/*/*"
}

フロントを追加する

最後にチュートリアルにはない内容としてS3のWebホスティング機能を使ってS3にHTMLファイルを配置します。

HTMLファイルはペライチで作るのでAPI Gatewayのエンドポイントを記述する必要があります。
Terraformのlocal_fileリソースとtemplatefileのメソッドを利用すると、テンプレートファイルをレンダリングして変数を書き込むことができます。

このテンプレートの書き方についてはこちら。

templatefile - Functions - Configuration Language | Terraform | HashiCorp Developer

私見ですが、なんかJinja2っぽいようなShellっぽいような感じでちょっと書きづらいです。

エンドポイントの部分に変数を埋める形のHTML形式のテンプレートファイルとしてindex.tftplを作成しました。

index.tftpl

<!DOCTYPE html>
<html lang="ja">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Item List</title>
</head>

<body>
    <h1>商品リスト</h1>
    <table id="item-table" border="1">
        <thead>
            <tr>
                <th>id</th>
                <th>品名</th>
                <th>価格</th>
            </tr>
        </thead>
        <tbody id="item-table-body">
        </tbody>
    </table>
    <h1 id="form-info">新規作成フォーム</h1>
    <form id="item-form">
        <input id="item-form-id" name="id" type="number" hidden>
        <div class="item-form-row">
            <label for="item-form-name">品名</label>
            <input id="item-form-name" name="name" required>
        </div>
        <div class="item-form-row">
            <label for="item-form-price">価格</label>
            <input id="item-form-price" name="price" type="number">
        </div>
        <div class="submit-btn-row">
            <button class="submit-btn" type="submit">送信</button>
            <button class="clear-btn" type="button" onclick="handleClear()">入力内容をクリア</button>
        </div>
    </form>
    <script>
        // ここにエンドポイントのURLをレンダリングしてもらう
        const endpoint = "${endpoint}";
        /* tdエレメントを作成する関数 */
        function createTableData(text) {
            const tableData = document.createElement("td")
            tableData.textContent = text
            return tableData
        }
        /* dynamoDBのデータを取得しtableの行を作成する関数 */
        async function fetchData() {
            const response = await fetch(
                `${endpoint}/items`,
                {
                    method: "GET", mode: "cors",
                    headers: {
                        'Content-Type': 'application/json;charset=utf-8'
                    }
                }
            )
            const data = await response.json()

            const tableBody = document.getElementById("item-table-body")
            // idの順に帰ってくるわけではないのでソートをする
            data.sort((a, b) => Number(a.id) - Number(b.id)).forEach((item, index) => {
                const tableRow = document.createElement("tr")
                tableRow.appendChild(createTableData(item.id))
                tableRow.appendChild(createTableData(item.name))
                tableRow.appendChild(createTableData(item.price))
                tableRow.onclick = () => {
                    parseForm(item, "編集フォーム")
                }
                tableBody.appendChild(tableRow)
                // 動的styleを追加
                const style = document.createElement("style")
                style.textContent = `
                    tr {
                        cursor: pointer;
                    }
                    
                    tr:hover {
                        opacity: 0.5;
                    }
                `
                tableBody.appendChild(style)
            });
            // localStorageに新規のIDをセットする
            localStorage.setItem("newId", String(Number(data.length) + 1))
        }

        function parseForm(data, title) {
            // 要素取得
            const idInput = document.getElementById("item-form-id")
            const nameInput = document.getElementById("item-form-name")
            const priceInput = document.getElementById("item-form-price")
            const formTitle = document.getElementById("form-info")
            // データを埋める
            idInput.value = data.id
            nameInput.value = data.name
            priceInput.value = data.price
            // フォームタイトルを変える
            formTitle.textContent = title
        }
        function handleClear() {
            parseForm({ id: null, name: "", price: null }, "新規作成フォーム")
        }

        const form = document.getElementById("item-form")
        form.addEventListener("submit", (ev) => {
            ev.preventDefault()
            const formData = new FormData(ev.target)
            const id = formData.get("id")
            console.log(id === "" ? localStorage.getItem("newId") : id)
            console.log(formData.get("name"))
            console.log(formData.get("price"))
            fetch(
                `${endpoint}/items`,
                {
                    method: "PUT",
                    body: JSON.stringify({
                        id: id === "" ? localStorage.getItem("newId") : id,
                        name: formData.get("name"),
                        price: Number(formData.get("price"))
                    }),
                    mode: "cors",
                }
            ).then(() => {
                location.reload()
            })
        })
        fetchData()
    </script>
</body>
<style>
    h1 {
        text-align: center;
    }

    #item-table {
        margin: auto;
    }

    #item-form {
        margin: auto;
    }

    .item-form-row {
        display: flex;
        justify-content: center;
        padding: 1rem;
    }

    .item-form-row label {
        text-align: right;
        padding-right: 5rem;
    }

    .submit-btn-row {
        display: flex;
        justify-content: center;
    }

    .submit-btn-row button {
        margin: 2%;
    }
</style>

</html>

こんな感じの画面を出してくれます。

フォーム画面

そうしたら、これをレンダリングしたindex.htmlを作成するlocal_fileリソースをmain.tfに追記します。

# API Gatewayのエンドポイントをファイルに書き出す
resource "local_file" "main" {
  content = templatefile("index.tftpl", {
    endpoint = aws_apigatewayv2_api.main.api_endpoint
    }
  )
  filename = "index.html"
}

続いてこのHTMLファイルをS3に配置する設定を追加します。

Terraformリソースとして作成するのは下記。

S3は静的ホスティングの設定もあるのですが、今回index.htmlのペライチだとパブリックアクセスで十分

Terraformコードは下記をmain.tfに追加します。

# S3
resource "aws_s3_bucket" "main" {
  bucket = "http-crud-tutorial-front"
  tags = {
    Name = "http-crud-tutorial-front"
  }
}

resource "aws_s3_object" "index_html" {
  depends_on   = [local_file.main]
  bucket       = aws_s3_bucket.main.id
  key          = "index.html"
  source       = "index.html"
  content_type = "text/html"
}

# 外部からのアクセスを許可する
resource "aws_s3_bucket_public_access_block" "main" {
  bucket                  = aws_s3_bucket.main.id
  block_public_acls       = false
  block_public_policy     = false
  ignore_public_acls      = false
  restrict_public_buckets = false
}

data "aws_iam_policy_document" "s3_allow_access" {
  statement {
    sid    = "Statement1"
    effect = "Allow"
    principals {
      type        = "*"
      identifiers = ["*"]
    }
    actions = [
      "s3:GetObject"
    ]
    resources = [
      "${aws_s3_bucket.main.arn}/*"
    ]
  }
}

resource "aws_s3_bucket_policy" "main" {
  depends_on = [aws_s3_bucket_public_access_block.main]
  bucket     = aws_s3_bucket.main.id
  policy     = data.aws_iam_policy_document.s3_allow_access.json
}

# 現在のリージョンを取得
data "aws_region" "current" {}
# アクセス用のURLを表示
output "s3_url" {
  value = "https://${aws_s3_bucket.main.bucket}.s3.${data.aws_region.current.name}.amazonaws.com/${aws_s3_object.index_html.key}"
}

リソース作成の後にoutputindex.htmlのオブジェクト URLを表示させています。

Terraformを実行

main.tfが完成したら実行して、AWSリソースを作成していきます。

$ terraform apply
(省略)

Apply complete! Resources: 18 added, 0 changed, 0 destroyed.

Outputs:

s3_url = "https://http-crud-tutorial-front.s3.ap-northeast-1.amazonaws.com/index.html"

リソースの作成に成功したら最後に表示されるアドレスにアクセスしてみましょう。
新規作成フォームでアイテム追加ができることを確認できれば完成です!

作成したリソースを削除

最後に作成したリソースを忘れずに削除します。

$ terraform destroy

まとめ

AWS公式チュートリアルをTerraformで書き換えてみましたが、思ったよりWebコンソールはリソースを自動で作ってくれているということを実感しました。

特にポリシー周り。

IAMロールの仕組みとか結構あいまいな理解をしているので改めて勉強せねば。