Task 4: The Container Image

In this task the first placeholders become real: the image-build and image-push jobs.

  • The image is built on a GitHub runner, a fresh machine per job, not on your own.
  • Only the merge workflow pushes it to Docker Hub. A pull request builds the image and throws it away; a topic branch builds none.
  • The push needs a Docker Hub login, with a token stored in GitHub: the steps are in Pipeline Identities, Part 2.

Write the Dockerfile

  • A slim Python base image, the requirements, the app, a non-root user.
  • The server: TODO: gunicorn for Flask, uvicorn for FastAPI.
  • Build and run it on your machine; call /healthz.
Why these choices
  • Slim base image: fewer packages, so a smaller download and fewer findings for Task 9's scanner.
  • The requirements before the app: Docker reuses a layer whose inputs did not change, so a change to the code rebuilds only the last layers, not the pip install.
  • A non-root user: a process that breaks out of the app is not root inside the container.
  • A production server: Flask's and FastAPI's own development servers are for your machine only.

Where the image is built: the runner

Every docker build in the pipeline runs on a GitHub-hosted runner: a virtual machine GitHub starts for one job and deletes when the job ends.

  • The job checks out the repository onto the runner, and builds there, with the Docker the runner has installed.
  • The image exists only on that machine: when the job ends it is gone, unless the job pushed it.
  • So a job that needs the image either builds it itself, or reads it from the registry.
What follows from building on the runner
  • Nothing is left from the last run. Each job starts on a clean machine:
    • no image layers cached from yesterday, so every build is a full build;
    • TODO: a build cache kept by GitHub (docker/build-push-action with cache-from: type=gha), or not.
  • The runner builds for its own processor: ubuntu-latest is x86-64 (amd64), as the EKS and EC2 nodes of Task 5 are.
    • An image built on an Apple-silicon Mac is arm64, and does not start on those nodes.
    • That is one more reason the deployed image comes from the runner, not from a laptop.
  • The build sees only the checkout: the files committed to the repository. A file on your machine that you never committed is not in the image, which is how the pipeline catches it.
  • What you run locally and what the pipeline runs are the same Dockerfile, so a build that works on your machine works there too, the processor aside.

Which workflows build, and which push

WorkflowBuilds the imagePushes it
Branchnono
Pull requestyes, to check the Dockerfileno
Mergeyesyes, tagged with the merge commit's SHA
  • This is the first place the three workflows differ in what they do, not only in how much.
  • The registry holds only images built from main: every tag is a commit that passed the gate and was deployed.
Why only the merge workflow pushes: a design choice
  • Every image in the registry was deployed, or is about to be. Nothing in it is a half-finished branch.
  • A tag names a commit on main, so going back to an earlier version means deploying an earlier tag.
  • The Docker Hub token reaches only the merge workflow. Pull request and branch runs never log in, so a change under review cannot use or print the token.
    • A pull request from a fork gets no secrets at all, so it could not push even if it tried.
  • The cost: an image is built twice, once in the pull request and once after the merge.
  • The other design: teams that run every pull request in a test environment of its own push a pull request's image too, to a separate repository that is cleaned up regularly. This project has one environment, so it does not.

Build the image in the pull request

  • Replace the image-build placeholder in pr.yml: check out the repository, then docker build, with no login and no push.
  • The job fails when the Dockerfile does, so a broken image never reaches main.

Log in to Docker Hub

Follow Pipeline Identities, Part 2, with this project's names:

  1. Create the Docker Hub repository: devops-project. TODO: public or private.
  2. Create the access token: Repo Read & Write, 30 days.
  3. Store it in GitHub: the secret DOCKERHUB_TOKEN, and the variable DOCKERHUB_USERNAME.
  4. Add the login step (docker/login-action) to the image-push job in merge.yml, and only there.
A public or a private Docker Hub repository
  • Public: anyone may pull the image, with no credential.
    • The cluster in Task 6 pulls it with nothing to configure.
    • Like the GitHub repository, it is something to show an employer.
  • Private: only the holders of a credential may pull it.
    • The cluster needs a pull credential of its own: Pipeline Identities, Part 6 shows one.
    • A free Docker Hub account has a limited number of private repositories.

Push the image from the merge workflow

  • Replace the image-push placeholder in merge.yml: check out, log in, build, tag with the commit's SHA ($GITHUB_SHA), push.
  • TODO: also a latest tag, or only the SHA.
  • TODO: hand the image's name to the deploy job, as a job output.

Merge, and find the image

  • Open a pull request: image-build runs, and nothing new appears in Docker Hub.
  • Merge it: image-push runs, and the image appears in Docker Hub under the merge commit's SHA.

Translations: