Der Raspberry Pi Zero 2 W ist mit seinen 512 MB RAM und der Quad-Core CPU ein sparsamer aber leistungsfähiger Kandidat für kleine Container-Workloads im Homelab. Dieser Artikel zeigt, wie du rootless Podman mit funktionierenden CPU- und Memory-Limits vollautomatisiert mit Ansible einrichtest.
Zum Einsatz kommt die Ansible-Rolle head1328.podman
, die Podman rootless für einen dedizierten User (z.B. podman) einrichtet. Mit der Option podman_restrict_user=true wird sichergestellt, dass nur dieser User Podman ausführen darf. Debian 13 (Trixie) bringt Podman 5 mit, das native Quadlet-Unterstützung für systemd-Integration bietet. Darauf gehen wir im nächsten Artikel der Serie ein.
Voraussetzungen#
Voraussetzung: Auf dem Raspberry Pi Zero 2 W ist bereits Raspberry Pi OS (Debian 13 / Trixie) mit dem Raspberry Pi Imager installiert. Dabei wurde ein SSH-Key hinterlegt, der Hostname auf pi-hole-primary gesetzt und ein User ansible mit sudo-Rechten angelegt. Der Pi ist per USB-Ethernet-Adapter am LAN angeschlossen und über pi-hole-primary.fritz.box erreichbar. Tutorials zur Grundeinrichtung des Raspberry Pi gibt es reichlich im Netz, das überspringen wir hier.
Du brauchst Ansible lokal, alternativ den Ansible Dev Container ghcr.io/ansible/community-ansible-dev-tools:latest.
Dieses Tutorial ist für macOS und eine FritzBox als Router geschrieben. Mit anderen Betriebssystemen oder Routern sollte es aber keine großen Unterschiede geben.
Ansible-Projekt aufsetzen#
Das komplette Projekt findest du hier:
Dieses Tutorial basiert auf der Version 1.0.0 .
Projektstruktur#
ansible/
├── ansible.cfg
├── requirements.yml
├── play.yml
└── inventories/
└── homelab/
├── hosts.yml
└── group_vars/
├── all.yml
└── podmen.ymlansible.cfg#
[defaults]
inventory = ./inventories/homelab/hosts.yml
ask_vault_pass = true
remote_user = ansible
private_key_file = ~/.ssh/id_ed25519
# allow becoming non privileged user
allow_world_readable_tmpfiles=truerequirements.yml#
---
collections:
- name: devsec.hardening
version: ">=10.4.0,<10.5"
type: galaxy
- name: containers.podman
version: ">=1.18.0,<1.19"
type: galaxy
roles:
- name: head1328.podman
version: "v0.1.0"
- name: escalate.swap
version: "v2.1.0"Abhängigkeiten installieren (Rollen und Collections):
ansible-galaxy install -r requirements.ymlInventory: hosts.yml#
---
all:
vars:
ansible_python_interpreter: /usr/bin/python3
children:
podmen:
hosts:
pi-hole-primary:
ansible_host: pi-hole-primary.fritz.boxGroup Vars: podmen.yml#
---
podman_user_name: podman
podman_user_group: podmanGroup Vars: all.yml#
---
ansible_become_pass: !vault |
$ANSIBLE_VAULT;1.1;AES256
...Das sudo-Passwort verschlüsseln:
# interactive mode (empfohlen)
ansible-vault encrypt_string
# Alternative
ansible-vault encrypt_string 'dein-sudo-passwort' --name ansible_become_passPlaybook erstellen#
play.yml#
---
- name: Podman Setup
hosts: podmen
handlers:
- name: Reload systemd
ansible.builtin.systemd:
daemon_reload: true
become: true
- name: Reboot Raspberry Pi
become: true
ansible.builtin.reboot:
msg: "Reboot required"
connect_timeout: 5
reboot_timeout: 600
pre_reboot_delay: 2
post_reboot_delay: 10
test_command: whoami
pre_tasks:
- name: Setup swap (optional for 512 MB RAM)
become: true
tags:
- swap
block:
- name: Include escalate.swap role
ansible.builtin.include_role:
name: escalate.swap
vars:
swap_enabled: true
swap_config:
CONF_SWAPSIZE: 256
- name: Protect critical system services from CPU starvation
become: true
block:
- name: Ensure systemd drop-in directories for critical services
ansible.builtin.file:
path: "/etc/systemd/system/{{ item }}.service.d"
state: directory
owner: root
group: root
mode: '0755'
loop:
- ssh
- systemd-journald
- dbus
- name: Set high CPUWeight for critical services
ansible.builtin.copy:
dest: "/etc/systemd/system/{{ item }}.service.d/cpu-weight.conf"
owner: root
group: root
mode: '0644'
content: |
[Service]
CPUWeight=1000
loop:
- ssh
- systemd-journald
- dbus
notify: Reload systemd
- name: Ensure kernel cmdline has required cgroup flags
become: true
tags:
- podman
block:
- name: Read kernel cmdline
ansible.builtin.slurp:
path: /boot/firmware/cmdline.txt
register: cmdline_raw
- name: Build normalized kernel cmdline
ansible.builtin.set_fact:
cmdline_new: >-
{{
(cmdline_raw.content | b64decode | trim)
| regex_replace('\s*cgroup_enable=memory\s*', ' ')
| regex_replace('\s*cgroup_memory=1\s*', ' ')
| regex_replace('\s*swapaccount=1\s*', ' ')
| regex_replace('\s+', ' ')
| trim
}}
cgroup_enable=memory cgroup_memory=1 swapaccount=1
- name: Write kernel cmdline (exactly one line)
ansible.builtin.copy:
dest: /boot/firmware/cmdline.txt
content: "{{ cmdline_new }}"
owner: root
group: root
mode: '0644'
notify: Reboot Raspberry Pi
roles:
- role: head1328.podman
become: true
tags:
- podmanPlaybook ausführen#
ansible-playbook play.ymlDas Playbook:
- Richtet Swap ein (optional, aber empfohlen bei 512 MB RAM)
- Schützt kritische Services (SSH, journald, dbus) vor CPU-Starvation
- Konfiguriert die Kernel-Parameter für cgroup v2 Memory-Controller
- Installiert Podman und richtet einen dedizierten
podman-User ein - Aktiviert die systemd-Delegation für rootless Container
- Startet den Raspberry Pi neu (falls Kernel-Parameter geändert wurden)
Was die head1328.podman Rolle macht#
Die Rolle automatisiert folgende Schritte:
| Schritt | Beschreibung |
|---|---|
| Podman installieren | apt install podman |
| User erstellen | System-User podman mit Gruppe podman |
| Zugriffsrechte | Podman-Binary nur für podman-Gruppe ausführbar |
| subuid/subgid | User-Namespaces für rootless Container konfigurieren |
| Lingering | loginctl enable-linger für User-Services ohne Login |
| Delegation | systemd-Drop-in für cgroup-Controller: cpu cpuset memory io pids |
| Socket | Podman-User-Socket aktivieren und starten |
Die delegate.conf#
Die Rolle erstellt /etc/systemd/system/user@.service.d/delegate.conf:
[Service]
Delegate=yes
Controllers=cpu cpuset memory io pidsDiese Konfiguration ist entscheidend für funktionierende Ressourcen-Limits in rootless Containern.
Damit ist die Einrichtung abgeschlossen und Podman läuft. Im Folgenden verifizieren wir die Installation.
Installation verifizieren#
Nach dem Playbook-Lauf verbindest du dich per SSH auf den Pi und prüfst:
cgroup v2 aktiv?#
stat -fc %T /sys/fs/cgroup
# Erwartet: cgroup2fs
sudo -u podman bash -c 'cd "$HOME" && podman info --format "{{ .Host.CgroupsVersion }}"'
# Erwartet: v2Controller für User verfügbar?#
cat /sys/fs/cgroup/user.slice/user-$(id -u podman).slice/cgroup.controllers
# Muss enthalten: cpuset cpu io memory pidsDelegation aktiv?#
systemctl show user@$(id -u podman) -p Delegate
# Erwartet: Delegate=yesCPU-Limits testen#
Mit stress-ng kannst du CPU-Limits verifizieren:
sudo -u podman bash -c 'cd "$HOME" && podman run --rm \
--name=stressng \
--cpus=0.5 \
alpine \
sh -c "apk add --no-cache stress-ng && stress-ng --cpu 1 --timeout 30"'In einem zweiten Terminal beobachtest du:
sudo -u podman bash -c 'cd "$HOME" && podman stats'Erwartung: max. ~50% eines CPU-Kerns.
Beweis auf cgroup-Ebene (CPU)#
CG=$(sudo -u podman bash -c 'cd "$HOME" && podman inspect -f "{{ .State.CgroupPath }}" stressng')
cat "/sys/fs/cgroup${CG}/cpu.max"
# Beispiel: 50000 100000 (50ms von 100ms = 50%)Memory-Limits testen#
Unter cgroups v2 ist memory.max keine harte Kill-Grenze. Der Kernel bevorzugt Reclaim und Throttling. OOM-Kill ist die letzte Eskalation, wenn nichts anderes mehr funktioniert. Das macht es schwierig, einen OOM-Kill für Demo-Zwecke zu provozieren.
Stattdessen kannst du das Memory-Limit über die cgroup-Dateien und podman stats verifizieren:
# Terminal 1: Container mit Memory-Limit starten
sudo -u podman bash -c 'cd "$HOME" && podman run --rm \
--name=stressng \
--memory=64m \
--memory-swap=64m \
alpine \
sh -c "apk add --no-cache stress-ng && stress-ng --vm 1 --vm-bytes 200M --timeout 30"'# Terminal 2: Memory-Verbrauch beobachten
sudo -u podman bash -c 'cd "$HOME" && podman stats stressng'Erwartung: MEM USAGE bleibt bei ~64 MB gedeckelt, obwohl stress-ng 200 MB anfordert.
Beweis auf cgroup-Ebene (Memory)#
CG=$(sudo -u podman bash -c 'cd "$HOME" && podman inspect -f "{{ .State.CgroupPath }}" stressng')
cat "/sys/fs/cgroup${CG}/memory.max"
# Erwartet: 67108864 (64 MB in Bytes)
cat "/sys/fs/cgroup${CG}/memory.current"
# Zeigt aktuellen Verbrauch (nahe am Limit)Best Practices für kleine Systeme#
Empfohlene Container-Limits#
sudo -u podman bash -c 'cd "$HOME" && podman run \
--cpus=0.25 \
--pids-limit=50 \
--memory=100m \
--memory-swap=100m \
--security-opt no-new-privileges \
alpine \
nice -n 10 your-command'| Option | Beschreibung |
|---|---|
--cpus=0.25 | Maximal 25% eines CPU-Kerns |
--pids-limit=50 | Maximal 50 Prozesse/Threads |
--memory=100m | Maximal 100 MB RAM |
--memory-swap=100m | Kein zusätzlicher Swap |
--security-opt no-new-privileges | Verhindert Privilege Escalation |
nice -n 10 | Niedrigere Prozess-Priorität |
Merksätze#
- CPUWeight schützt Services
- nice schützt das System
- pids-limit schützt den Scheduler
- no-new-privileges verhindert unbeabsichtigte Rechteausweitung
Fazit#
Mit der head1328.podman Ansible-Rolle und den zusätzlichen Pre-Tasks ist der Raspberry Pi Zero 2 W bereit für rootless Container mit funktionierenden Ressourcen-Limits:
- cgroup v2 mit allen Controllern aktiviert
- Kernel-Parameter für Memory-Accounting gesetzt
- systemd-Delegation für User-Container konfiguriert
- Kritische System-Services vor CPU-Starvation geschützt
- Dedizierter
podman-User mit korrekten Berechtigungen
Ein Container, der trotz Memory-Limit nicht terminiert, ist kein Fehler; Linux verwaltet den Speicher korrekt und lagert bei Bedarf in den Swap aus.
Im nächsten Artikel installieren wir Pi-hole als Container auf dem vorbereiteten Raspberry Pi.
Verwendete Projekte#
- Raspberry Pi - Hardware und Foundation
- Raspberry Pi OS - Debian-basiertes Betriebssystem
- Podman - daemonless Container-Engine
- Ansible - Automatisierung und Configuration Management
Eigene Rollen und Playbooks#
Hat dir das hier geholfen?
Unterstütze gerne via:
