Automatically build and push Docker images using GitLab CI

Automatically build and push Docker images using GitLab CI

When I began publishing public Docker images, I was using the GitHub integration with the Docker Hub to automatically build and publish my images.

However, the Docker Hub is very slow to build images and has very, very limited configuration options.

Then I discovered Drone which allowed me to build images on my own server, tag them, etc. The thing is I'm limited by the drone-docker plugin and I can't do everything I want with it.

A few days ago I decided to deepen my knowledge of GitLab CI. I mainly know it from work where we use it for Terraform pipelines. Β But now that I set it up for a few repositories and understand how it works, I am a new person. GitLab CI is simply awesome. There is nearly no limit to what you can do in your .gitlab.yml!

Using Docker inside GitLab CI#

It's a bit trickier when you want to use GitLab CI to build Docker images though. Indeed, pipelines are composed of stages, each stage is composed of jobs, and each job runs in its own Docker container. You can define the images used and the commands ran inside the containers.

Docker containers are made of layers, and Docker images are simply a container where its layers are defined by a Dockerfile. When you run the docker build command, it actually spawns a new container and then save the final state of the container into what's called an image. As we seen, jobs on GitLab CI are ran inside containers... Which would mean we have to run Docker in Docker.

Docker in Docker...?#

Other people already thought about that, so there is a documentation page on this topic. By the way the GitLab documentation is so good: completed, detailed, easy to read.

So, there are 3 approaches to build images with GitLab CI:

  • Use a runner is shell mode and give it permission to run docker commands on the host
  • Use a runner in privileged docker mode and use Docker in Docker (dind)
  • Use a runner in docker mode and give it access to the Docker socket of the host

