Automate Raspberry Pi image creation with Hashicorp Packer
03-26-2023 • Raspberry Pi, Packer
Raspberry Pi's are still on a supply shortage, but let's be honest, they are still fun if you already own one, and you can still do amazing things with them. Creating an image for your Raspberry Pi can be tedious and time-consuming. This post will have a look at Hashicorp Packer and how it can be used to automate the creation of customized images for your Raspberry Pis.
Prerequisite
Hardware
In order to follow this post you need to own this hardware:
Software
We are going to use HashiCorp Packer to create the images.
$ yay -S packer
As Raspberry Pis follow the ARM architecture, we won't be able to build the image on an x86 machine, without installing some qemu libraries, that allow us to do cross compilation.
$ yay -S qemu-user-static qemu-user-static-binfmt
WARNING
Depending on your operating system you might need to change the installation commands.
Directory structure
As we will do a lot of customizations, we need some kind of directory structure, that allows us to keep a structure, that will not be tedious when adding more and more files. I went with this structure, but this can be changed at any time:
.
├── build.pkr.hcl
├── files
│ ├── boot
│ │ └── ssh
│ ├── etc
│ │ └── cloud
│ │ └── cloud.cfg.d
│ │ ├── 99_datasource.cfg
│ │ └── 99_user.cfg
│ └── usr
│ └── local
│ └── bin
│ └── install-cloud-init.sh
└── sources.pkr.hcl
Configuration
Image
Let's start with the sources.pkr.hcl
file. This file will tell packer which base image should be used. I went with the Raspberry Pi OS (64-bit) Lite image. You can find a list of all available images and their corresponding checksum files within the official sources.
source "arm" "raspios_bullseye_arm64" {
file_urls = ["http://downloads.raspberrypi.org/raspios_lite_arm64/images/raspios_lite_arm64-2022-09-26/2022-09-22-raspios-bullseye-arm64-lite.img.xz"]
file_checksum_url = "http://downloads.raspberrypi.org/raspios_lite_arm64/images/raspios_lite_arm64-2022-09-26/2022-09-22-raspios-bullseye-arm64-lite.img.xz.sha256"
file_checksum_type = "sha256"
file_target_extension = "xz"
file_unarchive_cmd = ["xz", "--decompress", "$ARCHIVE_PATH"]
image_build_method = "reuse"
image_path = "2022-09-26-raspios-bullseye-arm64-lite.img"
image_size = "6G"
image_type = "dos"
# configure boot partition
image_partitions {
name = "boot"
type = "c"
start_sector = "2048"
filesystem = "fat"
size = "256M"
mountpoint = "/boot"
}
# configure root partition
image_partitions {
name = "root"
type = "83"
start_sector = "526336"
filesystem = "ext4"
size = "0"
mountpoint = "/"
}
image_chroot_env = ["PATH=/usr/local/bin:/usr/local/sbin:/usr/bin:/usr/sbin:/bin:/sbin"]
# qemu binary paths
qemu_binary_source_path = "/usr/bin/qemu-aarch64-static"
qemu_binary_destination_path = "/usr/bin/qemu-aarch64-static"
}
WARNING
Depending on your operating system you might need to change the qemu binary paths.
TIP
You can also use other images, e.g. Arch Linux ARM, but keep in mind, that you might need to change the partition layout.
Build
After we have defined our sources, we need to specify the build instructions for our image. This will be done within the build.pkr.hcl
file. Let's take a moment and think about what we want to achieve:
- configure a user
- enable SSH login by using a public key
- configure the network to use DHCP
All the tasks mentioned above can be done by using pure bash. But there are better solutions, e.g. cloud-init. The build.pkr.hcl
file will therefore, install cloud-init, and provide a cloud init configuration, that will allow us to use the image without changing the configuration at all
build {
# specify the build source image
sources = [
"source.arm.raspios_bullseye_arm64"
]
# install and start cloud init
provisioner "shell" {
scripts = [
"files/usr/local/bin/install-cloud-init.sh"
]
}
# configure cloud init (datasource)
provisioner "file" {
source = "files/etc/cloud/cloud.cfg.d/99_datasource.cfg"
destination = "/etc/cloud/cloud.cfg.d/99_datasource.cfg"
}
# configure cloud init (users)
provisioner "file" {
source = "files/etc/cloud/cloud.cfg.d/99_user.cfg"
destination = "/etc/cloud/cloud.cfg.d/99_user.cfg"
}
# set hostname via dhcp
provisioner "shell" {
inline = ["echo 'localhost' > /etc/hostname"]
}
# disable file system resize, this is already done by packer
provisioner "shell" {
inline = ["rm /etc/init.d/resize2fs_once"]
}
# disable the customization dialog, that raspberry pi os will show at boot
provisioner "shell" {
inline = ["rm /usr/lib/systemd/system/userconfig.service"]
}
# enable ssh access
provisioner "file" {
source = "files/boot/ssh"
destination = "/boot/ssh"
}
}
SSH
As seen in the build.pkr.hcl
, a file called /boot/ssh
with empty content will be created. This will instruct Raspberry Pi OS (64-bit) Lite to allow incoming SSH connections.
DANGER
The file will be copied by packer and has to exist within the directory structure. The file also shall not be empty, but contain a single empty line.
DHCP
Raspberry Pi OS (64-bit) Lite will automatically use DHCP, if you don't change anything within your network configuration, so DHCP should work out of the box.
cloud-init
User
As mentioned before, we can use cloud-init to set up the user. In order to do so, we need to create an SSH key first:
ssh-keygen -t ed25519 -C "nils@orbat.sh" -f /tmp/packer
We can inspect our generated public SSH key with:
cat /tmp/packer/packer.pub
The output should be something like:
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJLPHJkPwMIJ+TZuCzfqWYobOGkinHbOzygKa5kQFzvO nils@orbat.sh
After we have prepared the SSH key, we can create the new user configuration for our image:
users:
- name: nilsorbat
sudo: ALL=(ALL) NOPASSWD:ALL
shell: /bin/bash
lock_passwd: false
ssh_authorized_keys:
- ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJLPHJkPwMIJ+TZuCzfqWYobOGkinHbOzygKa5kQFzvO nils@orbat.sh
system_info:
default_user:
name: pi
lock_passwd: false
gecos: Raspbian
groups: [pi adm dialout cdrom sudo audio video plugdev games users input netdev spi i2c gpio]
sudo: ["ALL=(ALL) NOPASSWD: ALL"]
shell: /bin/bash
package_mirrors:
- arches: [default]
failsafe:
primary: http://raspbian.raspberrypi.org/raspbian
security: http://raspbian.raspberrypi.org/raspbian
DANGER
Remember to change your SSH key within the 99_user.cfg
file and only use your public key!
Datasource
The datasource within cloud-init is usually used to determine metadata for a cloud provider. As we do bare metal provisioning here, and we don't manage a cloud metadata server we need to create a file called 99_datasource.cfg
, that will specify the NoCloud
property.
# configure cloud-init for NoCloud
datasource_list: [ NoCloud, None ]
datasource:
NoCloud:
fs_label: boot
Packer
Now, that the whole configuration is done, we can finally build the desired image. In order to do we need to have a running installation of Docker.
docker run --rm --privileged -v /dev:/dev -v $PWD:/build mkaczanowski/packer-builder-arm:1.0.2 build .
This will generate an image, that can be flashed onto a micro sd card:
The command mentioned above will use packer-builder-arm, an opens-source project, that will stich all the pieces together and instruct packer to build the image. Have a look at their documentation and examples for deeper insights.
TIP
Usually you can use a plugins.pkr.hcl
file within your packer directory and packer will automatically download and configure all specified plugins. The plugin we're using does not fulfill some requirements for this to work. There is already an open issue on GitHub and I will update this post, if this gets resolved.
dd if=2022-09-26-raspios-bullseye-arm64-lite.img of=/dev/sdX bs=64k oflag=dsync status=progress
Debugging
You can check if all files were copied correctly by creating a loopback device:
udisksctl loop-setup --file 2022-09-26-raspios-bullseye-arm64-lite.img
After this, we can mount both of our partitions:
mkdir -p /tmp/rpi/{boot,root}
mount /dev/loop0p1 /tmp/rpi/boot
mount /dev/loop0p2 /tmp/rpi/root
Check the contents of the directories:
nilsorbat in /tmp/rpi/boot 🔒
[I] ➜ ls -l
drwxr-xr-x - root 22 Sep 2022 overlays
.rwxr-xr-x 30k root 30 Aug 2022 bcm2710-rpi-2-b.dtb
.rwxr-xr-x 32k root 30 Aug 2022 bcm2710-rpi-3-b-plus.dtb
.rwxr-xr-x 32k root 30 Aug 2022 bcm2710-rpi-3-b.dtb
.rwxr-xr-x 30k root 30 Aug 2022 bcm2710-rpi-cm3.dtb
.rwxr-xr-x 31k root 30 Aug 2022 bcm2710-rpi-zero-2-w.dtb
.rwxr-xr-x 31k root 30 Aug 2022 bcm2710-rpi-zero-2.dtb
.rwxr-xr-x 52k root 30 Aug 2022 bcm2711-rpi-4-b.dtb
.rwxr-xr-x 52k root 30 Aug 2022 bcm2711-rpi-400.dtb
.rwxr-xr-x 53k root 30 Aug 2022 bcm2711-rpi-cm4.dtb
.rwxr-xr-x 50k root 30 Aug 2022 bcm2711-rpi-cm4s.dtb
.rwxr-xr-x 52k root 30 Aug 2022 bootcode.bin
.rwxr-xr-x 154 root 22 Sep 2022 cmdline.txt
.rwxr-xr-x 2.1k root 22 Sep 2022 config.txt
.rwxr-xr-x 19k root 30 Aug 2022 COPYING.linux
.rwxr-xr-x 7.3k root 30 Aug 2022 fixup.dat
.rwxr-xr-x 5.4k root 30 Aug 2022 fixup4.dat
.rwxr-xr-x 3.2k root 30 Aug 2022 fixup4cd.dat
.rwxr-xr-x 8.4k root 30 Aug 2022 fixup4db.dat
.rwxr-xr-x 8.4k root 30 Aug 2022 fixup4x.dat
.rwxr-xr-x 3.2k root 30 Aug 2022 fixup_cd.dat
.rwxr-xr-x 10k root 30 Aug 2022 fixup_db.dat
.rwxr-xr-x 10k root 30 Aug 2022 fixup_x.dat
.rwxr-xr-x 145 root 22 Sep 2022 issue.txt
.rwxr-xr-x 8.2M root 30 Aug 2022 kernel8.img
.rwxr-xr-x 1.6k root 30 Aug 2022 LICENCE.broadcom
.rwxr-xr-x 0 root 28 Mar 13:37 meta-data
.rwxr-xr-x 0 root 28 Mar 13:37 ssh
.rwxr-xr-x 3.0M root 30 Aug 2022 start.elf
.rwxr-xr-x 2.2M root 30 Aug 2022 start4.elf
.rwxr-xr-x 804k root 30 Aug 2022 start4cd.elf
.rwxr-xr-x 3.7M root 30 Aug 2022 start4db.elf
.rwxr-xr-x 3.0M root 30 Aug 2022 start4x.elf
.rwxr-xr-x 804k root 30 Aug 2022 start_cd.elf
.rwxr-xr-x 4.8M root 30 Aug 2022 start_db.elf
.rwxr-xr-x 3.7M root 30 Aug 2022 start_x.elf
.rwxr-xr-x 0 root 28 Mar 13:37 user-data
nilsorbat in /tmp/rpi/root 🔒
[I] ➜ ls -l
total 76
lrwxrwxrwx 1 root root 7 Sep 22 2022 bin -> usr/bin/
drwxr-xr-x 2 root root 4096 Sep 22 2022 boot/
drwxr-xr-x 4 root root 4096 Sep 22 2022 dev/
drwxr-xr-x 82 root root 4096 Mar 28 13:37 etc/
drwxr-xr-x 3 root root 4096 Sep 22 2022 home/
lrwxrwxrwx 1 root root 7 Sep 22 2022 lib -> usr/lib/
drwx------ 2 root root 16384 Sep 22 2022 lost+found/
drwxr-xr-x 2 root root 4096 Sep 22 2022 media/
drwxr-xr-x 2 root root 4096 Sep 22 2022 mnt/
drwxr-xr-x 2 root root 4096 Sep 22 2022 opt/
drwxr-xr-x 2 root root 4096 Sep 3 2022 proc/
drwx------ 2 root root 4096 Sep 22 2022 root/
drwxr-xr-x 6 root root 4096 Sep 22 2022 run/
lrwxrwxrwx 1 root root 8 Sep 22 2022 sbin -> usr/sbin/
drwxr-xr-x 2 root root 4096 Sep 22 2022 srv/
drwxr-xr-x 2 root root 4096 Sep 3 2022 sys/
drwxrwxrwt 2 root root 4096 Mar 28 13:37 tmp/
drwxr-xr-x 11 root root 4096 Sep 22 2022 usr/
drwxr-xr-x 11 root root 4096 Sep 22 2022 var/
Verdict
By using Packer you can fully automate and customize Raspberry Pi image creation, so let the mass production begin.