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
andinstall.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 indevcontainer.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 thedevcontainer.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 bind
s 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!