Chapter 26Lesson 02220–300 min

Containers, Reproducible Runtimes, and Distributed Test Environments: Guided Hands-On Workflow

Build and inspect a version-pinned Robot Framework image, compare host and container execution, preserve results through an explicit Docker volume, and make runtime ownership observable.

Python 3.12.14DockerfileNon-root UIDNamed volumeHost/container comparison

Learning objectives

  • Create a disposable host/container project with exact Python and Robot dependency assumptions.
  • Build a non-root Robot image without baking runtime results or secrets.
  • Run the same deterministic suite on the host and in a container and compare semantic results rather than timestamps/paths.
  • Persist container result files through an explicit Docker named volume and export them before cleanup.
  • Inspect image ID, configured user, mount destination, container exit code and Robot output as independent evidence.

Current compatibility baseline — verified 2026-08-31. Robot Framework 7.4.2 is the stable course baseline and requires Python 3.8+. The mandatory container lab uses the Docker Official Image python:3.12.14-slim-bookworm and installs the Robot Framework 7.4.2 universal wheel by exact version and SHA-256 hash. Docker image tags are mutable, so the lesson records the resolved image digest and explains full digest pinning for controlled pipelines. Pabot 5.2.2 is discussed as the current stable optional parallel executor; 5.3.0b1 is prerelease and is not required. No browser library, external API/database/SSH service, CI provider, Kubernetes cluster, paid platform, real credential, or production target is required.

1. Disposable scenario and ownership contract

Create rf26-container-lab in a scratch location. The suite is intentionally non-browser and non-networked: it proves arithmetic plus creation of a small evidence file beneath Robot's own output directory. That keeps the first container lesson focused on runtime boundaries rather than on Selenium, Browser, RequestsLibrary, databases or SSH.

rf26-container-lab/
├── Dockerfile
├── .dockerignore
├── requirements.txt
├── tests/
│   └── runtime_contract.robot
├── tools/
│   └── compare_results.py
├── results-host/
├── results-container/
└── evidence/

Source files are owned by the learner. The image owns its baked copy of tests/. The container owns only its transient process/writable layer. A named Docker volume owns the first container result set until it is exported. Host directories results-host and results-container own the retained comparison artifacts.

2. Preflight: prove tools and empty evidence locations before building

python --version
python -m robot --version
docker version
docker info

git status --short 2>/dev/null || true

# Bash/zsh
mkdir -p results-host results-container evidence
find results-host results-container -maxdepth 1 -type f -print

# PowerShell equivalents:
# New-Item -ItemType Directory -Force results-host,results-container,evidence | Out-Null
# Get-ChildItem results-host,results-container

If python -m robot --version does not report 7.4.2, use an isolated virtual environment and install the pinned version before the host comparison. Record Docker client/server versions because Docker Desktop and a remote Engine are not the same runtime.

3. Pin the Python package, including its wheel hash

robotframework==7.4.2 \
    --hash=sha256:6e80f84cdc997bdde2abb6b729ac3531457ecf6d2e41abfb87a541877ab367bf

The hash is the SHA-256 of the Robot Framework 7.4.2 universal wheel published on PyPI. --require-hashes makes pip reject a different distribution. This is stronger evidence than a version string alone.

4. Create a deterministic Robot suite whose mutable file lives under OUTPUT DIR

*** Settings ***
Library    OperatingSystem

*** Variables ***
${RUNTIME_LABEL}    unset

*** Test Cases ***
Arithmetic Contract
    ${answer}=    Evaluate    6 * 7
    Should Be Equal As Integers    ${answer}    42
    Log    Runtime label: ${RUNTIME_LABEL}

Result Directory Contract
    ${proof}=    Join Path    ${OUTPUT DIR}    proof.txt
    Create File    ${proof}    rf26-container-contract
    File Should Exist    ${proof}
    ${content}=    Get File    ${proof}
    Should Be Equal    ${content}    rf26-container-contract
    Log    Output directory: ${OUTPUT DIR}

The suite never writes beside its source file. ${OUTPUT DIR} is chosen by the runner, so the same test can run under a host result directory or a mounted container result directory. The runtime label is only observational metadata; the assertions remain identical.

5. Keep build context narrow

.git
.venv
__pycache__/
*.pyc
results-host/
results-container/
evidence/

This prevents old logs, virtual environments and evidence from becoming image layers. Never put credentials into the context merely because .dockerignore currently excludes them; build contexts and layer history should be treated as potentially inspectable.

6. Build a version-pinned, non-root Robot image

# syntax=docker/dockerfile:1
FROM python:3.12.14-slim-bookworm

ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1

COPY requirements.txt /tmp/requirements.txt
RUN python -m pip install --no-cache-dir --only-binary=:all: --require-hashes \
    -r /tmp/requirements.txt

WORKDIR /workspace
COPY --chown=10001:10001 tests/ /workspace/tests/
RUN mkdir -p /workspace/results && chown -R 10001:10001 /workspace

USER 10001:10001
ENTRYPOINT ["python", "-m", "robot"]
CMD ["--outputdir", "/workspace/results", "/workspace/tests"]

The numeric user keeps the image independent of a particular host username. There is no need for root during execution. The ENTRYPOINT preserves Robot's exit code as the container exit code. Tests are baked deliberately in this first run; a read-only bind-mount alternative is introduced later as a design choice.

The Python tag is exact at patch/distribution level, but the tag can still be republished. Record its resolved digest now; for controlled long-lived pipelines, update FROM to include the full manifest digest.

7. Record base provenance, then build and inspect

