From 70727aeb17fde57642b9806f1a55c5cc2b3bd4ef Mon Sep 17 00:00:00 2001 From: Egor Tensin Date: Wed, 6 May 2020 15:24:01 +0300 Subject: add "Docker: bind mounts" --- _posts/2020-05-06-docker-bind-volumes.md | 176 +++++++++++++++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 _posts/2020-05-06-docker-bind-volumes.md (limited to '_posts/2020-05-06-docker-bind-volumes.md') diff --git a/_posts/2020-05-06-docker-bind-volumes.md b/_posts/2020-05-06-docker-bind-volumes.md new file mode 100644 index 0000000..9853331 --- /dev/null +++ b/_posts/2020-05-06-docker-bind-volumes.md @@ -0,0 +1,176 @@ +--- +title: 'Docker: bind mounts & file ownership' +excerpt: Docker + bind mounts + non-root users = pain. +--- +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][Redis Dockerfile] and +[MongoDB][MongoDB Dockerfile]. + +[Redis Dockerfile]: https://github.com/docker-library/redis/blob/cc1b618d51eb5f6bf6e3a03c7842317b38dbd7f9/6.0/Dockerfile#L4 +[MongoDB Dockerfile]: https://github.com/docker-library/mongo/blob/5cbf7be9a486932b7e472a39e432c9a444628465/4.2/Dockerfile#L4 + +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][Redis entrypoint], +[MongoDB][MongoDB entrypoint]). + +[Redis entrypoint]: https://github.com/docker-library/redis/blob/cc1b618d51eb5f6bf6e3a03c7842317b38dbd7f9/6.0/docker-entrypoint.sh#L11 +[MongoDB entrypoint]: https://github.com/docker-library/mongo/blob/5cbf7be9a486932b7e472a39e432c9a444628465/4.2/docker-entrypoint.sh#L12 + +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 accomodates 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 + +Sometimes `--user` is not enough though. +That specified user is almost certainly missing from container's /etc/passwd, +it doesn't have a $HOME, etc. +All of that could cause problems with some applications. + +One scenario I had to deal with is making an image that bundles all the gems +(and a specific Ruby version) for my Ruby web application. +That application shouldn't be run as root, but it must be able to pick up code +changes on the fly, and I should be able `docker exec` into the container, +update the dependencies (along with Gemfile[.lock], and those changes should be +reflected on the host without messing up file metadata), and restart the app. +It's quite easy to install the dependencies in the Dockerfile, but they (along +with the mapped Gemfile[.lock]) should be writable by the user running the +service. +The solution often suggested is to create a container user with a fixed UID +(that would match the host user UID). +That way, it would be able to update the dependencies stored in the container, +as well as write to the bind mount owned by the host user with the same UID. +Additionally, file ownership info would be preserved on the host! + +We can create a user with a fixed UID when + +1. building the image (using build `ARG`uments), +2. first starting the container by passing the required UID using environment +variables. + +The advantages of creating the user when building the image is that we can also +install the dependencies in the Dockerfile, thus eliminating the need to +rebuild them for every other application. +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 has the advantage of not +requiring image rebuilds. +But, as the dependencies need to be installed after creating the user, you'd +have to waste resources by installing them for every user and every app on +every machine (each time when creating 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). + +[jekyll-docker]: https://github.com/egor-tensin/jekyll-docker + +Useful links +------------ + +* [Docker and \-\-userns-remap, how to manage volume permissions to share data between host and container?](https://stackoverflow.com/q/35291520/514684) +* [What is the (best) way to manage permissions for Docker shared volumes?](https://stackoverflow.com/q/23544282/514684) +* [Handling Permissions with Docker Volumes](https://denibertovic.com/posts/handling-permissions-with-docker-volumes/) +* [File Permissions: the painful side of Docker](https://blog.gougousis.net/file-permissions-the-painful-side-of-docker/) +* [Avoiding Permission Issues With Docker-Created Files](https://vsupalov.com/docker-shared-permissions/) -- cgit v1.2.3