The following are some lessons I've learned from stumbling around with volumes and mountpoints in Docker. At the outset let me be clear that there is no new information here -- everything described below can be found in the user documentation in one form or another. However, I spent a bunch of time screwing things up for lack of context, so I am recording some of my lessons here so I can find them again.
There are broadly two ways external filesystems can be attached to a container:
-
As a pass-through to an existing filesystem on the host. This is what Docker calls a "bind mount", and it basically corresponds with the similarly-named Linux and Plan9 notion.
-
As a managed filesystem provided by the container execution environment. This is what Docker calls a "volume". Unlike a bind mount, a volume is entirely mediated by Docker.
The documentation strongly implies that volumes (2) are preferable to bind mounts (1). However the two features are not strictly comparable, and both have their place. A more useful interpretation of this advice is that volumes are a better fit for cases where you're attaching a filesystem to a container for persistent storage. Since that is probably the predominant use of external filesystems, the advice makes sense in that context.
The UI for attaching external filesystems to Docker containers has a pointlessly confusing legacy. Specifically, there are two distinct flags you can use: --volume
(or -v
), and --mount
. I assumed at first that --volume
was for volumes and --mount
was for bind mounts, but in fact it's mainly a syntactic difference arising from some uninteresting historical nonsense.
For many purposes you can use --volume
and --mount
interchangeably, modulo their argument syntax:
--volume <host-fs-or-volume>:<mount-path>[:<csv-options>]
--mount type=<type>,source=<src>,target=<tgt>,readonly,volume-opt=<csv-kv-opts>
Each can be used to specify both bind-mounts or volume-mounts. There are various pointless restrictions on which flag works in certain circumstances, but broadly speaking I use the rule of thumb that if you only care about source and target --volume
is fine; otherwise use --mount
.
Insofar as a compiler is basically an interpreted DSL for code generation, docker build
is a compiler whose input is a jumped-up shell script (née Dockerfile
) and whose output is an ordered bundle of overlays that record everything the script touched. During the compilation the script can do anything it wants (more or less), including copy files from the host, fetch things over the network, and run arbitrary processes in the containers the compiler provides. The compiler runs it each step in a sandbox and records any changes it made to the filesystem.
The compiler does not know the contents of external volumes that will be mounted after the image is finished. The input can stipulate (via the VOLUME <path>
instruction) that a certain place in the filesystem will have something attached to it (either from the host or from another container), but not what. This is intentional, since a given image may be attached to many different containers, each with different volumes mounted.
During compilation, whatever already exists under the <path>
touched by a VOLUME <path>
instruction remains visible to the instructions after that point. However, any changes made through that path are discarded. My understanding of it so far is that mountpoint paths are transparent-but-read-only at compile time, and opaque-but-possibly-writable at runtime (masked by whatever got attached, and "possibly" depending on whether readonly
was set).
The upshot of this distinction is that the Dockerfile
cannot do things with (or to) the contents of external volumes.
Creating a managed volume is easy enough:
docker volume create foo
Populating it is a little more tedious. The recommended way is using docker cp
, but to do that you need a container. Fortunately it doesn't have to be a useful container, or even a running container:
docker run --name foo-init --mount=src=foo,dst=/foo busybox true
Even though this container won't even be running by the time you check, you can copy to it:
docker cp data foo-init:/foo
And then clean up the stub container:
docker rm foo-init
Similarly, if you want to see what's in the volume, you'll have to create a container with it mounted. As long as you mount the volume writably, you can use this to make manual edits, e.g.,
docker run -it --rm --mount=src=foo,dst=/foo busybox sh
It works to nest mountpoints. The documentation doesn't actually spell out the semantics of this, but I kind of assume it's intended to be this way. You can say, for example:
docker run --mount=src=foo,dst=/alpha --mount=src=bar,dst=/alpha/tango ...
# ^^^^^^ ^^^^^^^^^^^^
# note that the latter shares a prefix with the former.
and the resulting filesystem will do what you might expect, viz.,
/alpha # from volume foo
beta # "
gamma # "
tango/ # from volume bar
uniform # "
zeta # from volume foo
As far as I can tell, if foo
has stuff under tango/...
already, it will be masked by the subordinate mount. That would make sense, but isn't documented anywhere I could find.
Using docker volume inspect <volume-name>
will give a JSON blob that describes the volume, including where its data are stored in the filesystem, e.g.,
$ docker volume inspect bblfsh-storage
[
{
"CreatedAt": "2019-02-06T18:39:15Z",
"Driver": "local",
"Labels": null,
"Mountpoint": "/var/lib/docker/volumes/bblfsh-storage/_data",
"Name": "bblfsh-storage",
"Options": null,
"Scope": "local"
}
]
The "Mountpoint"
key indicates where the data are stored. On macOS, however, that path is inside the OS VM, not the host filesystem, so you cannot simply visit that path in the Finder to browse the files. Instead, one simple strategy is to run a container with the root bind-mounted someplace, e.g.,
$ docker run --rm -it -v /var/lib/docker/volumes:/volumes alpine:latest /bin/sh
By binding /var/lib/docker/volumes
(in the VM) to /volumes
(in the container), you can resolve volume mountpoints like /var/lib/docker/volumes/foo
as /volumes/foo
inside the container you just started.