Last weeks article was about ways to make the docker daemon available inside a container. One of the motivations was GitLab’s CI. While the article discussed three different methods and advocated the docker-machine-based solution, it failed to explain how to actually use the methods in order to set up a GitLab runner and then actually run CI jobs with docker access. This is what this article will illustrate.
This article will show three more-or-less different implementations. The first fails to follow my advice from last week and therefore introduces a security risk. The latter two solutions are rather similar. They follow my advice from last week since they are based on docker-machine.
Targeted docker-builder image
This implementation is based on GitLab runners with the docker socket mounted
inside. In order to restrict access from the CI code to the docker daemon,
only a specific docker-builder image is allowed, which executes a fixed set of
docker commands (e.g. docker build
followed by docker push
) as the image’s
entry point.
Let’s start with the GitLab runner. Add a new runner with the gitlab-runner
command line tool to your project (or as a shared runner if this is your GitLab
instance). The executor should be docker
and we tag it with docker-builder
.
Open the runner config /etc/gitlab-runner/config.toml
and modify
the volumes
and allowed_images
in the runners
section in order to match the
following example.
[[runners]]
name = "docker-builder"
url = "https://gitlab.sauerburger.com/"
token = " * * * "
executor = "docker"
[runners.docker]
tls_verify = false
image = "ubuntu"
privileged = false
disable_cache = false
volumes = ["/cache", "/var/run/docker.sock:/var/run/docker.sock"]
shm_size = 0
allowed_images = ["gitlab.sauerburger.com:5049/frank/docker-builder:*"]
[runners.cache]
The added volume exposes the docker socket inside the container. This effectively given any CI code root access to the machine where the runner is executed. The idea is that we only allow a specific image to be executed in the runner. The image should limit access to the docker daemon.
The above runner configuration allows only one image from the newly created docker-builder repository. The image is built from the Dockerfile in the repository. The Dockerfile simply adds a custom entrypoint.
FROM docker
COPY entrypoint.sh /
ENTRYPOINT /entrypoint.sh
The
entrypoint
then runs the docker commands build
, login
and push
.
#!/bin/sh
cd ${CI_PROJECT_DIR}
docker build -t ${CI_REGISTRY_IMAGE} .
echo "${CI_REGISTRY_PASSWORD}" | docker login -u ${CI_REGISTRY_USER} --password-stdin ${CI_REGISTRY}
docker push ${CI_REGISTRY_IMAGE}
You can see that the docker entrypoint executes a predefined set of docker
commands. An example CI job in a different repository can make use of this, for
example, with the following .gitlab-ci.yml
build-myapp:
image: gitlab.sauerburger.com:5049/frank/docker-builder:latest
tags:
- docker-builder
script:
- echo "This is never executed."
Since we set the docker-builder
tag, GitLab will schedule this CI job on our
runner from above. Since
gitlab.sauerburger.com:5049/frank/docker-builder:latest
matches the
allowed_images
configuration, the GitLab runner will run our code. Here,
code refers only to the code written in entrypoint.sh
. The script body
is not executed because the entrypoint of our docker-builder image does not run
the command passed to the image.
But wait! We can overwrite the entrypoint in .gitlab-ci.yml.
Yes, indeed. Consider the following CI setup.
image-builder-mallory:
image:
name: gitlab.sauerburger.com:5049/frank/docker-builder:latest
entrypoint: ["/bin/sh", "-c"]
tags:
- docker-image-builder
script:
- docker ps
This CI job overwrites the entrypoint with a simple shell command, such that the script body is executed. If you push this configuration and thus trigger a CI pipeline, you can see all containers currently running on the host.
Running with gitlab-runner 11.0.0 (5396d320)
on image-builder 8d703628
Using Docker executor with image gitlab.sauerburger.com:5049/frank/docker-builder:latest ...
Pulling docker image gitlab.sauerburger.com:5049/frank/docker-builder:latest ...
Using docker image sha256:060696b0ae865efbe4643dba9b9f3d5fb6cd55507131a909950f2d6cac0f56e3 for gitlab.sauerburger.com:5049/frank/docker-builder:latest
Running on runner-8d703628-project-124-concurrent-0 via host...
Fetching changes...
HEAD is now at c2d679a Add Mallory job
Checking out c2d679a6 as master...
Skipping Git submodules setup
$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
ba55ca8012ef 060696b0ae86 "/bin/sh -c sh -c ..." 1 second ago Up Less than a second 2375/tcp runner-8d703628-project-124-concurrent-0-build-4
Job succeeded
By now, it should be clear that everyone who can add code to a repository has root access to your machine. This issue has been reported at GitLab, but it doesn’t look like there will be a solution in the near future.
To summarize, don’t do this.
docker+machine runner with docker socket
In order to have a more secure setup, I suggest using
docker-machine in combination with
GitLab runners. Let’s create a new runner with the docker+machine
executor and
the docker
tag. Make sure that MachineDriver
is set to "virtualbox"
and
that you use a unique value for MachineName
the runner configuration file
/etc/gitlab-runner/config.toml
.
So far, our new runner would run docker images inside the VM
set up by docker-machine, but we would not have access to docker from the CI
code. The simplest method to do this is to mount the docker socket
by adding "/var/run/docker.sock:/var/run/docker.sock"
to the volumes list
as shown in the previous section.
The important difference here is that we expose the docker daemon
of the VM and not the docker daemon of the host system. This sill bears the
security issues, that a CI job could infect the VM itself and then
infer with all subsequent jobs run in that VM. The solution is simply to allow
only a single CI job per VM before it is deleted. This is what I refer to as
disposable VMs. The runner configuration allows you to set this up with the
MaxBuilds = 1
option. The relevant section in the configuration file should look
as follows.
[[runners]]
name = "docker-machine"
url = "https://gitlab.sauerburger.com"
token = " * * * "
executor = "docker+machine"
[runners.docker]
tls_verify = false
image = "ubuntu"
privileged = false
disable_cache = false
volumes = ["/cache", "/var/run/docker.sock:/var/run/docker.sock"]
shm_size = 0
[runners.cache]
[runners.machine]
IdleCount = 0
MachineDriver = "virtualbox"
MachineName = "%s.myrunner.yourdomain"
OffPeakTimezone = ""
OffPeakIdleCount = 0
OffPeakIdleTime = 0
MaxBuilds = 1
A CI job can then directly access the docker socket and use it to build images and push them to the registry.
image-builder-machine:
image: docker
tags:
- docker
script:
- docker build -t ${CI_REGISTRY_IMAGE} .
- echo "${CI_REGISTRY_PASSWORD}" | docker login -u ${CI_REGISTRY_USER} --password-stdin ${CI_REGISTRY}
- docker push ${CI_REGISTRY_IMAGE}
It doesn’t matter if the CI code accidentally or purpusefully misuses the docker daemon since it’s actions are confined to the VM. Because we have mounted the docker socket and thus gave full access over the daemon to the CI code, the CI job could kill its own container.
docker+machine runner with docker-in-docker
Alternatively, instead of mounting the docker socket, you could also use
docker-in-docker. For this, we need to start a
privileged container inside the VM. Also, to make sure that every CI job has a
fresh and clean VM, we set MaxBuilds
to one.
[[runners]]
name = "docker-machine"
url = "https://gitlab.sauerburger.com"
token = " * * * "
executor = "docker+machine"
[runners.docker]
tls_verify = false
image = "ubuntu"
privileged = true
disable_cache = false
volumes = ["/cache"]
shm_size = 0
[runners.cache]
[runners.machine]
IdleCount = 0
MachineDriver = "virtualbox"
MachineName = "%s.myrunner.yourdomain"
OffPeakTimezone = ""
OffPeakIdleCount = 0
OffPeakIdleTime = 0
MaxBuilds = 1
The configuration for a CI job is slightly more involved and adds a few lines of
boilerplate code to the .gitlab-ci.yml
.
In order to use docker-in-docker in a CI job, we need to run a service and link
to it. Consider the following example.
image-builder-dind:
image: docker
tags:
- docker
services:
- name: docker:dind
alias: docker-in-docker
variables:
DOCKER_HOST: tcp://docker-in-docker:2375/
script:
- docker build -t ${CI_REGISTRY_IMAGE} .
- echo "${CI_REGISTRY_PASSWORD}" | docker login -u ${CI_REGISTRY_USER} --password-stdin ${CI_REGISTRY}
- docker push ${CI_REGISTRY_IMAGE}
When the GitLab runner executes this job inside the VM, it first starts a
privileged container: then docker-in-docker container. This container exposes
its docker socket on port 2375
. The runner then starts a second container in
which the script
part is executed. We can tell the docker client to connect to
the dind-daemon in the other container by setting the DOCKER_HOST
variable
tcp://docker-in-docker:2375/
.
The CI job does not show up in the docker ps
because of the separation
introduced by docker-in-docker. This means that it is not possible for
a CI job to kill its own container by accident.
This might also interest you