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-actionwithcache-from: type=gha), or not.
- The runner builds for its own processor:
ubuntu-latestis 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.
- An image built on an Apple-silicon Mac is
- 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
| Workflow | Builds the image | Pushes it |
|---|---|---|
| Branch | no | no |
| Pull request | yes, to check the Dockerfile | no |
| Merge | yes | yes, 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-buildplaceholder inpr.yml: check out the repository, thendocker 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:
- Create the Docker Hub repository:
devops-project. TODO: public or private. - Create the access token: Repo Read & Write, 30 days.
- Store it in GitHub: the secret
DOCKERHUB_TOKEN, and the variableDOCKERHUB_USERNAME. - Add the login step (
docker/login-action) to theimage-pushjob inmerge.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-pushplaceholder inmerge.yml: check out, log in, build, tag with the commit's SHA ($GITHUB_SHA), push. - TODO: also a
latesttag, or only the SHA. - TODO: hand the image's name to the
deployjob, as a job output.
Merge, and find the image
- Open a pull request:
image-buildruns, and nothing new appears in Docker Hub. - Merge it:
image-pushruns, and the image appears in Docker Hub under the merge commit's SHA.