Ken Muse

Improving Docker-From-Docker With Features


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

In my previous post we examined how to use Docker-from-Docker with an Alpine container. Unfortunately, this approach had some limitations. The biggest limitation was that changes to the docker.sock ownership or permissions could impact other containers or the host machine. That’s far from ideal. We also have another consideration: what if we want to allow others to use our script without requiring changes to their container? Last week we discussed features. This week, we’ll create our own.

The setup

To get started, let’s start a project in VS Code and create a .devcontainer directory. This will be the start of a container that will be used to test and validate the feature we’re building. Within the .devcontainer directory, create the following:

  • A file, devcontainer.json. It will contain the following:

    1{
    2   "name": "Alpine",
    3   "image": "mcr.microsoft.com/devcontainers/base:alpine-3.16",
    4   "features": {
    5     "./docker-from-docker":{}
    6   }
    7}

    This will create a basic dev container from Alpine 3.16 and use a local Feature from a folder.

  • The directory docker-from-docker. This will contain the feature.

  • Insider the docker-from-docker directory, create two files: devcontainer-feature.json and install.sh. This gives us the framework for our feature. If you recall from the previous post, these two files are the minimum requirements for a Feature.

The Feature definition

Time to explore how to build the Feature. Along the way you’ll be learning some of the basics behind how this Feature was implemented for Debian and Ubuntu

First, let’s start editing the devcontainer-feature.json file. We’ll start with this content:

 1{
 2    "id": "alpine-dfd",
 3    "version": "0.1.0",
 4    "name": "Alpine Docker-from-Docker",
 5    "description": "Enables using the Docker CLI with the host's Docker service",
 6    "mounts": [ "source=/var/run/docker.sock,target=/var/run/docker-host.sock,type=bind" ],
 7    "containerEnv": {
 8        "DOCKER_BUILDKIT": "1"
 9    },
10    "entrypoint": "/usr/local/share/dfd-init.sh"
11}

Let’s break this down:

  • id
    The unique identifier for our feature.
  • version
    A version number for the feature.
  • name
    A friendly name that can be displayed to users.
  • description
    What’s a name without a nice description to explain it, right?
  • mounts
    The feature is requesting some specific mounts to be created for the dev container. This is similar to creating the mounts entry in devcontainer.json, but it is being requested by the Feature. It’s modular!
  • containerEnv
    The Feature is adding an environment variable to the container, DOCKER_BUILDKIT. We’re explicitly enabling Docker to take advantage of the functionality that’s available in buildx. This is the default for newer versions of Docker, but we want our Feature to have support for more environments. This shows you how a Feature can introduce other aspects to the dev container.
  • entrypoint
    This is a script or command that will run each time the container starts. When the dev container is built, the features are used to add layers to the defined image. During that process, the entry scripts are gathered. When the container is started, each entrypoint script will be executed. This allows Features a way of starting important services or functionality. It will be run as the containerUser that is declared in the devcontainer.json that will be using this feature. In this case, we’re going to need something to run each time. We’ll come back to that. Because the Feature is built into the layer, try to ensure the script name won’t conflict with any other files in the container. Give it a unique name.

Creating the script

The first step in building the Feature is to create the install.sh script that is used to configure the container. Don’t forget to run chmod +x install.sh in the folder to give it execute permissions.

This script will be run as root.

At the start of the script, configure some initial variables to make it easier to manage:

1#!/usr/bin/env bash
2SOURCE_SOCKET="${SOURCE_SOCKET:-"/var/run/docker-host.sock"}"
3TARGET_SOCKET="${TARGET_SOCKET:-"/var/run/docker.sock"}"
4USERNAME="${USERNAME:-"${_REMOTE_USER:-"root"}"}"

The syntax ${VARIABLE:-"VALUE"} initializes the variable with the value of the environment variable VARIABLE. If that value is not set, it uses the default VALUE. For example, if $USERNAME is not configured, it tries to default to $_REMOTE_USER. If that’s not set, it falls back to root.

Next, install the dependencies. For this script we need docker-cli and socat:

1apk update
2apk add --no-cache --update docker-cli socat

Next, we need to make sure that the docker group exists so we can use it for permissions. The script will look for the group in /etc/group and add it if it’s missing. This ensures the group exists in an idempotent way. With that, we can use usermod to add the dev container’s user ($USERNAME) to the docker group:

1if ! grep -qE '^docker:' /etc/group; then
2    groupadd --system docker
3fi
4
5usermod -aG docker $USERNAME

We’ve now setup the user, group, and applications we need.

Binding the Docker socket (root)

In the Feature definition, we defined a mount that binds the host’s /var/run/docker.sock to the container’s /var/run/docker-host.sock. The Docker CLI needs /var/run/docker.sock, but we don’t want our permission changes to impact the host system. To make that work, we will need to do a few things.

