Volume subpath in docker compose

I may not be understanding something regarding the new subpath directive. My objective is to provide different containers a sub-directory of a named volume.

I created the sub-directory prior to ‘docker compose up’.
mkdir /var/lib/docker/volumes/my_vol/_data/tftp

services:
  file-upload:
    image: docker-file-upload
    container_name: docker-file-upload
    volumes:
      - type: volume
        source: my_vol
        target: /var/www/upload/server/php/chroot/files
        volume:
          subpath: tftp
volumes:
  my_vol:
    external: true
[root@portainer10 tmp]# docker compose -f compose.yml up
Attaching to docker-file-upload
Error response from daemon: cannot access path /var/lib/docker/volumes/my_vol/_data/tftp: lstat /var/lib/docker/volumes/my_vol/_data/tftp: no such file or directory
[root@portainer10 tmp]#

Can someone please point out where I am mistaken?

[root@portainer10 tmp]# docker info
Client: Docker Engine - Community
 Version:    26.0.1
 Context:    default
 Debug Mode: false
 Plugins:
  buildx: Docker Buildx (Docker Inc.)
    Version:  v0.13.1
    Path:     /usr/libexec/docker/cli-plugins/docker-buildx
  compose: Docker Compose (Docker Inc.)
    Version:  v2.26.1
    Path:     /usr/libexec/docker/cli-plugins/docker-compose

Server:
 Containers: 50
  Running: 9
  Paused: 0
  Stopped: 41
 Images: 94
 Server Version: 26.0.1
 Storage Driver: overlay2
  Backing Filesystem: xfs
  Supports d_type: true
  Using metacopy: false
  Native Overlay Diff: true
  userxattr: false
 Logging Driver: json-file
 Cgroup Driver: systemd
 Cgroup Version: 2
 Plugins:
  Volume: local
  Network: bridge host ipvlan macvlan null overlay
  Log: awslogs fluentd gcplogs gelf journald json-file local splunk syslog
 Swarm: inactive
 Runtimes: io.containerd.runc.v2 runc
 Default Runtime: runc
 Init Binary: docker-init
 containerd version: e377cd56a71523140ca6ae87e30244719194a521
 runc version: v1.1.12-0-g51d5e94
 init version: de40ad0
 Security Options:
  seccomp
   Profile: builtin
  cgroupns
 Kernel Version: 5.14.0-362.24.1.el9_3.0.1.x86_64
 Operating System: Rocky Linux 9.3 (Blue Onyx)
 OSType: linux
 Architecture: x86_64
 CPUs: 4
 Total Memory: 15.37GiB
 Name: portainer10
 ID: ce6a111f-7982-43e5-b4f6-a65fbc08794e
 Docker Root Dir: /var/lib/docker
 Debug Mode: false
 Experimental: true
 Insecure Registries:
  127.0.0.0/8
 Live Restore Enabled: false
 Default Address Pools:
   Base: 172.17.0.0/16, Size: 16
   Base: 172.18.0.0/16, Size: 16
   Base: 172.19.0.0/16, Size: 16
   Base: 172.20.0.0/14, Size: 16
   Base: 172.24.0.0/14, Size: 16
   Base: 172.28.0.0/14, Size: 16
   Base: 2001:db8::/104, Size: 112

[root@portainer10 tmp]#
1 Like

I tried a while ago, and it doesn’t seem to work with local or bind volume. But it works like a charm with nfsv4. See: Specifying top-level and service-level volumes in compose file - #2 by meyay

Update: related Github Issue → New volume mount subpath - does not create sub directory if it doesn't ee · Issue #47842 · moby/moby · GitHub

I played around a little:

Something like this should work if my_vol is a non-parameterized named volume:

services:
  init-subpath:
    image: "alpine:latest"
    restart: "no"
    command: |
      mkdir -p /mnt/tftp/
    volumes:
      - type: volume
        source: my_vol
        target: /mnt

  file-upload:
    image: docker-file-upload
    container_name: docker-file-upload
    depends_on:
      init-subpath:
        condition: service_completed_successfully
    volumes:
      - type: volume
        source: my_vol
        target: /var/www/upload/server/php/chroot/files
        volume:
          subpath: tftp
