Persisting Data with Nomad
Tags: hashicorp, nomad
Published: 2020-05-25 16:27:33

When you run stateful container with Nomad, you need to consider how to persist data between Docker containers that run on different hosts. What you want is this: Independent of where the container runs, it should have access to the same data. This sounds obvious, and yet I had severe trouble finding the right solution. I spend half a day to get either docker volume or docker bind mounts to a shared NFS dir with no success, and then another half day to systematically try all options.

This article is about the journey to achieve data persisting. You will read painful lessons learned and see the working, final solution.

_This article appeared originally at [my blog](https://admantium.com/blog/ic09_persisting_data_with_nomad/)_.

## Nomad Docker Bind Mounts

Nomad provides different mounting options at different places in the configuration. The first option is to use host volume mounts: You define a host-specific path which is mapped to any path in the Docker container. You need to provide three config stanzas:

* The Nomad agent config needs a `host_volume` config
* The job group needs a `volume` declaration
* The tasks needs a `volume_mount` declaration

Let’s see this setup, assuming we want to mount a volume in which we persists the monitoring data captured by [Prometheus](https://prometheus.io/docs/introduction/overview/).

### nomad.conf.hcl

```json
client {
  host_volume "prometheus_vol" {
    path = "/mnt/nfs/nomad/volumes/prometheus"
    read_only = false
  }
}
```

### monitoring.job.hcl

```json
group "monitoring" {
  volume "prometheus_grp" {
    type = "host"
    source = "prometheus_vol"
    read_only = false
  }
  
task "grafana" {
  volume_mount {
    volume      = "prometheus_vol"
    destination = "/prometheus"
    readonly = false
  }
  ...
}
```

## Nomad Docker Volume Mounts

The second option is to use Docker volume mounts. Volumes are named, persistent data that resides in the host in the path `/var/lib/docker/volumes`.  When the job is run, Docker will create the volume if it does not exist, or mount it [^1]. It’s the same as interacting with `docker volume create`.

```bash
>> docker volume ls

DRIVER              VOLUME NAME
local               1e9b98423c15f1aa833b131ab9f6c72b0433a212a89b5b75a30424ba917f09d9
local               prometheus_grp

>> docker volume inspect prometheus_grp
[
  {
    "CreatedAt": "2020-03-14T11:54:04Z",
    "Driver": "local",
    "Labels": null,
    "Mountpoint": "/var/lib/docker/volumes/prometheus_grp/_data",
    "Name": "prometheus_grp",
    "Options": null,
    "Scope": "local"
  }
]
```

To create a Nomad Docker volume, you need only one config stanza directly in the `docker.config` path.

### monitoring.job.hcl

```json
task "grafana" {
  driver = "docker"
  config {
    mounts = [
      {
        type = "volume"
        target = "/prometheus"
        source = "prometheus"
      }
    ]
  }
  ...
}
```

## Day 1 - Trial & Error

### Step 1: Symlink Docker Volumes to NFS

Many Docker documentations recommend to use docker volumes. I thought it would be some specific binary format, but actually it is a normal, accessible dir on your machine:

```bash
>> ls -la /var/lib/docker/volumes/prometheus_grp/_data/

total 16
drwxr-xr-x 3 nobody nogroup  4096 Mar 14 11:54 .
drwxr-xr-x 3 root   root     4096 Mar 14 11:54 ..
-rw-r--r-- 1 nobody nogroup     0 Mar 14 11:54 lock
-rw-r--r-- 1 nobody nogroup 20001 Mar 14 12:13 queries.active
drwxr-xr-x 2 nobody nogroup  4096 Mar 14 12:13 wal
```

Therefore, my first try was to set a symlink `/var/lib/docker/volumes` => `/mnt/nfs/docker/volumes`. I set the symlink on each machine, and then started spinning up Prometheus on different hosts. It did not work! I saw different data on different hosts. And all existing data was gone!

Here is why: When the Docker container starts on a different machine, and there is no Docker volume with the given name, it will create one. And because of the symlink, it will delete the target dir `/mnt/nfs/docker/volumes/prometheus` rigorously. This is the default behavior of `docker volume create` - you don’t get any warning when the target dir already exists!

Can I fix this by creating the Docker volumes manually? I deleted the symlink, deleted all docker volumes on each node, recreated the symlink and created docker volumes on all nodes. Then, I started the container on different nodes again - but Nomad overwrites the existing volume declaration rigorously.

### Second Try: Docker NFS Volumes

Ok. Let’s try to create proper Docker NFS volumes. The command I used is this:

```bash
docker volume create --driver local \
  --opt type=nfs \
  --opt o=addr=192.168.2.203,rw \
  --opt device=:/mnt/nfs/volumes/test/ nfs-test
```

When executing the following test command to mount the volume and create a file, I received an error.

```bash
docker run -v /mnt/nfs/volumes/test/:/nfs-test busybox touch /nfs-test/hello

docker: Error response from daemon: error while mounting volume '/var/lib/docker/volumes/nfs-test/_data': failed to mount local volume: mount /mnt/nfs/volumes/test:/var/lib/docker/volumes/nfs-test/_data, data: addr=192.168.2.203: invalid argument
```

For whatever reason, this directory was not writable.

### Third Try: Docker Bind Volumes

Ok, if Docker volumes will not work, I will instead mount the shared dir `/mnt/nfs/volumes/prometheus` into the container. This should work because this dir is available on all nodes. I got this solution to work, but then I was seeing a strange dir access error. Even as the root user, I could not create files inside shared directory, and also, I could not delete them!

This lead me to thinking: Maybe the problems originate from my NFS setup: autofs mounts, and an entry in `/etc/fstab` to unmount the dir when it is not used.

At this point, I spend 4 hours, and finally gave up for this day.

## Day 2 - Systematic Trial & Success

### Figuring Out What’s Wrong

What went wrong? The NFS mounts are available on the hosts, and they work. And I could get one Docker container to write to this share. So let’s assume the NFS itself works, but I could not figure out how to tell Nomad to use the network share.

First things first: Lets properly define a docker NFS volume. In a [stackoverflow question](https://stackoverflow.com/questions/45282608/how-to-directly-mount-nfs-share-volume-in-container-using-docker-compose-v3) I saw a Docker compose file for mounting NFS shares. This command uses more optional arguments then the one I have been using, so I adopted its.

I created a docker volume share with this command:

```bash
docker volume create
  --driver local
  --opt type=nfs
  --opt "o=nfsvers=4,addr=192.168.2.203,nolock,soft,rw"
  --opt device=:/mnt/nfs/volumes/test/ nfs-test
```

And then I started a docker container, mounted the volume, and put a `hello` file in the share.

```bash
docker run -v /mnt/nfs/volumes/test/:/nfs-test busybox touch /nfs-test/hello
```

That worked! Now let’s start again and systematically try the different options of mounting volumes inside Nomads `job.group.task` stanza.

### Nomad Named Volume Declaration

On each node, I removed all existing volumes, and then created the NFS volume with the working command from above.

Then, the first try is to use the named volume inside the `task.config` stanza.

```json
task "grafana" {
  driver = "docker"
  config {
    mounts = [
      {
        type = "volume"
        target = "/prometheus"
        source = "prometheus"
      }
    ]
  ...
  }
}
```

However, the job could not be executed. I saw this error message:

```bash
failed to create container: API error (500): failed to mount local volume: mount :/mnt/nfs/docker/prometheus:/var/lib/docker/volumes/prometheus/_data, data: nfsvers=4,addr=192.168.2.203,nolock,soft: no such file or directory
```

It seems that Nomad is confused with the extra options part of the Docker volume mount. I considered then to use the Docker NFS volume declaration in Nomad, but I could not find any example in the [official documentation](https://nomadproject.io/docs/drivers/docker/#inlinecode-volume_driver).

### Nomad Bind Mount Declaration

Then I tried a direct bind mount declaration.

```json
task "grafana" {
  driver = "docker"
  config {
    mounts = [
      {
        type = "bind"
        target = "/prometheus"
        source = "/mnt/nfs/docker/prometheus"
        readonly = false
        bind_options {
          propagation = "rshared"
        }
      }
    ]
  ...
  }
}
```

The Nomad job executed, and then ... something strange happened! The volume `prometheus` was suddenly an ordinary volume on the node!  Nomad overwrites the existing volume - this happened to me on day 1 too! To confirm this, I reapplied the same steps again - and yes, Nomad overwrites the existing docker volume declaration.

Before the job:

```bash
>> docker volume inspect prometheus
[
  {
    "CreatedAt": "2020-03-18T19:15:57Z",
    "Driver": "local",
    "Labels": {},
    "Mountpoint": "/var/lib/docker/volumes/prometheus/_data",
    "Name": "prometheus",
    "Options": {
        "device": ":/mnt/nfs/docker/prometheus",
        "o": "nfsvers=4,addr=192.168.2.203,nolock,soft,rw",
        "type": "nfs"
    },
    "Scope": "local"
  }
]
```

After the job:

```bash
>> docker volume inspect prometheus

[
  {
    "CreatedAt": "2020-03-18T19:17:30Z",
    "Driver": "local",
    "Labels": null,
    "Mountpoint": "/var/lib/docker/volumes/prometheus/_data",
    "Name": "prometheus",
    "Options": null,
    "Scope": "local"
  }
]
```

### Nomad Volume Declaration

Then I tried this [config.volumes](https://nomadproject.io/docs/drivers/docker/#inlinecode-volume_driver) declaration:

```json
task "grafana" {
  driver = "docker"
  config {
    image = "prom/prometheus:v2.16.0"
    volumes = [
      "local/prometheus.yml:/etc/prometheus/prometheus.yml",
      "/mnt/nfs/docker/prometheus:/prometheus"
    ]
    ...
  }
}
```

I was surprised. The Docker container started. I saw the data streaming. Then, I stopped the container, waited some time, and deployed it on another container. The data was still there! Stopping and restarting the job several times, the NFS mount steadily accumulated data from different nodes.

I got it working finally!

![prometheus_data_accumulation](https://admantium.com/images/blog/infra/ic09_prometheus_data_accumulation.png)

## Solution

The final solution for data persisting boils down to:

* Setup nfs share on nodes
* Use a `config.volumes` declaration with direct mapping between host source path and container destination path

```json
volumes = [
  "/path/to/nfs/share:/path/to/docker/destination"
]
```

No volume declaration needed. No docker volume creation on the host. Just one line per volume in your Nomad job!

## Conclusion

When you want to persist data of Docker containers running on different nodes, and have the flexibility to move containers around, then the best solution is to setup an NFS share between the nodes and use direct host to docker mounts. It was hard and painful figuring out how to achieve this. But learning from mistakes can be very educational because you learn about why things are not working. The goal was indeed worth the journey!

## Footnotes

[^1]: As I learned later, that’s not correct: It will overwrite the docker volume declaration if it is anything else then a normal volume! If you defined an NFS volume with the same name, it will get overwritten.