First, let’s consider how to handle the user running as root. In that case, the user doesn’t need any special permissions or handling. We can simply link the docker-host.sock and docker.sock. This will be our default starting point. When the Feature script is run, the mount will not have been created. As a result, we will need to idempotently create a placeholder:

1if [! -f "${SOURCE_SOCKET}" ]; then
2    touch "${SOURCE_SOCKET}"
3fi

Then, if the symbolic link from docker.sock to docker-host.sock doesn’t exist, create that:

1if [ ! "${TARGET_SOCKET}" -ef "${SOURCE_SOCKET}" ]; then
2    ln -s "${SOURCE_SOCKET}" "${TARGET_SOCKET}"
3fi

We can detect whether a link exists between two files in an if statement by using -ef. The ! acts as a logical NOT, allowing the script to identify when a link does not exist. Based on that, the script creates a symbolic link. This is also idempotent since the files won’t be relinked if a link exists. After this step, any request made by the Docker CLI to docker.sock will now be communicating with docker-host.sock.

If the user will be running as root, then the entrypoint script won’t be needed. Because we specified an entrypoint, the script must exist and be callable. To make sure the Feature is testable, we want to make sure that any additional commands passed to the entrypoint are also executed, allowing us to chain commands. To do that, we will invoke exec "$@", which executes all of the parameters passed to the script. This gives us:

 1# If the file already exists, exit
 2if [ -f "/usr/local/share/docker-dfd.sh" ]; then
 3    exit 0
 4fi
 5
 6if [ "${USERNAME}" = "root" ]; then
 7    # Use echo -e to interpret escapes, such as \n
 8    echo -e '#!/usr/bin/env bash\nexec "$@"' > /usr/local/share/dfd-init.sh
 9    # Make it executable
10    chmod +x /usr/local/share/dfd-init.sh
11    # Exit the script with a success status
12    exit 0
13fi

That’s a lot for one post! If you want to test your progress as root, just update devcontainer.json so that remoteUser is root. Then open the VS Code Command Pallette (Ctrl+Shift+P on Windows or Cmd+Shift+P on macOS). Choose Dev Containers: Rebuild and Reopen in Container. You should be able to run docker ps successfully.

If you want to test this with the vscode user, add these lines to install.sh, then reopen the project in a container:

1# Placeholder for non-root users
2echo -e '#!/usr/bin/env bash\nexec "$@"' > /usr/local/share/dfd-init.sh
3chmod +x /usr/local/share/dfd-init.sh
4chown ${USERNAME}:root /usr/local/share/dfd-init.sh

This makes sure that non-root users also have an entrypoint script. If you try to execute docker ps, you’ll get a permissions error. However, running sudo docker ps will work.

The script so far

If you’ve been following along your install.sh will look like this.

 1#!/usr/bin/env bash
 2SOURCE_SOCKET="${SOURCE_SOCKET:-"/var/run/docker-host.sock"}"
 3TARGET_SOCKET="${TARGET_SOCKET:-"/var/run/docker.sock"}"
 4USERNAME="${USERNAME:-"${_REMOTE_USER:-"root"}"}"
 5
 6apk update
 7apk add --no-cache --update docker-cli socat
 8
 9if ! grep -qE '^docker:' /etc/group; then
10    groupadd --system docker
11fi
12
13usermod -aG docker $USERNAME
14
15if [! -f "${SOURCE_SOCKET}" ]; then
16    touch "${SOURCE_SOCKET}"
17fi
18
19if [ ! "${TARGET_SOCKET}" -ef "${SOURCE_SOCKET}" ]; then
20    ln -s "${SOURCE_SOCKET}" "${TARGET_SOCKET}"
21fi
22
23# If the file already exists, exit
24if [ -f "/usr/local/share/docker-dfd.sh" ]; then
25    exit 0
26fi
27
28if [ "${USERNAME}" = "root" ]; then
29    # Use echo -e to interpret escapes, such as \n
30    echo -e '#!/usr/bin/env bash\nexec "$@"' > /usr/local/share/dfd-init.sh
31    # Make it executable
32    chmod +x /usr/local/share/dfd-init.sh
33    # Exit the script with a success status
34    exit 0
35fi
36
37# Placeholder for non-root users
38echo -e '#!/usr/bin/env bash\nexec "$@"' > /usr/local/share/dfd-init.sh
39chmod +x /usr/local/share/dfd-init.sh
40chown ${USERNAME}:root /usr/local/share/dfd-init.sh

Next steps

Next week, we’ll update this to improve the experience for non-root users and eliminate the need to use sudo. Until then, have fun exploring the custom Feature you’ve built!