Signal9Signal9 — home
online
all lessons

Containers & Docker Fundamentals

Intermediate45 min6 steps
What you'll learn
  • Explain what problem containers solve, images vs containers, and how containers differ from VMs
  • Find, pull, run, and inspect containers (Podman on RHEL, Docker elsewhere — same verbs)
  • Persist data with volumes and bind mounts — including the SELinux :Z gotcha
  • Run a container as a systemd service so it survives a reboot (including the rootless linger trap)
  • Build your own image from a Containerfile with cache-friendly layer ordering
  1. The problem containers solve — and why they're not VMs

    Get the mental model and the core vocabulary straight

    Start with the ancient pain: "it works on my machine." An app doesn't run alone — it needs a specific Python, particular libraries, config files in the right places. Ship the app without its world and it breaks on any machine whose world differs. For decades the fixes were documentation ("install these 14 things first") or virtual machines (ship the whole world, OS included — gigabytes, minutes to boot).

    A container is the middle path: package the app with its entire userland world — libraries, runtime, config — but share the host's kernel instead of shipping an OS. The result runs as an ordinary (but isolated) process: starts in milliseconds, weighs megabytes, and behaves identically on every host because it brought its world along.

    Picture shipping containers on one ship: each sealed box carries completely different cargo, none can spill into another, but they all share one hull. A VM is a separate boat per cargo — its own hull, engine, and crew. Sharing the hull is why you can run fifty containers where five VMs would strain; it's also the security caveat: one kernel means kernel-level isolation, not hardware-level.

    Two words get mixed up constantly, and keeping them straight makes every command make sense:

    • Image — the read-only template: stacked filesystem layers plus metadata. The recipe, frozen.
    • Container — a running instance of an image, with a thin writable layer on top. The cooked dish — and you can cook many dishes from one recipe.

    And the tooling note that matters on RHEL: the tool is Podman, not Docker. Same verbs almost everywhere (podman run, podman ps, podman build), but Podman is daemonless (no root-owned background service; containers are just your child processes) and rootless by default (your user, no sudo — a compromised container doesn't hand out root). Everything below works with docker too; swap the name.

    Check yourself

    Fifty microservices need to run on one host. Why do containers make this feasible where VMs wouldn't — and what's the security trade-off?

  2. Find, run, and inspect

    Pull an image and drive a container's lifecycle

    The workflow is find → pull → run → inspect, and the naming convention unlocks the first half. Images live in registries (image warehouses) and carry structured names: registry.access.redhat.com/ubi9/ubi:latest is registry / namespace / name : tag. Omit the tag and you get :latest — convenient interactively, a reproducibility bug in anything scripted (pin real versions in production).

    Example — not runnable on your lab box

    podman pull registry.access.redhat.com/ubi9/ubi
    podman images
    Trying to pull registry.access.redhat.com/ubi9/ubi:latest...
    Copying blob 3d2cf3fps done
    REPOSITORY                            TAG     IMAGE ID      CREATED     SIZE
    registry.access.redhat.com/ubi9/ubi   latest  a1b2c3d4e5f6  2 days ago  215 MB
    search / pull / images
    find in registries / download locally / list local
    pull downloads the image's layers (that 'Copying blob' line is one layer arriving — images are stacks of them, and layers already present are skipped, which is why related images download fast). images lists your local collection. podman search TERM queries registries when you don't know the exact name; RHEL's registries serve the UBI base images the exam builds on.

    Example — not runnable on your lab box

    podman run -d --name web -p 8080:80 docker.io/library/nginx
    podman ps
    podman logs web | tail -2
    f3a9c1...
    CONTAINER ID  IMAGE                           COMMAND               STATUS         PORTS                 NAMES
    f3a9c1d2e3f4  docker.io/library/nginx:latest  nginx -g daemon o...  Up 5 seconds   0.0.0.0:8080->80/tcp  web
    2026/07/11 14:03:22 [notice] 1#1: start worker processes
    -d
    detached — run in the background
    --name web
    stable name; otherwise a random one is assigned
    -p 8080:80
    publish host:container — host port first, the order people flip
    exec -it NAME bash
    interactive shell inside a running container
    The run line deserves a slow read: -d detaches (background), --name web gives it a stable handle (otherwise you get a random name and every later command needs the ID), and -p 8080:80 publishes HOST port 8080 to CONTAINER port 80 — host:container, in that order, the mapping people flip most. Then ps confirms it's up (note the PORTS column restating the mapping) and logs shows its output. Two more daily drivers: podman exec -it web bash opens a shell INSIDE the running container, and the lifecycle verbs mirror systemd — start/stop/restart/rm (ps -a shows stopped ones). All rootless: no sudo anywhere.

    Check yourself

    You ran podman run -d -p 80:8080 myapp (app listens on 8080 inside). curl localhost:8080 fails. What's wrong?

  3. Persistent storage — the whiteboard problem

    Keep data alive beyond a container, and satisfy SELinux on RHEL

    A container's writable layer is a whiteboard: scribble all you like, but podman rm wipes it — completely, unrecoverably. Run a database in a container, remove the container, and the database is gone. This isn't a flaw; it's the design: containers are meant to be disposable, replaceable, upgradeable by deletion. The discipline it demands: anything worth keeping lives outside the container, and two mechanisms put it there:

    • A named volume — storage Podman manages for you (appdata:/path). Portable, no host paths to invent; the default choice.
    • A bind mount — a host directory you pick, mapped in (/srv/conf:/path). For when the host location matters: configs you edit with vim, data other host tools read.

    Config, meanwhile, enters as environment variables (-e KEY=val) — the container-native alternative to editing files inside the box.

    Example — not runnable on your lab box

    podman volume create appdata
    podman run -d --name db \
      -v appdata:/var/lib/pgsql/data \
      -v /srv/conf:/etc/app:Z \
      -e POSTGRES_PASSWORD=secret \
      registry.redhat.io/rhel9/postgresql-15
    appdata
    8c4d2e...
    -v appdata:/path
    named volume — Podman-managed, survives container removal
    -v /host:/path:Z
    bind mount with SELinux relabel — private to this container
    -e KEY=val
    environment variable — container-native configuration
    Both mechanisms in one run: the named volume holds the database files (survives podman rm db — recreate the container tomorrow with the same -v and the data is exactly where it was), the bind mount injects host-managed config, and -e configures the app. The :Z on the bind mount is doing invisible, load-bearing work — it's the subject of the warning below, and the flag whose absence generates the most confusing error in RHEL containers.

    Check yourself

    podman rm -f db just deleted your database container. Under what condition is the data safe, and where is it?

  4. Autostart with systemd — containers as services

    Make a container survive a reboot, including the rootless linger trap

    Reboot the host and every container is simply off — they're processes, not services, and nothing brings them back. You already own the machinery that fixes this: systemd. Wrap the container in a unit file and it gains the whole service contract — starts at boot, restarts on failure, journalctl -u logging, the works. Podman will even write the unit for you:

    Example — not runnable on your lab box

    podman generate systemd --new --name web --files
    mkdir -p ~/.config/systemd/user && mv container-web.service ~/.config/systemd/user/
    systemctl --user daemon-reload
    systemctl --user enable --now container-web.service
    /home/user/container-web.service
    Created symlink /home/user/.config/systemd/user/default.target.wants/container-web.service → …
    generate systemd --new --files
    write a unit that recreates the container on each start
    systemctl --user
    manage per-user units — the rootless counterpart of system services
    Four beats, three of them pure systemd knowledge: generate the unit from the running container (--new makes the unit CREATE a fresh container each start — disposable, the container way — rather than restart one aging instance), install it in the user unit directory (rootless services are per-user, hence ~/.config and --user), daemon-reload so systemd reads it, enable --now. That symlink in the output is your old friend: boot persistence you can ls. On newer RHEL, Quadlet is the cleaner successor — drop a declarative web.container file into ~/.config/containers/systemd/ and systemd synthesizes the unit itself.

    Example — not runnable on your lab box

    cat > /etc/containers/systemd/web.container <<'EOF'
    [Container]
    ContainerName=web
    Image=localhost/busybox:1.36
    PublishPort=8080:8080
    Volume=/srv/web:/www:ro
    Exec=httpd -f -p 8080 -h /www
    
    [Install]
    WantedBy=multi-user.target
    EOF
    systemctl daemon-reload
    systemctl is-enabled web.service
    systemctl start web.service
    generated
    [Container]
    the podman run settings, one key per flag
    [Install] WantedBy=
    what makes a Quadlet unit start at boot (there is no systemctl enable step)
    The same job as a Quadlet file, rootful this time: the file lives in /etc/containers/systemd/ (rootless: ~/.config/containers/systemd/) and is named after the unit it becomes, so web.container turns into web.service. daemon-reload runs the Quadlet generator, which writes the unit under /run/systemd/generator. Boot persistence comes from the file's [Install] section, not from systemctl enable: is-enabled reports generated, and running systemctl enable on a generated unit fails. Keys map onto podman run flags: ContainerName= is --name, PublishPort= is -p, Volume= is -v, Exec= is the command. Leave out [Install] and the service exists but never starts at boot.

    Check yourself

    A rootless container service is enabled, worked when you tested it, but after a reboot (nobody has SSH'd in yet) it isn't running. What's missing?

  5. Build your own image

    Turn a Containerfile into a runnable image with cache-friendly layers

    Running other people's images gets you far; packaging your app closes the loop. A Containerfile (Docker calls it Dockerfile — same format) is an ordered recipe where each instruction bakes one layer:

    ```dockerfile

    FROM registry.access.redhat.com/ubi9/ubi

    RUN dnf -y install nginx && dnf clean all

    COPY ./site /usr/share/nginx/html

    EXPOSE 80

    USER 1001

    CMD ["nginx", "-g", "daemon off;"]

    `

    FROM picks the base (everything stacks on it), RUN executes a build step, COPY adds your files, USER 1001 drops root before runtime (a non-root USER is the single highest-value line in image security), and CMD names the default process. The ordering is strategy, not style: layers cache, and a change to any layer invalidates everything after it — so the expensive-and-stable (RUN dnf install) goes early, the cheap-and-volatile (COPY of your app) goes late. Get it backwards and every code edit re-runs the package install.

    Example — not runnable on your lab box

    podman build -t mysite:1.0 .
    podman run -d --name site -p 8080:80 mysite:1.0
    STEP 1/6: FROM registry.access.redhat.com/ubi9/ubi
    STEP 2/6: RUN dnf -y install nginx && dnf clean all
    --> Using cache 9e8d7c...
    STEP 3/6: COPY ./site /usr/share/nginx/html
    COMPLETED in 4s
    Successfully tagged localhost/mysite:1.0
    -t name:tag
    tag the built image
    .
    build context — the directory holding the Containerfile and files COPY reads
    --no-cache
    ignore the layer cache — a guaranteed-clean rebuild
    build reads the Containerfile in the context dir (that trailing .) and bakes the image; note 'Using cache' on step 2 — the dnf layer was reused from a previous build because nothing before it changed, and only the COPY re-ran. That's layer ordering paying rent: a 4-second rebuild instead of a 90-second one. Then run it exactly like any pulled image. When images must be lean, multi-stage builds (FROM ... AS builder, then a slim final FROM that copies only the built artifact) ship the binary without the build toolchain.

    Check yourself

    Your Containerfile COPYs the app code at step 2 and RUNs dnf install at step 3. Builds take 90 seconds after every one-line code change. Why, and what's the fix?

  6. Now do it — orient on the container runtime

    Run the reads that begin any container task, and stage the full lifecycle

    Orient first, exactly as you would landing on an unfamiliar box: which runtime, what mode, what's already here? (Running nested containers needs a container-enabled sandbox; the reads below always work, and they're genuinely what you run first on any container task.)

    command -v podman docker; podman --version 2>/dev/null || docker --version
    Which runtime lives here — Podman on RHEL-family, Docker most other places. Every command that follows works with either name; knowing which you have is step zero.
    podman info 2>/dev/null | grep -iE 'rootless|graphDriver'; id -u
    Confirm the mode: rootless: true plus your non-zero uid means containers here run under YOUR user namespace — no sudo, no root daemon, and a container escape lands as you, not root. This one read tells you the security posture of everything you'd run.

    Example — not runnable on your lab box

    podman images; podman ps -a; podman volume ls
    The three inventories: local images (the recipes), all containers running or stopped (the dishes, live and abandoned), and volumes (the data that outlives them). On a box you didn't build, these three answers ARE the container story — and stopped containers plus orphaned volumes are where disk space quietly disappears.
    echo 'the loop: podman run -d --name t -p 8080:80 nginx && podman ps && podman logs t && podman rm -f t'
    Type the full lifecycle out (and run it if this sandbox has a runtime): create with a name and port mapping, verify with ps, read the logs, remove. Narrate the whiteboard rule as the rm executes — 'writable layer gone, volumes survive' — until it's reflex.
Recap
  • Containers solve 'works on my machine' by shipping the app WITH its userland world while sharing the host kernel — milliseconds and megabytes vs a VM's minutes and gigabytes; the trade is kernel-level (not hardware-level) isolation.
  • Image = read-only template of stacked layers; container = running instance with an ephemeral writable layer. RHEL uses Podman: daemonless, rootless (your user, no sudo), same verbs as Docker.
  • Lifecycle: pull → run -d --name -p host:container -e → ps / exec -it / logs; stop/start/rm like systemd verbs. -p order is host:container — the most-flipped detail.
  • The writable layer is a whiteboard wiped by rm: persist with named volumes (default) or bind mounts (host path matters) — and on RHEL, bind mounts need :Z or SELinux denies the container despite perfect-looking permissions.
  • Boot persistence = systemd: podman generate systemd --new (or Quadlet) + systemctl --user enable --now, PLUS loginctl enable-linger — without linger, rootless services die with the session and don't start at boot.
  • Build with a Containerfile: FROM → RUN → COPY → USER (non-root!) → CMD, ordered stable-early / volatile-late so the layer cache turns rebuilds from minutes into seconds.
Task → command
TaskCommand
Find / pull / listpodman search X · pull X · images
Run named + publishedpodman run -d --name web -p 8080:80 IMG
Shell in / logspodman exec -it web bash · podman logs -f web
Persist data-v vol:/path · bind -v /host:/path:Z (SELinux!)
Survive rebootpodman generate systemd --new + systemctl --user enable --now + loginctl enable-linger
Buildpodman build -t name:tag . (stable layers early)
Inventorypodman images · ps -a · volume ls