As you can see, each method needs a form of access to the Docker daemon (you can't run privileged container on shared runners): we'll have to use our own gitlab-runner. Thus, it doesn't matter if you follow this tutorial while using your own instance or gitlab.com.

The Docker in Docker approach is interesting, in the sense that it is possible. However you really want to avoid that. Even JΓ©rΓ΄me Petazzoni, the creator of Docker in Docker says: "Using Docker-in-Docker for your CI or testing environment? Think twice." Indeed it's not efficient nor reliable, slow and exposes security concerns.

Docker from Docker#

The best option for me is to give actual Docker access to the runner. Using the shell mode, it means adding the gitlab-runner user to the docker group so that it can launch containers. In Docker mode, it means binding the docker socket to the container using a volume. The container will then be able to communicate with the Docker daemon and thus spawn containers.

In any case, giving the runner access to the host's docker socket means giving it root access, so keep that in mind. You may want to dedicate a VM to your runner.

I want to keep my host as clean as possible, so I went with the last option, but without even having gitlab-runner on the host: I run the runner directly in a container , without installing gitlab-runner on my host and using the docker mode.

Configuring the gitlab-runner container#

Tt's really easy. Here is my gitlab-runner directory:

.
β”œβ”€β”€ config
β”‚Β Β  └── config.toml
└── docker-compose.yml

My docker-compose.yml:

version: '3'

services:
  gitlab-runner:
    image: gitlab/gitlab-runner:alpine-v11.2.0
    container_name: gitlab_runner
    restart: always
    volumes:
      - ./config/:/etc/gitlab-runner/
      - /var/run/docker.sock:/var/run/docker.sock

It's painless to run. We just need to mount the Docker socket and the configuration file.

A base configuration file may be empty. The first thing you can configure is how much jobs you want running at the same time:

concurrent = 1

One is fine for a light CI/CD workflow and optimum pipeline execution.

One runner for multiple runners πŸ€”#

Even though we have one "runner", e.g. the actual gitlab-runner container, you can configure as much runners you want within this runner. I know it sounds weird, I may be misusing the terminology.

To be clearer: you have one runner. For each repository, you will have to assign one or more "instance" of the runner. GitLab will see different runners, but in fact you only have one runner container, which will handle and queues the different jobs assigned to its "instances", and execute them in different Docker containers according to the .gitlab.yml configuration for each repository.

I hope it makes sense.

Assigning a runner to a repository#

Let's assign a runner to our GitLab repository. Go to Settings -> CI / CD, and get a token:

Now register your runner trough your Docker runner:

docker-compose run --rm gitlab-runner register -n \
  --url https://gitlab.com/ \
  --registration-token <token> \
  --executor docker \
  --description "Angristan's Docker Runner" \
  --docker-image "docker:stable" \
  --docker-volumes /var/run/docker.sock:/var/run/docker.sock

Adapt it to your GitLab instance's URL and set the description you want. This will register a Docker runner running on the docker:stable image. Indeed, we have access to the host's Docker socket so we need the Docker CLI to actually run containers on the host.

After a few seconds, it will output:

Runner registered successfully. Feel free to start it, but if it's running already the config should be automatically reloaded!

Our config.toml file should have a new section:

[[runners]]
  name = "Angristan's Docker Runner"
  url = "https://gitlab.com/"
  token = "xxxxxxxxxxxxxxxxxxxxxx"
  executor = "docker"
  [runners.docker]
    tls_verify = false
    image = "docker:stable"
    privileged = false
    disable_cache = false
    volumes = ["/var/run/docker.sock:/var/run/docker.sock", "/cache"]
    shm_size = 0
  [runners.cache]

Since we ran our register command with --rm, the container disappeared. You can now start your runner:

# run docker-compose down if the runner was running before the register
docker-compose up -d

It should up and running on GitLab:

If you're on gitlab.com don't forget to disable shared runners since they won't be usable for our builds.

Setting up a CI/CD pipeline#

Our repo is ready for GitLab CI. Let's create a pipeline with one stage and one job.

Here's a good start:

cover: docker:stable
images: ["docker:stable"]

stages:
  - Build image

docker build:
  stage: Build image
  script:
    - docker info
    - docker build -t myimage .

Commit and push, and your pipeline will automatically start:

Click on the job to see the output, and you will see that docker info worked and your image being built.

Now, let's push our image to the Docker Hub. This can be easily adapted to other registries.

cover: docker:stable
images: ["docker:stable"]

stages:
  - Build image
  - Push to Docker Hub

docker build:
  stage: Build image
  script:
    - docker info
    - docker build -t riot .

docker push:
  stage: Push to Docker Hub
  only:
    - master
  script:
    - echo "$REGISTRY_PASSWORD" | docker login -u "$REGISTRY_USER" --password-stdin
    - docker push angristan/riot

We don't really have a choice when it comes to logging in. We'll have to use variables, stored by GitLab:

Protect your variables!

And then logging in usingstdin.

Please be aware that you won't be able to login if you have special characters in your password. This made me loose a lot of time until I found out the reason. Also, REGISTRY_USER is your username, not your email.

Commit and push, and after the build job, your image will be pushed to the hub! Using the host's Docker daemon is really convenient in our case because the image is built and stored in the local registry. That means that the push jobs will have access to the image πŸ‘. That's why I recommend removing the image after pushing it, in order to keep your local registry clean and saving storage.

Here's how my usual .gitlab-ci.yml looks like:

cover: docker:stable
images: ["docker:stable"]

variables:
  LATEST_VER: angristan/app:latest
  MAJOR_VER: angristan/app:2
  MINOR_VER: angristan/app:2.3
  PATCH_VER: angristan/app:2.3.1

stages:
  - Build image
  - Push to Docker Hub

docker build:
  stage: Build image
  script:
    - docker info
    - docker build -t $LATEST_VER -t $MAJOR_VER -t $MINOR_VER -t $PATCH_VER .

docker push:
  stage: Push to Docker Hub
  only:
    - master
  script:
    - echo "$REGISTRY_PASSWORD" | docker login -u "$REGISTRY_USER" --password-stdin
    - docker push $LATEST_VER && docker image rm $LATEST_VER
    - docker push $MAJOR_VER && docker image rm $MAJOR_VER
    - docker push $MINOR_VER && docker image rm $MINOR_VER
    - docker push $PATCH_VER && docker image rm $PATCH_VER

It's pretty short and I can reuse most of it across my images repositories. Note how I handle tags, too.

Make sure to protect your variables. It means they will be accessible only on protected branches/tags, by default master. Otherwise anyone with the permissions to create a branch will be able to echo your Docker Hub password in a job...

On our case, we won't be able (and we don't want) to push to the Hub on non-master branches, so I added only: master to the push job. On other branches, the second stage will simply be ignored.

Notice how my pipeline for the `test` branch only has one stage.

Another thing I like is scheduled builds. Thanks to them, my images are rebuilt and pushed once a week. This is important, because once a repository is configured, I mainly commit to it when there is a new version of the software. But months can happen between versions, so that means images will have months-old underlying layers (OS, packages, dependencies, etc).

This is very easy to setup on GitLab:

The proposed @weekly interval is fine for me, but you may want to use a custom crontab syntax.
You can see the next run on your scheduled builds

So... there we are!

My current setup is:

  • My origin is GitHub
  • The CI / CD is on gitlab.com
  • The Docker images are built using my own Docker Gitlab-Runner
  • The images are then pushed to the Docker Hub (the only registry I use right now)

