Proxmoxで始めるAnsible入門

自宅のProxmoxクラスタでLXCコンテナが10台を超えてきて、アップデートするたびに1台ずつSSHして回るのがしんどくなってきました。そろそろAnsibleを使うタイミングかなということで導入してみました。

TL;DR

  • AnsibleはMacにbrewで入ります
  • ProxmoxノードへのSSH鍵配布は ssh-copy-id
  • コンテナへの鍵配布は pct exec 経由でProxmoxノードからやります
  • 全台アップデートは DEBIAN_FRONTEND=noninteractive apt-get upgrade -y をshellモジュールで叩くのが確実です
  • 構文はわかりにくい。
  • Pythonバージョン問題がカス

環境

  • Proxmox VE 8.1.4(4ノードクラスタ: pve1〜pve4)
  • LXCコンテナ 10台(Ubuntu 20.04/22.04、Debian 11混在)
  • 手元はMac

そもAnsibleって何

複数のサーバーに同じ操作を自動でやってくれるツールです。

コンテナが10台あって全部にパッケージを入れたいとき、普通は1台ずつSSHして回ります。(絶対にもっと楽な方法はあると思いますが)

ssh [email protected]
apt-get install -y vim
# 次のコンテナへ…
ssh [email protected]
apt-get install -y vim
# あと8台…

Ansibleを使うとこれが1コマンドで終わります。

ansible all -m apt -a "name=vim state=present"

何をするかをYAMLファイルに書いておいて、対象のサーバー全台に一括で実行する、というのが基本的な使い方です。

似たようなツールにChefやPuppet、Terraformがあります。

ツールエージェント学習コスト用途
Ansible不要低めサーバー設定・操作
Chef / Puppet必要高めサーバー設定
Terraform不要中くらいインフラの構築(クラウド向け?s)

Ansibleの一番の特徴はエージェントが不要なところで、対象サーバーにPythonとSSHが入っていれば動きます。自宅サーバーに余計なソフトを入れたくない場合はこれが地味に嬉しいです。どこぞのVScode、お前のことやぞ


よかったところ

SSHさえ通れば動く

Chef/Puppetは対象サーバーにエージェントを入れる必要があります。Ansibleはエージェント不要なので、既存のサーバーにもそのまま使えます。

YAMLで書ける(と言われる)

Playbookと呼ばれる手順書はYAMLで書きます。3行の例なら確かにシェルより読みやすいです。ただ後述のとおり、本番のPlaybookに入ると話は別になります。

冪等性を意識して書ける(モジュール次第)

何言ってんのかわからんって人向け:何回実行しても結果が同じになるという性質のこと。

Ansibleの宣伝だと「冪等性がある!」みたいに聞こえますが、正確には 冪等性がある書き方をしないといけない です。apt モジュールで state: present と書けば、すでに入っていれば何もしない・入っていなければ入れる、という動きになります。こういう「望ましい状態」を宣言する書き方をすると、2回目以降の実行が安全です。

一方、今回みたいに shellapt-get upgrade を毎回叩く書き方は、毎回コマンドが走るので冪等とは言いにくいです。壊れるわけではないけど、「もう一回実行したら何も変わらない」とは限りません。結局、冪等性はAnsibleが勝手についてくる機能というより、適切なモジュールと state を選ぶ設計の話です。

インベントリがそのまま台帳になる

どのサーバーがどのIPか、インベントリファイルに全部書くことになるので、それがそのまま管理台帳になります。「あのコンテナのIPなんだっけ」がなくなりました。


カスなところ

正直に書きます。導入はしたけど、構文がわかりにくくて書きにくいのが一番のストレスです。「Infrastructure as Codeで宣言的に!」みたいなキラキラした話と、実際にPlaybookを書いてる体感がかなりズレます。

同じ操作の書き方がバラバラ

たとえばパッケージを入れるだけでも、最低3通りあります。

