TL; DR;
- Packerを使ってKVMのディスクイメージをAnsibleによる設定をすませた状態で作成できるようにしました。
- 作成したイメージは利用時にcloud-initによる設定も可能です。
モチベーション
我が家ではKVM上のVMを用いてKubernetesクラスタを運用しており、Terraformで構築したVMに対してAnsibleで設定の投入・必要なソフトウェアのインストールを行ってきました。
これでも動作はするのですが、運用していくにつれてバージョンのアップグレードのオペレーションが煩雑になるという問題点が浮き彫りになってきています。 例えばKubernetesをアップグレードするときにはノードのcordon・コンポーネントのアップグレードを1台ずつやっていきますが、どのノードまでやったのかが管理しにくい、全ての作業を1つのプレイブックにまとめると肥大化して管理しにくいなどの問題点があります。
そこで発想を変えて、既存のノードのバージョンをアップするのではなく、アップグレード後のバージョンのノードを新しく作成し入れ替えるというオペレーションにすることにしました。 最初のコントロールプレーンは既存のノードに対して操作する必要がありますが、それ以外は全てノードの入れ替えだけで済み、ノードを除外するプレイブックさえ用意すれば入れ替えたノードの追加はクラスタの構築と同じ手順で行うことができます。
「既存のインフラを変更するのではなく、イミュータブルなインフラを新たにデプロイする」という考えはイミュータブルインフラストラクチャというらしく、変更の追跡性・ロールバックの容易さという点で利点があるということです。 我が家の宅鯖はこの利点を直接享受しているわけではないですが、やっていることはイミュータブルインフラストラクチャであり上述の運用面のメリットを受けられます。
Packerについて
PackerはTerraformやVaultを展開するHashicorp社のOSSであり、ベンダーに依存しない形でマシンイメージをコードで管理・設定することができます。 主な用途は各種クラウド向けですが、VMWareやVirtualBoxといった環境に向けたイメージも作成することができ、我が家の宅鯖で使っているKVMイメージも作成することができます。
Packer自体のチュートリアルや利用方法は公式ドキュメントをはじめ調べればたくさん出てくるので全部を書きませんが、ここでは以下の概念を知っておけば十分です。
- Artifact:Packerによって最終的に生成されるイメージ。AWSならAMI・KVMならQCOWイメージなど。
- Builder:Artifactを生成するために呼ばれるコンポーネント。ArtifactとしてAMIを作る場合にはBuilderでEC2が起動されます。
- Provisioner:Artifactを作成する際に設定をするために呼ばれるコンポーネント。シェルやAnsibleを実行するProvisionerがあります。
Packer・Ansibleを用いたKVMイメージの作成
作成したいイメージについて
今回はKubernetesのノードとして使うことを前提に以下のようなイメージを作成していきます。
- ログイン情報・接続情報をビルド時ではなくイメージ利用時にcloud-initで指定できるようにする
- Kubernetesノードとしての設定をすませた状態にし、イメージ利用時にはjoinするだけでクラスタに参加できるようにする
とはいってもこの記事で紹介する内容はKubernetesではない用途に対しても使えますし、むしろKubernetes独自の内容は説明しません。 ベースのOSとしてはUbuntu Serverを利用します。
Packerビルド時の戦略
上記のようなイメージを作るにあたり、まずはKVMイメージを作成するためのBuilderが必要です。 幸い、Packer公式からKVMイメージを作ることのできるQEMU Builderが提供されているのでこれを利用します。 このBuilderでは、Packerを動作させるマシンでQEMUで仮想マシンを起動させ、その仮想マシンのイメージを抽出することで最終的なイメージを作成します。
また、Kubernetesノードとしての設定を行うために、こちらも公式から提供されているAnsible Provisionerを利用します。 このProvisionerではAnsibleのインベントリを自動で作り、SSHを介したプレイブックを自動で実行できます。
つまり流れとしては、QEMU BuilderでAnsibleによる設定が可能な仮想マシンを立ち上げ、それに対してAnsibleによる設定を投入していく、というものになります。
この2つを用いればやりたいことはほぼ実現できるのですが、1つ注意する必要があるのは「各種設定はビルド時ではなくイメージ利用時にcloud-initで指定」という部分です。 というのも、cloud-initは基本的にはマシンの初回起動時に設定を適用することを想定されている一方で、イメージ利用時は2回目の起動である(1回目はイメージ作成時)からです。 別の言い方をすると、Ansible Provisioner用のSSH設定をcloud-initで行いますが、ビルドされたイメージを利用するときにはその設定を無視してあたかも初回起動時のように振る舞わせる必要があるということです。
これに関しては少しトリッキーな方法で、初回(ビルド時)のcloud-initによる設定を消去していきます。
コード
全体像
Packerではテンプレートという設定ファイルを用いてBuilderやProvisionerの指定を行います。 テンプレートファイルを中心としたディレクトリ構成は以下のようになっています。
$ tree
.
├── ansible-provisioner
│ ├── kubernetes-node.yaml # Kubernetesノードとして稼働させるための諸々をインストールするAnsibleプレイブック
│ ├── var-development.yaml # 環境毎に用意したAnsibleプレイブック用の変数
│ └── var-production.yaml
├── image-repository-local # KVMイメージの格納先
└── ubuntu-2404-kubernetes.pkr.hcl # Packerテンプレート
3 directories, 4 files
それぞれのファイルの内容については以下のようになっています。
# ubuntu-2404-kubernetes.pkr.hcl
packer {
required_plugins {
qemu = {
version = "~> 1"
source = "github.com/hashicorp/qemu"
}
ansible = {
version = "~> 1"
source = "github.com/hashicorp/ansible"
}
}
}
# --- 変数の宣言
# 同一名でvariableとlocalを指定しているのは変数のデフォルト値として関数を使うため
# cf. https://github.com/hashicorp/packer/issues/9430
variable "image_id" {
type = string
default = null
}
local "image_id" {
expression = var.image_id != null ? var.image_id : formatdate("YYYYMMDDhhmmss",timestamp())
}
variable "build_production" {
type = bool
default = false
}
# --- KVMイメージ用Builderの設定
source "qemu" "ubuntu-2404-kubernetes" {
output_directory = "./image-repository-local/ubuntu-2404-kubernetes-${local.image_id}"
iso_url = "https://cloud-images.ubuntu.com/noble/20240710/noble-server-cloudimg-amd64.img"
iso_checksum = "5d482ff93a2ba3cf7e609551733ca238ed81a6afdaafcdce53140546b09a6195"
headless = true
vm_name = "${source.name}.qcow2"
cpus = 2
memory = 2048
disk_size = "20G"
disk_image = true
format = "qcow2"
accelerator = "kvm"
ssh_username = "packer"
ssh_password = "password"
ssh_timeout = "20m"
boot_wait = "10s"
cd_label = "cidata"
cd_content = {
"meta-data" = ""
"vendor-data" = ""
"network-config" = <<EOF
network:
version: 2
renderer: networkd
ethernets:
mainif:
match:
driver: "virt*"
dhcp4: true
dhcp6: true
EOF
"user-data" = <<EOF
#cloud-config
ssh_pwauth: true
users:
- name: packer
groups: sudo
plain_text_passwd: password
lock_passwd: false
sudo: ALL=(ALL) NOPASSWD:ALL
EOF
}
}
# --- 各種Provisioner設定によって実際にイメージをビルド
build {
dynamic "source" {
for_each = var.build_production ? ["production", "development"] : ["development"]
labels = ["qemu.ubuntu-2404-kubernetes"]
content {
name = source.value
}
}
# cloud-initによる初期化が終了するのを待機
provisioner "shell" {
execute_command = "sudo sh -c '{{ .Vars }} {{ .Path }}'"
valid_exit_codes = [0, 2]
inline = [
"cloud-init status --wait",
]
}
# それぞれの環境に対し動的に変数を読み込んでAnsibleプレイブックを実行
provisioner "ansible" {
playbook_file = "./ansible-provisioner/kubernetes-node.yaml"
ansible_env_vars = [
"ANSIBLE_ROLES_PATH=../roles"
]
extra_arguments = [
"--extra-vars",
"@ansible-provisioner/var-${source.name}.yaml"
]
}
# イメージビルド用の一時情報を消去しイメージ起動時にcloud-initによる設定を可能にする
provisioner "shell" {
execute_command = "sudo sh -c '{{ .Vars }} {{ .Path }}'"
inline = [
"rm /etc/netplan/50-cloud-init.yaml",
"rm /etc/sudoers.d/90-cloud-init-users",
"/usr/bin/truncate --size 0 /etc/machine-id",
"rm -r /var/lib/cloud /var/lib/dbus/machine-id ",
# Shell provisioner is responsible for deleting packer user
"sed -i '/^packer/d' /etc/passwd",
"sed -i '/^packer/d' /etc/shadow",
"rm -rf /home/packer",
"/bin/sync",
"/sbin/fstrim -v /"
]
}
}
# kubernetes-node.yaml
---
- name: Apply common settings
hosts: default
tasks:
- ansible.builtin.import_role: name=common
- ansible.builtin.import_role: name=vault-ssh-otp
- ansible.builtin.import_role: name=kubernetes_common
- name: Delete information of packer user
# Error occurs when deleting user itself using ansible.
# So, Shell provisioner is responsible for deleting user.
block:
- name: Delete packer group from /etc/group
ansible.builtin.lineinfile:
path: /etc/group
regexp: '^packer.*'
state: absent
become: true
- name: Delete packer group from /etc/gshadow
ansible.builtin.lineinfile:
path: /etc/gshadow
regexp: '^packer.*'
state: absent
become: true
- name: Delete packer user from /etc/subuid
ansible.builtin.lineinfile:
path: /etc/subuid
regexp: '^packer.*'
state: absent
become: true
- name: Delete packer user from /etc/subgid
ansible.builtin.lineinfile:
path: /etc/subgid
regexp: '^packer.*'
state: absent
become: true
- name: Remove packer user from sudo group in /etc/group
ansible.builtin.lineinfile:
path: /etc/group
regexp: '^(sudo.*)packer'
backrefs: true
line: '\1'
become: true
- name: Remove packer user from sudo group in /etc/gshadow
ansible.builtin.lineinfile:
path: /etc/gshadow
regexp: '^(sudo.*)packer'
backrefs: true
line: '\1'
become: true
# var-development.yaml
stage: development
kubernetes_major_minor_version: 1.29
kubernetes_patch_version: 9
cilium_version: 0.16.8
# var-production.yaml
stage: production
kubernetes_major_minor_version: 1.28
kubernetes_patch_version: 2
cilium_version: 0.15.8
要所解説
QEMU Builderは、ISOイメージを指定することでそのISOからマシンを起動することができます。 このベースイメージとして、canonicalがパブリッククラウド向けに用意しておりセットアップなしで高速に起動するUbuntu Cloud Imagesを使用します。 URLでISOを指定できるので、手動でダウンロードする必要はありません。 それ以外にもローカルやS3・Gitからも取得できます。
Cloud Imageはそのままでは起動せず適切にcloud-initによる設定を投入する必要があります。 設定と言っても今回はビルド時にSSHができるようにする程度なのでそこまで複雑ではありません。
cloud-initの設定はCD・HTTP・パブリッククラウドのメタデータなどさまざまな方法(Datasource)で投入することができますが、QEMU BuilderではコンテンツをCD形式で指定することができるのでその方法を選択しました。
設定内容自体は、ビルド中にインターネットにつなぐための最低限のネットワーク設定と、SSHするための認証情報と非常にシンプルです。
# --- KVMイメージ用Builderの設定
source "qemu" "ubuntu-2404-kubernetes" {
# ...
# Ubuntu Cloud Imagesをベースとして使用
iso_url = "https://cloud-images.ubuntu.com/noble/20240710/noble-server-cloudimg-amd64.img"
iso_checksum = "5d482ff93a2ba3cf7e609551733ca238ed81a6afdaafcdce53140546b09a6195"
# ...
# ビルド時用のcloud-init設定の投入
cd_label = "cidata"
cd_content = {
"meta-data" = ""
"vendor-data" = ""
"network-config" = <<EOF
network:
version: 2
renderer: networkd
ethernets:
mainif:
match:
driver: "virt*"
dhcp4: true
dhcp6: true
EOF
"user-data" = <<EOF
#cloud-config
ssh_pwauth: true
users:
- name: packer
groups: sudo
plain_text_passwd: password
lock_passwd: false
sudo: ALL=(ALL) NOPASSWD:ALL
EOF
}
}
ビルド用のVMが立ち上がった段階では、cloud-initによる設定が非同期で動作しておりこれが終わるのを待つ必要があります。 このためにShell Provisionersを利用します。 cloud-initには処理が終了するのを待つコマンドオプションがあるためこれを用います。
# cloud-initによる初期化が終了するのを待機
provisioner "shell" {
execute_command = "sudo sh -c '{{ .Vars }} {{ .Path }}'"
valid_exit_codes = [0, 2]
inline = [
"cloud-init status --wait",
]
}
cloud-initによる初期化処理が終わり次第Ansibleのプレイブックを実行していきます。 プレイブックの内容自体は解説しませんが、プレイブックの最後にcloud-initで作成されたビルド用ユーザーを削除します。 また、ビルドされたイメージを使った起動が初回起動であると認識してもらうためのクリーンアップ処理をします。 この部分の基本的な考え方はこちらの記事で紹介されていたものを利用しています。 ただし、そのやり方をそのまま使うのだとうまくいかないケースがあったため、次に説明するような工夫を入れています。
cloud-initによる初期化処理のクリーンアップ
先ほどの記事では、Shell Provisionerを使って以下のようなクリーンアップ処理をしています(コメントは解説のために追加)。 このうち、/etc/passwdといったユーザー情報を表すためのファイルをcloud-initが実行される前に戻す処理では単純にファイルの内容を元に戻すといった処理をしています。
"provisioners": [
{
"execute_command": "sudo sh -c '{{ .Vars }} {{ .Path }}'",
"inline": [
# cloud-initによって作成されたファイルを削除
"mv /etc/netplan/50-cloud-init.yaml /root/",
"mv /etc/sudoers.d/90-cloud-init-users /root/",
# 初回起動済みと判定されないようにする
"/usr/bin/truncate --size 0 /etc/machine-id",
"rm -r /var/lib/cloud /var/lib/dbus/machine-id ",
# --- この部分が問題 ---
"for i in group gshadow passwd shadow subuid subgid; do mv /etc/$i- /etc/$i; done",
# 変更をディスクに反映する
"/bin/sync",
"/sbin/fstrim -v /"
],
"remote_folder": "/tmp",
"type": "shell"
}
]
しかし、単純にcloud-initが実行する前に戻すだけだと、Ansibleによってこれらのファイルに変更があった場合、その内容も無かったことにされてしまいます。 これによってビルドされたイメージを利用するときに例えば以下のようなエラーが発生します。 このエラーはaptインストール時に作成されたユーザーがクリーンアップ処理によって削除されたことによる不整合です。 このエラー以外にも同様の原因でエラーが起こる可能性があります。
$ sudo apt install mysql-client
パッケージリストを読み込んでいます... 完了
依存関係ツリーを作成しています... 完了
状態情報を読み取っています... 完了
以下の追加パッケージがインストールされます:
mysql-client-8.0 mysql-client-core-8.0 mysql-common
以下のパッケージが新たにインストールされます:
mysql-client mysql-client-8.0 mysql-client-core-8.0 mysql-common
アップグレード: 0 個、新規インストール: 4 個、削除: 0 個、保留: 51 個。
2,832 kB のアーカイブを取得する必要があります。
この操作後に追加で 61.8 MB のディスク容量が消費されます。
続行しますか? [Y/n] Y
取得:1 http://archive.ubuntu.com/ubuntu noble-updates/main amd64 mysql-client-core-8.0 amd64 8.0.39-0ubuntu0.24.04.1 [2,794 kB]
取得:2 http://archive.ubuntu.com/ubuntu noble/main amd64 mysql-common all 5.8+1.1.0build1 [6,746 B]
取得:3 http://archive.ubuntu.com/ubuntu noble-updates/main amd64 mysql-client-8.0 amd64 8.0.39-0ubuntu0.24.04.1 [22.5 kB]
取得:4 http://archive.ubuntu.com/ubuntu noble-updates/main amd64 mysql-client all 8.0.39-0ubuntu0.24.04.1 [9,412 B]
2,832 kB を 3秒 で取得しました (1,129 kB/s)
dpkg: unrecoverable fatal error, aborting:
unknown system group '_chrony' in statoverride file; the system group got removed
before the override, which is most probably a packaging bug, to recover you
can remove the override manually with dpkg-statoverride
E: Sub-process /usr/bin/dpkg returned an error code (2)
これを回避するために、ファイルを削除するのではなくビルド用に作成したユーザーのみをピンポイントで削除するようにします。 この処理はShell ProvisionerでもAnsible Provisionerでもどちらで実行しても良いですが、シェルと比べてAnsibleの方がビルドインのモジュールによって表現力が高くなっているため、Ansible Provisionerで行っています。
ただし、Ansible Provisionerでクリーンアップ処理を実行する場合には、Ansible自体を実行しているユーザーを消してしまうとエラーが起きるため、ユーザーの削除だけはShell Provisionerで実施します。
# Ansible Provisionerでのクリーンアップ処理
---
- name: Apply common settings
hosts: default
tasks:
...
- name: Delete information of packer user
# Error occurs when deleting user itself using ansible.
# So, Shell provisioner is responsible for deleting user.
block:
- name: Delete packer group from /etc/group
ansible.builtin.lineinfile:
path: /etc/group
regexp: '^packer.*'
state: absent
become: true
...
# Shell Provisionerでの残りのクリーンアップ処理
provisioner "shell" {
execute_command = "sudo sh -c '{{ .Vars }} {{ .Path }}'"
inline = [
...
# Shell provisioner is responsible for deleting packer user
"sed -i '/^packer/d' /etc/passwd",
"sed -i '/^packer/d' /etc/shadow",
"rm -rf /home/packer",
...
]
}
まとめ
この記事では、Ansibleを使ってあらかじめインストール処理やセットアップを最大限実行することでKVMでのイミュータブルインフラストラクチャを実現しました。 パブリッククラウドではないのでイメージ作成自体を自動化・Gitコミットとの紐付けなどメタデータ管理は不十分ですが、今後の課題として取り組んでいきます。