Note that since a recent version of GitLab, each repository has its own Docker registry!

This tutorial can be used regardless of your GitLab instance, whether or not your repo is a mirror and whatever your preferred registry is.

And that's why I fell in love with GitLab CI: it's pleasant to use and you can do whatever you want with it!

Stanislas
Author
Stanislas
I like building things with code and computers

Comments

29Atom feed
Markdown supported
  1. Anonymous

    Thanks for this. Very helpful.

    FYI, in section "Setting up a CI/CD pipeline", '- docker build myimage .' should probably be '- docker build -t myimage .'.

  2. Anonymous

    I am surprised you said the gitlab docs are nice, I literally got pissed off at them and came here.

  3. Anonymous

    for docker info in the gitlab yaml file iam getting following error Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?

    1. Anonymous

      I'm having the same issue... :-(

    2. Anonymous

      hi Angristan i have more errors on yml . can you have any referenece for making yml file specially. or any tutorials you have

    3. Anonymous

      I'm having the same issue, too. I noticed that there are some difference between the config.toml. Gitlab Runner running on the docker is latest and I'm sure following your tutorial step by step.

      My system info:

      • ubuntu server 18.04 LTS

      • docker version 18.06.1-ce, build e68fc7a

      EDIT: I resolved it by running following command, sudo systemctl start docker, to startup docker daemon.

    4. Anonymous

      This error could be caused by insufficient permissions. Running docker daemon is not resolving the problem, just avoiding it. You should grant permission to interact with docker to your runner. Here you can find the solution of the mentioned error: https://docs.gitlab.com/runner/install/linux-manually.html (Update, 3rd point)

  4. Anonymous

    Can you provide your Dockerfile?

    1. StanislasAuthor

      Why? This tutorial is for any image, I don't have "a" Dockerfile

  5. Anonymous

    Thanks for this article. It's a great introduction!

  6. Angadur

    Very good article angristan. I have a doubt, you are running the runner in a VM that you own? Im triying to use docker in a CI pipeline in gitlab.com, there is a way to run docker in gitlab.com without using any VM? Thanks.

  7. Anonymous

    Hi Iam getting error with starting gitlab runner:

    gitlab_runner | ERROR: Checking for jobs... forbidden runner=WWgsjUji gitlab_runner | ERROR: Checking for jobs... forbidden runner=WWgsjUji gitlab_runner | ERROR: Checking for jobs... forbidden runner=WWgsjUji gitlab_runner | ERROR: Runner https://gitlab.com/WWgsjUjiSGiYng1Vzzzc is not healthy and will be disabled!

    What did I wrong?

  8. Phuc

    Could you explain how the docker push job can be able to use a result from the docker build job ?

    1. StanislasAuthor

      It uses the name of the image you built + your docker hub login.

    2. Phuc

      I mean if we don't use cache or artifacts or something esle that keeps a built result, an image that is built from the docker build job will be lost in the docker push job

      I don't get at that point, please explain !!!

    3. StanislasAuthor

      That is because a requirement for this to work is to have your own runner with access to the Docker daemon. This the built images will be stored on the host and available between jobs.

    4. Rik

      Because it is a custom runner, provided in a container that has access to the host's docker resources (local registry). Due to this, when you run the push image command during the push stage, the image is available from the host (no need for any artifacts or cache).

  9. John

    Hi

    Create a .gitlab-ci.yml of GitLab with the following as outcome:

    The pipeline should automatically create a docker image using the Dockerfile existing in the project and push the image to PWSLab integrated Docker container registry - https://pwslab.org/mk/demo-java15/container_registry

    The docker-image after it is pushed to the above mentioned container registry should be used for deployment on to the newly created AWS EC2 instance (using login credentials shared and the pem file)

    Any changes make in the code, will automatically trigger a pipeline and deploy the code on to AWS EC2 instance, which should have a web server to run the Java application. You can use a web-server of your choice.

    Can you please help me to write this yml file I tried buy my approach was totally wrong and i have to submit by today please help me

  10. Mauricio Cano

    You totally saved my life!!! thank you so much!

  11. Anonymous

    Maybe a silly question, but: if our repository is on GitLab, why do you have to push anything to a DockerHub? I don't understand this.

  12. newskooler

    Maybe a silly question, but: if our repository is on GitLab, why do you have to push anything to a DockerHub? I don't understand this.

    1. StanislasAuthor

      Because this is the most popular place to share Docker images. And it's free.

    2. Anonymous

      You don't have to, you can use registry.gitlab.com, gcr.io, or any other registry (author mentions that at some point).

  13. Anonymous

    Big thanks. Very helpful

  14. Anonymous

    yo y u so angri