Skip to content

Docker

This activity puts into practice the concepts from the Containerization with Docker lecture. You will investigate how containers actually behave: namespace isolation, ephemeral storage, volumes, image layer caching, port mapping, and multi-service Compose stacks. By the end, you will have run, built, and inspected containers and seen for yourself exactly what isolation they provide and where it ends.


You need a container runtime installed on your laptop before starting.

On macOS, Docker Engine runs inside a Linux VM managed transparently by the tool you choose. All of the following expose the same docker CLI and are compatible with this activity.

Docker Desktop: Download from docker.com/products/docker-desktop. After installation, start Docker Desktop from your Applications folder and wait for the whale icon in the menu bar to show “Docker Desktop is running.”

OrbStack (lightweight alternative): Download from orbstack.dev. OrbStack runs a lightweight Linux VM with lower memory overhead and faster startup than Docker Desktop. All docker commands work without modification.

Podman Desktop: Download from podman-desktop.io. Provides a Podman-based environment; most docker commands work with podman substituted.

Rancher Desktop: Download from rancherdesktop.io. Free and open-source; bundles either containerd or dockerd.

Verify your installation:

Terminal window
docker version
docker run --rm hello-world

Before running anything, let us look at what an image actually is.

  1. Check which cgroup version your container runtime is using:

    Terminal window
    docker info | grep -Ei 'Cgroup'

    Most modern Linux distributions and recent Docker Desktop setups report cgroup v2. If you are using Podman, podman info also reports the cgroup version in its host information.

  2. Pull two variants of the nginx image:

    Terminal window
    docker pull nginx:alpine
    docker pull nginx:latest
  3. Compare their sizes:

    Terminal window
    docker images nginx

    Record the size of each. The alpine variant should be considerably smaller.

  4. Inspect the layers of the alpine image:

    Terminal window
    docker history nginx:alpine

    Each row is one layer. The columns show the instruction that created it, when it was built, and how much disk space it added.

    Record:

    • How many layers does nginx:alpine have?
    • Which layer is the largest, and what instruction created it?
    • Why are some layers listed with size 0B?
  5. Examine the image’s metadata:

    Terminal window
    docker inspect nginx:alpine

    In the JSON output, find:

    • ExposedPorts: what port does the image declare as its service port?
    • Entrypoint and Cmd: what command runs when a container starts from this image?
    • Env: what environment variables are set by default?

A running container is an isolated process with its own view of the system. This section makes that isolation visible.

  1. Start an nginx container in the background and then open an interactive shell inside it:

    Terminal window
    docker run -d --name sandbox nginx:alpine
    docker exec -it sandbox sh

    You are now inside the container’s namespace.

  2. Explore what the container can see:

    Terminal window
    # What is this machine's hostname?
    hostname
    # Who is the current user?
    whoami
    # What processes are running?
    ps aux
    # What does the filesystem look like?
    ls /
    # What OS is this?
    cat /etc/os-release

    ps aux lists running processes: a includes processes from all users, u shows the user-oriented format with CPU and memory columns, and x includes processes not attached to a terminal. Inside the container you should see only a handful of nginx processes. Record each result.

  3. Exit the container and run the same commands on your host machine:

    Terminal window
    exit
    hostname
    whoami
    ps aux
    cat /etc/os-release

    The host ps aux output will be considerably longer than what you saw inside the container. Every daemon, shell session, and background service on your machine is visible here; inside the container you saw only the processes that belong to it.