docker pull python:3.12.14-slim-bookworm
docker image inspect python:3.12.14-slim-bookworm --format '{{json .RepoDigests}}'   > evidence/base-image-digests.json

docker build --tag rf26-robot:7.4.2 .

docker image inspect rf26-robot:7.4.2 --format '{{.Id}}'   > evidence/test-image-id.txt
docker image inspect rf26-robot:7.4.2 --format '{{.Config.User}}'   > evidence/test-image-user.txt

docker run --rm --entrypoint sh rf26-robot:7.4.2   -c 'id; python --version; python -m robot --version'   > evidence/container-preflight.txt

Expected evidence: the configured user is 10001:10001; Python reports 3.12.14; Robot reports 7.4.2. The image ID identifies your built image, while the base-image digest records what the upstream tag resolved to at build time.

8. Run the host baseline first

python -m robot   --variable RUNTIME_LABEL:host   --outputdir results-host   tests/runtime_contract.robot

echo "host_exit=$?" > evidence/host-exit.txt

PowerShell: run the same python -m robot command on one line (or use backticks), then record $LASTEXITCODE. Expected artifacts are results-host/output.xml, log.html, report.html and proof.txt.

9. Run the image with an explicit result volume and preserve the stopped container until evidence is copied

VOL=rf26-results
NAME=rf26-run

docker volume create "$VOL"
docker create --name "$NAME"   --mount type=volume,src="$VOL",dst=/workspace/results   rf26-robot:7.4.2   --variable RUNTIME_LABEL:container   --outputdir /workspace/results   /workspace/tests/runtime_contract.robot

docker start --attach "$NAME"
docker inspect "$NAME" --format '{{.State.ExitCode}}' > evidence/container-exit.txt
docker inspect "$NAME" --format '{{json .Mounts}}' > evidence/container-mounts.json
docker cp "$NAME":/workspace/results/. results-container/

The stopped container is intentionally retained until the copy succeeds. That is evidence-preserving behavior: do not use --rm until you already know the output path is durable. The named volume is the runtime result store; docker cp creates the host copy used by the comparison and later archival.

10. Compare semantic results, not byte-for-byte XML

from pathlib import Path
from xml.etree import ElementTree as ET


def status_map(path: str) -> dict[str, str]:
    root = ET.parse(path).getroot()
    result = {}
    for test in root.iter("test"):
        status = test.find("status")
        result[test.attrib["name"]] = status.attrib["status"]
    return result


host = status_map("results-host/output.xml")
container = status_map("results-container/output.xml")
print("host:", host)
print("container:", container)
if host != container:
    raise SystemExit("Host/container semantic result mismatch")

for path in [
    Path("results-host/proof.txt"),
    Path("results-container/proof.txt"),
]:
    assert path.read_text(encoding="utf-8") == "rf26-container-contract"

print("semantic outcomes and proof artifacts match")
python tools/compare_results.py

Robot output contains timestamps, absolute paths and runtime metadata, so byte identity is not a useful equivalence test. The comparison deliberately checks stable semantics: test names, PASS/FAIL states and domain proof content.

11. Optional comparison: mount tests read-only instead of baking them

# POSIX example; source is read-only. Result ownership may require matching host UID/GID.
docker run --rm   --user "$(id -u):$(id -g)"   --mount type=bind,src="$PWD/tests",dst=/workspace/tests,readonly   --mount type=bind,src="$PWD/results-container",dst=/workspace/results   rf26-robot:7.4.2   --variable RUNTIME_LABEL:mounted-source   --outputdir /workspace/results   /workspace/tests/runtime_contract.robot

This mode gives rapid source iteration, but the image no longer contains the exact test source by itself. On native Linux the explicit host UID/GID prevents root-owned or inaccessible result files. Docker Desktop has different host-filesystem mediation, so inspect ownership rather than copying a Linux fix blindly. Avoid chmod 777.

12. Challenge: choose the correct layer

Your container run passes but the CI job cannot find output.xml. Which layer should you inspect first?

Start with the runtime mount and Robot --outputdir contract, not the test assertions. Prove the container destination, mount destination and stopped-container filesystem before rerunning. If results exist only inside the container, copy them out before cleanup and fix the mount path for the next run.

Knowledge check

Why does the lab retain a named container instead of using --rm immediately?

Why is the tests directory baked into the first image run?

What does USER 10001:10001 change?

Why compare test statuses instead of comparing output.xml bytes?

13. Cleanup/rollback

docker rm rf26-run
docker volume rm rf26-results
# Keep results-host/, results-container/, and evidence/ if they are your evidence packet.
# Remove the disposable image only after you no longer need provenance inspection:
docker image rm rf26-robot:7.4.2

Do not delete the host results/evidence until the comparison and any review are complete. Cleanup removes only resources created by this lab; it never prunes unrelated images, volumes or containers.

14. Summary and bridge

You now have a reproducible execution chain with a pinned Python/Robot dependency set, explicit non-root user, image provenance, a durable result volume, host/container semantic comparison and evidence-first cleanup. Lesson 3 turns these mechanics into architectural choices for real teams.

Next lesson

Containers, Reproducible Runtimes, and Distributed Test Environments: Configuration, Design Patterns, and Trade-Offs

Continue with Containers, Reproducible Runtimes, and Distributed Test Environments: Configuration, Design Patterns, and Trade-Offs. It builds directly on the state, evidence, and operating assumptions established here, so carry those constraints forward rather than treating the next page as an isolated topic.

References and version anchors

Keep the academy open

Support free, practical DevOps education.

Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0 Send only Ethereum/ERC-20 compatible assets to this address.