How to Use GitLab CI/CD to Package an Application and Dockerize it Using Kaniko
These days, many applications are distributed as Docker images. That includes applications that are built and packaged, e.g. Java or Clojure based applications. Recently, I started to explore Clojure by rewriting a simple application I implemented in Java.
I decided to use this application to write my first GitLab CI/CD pipeline.
The pipeline should solve the following tasks:
I was quite surprised to find that this was not as straightforward as expected. Neither could I find a concise tutorial or blog post that helped me build this simple pipeline.
Building and packaging the application means creating an artifact in the first step that is then included in the Docker image in the second step.
The first thing I learned was that in order to have build jobs execute sequentially, I had to use build stages. I defined two
stages, called build
and docker
. Jobs in the same stage are executed concurrently. Also, the stage is a property of the job,
rather than that jobs are defined as children of the stages.
All of the following yaml-snippets are from the file .gitlab-ci.yml
in the project’s root directory.
First, define two custom stages:
stages:
- build
- docker
The job build_mock_service
(see below) is part of the build
stage and creates a jar file containing the application and all of its
dependencies, a so-called uberjar. In this example, this is the only job in the build stage.
All jobs in this pipeline use different images, and the image is therefore a part of each job definition. The script parameter calls
the build tool, lein
(Leiningen), and instructs it to create the uberjar. The artifact thus created is
target/uberjar/mock-service-0.0.0-standalone.jar
, and it is available in jobs of subsequent stages. Note that the version is always a
dummy value of '0.0.0', the real version is defined by a git tag.
build_mock_service:
stage: build
image: clojure:openjdk-8-lein-2.9.3-buster
script:
- lein uberjar
artifacts:
paths:
- target/uberjar/mock-service-0.0.0-standalone.jar
The second thing I learned was that it wasn’t just smooth sailing to build a docker image in GitLab CI/CD, mainly because it is not trivial to build Docker images using the Docker-in-Docker build method in general. GitLab lists two possible approaches: Building Docker images with GitLab CI/CD and Building images with kaniko and GitLab CI/CD. Building Docker images with kaniko solves two problems:
Docker-in-Docker requires privileged mode in order to function, which is a significant security concern.
Docker-in-Docker generally incurs a performance penalty and can be quite slow.
I therefore decided to try kaniko and based my build job build_with_kaniko
(see below) on the GitLab pipeline from
Simple Best Practice Container Build Using Kaniko.
Compared to that tutorial, the variables and image definition and entry point were moved to the job definition, and the stage is called docker instead of build.
build_with_kaniko:
stage: docker
image:
name: gcr.io/kaniko-project/executor:debug
entrypoint: [""]
variables:
VERSIONLABELMETHOD: "OnlyIfThisCommitHasVersion" # options: "OnlyIfThisCommitHasVersion","LastVersionTagInGit"
IMAGE_LABELS: >
--label org.opencontainers.image.vendor=$GITLAB_USER_EMAIL
--label org.opencontainers.image.authors=$GITLAB_USER_EMAIL
--label org.opencontainers.image.revision=$CI_COMMIT_SHA
--label org.opencontainers.image.source=$CI_PROJECT_URL
--label org.opencontainers.image.documentation=$CI_PROJECT_URL
--label org.opencontainers.image.licenses=$CI_PROJECT_URL
--label org.opencontainers.image.url=$CI_PROJECT_URL
--label vcs-url=$CI_PROJECT_URL
--label com.gitlab.ci.user=$GITLAB_USER_EMAIL
--label com.gitlab.ci.email=$GITLAB_USER_EMAIL
--label com.gitlab.ci.tagorbranch=$CI_COMMIT_REF_NAME
--label com.gitlab.ci.pipelineurl=$CI_PIPELINE_URL
--label com.gitlab.ci.commiturl=$CI_PROJECT_URL/commit/$CI_COMMIT_SHA
--label com.gitlab.ci.cijoburl=$CI_JOB_URL
--label com.gitlab.ci.mrurl=$CI_PROJECT_URL/-/merge_requests/$CI_MERGE_REQUEST_ID
script:
- |
echo "Building and shipping image to $CI_REGISTRY_IMAGE"
#Build date for opencontainers
BUILDDATE="'$(date '+%FT%T%z' | sed -E -n 's/(\+[0-9]{2})([0-9]{2})$/\1:\2/p')'" #rfc 3339 date
IMAGE_LABELS="$IMAGE_LABELS --label org.opencontainers.image.created=$BUILDDATE --label build-date=$BUILDDATE"
#Description for opencontainers
BUILDTITLE=$(echo $CI_PROJECT_TITLE | tr " " "_")
IMAGE_LABELS="$IMAGE_LABELS --label org.opencontainers.image.title=$BUILDTITLE --label org.opencontainers.image.description=$BUILDTITLE"
#Add ref.name for opencontainers
IMAGE_LABELS="$IMAGE_LABELS --label org.opencontainers.image.ref.name=$CI_REGISTRY_IMAGE:$CI_COMMIT_REF_NAME"
#Build Version Label and Tag from git tag, LastVersionTagInGit was placed by a previous job artifact
if [[ "$VERSIONLABELMETHOD" == "LastVersionTagInGit" ]]; then VERSIONLABEL=$(cat VERSIONTAG.txt); fi
if [[ "$VERSIONLABELMETHOD" == "OnlyIfThisCommitHasVersion" ]]; then VERSIONLABEL=$CI_COMMIT_TAG; fi
if [[ ! -z "$VERSIONLABEL" ]]; then
IMAGE_LABELS="$IMAGE_LABELS --label org.opencontainers.image.version=$VERSIONLABEL"
ADDITIONALTAGLIST="$ADDITIONALTAGLIST $VERSIONLABEL"
fi
ADDITIONALTAGLIST="$ADDITIONALTAGLIST $CI_COMMIT_REF_NAME $CI_COMMIT_SHORT_SHA"
if [[ "$CI_COMMIT_BRANCH" == "$CI_DEFAULT_BRANCH" ]]; then ADDITIONALTAGLIST="$ADDITIONALTAGLIST latest"; fi
if [[ -n "$ADDITIONALTAGLIST" ]]; then
for TAG in $ADDITIONALTAGLIST; do
FORMATTEDTAGLIST="${FORMATTEDTAGLIST} --tag $CI_REGISTRY_IMAGE:$TAG ";
done;
fi
#Reformat Docker tags to kaniko's --destination argument:
FORMATTEDTAGLIST=$(echo "${FORMATTEDTAGLIST}" | sed s/\-\-tag/\-\-destination/g)
echo $FORMATTEDTAGLIST
echo $IMAGE_LABELS
mkdir -p /kaniko/.docker
echo "{\"auths\":{\"$CI_REGISTRY\":{\"auth\":\"$(echo -n $CI_REGISTRY_USER:$CI_REGISTRY_PASSWORD | base64)\"}}}" > /kaniko/.docker/config.json
/kaniko/executor --context $CI_PROJECT_DIR --dockerfile $CI_PROJECT_DIR/Dockerfile $FORMATTEDTAGLIST $IMAGE_LABELS
Note the usage of the file VERSIONTAG.txt
and the logic used to set the environment variable VERSIONLABEL
. This file is created
in the following job in the predefined .pre
stage:
get-latest-git-version:
stage: .pre
image:
name: alpine/git
entrypoint: [""]
script:
- |
echo "$(git describe --abbrev=0 --tags)" > VERSIONTAG.txt
echo "VERSIONTAG.txt contains $(cat VERSIONTAG.txt)"
artifacts:
paths:
- VERSIONTAG.txt
This conveniently helps to implement artifact versioning also. The produced artifact is the Docker image, the uberjar is only an
intermediate artifact that is not preserved. Thus, it is the Docker image that is versioned using several tags, one of them being
the tag of the current commit in git if it exists, or the latest tag in git (depending on VERSIONLABELMETHOD
in
build_with_kaniko
).
Thus, the pipeline presented here with two custom stages and three jobs solves the task of building and packaging a Clojure application and containerizing it using kaniko.
The key takeaways are:
use stages to ensure that jobs are run sequentially
use kaniko to build the Docker image.