Ken Muse

Implementing Docker-from-Docker for Non-Root Users


This is a post in the series Dev Container Features. The posts in this series include:

The install.sh we’ve created so far is impressive, but it doesn’t have the best experience for non-root users. We want non-root users (such as vscode) to be able to run the Docker CLI without requiring sudo. To do this, we need the entrypoint to proxy details from a non-privileged docker.sock to the docker-host.sock that connects to the host. For that, we’ll start a background socat process. We’ll dynamically create an entrypoint script that invokes this application, using some of the variables we have in the install.sh.

Why socat?

The socat application acts as a proxy, providing bidirectional communication. That is, it allows data to flow in both directions between two endpoints. If the user is not running as root, we can create /var/run/docker.sock with socat, give that appropriate permissions, and allow that lower-privileged process to proxy the requests for the Docker CLI.

Building the script

Starting for last week’s code, remove the placeholder for non-root users. Replace it with this:

1cat << EOF > /usr/local/share/dfd-init.sh

This starts the process of dynamically creating a script. It uses cat to read some content and write it to /usr/local/share/dfd-init.sh. This is using a heredoc to make the writing easier. If you’re not familiar with the term, it instructs cat to use everything that follows as an input (<<). It can stop reading when it reaches a line that contains EOF.

Now, let’s setup some variables and the start of the script:

1#!/usr/bin/env bash
2SOCAT_PATH_BASE=/tmp/vsf-dfd
3SOCAT_LOG=\${SOCAT_PATH_BASE}.log
4SOCAT_PID=\${SOCAT_PATH_BASE}.pid

SOCAT_PATH_BASE is used to create a base name that we use with other variables. This is used to specify a location and extension for SOCAT_LOG, which will contain the output for the socat process.

The SOCAT_PID file will be used to contain the process ID for the current instance. If the file exists and the process ID is valid, then the proxy is already running. Otherwise, we need to start the process. This only matters if the user account being used by the dev container is not root. If the user is root, the symlink will handle everything. We can express that in a script this way:

1if [ "${USERNAME}" != "root" ]; then
2    if [ ! -f "\${SOCAT_PID}" ] || ! ps -p \$(cat \${SOCAT_PID}) > /dev/null; then

The if statement starts with a command ([ ... ]). The -f option is used to determine if the file name in the variable SOCAT_PID file exists. The modifier ! changes this to check that it does not exist.

If the file does exist, the second part of the if expression uses ps to determine if the process ID in the file SOCAT_PID exists. The contents of the file are read into the command using the command substitution $(cat ${SOCAT_PID}). A command substitution ($(...)) runs the code in parentheses and replaces the command with the result. Any console messages from ps are piped (>) to /dev/null to prevent any written output. If a process exist with that identifier, we’ll get a 0 status code (true).

Notice that \${SOCAT_PID} has a \ to escape the $. The code is creating a script from within a script. Within a script, ${...} would normally be expanded automatically. For example, ${USERNAME} will be replaced with the current value, vscode. In the case of SOCAT_PID, we want the variable to when the generated script runs. Escaping the value ensures that the literal text ${SOCAT_PID} is written to the new script file. There are a few places in this script where $ will need to be escaped as \$ for this reason.

If the code has gotten this far, we need to cleanup any existing docker.sock and the PID file before we start the proxy. Inside the if block, we can add:

1    sudo rm -f \${SOCAT_PID}
2    sudo rm -rf ${TARGET_SOURCE}

First, we use a variable expression to find and remove the SOCAT_PID file. We do this in case it is a leftover from a previous run. Otherwise, this will block the process from starting. Next, we remove any existing docker.sock (or symlink) to allow us to create that as a proxy. Just like before, ${TARGET_SOURCE} will be expanded into the path when writing the script.

Now we have one task left: starting the proxy.

Start your (relay) engines!

Using socat is fairly straight-forward. Inside the same if block, we add:

1sudo socat -L\${SOCAT_PID} -lf \${SOCAT_LOG} UNIX-LISTEN:${TARGET_SOCKET},fork,mode=660,group=docker UNIX-CONNECT:${SOURCE_SOCKET} &

Let’s break this down to make it easier to understand.

The command starts with sudo to run with higher privileges. It ends with & to make the command run as a background process. This allows the rest of the script to continue. This is sometimes also handled by using a subshell. In that approach, entire command is placed in parentheses to run it in a separate process group. The difference is that a with a subshell, the current script will wait for a response. With a background process, there’s no waiting. The script can continue to its end. Currently both approaches work with Features.

