Docker: bind mounts & file ownership

 2020-05-06

If you want to:

  1. run your Docker service as a user other than root,
  2. share a writable directory between your host and the container,

you’re in for a treat! The thing is, files stored in the shared directory retain their ownership (and by that I mean their UIDs and GIDs, as they’re the only thing that matters) after being mounted in the container.

Case in point:

$
docker run -it --rm -v "$( pwd ):/data" alpine touch /data/test.txt

would create file ./test.txt owned by root:root.

You can fix that by using the --user parameter:

$
docker run -it --rm -v "$( pwd ):/data" --user "$( id -u ):$( id -g )" alpine touch /data/test.txt

That would create file ./test.txt owned by the current user (if the current working directory is writable by the current user, of course).

More often though, instead of a simple touch call, you have a 24/7 service, which absolutely mustn’t run as root, regardless of whether --user was specified or not. In such cases, the logical solution would be to create a regular user in the container, and use it to run the service. In fact, that’s what many popular images do, i.e. Redis and MongoDB.

How do you run the service as regular user though? It’s tempting to use the USER directive in the Dockerfile, but that can be overridden by --user:

$
cat Dockerfile
FROM alpine

RUN addgroup --gid 9099 test-group && \
    adduser \
        --disabled-password \
        --gecos '' \
        --home /home/test-user \
        --ingroup test-group \
        --uid 9099 \
        test-user

RUN touch /root.txt
USER test-user:test-group
RUN touch /home/test-user/test-user.txt

CMD id && stat -c '%U %G' /root.txt && stat -c '%U %G' /home/test-user/test-user.txt
$
docker build -t id .
$
docker run -it --rm id
uid=9099(test-user) gid=9099(test-group)
root root
test-user test-group
$
docker run -it --rm --user root id
uid=0(root) gid=0(root) groups=0(root),1(bin),2(daemon),3(sys),4(adm),6(disk),10(wheel),11(floppy),20(dialout),26(tape),27(video)
root root
test-user test-group

I suppose that’s the reason why many popular images override ENTRYPOINT, using a custom script (and gosu, which is basically sudo, I think) to forcefully drop privileges (for example, see Redis, MongoDB).

Now, what if such service needs persistent storage? A good solution would be to use Docker volumes. For development though, you often need to just share a directory between your host and the container, and it has to be writable by both the host and the container process. This can be accomplished using bind mounts. For example, let’s try to map ./data to /data inside a Redis container (this assumes ./data doesn’t exist and you’re running as regular user with UID 1000; press Ctrl+C to stop Redis):

$
mkdir data
$
stat -c '%u' data
1000
$
docker run -it --rm --name redis -v "$( pwd )/data:/data" redis:6.0
$
stat -c '%u' data
999

As you can see, ./data changed its owner from user with UID 1000 (the host user) to user with UID 999 (the redis user inside the container). This is done in Redis’ ENTRYPOINT script, just before dropping root privileges so that the redis-server process owns the /data directory and thus can write to it.

If you want to preserve ./data ownership, Redis’ image (and many others) explicitly accommodates for it by not changing its owner if the container is run as anybody other than root. For example:

$
mkdir data
$
stat -c '%u' data
1000
$
docker run -it --rm --name redis -v "$( pwd )/data:/data" --user "$( id -u ):$( id -g )" redis:6.0
$
stat -c '%u' data
1000

Going hardcore

Sometimes --user is not enough though. The specified user is almost certainly missing from container’s /etc/passwd, it doesn’t have a $HOME directory, etc. All of that could cause problems with some applications.

The solution often suggested is to create a container user with a fixed UID (that would match the host user UID). That way, the app won’t be run as root, the user will have a proper entry in /etc/passwd, it will be able to write to the bind mount owned by the host user, and it won’t have to change the directory’s permissions.

We can create a user with a fixed UID when

  1. building the image (using build ARGuments),
  2. first starting the container by passing the required UID using environment variables.

The advantage of creating the user when building the image is that we can also do additional work in the Dockerfile (like if you need to install dependencies as that user). The disadvantage is that the image would need to be rebuilt for every user on every machine.

Creating the user when first starting the container switches the pros and cons. You don’t need to rebuild the image every time, but you’ll have to waste time and resources by doing the additional work that could’ve been done in the Dockerfile every time you create a container.

For my project jekyll-docker I opted for the former approach, making sure the jekyll process runs with the same UID as the user who built the image (unless it was built by root, in which case it falls back to a custom UID of 999). Seems to work quite nicely in practice.