Chapter 15Lesson 02~220 minutes

Service Containers, Job Containers, Docker Builds, and Integration-Test Environments: Guided Hands-On Workflow

This guided lab runs the same tiny Redis interaction in two topologies: directly on an Ubuntu runner with a mapped service port, then inside a Python job container that reaches Redis by service hostname. A third host job builds a tiny Docker image locally, records image identity and performs bounded cleanup without authenticating to or pushing into a registry.

Redis labHost topologyJob-container topologyDocker buildNo registry push

Learning objectives

  • Run a healthy Redis service from a host job and reach it through localhost plus a mapped port.
  • Run the same interaction from a Python job container and reach Redis by the service hostname.
  • Capture safe Docker, service, runner and network evidence without exposing credentials.
  • Build a tiny Docker image locally on the hosted runner and verify its local image ID.
  • Perform explicit lab cleanup while understanding what GitHub tears down automatically.

1. Disposable lab and preflight

Create a throwaway GitHub.com repository named gha-container-lab. The workflow is manual, uses permissions: {}, GitHub-hosted ubuntu-24.04, public Redis/Python images, synthetic keys and no secret. The lab never logs into or pushes to a registry.

Before the first run, predict three states: the host job will use 127.0.0.1:6379; the container job will use redis:6379 without a host-port mapping; and the build job will create a local image whose sha256: ID disappears with the ephemeral runner after cleanup/teardown.

2. One workflow, three bounded jobs

name: chapter15-container-lab
on:
  workflow_dispatch:
permissions: {}

jobs:
  host-service:
    runs-on: ubuntu-24.04
    services:
      redis:
        image: redis:7.4-alpine
        ports:
          - 6379:6379
        options: >-
          --health-cmd "redis-cli ping"
          --health-interval 5s
          --health-timeout 3s
          --health-retries 10
    steps:
      - name: Inspect host and Docker state
        shell: bash
        run: |
          echo "run=$GITHUB_RUN_ID attempt=$GITHUB_RUN_ATTEMPT sha=$GITHUB_SHA"
          echo "runner=$RUNNER_OS/$RUNNER_ARCH"
          docker version
          docker ps --format 'table {{.ID}}	{{.Image}}	{{.Status}}	{{.Ports}}'
          docker image inspect redis:7.4-alpine             --format 'redis_id={{.Id}} repo_digests={{json .RepoDigests}}' || true

      - name: Exercise Redis from the runner host
        shell: bash
        env:
          REDIS_HOST: 127.0.0.1
          REDIS_PORT: '6379'
        run: |
          python3 - <<'PY'
          import os, socket
          host=os.environ['REDIS_HOST']; port=int(os.environ['REDIS_PORT'])
          def command(*parts):
              payload=f"*{len(parts)}\r\n" + ''.join(f"${len(p)}\r\n{p}\r\n" for p in parts)
              with socket.create_connection((host,port), timeout=5) as s:
                  s.sendall(payload.encode())
                  return s.recv(4096).decode(errors='replace')
          print('PING =>', command('PING').strip())
          print('SET  =>', command('SET','chapter15:key','host-ok').strip())
          print('GET  =>', command('GET','chapter15:key').strip())
          PY

  container-service:
    runs-on: ubuntu-24.04
    container:
      image: python:3.13-slim
    services:
      redis:
        image: redis:7.4-alpine
        options: >-
          --health-cmd "redis-cli ping"
          --health-interval 5s
          --health-timeout 3s
          --health-retries 10
    steps:
      - name: Exercise Redis from the job container
        shell: sh
        env:
          REDIS_HOST: redis
          REDIS_PORT: '6379'
        run: |
          python - <<'PY'
          import os, socket, platform
          print('python', platform.python_version())
          host=os.environ['REDIS_HOST']; port=int(os.environ['REDIS_PORT'])
          def command(*parts):
              payload=f"*{len(parts)}\r\n" + ''.join(f"${len(p)}\r\n{p}\r\n" for p in parts)
              with socket.create_connection((host,port), timeout=5) as s:
                  s.sendall(payload.encode())
                  return s.recv(4096).decode(errors='replace')
          print('PING =>', command('PING').strip())
          print('SET  =>', command('SET','chapter15:key','container-ok').strip())
          print('GET  =>', command('GET','chapter15:key').strip())
          PY

  build-local:
    needs: [host-service, container-service]
    runs-on: ubuntu-24.04
    steps:
      - name: Create deterministic tiny build context
        shell: bash
        run: |
          mkdir -p .lab
          cat > .lab/app.py <<'PY'
          import platform
          print('chapter15 image self-test')
          print('python', platform.python_version())
          PY
          cat > .lab/Dockerfile <<'DOCKER'
          FROM python:3.13-slim
          WORKDIR /app
          COPY app.py /app/app.py
          CMD ["python", "/app/app.py"]
          DOCKER
          cat > .lab/.dockerignore <<'EOF'
          image-id.txt
          EOF

      - name: Build and verify a local image
        shell: bash
        run: |
          tag="gha-ch15-local:${GITHUB_RUN_ID}"
          docker version
          docker build --pull --iidfile .lab/image-id.txt -t "$tag" .lab
          iid_file="$(cat .lab/image-id.txt)"
          iid_inspect="$(docker image inspect "$tag" --format '{{.Id}}')"
          printf 'iid_file=%s
