A First Look at Dev Containers
Let's look at how you can locally write and run code inside a docker container to simplify and speed up the development process.
Who and what
This article is for devs
with a basic familiarity with Docker
who already maintain an application of some sort
especially with external dependencies like a local database
but even beginners might benefit from this
We will cover
configuring a slightly advanced dev container configuration
that allows for locally running sidecars, such as Mongo and Redis containers
include our GitHub credentials
and create a fun default zsh configuration
Why
Dev Containers are a feature supported by Visual Studio code allowing you to do your local development in an isolated, consistent environment. Just as docker containers allow you consistently deploy your application to production, dev containers do this for your local development.
In the Node.js world, we already maintain some consistency with dev dependencies: we can configure eslint, Typescript, and husky for local development without installing everything globally. But this doesn't work for every tool that we use. For example, we often use nvm to manage the node version itself. We might also have other tools in our toolbox that require a global binary or a version of Python. In the microservice world, we might have 50 of these projects to maintain with slightly different versions of these external dependencies. So, what if we develop each microservice in its own isolated operating system with all of these dependencies baked in?
Onboarding can be painful also. Each microservice needs a CONTRIBUTING.md file explaining to newcomers how to dive in and edit code. What if we can simplify that process using code to give those newcomers a one-click setup for all the various things our project depends on?
Dive in
Here's the example: https://github.com/jbcbdse/dev-container-demo
Clone the repo
Open it in VSCode
When prompted, click "Reopen in Container"
That's it! You have a working dev environment. You can run the sample app with
yarn install
thenyarn start
When running the dev container, VSCode will: Start the dev container and the sidecars, mount our workspace to the container, and install any VSCode extensions we need. Let's break it down.
Configuration breakdown
The configuration lives in the .devcontainer
directory.
devcontainer.json
The devcontainer.json
file is the basic configuration for the container. devcontainer.json reference.
{
"name": "dev-container-demo",
"dockerComposeFile": [
"./docker-compose.yml"
],
"service": "dev-container",
"remoteUser": "user",
"workspaceFolder": "/workspace",
"customizations": {
"vscode": {
"extensions": [
"ms-azuretools.vscode-docker"
]
}
}
}
There are 3 different ways to define what Docker image we want to use for the dev container:
A pre-built image from a registry using the
image
property. This will allow no customization.A local Dockerfile. This will allow you to customize the image for this project
A
docker-compose.yml
file. This will allow the most customization. It can also reference a local Dockerfile, and it can automatically run any sidecars you need. Even though this is more complex, this is what I'm doing here because it allows for the most customization later. This is thedockerComposeFile
property
The service
property is which service in the docker-compose.yml file VSCode will integrate with. remoteUser
lets us define a non-root user, which is created by the Dockerfile. workspaceFolder
is the directory in the container where your code will be mounted. Some VSCode extensions
can be automatically installed.
Dockerfile
FROM ubuntu:22.04
RUN apt-get update && apt-get install -y ca-certificates curl git gnupg sudo vim zsh
RUN curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash - && sudo apt-get install -y nodejs
RUN npm install --global yarn
# install docker
RUN mkdir -m 0755 -p /etc/apt/keyrings
RUN curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
RUN echo \
"deb [arch="$(dpkg --print-architecture)" signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \
"$(. /etc/os-release && echo "$VERSION_CODENAME")" stable" | \
tee /etc/apt/sources.list.d/docker.list > /dev/null
RUN apt-get update && apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
# Set up non-root user
ARG USERNAME=user
ARG USER_UID=1000
ARG USER_GID=$USER_UID
RUN groupadd --gid $USER_GID $USERNAME \
&& useradd --uid $USER_UID --gid $USER_GID -m $USERNAME \
&& echo $USERNAME ALL=\(root\) NOPASSWD:ALL > /etc/sudoers.d/$USERNAME \
&& chmod 0440 /etc/sudoers.d/$USERNAME
USER $USERNAME
# Set up zsh with nice defaults
ENV SHELL=/usr/bin/zsh
ENV EDITOR=/usr/bin/vim
RUN git clone --depth=1 https://github.com/romkatv/powerlevel10k.git /home/${USERNAME}/powerlevel10k
COPY ./assets/.zshrc /home/${USERNAME}/
COPY ./assets/.p10k.zsh /home/${USERNAME}/
This looks like a lot, but this is our chance to bake in anything that we need to be installed for development.
Using Ubuntu as a base. I like Ubuntu and most external tools I can think of should be available for Ubuntu.
Install only the version of Node.js we need
Install Docker. Our application needs to be built for docker, so we need to run
docker build
from inside the dev container. Because we're using the docker-in-docker approach, we can run docker commands in the sidecars from inside our dev environment.Run the container as a non-root user named
user
.Set up zsh with a friendly shell using powerlevel10k. We'd like to have a nice terminal in our dev environment.
docker-compose.yml
This will allow our dev environment to have sidecars. Your application probably needs a Mongo database or a Redis database for caching. Let's run those as Docker sidecars also:
services:
net:
image: alpine:latest
command: /bin/sh -c "tail -f /dev/null"
mongo:
image: mongo:6
volumes:
- ../data:/data/db
network_mode: service:net
redis:
image: redis:7
network_mode: service:net
dev-container:
build:
context: .
dockerfile: Dockerfile
command: /bin/sh -c "tail -f /dev/null"
volumes:
- ..:/workspace:cached
- $HOME/.ssh:/home/user/.ssh
- /var/run/docker.sock:/var/run/docker.sock:rw
network_mode: service:net
the
net
container is a dummy container using a very tiny Alpine Linux image. All the other containers will share its network.The
mongo
container will mount a local../data
directory to store its data. You're welcome to use Docker volumes if you prefer. We get persistent data, even if all the containers are destroyed.The
dev-container
is what VSCode will talk to:It needs a command to keep the container running.
tail -f /dev/null
works for this.It uses our Dockerfile to build its image
The
..
directory, the top level of the project, will be mounted to/workspace
Our
.ssh
directory will be mounted to theuser
's .ssh directory so we can use our GitHub credentialsdocker.sock is mounted so we can run docker commands on the host.
Note that no ports are forwarded:
Because every container uses the same network, owned by the
net
container, your application can reach mongo usinglocalhost:27017
and redis usinglocalhost:6379
.When you start the application, VSCode will automatically forward the application port.
When you open the project in VSCode, you should see this notification. Click "Reopen in Container"
The first time the dev container builds, it will take a while. You should see a notification that shows the log:
When it's done, you can open a terminal in VSCode, and you should see our configured fancy zsh environment:
Now you're ready to run the application with yarn install
and yarn start
:
VSCode will automatically forward the application port:
Let's open that in a browser
VSCode shows the forwarded port:
In this sample application, I have a very basic example of connecting and read/writing to Mongo and Redis. Your application is likely more complex. Note that I did not explicitly start Mongo or Redis. They started when the dev container started.
Here's Docker Desktop showing the running containers:
It works with GitHub Codespaces. This was a big wow moment for me. The example I've presented here works out of the box with GitHub Codespaces.
Here it is running in my browser
I can have a working dev environment in the browser.
Some more tips
Features. I didn't cover it here, but you can add features to your devcontainer.json file. These are some extra pre-built scripts that run when your container is first built. In my experience, they do not benefit from Docker layer caching and run explicitly every time the container is built.
Only rebuild when you need to. Building the dev container the first time is slow. You should not need to do it often. When you open your dev container later, it should open very fast, restarting the container that is already built.
The container is ephemeral. Rebuilding the container should be a safe operation. If you need anything persistent in the container, use a bind mount or a volume in the docker-compose.yml file.
You can escape the dev container and return to your local environment with the command "Reopen folder locally" in VSCode, or in my case "Reopen Folder in WSL"
You can use multiple docker-compose.yml files. I didn't cover it here, you can read more about it. If you are already using docker-compose.yml to manage dev sidecars, you can use your existing file and extend it with a separate docker-compose.extend.yml file for your dev container. This might help make dev containers an opt-in experience for your team. A warning: paths are relative to the location of the first compose file in the list, so if your existing compose file is in the project root, then paths in the "extended" compose file, must also be relative to the project root.
A couple final caveats
I don't know a way to open a folder in the dev container in a single CLI command. I can run code my-folder
to open a folder, and then follow the prompt to reopen in dev container.
I haven't battle tested this with a full team yet. I have daily struggles with globally installed tools and version mismatches that I think will be greatly solved here. I don't have to fight with "how to install x on macOS" or on Windows, when I can have a consistent Ubuntu installation for each tool. But whether my teams agree on this remains to be seen.
You are forcing an opinionated environment on devs. Without support from other IDEs, only VSCode users will benefit. Also, my opinionated zsh configuration baked in to the example, may not be preferred by everyone on the team. Generally, though, agreeing as a team can speed up development even if it's not perfect for everyone.
References
Thank you!
I hope you find this helpful. Any feedback is welcome. I may be doing something a little wrong here and I'll be glad to update the example with improvements.