Firecracker VM aus Docker Image erstellen

Dieser Artikel dokumentiert den aktuellen Stand meiner Recherche und Implementierung zur Erstellung eines Root-Dateisystems für eine Firecracker-VM aus einem Docker-Image. Eine Einführung in Firecracker und die ersten Schritte findest du in den vorherigen Artikeln dieser Serie.

Teil der Serie
  1. Firecracker MicroVM Einstieg
  2. Firecracker VM sicher mit Jailer starten
  3. Firecracker VM aus Docker Image erstellen

Das Ziel dieses Artikels ist es, ein Filesystem mit Firecracker zu erstellen, in dem ein Go-Programm enthalten ist und dieses beim Start ausgeführt wird. Der Artikel beschreibt einen Weg, der dank Github Workflow unabhängig von meinem Apple M1 funktioniert.

Firecracker VM aus Docker Image erstellen

Der langfristige und flexiblere Weg wäre die Nutzung von DeviceMapper und einem eigenen Root-Dateisystem, das alles initialisiert und überwacht. Da ich dies vorerst nicht benötige, werde ich es eventuell zu einem späteren Zeitpunkt ausprobieren.

Dockerfile link

Basierend auf den Blog-Artikeln “Firecracker: start a VM in less than a second” von Julia Evans und “Launching Alpine Linux on Firecracker like a boss” von Radek Gruchalski habe ich ein Dockerfile erstellt, das ein Root-Dateisystem auf Basis von Alpine Linux erstellt.

Das Dockerfile besteht aus zwei Teilen: Im ersten Teil wird das Go-Programm kompiliert und im zweiten Teil wird das Root-Dateisystem erstellt. Das Go-Programm wird beim Start des Containers ausgeführt.

FROM golang:1.22-alpine3.19 as builder

COPY go.mod go.sum ./
RUN go mod download
COPY ./agent ./
RUN go build -o /agent main.go

FROM alpine:3.19
RUN apk update \
    && apk add openrc \
    && rc-update add devfs boot \
    && rc-update add procfs boot \
    && rc-update add sysfs boot \
    && rc-update add local default \
    && rm -rf /var/cache/apk/*
COPY --from=builder /agent /agent
RUN echo "#!/bin/sh" >> /etc/local.d/agent.start \
    && echo "/agent && reboot || reboot" >> /etc/local.d/agent.start \
    && chmod +x /etc/local.d/agent.start \
    && echo rc_verbose=yes > /etc/conf.d/local

Um sicherzustellen, dass normale Programme auf Alpine Linux funktionieren, müssen beim Start des Containers devfs, procfs und sysfs aktiviert werden.

Das Go-Programm wird in /agent kopiert und ein Startskript wird in /etc/local.d/agent.start erstellt, das das Go-Programm ausführt.

Normalerweise beendet sich die Firecracker-VM nicht selbst. Wenn das Go-Programm beendet wird, läuft sie als Zombie-Prozess weiter. Deshalb wird nach dem Ende des Go-Programms ein Neustart des Containers durchgeführt, welcher die VM herunterfährt.

RootFS erstellen link

Das Root-Dateisystem wird aus dem Docker-Image erstellt. Dazu wird das Docker-Image gebaut und in einem Container gestartet, um die Dateien zu kopieren.

IMG_ID=$(docker build -q -f rootfs/Dockerfile .)
CONTAINER_ID=$(docker run --rm -td $IMG_ID /bin/sh)

Anschließend wird ein ext4 Dateisystem erstellt und gemountet.

MOUNTDIR=mnt-agent-rootfs
FS=rootfs.ext4

mkdir -p $MOUNTDIR
qemu-img create -f raw $FS 300M
mkfs.ext4 $FS
sudo mount $FS $MOUNTDIR

Die Dateien werden aus dem Container in das Dateisystem kopiert.

sudo docker cp $CONTAINER_ID:/ $MOUNTDIR

Nach dem Kopieren wird das Dateisystem ungemountet und der Container gestoppt. Das Docker Image wird nicht mehr benötigt und kann gelöscht werden.

sudo umount $MOUNTDIR
docker stop $CONTAINER_ID
docker rmi $IMG_ID
rm -rf $MOUNTDIR

Das rootfs.ext4 kann nun als Root Filesystem für Firecracker verwendet werden.

Github Workflow link

Um das Root Filesystem automatisch zu erstellen, habe ich einen Github Workflow erstellt. Dieser wird bei einem Push auf den main Branch ausgeführt.

Das Filesystem wird erstellt und als Artefakt hochgeladen. Den Upload auf den Server führe ich manuell durch.

name: ci

on:
  push:
    branches:
      - 'main'

jobs:
  docker:
    runs-on: ubuntu-latest
    steps:
      - name: Set up QEMU
        uses: docker/setup-qemu-action@v3
      - name: Install qemu-img
        run: sudo apt-get install qemu-utils
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3
      - name: Checkout
        uses: actions/checkout@v4
      - name: Build
        run: |
            #!/bin/bash

            IMG_ID=$(docker build -q -f rootfs/Dockerfile .)
            CONTAINER_ID=$(docker run --rm -td $IMG_ID /bin/sh)

            MOUNTDIR=mnt-agent-rootfs
            FS=agent.ext4

            mkdir -p $MOUNTDIR
            echo "# create the rootfs image"
            echo "qemu-img create -f raw $FS 300M"
            qemu-img create -f raw $FS 300M
            echo "# create a filesystem on the image"
            echo "mkfs.ext4 $FS"
            mkfs.ext4 $FS
            echo "# mount the image"
            echo "sudo mount $FS $MOUNTDIR"
            sudo mount $FS $MOUNTDIR
            echo "# copy the data from the container to the image"
            echo "sudo docker cp $CONTAINER_ID:/ $MOUNTDIR"
            sudo docker cp $CONTAINER_ID:/ $MOUNTDIR
            echo "# unmount the image"
            echo "sudo umount $MOUNTDIR"
            sudo umount $MOUNTDIR

            echo "# cleanup"

            echo "# stop the container"
            echo "docker stop $CONTAINER_ID"
            docker stop $CONTAINER_ID
            echo "# remove the image"
            echo "docker rmi $IMG_ID"
            docker rmi $IMG_ID
            echo "rm -rf $MOUNTDIR"
            rm -rf $MOUNTDIR            

      - name: Upload ext4 as artifact
        uses: actions/upload-artifact@v2
        with:
          name: agent.ext4
          path: agent.ext4

Fazit link

Das Erstellen des Root-Filesystems mit dieser Methode ist vergleichsweise einfach. Allerdings erfordert es die Erstellung eines speziellen Dockerfiles. Dabei werden Dockereigenschaften wie Entrypoint, Command oder andere Definitionen ignoriert.

Wenn lediglich ein relativ statisches RootFS benötigt wird, ist dies ein einfacher und guter Weg.