Now that you have seen what isolation looks like from inside, look at container management from the outside: checking logs, monitoring resource usage, stopping and restarting without discarding, and passing configuration in at runtime.

  1. The sandbox container from the previous section is still running. Generate an HTTP request inside it to produce a log entry, then view the logs from the host:

    Terminal window
    docker exec sandbox wget -qO- http://localhost > /dev/null
    docker logs sandbox

    wget downloads a URL from the command line. -q suppresses progress output; -O- writes the response to standard output instead of saving it to a file.

    docker logs shows everything the container has written to stdout and stderr since it started. For nginx, that is the access log. Follow logs in real time with -f; press Ctrl+C to stop:

    Terminal window
    docker logs -f sandbox
  2. Check resource usage across all running containers:

    Terminal window
    docker stats --no-stream

    --no-stream prints one snapshot and exits instead of staying in interactive mode. You will see CPU percentage, memory usage, and network I/O for each container.

  3. Stop the container and observe its status:

    Terminal window
    docker stop sandbox
    docker ps
    docker ps -a

    docker ps shows only running containers; sandbox is gone from that list. docker ps -a shows all containers including stopped ones; sandbox appears with status Exited. The container is paused, not erased.

  4. Restart it:

    Terminal window
    docker start sandbox
    docker ps

    The same container is running again. Its writable layer was preserved through the stop and start.

  5. Pass configuration into a one-off container at runtime. -e sets an environment variable inside the container; --rm removes the container automatically when it exits:

    Terminal window
    docker run --rm -e GREETING="hello from the host" alpine sh -c 'echo $GREETING'

    You should see hello from the host printed. --rm is useful for short-lived commands that produce output and do not need to persist.

  6. Clean up:

    Terminal window
    docker stop sandbox
    docker rm sandbox

Ephemeral Storage: What Containers Don’t Keep

Section titled “Ephemeral Storage: What Containers Don’t Keep”

A container’s writable layer disappears when the container is removed. This section makes that concrete.

  1. Run a container and write something to its filesystem:

    Terminal window
    docker run -d --name ephemeral nginx:alpine
    docker exec ephemeral sh -c 'echo "this will not survive" > /tmp/testfile.txt'
    docker exec ephemeral cat /tmp/testfile.txt

    Confirm the file is there.

  2. Inspect what changed in the container’s writable layer:

    Terminal window
    docker diff ephemeral

    docker diff prefixes each path with a status: A means added, C means changed, and D means deleted. In this example you should see A /tmp/testfile.txt; you may also see C entries for files nginx touched while running. D is not used in this step, but you would see it if a file or directory from the container filesystem had been removed.

  3. Stop and remove the container completely:

    Terminal window
    docker stop ephemeral
    docker rm ephemeral
  4. Start a fresh container from the same image:

    Terminal window
    docker run -d --name ephemeral2 nginx:alpine
    docker exec ephemeral2 cat /tmp/testfile.txt

    The file is gone.

  5. Clean up the second container:

    Terminal window
    docker stop ephemeral2
    docker rm ephemeral2

Volumes live outside the container’s writable layer and survive container replacement.

  1. Create a named volume:

    Terminal window
    docker volume create mydata
    docker volume ls
  2. Run a container with the volume attached and write to it:

    Terminal window
    docker run -d --name writer -v mydata:/data nginx:alpine
    docker exec writer sh -c 'echo "persisted across containers" > /data/record.txt'
    docker exec writer cat /data/record.txt
  3. Remove the container and start a completely new one with the same volume:

    Terminal window
    docker stop writer
    docker rm writer
    docker run -d --name reader -v mydata:/data nginx:alpine
    docker exec reader cat /data/record.txt

    The data is still there, even though the original container no longer exists.

  4. Find where Docker stores the volume on your host:

    Terminal window
    docker volume inspect mydata

    Look for the Mountpoint field. On Linux, you can read the files directly at that path (as root). On macOS and Windows, this path is inside the Linux VM that Docker Desktop manages.

  5. Clean up:

    Terminal window
    docker stop reader
    docker rm reader
    docker volume rm mydata

Building an Image and Observing Layer Caching

Section titled “Building an Image and Observing Layer Caching”

This section shows how Docker’s layer cache behaves in practice: both when it helps and when it gets invalidated.

  1. Create a working directory and add two files:

    Terminal window
    mkdir mysite && cd mysite

    Create index.html:

    <h1>Hello from my container</h1>

    Create Dockerfile:

    FROM nginx:alpine
    RUN echo "server_tokens off;" > /etc/nginx/conf.d/security.conf
    COPY index.html /usr/share/nginx/html/index.html

    server_tokens off is a real nginx security setting: it removes the server version number from response headers.

  2. Build the image:

    Terminal window
    docker build -t mysite:v1 .

    This is the first build, so nothing is cached. You will see each step execute from scratch. Note the layer IDs in the output.

  3. Build again without changing anything:

    Terminal window
    docker build -t mysite:v1 .

    Every step should now say CACHED. Docker reused all layers from the first build.

  4. Change only index.html (edit the file to say something different), then rebuild:

    Terminal window
    docker build -t mysite:v2 .

    The RUN step says CACHED. Only the COPY step re-runs, because that is the first step whose inputs changed.

  5. Now change the FROM line in the Dockerfile to FROM nginx:latest and rebuild:

    Terminal window
    docker build -t mysite:v3 .

    FROM now resolves to a different base image. Both the RUN step and the COPY step must re-run: every cached layer was built on top of the previous base image, so none of them can be reused.

  6. Run the resulting image and verify it serves your content:

    Terminal window
    docker run -d -p 8080:80 --name mysite mysite:v3
    curl http://localhost:8080
    docker stop mysite && docker rm mysite

    Clean up your working directory when done: cd .. && rm -rf mysite


