Back to blog

Running Containerized Jenkins Pipelines with Full Host Isolation via Docker-in-Docker (DinD)

Pedro Rodrigues
Pedro Rodrigues
November 2, 2024

Incorporating containerization in CI/CD pipelines has become essential, as using Docker to build and test applications helps prevent unexpected behaviors while allowing full customization over the execution environment.

However, as of today, many articles promoting the use of containerized Jenkins pipelines suggest solutions that pose significant security risks by granting unrestricted root access to the host system through Docker socket mounts. This is particularly concerning for organizations where system administrators may not have complete control over who runs what during builds.

In this post, you’ll learn how to execute Docker CLI commands through the Docker-in-Docker (DinD) daemon within your Jenkins pipelines. This approach serves as an alternative to the traditional method of creating containers on the top-level Docker host.

Why Docker-in-Docker?

  1. Host isolation πŸ”’

    • A secure TCP connection is established to grant access to the Docker daemon running within the DinD service. Running DinD as a sidecar ensures complete isolation between the host system and the Jenkins container by avoiding the common, yet very dangerous, practice of exposing the Docker socket (docker.sock) via a bind mount. The socket is owned by root, so granting access to it effectively provides unrestricted root privileges over any operations the Docker service can perform.

  2. No custom builds required βœ…

    • To eliminate the need for custom builds, Docker CLI binaries and plugins are mounted into the Jenkins container as read-only shared volumes. The official jenkins/jenkins image does not include these binaries by default.

  3. Automated Jenkins updates πŸ”„

    • By not relying on custom builds of Jenkins, we can easily automate updates based on official releases, using services designed for this purpose, such as containrrr/watchtower. Maintaining a custom image of Jenkins consistently up-to-date requires frequent builds, as updates get often released.

Setting up Jenkins with a DinD sidecar

Enough rambling, let’s jump right into the solution! Here’s the complete docker-compose.yml file:

# docker compose up -d
services:
  jenkins:
    image: jenkins/jenkins:jdk21
    environment:
      - DOCKER_HOST=tcp://docker:2376 (1)
      - DOCKER_CERT_PATH=/certs/client
      - DOCKER_TLS_VERIFY=1
    depends_on: [ dind ]
    ports: [ 8080:8080, 50000:50000 ]
    networks: [ dind-network ]
    restart: unless-stopped
    volumes:
      - jenkins-data:/var/jenkins_home
      # Read-only shared volumes (2)
      - dind-bin:/usr/local/bin/dind:ro
      - dind-plugins-bin:/usr/local/libexec/docker/cli-plugins:ro
      - dind-certs-client:/certs/client:ro
    entrypoint: /bin/sh -c 'PATH="/usr/local/bin/dind:$${PATH}" exec tini -- jenkins.sh' (3)
  dind:
    image: docker:dind
    networks:
      dind-network:
        aliases: [ docker ]
    restart: unless-stopped
    privileged: true (4)
    volumes:
      - dind-bin:/usr/local/bin
      - dind-plugins-bin:/usr/local/libexec/docker/cli-plugins
      - dind-certs-client:/certs/client
      - dind-data:/var/lib/docker
      - jenkins-data:/var/jenkins_home

volumes:
  jenkins-data:
  dind-bin:
  dind-plugins-bin:
  dind-certs-client:
  dind-data:

networks:
  dind-network:
    driver: bridge
1 Uses docker as the DinD network alias for encrypted communications with the daemon.
2 Mounts shared volumes as read-only (ro) to restrict the ability to rewrite binaries in the source container.
3 I know what you’re thinking, this does look a bit hacky, but all it does is add the DinD binaries directory to the PATH variable before starting Jenkins.
4 Enabling the privileged [1] property is necessary to allow the inner Docker daemon to function properly.

Docker Compose allows us to organize complex multi-container setups within a single, easy-to-read file. This way, users can start Jenkins with DinD by simply running the command: docker compose up -d.

Running build steps inside a DinD container

Starting a DinD container as the execution environment for your pipeline builds couldn’t be simpler!

You can use DinD in the same way as you would with the top-level Docker, but first, make sure to have the Docker Pipeline plugin installed. Jenkins will automatically start the specified container and execute the defined steps within.

Jenkinsfile (Declarative Pipeline)
pipeline {
    agent any

    stages {
        stage('Run DinD') {
            agent {
                docker {
                    image 'python:3'
                    args '--network host' (1)
                    reuseNode true (2)
                }
            }
            steps {
                sh '''python -c "import os; os.system('curl https://example.com')"''' (3)
            }
        }
    }
}
1 Helps prevent networking issues by enabling data transmission over the host network of the DinD daemon.
2 Starts the container on the same node workspace, rather than on a new node entirely.
3 Runs a small Python script that uses curl to fetch and display the content from the example.com website.

Note the --network host argument. Without it, you may run into some network issues due to the container inception going on. Bear in mind, we’re starting a new container inside the existing DinD service container.

Configuring the Pipeline Docker Label

Building and running a DinD container from a Dockerfile

For a more customized execution environment, Jenkins pipelines also support building and running a container from a Dockerfile located in the source repository. With this approach, a new image is built instead of retrieving one from a Docker registry.

Jenkinsfile (Declarative Pipeline)
pipeline {
    agent any

    stages {
        stage('Build & Run DinD') {
            agent {
                dockerfile {
                    dir './path/to/dockerfile/dir'
                    additionalBuildArgs '--network host' (1)
                    args '--network host'
                    reuseNode true
                }
            }
            steps { ... }
        }
    }
}
1 Helps prevent networking issues by enabling data transmission over the DinD daemon’s host network for RUN instructions during build.

Conclusion and future work

In this post, we discussed how to make the most of Docker-in-Docker as a sidecar container to keep Jenkins execution environments fully isolated from the host system, while eliminating the need for custom builds to include Docker binaries.

In case you’re considering transitioning your existing Jenkins setup to the one described, it’s worth checking what problems you may run into when adopting Docker-in-Docker. I highly recommend reading JΓ©rΓ΄me Petazzoni’s article [2], as it goes through the pros and cons of the various options available for running Docker from your Jenkins CI system.

Furthermore, the proposed DinD sidecar solution relies on a single host machine with limited resources available, as we opted for local agents to handle job scheduling. For larger workloads, please consider remote SSH agents or Kubernetes to achieve optimal scalability.

Future developments or improvements to this solution may involve:

  • Integrating the DinD sidecar solution in remote SSH Jenkins agents to avoid Docker socket mounts.

  • Find a way to run the DinD sidecar in rootless mode [3], despite the --privileged flag being required.

    • An attempt was made to use rootless mode, but a few runc/cgroups related issues were encountered.

About the author

Pedro Rodrigues

Pedro Rodrigues

Pedro Rodrigues is a DevOps Engineer and RedHat certified System Administrator, originally from Lisbon, Portugal. Pedro is truly passionate about building efficient, secure and maintainable software systems that challenge him to step out of his comfort zone.

Jenkins has been his go-to CI/CD automation tool since 2021, as he is a strong advocate for open-source projects.