pyratelog

personal blog
git clone git://git.pyratebeard.net/pyratelog.git
Log | Files | Refs | README

20240510-cluster_fu.md (26916B)


      1 I am always looking for ways to improve my home setup and one of the things I had been wanting to do for a while was to put together a dedicated homelab.
      2 
      3 My desktop computer is a bit of a workhorse so it has always been used for creating virtual machines (VMs), running [OCI][1] containers such as [Docker][2] or [Podman][3], [LXC][4] containers, and testing various warez.
      4 
      5 Recently I acquired four ThinkCentre M900 Tiny devices.  After dedicating one to become another 'production' system running [Proxmox][5], I decided to use two as my homelab.  Now, I could have put Proxmox on these two as well then [created a cluster][6], but where is the fun in that?  I felt like I didn't know enough about how clustering is done on Linux so set out to build my own from scratch.  The end goal was to have the ability to create LXC containers and VMs, which I can then use for testing and running OCI containers along with tools such as [Kubernetes][7].
      6 
      7 ## base camp
      8 I get on well with [Debian][8] as a server operating system so installed the latest version (12 "_Bookworm_" at time of writing) on my two nodes, which I am naming _pigley_ and _goatley_ (IYKYK).
      9 
     10 For the cluster I opted to use [Pacemaker][9] as the resource manager and [Corosync][10] for the cluster communication, with [Glusterfs][11] for shared storage.
     11 
     12 Now before everyone writes in saying how two node clusters are not advised, due to quorum issues or split-brain scenarios, I have thought about it.
     13 
     14 I didn't want to use the forth ThinkCentre in this homelab, I have other plans for that.  Instead I opted to use a spare RaspberryPi (named _krieger_) as a Corosync quorum device.  This (should) counteract the issues seen in a two node cluster.
     15 
     16 Ideally for Glusterfs I would configure _krieger_ as an arbiter device, however in order to get the same version of `glusterfs-server` (10.3 at time of writing) on Raspbian I had to add the testing repo.  Unfortunately I couldn't get the `glusterd` service to start.  The stable repo only offered `glusterfs-server` version 9.2-1 at the time, which was incompatible with 10.3-5 on _pigley_ and _goatley_.
     17 
     18 I decided to forgo the Glusterfs arbiter, while there is a risk of split-brain this is only a lab environment.
     19 
     20 After provisioning _pigley_ and _goatley_ I installed the required packages
     21 ```
     22 apt-get install pcs corosync-qdevice glusterfs-server
     23 ```
     24 
     25 * `pcs` - pacemaker/cluster configuration system.  This package will install
     26 	* `pacemaker`
     27 	* `corosync`
     28 * `corosync-qdevice` - for the quorum device
     29 * `glusterfs-storage`
     30 
     31 According to the documentation it is advisable to disable Pacemaker from automatic startup for now
     32 ```
     33 systemctl disable pacemaker
     34 ```
     35 
     36 On _krieger_ I installed the `corosync-qnetd` package
     37 ```
     38 apt-get install pcs corosync-qnetd
     39 ```
     40 
     41 ## share and share alike
     42 On _pigley_ and _goatley_ I created a partition on the main storage device and formatted it with XFS, created a mount point, and mounted the partition
     43 ```
     44 mkfs.xfs -i size=512 /dev/sda3
     45 mkdir -p /data/glusterfs/lab
     46 mount /dev/sda3 /data/glusterfs/lab
     47 ```
     48 
     49 Next I had to ensure _pigley_ and _goatley_ could talk to each other.  To make things easy I put the IP addresses in _/etc/hosts_, then using the `gluster` tool confirmed connectivity
     50 ```
     51 systemctl start glusterd
     52 systemctl enable glusterd
     53 gluster peer probe pigley
     54 gluster peer status
     55 ```
     56 
     57 I opted to configure a replica volume, keeping the data on 2 bricks (as that's all I have)
     58 ```
     59 gluster volume create lab0 replica 2 pigley.home.lab:/data/glusterfs/lab/brick0 \
     60     goatley.home.lab:/data/glusterfs/lab/brick0
     61 gluster volume start lab0
     62 gluster volume info
     63 ```
     64 
     65 The data isn't accessed directly in the brick directories, so I mounted the Glusterfs volume on a new mountpoint on both systems
     66 ```
     67 mkdir /labfs && \
     68 	mount -t glusterfs <hostname>:/lab0 /labfs
     69 ```
     70 
     71 To test the replication was working I created a few empty files on one of the systems
     72 ```
     73 touch /labfs/{a,b,c}
     74 ```
     75 
     76 Then checked they existed on the other system
     77 ```
     78 ls -l /labfs
     79 ```
     80 
     81 And they did! Win win.
     82 
     83 I did experience an issue when adding the _/labfs_ mount in _/etc/fstab_, as it would try to mount before the `glusterd` service was running.  To workaround this I included the `noauto` and `x-systemd.automount` options to my _/etc/fstab_ entry
     84 ```
     85 localhost:/lab0 /labfs glusterfs defaults,_netdev,noauto,x-systemd.automount 0 0
     86 ```
     87 
     88 ## start your engine
     89 Now the `corosync` config.  On both nodes I created _/etc/corosync/corosync.conf_
     90 ```
     91 cluster_name: lab
     92 crypto_cipher: none >> crypto_cipher: aes256
     93 crypto_hash: none >> crypto_hash: sha1
     94 nodelist {
     95 	node {
     96 		name: pigley
     97 		nodeid: 1
     98 		ring0_addr: 192.168.1.8
     99 	}
    100 	node {
    101 		name: goatley
    102 		nodeid: 2
    103 		ring0_addr: 192.168.1.9
    104 	}
    105 }
    106 ```
    107 
    108 On one node I had to generate an authkey using `corosync-keygen`, then copied it (_/etc/corosync/authkey_) to the other node.  I could then add the authkey to my cluster and restart the cluster services on each node
    109 ```
    110 pcs cluster authkey corosync /etc/corosync/authkey --force
    111 systemctl restart corosync && systemctl restart pacemaker
    112 ```
    113 
    114 The cluster takes a short while to become clean so I monitored it using `pcs status`.  The output below shows everything (except [STONITH][12]) is looking good
    115 ```
    116 Cluster name: lab
    117 
    118 WARNINGS:
    119 No stonith devices and stonith-enabled is not false
    120 
    121 Status of pacemakerd: 'Pacemaker is running' (last updated 2023-10-24 21:40:57 +01:00)
    122 Cluster Summary:
    123   * Stack: corosync
    124   * Current DC: pigley (version 2.1.5-a3f44794f94) - partition with quorum
    125   * Last updated: Tue Mar 26 11:37:06 2024
    126   * Last change:  Tue Mar 26 11:36:23 2024 by hacluster via crmd on pigley
    127   * 2 nodes configured
    128   * 0 resource instances configured
    129 
    130 Node List:
    131   * Online: [ goatley pigley ]
    132 
    133 Full List of Resources:
    134   * No resources
    135 
    136 Daemon Status:
    137   corosync: active/enabled
    138   pacemaker: active/enabled
    139   pcsd: active/enabled
    140 ```
    141 
    142 STONITH, or "Shoot The Other Node In The Head", is used for fencing failed cluster nodes.  As this is a test lab I am disabling it but may spend some time configuring it in the future
    143 ```
    144 pcs property set stonith-enabled=false
    145 ```
    146 
    147 ## the votes are in
    148 As mentioned, I want to use a third system as a quorum device.  This means that it casts deciding votes to protect against split-brain yet isn't part of the cluster, so doesn't have to be capable of running any resources.
    149 
    150 While I used an authkey to authenticate _pigley_ and _goatley_ in the cluster, for _krieger_ I had to use password authentication.  On _pigley_ I set the `hacluster` user's password
    151 ```
    152 passwd hacluster
    153 ```
    154 
    155 On _krieger_ I set the same password and started the quorum device
    156 ```
    157 passwd hacluster
    158 pcs qdevice setup model net --enable --start
    159 ```
    160 
    161 Back on _pigley_ I then authenticated _krieger_ and specified it as the quorum device
    162 ```
    163 pcs host auth krieger
    164 pcs quorum device add model net host=krieger algorithm=ffsplit
    165 ```
    166 
    167 The output of `pcs quorum device status` shows the QDevice information
    168 ```
    169 Qdevice information
    170 -------------------
    171 Model:                  Net
    172 Node ID:                1
    173 Configured node list:
    174     0   Node ID = 1
    175     1   Node ID = 2
    176 Membership node list:   1, 2
    177 
    178 Qdevice-net information
    179 ----------------------
    180 Cluster name:           lab
    181 QNetd host:             krieger:5403
    182 Algorithm:              Fifty-Fifty split
    183 Tie-breaker:            Node with lowest node ID
    184 State:                  Connected
    185 ```
    186 
    187 On _krieger_ the output of `pcs qdevice status net` shows similar information
    188 ```
    189 QNetd address:                  *:5403
    190 TLS:                            Supported (client certificate required)
    191 Connected clients:              2
    192 Connected clusters:             1
    193 Cluster "lab":
    194     Algorithm:          Fifty-Fifty split (KAP Tie-breaker)
    195     Tie-breaker:        Node with lowest node ID
    196     Node ID 2:
    197         Client address:         ::ffff:192.168.1.9:52060
    198         Configured node list:   1, 2
    199         Membership node list:   1, 2
    200         Vote:                   ACK (ACK)
    201     Node ID 1:
    202         Client address:         ::ffff:192.168.1.8:43106
    203         Configured node list:   1, 2
    204         Membership node list:   1, 2
    205         Vote:                   No change (ACK)
    206 ```
    207 
    208 ## build something
    209 Now my cluster is up and running I can start creating resources.  The first thing I wanted to get running were some VMs.
    210 
    211 I installed `qemu` on _pigley_ and _goatley_
    212 ```
    213 apt-get install qemu-system-x86 libvirt-daemon-system virtinst
    214 ```
    215 
    216 Before creating a VM I made sure the default network was started, and set it to auto start
    217 ```
    218 virsh net-list --all
    219 virsh net-start default
    220 virsh net-autostart default
    221 ```
    222 
    223 I uploaded a Debian ISO to _pigley_ then used `virt-install` to create a VM
    224 ```
    225 virt-install --name testvm \
    226 	--memory 2048 \
    227 	--vcpus=2 \
    228 	--cdrom=/labfs/debian-12.1.0-amd64-netinst.iso \
    229 	--disk path=/labfs/testvm.qcow2,size=20,format=qcow2 \
    230 	--os-variant debian11 \
    231 	--network network=default \
    232 	--graphics=spice \
    233 	--console pty,target_type=serial -v
    234 ```
    235 
    236 The command waits until the system installation is completed, so from my workstation I used `virt-viewer` to connect to the VM and run through the Debian installer
    237 ```
    238 virt-viewer --connect qemu+ssh://pigley/system --wait testvm
    239 ```
    240 
    241 Once the installation is complete and the VM has been rebooted I can add it as a resource to the cluster.  First the VM (or VirtualDomain in `libvirt` speak) has to be shutdown and the configuration XML saved to a file
    242 ```
    243 virsh shutdown testvm
    244 virsh dumpxml testvm > /labfs/testvm.xml
    245 pcs resource create testvm VirtualDomain \
    246 	config=/labfs/testvm.xml \
    247 	migration_transport=ssh \
    248 	meta \
    249 	allow-migrate=true
    250 ```
    251 
    252 To allow the resource to run on any of the cluster nodes the `symmetric-cluster` option has to be set to `true` (I am not bothering with specific resource rules at this time).  Then I can enable the resource
    253 ```
    254 pcs property set symmetric-cluster=true
    255 pcs resource enable testvm
    256 ```
    257 
    258 Watching `pcs resource` I can see that the VM has started on _goatley_
    259 ```
    260   * testvm    (ocf:heartbeat:VirtualDomain):   Started goatley
    261 ```
    262 
    263 On _goatley_ I can check that the VM is running with `virsh list`
    264 ```
    265  Id   Name       State
    266 --------------------------
    267  1    testvm     running
    268 ```
    269 
    270 To connect from my workstation I can use `virt-viewer` again
    271 ```
    272 virt-viewer --connect qemu+ssh://goatley/system --wait testvm
    273 ```
    274 
    275 Now I can really test the cluster by moving the VM from _goatley_ to _pigley_ with one command from either node
    276 ```
    277 pcs resource move testvm pigley
    278 ```
    279 
    280 The VM is automatically shutdown and restarted on _pigley_, now the output of `pcs resource` shows
    281 ```
    282   * testvm    (ocf:heartbeat:VirtualDomain):   Started pigley
    283 ```
    284 
    285 Successfully clustered!
    286 
    287 Most of the VMs I create will probably be accessed remotely via `ssh`.  The VM network on the cluster is not directly accessible from my workstation, I have to `ProxyJump` through whichever node is running the VM (this is by design)
    288 ```
    289 ssh -J pigley testvm
    290 ```
    291 
    292 Unless I check the resource status I won't always know which node the VM is on, so I came up with a workaround.
    293 
    294 The `libvirt` network sets up `dnsmasq` for local name resolution, so by setting the first `nameserver` on _pigley_ and _goatley_ to `192.168.122.1` (my virtual network) each node could resolve the hostnames of the VirtualDomains that are running on them.  I set this in _dhclient.conf_
    295 ```
    296 prepend domain-name-servers 192.168.122.1;
    297 ```
    298 
    299 On my workstation I made use of the tagging capability and `Match` function in SSH to find which node a VM is on
    300 ```
    301 Match tagged lab exec "ssh pigley 'ping -c 1 -W 1 %h 2>/dev/null'"
    302   ProxyJump pigley
    303 
    304 Match tagged lab
    305   ProxyJump goatley
    306 ```
    307 
    308 When I want to connect to a VM I specify a tag with the `-P` flag
    309 ```
    310 ssh -P lab root@testvm
    311 ```
    312 
    313 My SSH config will then use the first `Match` to ping that hostname on _pigley_ and if the VM is running on _pigley_ it will succeed, making _pigley_ the proxy.  If the `ping` fails SSH will go to the second `Match` and use _goatley_ as the proxy.
    314 
    315 ## this cloud is just my computer
    316 Now that my cluster is up and running I want to be able to interact with it remotely.
    317 
    318 For personal ethical reasons I opted to use [OpenTofu][13] instead of [Terraform][14].  OpenTofu's command `tofu` is a drop-in replacement, simply swap `terraform` for `tofu`. OpenTofu doesn't have a module for managing Pacemaker cluster resources but it does have a `libvirt` module, so I started there.
    319 
    320 I created an OpenTofu configuration using the provider [dmacvicar/libvirt][15].  Unfortunately I had issues trying to get it to connect over SSH.
    321 
    322 My OpenTofu configuration for testing looked like this
    323 ```
    324 terraform {
    325 	required_providers {
    326 		libvirt = {
    327 			source = "dmacvicar/libvirt"
    328 			version = "0.7.1"
    329 		}
    330 	}
    331 }
    332 
    333 provider "libvirt" {
    334 	uri = "qemu+ssh://pigley/system"
    335 }
    336 
    337 resource "libvirt_domain" "testvm" {
    338 	name = "testvm"
    339 }
    340 ```
    341 
    342 After initialising (`tofu init`) I ran `tofu plan` and got an error
    343 ```
    344 Error: failed to dial libvirt: ssh: handshake failed: ssh: no authorities for hostname: pigley:22
    345 ```
    346 
    347 This was remedied by setting the `known_hosts_verify` option to `ignore`
    348 ```
    349 ...
    350 
    351 provider "libvirt" {
    352 	uri = "qemu+ssh://pigley/system?known_hosts_verify=ignore"
    353 }
    354 
    355 ...
    356 ```
    357 
    358 Another `tofu plan` produced another error
    359 ```
    360 Error: failed to dial libvirt: ssh: handshake failed: ssh: unable to authenticate, attempted methods [none publickey], no supported methods remain
    361 ```
    362 
    363 At first it I thought it was due to using an SSH agent for my key, so I created a dedicated passphrase-less SSH keypair and specified the file in the `uri`
    364 ```
    365 ...
    366 
    367 provider "libvirt" {
    368 	uri = "qemu+ssh://pigley/system?keyfile=/home/pyratebeard/.ssh/homelab_tofu.key&known_hosts_verify=ignore"
    369 }
    370 
    371 ...
    372 ```
    373 
    374 This produced the same error again.  After some Github issue digging I found mention of setting the [sshauth][16] option to `privkey`.  The default is supposedly `agent,privkey` but as I found it isn't picking up my agent, even with `$SSH_AUTH_SOCK` set.  I set the option in the `uri`
    375 ```
    376 ...
    377 
    378 provider "libvirt" {
    379 	uri = "qemu+ssh://pigley/system?keyfile=/home/pyratebeard/.ssh/homelab_tofu.key&known_hosts_verify=ignore&sshauth=privkey"
    380 }
    381 
    382 ...
    383 ```
    384 
    385 Finally it worked!  Until I tried to apply the plan with `tofu apply`
    386 ```
    387 Error: error while starting the creation of CloudInit's ISO image: exec: "mkisofs": executable file not found in $PATH
    388 ```
    389 
    390 This was easily fixed by installing the `cdrtools` package on my workstation
    391 ```
    392 pacman -S cdrtools
    393 ```
    394 
    395 After creating a new VM I want to automatically add it as a cluster resource.  To do this I chose to use [Ansible][17] and so that I don't have to run two lots of commands I wanted to use Ansible to deploy my OpenTofu configuration.  Ansible does have a [terraform module][18] but there is not yet one for OpenTofu.  A workaround to this is to create a symlink for the `terraform` command
    396 ```
    397 sudo ln -s /usr/bin/tofu /usr/bin/terraform
    398 ```
    399 
    400 Ansible will never know the difference!  The task in the playbook looks like this
    401 ```
    402 - name: "tofu test"
    403   community.general.terraform:
    404     project_path: '~src/infra_code/libvirt/debian12/'
    405     state: present
    406     force_init: true
    407   delegate_to: localhost
    408 ```
    409 
    410 That ran successfully so I started to expand my OpenTofu config so it would actually build a VM.
    411 
    412 
    413 In order to not have to go through the ISO install every time, I decided to use the Debian cloud images then make use of [Cloud-init][19] to apply any changes when the new VM is provisioned.  Trying to keep it similar to a "real" cloud seemed like a good idea.
    414 
    415 ```
    416 terraform {
    417 	required_providers {
    418 		libvirt = {
    419 			source = "dmacvicar/libvirt"
    420 			version = "0.7.1"
    421 		}
    422 	}
    423 }
    424 
    425 provider "libvirt" {
    426 	uri = "qemu+ssh://pigley/system?keyfile=/home/pyratebeard/.ssh/homelab_tofu.key&known_hosts_verify=ignore&sshauth=privkey"
    427 }
    428 
    429 variable "vm_name" {
    430 	type = string
    431 	description = "hostname"
    432 	default = "testvm"
    433 }
    434 
    435 variable "vm_vcpus" {
    436 	type = string
    437 	description = "number of vcpus"
    438 	default = 2
    439 }
    440 
    441 variable "vm_mem" {
    442 	type = string
    443 	description = "amount of memory"
    444 	default = "2048"
    445 }
    446 
    447 variable "vm_size" {
    448 	type = string
    449 	description = "capacity of disk"
    450 	default = "8589934592" # 8G
    451 }
    452 
    453 resource "libvirt_volume" "debian12-qcow2" {
    454 	name = "${var.vm_name}.qcow2"
    455 	pool = "labfs"
    456 	source = "http://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2"
    457 	format = "qcow2"
    458 }
    459 
    460 resource "libvirt_volume" "debian12-qcow2" {
    461 	name = "${var.vm_name}.qcow2"
    462 	pool = "labfs"
    463 	format = "qcow2"
    464 	size = var.vm_size
    465 	base_volume_id = libvirt_volume.base-debian12-qcow2.id
    466 }
    467 
    468 data "template_file" "user_data" {
    469 	template = "${file("${path.module}/cloud_init.cfg")}"
    470 	vars = {
    471 		hostname = var.vm_name
    472 	}
    473 }
    474 
    475 resource "libvirt_cloudinit_disk" "commoninit" {
    476 	name = "commoninit.iso"
    477 	pool = "labfs"
    478 	user_data = "${data.template_file.user_data.rendered}"
    479 }
    480 
    481 resource "libvirt_domain" "debian12" {
    482 	name = var.vm_name
    483 	memory = var.vm_mem
    484 	vcpu = var.vm_vcpus
    485 
    486 	network_interface {
    487 		network_name = "default"
    488 		wait_for_lease = true
    489 	}
    490 
    491 	disk {
    492 		volume_id = "${libvirt_volume.debian12-qcow2.id}"
    493 	}
    494 
    495 	cloudinit = "${libvirt_cloudinit_disk.commoninit.id}"
    496 
    497 	console {
    498 		type = "pty"
    499 		target_type = "serial"
    500 		target_port = "0"
    501 	}
    502 }
    503 ```
    504 
    505 The _cloud\_init.cfg_ configuration is very simple at the moment, only setting the hostname for DNS to work and creating a new user
    506 ```
    507 #cloud-config
    508 ssh_pwauth: false
    509 
    510 preserve_hostname: false
    511 hostname: ${hostname}
    512 
    513 users:
    514   - name: pyratebeard
    515     ssh_authorized_keys:
    516       - ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICSluiY54h5FlGxnnXqifWPnfvKNIh1/f0xf0yCThdqV
    517     sudo: ['ALL=(ALL) NOPASSWD:ALL']
    518     shell: /bin/bash
    519     groups: wheel
    520 ```
    521 
    522 Out of (good?) habit I tested this with `tofu` before running it with Ansible, and I hit another issue (getting tiring isn't it?!)
    523 ```
    524 Error: error creating libvirt domain: internal error: process exited while connecting to monitor: ... Could not open '/labfs/debian-12-genericcloud-amd64.qcow2': Permission denied
    525 ```
    526 
    527 OpenTofu wasn't able to write out the _qcom2_ file due to AppArmor.  I attempted to give permission in _/etc/apparmor.d/usr.lib.libvirt.virt-aa-helper_ yet that didn't seem to work.  Instead I set the following line in _/etc/libvirt/qemu.conf_ and restarted `libvirtd` on _pigley_ and _goatley_
    528 ```
    529 security_device = "none"
    530 ```
    531 
    532 That "fixed" my issue and I successfully built a new VM.  Now I could try it with Ansible.
    533 
    534 My playbook applies the infrastructure configuration, then creates a cluster resource using the same `virsh` and `pcs` commands as I used earlier
    535 
    536 ```
    537 - hosts: pigley
    538   gather_facts: true
    539   become: true
    540   pre_tasks:
    541     - name: "load vars"
    542       ansible.builtin.include_vars:
    543         file: vars.yml
    544       tags: always
    545 
    546   tasks:
    547     - name: "create vm"
    548       community.general.terraform:
    549         project_path: '{{ tofu_project }}'
    550         state: present
    551         complex_vars: true
    552         variables:
    553           vm_name: "{{ vm_name }}"
    554           vm_vcpus: "{{ vm_vcpus }}"
    555           vm_mem: "{{ vm_mem }}"
    556           vm_size: "{{ vm_size }}"
    557         force_init: true
    558       delegate_to: localhost
    559 
    560     - name: "shutdown vm & dumpxml"
    561       ansible.builtin.shell: |
    562         virsh shutdown {{ vm_name }} && \
    563           virsh dumpxml {{ vm_name }} > /labfs/{{ vm_name }}.xml
    564 
    565     - name: "create cluster resource"
    566       ansible.builtin.shell: |
    567         pcs resource create {{ vm_name }} VirtualDomain \
    568         config=/labfs/{{ vm_name }}.xml \
    569         migration_transport=ssh \
    570         meta \
    571         allow-migrate=true
    572 ```
    573 
    574 This is not the most elegant solution of adding the cluster resource, yet seems to be the only way of doing it.
    575 
    576 The _vars.yml_ file which is loaded at the beginning lets me define some options for the VM
    577 ```
    578 vm_os: "debian12" # shortname as used in opentofu dir hierarchy
    579 vm_name: "testvm"
    580 vm_vcpus: "2"
    581 vm_mem: "2048"
    582 vm_size: "8589934592" # 8G
    583 
    584 ## location of opentofu project on local system
    585 tofu_project: "~src/infra_code/libvirt/{{ vm_os }}/"
    586 ```
    587 
    588 When the playbook runs the VM variables defined in _vars.yml_ override anything configured in my OpenTofu project.  This means that once my infrastructure configuration is crafted I only have to edit the _vars.yml_ file and run the playbook.  I can add more options to _vars.yml_ as I expand the configuration.
    589 
    590 When the playbook completes I can SSH to my new VM without knowing where in the cluster it is running, or even knowing the IP thanks to the SSH tag and the `libvirt` DNS
    591 ```
    592 ssh -P lab pyratebeard@testvm
    593 ```
    594 
    595 ## contain your excitement
    596 For creating LXC (not LXD) containers the only (active and working) OpenTofu/Terraform modules I could find were for Proxmox or [Incus][20].  I have not been able to look into the new Incus project at the time of writing so for now I went with using Ansible's [LXC Container][21] module.
    597 
    598 On _pigley_ I installed LXC and the required Python package for use with Ansible
    599 ```
    600 apt-get install lxc python3-lxc
    601 ```
    602 
    603 I opted not to configure unprivileged containers at this time, this is a lab after all.
    604 
    605 After a quick test I decided to not use the default LXC bridge, instead I configured it to use the existing "default" network configured with `libvirt`.  This enabled me to use the same SSH tag method for logging in as the nameserver would resolve the LXC containers as well.  The alternative was to configure my own DNS as I can't use two separate nameservers for resolution.
    606 
    607 In _/etc/default/lxc-net_ I switched the bridge option to `false`
    608 ```
    609 USE_LXC_BRIDGE="false"
    610 ```
    611 
    612 In _/etc/lxc/default.conf_ I set the network link to the `libvirt` virtual device
    613 ```
    614 lxc.net.0.link = virbr0
    615 ```
    616 
    617 Then I restarted the LXC network service
    618 ```
    619 systemctl restart lxc-net
    620 ```
    621 
    622 To use my Glusterfs mount for the LXC containers I had to add the `lxc.lxcpath` configuration to _/etc/lxc/lxc.conf_
    623 ```
    624 lxc.lxcpath = /labfs/
    625 ```
    626 
    627 I tested this by manually creating an LXC container
    628 ```
    629 lxc-create -n testlxc -t debian -- -r bookworm
    630 ```
    631 
    632 Which resulted in a ACL error
    633 ```
    634 Copying rootfs to /labfs/testlxc/rootfs...rsync: [generator] set_acl: sys_acl_set_file(var/log/journal, ACL_TYPE_ACCESS): Operation not supported (95)
    635 ```
    636 
    637 The fix for this was to mount _/labfs_ with the `acl` option in _/etc/fstab_
    638 ```
    639 localhost:/lab0 /labfs glusterfs defaults,_netdev,noauto,x-systemd.automount,acl 0 0
    640 ```
    641 
    642 With Ansible, creating a new container is straight forward
    643 ```
    644 - name: Create a started container
    645   community.general.lxc_container:
    646     name: testlxc
    647     container_log: true
    648     template: debian
    649     state: started
    650     template_options: --release bookworm
    651 ```
    652 
    653 Once it is created I could connect with the same SSH tag I used with the VMs
    654 ```
    655 ssh -P lab root@testlxc
    656 ```
    657 
    658 This wouldn't let me in with the default build configuration, I am expected to set up a new user as I did with the VM cloud image.  There is no way (that I know of) to use Cloud-init to do this with Ansible.  Thankfully the Ansible LXC module has a `container_command` option, which allows specified commands to run inside the container on build.
    659 
    660 I adjusted my playbook task to create a new user and included a task to load variables from a file
    661 ```
    662 - hosts: pigley
    663   gather_facts: true
    664   become: true
    665   pre_tasks:
    666     - name: "load vars"
    667       ansible.builtin.include_vars:
    668         file: vars.yml
    669       tags: always
    670 
    671   tasks:
    672     - name: Create a started container
    673       community.general.lxc_container:
    674         name: "lxc-{{ lxc_name }}"
    675         container_log: true
    676         template: "{{ lxc_template }}"
    677         state: started
    678         template_options: "--release {{ lxc_release }}"
    679         container_command: |
    680           useradd -m -d /home/{{ username }} -s /bin/bash -G sudo {{ username }}
    681           [ -d /home/{{ username }}/.ssh ] || mkdir /home/{{ username }}/.ssh
    682           echo {{ ssh_pub_key }} > /home/{{ username }}/.ssh/authorized_keys
    683 ```
    684 
    685 With the variables stored in _vars.yml_
    686 ```
    687 lxc_template: "debian"
    688 lxc_release: "bookworm"
    689 lxc_name: "testlxc"
    690 username: "pyratebeard"
    691 ssh_pub_key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICSluiY54h5FlGxnnXqifWPnfvKNIh1/f0xf0yCThdqV"
    692 ```
    693 
    694 Now I can log in with SSH
    695 ```
    696 ssh -P lab pyratebeard@testlxc
    697 ```
    698 
    699 With a similar command as the VM resource creation I tested adding the LXC container to the cluster
    700 ```
    701 pcs resource create testlxc ocf:heartbeat:lxc \
    702 	container=testlxc
    703 	config=/labfs/testlxc/config \
    704 	op monitor timeout="20s" interval="60s" OCF_CHECK_LEVEL="0"
    705 ```
    706 
    707 This created the resource, which I could migrate between hosts as before
    708 ```
    709 pcs resource move testlxc goatley
    710 ```
    711 
    712 I updated my playbook to include the resource creation task
    713 ```
    714 - name: "create cluster resource"
    715   ansible.builtin.shell: |
    716     pcs resource create {{ lxc_name }} ocf:heartbeat:lxc \
    717     container={{ lxc_name }}
    718     config=/labfs/{{ lxc_name }}/config \
    719     op monitor timeout="20s" interval="60s" OCF_CHECK_LEVEL="0"
    720 ```
    721 
    722 And done!  My homelab is now ready to use.  I am able to quickly create Virtual Machines and LXC containers as well as accessing them via SSH without caring which cluster node they are on.
    723 
    724 After testing all of the VM and container creations I made a small change to my SSH config to discard host key fingerprint checking.  I set `StrictHostKeyChecking` to `no`, which stops the host key fingerprint accept prompt, then set the `UserKnownHostsFile` to _/dev/null_ so that fingerprints don't get added to my usual known hosts file.
    725 ```
    726 Match tagged lab exec "ssh pigley 'ping -c 1 -W 1 %h 2>/dev/null'"
    727   ProxyJump pigley
    728   StrictHostKeyChecking no
    729   UserKnownHostsFile /dev/null
    730 
    731 Match tagged lab
    732   ProxyJump goatley
    733   StrictHostKeyChecking no
    734   UserKnownHostsFile /dev/null
    735 ```
    736 
    737 It is fun having my own little cloud, building it has certainly taught me a lot and hopefully will continue to do so as I improve and expand my OpenTofu and Ansible code.
    738 
    739 If you are interested keep an eye on my [playbooks][22] and [infra_code][23] repositories.
    740 
    741 [1]: https://opencontainers.org/
    742 [2]: https://www.docker.com/resources/what-container/
    743 [3]: https://docs.podman.io/en/latest/
    744 [4]: https://linuxcontainers.org/lxc/introduction/
    745 [5]: https://www.proxmox.com/en/
    746 [6]: https://pve.proxmox.com/wiki/Cluster_Manager
    747 [7]: https://kubernetes.io/
    748 [8]: https://debian.org
    749 [9]: http://clusterlabs.org/pacemaker
    750 [10]: http://corosync.github.io/
    751 [11]: https://www.gluster.org/
    752 [12]: https://en.wikipedia.org/wiki/STONITH
    753 [13]: https://opentofu.org
    754 [14]: https://terraform.io/
    755 [15]: https://github.com/dmacvicar/terraform-provider-libvirt
    756 [16]: https://github.com/dmacvicar/terraform-provider-libvirt/issues/886#issuecomment-986423116
    757 [17]: https://www.ansible.com/
    758 [18]: https://docs.ansible.com/ansible/latest/collections/community/general/terraform_module.html
    759 [19]: https://cloud-init.io/
    760 [20]: https://linuxcontainers.org/incus/
    761 [21]: https://docs.ansible.com/ansible/latest/collections/community/general/lxc_container_module.html
    762 [22]: https://git.pyratebeard.net/playbooks/
    763 [23]: https://git.pyratebeard.net/infra_code/