newline

TIL: you can manage Docker containers with Terraform

TIL, Shell

December 10, 2024

Did you know you could use Terraform to create and manage Docker containers? Yes, there’s Docker Compose, but I wanted a place to practice working with Terraform. And actually, after trying it out with Docker, I might even prefer it over Docker Compose, at least for simple-ish use cases. In this post I’ll walk you through a basic two-container setup that supports systemd, using Terraform and a custom image.

Let’s say we want to create two containers, which will have a systemd service to ping each other. I’m assuming you know Docker, Terraform file syntax, have Docker and Terraform installed, and are comfortable working on the command line; I won’t explain these things here. I’m working on Ubuntu 22.04 LTS, GNU/Linux.

First, let’s create a Dockerfile that installs ping and allows us to use systemd:

FROM robertdebock/debian
RUN apt update && apt install -y inetutils-ping

And now to the Terraform part. We’ll create and work in the file main.tf, placed next to the Dockerfile from above. Let’s first configure the provider:

terraform {
  required_providers {
    docker = {
      source = "kreuzwerker/docker"
      version = "~> 3.0.1"
    }
  }
}

provider "docker" {}

This gives our terraform the ability to manage Docker containers.

Let’s define the image to build as a resource:

resource "docker_image" "pinger_systemd" {
  name = "pinger-systemd"
  build {
    context = "."
    tag = ["pinger-systemd:latest"]
  }
}

This creates a resource with the name pinger_systemd of type docker_image (provided by kreuzwerker/docker). The image name is pinger-systemd, and we instruct it to build with a Dockerfile in the current directory, tagging it as pinger-systemd:latest.

Let’s create a network for the containers:

resource "docker_network" "ping_net" {
  name = "ping_net"
  ipam_config {
    subnet = "10.21.21.0/24"
  }
}

This creates a docker network resource named ping_net, the network will have the name ping_net, and hosts on it will fall in the CIDR range of 10.21.21.0/24.

And now to create the containers themselves:

resource "docker_container" "pinger_a" {
  # use the image we built; get its id from the resource we defined
  image = docker_image.pinger_systemd.image_id

  # the name of the container
  name = "pinger-a"

  # the hostname of the container
  hostname = "pingera"

  # connect it to the bridge network and to our custom network
  network_mode = "bridge"
  networks_advanced {
    name = "bridge"
  }
  networks_advanced {
    # get the network name from the resource we created
    name = docker_network.ping_net.name
    # use an ip address in the cidr range
    ipv4_address = "10.21.21.2"
  }

  # mount the current directory as a bind mount just in case we want to easily edit things
  mounts {
    target = "/opt/cwd"
    source = "/home/me/Documents/terraform-docker-example"
    type = "bind"
  }

  # enable systemd
  privileged = true
  volumes {
    host_path = "/sys/fs/cgroup"
    container_path = "/sys/fs/cgroup"
  }
  cgroupns_mode = "host"
}
resource "docker_container" "pinger_b" {
  # use the image we built; get its id from the resource we defined
  image = docker_image.pinger_systemd.image_id

  # the name of the container
  name = "pinger-b"

  # the hostname of the container
  hostname = "pingerb"

  # connect it to the bridge network and to our custom network
  network_mode = "bridge"
  networks_advanced {
    name = "bridge"
  }
  networks_advanced {
    # get the network name from the resource we created
    name = docker_network.ping_net.name
    # use an ip address in the cidr range
    ipv4_address = "10.21.21.3"
  }

  # mount the current directory as a bind mount just in case we want to easily edit things
  mounts {
    target = "/opt/cwd"
    source = "/home/me/Documents/terraform-docker-example"
    type = "bind"
  }

  # enable systemd
  privileged = true
  volumes {
    host_path = "/sys/fs/cgroup"
    container_path = "/sys/fs/cgroup"
  }
  cgroupns_mode = "host"
}

Run terraform init to initialize terraform, creating ./.terraform/.

Then, terraform apply, read through the changes, and type yes to apply. This will take a bit, because it has to download and create the image.

When that’s done, you can do docker ps and see that the containers are running.

Let’s try to create a systemd service in one of the containers.

In the current directory, create a service file:

[Unit]
Description=Ping b at 10.21.21.3

[Service]
ExecStart=ping 10.21.21.3

Enter the pinger-a container by running docker exec -it pinger-a bash. Then, copy over the service file from the mounted directory:

cp /opt/cwd/pingb.service /lib/systemd/system/

And start the service:

systemctl daemon-reload
systemctl start pingb

And if you check the status, you’ll see that the service is running:

root@pingera:/# systemctl status pingb
● pingb.service - Ping b at 10.21.21.3
     Loaded: loaded (/lib/systemd/system/pingb.service; static)
     Active: active (running) since Tue 2024-12-10 14:02:11 UTC; 3s ago
   Main PID: 86 (ping)
      Tasks: 1 (limit: 2782)
     Memory: 236.0K
        CPU: 1ms
     CGroup: /system.slice/docker-c7fe2a0871f0dd370419e1c246c1d0da529a57d6d8984c7e283448b12cfffbae.scope/system.slice/pingb.service
             └─86 ping 10.21.21.3

Dec 10 14:02:11 pingera systemd[1]: Started pingb.service - Ping b at 10.21.21.3.
Dec 10 14:02:11 pingera ping[86]: PING 10.21.21.3 (10.21.21.3): 56 data bytes
Dec 10 14:02:11 pingera ping[86]: 64 bytes from 10.21.21.3: icmp_seq=0 ttl=64 time=0.047 ms
Dec 10 14:02:12 pingera ping[86]: 64 bytes from 10.21.21.3: icmp_seq=1 ttl=64 time=0.070 ms
Dec 10 14:02:13 pingera ping[86]: 64 bytes from 10.21.21.3: icmp_seq=2 ttl=64 time=0.080 ms

If you want to check in more detail, you can install tcpdump in the pinger-b container, inspect the traffic on the interface connected to the docker network we defined, and you’ll see the incoming pings:

root@pingerb:/# tcpdump -i eth1 icmp
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on eth1, link-type EN10MB (Ethernet), snapshot length 262144 bytes
14:04:54.385465 IP pinger-a.ping_net > pinger_b: ICMP echo request, id 86, seq 163, length 64
14:04:54.385514 IP pinger_b > pinger-a.ping_net: ICMP echo reply, id 86, seq 163, length 64
14:04:55.386809 IP pinger-a.ping_net > pinger_b: ICMP echo request, id 86, seq 164, length 64

For more information and config options, here’s the documentation for the Terraform Docker provider.