iid_inspect=%s
' "$iid_file" "$iid_inspect"
          test "$iid_file" = "$iid_inspect"
          docker run --rm "$tag"
          docker image inspect python:3.13-slim             --format 'base_id={{.Id}} repo_digests={{json .RepoDigests}}' || true

      - name: Bounded cleanup
        if: ${{ always() }}
        shell: bash
        run: |
          tag="gha-ch15-local:${GITHUB_RUN_ID}"
          docker image rm "$tag" 2>/dev/null || true

3. Why the host job publishes a port

The first job's Python process runs on the Ubuntu runner, not inside the Redis service network. The Redis container therefore publishes container port 6379 to host port 6379. The client connects to 127.0.0.1:6379.

The health check is executed as part of the service lifecycle. If Redis never becomes healthy, GitHub fails the service setup before the application step. Keep that distinction: “service did not become healthy” is a different failure layer from “application used the wrong endpoint.”

4. Why the job-container variant removes the port mapping

The second job runs its ordinary steps inside python:3.13-slim. GitHub creates a Docker network shared by the job container and the Redis service. The service label redis becomes the DNS hostname. No host-port mapping is required because both peers are inside the same Docker network.

The step explicitly uses shell: sh because the Python slim image does not promise Bash. This keeps the example coherent with GitHub's documented default shell behavior for container jobs.

5. Evidence to capture from the two integration jobs

Evidence Host-service job Job-container job
Client location Ubuntu runner host Python job container
Redis address 127.0.0.1:6379 redis:6379
Port publication required for host process not required
Shell/runtime Bash + host Python sh + Python 3.13 image
Service evidence Docker ps/status, health, image ID/digest if visible job conclusion + service health; container-level host Docker inspection is not assumed inside the job container
Run identity run ID, attempt, source SHA same run identity but independent job/runner allocation

6. Local Docker build: no registry is involved

The build job creates its entire source and Dockerfile inside a bounded .lab/ context. docker build --iidfile writes the local image ID. The workflow independently asks Docker for .Id and requires the two values to match. That is a content-addressed identity inside this daemon.

The tag gha-ch15-local:<run-id> is only a convenient local name. Since the image is never pushed, there is no registry digest for the new application image. The base image may have a RepoDigest after pull, so the workflow records it when Docker exposes it.

7. Cleanup has two layers

The workflow explicitly removes the locally built image by its run-bounded tag. GitHub separately manages service and job-container teardown. Finally, because each GitHub-hosted job gets an ephemeral VM, any remaining Docker state disappears with the runner.

This does not generalize to persistent self-hosted runners. There, explicit image/container/volume lifecycle policy is required, and cleanup must be scoped so one workflow cannot delete another workload's state.

8. Challenge: choose the layer before changing YAML

You add a PostgreSQL service and the application runs in a job container. A teammate publishes 5432:5432 and connects to localhost:5432. Without running anything, identify the unnecessary configuration and the wrong assumption. Then state the smallest correction and the evidence that would prove it.

Answer: remove the host port publication unless a host process truly needs it, connect the job-container application to the service label such as postgres:5432, and verify service health plus application connection logs from the same run/SHA.

9. Local faithful simulation

With Docker installed locally, create a user-defined network, start redis:7.4-alpine on it, then run python:3.13-slim on the same network and connect to hostname redis. Repeat by publishing Redis port 6379 and connecting from the host to 127.0.0.1:6379. Build the .lab Dockerfile and inspect its image ID.

The simulation validates Docker topology and image identity, but it does not validate GitHub's managed service lifecycle, contexts, run IDs or runner teardown. Record that limitation.

Knowledge check

Why is no port mapping needed in container-service?

Why does build-local use a separate host job?

Does the run-bounded image tag prove image content?

What is the cleanup difference on a self-hosted runner?

What should the learner change when moving from host process to job container?

Next lesson

Choose the right container boundary

Lesson 3 turns these mechanics into design decisions about isolation, evidence, cost and deployment boundaries.

Official references and version notes

Version and compatibility note

Version-sensitive behavior was rechecked on 2026-09-09. GitHub job containers, service containers and Docker container actions require a Linux runner; on GitHub-hosted runners that means Ubuntu. When a job runs on the host, mapped service ports are reached through localhost; when the job itself runs in a container, service containers share a Docker network and are reached by their service labels without host-port publication. The mandatory labs target ubuntu-24.04, use public version-family images redis:7.4-alpine and python:3.13-slim, and record resolved image metadata rather than claiming those tags are immutable. They use the Docker CLI already present on the hosted Ubuntu runner and perform no registry login or push. Optional production Buildx examples should pin the full SHAs listed above.

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
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0Send only Ethereum/ERC-20 compatible assets to this address.