Containers have their own network namespace. Port mapping is how services inside that namespace become reachable from outside.

  1. Start a container with port mapping and verify you can reach it from your host:

    Terminal window
    docker run -d -p 8080:80 --name webserver nginx:alpine
    curl http://localhost:8080

    curl makes an HTTP request from the command line and prints the response body. You made a request to your host on port 8080; Docker forwarded it to the container on port 80.

  2. Inspect the container’s network configuration:

    Terminal window
    docker inspect webserver

    In the NetworkSettings section, find:

    • IPAddress: the container’s private IP on the Docker bridge network
    • Ports: the port mapping Docker configured
  3. Try to reach the container’s private IP directly from your host:

    Terminal window
    # Substitute the IP address you found in step 2
    curl http://<container-ip>:80

    On Linux with Docker Engine this will work. On macOS and Windows with Docker Desktop it likely will not, because the bridge network is inside a Linux VM.

  4. List the Docker networks on your system and inspect the default bridge:

    Terminal window
    docker network ls
    docker network inspect bridge

    Find the webserver container listed under Containers. Note the bridge network’s subnet.

  5. Create a user-defined network and run two containers on it:

    Terminal window
    docker network create mynet
    docker run -d --network mynet --name server1 nginx:alpine
    docker run -d --network mynet --name server2 nginx:alpine
    # From server2, reach server1 by container name
    docker exec server2 wget -qO- http://server1

    Container DNS resolves server1 to its IP within mynet.

  6. Confirm that containers on different networks cannot reach each other:

    Terminal window
    # webserver is on the default bridge, server1 is on mynet
    docker exec server2 wget -qO- http://webserver 2>&1

    This should fail or time out: webserver is not on mynet.

  7. Clean up:

    Terminal window
    docker stop webserver server1 server2
    docker rm webserver server1 server2
    docker network rm mynet

Compose wires multiple containers together with automatic networking and a single declarative file. This section uses nginx as a web frontend and Redis as an internal cache, a common real-world pairing. Redis is a fast in-memory data store that should never be exposed directly to the network; only the application that uses it should be able to reach it.

  1. Create a working directory and write a compose.yml:

    Terminal window
    mkdir mystack && cd mystack
    mkdir html
    echo "<h1>Compose stack</h1>" > html/index.html

    compose.yml:

    services:
    web:
    image: nginx:alpine
    ports:
    - "8080:80"
    volumes:
    - ./html:/usr/share/nginx/html:ro
    depends_on:
    - cache
    cache:
    image: redis:alpine

    Two things to notice: the :ro suffix on the bind mount makes it read-only inside the container, so nginx can read your files but cannot modify them. The depends_on entry tells Compose to start cache before web.

  2. Start the stack:

    Terminal window
    docker compose up -d

    Watch the output: Compose starts cache first, then web, respecting the depends_on order. Confirm this in the startup logs:

    Terminal window
    docker compose logs
  3. Inspect what is running:

    Terminal window
    docker compose ps

    Look at the PORTS column. web shows 0.0.0.0:8080->80/tcp; cache shows nothing. That absence tells you cache is not reachable from outside the Compose network.

  4. Reach the web service from your host:

    Terminal window
    curl http://localhost:8080
  5. Verify the cache service is running. redis-cli is the Redis command-line client, included in the redis:alpine image:

    Terminal window
    docker compose exec cache redis-cli ping

    Redis responds with PONG. Now store and retrieve a value to confirm it is working:

    Terminal window
    docker compose exec cache redis-cli set greeting "hello from cache"
    docker compose exec cache redis-cli get greeting
  6. Confirm that the web container can resolve the cache hostname via Compose’s embedded DNS:

    Terminal window
    docker compose exec web nslookup cache

    You should see the IP address assigned to the cache container within the Compose network. Containers on the same Compose network reach each other by service name; the host cannot.

  7. Upgrade the web service. Open compose.yml and change the web image from nginx:alpine to nginx:latest, then pull and recreate:

    Terminal window
    docker compose pull
    docker compose up -d

    Read the output carefully. You will see something like:

    ✔ Container mystack-cache-1 Running
    ✔ Container mystack-web-1 Started

    Running means the container was already up and was left alone. Started means it was stopped, replaced with a new container from the updated image, and started again. Compose determines which containers to recreate by comparing the running container’s image digest against what was just pulled.

  8. Verify the web content is still served:

    Terminal window
    curl http://localhost:8080

    The HTML is unchanged. It lives in html/ on your host; replacing the web container had no effect on it.

  9. Stop the stack and confirm what persists:

    Terminal window
    docker compose down
    docker compose ps # containers are gone
    ls html/ # bind-mounted host directory is unchanged
  10. Clean up:

    Terminal window
    cd .. && rm -rf mystack

