July 8, 2020

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:

  1. build and package a Clojure application using Leiningen.

  2. create a Docker image and push it to GitLab’s Docker registry.

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:

  1. Docker-in-Docker requires privileged mode in order to function, which is a significant security concern.

  2. 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:

  1. use stages to ensure that jobs are run sequentially

  2. use kaniko to build the Docker image.

Tags: GitLab Docker