volumes:
  my_vol:
    external: true

Though, it will not work, if my_vol is a parameterized named volume of type: bind. Declaring a subpath will continue to raise an error.

1 Like

Thanks, meyay. I got it working after converting from local volume to NFS volume. This workaround is not ideal but acceptable in the short term. I do not fully understand your 2nd post. Are you writing that local volumes will work under certain configurations?

It depends…

Using a non-parameterized named volume works:

services:
  init_volumes:
    image: alpine
    restart: no
    command: mkdir -p /mnt/sub1 /mnt/sub2
    volumes:
      - type: volume
        source: subpath-test
        target: /mnt

  test:
    image: alpine
    depends_on:
      init_volumes:
        condition: service_completed_successfully
    command: ls -lR /mnt
    volumes:
      - type: volume
        source: subpath-test
        target: /mnt/sub1
        volume:
          subpath: sub1
      - type: volume
        source: subpath-test
        target: /mnt/sub2
        volume:
          subpath: sub2
volumes:
  subpath-test: {}

Using a parameterized named volume of type bind doesn’t work:

services:
  init_volumes:
    image: alpine
    restart: no
    command: mkdir -p /mnt/sub1 /mnt/sub2
    volumes:
      - type: volume
        source: bind-subpath-test
        target: /mnt

  test:
    image: alpine
    depends_on:
      init_volumes:
        condition: service_completed_successfully
    command: ls -lR /mnt
    volumes:
      - type: volume
        source: bind-subpath-test
        target: /mnt/sub1
        volume:
          subpath: sub1
      - type: volume
        source: bind-subpath-test
        target: /mnt/sub2
        volume:
          subpath: sub2
volumes:
  bind-subpath-test:
    driver_opts:
      type: local
      o: bind
      device: /home/me/test/bind-subpath-test

Note: device needs to point to an existing folder on the host.

2 Likes

I never recommend changing files in the docker data root, but I tried your way with Docker CE 27.1.2 and worked perfectly. I don’t think it matters, but my backing filesystem was ext4 and not xfs as yours. I also had an officially supported operating system, Ubuntu, not Rocky Linux.

Since it works for me with local named volumes, that could be a bug but your post made me curious and I played with the settings and the API a little.

I enabled the debug mode in the docker daemon:

{
  "log-level": "debug"
}
systemctl restart docker

Then I tried

services:
  web:
    image: nginx
    volumes:
      - type: volume
        source: data_bind
        target: /app
        volume:
          nocopy: true # I tried without this first
          subpath: tftp

volumes:
  data_bind:
    driver: local
    driver_opts:
      type: none
      device: ./data
      o: bind

I got the error message:

Error response from daemon: invalid mode: rw,nocopy,tftp

So I checked the API error log

journalctl -e -u docker | grep 'form data'

My last entry was the json of the container create api call