The socat command can be broken down into a few parts:

  • -L\${SOCAT_PID} is used to create a lock file with the PID. The process will create this file, put the PID in the file, and hold a lock on that file. When the process ends, the file is deleted. If the file and lock exist, socat will terminate since another process is using the lock already. This ensures a singleton process. Coming into the command, the script has verified that if the file exists it references a process that does not. If the file exists with a missing process, the file must be deleted before calling socat to ensure it can take the lock.
  • -lf \${SOCAT_LOG} is used to log any messages to the file pointed to by the variable SOCAT_LOG. By default, this will be just errors.
  • UNIX-LISTEN:${TARGET_SOCKET}. This is the “source” address where socat will listen in the form protocol:endpoint. In this case, the protocol is UNIX-LISTEN. This instructs socat to accept connections using a UNIX domain socket using the provide file (TARGET_SOURCE). The socket/file is created at the start of the process and removed when the address is closed. If the file already exists, binding fails.
  • fork. Without this, socat is limited to a single connection. With this command, socat forks a new child process for every new connection.
  • mode=660. The permissions on the socket. This allows the user ($USERNAME) and group (docker) read and write permissions and denies permissions for others.
  • group=docker. This sets the group to docker, allowing access from members of that group. Because of the earlier script, that also includes the user. It’s possible to use user=$USERNAME to set the owner to $USERNAME (instead of defaulting to root), but its best to minimize privileges whenever possible.
  • UNIX-CONNECT:${SOURCE_SOCKET}. Where the relay sends incoming messages and listens for responses. This connects our docker.sock to the existing docker-host.sock. The file must exist and have a listening process.

The final result is that we see the two sockets with appropriate permissions for the user if we run ls -l /var/run/ in the container:

1srwxr-xr-x 1 root   root      0 Jan 18 14:30 docker-host.sock
2srw-rw---- 1 root   docker    0 Jan 23 04:30 docker.sock

Notice that the docker.sock now has the group docker, allowing our user permissions. The mount to the host docker-host.sock remains root:root and the privileged access is handled by socat.

Wrapping it up

Now we add in a few more commands to round out the install.sh:

1set +e
2exec "\$@"
3
4EOF
5chmod +x /usr/local/share/dfd-init.sh
6chown ${USERNAME}:root /usr/local/share/dfd-init.sh

First, set +e is used to ensure that an error running the next steps does not automatically terminate the script. Next, any additional scripts passed to the entrypoint will be run using exec. We discussed this process in the last most. We then end the heredoc by providing EOF (which matches the <<EOF we originally provided).

After the EOF, the original install.sh script continues. First, it makes the entrypoint script executable using chmod. Then, the owner is changed to ${USERNAME}:root to ensure that the script can be executed by the assigned user.

The final script

That was a lot of details. If you’ve followed along, you should now have this:

 1
 2
 3#!/usr/bin/env bash
 4SOURCE_SOCKET="${SOURCE_SOCKET:-"/var/run/docker-host.sock"}"
 5TARGET_SOCKET="${TARGET_SOCKET:-"/var/run/docker.sock"}"
 6USERNAME="${USERNAME:-"${_REMOTE_USER:-"root"}"}"
 7
 8apk update
 9apk add --no-cache --update docker-cli socat
10
11if ! grep -qE '^docker:' /etc/group; then
12    groupadd --system docker
13fi
14
15usermod -aG docker $USERNAME
16
17if [! -f "${SOURCE_SOCKET}" ]; then
18    touch "${SOURCE_SOCKET}"
19fi
20
21if [ ! "${TARGET_SOCKET}" -ef "${SOURCE_SOCKET}" ]; then
22    ln -s "${SOURCE_SOCKET}" "${TARGET_SOCKET}"
23fi
24
25# If the file already exists, exit
26if [ -f "/usr/local/share/docker-dfd.sh" ]; then
27    exit 0
28fi
29
30if [ "${USERNAME}" = "root" ]; then
31    # Use echo -e to interpret escapes, such as \n
32    echo -e '#!/usr/bin/env bash\nexec "$@"' > /usr/local/share/dfd-init.sh
33    # Make it executable
34    chmod +x /usr/local/share/dfd-init.sh
35    # Exit the script with a success status
36    exit 0
37fi
38
39cat << EOF > /usr/local/share/dfd-init.sh
40#!/usr/bin/env bash
41SOCAT_PATH_BASE=/tmp/vsf-dfd
42SOCAT_LOG=\${SOCAT_PATH_BASE}.log
43SOCAT_PID=\${SOCAT_PATH_BASE}.pid
44
45touch \${SOCAT_LOG}
46chown ${USERNAME}:root \${SOCAT_LOG}
47
48if [ "${USERNAME}" != "root" ]; then
49    if [ ! -f "\${SOCAT_PID}" ] || ! sudo ps -p \$(sudo cat \${SOCAT_PID}) > /dev/null; then
50        sudo rm -f \${SOCAT_PID}
51        sudo rm -rf ${TARGET_SOCKET}
52        sudo socat -L\${SOCAT_PID} -lf \${SOCAT_LOG} UNIX-LISTEN:${TARGET_SOCKET},fork,mode=660,group=docker UNIX-CONNECT:${SOURCE_SOCKET} &
53    fi
54fi
55
56set +e
57exec "\$@"
58
59EOF
60chmod +x /usr/local/share/dfd-init.sh
61chown ${USERNAME}:root /usr/local/share/dfd-init.sh

You should be able to start the container and run docker ps with any user, including root. Congratulations on implementing your first complete Feature!

Next Steps

If you want to see a more complete implementation for Debian/Ubuntu, consider looking at the install.sh in the Docker-Outside-of-Docker Feature’s source code. This will give you a more robust implementation of what we’ve just created for Alpine. In fact, we just walked through the key parts of this script as we re-implemented the functionality for Alpine.!

Happy DevOp’ing!