Skip to content

Understand Docker's Isolation Boundary

Docker packages and runs an application with its dependencies. On a Linux Raspberry Pi, that does not give the application a separate guest kernel or automatically decide which host data it should access.

Outcome: review a proposed container's access and distinguish resource limits from data isolation. Complete the filesystem and cgroup lessons first. Docker is not required for the review. An optional, Pi-tested lab below runs a trusted official image with no host-data mount or published port; it requires an installed, authorized local engine.

Connect Docker to the mechanisms you just tested

Mechanism Your systemd exercise What to review in Docker
Mount namespace and filesystem policy Private temporary paths and read-only settings Image filesystem, read-only mode, writable volumes and host bind mounts
Network namespace A socket filter is a different control, not a network namespace Network mode, shared namespaces and published ports
cgroup controllers TasksMax, MemoryMax, CPUQuota PID, memory, swap and CPU constraints
Process identity and privileges Ordinary user and NoNewPrivileges Container user, UID mapping, capabilities and security options
Kernel boundary The same kernel runs the practice service A normal Linux container also shares the host kernel

The Docker security overview describes namespaces, cgroups and the daemon's attack surface. Docker is not merely a memory limit, and cgroups alone are not containers. Namespaces, identity and resource controls solve different parts of the problem; configuration determines which boundaries remain.

--network none illustrates that distinction: Docker creates only the container's loopback interface, rather than applying the previous lesson's socket-family filter. Loopback remains usable; this is not equivalent to denying every socket operation. See the none network driver.

Review a Raspberry Pi logger before containerizing it

Use the same hypothetical logger, not an unknown image or someone else's install script. Write down its needs:

Proposed access Ask first Narrower starting point to investigate
Bind-mount your whole home Does the logger need browser data or SSH keys? A dedicated input/settings directory; no unrelated files
Writable settings mount Must the application edit its own configuration? Read-only settings, separate writable readings
GPIO/I2C device access Which exact device and permissions are required? Only the reviewed device and needed group access, not all of /dev
Host networking Does it need the host's entire network view? A separate network; deliberately choose any published address/port
Docker socket mount Must the logger create/manage containers? No daemon socket or daemon credentials
Unlimited workers/memory What is its normal workload and failure response? Measured limits, plus a plan for failed requests

For a software-only log processor, hardware devices might not be needed at all. Do not add --privileged, host PID sharing or the host root filesystem just to suppress an error. A GPIO workload may justify device access, but it expands the review; the previous no-device labs cannot validate it.

Read-only is not secret. A read-only bind mount still lets the application read its contents. Conversely, a writable host bind mount can expose the original host data, not an expendable copy inside the image. The Docker bind-mount guide explains that connection. Mounting a volume also means removing a container is not the same as restoring or backing up its data.

Distinguish three meanings of root

  1. Container process user: --user selects the application's UID/GID inside the container. A non-root app is useful, but it does not turn a rootful Docker daemon into a rootless one.
  2. Host identity mapping: a user namespace can map container IDs to different host IDs. File ownership on bind mounts still needs deliberate review.
  3. Daemon identity and authority: who runs the engine, and who can ask it to create containers with powerful host access?

Access to a rootful daemon is security-sensitive. Adding an account to the docker group is not merely a convenient way to run a harmless command; controlling that daemon can confer host-level authority. See Docker's explicit group privilege warning. Do not grant it as a prerequisite for this course, and do not expose its API publicly.

Rootless Docker runs the daemon and containers without host root, subject to prerequisites and limitations. It is different from an image containing USER 1000. It also does not prevent a container from reading data you intentionally make available to it. Consider the daemon, workload identity and shared data together.

Optional read-only inspection of an existing container

Skip this if Docker is absent. Do not install it or change group membership for the exercise. On the Docker host, first verify the configured endpoint:

docker context show
docker context inspect

Read the endpoint and any DOCKER_HOST/DOCKER_CONTEXT overrides you have set. Stop if it targets a remote, production or unfamiliar engine; a local CLI does not guarantee a local daemon. Only after confirming your authorized practice endpoint, inspect it:

docker info
docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}'

docker info may report missing controller support or rootless mode. A command permission error is a reason to stop, not to add sudo blindly or relax socket permissions.

