Permission problems with volume

In Docker, file permission issues often arise when you need to mount a host directory to a container for use as a volume. It is common that the container does not have write access to the path, which can lead to all sorts of weird problems with the service.

The reason for this type of problem is that the UIDs inside and outside the container are different. For example, host is currently using docker with a user UID of 1000 (this is the default first user UID). If the UID inside the container is 2000, then the directory created by host is not an owner for the container and is not writable by default.

There is also a case where the directory to be mounted does not exist on the host before it is mounted. Docker will create the directory with root privileges and then mount it. This leads to a situation where there are no write permissions even if both host and container have a UID of 1000. This phenomenon, which only occurs during initialisation, is enough to confuse newbies and bore veterans.

Why can’t I configure the permissions for the volume in the Dockerfile? Because a Dockerfile is a description of an image, while a volume is the contents of a container. Permissions configured in Dockerfile are valid for non-volume, but not for volume. Essentially, the directory that host mounts to a volume belongs to host. Dockerfile is executed during docker build, while volume is generated during docker run.

In fact, when Docker automatically creates the volume path, it should then automatically change it to the user:group of the foreground process in the container. However, Docker does not currently have such a mechanism, so we have to find another way.

The usual temporary solution is to change the permissions manually. Either by chown, changing the owner to the UID of the user in the container, or by chmod 777, making it common to all users. These are certainly not good long-term solutions and defeat the purpose of “Docker for easy deployment”.

The best solution, for now, seems to be to customise the Dockerfile’s ENTRYPOINT.

ENTRYPOINT

ENTRYPOINT has the following key points.

  • ENTRYPOINT specifies the default entry command for the image, which will be executed as the root command when the container is started, with all other incoming values as arguments to that command.
  • The value of ENTRYPOINT can be overridden by docker run --entrypoint.
  • Only the last ENTRYPOINT command in the Dockerfile will work.

When ENTRYPOINT is specified, the meaning of CMD changes and instead of running its command directly, the contents of CMD are passed as an argument to the ENTRYPOINT command. In other words the actual execution will become <ENTRYPOINT> "<CMD>".

So write an entry script entrypoint.sh or docker-entrypoint.sh in ENTRYPOINT in the dockerfile. When the container is running, use ENTRYPOINT to do something like change the permissions of the directory where the volume is mounted, and then switch to the normal user to run the normal program process.

gosu and su-exec

gosu’s github repository: https://github.com/tianon/gosu

Usage.

1
2
3
4
5
6
7
$ gosu
Usage: ./gosu user-spec command [args]
   eg: ./gosu tianon bash
       ./gosu nobody:root bash -c 'whoami && id'
       ./gosu 1000:1 id

./gosu version: 1.1 (go1.3.1 on linux/amd64; gc)

A simple example from the documentation.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
$ docker run -it --rm ubuntu:trusty su -c 'exec ps aux'
USER       PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root         1  0.0  0.0  46636  2688 ?        Ss+  02:22   0:00 su -c exec ps a
root         6  0.0  0.0  15576  2220 ?        Rs   02:22   0:00 ps aux
$ docker run -it --rm ubuntu:trusty sudo ps aux
USER       PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root         1  3.0  0.0  46020  3144 ?        Ss+  02:22   0:00 sudo ps aux
root         7  0.0  0.0  15576  2172 ?        R+   02:22   0:00 ps aux
$ docker run -it --rm -v $PWD/gosu-amd64:/usr/local/bin/gosu:ro ubuntu:trusty gosu root ps aux
USER       PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root         1  0.0  0.0   7140   768 ?        Rs+  02:22   0:00 ps aux

Neither su nor sudo have a PID of 1 when they execute the ps aux command, which is fine in a container, but it is not a good solution; the process with PID=1 inside the container is the application itself. So you can use the gosu command to switch users to execute commands.

For debian, the installation is as follows.

Debian 9 (“Debian Stretch”) or newer