Aug 30 00:29:20 docker-vm dockerd[6732]: time="2024-08-30T00:29:20.854456037+02:00" level=debug msg="form data: {\"AttachStderr\":true,\"AttachStdin\":false,\"AttachStdout\":true,\"Cmd\":null,\"Domainname\":\"\",\"Entrypoint\":null,\"Env\":null,\"HostConfig\":{\"AutoRemove\":false,\"Binds\":[\"test_data_bind:/app:rw,nocopy,tftp\"],\"BlkioDeviceReadBps\":null,\"BlkioDeviceReadIOps\":null,\"BlkioDeviceWriteBps\":null,\"BlkioDeviceWriteIOps\":null,\"BlkioWeight\":0,\"BlkioWeightDevice\":null,\"CapAdd\":null,\"CapDrop\":null,\"Cgroup\":\"\",\"CgroupParent\":\"\",\"CgroupnsMode\":\"\",\"ConsoleSize\":[0,0],\"ContainerIDFile\":\"\",\"CpuCount\":0,\"CpuPercent\":0,\"CpuPeriod\":0,\"CpuQuota\":0,\"CpuRealtimePeriod\":0,\"CpuRealtimeRuntime\":0,\"CpuShares\":0,\"CpusetCpus\":\"\",\"CpusetMems\":\"\",\"DeviceCgroupRules\":null,\"DeviceRequests\":null,\"Devices\":null,\"Dns\":null,\"DnsOptions\":null,\"DnsSearch\":null,\"ExtraHosts\":[],\"GroupAdd\":null,\"IOMaximumBandwidth\":0,\"IOMaximumIOps\":0,\"IpcMode\":\"\",\"Isolation\":\"\",\"Links\":null,\"LogConfig\":{\"Config\":null,\"Type\":\"\"},\"MaskedPaths\":null,\"Memory\":0,\"MemoryReservation\":0,\"MemorySwap\":0,\"MemorySwappiness\":null,\"NanoCpus\":0,\"NetworkMode\":\"test_default\",\"OomKillDisable\":false,\"OomScoreAdj\":0,\"PidMode\":\"\",\"PidsLimit\":null,\"PortBindings\":{},\"Privileged\":false,\"PublishAllPorts\":false,\"ReadonlyPaths\":null,\"ReadonlyRootfs\":false,\"RestartPolicy\":{\"MaximumRetryCount\":0,\"Name\":\"\"},\"SecurityOpt\":null,\"ShmSize\":0,\"UTSMode\":\"\",\"Ulimits\":null,\"UsernsMode\":\"\",\"VolumeDriver\":\"\",\"VolumesFrom\":null},\"Hostname\":\"\",\"Image\":\"nginx\",\"Labels\":{\"com.docker.compose.config-hash\":\"ca204030ebfa4e6e176ee2e383c1f0f9695fb76240141c92fd8d307909068d82\",\"com.docker.compose.container-number\":\"1\",\"com.docker.compose.depends_on\":\"\",\"com.docker.compose.image\":\"sha256:a9dfdba8b719078c5705fdecd6f8315765cc79e473111aa9451551ddc340b2bc\",\"com.docker.compose.oneoff\":\"False\",\"com.docker.compose.project\":\"test\",\"com.docker.compose.project.config_files\":\"/home/ubuntu/test/compose.yml\",\"com.docker.compose.project.working_dir\":\"/home/ubuntu/test\",\"com.docker.compose.service\":\"web\",\"com.docker.compose.version\":\"2.29.1\"},\"NetworkingConfig\":{\"EndpointsConfig\":{\"test_default\":{\"Aliases\":[\"test-web-1\",\"web\"],\"DNSNames\":null,\"DriverOpts\":null,\"EndpointID\":\"\",\"Gateway\":\"\",\"GlobalIPv6Address\":\"\",\"GlobalIPv6PrefixLen\":0,\"IPAMConfig\":null,\"IPAddress\":\"\",\"IPPrefixLen\":0,\"IPv6Gateway\":\"\",\"Links\":null,\"MacAddress\":\"\",\"NetworkID\":\"\"}}},\"OnBuild\":null,\"OpenStdin\":false,\"StdinOnce\":false,\"Tty\":false,\"User\":\"\",\"Volumes\":null,\"WorkingDir\":\"\"}"

But it shows other things before the log. so I made this command to make it compatible with jq and show only valid json outputs

journalctl -e -u docker \
  | grep 'form data' \
  | sed 's/.*form data: /"/' \
  | jq 'fromjson'

This was my last json output:

