Containers & Docker Fundamentals
- 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
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 withdockertoo; 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?
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:latestis 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 imagesTrying 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 -2f3a9c1... 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?
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 rmwipes 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-15appdata 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?
- A named volume — storage Podman manages for you (
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 -ulogging, 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.servicegenerated
- [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?
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;"]
`FROMpicks the base (everything stacks on it),RUNexecutes a build step,COPYadds your files,USER 1001drops root before runtime (a non-rootUSERis the single highest-value line in image security), andCMDnames 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 (COPYof 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.0STEP 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?
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 --versionWhich 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 -uConfirm 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 lsThe 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.
- 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 |
|---|---|
| Find / pull / list | podman search X · pull X · images |
| Run named + published | podman run -d --name web -p 8080:80 IMG |
| Shell in / logs | podman exec -it web bash · podman logs -f web |
| Persist data | -v vol:/path · bind -v /host:/path:Z (SELinux!) |
| Survive reboot | podman generate systemd --new + systemctl --user enable --now + loginctl enable-linger |
| Build | podman build -t name:tag . (stable layers early) |
| Inventory | podman images · ps -a · volume ls |
