GitHub action to use systemd-nspawn
to run commands in a (un)booted container on a Raspberry Pi SD card image
systemd-nspawn
is
used to run commands in a light-weight namespace container, like chroot but with full virtualization
of the file system hierarchy, the process tree, the various IPC subsystems, and the host and domain
name. It can also be used to boot the image's init program (which is usually systemd) as an OS; this
action makes it easy to run a set of shell commands whether or not the OS is booted in the
container. You can use this action to set up Docker containers as part of your OS image build
process in GitHub Actions!
Note that currently only unbooted containers work correctly on GitHub's new hosted arm64 runners;
booted systemd-nspawn containers spontaneously initiate shutdown as soon as the system boot sequence
reaches the login prompt. Maybe that's a bug which will magically go away after the hosted arm64
runners exit public preview (this is wishful thinking). If you want to start or interact with the
Docker daemon inside an unbooted container on an arm64 runner, you will need instantiate the
container with the CAP_NET_ADMIN
capability (to make iptables work as required by Docker) and then
manually start both containerd (by launching /usr/bin/containerd
as a background process) and the
Docker daemon (by launching /usr/bin/dockerd
as a background process). See
the relevant example below for an illustration of
how to do this.
Unlike the alternatives listed below (which you should evaluate based on your own
project's requirements to see which ones might be more appropriate for you),
ethanjli/pinspawn-action
attempts to provide a bare-minimum abstraction which gets you closer
to shell scripting - it tries to minimize the amount of tool-specific abstraction for you to learn,
and the only thing you can do with it is to run your own shell commands/scripts.
Also, by contrast to every below-listed alternative besides sdm, pinspawn-action takes advantage of a mechanism which is more powerful (and more similar to actually-booted Raspberry Pi environments) than chroots. pinspawn-action is designed specifically as an ergonomic wrapper for GitHub Actions to use systemd-nspawn with Raspberry Pi OS images.
- name: Install and run cowsay
uses: ethanjli/pinspawn-action@v0.1.4
with:
image: rpi-os-image.img
run: |
apt-get update
apt-get install -y cowsay
/usr/games/cowsay 'I am running in a light-weight namespace container!'
- name: Run in Python
uses: ethanjli/pinspawn-action@v0.1.4
with:
image: rpi-os-image.img
shell: python
run: |
import platform
for word in reversed(['!', platform.python_version(), 'Python', 'in', 'running', 'am', 'I']):
print(word, end=' ')
- name: Run without root permissions
uses: ethanjli/pinspawn-action@v0.1.4
with:
image: rpi-os-image.img
user: pi
shell: sh
run: |
sudo apt-get update
sudo apt-get install -y figlet
figlet -f digital "I am $USER in $SHELL!"
- name: Make a script on the host
uses: 1arp/create-a-file-action@0.4.5
with:
file: figlet.sh
content: |
#!/usr/bin/env -S bash -eux
figlet -f digital "I am $USER in $SHELL!"
- name: Make the script executable
run: chmod a+x figlet.sh
- name: Run script directly
uses: ethanjli/pinspawn-action@v0.1.4
with:
image: rpi-os-image.img
args: --bind "$(pwd)":/run/external
user: pi
shell: /run/external/figlet.sh
- name: Make a bootloader configuration snippet
uses: 1arp/create-a-file-action@0.4.5
with:
file: boot-config.snippet
content: |
# Enable support for the RV3028 RTC
dtoverlay=i2c-rtc,rv3028,trickle-resistor-ohms=3000,backup-switchover-mode=1
- name: Modify bootloader configuration
uses: ethanjli/pinspawn-action@v0.1.4
with:
image: rpi-os-image.img
args: --bind "$(pwd)":/run/external
run: |
cat /run/external/boot-config.snippet >> /boot/config.txt
cp /boot/config.txt /run/external/boot.config
- name: Print the bootloader config
run: cat boot.config
Note: the system in the container will shut down after the specified commands finish running.
- name: Analyze systemd boot process
uses: ethanjli/pinspawn-action@v0.1.4
with:
image: rpi-os-image.img
args: --bind "$(pwd)":/run/external
boot: true
run: |
while ! systemd-analyze 2>/dev/null; do
echo "Waiting for boot to finish..."
sleep 5
done
systemd-analyze critical-chain | cat
systemd-analyze blame | cat
systemd-analyze plot > /run/external/bootup-timeline.svg
echo "Done!"
- name: Upload the bootup timeline to Job Artifacts
uses: actions/upload-artifact@v4
with:
name: bootup-timeline
path: bootup-timeline.svg
if-no-files-found: error
overwrite: true
Note: this example will only work if you run it in the ubuntu-24.04-arm
GitHub Actions runner;
trying to run it on ubuntu-22.04-arm
results in an error when dockerd
tries to start
(failed to start daemon: Devices cgroup isn't mounted
).
- name: Install Docker
uses: ethanjli/pinspawn-action@v0.1.4
with:
image: rpi-os-image.img
run: |
export DEBIAN_FRONTEND=noninteractive
apt-get update
apt-get install -y ca-certificates curl
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \
https://download.docker.com/linux/debian \
$(. /etc/os-release && echo "$VERSION_CODENAME") stable" \
> /etc/apt/sources.list.d/docker.list
apt-get update
apt-get install -y \
docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
- name: Pull a Docker container image
uses: ethanjli/pinspawn-action@v0.1.4
with:
image: rpi-os-image.img
args: --capability=CAP_NET_ADMIN
run: |
#!/bin/bash -eux
/usr/bin/containerd &
sleep 5
/usr/bin/dockerd &
sleep 10
docker image pull hello-world
docker image ls
Inputs:
Input | Allowed values | Required? | Description |
---|---|---|---|
image |
file path | yes | Path of the image to use for the container. |
args |
systemd-nspawn options/args |
no (default ``) | Options, args, and/or a command to pass to systemd-nspawn . |
shell |
``, bash , `sh`, `python`, etc. |
no (default ``) | The shell to use for running commands. |
run |
shell commands | no (default ``) | Commands to run in the shell. |
user |
name of user in image | no (default root ) |
The user to run commands as. |
boot |
false , true |
no (default false ) |
Boot the image's init program (usually systemd) as PID 1. |
run-service |
file path | no (default ``) | systemd service to run shell with the run commands; only used with booted containers. |
-
image
must be the path of an unmounted raw disk image (such as a Raspberry Pi OS SD card image), where partition 2 should be mounted as the root filesystem (i.e./
) and partition 1 should be mounted to/boot
. -
args
can be a list of command-line options/arguments forsystemd-nspawn
. You should not set the--user
or--boot
flags here; instead, you should set theuser
andboot
action inputs. -
If
run
is not left empty,shell
will be used to execute commands specified in therun
input. You can use built-inshell
keywords, or you can define a custom set of shell options. The shell command that is run internally executes a temporary file that contains the commands to run, like in GitHub Actions. Please refer to the GitHub Actions semantics of theshell
keyword of job steps for details about the behavior of this action'sshell
input.If you just want to run a single script, you can leave
run
empty and provide that script as theshell
input. However, you will need to set the appropriate permissions on the script file. -
If
boot
is enabled, this action will usesystemd-nspawn
to automatically search for an init program in the image (typically systemd) and invoke it as PID 1, instead of a shell.-
The provided
run
commands will be triggered by a temporary system service defined with the following template (unless you specify a different service file template using therun-service
input):[Unit] Description=Run commands in booted OS After=getty.target [Service] Type=exec ExecStart=bash -c "\ su - {user} -c '{command}; echo $? | tee {result}'; \ echo Shutting down...; \ shutdown now \ " & StandardOutput=tty [Install] WantedBy=getty.target
This service file template has string interpolation applied to the following strings:
{user}
will be replaced with the value of the action'suser
input.{command}
will be replaced with a command to run your specifiedrun
commands using your specifiedshell
{result}
will be replaced with the path of a temporary file whose contents will be checked after the container finishes running to determine whether the command finished successfully (in which case the file should be the string0
); this file is interpreted as holding a return code.
-
If this flag is enabled, then any arguments specified as the command line in
args
are used as arguments for the init program, i.e.systemd-nspawn
will be invoked likesystemd-nspawn --boot {args}
.
-
You may also be able to run the gha-wrapper-pinspawn.sh
script on your own computer, but you will
have to figure out how to install the required dependencies yourself - take a look at
action.yml to see what extra apt packages get installed on top of the GitHub Actions
runner's default set of packages, and to see how you can pass inputs to the
gha-wrapper-pinspawn.sh script as environment variables. Or, if you
really can't tolerate using environment variables, you can instead directly invoke
pinspawn.sh - look at the contents of gha-wrapper-pinspawn.sh
to see how to do
so.
I'm aware of a variety of existing approaches for generating custom Raspberry Pi OS images in GitHub Actions CI for building a custom OS which is meant to be maintained (i.e. changed) over time. The following are all built as abstractions away from pure shell-scripting and, with the exception of sdm, are based on pure chroots (which come with various limitations, some of which may affect your work depending on your goals):
- Nature40/pimod: a great option to consider if you want to use Dockerfile-style syntax. Ready-to-use as a GitHub Action! If you want to interact with Docker, you will need to use some advanced Docker-in-Docker magic - see here for details.
- usimd/pi-gen-action with RPi-Distro/pi-gen: a good option to consider if you want to build OS images using the same (rather-complicated) abstraction system that is used for building the Raspberry Pi OS, e.g. for multi-stage builds.
- guysoft/CustomPiOS: a system of build scripts organized around pre-defined modules which you can combine with your own scripts. A good option to consider if you want to use some of the modules they provide in your own OS image, or if you also want to build images locally (e.g. in a Docker container, apparently?).
- gitbls/sdm: a system of build scripts organized around pre-defined plugins which you can combine with your own scripts. Has many more plugins for you to search through compared to CustomPiOS, and also has enough functionality to replace Raspberry Pi Imager. Can work on chroots, but defaults to using systemd-nspawn instead. You may need to figure out GitHub Actions integration yourself.
- pndurette/pi-packer: potentially reasonable if you know (or would be comfortable learning) Packer and Packer HCL. You may need to figure out GitHub Actions integration yourself.
- raspberrypi/rpi-image-gen: Raspberry Pi's new framework for building custom images, if you want to learn their unique YAML-based configuration system. You may need to figure out GitHub Actions integration yourself.
If you absolutely need to run shell commands/scripts in a booted QEMU virtual machine with full virtualization of Raspberry Pi hardware, I have created ethanjli/piqemu-action with basically the same interface as pinspawn-action. However, I found in GitHub Actions runners that Raspberry Pi QEMU VMs are quite slow (especially for downloading files over the network) and flaky (in the sense that they will just freeze in the middle of work without a helpful error message, requiring you to restart the workflow to make it work - which is definitely a some kind of bug). I strongly recommend using pinspawn-action instead of piqemu-action unless you have something which absolutely won't work outside a full virtual machine.
We have chosen the following licenses in order to give away our work for free, so that you can freely use it for whatever purposes you have, with minimal restrictions, while still protecting our disclaimer that this work is provided without any warranties at all. If you're using this project, or if you have questions about the licenses, we'd love to hear from you - please start a new discussion thread in the "Discussions" tab of this repository on Github or email us at lietk12@gmail.com .
Except where otherwise indicated, source code provided here is covered by the following information:
Copyright Ethan Li and pinspawn-action contributors
SPDX-License-Identifier: Apache-2.0 OR BlueOak-1.0.0
Software files in this repository are released under the Apache 2.0 License and the Blue Oak Model License 1.0.0; you can use the source code provided here either under the Apache License or under the Blue Oak Model License, and you get to decide which license you will agree to. We are making the software available under the Apache license because it's OSI-approved, but we like the Blue Oak Model License more because it's easier to read and understand. Please read and understand the licenses for the specific language governing permissions and limitations.