Chapter 35C: Container runtimes on embedded¶
What: running OCI containers on an i.MX6ULL with Podman. By the end you’ll have an Alpine Linux container running a small Python web server, accessing the host’s GPIO from inside the container. MCU bridge: Think of Linux GPIO like the same pin set/reset block you used on STM32, but accessed through a kernel subsystem that owns numbering, direction, interrupts, and user-space exposure. GPIO: General-Purpose Input/Output, a pin controlled as a digital input, output, or interrupt source.
Why: modern embedded products separate “the base system” (kernel + bootloader + minimal rootfs, updated rarely) from “the application” (one or more containers, updated whenever a customer-facing change is needed). Containers give you reproducible app deployment, image-based updates, and the ability to roll back in seconds. The cost is some RAM and some complexity. The benefit is a deployment story that scales from one device or a fleet of a million. MCU bridge: Think of the rootfs as the firmware image’s file-backed runtime environment. On an MCU you link everything into flash. On Linux, programs and config live in this mounted tree. rootfs: root filesystem, the directory tree mounted at / that contains /bin, /etc, /dev, and libraries.
Focus: Three kernel features make containers work: namespaces (process isolation), cgroups (resource limits), and overlayfs (storage). All three have been in mainline Linux for years. You just need them turned on in
.config.
35C.1 When containers make sense on embedded¶
Reason |
Yes |
No |
|---|---|---|
App + base system have different update cadences |
✓ |
|
Single product variant, single binary |
✓ |
|
Multiple apps that mustn’t interfere |
✓ |
|
Need to ship third-party software unmodified |
✓ |
|
Need 100 % minimum disk footprint |
✓ |
|
Need < 50 ms cold-start latency |
✓ |
|
Need to roll back app-only without touching kernel |
✓ |
For a single-purpose appliance with one in-house binary, containers are overhead. For a smart-camera platform that ships one base image to thousands of customers, with each customer running 1-5 of their applications on top, containers are how you keep your sanity.
The i.MX6ULL is on the small side for containers, but it’s tractable: a single Alpine-based container costs ~30 MB additional disk and ~15 MB RAM at idle.
35C.2 What containers actually are¶
A “container” is just a Linux process group with three things layered on:
Namespaces: The kernel maintains separate “views” per namespace type: PID, network, mount, UTS (hostname), IPC, user, cgroup. A process in a PID namespace sees only processes in that namespace. It thinks it’s PID 1.
Cgroups (control groups): Resource limits per group: CPU, memory, IO. Enforced by the kernel.
Overlay filesystem: The container’s root is an overlayfs over the image’s read-only layers + a writable upper. Same
overlayfsfrom Chapter 35B.
There is no container-runtime magic. A container is a process with namespaces, cgroups, and a careful mount setup. Docker, Podman, containerd, and CRI-O all do the same thing. They differ in UI, daemons, and ecosystem.
For embedded, Podman wins:
Daemonless. Docker has a privileged daemon that runs as root and listens on a socket. Podman has no daemon,
podman rundirectly spawns the container process. One less moving part.Rootless mode. Containers can run as a normal user, using user namespaces. Better security story than Docker.
Drop-in CLI-compatible with Docker.
podman pull,podman run,podman psall work likedocker pull, etc.
35C.3 Kernel configuration¶
The kernel features containers need. Check your .config:
CONFIG_NAMESPACES=y
CONFIG_UTS_NS=y
CONFIG_IPC_NS=y
CONFIG_USER_NS=y
CONFIG_PID_NS=y
CONFIG_NET_NS=y
CONFIG_CGROUPS=y
CONFIG_MEMCG=y
CONFIG_CPUSETS=y
CONFIG_CGROUP_DEVICE=y
CONFIG_CGROUP_FREEZER=y
CONFIG_CGROUP_PIDS=y
CONFIG_CGROUP_SCHED=y
CONFIG_CGROUP_NET_PRIO=y
CONFIG_BRIDGE=y # for the container network bridge
CONFIG_NETFILTER=y
CONFIG_NETFILTER_XT_MATCH_ADDRTYPE=y
CONFIG_NF_CONNTRACK=y
CONFIG_OVERLAY_FS=y
CONFIG_SECCOMP=y
CONFIG_VETH=y # virtual ethernet pair for container networking
imx_v6_v7_defconfig enables most of these. CONFIG_USER_NS is sometimes off for size and needs flipping on.
After confirming and rebuilding the kernel:
[root@pa-mini:~]# zgrep -E "CONFIG_(NAMESPACES|CGROUPS|OVERLAY_FS|USER_NS)" /proc/config.gz
CONFIG_NAMESPACES=y
CONFIG_USER_NS=y
CONFIG_CGROUPS=y
CONFIG_OVERLAY_FS=y
(If /proc/config.gz is empty, your kernel was built without CONFIG_IKCONFIG_PROC=y. The check still works by inspecting .config on the build host.)
35C.4 Install Podman¶
On a Buildroot rootfs, enable BR2_PACKAGE_PODMAN. On Ubuntu-base:
Buildroot: a configuration-driven build system that produces a complete root filesystem and related images.
$ ./mount-ubuntu.sh # chroot in
root@host:/# apt update
root@host:/# apt install -y podman
Verify:
[root@pa-mini:~]# podman --version
podman version 4.3.1
[root@pa-mini:~]# podman info | head
host:
arch: arm
buildahVersion: 1.28.2
cgroupManager: systemd ← or "cgroupfs" for non-systemd setups
cgroupVersion: v2
...
If cgroupManager is shown but errors are reported, you may need to switch to cgroupfs. On a BusyBox rootfs:
cat > /etc/containers/containers.conf <<'EOF'
[engine]
cgroup_manager = "cgroupfs"
events_logger = "file"
EOF
35C.5 Pull and run a first container¶
[root@pa-mini:~]# podman pull docker.io/library/alpine:latest
Trying to pull docker.io/library/alpine:latest...
Getting image source signatures
Copying blob sha256:abcdef... done
Copying config sha256:fedcba... done
Writing manifest to image destination
abcdefabcdef...
[root@pa-mini:~]# podman run --rm alpine sh -c "echo hello from $(uname -m)"
hello from armv7l
A real Alpine Linux 3.x container, pulled, run, exited, cleaned up, in roughly 30 seconds plus download time. Alpine images for armv7 are ~3-5 MB compressed, ~10 MB on disk.
Run it interactively:
[root@pa-mini:~]# podman run --rm -it alpine sh
/ # cat /etc/os-release
NAME="Alpine Linux"
ID=alpine
VERSION_ID=3.19.1
...
/ # apk add python3
/ # python3 -c 'print("hi")'
hi
/ # exit
You just ran Alpine’s apk inside a container running on Ubuntu (or BusyBox), all on top of the same kernel. From inside, it looks like a complete Alpine system.
35C.6 Container that talks to GPIO¶
The interesting case for embedded: a container that can access host hardware.
Goal: a container running a Python script that toggles the LED via /sys/class/leds/led0/brightness.
Dockerfile:
FROM alpine:latest
RUN apk add --no-cache python3
COPY blink.py /blink.py
CMD ["python3", "/blink.py"]
blink.py:
#!/usr/bin/env python3
import time
LED = "/sys/class/leds/led0/brightness"
while True:
with open(LED, "w") as f:
f.write("1")
time.sleep(0.5)
with open(LED, "w") as f:
f.write("0")
time.sleep(0.5)
Build:
[root@pa-mini:~]# podman build -t led-blinker .
Run, with /sys/class/leds/ bind-mounted into the container:
[root@pa-mini:~]# podman run --rm -v /sys/class/leds:/sys/class/leds:rw led-blinker
The LED blinks. Inside the container, the Python script writes to /sys/class/leds/led0/brightness. Thanks to the bind mount, those writes hit the host’s actual sysfs.
sysfs: a kernel-generated filesystem under /sys that exposes devices, drivers, and attributes.
A more careful invocation passes only the specific device, not the entire sysfs hierarchy:
[root@pa-mini:~]# podman run --rm \
-v /sys/class/leds/led0:/sys/class/leds/led0:rw \
led-blinker
That gives the container access to only led0’s files. Other LEDs (and the rest of /sys/) remain invisible.
For more privileged access patterns:
--device /dev/i2c-0to expose/dev/i2c-0to the container.--cap-add SYS_RAWIOto grant a capability the container needs.--privileged(last resort) to give the container full host access. Bad practice except for development.
35C.7 Storage and rootfs layout¶
Podman stores images and containers under /var/lib/containers/ (rootful) or ~/.local/share/containers/ (rootless). On embedded systems where /var/ may be tmpfs (Ch 35B), redirect this:
$ cat > /etc/containers/storage.conf <<'EOF'
[storage]
driver = "overlay"
runroot = "/run/containers/storage"
graphroot = "/data/containers/storage"
EOF
graphroot should be on persistent storage. runroot (the per-run temp) on tmpfs is fine.
35C.8 When not to use containers¶
Be honest about the costs:
RAM. Each container adds ~10-20 MB at minimum. On 512 MB i.MX6ULL with three containers, that’s 50 MB just to be in containers. Not always a problem. Sometimes prohibitive.
Boot time.
podman.serviceplus container-pull-and-start adds 2-5 seconds.Complexity. “Why isn’t my hardware working?” answers gain a layer: “Is it the device driver? The bind mount? The cgroup device controller? The user-namespace mapping?”
Determinism. A pulled container’s exact bytes depend on the registry server. For a closed-supply-chain product, you’d mirror the registry or build images locally and bake them into the rootfs.
For one-purpose appliances, just ship a binary. For dynamic product lines that update frequently, containers earn their cost.
35C.9 Production patterns¶
A few real-world patterns:
Pre-loaded image, no registry pulls¶
For products without reliable internet, you don’t podman pull on every boot, you bake the image into the rootfs at build time:
# At build time on the host:
$ podman save -o myapp.tar localhost/myapp:v1.0
$ cp myapp.tar $TARGET_ROOTFS/opt/preloaded-images/
# At first boot on the target:
[root@pa-mini:~]# podman load -i /opt/preloaded-images/myapp.tar
[root@pa-mini:~]# podman run -d --restart=always myapp:v1.0
podman generate systemd¶
Generate a systemd unit file for a container:
[root@pa-mini:~]# podman generate systemd --new --name myapp --files
$ ls
container-myapp.service
[root@pa-mini:~]# cp container-myapp.service /etc/systemd/system/
[root@pa-mini:~]# systemctl enable --now container-myapp.service
The systemd unit handles auto-start, dependencies, log capture, and restart-on-failure.
Updates via image swap¶
# New version available:
podman pull registry.example.com/myapp:v1.1
podman stop myapp
podman rm myapp
podman run -d --name myapp --restart=always registry.example.com/myapp:v1.1
# If it crashes immediately:
podman rollback ... # or pull v1.0 again
Image swap is atomic at the container level: either the new image is running or the old one is. Much cleaner than “extract a tarball into /opt/myapp/” updates.
35C.10 Lab¶
Install Podman. Get to
podman --versionon the target.Pull and run Alpine. Confirm it works.
Build a custom image. Write a
Dockerfilefor a tiny Python script.podman buildit.podman runit.Bind-mount sysfs. Build the LED blinker container and run it with
--vmounting/sys/class/leds/. Verify LED toggles.Measure overhead.
podman inforeports memory. Do afree -hbefore and afterpodman run. Quantify the cost.Pre-bake an image.
podman saveon the host, copy to the target,podman loadon first boot. Verify no network calls happen.Systemd integration. Generate a unit file via
podman generate systemd. Enable it. Reboot. Verify the container starts.
35C.11 Pitfalls¶
USER_NSdisabled. Symptom: Podman rootless mode fails with “no subuid map”. Fix: rebuild kernel withCONFIG_USER_NS=y.cgroup v1 vs v2 mismatch. Some older systemd setups expect v1 hierarchies. Podman 4.x prefers v2. Force v2 in the kernel cmdline:
cgroup_no_v1=all systemd.unified_cgroup_hierarchy=1.Network bridge not created. Without
CONFIG_BRIDGEandCONFIG_VETH, containers have no network. Symptom: container can’t reach the internet. Fix: kernel config./var/lib/containers/on tmpfs. Loses every image on reboot. Always redirectgraphrootto persistent storage.Trying to
podman pullfrom a registry behind a corporate proxy. SetHTTPS_PROXY=...in/etc/containers/registries.confor globally.Image platform mismatch.
podman pull alpinemight fetch x86_64 by default if the registry doesn’t auto-select. Force:podman pull --platform linux/arm/v7 alpine.Bind-mount permissions. Containers run as a different UID than the host’s. Bind-mounting
/sys/class/gpio/may work for reads but not writes if UID-mapping isn’t right. The--privilegedworkaround is too coarse. The correct fix is--user 0:0plus careful capability grants.
35C.12 Going deeper¶
Podman documentation at
docs.podman.io/, comprehensive and well-organised.man podman-runfor the full set of options.runc: the lower-level container runtime that Podman invokes. Smaller, fewer dependencies. Sometimes used directly on resource-constrained systems.containerdandnerdctl: alternative runtime + CLI, slightly closer to “what Kubernetes uses.” Heavier than Podman but ecosystem-richer.Buildah: image-building companion to Podman without needing a daemon.OCI Image Specification at
github.com/opencontainers/image-spec, the standard image format Podman, Docker, and friends all consume.balenaEngine: a stripped-down Docker variant specifically for embedded. Used by balena.io’s commercial fleet-management platform.
End of Part V. You can now build a rootfs three ways (by hand, Buildroot, Ubuntu-base), harden it for production (RO + overlayfs), and ship containerised applications on top.
Next chapter: Chapter 36: Your first kernel module. With the user-space picture complete, we turn back to the kernel to write the driver that gives
/sys/class/leds/led0/brightnessits behavior.