For one existing practice container, replace container-name below with the exact name from your list:

1
2
3
4
docker inspect --format '{{json .HostConfig}}' container-name
docker inspect --format '{{json .Mounts}}' container-name
docker inspect --format '{{json .Config.User}}' container-name
docker inspect --format '{{json .NetworkSettings.Ports}}' container-name

Look for Privileged, NetworkMode, PidMode, ReadonlyRootfs, CapAdd, SecurityOpt, Devices, Memory, MemorySwap, NanoCpus/CpuQuota, and PidsLimit. Compare actual mounts and published addresses with the application's needs. Interpret defaults and zero/unset values using the container-run reference, not as a binary secure/insecure score. Inspecting a configured limit is not an enforcement test.

These outputs can include private paths, environment/security settings or endpoint details. Review them locally; do not paste them unredacted into a public issue. Do not dump .Config.Env looking for secrets as part of this exercise.

Optional Docker lab on Pi 4B

Confirm the local engine and image

For the verification run on 31 October 2026, Debian's docker.io and docker-cli packages had been installed on the Pi. Client/server version was 26.1.5+dfsg1, linux/arm64, cgroup v2 and the systemd cgroup driver. No reboot was performed during verification. This is a Debian-packaged engine, not Docker CE from Docker's own repository. Installation is an administrative change and starts host services; it is not part of the preceding unprivileged systemd labs.

The following commands run on the Pi, against its local rootful engine, with explicitly authorized sudo. Do not add yourself to the Docker group or change socket permissions to follow along. A rootless installation uses a different endpoint and should not blindly copy this sudo workflow.

1
2
3
sudo docker context inspect default --format '{{.Endpoints.docker.Host}}'
sudo docker version
sudo docker info --format '{{json .Warnings}}'

Confirm unix:///var/run/docker.sock and the intended local engine. Stop if DOCKER_HOST, DOCKER_CONTEXT or another configuration selects a different endpoint. This Pi reports No memory limit support and No swap limit support. The lab does not set --memory, allocate large buffers or claim memory protection. A Docker flag cannot supply a missing kernel controller; see the observed cgroup limitation.

The lab uses the Docker Official Python image. Downloading it consumes network/disk space and running it executes its code. Review that source before choosing to proceed. The tested python:3.13-slim image resolved to the digest below; the command pins that content rather than silently following future tag changes:

sudo docker pull docker.io/library/python@sha256:2d44738ebf4ce5f63b9d9ea871e9c7cfdc0426f7b0c97cac915fd88ded1d86bb

If the image cannot be downloaded or does not support your architecture, stop rather than substitute an unknown image. A pinned digest records reproducibility, not a promise of future security updates; re-review and retest an updated image when maintaining a real deployment.

Observe the selected boundaries

Copy this whole block. It creates only an expendable container and a 1 MiB writable tmpfs inside it, with no bind mounts, hardware devices, daemon socket or published ports. --pull=never requires the reviewed image already to be present. Python sets an eight-second alarm; waiting workers have timeouts and the attempt count is fixed. It is not a stress test.

sudo docker run --rm --pull=never -i \
    --network none --read-only --user 1000:1000 \
    --cap-drop ALL --security-opt no-new-privileges=true \
    --pids-limit 16 --cpus 0.25 \
    --tmpfs /work:rw,noexec,nosuid,nodev,size=1m,uid=1000,gid=1000,mode=0700 \
    docker.io/library/python@sha256:2d44738ebf4ce5f63b9d9ea871e9c7cfdc0426f7b0c97cac915fd88ded1d86bb \
    python3 -B -u - <<'PY'
import errno
import os
from pathlib import Path
import signal
import threading

def timed_out(signum, frame):
    raise SystemExit("Docker exercise timed out")

signal.signal(signal.SIGALRM, timed_out)
signal.alarm(8)
if os.getuid() != 1000:
    raise SystemExit("Unexpected container user")
print("Container UID: 1000; kernel: %s" % os.uname().release)

status = dict(line.split(":", 1) for line in
              Path("/proc/self/status").read_text().splitlines() if ":" in line)
if status["NoNewPrivs"].strip() != "1" or int(status["CapEff"].strip(), 16) != 0:
    raise SystemExit("Unexpected privilege configuration")
