Ian Cleary RF Systems Design Engineer

Satellite Logo

Why abstraction is often better than automation

The Visual Studio Code Dev Containers extension lets you use a Docker container as a full-featured development environment. It allows you to open any folder or repository inside a container and take advantage of Visual Studio Code's full feature set. A devcontainer.json file in your project tells VS Code how to access (or create) a development container with a well-defined tool and runtime stack. This container can be used to run an application or to provide separate tools, libraries, or runtimes needed for working with a codebase

Container Architecture from Visual Studio Code Dev Containers Documentation

I learned about the Visual Studio Code Dev Containers extension and I am a big fan of it.

For a long time, I maintained a ansible playbook of my development environment.
It was a lot of work to maintain and it was not easy to share with others.

Abstraction is valuable

Most of the time, I don't want to write complicated setup instructions for a project that have a tendency to become out of date as time goes on.

I want to ensure my team gets started quickly, repeatably, and easily.

My users and team members often just want to use the tool; they don't want to spend time setting it up.

Does this work on Windows? What version of python do I need? But I have a Mac?

Automating the setup is great, but it can be brittle and there are many edge cases. Abstracting away the setup of the development environment is a great way to solve this problem.

This is a problem to solve once, commit to version code, and to update/maintain in version control as needed.

5 Reasons to use Dev Containers

  1. Consistent Development Environment - The devcontainer.json file defines the development environment for the project. This means that everyone on the team will have the same development environment. This is especially useful for onboarding new developers to the team.

  2. Easy to get started - The devcontainer.json file can be generated from the command palette. This makes it easy to get started with a devcontainer.

  3. Easy to share - The .devcontainer folder can be shared with the team within every git repository that requires certain software to be used. This makes it easy to share and document the development environment.

  4. Private or public - The Docker image used can be hosted in the cloud or on-premise to suite your needs. That make it easy to have a common tailored image for your team needs.

  5. Cross Platform - The devcontainer.json file can be used on Windows, Mac, and Linux. This makes it easy to have a consistent development environment across all platforms.

Note: The performance overhead of using a devcontainer is minimal, but can sometimes be an issue depending on the amount of I/O done if not using docker-compose to cache build files. My recommendation is to use a Linux Host with a Linux Container for the best performance, as there is no virtual machine needed by Docker Desktop (as Windows and MacOS use a VM).

How to get started

Visual Studio Code has great getting started documentation on this topic, so I will primarily defer to them.

For Windows or Mac, install Docker Desktop. For Linux, install docker. Install Visual Studio Code. Install the Visual Studio Code Dev Containers extension.

Open the repo, click the pop-up that says open in containner, and you are ready to go!

Note: In order to comply with the Docker Terms of Service, you may need to acquire a Docker Desktop business license depending on the purpose of the work/project and the size of your company.

Performance Gotchas

Use docker-compose to cache I/O senstive folders

Using a docker-compose file to start the devcontainer is a better option than just using the devcontainer.json file.

There are a couple benefits:

  • Cache your local folders (node_modules, .next, .venv, .mypy_cache, pytest_cache, etc.) on the host, so you don't have to download them every time you start the devcontainer.
  • Declare your complete development environment in docker-compose files.

Example

Note: This is an example of a devcontainer setup I use for my personal projects. It is not meant to be a one-size-fits-all solution. It is what I use for this website.

.devcontainer/devcontainer.json

{
"name": "iancleary-me",
"remoteUser": "vscode",
"dockerComposeFile": ["docker-compose.devcontainer.yml"],
"service": "iancleary-me",
"workspaceFolder": "/workspace/",
"customizations": {
"vscode": {
"extensions": [
"unifiedjs.vscode-mdx",
"dbaeumer.vscode-eslint",
"bradlc.vscode-tailwindcss",
"GitHub.copilot",
"streetsidesoftware.code-spell-checker"
]
}
}
}
{
"name": "iancleary-me",
"remoteUser": "vscode",
"dockerComposeFile": ["docker-compose.devcontainer.yml"],
"service": "iancleary-me",
"workspaceFolder": "/workspace/",
"customizations": {
"vscode": {
"extensions": [
"unifiedjs.vscode-mdx",
"dbaeumer.vscode-eslint",
"bradlc.vscode-tailwindcss",
"GitHub.copilot",
"streetsidesoftware.code-spell-checker"
]
}
}
}