# 1. ad-hoc(コマンドライン)
ansible all -m apt -a "name=vim state=present"
# 2. Playbook・キー=値っぽい書き方
- name: vimをインストール
  apt: name=vim state=present
# 3. Playbook・辞書形式(公式ドキュメント寄り)
- name: vimをインストール
  ansible.builtin.apt:
    name: vim
    state: present

どれも正しいですね〜。でも初見だと「結局どれを覚えればいいの?」となります。しかもモジュール名が apt だったり ansible.builtin.apt だったり、FQCN(完全修飾名)を求められるバージョンもある。ドキュメントを見るたびに表記が違ってて地味に疲れます。

用語と階層が直感的じゃない

最初に覚える単語が多いです。

用語ざっくり意味
inventory対象ホストの名簿(hosts.ini
play「このグループに、この手順を」という1ブロック
task実際の1操作(aptshell など)
moduletaskの中で呼ぶ機能単位
playbookplayとtaskをまとめたYAMLファイル
role再利用用のまとまり(今回は未使用)

「Playbookの中のplayのtasksでmoduleを呼ぶ」<- 意識高い系みたいで、紙の上では筋が通ってるんですが、エディタを開いて最初の1本を書くときは毎回迷子になります。hosts: にグループ名を書くのか all なのか、tasks: の下に - name: が来るのか、どこまでインデントするのか、全部暗記ゲーです。

Jinja2が紛れ込んでくる

ループや条件を書くと、YAMLの中にJinja2テンプレートが入ります。今回の鍵配布Playbookだとこんな感じ。

loop: "{{ node_containers[inventory_hostname] }}"

{{ }} の中はPythonっぽい式が書けるので便利な反面、「ここはYAML?Jinja?シェル?」の三重境界になりがちです。shell モジュールの中に {{ item }} を埋め込むと、クォートのネストでハマります。

# pubkeyをシングルクォートで囲んで、シェル側のクォートと喧嘩しないようにした
pct exec {{ item }} -- sh -c "echo '{{ pubkey }}' >> /root/.ssh/authorized_keys"

動くけど、これを他人に読ませるのは気が引けます。だって別にこれだったら素のshellの方がわかりやすいじゃんっていう。

YAMLそのものの罠

インデント1つズレると死ぬのは有名どころですが、それ以外にも地雷があります。

# これで動く
- name: なんか
  shell: |
    echo hello

# インデント1つズレると死ぬ
- name: なんか
 shell: |    ← ここがズレてるだけでエラー
    echo hello

折り返し(>)とリテラル(| も毎回悩みます。今回のアップデートPlaybookは > を使っています。

shell: >
  DEBIAN_FRONTEND=noninteractive apt-get update &&
  ...

> は改行をスペースに潰して1行に連結します。| は改行をそのまま残します。シェルに渡す長いコマンドだとどっちが正しいか毎回ググります。

真偽値 も統一感がありません。ignore_errors: yes と書いても動くし、true / false もある。古い記事は yes/no、新しい例は true/false で、コピペ元によってブレます。

モジュールの引数が直感に反する

ansible-doc apt を見ても、必須かどうか・デフォルト値・型が一覧になっているだけで、「このサーバー向けに何を書けばいいか」は結局試行錯誤です。

  • state: presentstate: latest の違いは名前からは微妙
  • shellcommandraw の使い分けは経験則
  • 結局 shellapt-get を叩くと、冪等に書けたつもりでも毎回コマンドが走る

今回 apt モジュールが python3-apt を要求して使えなかったので、結局ほぼ全部 shell に逃げました。Ansibleを入れたのに結局シェルスクリプトをYAMLで包んでるだけで、宣伝で聞く「冪等性」の恩恵もほぼ受けられていません。動くけど、気持ちよくはない。

ad-hocとPlaybookで文法が違う

-m(モジュール)と -a(引数)の ad-hoc 形式は、PlaybookのYAMLとは別物です。

ansible all -m ping
ansible containers -m shell -a "uptime"

Playbookに落とすとキー構造が変わるので、Stack Overflowの回答をそのままコピペできないことが多いです。「動いたコマンドをPlaybook化する」たびに翻訳作業が発生します。

エラーメッセージが読みにくい

失敗したときのエラーが長くてどこが原因かわかりにくいです。今回も Module result deserialization failed とか言われて、最初は何が起きてるのか全然わかりませんでした。

Pythonのバージョン問題がある

Ansibleは対象サーバー側にもPythonが必要で、しかもバージョン要件があります。今回はUbuntu 20.04のコンテナがPython 3.8だったせいでAnsible 2.20に弾かれました。古いOSのコンテナが混在してると地味に面倒です。

aptモジュールがpython3-aptを要求する

aptモジュールを使うと内部で python3-apt というパッケージを要求してきます。これが入っていないと動かなくて、今回はしょうがなくshellモジュールで直接apt-getを叩くことにしました。せっかくのモジュールが使えないのはちょっと残念です。

実行が遅い

全台に対してSSHして処理するので、台数が増えると時間がかかります。自宅サーバー規模だと許容範囲ですが、それでもシェルスクリプトを直接実行するより遅いです。


Ansibleのインストール

Macなのでbrewで入ります。

brew install ansible

作業用のディレクトリを作っておきます。Gitで管理したかったので既存のリポジトリに追加しました。

mkdir -p ~/ansible/inventory ~/ansible/playbooks

インベントリを作る

Ansibleにとってのインベントリは「どのホストを管理するか」の名簿です。hosts.ini に書いていきます。

[pve_nodes]
pve1 ansible_host=192.168.1.11
pve2 ansible_host=192.168.1.12
pve3 ansible_host=192.168.1.13
pve4 ansible_host=192.168.1.14

[containers]
app1 ansible_host=192.168.1.21
app2 ansible_host=192.168.1.22
app3 ansible_host=192.168.1.23
# … 実際の台数分ホストを並べる

[all:vars]
ansible_user=your-user

ansible.cfg も作っておきます。

[defaults]
inventory = inventory/hosts.ini
host_key_checking = False
private_key_file = ~/.ssh/id_ed25519
interpreter_python = auto_silent

host_key_checking = False にしておかないと初回接続時に毎回確認が入って面倒です。interpreter_python = auto_silent はPythonバージョンの警告を黙らせるためです。


SSH鍵を全台に配る

Proxmoxノードへの配布

これは普通に ssh-copy-id でOKです。

for host in 192.168.1.11 192.168.1.12 192.168.1.13 192.168.1.14; do
  ssh-copy-id your-user@$host
done

パスワードを4回聞かれるので入力します。

コンテナへの配布

コンテナへは直接SSH鍵を送れないので、ProxmoxノードのシェルからPlaybookで配布します。各ノードは自分が管理しているコンテナにしか pct exec できないので、ノードとコンテナの対応をvarsに書く必要があります。

# playbooks/distribute-ssh-key.yml
- name: SSH鍵を配布
  hosts: pve_nodes
  vars:
    pubkey: "{{ lookup('file', '~/.ssh/id_ed25519.pub') }}"
    node_containers:
      pve1: [100, 101]
      pve2: [102, 103]
      pve3: [104]
      pve4: [105]
  tasks:
    - name: 鍵を配布
      shell: |
        pct exec {{ item }} -- sh -c "mkdir -p /root/.ssh && chmod 700 /root/.ssh && echo '{{ pubkey }}' >> /root/.ssh/authorized_keys && chmod 600 /root/.ssh/authorized_keys"
      loop: "{{ node_containers[inventory_hostname] }}"
      ignore_errors: yes
ansible-playbook playbooks/distribute-ssh-key.yml

疎通確認します。

ansible all -m ping

全台から pong が返ってきたら完成です。


つまづいたところ

Privilege Separationが有効になっていてAPIが使えなかった

ansibleとは直接関係ないですが、ProxmoxのAPIトークンを使ってコンテナ一覧を取得しようとしたら全部空で返ってきました。Proxmox管理画面の Datacenter → Permissions → API TokensPrivilege Separation にチェックが入っていると、rootでもトークン単体の権限に制限されます。外せば解決しました。

Python 3.8のコンテナがAnsibleに対応していなかった

Ubuntu 20.04のコンテナがいくつかPython 3.8のままで、Ansible 2.20はPython 3.9以上を要求するので弾かれました。

# pve_nodes経由でpython3.9を入れる
pct exec 100 -- sh -c "apt-get update && apt-get install -y python3.9"

pct execはそのノードのコンテナにしか使えない

Playbookで全ノードに全コンテナIDを渡すと Configuration file 'nodes/pve1/lxc/101.conf' does not exist というエラーが大量に出ました。各ノードが管理しているコンテナIDをvarsで対応付けする必要があります。

aptモジュールがpython3-aptを要求する

apt モジュールを使うと python3-apt パッケージが必要で、Python 3.9を入れた直後のコンテナにはそれが入っていないことがありました。shell モジュールで直接 apt-get を叩く方が依存が少なくて確実でした。


一括アップデートPlaybookを作る

# playbooks/update-all.yml
- name: 全コンテナ・ノードをアップデート
  hosts: all
  tasks:
    - name: apt update & upgrade
      shell: >
        DEBIAN_FRONTEND=noninteractive apt-get update &&
        DEBIAN_FRONTEND=noninteractive apt-get upgrade -y
        -o Dpkg::Options::="--force-confdef"
        -o Dpkg::Options::="--force-confold" &&
        apt-get autoremove -y
      ignore_errors: yes

DEBIAN_FRONTEND=noninteractive を付けないとアップデート中に対話プロンプトが出て止まります。--force-confdef--force-confold は設定ファイルの差分が出たとき自動でデフォルト値を使うオプションです。

実行します。

ansible-playbook playbooks/update-all.yml

13台が並列でアップデートされていくのは見ていて楽しいです。ただし4ノード同時にディスクI/Oを食うので、ProxmoxのWeb UIが重くなる時間帯は覚悟した方がいいです(I/O waitが跳ねます)。

並列数を絞りたいときは ansible.cfgforks = 5 のように書くか、-f 5 オプションで調整できます。


書き方のコツ(自分用メモ)

構文がクソ書きにくいのは変わらないので、無理に全部Playbook化せず、動くところから足していく方針にしました。

  1. まず ansible all -m ping で疎通だけ確認する
  2. 繰り返し使う操作だけPlaybookにする(今回は鍵配布とアップデートの2本)
  3. モジュールでハマったら shell に逃げて先に運用を回す(美化はあと)
  4. ansible-playbook playbooks/xxx.yml --syntax-check でYAMLの文法ミスだけ先に潰す
  5. 1台だけ試す: ansible-playbook ... --limit app1

--check(ドライラン)もあるけど、shell モジュールだとほぼ意味がないので、limitで1台試す方が現実的でした。


振り返り

コンテナが10台を超えると「全台にSSHして回る」のが地味に面倒で、アップデートをサボりがちになっていました。Ansibleを入れてからは ansible-playbook playbooks/update-all.yml の1コマンドで済むので、面倒さはだいぶ減りました。

一方で、宣伝とかけ離れている感じはしました。「YAMLで宣言的に美しく」みたいな理想と、実際は shell とJinja2とインデントと格闘しながら動かす現実のギャップは大きいです。それでも「全台に同じことを安全に一発」はできるので、コンテナが増えてきたら試す価値はあります。ただし最初の1,2時間はキレそうになるので、余裕のあるタイミングでやることをおすすめします。

そもそも私の使い方、考え方が間違っている可能性も大いにありますが…


参考