How to optimise GitLab CI runtime environments using custom Docker images

Zach Laster • 6 minutes • 2023-03-16

How to optimise GitLab CI runtime environments using custom Docker images

Often when running CI/CD jobs we need to use custom built tools and applications. While we could download the things we need each time we run a relevant job, it would be more efficient to have them already available on the images we are using. Fortunately, we can build our own Docker images, and we can have GitLab manage them for us.


Introduction#

GitLab CI allows us to run jobs on our repos. These can be tests, compilation, deployment, or anything else you’d like to do with your code. GitLab CI uses Docker images to provide the environments in which it runs jobs, and we can specify what image to use for each job. While images often come from Docker Hub, we can use GitLab’s Container Registry to have a private registry of our own.

Process#

For this test, I wanted a GitLab project that, on push to repository, would build, tag, and push an image to the container registry for that project.

As an extension, I tested using the resulting image.

Itch.io Butler#

For the purposes of this test, I wanted something relatively simple to dockerise that still reflected a typical use case. Itch.io is a distribution platform commonly used by “indie developers” to share digital games, assets, printables, and other products. It has a CLI tool named Butler which is used to automate the deployment of products to created pages on the platform.

The following bash script downloads and unpacks butler inside our docker image.

 1#!/usr/bin/bash
 2
 3mkdir -p /opt/butler/bin
 4cd /opt/butler/bin
 5
 6curl -L -o butler.zip https://broth.itch.ovh/butler/linux-amd64/LATEST/archive/default
 7unzip butler.zip
 8
 9# GNU unzip often fails to set the executable bit, regardless of the state in the .zip
10# In other contexts, this command might require sudo, but inside an image that usually doesn't work!
11chmod +x butler

Dockerfile#

Docker images are created by building from a Dockerfile. A Dockerfile specifies a base image to build from and then applies layers to that image in order to produce the desired filesystem/installation state. Often, those layers are a series of annotated bash script lines.

