Post

DevContainers: Migrating my entire Rust setup

Introduction

I’ve been using Rust for a long time, and I love it, we all know what it offers so lets not focus on it. I’m also a VSCode enjoyer, and recently I found about DevContainers on a release note of VSCode. I was curious about it, so I decided to investigate more, and it definitely caught my attention.

What are DevContainers?

As per the official documentation: A development container (or dev container for short) allows you to use a container as a full-featured development environment. It can be used to run an application, to separate tools, libraries, or runtimes needed for working with a codebase, and to aid in continuous integration and testing.

If there’s something that I loved about DevContainers, is that they do have a specification. That’s all. Having a specification means that you can define your development environment in a declarative way, and then you can share it with others, or use it on different projects. Basically, work one time, use it forever.

The idea is to have a container with all the tools you need to work on a project, and everyone using it will have the same environment, no matter the OS they are using. That’s great for teams, but also for personal projects, as you can have a clean environment for each project you are working on, or a shared one that doesn’t mess with your bare system.

Why I decided to migrate?

I have a lot of projects in Rust, and I ended up with a lot of tools installed on my system using cargo (which means that they aren’t tracked by the system’s package manager), that’s not something I like. There are also projects that require a specific version of Rust, adding another layer of complexity to the setup. All of this, on a non-declarative way, which means that I can’t just clone a project and start working on it, I need to remember to install the tools, and the correct version of Rust.

So, I decided to give DevContainers a try, and I’m very happy with the results.

How I did it?

The first thing, was reading the specification and understanding how it works, then I created a .devcontainer/devcontainer.json file on my project’s root, and started playing with it. There are a lot of available templates to work with (including a Rust + Postgres one), which I highly recommend you to check out, but as I was more interested on learning how it works, I decided to start from scratch.

The specification documentation is great, and I was able to create a basic setup in a few minutes. I took the official Rust image from Docker Hub, created a Dockerfile that installs the tools I need, and then created the devcontainer.json file that points to the Dockerfile on the build step, and added some custom settings.

The Dockerfile 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
FROM rust:latest
# Install the development dependencies
RUN apt-get update && apt-get upgrade -y && apt-get install -y build-essential curl git libssl-dev pkg-config make postgresql-client \
    postgresql clang lld sudo vim bash-completion && apt-get clean && rm -rf /var/lib/apt/lists/*
# Create a non-root user
ENV USER_NAME=vscode
RUN useradd -m $USER_NAME -s /bin/bash
USER $USER_NAME
# Install nightly toolchain and rustfmt for it and the stable version
RUN rustup toolchain install nightly --component rustfmt clippy && rustup component add rustfmt clippy \
    && cargo install diesel_cli cargo-edit cargo-update cargo-audit cargo-udeps && mkdir -p /home/$USER_NAME/workspace
# Copy .bash_aliases, it contains several useful aliases for cargo
COPY configs/.bash_aliases /home/$USER_NAME/.bash_aliases
# Detect the postgres version and set the volume
ENV POSTGRES_VERSION=15
VOLUME /var/lib/postgresql/$POSTGRES_VERSION/main
# trust all local connections to postgres
COPY configs/pg_hba.conf /etc/postgresql/$POSTGRES_VERSION/main/pg_hba.conf
# Allow the user to run sudo without password, generate locales and set the default one
USER root
RUN echo "$USER_NAME ALL=(ALL:ALL) NOPASSWD: ALL" | tee /etc/sudoers.d/$USER_NAME && \
    echo "en_US.UTF-8 UTF-8" > /etc/locale.gen && locale-gen && update-locale
# Delete all the cargo cache and the apt cache
RUN rm -rf /usr/local/cargo/registry && apt-get clean

And the devcontainer.json file 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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
{
    // For format details, see https://aka.ms/devcontainer.json. For config options, see the README.md of this repo.
    "name": "rust_devcontainer",
    "build": {
        "dockerfile": "Dockerfile"
    },
    "customizations": {
        "vscode": {
            // Add the settings to use bash as the default shell
            "settings": {
                "terminal.integrated.shell.linux": "/bin/bash"
            },
            // Add the extensions that I use
            "extensions": [
                "rust-lang.rust-analyzer",
                "ms-vscode.cpptools",
                "vadimcn.vscode-lldb",
                "ms-vscode.cmake-tools",
                "twxs.cmake",
                "fill-labs.dependi",
                "tamasfe.even-better-toml",
                "GitLab.gitlab-workflow",
                "ms-ossdata.vscode-postgresql",
                "mtxr.sqltools",
                "mtxr.sqltools-driver-pg"
            ]
        }
    },
    // Set the workspace folder and the mount
    "workspaceFolder": "/home/vscode/workspace",
    "workspaceMount": "source=/var/local/development,target=/home/vscode/workspace,type=bind,consistency=cached",
    // Handle CARGO_HOME=/usr/local/cargo creating a volume for the cargo data to persist between runs
    "mounts": [
        {
            "source": "cargo-cache-rust_devcontainer",
            "target": "/usr/local/cargo",
            "type": "volume"
        },
        // Handle the postgres data directory
        {
            "source": "postgres-rust_devcontainer",
            "target": "/var/lib/postgresql/15/main",
            "type": "volume"
        }
    ],
    // Add additional arguments to the container
    "runArgs": [
        "--restart=always",
        "--name=rust_devcontainer"
    ],
    // Start the postgres service on the postStartCommand
    "postStartCommand": "sudo service postgresql start",
    // Tell VSCode to use the non-root user
    "remoteUser": "vscode"
}

As you can see in the workspaceMount, I wanted a shared container for most of my projects, so I created a symlink from the folder where these Rust projects were to /var/local/development, installed devcontainer-cli, ran devcontainer up --workspace-folder ., and finally attached to the container on VSCode. Everything was working as expected, just opened my workspace and started working on it.

Note: The ideal setup would be a container per specific Rust version required, then attach all the matching projects to it. That’s what I ended up doing. Using a container per project is also a good idea, just have in mind that you will have a lot of VSCodes running because you can only attach to one container per instance.

That can be easily achieved by just adding a .devcontainer/devcontainer.json to a project, then modifying the options that you want, take a look at my example.

Conclusion

I’m very happy with the results, I can now work on my projects without worrying about the tools I need, the version of Rust that I need to use, or the OS where I’m at (not gonna lie, I use the same machine 99% of the time, but still). I can also share the environment with others, and they will have the same environment as me. I highly recommend you to give DevContainers a try, it’s a great tool that will make your development a lot easier.

All the resulting code for this project is available on Github, and the resulting image is available on GHCR.

Important: this project is just an showcase, and even when I’m going to keep this updated, and you are free to use, modify, and share it, I highly recommend you to check the official templates to get started.

Happy coding! 🦀

This post is licensed under CC BY 4.0 by the author.