1
2
3
4
5
6
RUN set -eux; \
	apt-get update; \
	apt-get install -y gosu; \
	rm -rf /var/lib/apt/lists/*; \
# verify that the binary works
	gosu nobody true

Older versions of Debian (or newer versions of gosu)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
ENV GOSU_VERSION 1.16
RUN set -eux; \
# save list of currently installed packages for later so we can clean up
	savedAptMark="$(apt-mark showmanual)"; \
	apt-get update; \
	apt-get install -y --no-install-recommends ca-certificates wget; \
	if ! command -v gpg; then \
		apt-get install -y --no-install-recommends gnupg2 dirmngr; \
	elif gpg --version | grep -q '^gpg (GnuPG) 1\.'; then \
# "This package provides support for HKPS keyservers." (GnuPG 1.x only)
		apt-get install -y --no-install-recommends gnupg-curl; \
	fi; \
	rm -rf /var/lib/apt/lists/*; \
	\
	dpkgArch="$(dpkg --print-architecture | awk -F- '{ print $NF }')"; \
	wget -O /usr/local/bin/gosu "https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$dpkgArch"; \
	wget -O /usr/local/bin/gosu.asc "https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$dpkgArch.asc"; \
	\
# verify the signature
	export GNUPGHOME="$(mktemp -d)"; \
	gpg --batch --keyserver hkps://keys.openpgp.org --recv-keys B42F6819007F00F88E364FD4036A9C25BF357DD4; \
	gpg --batch --verify /usr/local/bin/gosu.asc /usr/local/bin/gosu; \
	command -v gpgconf && gpgconf --kill all || :; \
	rm -rf "$GNUPGHOME" /usr/local/bin/gosu.asc; \
	\
# clean up fetch dependencies
	apt-mark auto '.*' > /dev/null; \
	[ -z "$savedAptMark" ] || apt-mark manual $savedAptMark; \
	apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false; \
	\
	chmod +x /usr/local/bin/gosu; \
# verify that the binary works
	gosu --version; \
	gosu nobody true

For alpine (3.7+)

When using Alpine it might also be worth checking out su-exec (apk add --no-cache su-exec), which since version 0.2 is fully compatible with gosu at a fraction of the file size.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
ENV GOSU_VERSION 1.16
RUN set -eux; \
	\
	apk add --no-cache --virtual .gosu-deps \
		ca-certificates \
		dpkg \
		gnupg \
	; \
	\
	dpkgArch="$(dpkg --print-architecture | awk -F- '{ print $NF }')"; \
	wget -O /usr/local/bin/gosu "https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$dpkgArch"; \
	wget -O /usr/local/bin/gosu.asc "https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$dpkgArch.asc"; \
	\
# verify the signature
	export GNUPGHOME="$(mktemp -d)"; \
	gpg --batch --keyserver hkps://keys.openpgp.org --recv-keys B42F6819007F00F88E364FD4036A9C25BF357DD4; \
	gpg --batch --verify /usr/local/bin/gosu.asc /usr/local/bin/gosu; \
	command -v gpgconf && gpgconf --kill all || :; \
	rm -rf "$GNUPGHOME" /usr/local/bin/gosu.asc; \
	\
# clean up fetch dependencies
	apk del --no-network .gosu-deps; \
	\
	chmod +x /usr/local/bin/gosu; \
# verify that the binary works
	gosu --version; \
	gosu nobody true

entrypoint scripts

Script example 1.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
#!/bin/sh
set -e
ls ${LOG_PATH} > /dev/null 2>&1 || mkdir -p ${LOG_PATH}
chown -R www-data ${LOG_PATH}
if [ $# -gt 0 ];then
    #su ${USERNAME} -c "exec $@"
    exec su-exec www-data $@
else
    #su ${USERNAME} -c "exec uwsgi --ini uwsgi.ini --http=0.0.0.0:${DJANGO_PORT}"
    exec su-exec www-data uwsgi --ini uwsgi.ini --http=0.0.0.0:${DJANGO_PORT}
fi

Description.

  • set -e: If there is a command execution failure, then you should exit the script and not continue to execute further, to avoid the failure having an impact on the subsequent. This avoids the problem of the operation failing and continuing to execute.
  • exec: The system call exec replaces the original process with a new one, but the PID of the process remains unchanged, ensuring that the container’s main program PID = 1.

Script example 2.

1
2
3
4
5
6
7
8
9
#!/bin/sh
set -e
if [ "$1" = 'uwsgi' -a "$(id -u)" = '0' ]
then
    ls ${LOG_PATH} > /dev/null 2>&1 || mkdir -p ${LOG_PATH}
    chown -R www-data ${LOG_PATH}
    exec su-exec www-data "$0" "$@"
fi
exec "$@"

Instructions.

  • If the current user is root, then create and modify the LOG_PATH directory permissions, switch to the www-data identity, take the remaining arguments and run the docker-entrypoint.sh file again ("$0" means docker-entrypoint.sh itself, "$@" means the remaining arguments). If there is code elsewhere in this script that needs to be executed by the www-data user, it can be executed along with it.
  • When the script is executed again, since it is no longer the root user, it will simply execute exec "$@", thus executing the script with the arguments, i.e. the CMD definition.

Add the docker-entrypoint.sh script to the Dockerfile and note the x execute permission, otherwise you will not have permission to execute it.

1
2
3
COPY docker-entrypoint.sh /usr/local/bin/

ENTRYPOINT ["docker-entrypoint.sh"]

This docker-entrypoint.sh script allows you to force directory permissions to be changed to the required permissions when the container is run, even if the volume mount directory is created by docker initialization via the root user. In this way, it is possible to run the program through a normal user in the container and write files in this normal permissions directory.