{
  "AttachStderr": true,
  "AttachStdin": false,
  "AttachStdout": true,
  "Cmd": null,
  "Domainname": "",
  "Entrypoint": null,
  "Env": null,
  "HostConfig": {
    "AutoRemove": false,
    "Binds": [
      "test_data_bind:/app:rw,nocopy,tftp"
    ],
    "BlkioDeviceReadBps": null,
    "BlkioDeviceReadIOps": null,
    "BlkioDeviceWriteBps": null,
    "BlkioDeviceWriteIOps": null,
    "BlkioWeight": 0,
    "BlkioWeightDevice": null,
    "CapAdd": null,
    "CapDrop": null,
    "Cgroup": "",
    "CgroupParent": "",
    "CgroupnsMode": "",
    "ConsoleSize": [
      0,
      0
    ],
    "ContainerIDFile": "",
    "CpuCount": 0,
    "CpuPercent": 0,
    "CpuPeriod": 0,
    "CpuQuota": 0,
    "CpuRealtimePeriod": 0,
    "CpuRealtimeRuntime": 0,
    "CpuShares": 0,
    "CpusetCpus": "",
    "CpusetMems": "",
    "DeviceCgroupRules": null,
    "DeviceRequests": null,
    "Devices": null,
    "Dns": null,
    "DnsOptions": null,
    "DnsSearch": null,
    "ExtraHosts": [],
    "GroupAdd": null,
    "IOMaximumBandwidth": 0,
    "IOMaximumIOps": 0,
    "IpcMode": "",
    "Isolation": "",
    "Links": null,
    "LogConfig": {
      "Config": null,
      "Type": ""
    },
    "MaskedPaths": null,
    "Memory": 0,
    "MemoryReservation": 0,
    "MemorySwap": 0,
    "MemorySwappiness": null,
    "NanoCpus": 0,
    "NetworkMode": "test_default",
    "OomKillDisable": false,
    "OomScoreAdj": 0,
    "PidMode": "",
    "PidsLimit": null,
    "PortBindings": {},
    "Privileged": false,
    "PublishAllPorts": false,
    "ReadonlyPaths": null,
    "ReadonlyRootfs": false,
    "RestartPolicy": {
      "MaximumRetryCount": 0,
      "Name": ""
    },
    "SecurityOpt": null,
    "ShmSize": 0,
    "UTSMode": "",
    "Ulimits": null,
    "UsernsMode": "",
    "VolumeDriver": "",
    "VolumesFrom": null
  },
  "Hostname": "",
  "Image": "nginx",
  "Labels": {
    "com.docker.compose.config-hash": "ca204030ebfa4e6e176ee2e383c1f0f9695fb76240141c92fd8d307909068d82",
    "com.docker.compose.container-number": "1",
    "com.docker.compose.depends_on": "",
    "com.docker.compose.image": "sha256:a9dfdba8b719078c5705fdecd6f8315765cc79e473111aa9451551ddc340b2bc",
    "com.docker.compose.oneoff": "False",
    "com.docker.compose.project": "test",
    "com.docker.compose.project.config_files": "/home/ubuntu/test/compose.yml",
    "com.docker.compose.project.working_dir": "/home/ubuntu/test",
    "com.docker.compose.service": "web",
    "com.docker.compose.version": "2.29.1"
  },
  "NetworkingConfig": {
    "EndpointsConfig": {
      "test_default": {
        "Aliases": [
          "test-web-1",
          "web"
        ],
        "DNSNames": null,
        "DriverOpts": null,
        "EndpointID": "",
        "Gateway": "",
        "GlobalIPv6Address": "",
        "GlobalIPv6PrefixLen": 0,
        "IPAMConfig": null,
        "IPAddress": "",
        "IPPrefixLen": 0,
        "IPv6Gateway": "",
        "Links": null,
        "MacAddress": "",
        "NetworkID": ""
      }
    }
  },
  "OnBuild": null,
  "OpenStdin": false,
  "StdinOnce": false,
  "Tty": false,
  "User": "",
  "Volumes": null,
  "WorkingDir": ""
}

Notice this part:

  "HostConfig": {
    "AutoRemove": false,
    "Binds": [
      "test_data_bind:/app:rw,nocopy,tftp"
    ],

If it is a standard named volume it is set in Mounts instead of Binds

    "Mounts": [
      {
        "Source": "test_data",
        "Target": "/app",
        "Type": "volume",
        "VolumeOptions": {
          "Subpath": "tftp"
        }
      }
    ],

So it turns out that the named volume backed by a bind mount is indeed handled differently which surprised me. And somehow the content of an object created from the volume block is added after the mode which does not make sense so it must be a bug.

Even if it is a bug, I guess it was not discovered before, because a subpath could be useful when a container generates data ona volume, for example a document of a webapp is on this volume including the “photos” folder. Now you don’t need all the containers to use a named volume backed by a bind mount, only the first so it can copy the data to the volume, but the second container can use a simple bind mount and set any directory. This container then can for example generate thumbnails for the photos.

In case of a standard named volume, bind mounting anything from the docker data root could mean that if the volume is already deleted, and the short syntax is used for the bind mount definition, the fodler could be created in the docker data root and docker volume create might fail when trying to create a volume with the same name later.