print("Confirmed: NoNewPrivs=1 and effective capabilities=0")

try:
    with open("/tmp/pi-security-write-probe", "x") as output:
        output.write("unexpected root-filesystem write\n")
except OSError as error:
    if error.errno != errno.EROFS:
        raise
    print("Blocked: root-filesystem write (EROFS)")
else:
    raise SystemExit("Read-only root was not demonstrated")

sample = Path("/work/result.txt")
sample.write_text("disposable result\n")
if sample.read_text() != "disposable result\n":
    raise SystemExit("Writable tmpfs readback failed")
print("Allowed: write and read the temporary result")

interfaces = sorted(path.name for path in Path("/sys/class/net").iterdir())
if interfaces != ["lo"]:
    raise SystemExit("Unexpected network interfaces: %s" % interfaces)
print("Network view: loopback only")

group = Path("/sys/fs/cgroup")
if (group / "pids.max").read_text().strip() != "16":
    raise SystemExit("Task limit was not configured as expected")
quota, period = (group / "cpu.max").read_text().split()
if quota == "max" or int(quota) * 4 != int(period):
    raise SystemExit("CPU quota readback failed")
print("Configured: pids.max=16 and CPU quota=0.25 CPU")
print("Memory controller visible: %s" % (group / "memory.max").is_file())

def denied_count():
    return int(next(line.split()[1] for line in
                    (group / "pids.events").read_text().splitlines()
                    if line.startswith("max ")))

before = denied_count()
release = threading.Event()
workers = []
blocked = False
try:
    for _ in range(20):
        worker = threading.Thread(target=release.wait, args=(3,))
        try:
            worker.start()
        except RuntimeError:
            if denied_count() <= before:
                raise
            blocked = True
            break
        workers.append(worker)
finally:
    release.set()
    for worker in workers:
        worker.join(timeout=1)
if not workers or not blocked:
    raise SystemExit("Task-limit denial was not demonstrated")
print("Allowed: %d waiting workers; blocked: extra thread" % len(workers))
print("PASS: bounded Docker isolation exercise")
PY

Expect a non-root UID, no effective capabilities, NoNewPrivs=1, an EROFS write denial, successful tmpfs output, loopback-only interfaces, matching cgroup controls and a recorded task denial, followed by PASS and exit status zero. The Pi allowed 15 waiting workers alongside Python's main thread. An unexpected error is not a passing restriction test.

The kernel release printed inside the container matched the Pi host's 6.18.50+rpt-rpi-v8: this is a shared-kernel Linux container, not an emulated guest OS. CPU quota was read back, not throughput-tested. The interface check is not an exhaustive network escape test, and the program deliberately does not contact an Internet host.

Check completion and retained data

--rm removes this container when it exits, including its temporary result. It does not remove the downloaded image, which stays cached for another run. No host files or named volumes were created by the exercise. Inspect rather than pruning other people's data:

sudo docker ps -a
sudo docker image ls

If a container remains after an interrupted run, identify only this exercise's exact container ID before stopping/removing it. Never use a blanket prune or stop every container. Installing Docker also leaves its daemon/containerd services in place; container cleanup does not uninstall them. See the resource constraints documentation before relying on limits in another environment.

Make a boundary decision

For a trusted sensor application, a reviewed systemd service may be simpler than adding a container engine. Docker can be useful for dependency packaging and repeatable deployment, but it introduces image provenance, updates, daemon access and persistent-volume responsibilities. A VM adds a guest kernel boundary, with its own configuration and device-sharing considerations. None is a universal winner.

Your review should end with four explicit statements: what is shared, what is restricted, what evidence you have, and how data is recovered. If code is hostile or highly sensitive, these beginner labs are not sufficient assurance; seek a stronger, separately reviewed environment.

Checkpoint: would --memory=64m stop a container reading an SSH key bind-mounted into it? Would --user=1000 make a rootful daemon rootless? What host data survives container removal?

Next: restore and verify a backup. Return to Service Isolation.

Verification scope: reviewed and container-tested on 31 October 2026, on the Pi 4B and exact environment in verification scope. The bounded exercise checks the listed file, privilege, interface and task-limit behaviors only. No memory-limit enforcement, hardware passthrough, rootless setup, Internet reachability or container escape test is claimed.

Donate