Ubuntu makes a good base in our case, as it is very small (less than 30MB). From there we only need to ensure we have the tools used in the script and to run the script itself.

 1FROM ubuntu:23.04
 2Label Author="Zach Laster <zlaster@verifa.io>"
 3
 4RUN apt-get update && apt-get install -y --no-install-recommends \
 5  ca-certificates \
 6  unzip \
 7  zip \
 8  curl \
 9  && rm -rf /var/lib/apt/lists/*
10
11ADD get_butler.sh /opt/butler/get_butler.sh
12RUN bash /opt/butler/get_butler.sh
13RUN /opt/butler/bin/butler -V
14
15ENV PATH="/opt/butler/bin:${PATH}"

Note that we also add butler to the PATH.

Building#

With the above files, we are able to create our image (assuming we have docker or some other image building tool installed). There are other options, but to build our image in GitLab CI we’ll use “Docker-In-Docker”.

Since all we want to do is create a Docker image and push it to our private registry, our CI pipeline is quite straightforward. The simplest CI file could look like this:

 1image: docker:23.0.1 # Specify the Docker image
 2services:
 3  - docker:23.0.1-dind # We are using Docker-In-Docker
 4
 5# We define a variable to based on GitLab-provided variables.
 6# This will be used as the name of the tag of our image.
 7# Note that we use the REF_SLUG and not REF_NAME.
 8variables:
 9  DOCKER_IMAGE_BUILD_TAG: $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG
10
11# Build job in build stage which runs the Docker commands.
12build:
13  stage: build
14  script:
15    # Our login is provided to us by GitLab.
16    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
17    # Build and tag image
18    - docker build --pull -t $DOCKER_IMAGE_BUILD_TAG .
19    # Push image with tag. This puts it in the repo's registry.
20    - docker push $DOCKER_IMAGE_BUILD_TAG

If we push these files to a repo then the CI pipeline will make us an image. Simple!

Fleshing out and Testing#

My full CI file looks like this:

 1image: docker:23.0.1
 2services:
 3  - docker:23.0.1-dind
 4
 5stages:
 6  - build
 7  - test
 8  - release
 9
10variables:
11  DOCKER_IMAGE_BUILD_TAG: $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG
12  DOCKER_IMAGE_LATEST_TAG: $CI_REGISTRY_IMAGE:latest
13
14before_script:
15  - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
16
17build:
18  stage: build
19  script:
20    - docker build --pull -t $DOCKER_IMAGE_BUILD_TAG .
21    - docker push $DOCKER_IMAGE_BUILD_TAG
22
23test-version:
24  image: $DOCKER_IMAGE_BUILD_TAG
25  stage: test
26  before_script: []
27  script:
28    - ./tests/check_version.sh
29
30# When pushing the the main branch of the repo, we also tag the image as "latest"
31release-image:
32  stage: release
33  script:
34    - docker pull $DOCKER_IMAGE_BUILD_TAG
35    - docker tag $DOCKER_IMAGE_BUILD_TAG $DOCKER_IMAGE_LATEST_TAG
36    - docker push $DOCKER_IMAGE_LATEST_TAG
37  # This particular job should only run when we push to the branch named 'main'
38  only:
39    - main

And the check_version.sh file looks like this:

1#!/usr/bin/bash
2butler -V # This just outputs the version identifier of butler

This gives a quick verification that butler is correctly set up and that the image is working.

The CI file also now tags the image as latest if it passes the test and this was a push to the main branch.

Testing it Out#

Now we have an image in our project’s container registry and we’ve even made sure it works within the project. Let’s try using it in a different project.

Let’s spin up a repo that contains only a .gitlab-ci.yml file:

1# .gitlab-ci.yml
2test-version:
3  image: registry.gitlab.com/verifa/examples/docker-butler:latest
4  stage: test
5  script:
6    - butler -V

Let’s initialise a repo locally, commit the file, and push it to a new GitLab repo.

1git init
2git add .
3git commit -m"Test the butler version in the latest image"
4git remote add origin git@gitlab.com:verifa/examples/butler-image-test.git
5git push --set-upstream origin main

If we check the pipeline job, we see this:

Failed

Well that’s not good!

Turns out, we can’t access this image outside of our initial project! That’s because the image is in a private project.

There’s several ways to address this. A very simple option is to go to the GitLab project page, Settings→CI/CD→Token Access and add the name of our test project (including the group name) to the CI_JOB_TOKEN access list.

Testing after that yields this:

Passed

Job done!

That’s great for a few projects, but what if we have 100 projects that all use this image? We probably don’t want to add the name to the list every time we spin up a new project.

The description for Token Access is confusing at best, but let’s try disabling the CI_JOB_TOKEN management in the image project completely.

Passed Again

Well that’s surprising. That system is actually a whitelist, and disabling it permits anyone with access to the repo to use the images.

The documentation for this feature doesn't mention how access works when it is disabled, but the documentation for the original, deprecated, outbound behavior says that when it is disabled then the user's access permissions are used, so it is probably that.

Conclusion#

It’s very easy to set up a pipeline whereby our own custom tools and processes can be containerised for future use, which is a very valuable way of making our pipelines faster!

Here we’ve seen how to do this with a simple tool we download from a public URL, but it’s also practical to do this with artifacts from other projects or installations from other sources.

Accessing the image afterward is somewhat more difficult. I’m still exploring how to make the container registry available to other projects within the same group and beyond. Adding projects to the CI_JOB_TOKEN access list works well enough, but is a bit impractical in a larger organization with many projects. Meanwhile turning off Token Access works in the same way blowing a hole in something lets water through; it works, but it’s not very managed and might be unsafe. It’s also likely that disabling the Token Access flag only permits pipelines started by users with access to the project, which might have other issues. This will be looked at more in the future.

I'm working on some related test projects to have code compiled using a custom image and then to Dockerise that into its own image. Stay tuned for more!



Comments


Read similar posts

Blog

2024-01-24

13 minutes

How to secure Terraform code with Trivy

Learn how Trivy can be used to secure your Terraform code and integrated into your development workflow.

Event

2023-05-30

1 minutes

The Lazy Game Dev's Guide to Automation

Recorded session at the Nordic Game conference, Malmö, May 2023

Blog

2023-05-15

4 minutes

Bag of Git tricks

Neat git tricks in bite-sized chunks.

Sign up for our monthly newsletter.

By submitting this form you agree to our Privacy Policy