Notes:

  • The service name is also present in the docker-compose.devcontainer.yml file.
  • The name appears in the user interface of the VS Code Client in the bottom left corner.

Generally speaking, I only change the name and service name in the devcontainer.json file.
I also add same service text as the name of the service in docker-compose.devcontainer.yml file.

Reference documentation for devcontainer.json.

.devcontainer/docker-compose.devcontainer.yml

version: '3'
services:
iancleary-me:
image: "ghcr.io/iancleary/devcontainer:v0.0.18"
volumes:
# paths are relative to the first docker-compose
# in the list in devcontainer.json
# since this is the only file in that list,
# this mounts the project folder (up one level)
# to '/workspace'.
- ..:/workspace:cached

# [Optional] Required for ptrace-based debuggers
# like C++, Go, and Rust
cap_add:
- SYS_PTRACE
security_opt:
- seccomp:unconfined

# Overrides default command so things
# don't shut down after the process ends.
command: /bin/sh -c "while sleep 1000; do :; done"
version: '3'
services:
iancleary-me:
image: "ghcr.io/iancleary/devcontainer:v0.0.18"
volumes:
# paths are relative to the first docker-compose
# in the list in devcontainer.json
# since this is the only file in that list,
# this mounts the project folder (up one level)
# to '/workspace'.
- ..:/workspace:cached

# [Optional] Required for ptrace-based debuggers
# like C++, Go, and Rust
cap_add:
- SYS_PTRACE
security_opt:
- seccomp:unconfined

# Overrides default command so things
# don't shut down after the process ends.
command: /bin/sh -c "while sleep 1000; do :; done"

Custom Software

Your team can install the software they need in the Dockerfile. This makes it easy to have a consistent development environment across all platforms.

Python, node, npm, rust, pipx, pre-commit, pdm are installed in the my devcontainer's Dockerfile.

docker pull ghcr.io/iancleary/devcontainer:v0.0.18
docker pull ghcr.io/iancleary/devcontainer:v0.0.18

Note: when used in projects, pin the version, don't use latest.

This image is used for development of Python, Rust, and Node projects. I prefer to keep my operating system clean and use containers for development.

One could make separate images for each language, but I prefer to have one image for all my development needs as the size of this alpine based image is small enough for my needs.

Be careful of scope-creep and keep the image small and focused on the needs of your team. It is easy to add more software, but it is hard to remove software once it has been added. It is easy to say you will come back to it later, but you never do.

Windows Hosts

To help ensure the code application picks up the correct SSH agent, we need to tell Visual Studio Code to use the SSH agent bundled with Git for Windows.

ssh-agent

Open the settings pane in Visual Studio Code and search for remote.SSH.path.

Set remote.SSH.path to the absolute path of ssh.exe in Git for Windows.

For me, it is C:\Program Files\Git\usr\bin\ssh.exe.

Agent Forwarding

We also need to enable agent forwarding for all hosts in ~/.ssh/config:

# ~/.ssh/config
Host *
ForwardAgent yes
# ~/.ssh/config
Host *
ForwardAgent yes

Start the agent locally and add keys: eval "$(ssh-agent -s)"

Open VS Code from Git Bash so it sees the SSH_* env vars: code .

Reopen the workspace in a dev container.

Verify that SSH_AUTH_SOCK env var is defined in the container and try to list keys:

vscode@3e22ae244d5e:~# `echo $SSH_AUTH_SOCK`
vscode@3e22ae244d5e:~# `echo $SSH_AUTH_SOCK`

Alternatively, you can check if your ssh-keys are loaded

ssh-add -l
ssh-add -l