This is the fifth part of my Raspberry Pi netboot series. The introduction with an overview and links to previous articles can be found here.
In this post, we will go over the Ansible playbook I use to provision my Pis. This playbook’s intention is not to fully provision the Pi for use, e.g. it will not set up Nomad or Ceph. It will only prepare the image to netboot and set up my normal Ansible user for my main provisioning Ansible playbooks, to be executed after first boot.
In the playbook, I’m using a couple of files for setting up the initramfs for mounting a Ceph RBD volume. The approach was taken from this PR and slightly adapted. A full description of those file’s contents can be found in Part III of the series. These files can be found in this gist on GitHub for ease of copying.
Caveats
There are a couple of caveats when using Packer and the packer-builder-arm builder.
This is due to the fact that the builder does not launch a full VM, but merely
mounts the image and the chroots into it. This means that things like checking
the running kernel version or the system architecture with standard Ansible
facts will not be working, due to the fact that the kernel Ansible sees is the
one from the host you are running Packer on, not the one of the actual host
you are provisioning.
This is why there is a hn_pi
variable used throughout the playbook, to identify
whether we are preparing an image for a Pi or a more standard host.
How it’s invoked
To reiterate from my previous article on the Packer setup, this is how the Ansible provisioner is configured:
provisioner "ansible" {
extra_arguments = [
"--connection=chroot",
"--inventory-file=${local.mountpath},",
"--limit=${local.mountpath}",
"--extra-vars", "foobar_pw=${local.foobar-pw}",
"--extra-vars", "hn_hostname=${var.hn_hostname}",
"--extra-vars", "hn_netboot=${var.hn_netboot}",
"--extra-vars", "hn_host_id=${var.hn_host_id}",
"--extra-vars", "hn_ceph_key=${local.hn_ceph_key}",
"--extra-vars", "hn_pi=true",
"--user", "ubuntu",
]
playbook_file = "${path.root}/../bootstrap-ubuntu-image.yml"
}
Note especially the connection=chroot
option, which tells Ansible to use chroot
instead of the default SSH connection. This is required when using packer-builder-arm
because that builder makes use of chroot instead of launching a VM.
The playbook
Here is the playbook itself:
- hosts: all
name: Bootstrap python for ansible
gather_facts: no
pre_tasks:
- name: install ansible dependencies
tags:
- bootstrap
raw: apt -y install python3 sudo
- hosts: all
name: setup host
tasks:
- name: create user foobar
tags:
- foobar
- ansible
user:
name: foobar
state: present
create_home: yes
shell: /bin/bash
groups: ''
- name: configure SSH key for foobar
tags:
- foobar
- ansible
authorized_key:
user: foobar
state: present
exclusive: yes
key: "{{ lookup('file','ansible.pub') }}"
key_options: '{% if ansible_hostname != "candc" %}from="10.0.0.200"{% else %}from="127.0.0.1"{% endif %}'
- name: add foobar to sudoers file
tags:
- foobar
- ansible
copy:
dest: /etc/sudoers.d/foobar
content: "foobar ALL=(ALL:ALL) ALL"
owner: root
group: root
mode: 0440
validate: 'visudo -cf %s'
- name: set foobar password
tags:
- bootstrap
user:
name: foobar
state: present
password: "{{ foobar_pw | password_hash('sha512') }}"
- name: set hostname in /etc/hostname
tags:
- bootstrap
replace:
path: /etc/hostname
regexp: '^ubuntu$'
replace: '{{ hn_hostname }}'
when: hn_pi is defined
- name: set hostname in /etc/hosts
tags:
- bootstrap
replace:
path: /etc/hosts
regexp: '^127\.0\.0\.1 localhost'
replace: '127.0.0.1 localhost {{ hn_hostname }} {{ hn_hostname }}.home'
- name: remove quiet setting from kernel command line
tags:
- bootstrap
replace:
path: /boot/firmware/cmdline.txt
regexp: '(.*) quiet (.*)'
replace: '\1 \2'
when: hn_pi is defined
- name: remove splash setting from kernel command line
tags:
- bootstrap
replace:
path: /boot/firmware/cmdline.txt
regexp: '(.*) splash(.*)'
replace: '\1 \2'
when: hn_pi is defined
# with lz4 compression (default in Pi images), something goes wrong with
# compression, and some files seem to be corrupted when unpacked during boot.
- name: switch initrd compression to gzip
tags:
- ubuntu
lineinfile:
path: /etc/initramfs-tools/conf.d/raspi-lz4.conf
line: "COMPRESS=gzip"
regexp: "^COMPRESS=lz4$"
state: present
owner: root
group: root
mode: '644'
create: yes
when: hn_pi is defined
- name: Prevent automatic backups of files in /boot/firmware
tags:
- ubuntu
lineinfile:
path: /etc/default/flash-kernel
line: "NO_CREATE_DOT_BAK_FILES=yes"
when: hn_pi is defined
- hosts: all
name: setup netboot
tasks:
- name: Remove /boot/firmware dependency for cloud-init-local service
tags:
- netboot
file:
path: /etc/systemd/system/cloud-init-local.service.d/mount-seed.conf
state: absent
when: hn_netboot == "true"
- name: create directory for rpi eeprom service file dropin
tags:
- netboot
file:
path: /etc/systemd/system/rpi-eeprom-update.service.d
state: directory
owner: root
group: root
when: hn_netboot == "true" and hn_pi is defined
- name: add dependency on boot/firmware mount for rpi eeprom service
tags:
- netboot
copy:
src: images/files/eeprom-boot-mount-dep.conf
dest: /etc/systemd/system/rpi-eeprom-update.service.d/eeprom-boot-mount-dep.conf
owner: root
group: root
mode: '0644'
when: hn_netboot == "true" and hn_pi is defined
- name: setup name resolution
tags:
- netboot
- initramfs
copy:
dest: /etc/resolv.conf
content: "nameserver 10.86.5.254"
when: hn_netboot == "true" and hn_pi is defined
- name: install NFS tools
tags:
- netboot
apt:
name: nfs-common
state: present
install_recommends: no
when: hn_netboot == "true"
- name: add rbd boot hook
tags:
- netboot
- initramfs
copy:
src: images/files/rbd-gen-hook.sh
dest: /etc/initramfs-tools/hooks/rbd-gen-hook.sh
owner: root
group: root
mode: '0644'
when: hn_netboot == "true"
- name: add rbd boot script
tags:
- netboot
- initramfs
copy:
src: images/files/rbd-boot-script.sh
dest: /etc/initramfs-tools/scripts/rbd
owner: root
group: root
mode: '0644'
when: hn_netboot == "true"
- name: backup flash-kernel initramfs gen hook
tags:
- netboot
- initramfs
copy:
dest: /tmp/initd-flash-kernel.sh.bak
remote_src: yes
src: /etc/initramfs/post-update.d/flash-kernel
when: hn_netboot == "true" and hn_pi is defined
- name: temporarily delete flash-kernel initramfs hook
tags:
- netboot
- initramfs
file:
path: /etc/initramfs/post-update.d/flash-kernel
state: absent
when: hn_netboot == "true" and hn_pi is defined
- name: install linux-modules-extra to get rbd kernel module
tags:
- netboot
- initramfs
apt:
name: linux-modules-extra-raspi
state: present
when: hn_netboot == "true" and hn_pi is defined
- name: restore flash-kernel initramfs gen hook
tags:
- netboot
- initramfs
copy:
src: /tmp/initd-flash-kernel.sh.bak
remote_src: yes
dest: /etc/initramfs/post-update.d/flash-kernel
when: hn_netboot == "true" and hn_pi is defined
- name: copy initrd to firmware partition
tags:
- netboot
- initramfs
copy:
src: /boot/initrd.img
dest: /boot/firmware/initrd.img
remote_src: yes
when: hn_netboot == "true" and hn_pi is defined
- name: remove old boot partition mount
tags:
- netboot
lineinfile:
path: /etc/fstab
regexp: ".*/boot/firmware.*"
state: absent
when: hn_netboot == "true" and hn_pi is defined
- name: add NFS based boot partition
tags:
- netboot
mount:
path: "{{ '/boot/firmware' if hn_pi is defined else '/boot' }}"
src: "nfs-host:/picluster-boot/{{ hn_host_id if hn_pi is defined else hn_hostname }}"
fstype: nfs
opts: defaults,timeo=900,_netdev
state: present
when: hn_netboot == "true"
- name: replace temporary resolv.conf with correct symlink
tags:
- netboot
file:
path: /etc/resolv.conf
src: /run/systemd/resolve/resolv.conf
state: link
force: yes
when: hn_netboot == "true" and hn_pi is defined
- name: add rbd boot kernel command line arguments Pi
tags:
- netboot
replace:
path: /boot/firmware/cmdline.txt
regexp: '(^dwc_otg.lpm_enable=0.*)$'
replace: '\1 boot=rbd rbdroot=10.0.0.15,10.0.0.12,10.0.0.14:clusteruser:{{ hn_ceph_key }}:pi-cluster:{{ hn_hostname }}::_netdev,noatime'
when: hn_netboot == "true" and hn_pi is defined
- name: Add post kernel install chmod
tags:
- netboot
copy:
src: images/files/zzz-chmod-kernel-image
dest: /etc/kernel/postinst.d/zzz-chmod-kernel-image
owner: root
group: root
mode: '0755'
when: hn_netboot == "true"
Let’s go through the playbook piece by piece.
- hosts: all
name: Bootstrap python for ansible
gather_facts: no
pre_tasks:
- name: install ansible dependencies
tags:
- bootstrap
raw: apt -y install python3 sudo
This initial play ensures that the necessary tooling for Ansible is installed.
Ansible requires Python 3 for the vast majority of it’s modules, but provides
the raw
module to work without any Python for initial setup.
Prepare an Ansible user
The next part will introduce a user for later use by Ansible once the image is
deployed. As you might guess, this user is not actually called foobar
in my
setup. 😉
- name: create user foobar
tags:
- foobar
- ansible
user:
name: foobar
state: present
create_home: yes
shell: /bin/bash
groups: ''
- name: configure SSH key for foobar
tags:
- foobar
- ansible
authorized_key:
user: foobar
state: present
exclusive: yes
key: "{{ lookup('file','ansible.pub') }}"
key_options: '{% if ansible_hostname != "candc" %}from="10.0.0.200"{% else %}from="127.0.0.1"{% endif %}'
- name: add foobar to sudoers file
tags:
- foobar
- ansible
copy:
dest: /etc/sudoers.d/foobar
content: "foobar ALL=(ALL:ALL) ALL"
owner: root
group: root
mode: 0440
validate: 'visudo -cf %s'
- name: set foobar password
tags:
- bootstrap
user:
name: foobar
state: present
password: "{{ foobar_pw | password_hash('sha512') }}"
As this user will be quite powerful, I restrict the access to SSH via the
from
option in the authorized_keys
file to my command and control machine’s
IP.
This part of the setup expects the SSH public key for your Ansible controller’s
private key to reside in a file ansible.pub
. Ansible will search a number of
file/
directories to find the file.
Now, the user also needs to have sudo rights to function as an Ansible user.
Here, I’m just giving it full rights in a separate sudoers.d/
file, as that
is easier to configure in my opinion than mucking about with the lineinfile
or similar modules. Note especially the validate: visudo -cf %s
option in that
task. It ensures that the new sudoers file is correctly formatted, so you don’t
lock yourself out.
Finally, the password is set. Here, I’m taking the plaintext password from the
foobar_password
variable which we hand to Ansible in the Packer template file.
In my case, this password comes from my HashiCorp Vault instance to Packer, and
Packer then hands it to Ansible. The password hashing is important, as the user
module expects the hashed password instead of the plaintext password.
Some basic config for initrd and kernel cmdline
The next two tasks just remove a few options from the kernel command line in
/boot/firmware/cmdline.txt
. This is purely a personal preferences thing.
The final two tasks are more interesting. First, the modification of the initrd
compression method. I have observed some unknown problem with the initrd
unpacking when using lz4
compression, which became the default in Ubuntu
22.04. The bug seems to be known already, and is being rolled back, but at the
time I created the playbook, it was still set, and the compression needs to be
changed to gzip
. This is done with the following task:
- name: switch initrd compression to gzip
tags:
- ubuntu
lineinfile:
path: /etc/initramfs-tools/conf.d/raspi-lz4.conf
line: "COMPRESS=gzip"
regexp: "^COMPRESS=lz4$"
state: present
owner: root
group: root
mode: '644'
create: yes
when: hn_pi is defined
The last task in this play is a personal preferences thing again. Ubuntu for
Pi makes use of the flash-kernel tool. This tool, before writing a kernel to
/boot/firmware
, creates a backup of all the previous files, including e.g.
the initrd and the kernel itself. I don’t need that, and disabled it with this
Ansible task:
- name: Prevent automatic backups of files in /boot/firmware
tags:
- ubuntu
lineinfile:
path: /etc/default/flash-kernel
line: "NO_CREATE_DOT_BAK_FILES=yes"
when: hn_pi is defined
Setting up the netboot
This is probably the part you’re all here for. And we’re just in line 471 of my markdown file. 😅
The first thing we do here is again some housekeeping. By default, you can put
local cloud-init files into /boot/firmware
to have them
loaded early in boot. This means the cloud-init
service has a dependency on
/boot
being mounted. But the cloud-init
service is also a dependency for
networking to come up (because you can change networking config in cloud-init).
But, with /boot/firmware
being located on an NFS mount when netbooting, there
is now a circle in the systemd dependency tree: cloud init -> /boot
-> networking
-> cloud-init
. Hence, I remove the dependency between /boot
and cloud-init
with this task:
- name: Remove /boot/firmware dependency for cloud-init-local service
tags:
- netboot
file:
path: /etc/systemd/system/cloud-init-local.service.d/mount-seed.conf
state: absent
when: hn_netboot == "true"
This also means that default cloud-init for Pi’s, which makes use of these local files, will not work. I’ve got the creation of a cloud-init server on my list and will most likely make another article out of it if I succeed.
The next two tasks fix a problem with the automatic Pi EEPROM update service,
by adding a dependency on /boot
being mounted.
- name: create directory for rpi eeprom service file dropin
tags:
- netboot
file:
path: /etc/systemd/system/rpi-eeprom-update.service.d
state: directory
owner: root
group: root
when: hn_netboot == "true" and hn_pi is defined
- name: add dependency on boot/firmware mount for rpi eeprom service
tags:
- netboot
copy:
src: images/files/eeprom-boot-mount-dep.conf
dest: /etc/systemd/system/rpi-eeprom-update.service.d/eeprom-boot-mount-dep.conf
owner: root
group: root
mode: '0644'
when: hn_netboot == "true" and hn_pi is defined
This task creates a systemd dropin file which adds a dependency on /boot
being
mounted, as the boot files are required by this service.
The content of the eeprom-boot-mount-dep.conf
file can again be found in
the GitHub Gist.
Installing NFS tooling
As NFS will be used to mount the /boot
directory, we need to make sure that
NFS tooling is installed to make that possible.
These two tasks accomplish that:
- name: setup name resolution
tags:
- netboot
- initramfs
copy:
dest: /etc/resolv.conf
content: "nameserver 1.1.1.1"
when: hn_netboot == "true" and hn_pi is defined
- name: install NFS tools
tags:
- netboot
apt:
name: nfs-common
state: present
install_recommends: no
when: hn_netboot == "true"
The DNS server setup here is necessary because we’re working in a chroot, not an actual VM. Hence, no DNS servers were configured via DHCP.
This needs to be undone in a later task, as /etc/resolv.conf
is normally a
symlink from systemd which points to a file under /run
instead of being a
normal file.
- name: replace temporary resolv.conf with correct symlink
tags:
- netboot
file:
path: /etc/resolv.conf
src: /run/systemd/resolve/resolv.conf
state: link
force: yes
when: hn_netboot == "true" and hn_pi is defined
Configuring the netboot files
The following tasks configure netbooting and all necessary files to accomplish it, most importantly the changes in the initramfs scripting to add the capability to mount a Ceph RBD based root disk:
- name: add rbd boot hook
tags:
- netboot
- initramfs
copy:
src: images/files/rbd-gen-hook.sh
dest: /etc/initramfs-tools/hooks/rbd-gen-hook.sh
owner: root
group: root
mode: '0644'
when: hn_netboot == "true"
- name: add rbd boot script
tags:
- netboot
- initramfs
copy:
src: images/files/rbd-boot-script.sh
dest: /etc/initramfs-tools/scripts/rbd
owner: root
group: root
mode: '0644'
when: hn_netboot == "true"
The two script files, rbd-boot-script.sh
and rbd-gen-hook.sh
can be found
in this Gist.
Next, the initramfs update itself is run:
- name: backup flash-kernel initramfs gen hook
tags:
- netboot
- initramfs
copy:
dest: /tmp/initd-flash-kernel.sh.bak
remote_src: yes
src: /etc/initramfs/post-update.d/flash-kernel
when: hn_netboot == "true" and hn_pi is defined
- name: temporarily delete flash-kernel initramfs hook
tags:
- netboot
- initramfs
file:
path: /etc/initramfs/post-update.d/flash-kernel
state: absent
when: hn_netboot == "true" and hn_pi is defined
- name: install linux-modules-extra to get rbd kernel module
tags:
- netboot
- initramfs
apt:
name: linux-modules-extra-raspi
state: present
when: hn_netboot == "true" and hn_pi is defined
- name: restore flash-kernel initramfs gen hook
tags:
- netboot
- initramfs
copy:
src: /tmp/initd-flash-kernel.sh.bak
remote_src: yes
dest: /etc/initramfs/post-update.d/flash-kernel
when: hn_netboot == "true" and hn_pi is defined
- name: copy initrd to firmware partition
tags:
- netboot
- initramfs
copy:
src: /boot/initrd.img
dest: /boot/firmware/initrd.img
remote_src: yes
when: hn_netboot == "true" and hn_pi is defined
The procedure here is a little bit more complicated. This is due, again, to the
fact that we are not in a netbooted VM, but in a chroot. This confuses the kernel
flashing tool flash-kernel
, which is used to update the kernel on /boot/firmware
.
It gets confused because it is not able to determine the running system’s type.
The main problem here is the install of the linux-modules-extra-raspi
package.
This package is necessary to get the rbd
kernel module, which in turn is needed
to be able to mount the Ceph RBD root partition during the initramfs phase of
the boot.
To work around this problem, the configuration which runs the flash-kernel
tool
at the end of an initramfs-update
run is temporarily removed and restored after
the initramfs has been updated as part of the linux-modules-extra-raspi
install.
Finally, the new image needs to be copied to the /boot/firmware
directory
manually, because of the previously described switch off of the flash-kernel
tool.
Setup boot partition
The next two tasks set up the new, NFS based boot partition:
- name: remove old boot partition mount
tags:
- netboot
lineinfile:
path: /etc/fstab
regexp: ".*/boot/firmware.*"
state: absent
when: hn_netboot == "true" and hn_pi is defined
- name: add NFS based boot partition
tags:
- netboot
mount:
path: "{{ '/boot/firmware' if hn_pi is defined else '/boot' }}"
src: "nfs-host:/picluster-boot/hn_host_id if hn_pi is defined else hn_hostname }}"
fstype: nfs
opts: defaults,timeo=900,_netdev
state: present
when: hn_netboot == "true"
This is reasonably simple. Each Pi gets it’s own directory on the NFS mount,
which is defined by the Pi’s serial number (see cat /proc/cpuinfo
) and also
used in the netboot setup.
Setting the kernel command line
In Raspberry Pi’s, the kernel command line is defined in a file in /boot/firmware/cmdline.txt
.
In the following task, this file is adapted to add the necessary parameters for netbooting from a Ceph RBD volume:
- name: add rbd boot kernel command line arguments Pi
tags:
- netboot
replace:
path: /boot/firmware/cmdline.txt
regexp: '(^dwc_otg.lpm_enable=0.*)$'
replace: '\1 boot=rbd rbdroot=10.0.0.15,10.0.0.12,10.0.0.14:clusteruser:{{ hn_ceph_key }}:pi-cluster:{{ hn_hostname }}::_netdev,noatime'
when: hn_netboot == "true" and hn_pi is defined
These parameters are not actually used by the kernel, but forwarded to the
init
binary the kernel calls after it is done with early initialization. When
an initramfs is used, this init
binary is actually a shell script which
will mount the real root partition and then load the init
binary from that
partition.
The meaning of the parameters is described in detail in my previous post on setting up the initramfs.
Final little housekeeping
The last task in the playbook does another necessary piece of housekeeping, this time for permissions:
- name: Add post kernel install chmod
tags:
- netboot
copy:
src: images/files/zzz-chmod-kernel-image
dest: /etc/kernel/postinst.d/zzz-chmod-kernel-image
owner: root
group: root
mode: '0755'
when: hn_netboot == "true"
The zzz-chmod-kernel-image
file can also be found in the GitHub Gist.
This task installs a small script which will chmod
the kernel image to be world
readable. This is necessary because otherwise, the TFTP server serving the kernel
image at boot time will not be able to access it, leading to a failed boot.
This concludes the walk through my bootstrapping playbook. Putting all of these tasks together into a playbook and running the Packer image template from Part IV of this series should net you a Raspberry Pi image for booting from NFS as the boot partition and a Ceph RBD volume as the root partition.
The next and final article in the series will be a short one about actually deploying the image.