You have explored existing images, observed isolation, built a layered image, and run a Compose stack. Now build an image that carries your identity and applies the best practices from the lecture: a non-root user and a realistic server process.

  1. Create a working directory and an HTML file with your ONID:

    Terminal window
    mkdir myapp && cd myapp
    echo "<h1>Built by: ONID@oregonstate.edu</h1>" > index.html
  2. Create a Dockerfile:

    FROM python:3.12-slim
    RUN useradd --create-home appuser
    WORKDIR /home/appuser/app
    COPY index.html .
    USER appuser
    EXPOSE 8080
    CMD ["python3", "-m", "http.server", "8080"]

    python3 -m http.server 8080 is Python’s built-in static file server. It serves any files in the working directory over HTTP on port 8080. Because it does not need to bind a privileged port, it can run as a non-root user.

  3. Build and run the image:

    Terminal window
    docker build -t myapp:v1 .
    docker run -d -p 9090:8080 --name myapp myapp:v1
  4. Confirm your content is served:

    Terminal window
    curl http://localhost:9090

    You should see your ONID in the response.

  5. Verify the container process is running as a non-root user:

    Terminal window
    docker exec myapp whoami

    You should see appuser, not root.

  6. Tag the image in the format a registry expects. Replace YOURONID with your actual ONID:

    Terminal window
    docker tag myapp:v1 YOURONID/myapp:v1
    docker images myapp

    Both tags point to the same image ID. The username/repository:tag format is what Docker Hub expects; a private registry would use registry.example.com/repository:tag. No account or login is needed to tag locally.

  7. Inspect the image layers:

    Terminal window
    docker history myapp:v1

    You will see several base layers from python:3.12-slim, then your own layers on top: the RUN useradd, WORKDIR, COPY, USER, and CMD entries.

  8. Clean up:

    Terminal window
    docker stop myapp
    docker rm myapp
    cd .. && rm -rf myapp

You have worked through the core mechanics of containers from scratch. The natural next step is to build something real.

The Docker documentation’s Workshop guide is the most direct continuation of this activity. It walks you through containerizing a complete application, adding a database with Compose, and pushing the finished image to Docker Hub. Plan about an hour for it.

If you would rather start from your own code, pick a project in a language you already know and write a Dockerfile for it from scratch. Start with a single-stage build that runs correctly, then improve it step by step: add a multi-stage build to strip the build toolchain from the final image, shrink the base image, add a non-root USER, and add a HEALTHCHECK. Run docker images after each change and watch what happens to the size. The gap between a first-draft image and an optimized one is often an order of magnitude.

Two tools worth knowing once you are writing real Dockerfiles:

  • hadolint is a Dockerfile linter that catches common mistakes and anti-patterns, from wrong COPY ordering to unintentional package cache bloat. Run it against any Dockerfile you write.
  • dive lets you explore an image layer by layer in the terminal and shows exactly which files each instruction added, removed, or changed. It is the fastest way to understand why an image is larger than you expect.