# Docker and Ansible ## Context Docker is another fabulous tool for System Administration. Docker lets you isolate system processes. Rather than simulating a whole other system, like with a VM, you can replicate the function of a different operating system. Docker is an example of "containerization." It's far from the only solution, but it's a solid product that can get you started building things faster. Instead of setting up a new server, you download a tiny image of a root filesystem, and boot it up with processes isolated from your host system using cgroups. For Wizardlab, this means we can deploy a variety of servers onto the one `services` host. Mix or match Operating Systems, network things together, take over host network ports, and script out the whole thing in easy human readable configuration files. It's lovely. I'll be setting up Docker and the Docker Compose plugin. `Docker compose` to Docker as ansible-playbook is to ansible. It's another level of automaion. The `docker` command runs the file called `Dockerfile` inside ${PWD} while `docker compose` will look for a file named `docker-compose.yml` (.yaml works too!). ## The Docker role Here's how I decided to design a "docker" role for Wizardlab. By no means is this *the* way to design something like this. Rather, this is how I decided to practice writing Ansible configuration in the context of automating docker setup on debian. I decided to have the docker role only install Docker by default, without the docker compose plugin. I am doing this to demonstrate the use of tags, and I would probably delete all the tag stuff if I was using this at work. ### The Process I create a new role under `playbooks/roles/docker`, and I setup file, template, and task subdirectories, utilizing BASH expansion. ``` $ mkdir -p playbooks/roles/docker/{files,tasks,templates} ``` I create a all-powerful playbook file under `/playbooks` that will drive the rest of the files: ``` $ vi playbooks/docker.yml ``` This playbook specifies Hosts\groups that I want to target, and imports the role I want in the proper order. I use 'roles' to run the 'infra' role which will give me DHCP services on `services` before I setup Docker if I give run the playbook without tags. When I run with the `--tag docker` option, I should get docker setup without installing dhcp. ``` --- - name: "Setup Docker" hosts: services become: yes tasks: - name: include infra ansible.builtin.include_role: name: infra - name: include docker ansible.builtin.include_role: name: docker tags: - docker - always ``` Create a `main.yml` file under playbooks/docker/tasks/: ``` $ vi playbooks/roles/docker/tasks/main.yml ``` This file will pull in the task files that we design to setup docker. For this design, I will put it all in two task files, so I can decide whether I want Docker only, or both Docker and the docker compose plugin. ``` - name: Import the tasks that install just Docker ansible.builtin.include_tasks: file: "setup_docker.yml" apply: tags: - always - docker tags: - always - name: Import the tasks that install Docker Compose ansible.builtin.include_tasks: file: "setup_compose.yml" apply: tags: - compose tags: - compose ``` I want the docker compose task to not run when I specify only the 'docker' tag, so I give the second include only the `compose` tag, and the compose tasks will run if I do not specify tags, or if I specify `--tags docker,compose`. This is a way to setup "defaults" for what tasks a role will include, but allow you to switch it up if you need to with the ansible-playbook `--tags` option. Tags are pretty neat. Next, I create the two task files: ``` $ vi playbooks/roles/docker/tasks/setup_docker.yml ``` entering in the necessary tasks, I used this page for the recommended approach: [docs.docker.com/engine/install/debian/](https://docs.docker.com/engine/install/debian/) ``` - name: remove conflicting packages ansible.builtin.shell: cmd: "for pkg in docker.io docker-doc docker-compose podman-docker containerd runc; \ do sudo apt-get remove $pkg; done" - name: ensure I have the dependency packages to setup the repo ansible.builtin.apt: pkg: - ca-certificates - curl - gnupg state: present - name: remove the old gpg key if it already exists ansible.builtin.file: path: "/etc/apt/keyrings/docker.gpg" state: absent - name: grab the official GPG key ansible.builtin.shell: cmd: "install -m 0755 -d /etc/apt/keyrings; \ curl -fsSL https://download.docker.com/linux/debian/gpg | \ gpg --dearmor -o /etc/apt/keyrings/docker.gpg" - name: remove the old repo file ansible.builtin.file: path: "/etc/apt/sources.list.d/docker.list" state: absent - name: setup the repository ansible.builtin.shell: cmd: "echo 'deb [arch=amd64 signed-by=/etc/apt/keyrings/docker.gpg] \ https://download.docker.com/linux/debian bookworm stable' | \ tee /etc/apt/sources.list.d/docker.list > /dev/null" - name: update the app repo ansible.builtin.shell: "apt update" - name: install docker packages ansible.builtin.apt: pkg: - docker-ce - docker-ce-cli - containerd.io - docker-buildx-plugin - name: Celebrate docker tasks being finished ansible.builtin.debug: msg: "Docker setup tasks finished" ``` Next, a tiny task file that just installs the one package for docker compose: ``` $ vi playbooks/roles/docker/tasks/setup_compose.yml ``` and enter a few lines: ``` - name: Install the docker compose plugin ansible.builtin.apt: pkg: docker-compose-plugin - name: Celebrate the docker compose task running ansible.builtin.debug: msg: "docker compose task" ``` Let's test it out: ``` $ vagrant ssh control ``` Now I am connected to control, let's go to the /vagrant/ folder to pickup our latest edits Note: Vagrant copies the /playbook directory to ~ at provisioning. If you start the provisioning before building playbooks, you'll want to move over to /vagrant ``` $ cd /vagrant ``` let's activate the ansible Python virtual environment (venv) in our home dirctory: ``` $ source ~/ansible/bin/activate ``` Next, run the playbook and just install docker! ``` (ansible)$ ansible-playbook -i inventory --tags docker playbooks/docker.yml ``` The results: ``` PLAY [Setup Docker] ************************************************************************************* TASK [Gathering Facts] ********************************************************************************** ok: [services] TASK [include docker] *********************************************************************************** TASK [docker : Import the tasks that install just Docker] *********************************************** included: /vagrant/playbooks/roles/docker/tasks/setup_docker.yml for services TASK [docker : remove conflicting packages] ************************************************************* changed: [services] TASK [docker : ensure I have the dependecy packages to setup the repo] ********************************** changed: [services] TASK [docker : remove the old gpg key if it already exists] ********************************************* ok: [services] TASK [docker : grab the official GPG key] *************************************************************** changed: [services] TASK [docker : remove the old repo file] **************************************************************** ok: [services] TASK [docker : setup the repository] ******************************************************************** changed: [services] TASK [docker : update the apt database] ***************************************************************** changed: [services] TASK [docker : install docker packages] ***************************************************************** changed: [services] TASK [docker : Celebrate docker tasks being finished] *************************************************** ok: [services] => { "msg": "Docker setup tasks finished" } PLAY RECAP ********************************************************************************************** services : ok=11 changed=6 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 ``` Now, to add the compose plugin by including the `--tag compose` option. Both task files run now, yet Ansible only needs to apply the single task from the `setup_compose.yml` include in `playbooks/docker.yml`: ``` (ansible)$ ansible-playbook -i inventory --tags compose playbooks/docker.yml ``` Results: ``` PLAY [Setup Docker] ************************************************************************************* TASK [Gathering Facts] ********************************************************************************** ok: [services] TASK [include docker] *********************************************************************************** TASK [docker : Import the tasks that install just Docker] *********************************************** included: /vagrant/playbooks/roles/docker/tasks/setup_docker.yml for services TASK [docker : remove conflicting packages] ************************************************************* changed: [services] TASK [docker : ensure I have the dependecy packages to setup the repo] ********************************** ok: [services] TASK [docker : remove the old gpg key if it already exists] ********************************************* changed: [services] TASK [docker : grab the official GPG key] *************************************************************** changed: [services] TASK [docker : remove the old repo file] **************************************************************** changed: [services] TASK [docker : setup the repository] ******************************************************************** changed: [services] TASK [docker : update the apt database] ***************************************************************** changed: [services] TASK [docker : install docker packages] ***************************************************************** ok: [services] TASK [docker : Celebrate docker tasks being finished] *************************************************** ok: [services] => { "msg": "Docker setup tasks finished" } TASK [docker : Import the tasks that install Docker Compose] ******************************************** included: /vagrant/playbooks/roles/docker/tasks/setup_compose.yml for services TASK [docker : Install the docker compose plugin] ******************************************************* ok: [services] TASK [docker : Celebrate the docker compose task running] *********************************************** ok: [services] => { "msg": "docker compose task" } PLAY RECAP ********************************************************************************************** services : ok=14 changed=6 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 ``` Next, I pull the ole' Test Kitchen trick, and show you what happens if I run the last command on a fresh VM. We'll need to setup a clean uninstall playbook later... Notice, both task lists execute: ``` (ansible)$ ansible-playbook -i inventory --tags compose playbooks/docker.yml ``` Results: ``` PLAY [Setup Docker] ************************************************************************************* TASK [Gathering Facts] ********************************************************************************** ok: [services] TASK [include infra] ************************************************************************************ TASK [infra : Import tasks to setup isc-dhcp-server by default] ***************************************** included: /vagrant/playbooks/roles/infra/tasks/dhcp_conf.yml for services TASK [infra : Deploying dhcpd.conf] ********************************************************************* changed: [services] TASK [infra : copy dhcp defaults (eth1 and ipv4 only)] ************************************************** changed: [services] TASK [infra : install dependencies] ********************************************************************* changed: [services] TASK [infra : Alternatively, setup Kea in a memfile configuration] ************************************** included: /vagrant/playbooks/roles/infra/tasks/kea-memfile.yml for services TASK [include docker] *********************************************************************************** TASK [docker : Import the tasks that install just Docker] *********************************************** included: /vagrant/playbooks/roles/docker/tasks/setup_docker.yml for services TASK [docker : remove conflicting packages] ************************************************************* changed: [services] TASK [docker : ensure I have the dependecy packages to setup the repo] ********************************** changed: [services] TASK [docker : remove the old gpg key if it already exists] ********************************************* ok: [services] TASK [docker : grab the official GPG key] *************************************************************** changed: [services] TASK [docker : remove the old repo file] **************************************************************** ok: [services] TASK [docker : setup the repository] ******************************************************************** changed: [services] TASK [docker : update the apt database] ***************************************************************** changed: [services] TASK [docker : install docker packages] ***************************************************************** changed: [services] TASK [docker : Celebrate docker tasks being finished] *************************************************** ok: [services] => { "msg": "Docker setup tasks finished" } TASK [docker : Import the tasks that install Docker Compose] ******************************************** included: /vagrant/playbooks/roles/docker/tasks/setup_compose.yml for services TASK [docker : Install the docker compose plugin] ******************************************************* ok: [services] TASK [docker : Celebrate the docker compose task running] *********************************************** ok: [services] => { "msg": "docker compose task" } PLAY RECAP ********************************************************************************************** services : ok=19 changed=9 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 ``` ### Testing the Installed Docker Engine To test the newly installed Docker engine, the installation instructions suggest running: ``` $ sudo docker run hello-world ``` Results: ``` Unable to find image 'hello-world:latest' locally latest: Pulling from library/hello-world 719385e32844: Pull complete Digest: sha256:dcba6daec718f547568c562956fa47e1b03673dd010fe6ee58ca806767031d1c Status: Downloaded newer image for hello-world:latest Hello from Docker! This message shows that your installation appears to be working correctly. To generate this message, Docker took the following steps: 1. The Docker client contacted the Docker daemon. 2. The Docker daemon pulled the "hello-world" image from the Docker Hub. (amd64) 3. The Docker daemon created a new container from that image which runs the executable that produces the output you are currently reading. 4. The Docker daemon streamed that output to the Docker client, which sent it to your terminal. To try something more ambitious, you can run an Ubuntu container with: $ docker run -it ubuntu bash Share images, automate workflows, and more with a free Docker ID: https://hub.docker.com/ For more examples and ideas, visit: https://docs.docker.com/get-started/ ``` ### Achieving Idempotency When we ran the playbook with `--tags compose`, the play went through the whole apt repo setup process; this slowed things down a little bit. Idealy, the run that installed compose should not have had to change any of those options since the repo was already installed. Since we have a working Docker engine with the necessary `docker.list` file on the `services` host, grab that file and send it over to `control`: ``` $ scp /etc/apt/sources.list.d/docker.list \ vagrant@control:/vagrant/playbooks/roles/docker/files/docker.list ``` Let's make better use of the apt module, and check for the repo configuration inside a `block`, and put the repo setup tasks inside a `rescue` block. Now, I'll have better "idempotency," and I get better performance. playbooks/roles/docker/tasks/setup_docker.yml ``` - name: remove conflicting packages ansible.builtin.apt: pkg: - docker.io - docker-doc - docker-compose - podman-docker - containerd - runc state: absent - name: install the docker repo only if needed block: - name: check if the we have the repo already configured ansible.builtin.stat: path: "/etc/apt/sources.list.d/docker.list" register: docker_repo_stat - name: fail if docker.list isn't configured. ansible.builtin.fail: msg: "docker repo is not configured" when: docker_repo_stat["stat"]["exists"]==false rescue: - name: ensure I have the dependecy packages to setup the repo ansible.builtin.apt: pkg: - ca-certificates - curl - gnupg state: present - name: remove the old gpg key if it already exists ansible.builtin.file: path: "/etc/apt/keyrings/docker.gpg" state: absent - name: grab the official GPG key ansible.builtin.shell: cmd: "install -m 0755 -d /etc/apt/keyrings; \ curl -fsSL https://download.docker.com/linux/debian/gpg | \ gpg --dearmor -o /etc/apt/keyrings/docker.gpg" - name: configure docker repo ansible.builtin.copy: src: "docker.list" dest: "/etc/apt/sources.list.d/docker.list" - name: install docker packages ansible.builtin.apt: pkg: - docker-ce - docker-ce-cli - containerd.io - docker-buildx-plugin update_cache: true state: present - name: Celebrate docker tasks being finished ansible.builtin.debug: msg: "Docker setup tasks finished" ``` The stat module grabs a variable, and we inspect this with the fail module. Because the `/etc/apt/sources.list.d/docker.list` file isn't in place, the block fails. We use the copy module to place the `docker.list` file where it needs to go, and we install the gpg key. Setting things up this way ensure that it will happen only once. Future runs will not touch the gpg key or `/etc/apt/sources.list.d/` directory. What it looks like when we run `ansible-playbook -i inventory --tags docker playbooks/docker.yml` repeatedly: ``` PLAY [Setup Docker] ************************************************************************************* TASK [Gathering Facts] ********************************************************************************** ok: [services] TASK [include docker] *********************************************************************************** TASK [docker : Import the tasks that install just Docker] *********************************************** included: /vagrant/playbooks/roles/docker/tasks/setup_docker.yml for services TASK [docker : remove conflicting packages] ************************************************************* ok: [services] TASK [docker : check if the we have the repo already configured] **************************************** ok: [services] TASK [docker : fail if docker.list isn't configured.] *************************************************** skipping: [services] TASK [docker : install docker packages] ***************************************************************** ok: [services] TASK [docker : Celebrate docker tasks being finished] *************************************************** ok: [services] => { "msg": "Docker setup tasks finished" } PLAY RECAP ********************************************************************************************** services : ok=6 changed=0 unreachable=0 failed=0 skipped=1 rescued=0 ignored=0 ``` Note: all the tasks return `ok` or `skipped`. That's idempotency! Going further, I could make a template for `docker.list`, so it can generate the right file for multiple debian-based configurations. (Not just Debian Bookworm on amd64). ## Setting up a docker_lab target to Wizardlab's Makefile Adding a target to our make file is as simple as adding these two lines: ``` docker_lab: eval `ssh-agent` && ssh-add ~/.ssh/id_rsa && . ~/ansible/bin/activate && ansible-playbook -i inventory /vagrant/playbooks/docker.yml ``` Now, I can start up the Wizardlab with `vagrant up`, connect to control with `vagrant ssh control`, run `make keys`, and make `docker_lab`, and I should have everything in `infra` role setup with docker and the compose plugin. ``` $ make docker_lab ``` Results: ``` eval `ssh-agent` && ssh-add ~/.ssh/id_rsa && . ~/ansible/bin/activate && ansible-playbook -i inventory /vagrant/playbooks/docker.yml Agent pid 16492 Identity added: /home/vagrant/.ssh/id_rsa (localhost) PLAY [Setup Docker] ************************************************************************************* TASK [Gathering Facts] ********************************************************************************** ok: [services] TASK [include infra] ************************************************************************************ TASK [infra : Import tasks to setup isc-dhcp-server by default] ***************************************** included: /vagrant/playbooks/roles/infra/tasks/dhcp_conf.yml for services TASK [infra : Deploying dhcpd.conf] ********************************************************************* changed: [services] TASK [infra : copy dhcp defaults (eth1 and ipv4 only)] ************************************************** changed: [services] TASK [infra : install dependencies] ********************************************************************* changed: [services] TASK [infra : Alternatively, setup Kea in a memfile configuration] ************************************** included: /vagrant/playbooks/roles/infra/tasks/kea-memfile.yml for services TASK [include docker] *********************************************************************************** TASK [docker : Import the tasks that install just Docker] *********************************************** included: /vagrant/playbooks/roles/docker/tasks/setup_docker.yml for services TASK [docker : remove conflicting packages] ************************************************************* ok: [services] TASK [docker : check if the we have the repo already configured] **************************************** ok: [services] TASK [docker : fail if docker.list isn't configured.] *************************************************** fatal: [services]: FAILED! => {"changed": false, "msg": "docker repo is not configured"} TASK [docker : ensure I have the dependecy packages to setup the repo] ********************************** changed: [services] TASK [docker : remove the old gpg key if it already exists] ********************************************* ok: [services] TASK [docker : grab the official GPG key] *************************************************************** changed: [services] TASK [docker : configure docker repo] ******************************************************************* changed: [services] TASK [docker : install docker packages] ***************************************************************** changed: [services] TASK [docker : Celebrate docker tasks being finished] *************************************************** ok: [services] => { "msg": "Docker setup tasks finished" } TASK [docker : Import the tasks that install Docker Compose] ******************************************** included: /vagrant/playbooks/roles/docker/tasks/setup_compose.yml for services TASK [docker : Install the docker compose plugin] ******************************************************* ok: [services] TASK [docker : Celebrate the docker compose task running] *********************************************** ok: [services] => { "msg": "docker compose task" } PLAY RECAP ********************************************************************************************** services : ok=18 changed=7 unreachable=0 failed=0 skipped=0 rescued=1 ignored=0 ``` With those few commands, I have a fresh setup to practice building something new on top of docker! ## Docker in Wizardlab Now that we have a working Docker role, I can assemble configuration that is built on Docker containers. Docker makes managing complex services simpler by bundling the automation of multiple containers under a single `docker compose` command. When I set out to build Wizardlab, I decided that I would want docker for certain services: - Nginx: HTTPS proxy, so we can simply certificate management - Bind9: authoritative DNS - Unbound: recoursive DNS lookup, and DNS proxy - Kea: Next generation DHCP server from ISC - ISC's Stork, for managing Kea and Bind - FreeIPA: Kerberos, LDAP, host-based access control, Certificate and Key management - All-in-one Openstack with StackHPC's Kayobe - Gitlab, Gitea: Options for version control and CI/CD automation - Jenkins: More options for CI/CD - Wordpress: Popular CMS. Not everyone wants to blog with `vi` and sphinx static-page pipeline. Building these services in Docker allows me to diversify my code-base: branching into several public facing projects.I can build services that can translate directly to anywhere Docker engine is installed. In the Wizardlab playbooks, I can instruct `services` to `git clone` a repository for a service that runs on top of Docker, build the containers, and start the services with one `docker compose up -d` command.