TLDR: Maybe, but it's still a bit clunky.

What are Dev Containers you might ask? Elementary, my dear Watson* / fellow developer.

It’s a fancy way of using container images as a full blown development environment. You can read containers.dev for more details, but the key IMHO that is missing on the homepage, is that it can run actual compose.yaml (popularised by docker-compose) as an entry point.

You can consider this post a follow up A Go(lang) journey in live reload and pretty panics. As I was writing tests, I realised that by just starting docker compose, there’s no easy way to have the tests also run in the container while also allowing the usage of the built in debugger in vscode.

I decided to give devcontainers a go, and see how they would work. The idea as mentioned in the intro is pretty nice since it allows you to:

  • use a compose.yaml as your actual entry point to the system
  • setup the required extensions in vscode (not strictly in the spec, but a nice to have)
  • have the tests run in the actual container, so you don’t need to worry about different golang versions (or any other problems you might have with project specific packages)

You could argue that compared to the old-school setup of setting up your images, and then using them in something like Vagrant, this is a newer approach in which composability (ha, the puns) helps you build your development environment from various pieces.

Devcontainers Development

If your database has different dependencies and packages from your queue system this makes it an non issue as each runs in its own container. Same goes for your test mailing system, queue system and whatever other dependency you will add in the future.

That brings us to the following config:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
{
    "name": "API",
    "dockerComposeFile": [
        "../compose.yml"
    ],
    "service": "api",
    "workspaceFolder": "/go/src/app",
    "initializeCommand": "./scripts/devcontainer.sh",
    "shutdownAction": "stopCompose",
    "remoteUser": "devuser:devuser",
    "customizations": {
        "vscode": {
            "settings": {
                "terminal.integrated.shell.linux": "/bin/bash"
            },
            "extensions": [
                "ms-azuretools.vscode-docker",
                "golang.Go",
                "eamodio.gitlens",
                "timonwong.shellcheck",
                "tamasfe.even-better-toml"
            ]
        }
    }
}

After you take a quick look at the config, you’ll notice an interesting key called initializeCommand . That is being used to actually solve a thing / bug / annoyance or whatever you want to call it.

Usually when you run inside a container, you mount your application code and then the user that container image is built with is the root user. That makes it annoying for a very simple reason, the owner of the file in WSL is going to be root, making it a pita to do cleanup and/or editing of the vscode workspace when the docker engine (in my case) doesn’t work.

To fix that, I’m semi abusing the functionality provided by compose.yaml to provide build arguments to the base image. The devcontainer.sh looks like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#!/bin/bash

# Get the current user and group ID in WSL
PROJECT_DOCKER_USERNAME=$(id -u)
PROJECT_DOCKER_GROUPNAME=$(id -g)

# The comment to add to the .env file
COMMENT="# The following two environment variables are needed for container to host permission interoperability"

# If the .env file exists
if [ -f .env ]; then
    
    # If the variables exist in the .env file, remove them
    if grep -q "PROJECT_DOCKER_USERNAME=" .env; then
        sed -i "/PROJECT_DOCKER_USERNAME=/d" .env
    fi

    if grep -q "PROJECT_DOCKER_GROUPNAME=" .env; then
        sed -i "/PROJECT_DOCKER_GROUPNAME=/d" .env
    fi

    # If the comment exists in the .env file, remove it
    if grep -qF "$COMMENT" .env; then
        sed -i "\|$COMMENT|d" .env
    fi
fi

# Add the comment and variables to the .env file
{
    echo "$COMMENT"
    echo "PROJECT_DOCKER_USERNAME=$PROJECT_DOCKER_USERNAME"
    echo "PROJECT_DOCKER_GROUPNAME=$PROJECT_DOCKER_GROUPNAME"
} >> .env

This allows the creation / reuse of the local .env file that gets automatically used by docker as a source for your ENV variables making the usage in compose.yaml painless.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
api:
  build:
    context: .
    dockerfile: Dockerfile
    target: api
    args:
      # Pass the user and group id as build arguments
      USER_ID: ${PROJECT_DOCKER_USERNAME:-1000} # default wsl initial user in case we mess it up
      GROUP_ID: ${PROJECT_DOCKER_GROUPNAME:-1000}
  depends_on:
    db:
      condition: service_healthy # secret db :P
  working_dir: /go/src/app
  ports:
    - 8080:8080
  volumes:
    - ./:/go/src/app
  cap_add:
    - SYS_PTRACE
  security_opt:
    - seccomp:unconfined
  user: ${PROJECT_DOCKER_USERNAME}:${PROJECT_DOCKER_GROUPNAME}

Now we get to the Dockerfile itself. It’s technically a container image, but for all intents and purposes you shouldn’t treat it different than a normal VM. This is a development environment, so it is going to need all kinds of packages, especially for support tools like git.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
FROM golang:1.20-bookworm AS api

ARG USER_ID
ARG GROUP_ID

# let's add the group / user based on the ARG's passed by compose.yaml
RUN addgroup --gid $GROUP_ID devuser
RUN adduser --uid $USER_ID --gid $GROUP_ID --disabled-password --gecos '' devuser

RUN apt-get install -y \
    git # and other packages you might need

WORKDIR /go/src/app

# A bunch of golang tools needed by vscode and our app
RUN go install github.com/cosmtrek/air@latest \
    && go install github.com/maruel/panicparse/v2@latest \
    && go install golang.org/x/tools/gopls@latest \
    && go install github.com/go-delve/delve/cmd/dlv@latest \
    && go install honnef.co/go/tools/cmd/staticcheck@latest

# Copy Go modules and dependencies to image
COPY go.mod ./

# Download Go modules and dependencies
RUN go mod download

# Copy directory files
COPY . ./

RUN chown devuser. ./

USER devuser # finally let's switch to the devuser

CMD ["/bin/bash", "scripts/dev.sh"]

After all is set and done, it is a pretty nifty and useful setup, since it makes the onboarding in your application a lot more approachable, and developers can just open it in vscoode and have a fully working environment.

With additional support from Jetbrains and other tools it will become hopefully more wide spread and used in other editors and ides, making the life of a DX (developer experience) engineer a lot easier (or however your organisation calls the hard working people that work on allowing your developers to focus on you know… development).

If this doesn’t work for you, you can buy me a beer and tell me how to improve it ;-).

* Yes, Sherlock never said that, but it's pop culture :P.