How to Quickly Get a Running Development Environment
You got an idea, and you want to start coding a proof-of-concept, but you are afraid to spent hours on setting up a development environment?
Devcontainers is a very nice tool to become quicker to get a running environment. It also provides a reproducible environment so that other developers can easily contribute to your project. Because Devcontainers starts a container, your development environment is isolated, and it is not polluting your host system with all kind of software.
In this article, I show an example how to set up a simple project in Go with Devcontainers.
Configuration of Visual Studio Code
For this example, I am using a Debian testing system and Podman. If you are not using Debian, it should also work with other Linux systems like Ubuntu or Fedora.
I have not tested other operating systems like macOS or Windows. For that reason, they are out of scope for this article. Please drop a comment and share your experience when you have experience with those systems.
I am in favor of Podman because I prefer a daemon-less alternative. Also, the fact that Podman does not need to run as root is a benefit compared to Docker.
In order to get Podman running, it needs to be installed on the Debian system.
If it is not installed yet, apt install podman podman-compose golang-github-containernetworking-plugin-dnsname
does fulfill this requirement.
I also installed podman-compose in case I want to use additional services and not just one container.
Visual Studio Code has defaults for Docker, and therefore it needs a reconfiguration for Podman.
So fare, the system should be ready for the next step with a concrete example.
How to set up a Devcontainer
I named this example hello-devcontainers
.
First step is to create an empty git repository with git init hello-devcontainers
and to switch into this newly created directory.
Next step is to create a file .devcontainer.json
within the root directory of your project with the following content:
When I open the project in Visual Studio Code, it usually asks me for (re-)building a devcontainer.
But there is also the option to start your container manually with Dev Containers: Rebuild Container
on the Command Palette (Ctrl+Shift+P).
Afterward it needs some waiting time until the container is built and started.
When the devcontainer has successfully started, Visual Studio Code reloads itself and connects to the container. The terminal within Visual Studio Code is directly attached to the container so that I can execute shell commands directly on the container. It can be used to compile your code, run your service or anything else.
For the next step, I need to initialize a proper Go module with go mod init hello-devcontainers
which creates the file go.mod
.
I create main.go
so that I can run a simple HTTP server.
1package main
2
3import (
4 "fmt"
5 "net/http"
6)
7
8func main() {
9 http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
10 fmt.Fprintf(w, "Hello, World!\n")
11 })
12
13 if err := http.ListenAndServe(":9080", nil); err != nil {
14 fmt.Println("Error starting server:", err)
15 }
16}
On the container’s terminal, I run this program with go run ./main.go
.
On the host system, I get a “Hello World” with curl 'http://localhost:9080'
as soon as the HTTP service is running.
All files of this example are available on GitHub Gist.
How to get a database connected
The example, from the previous section, is very simple. However, in practice this is not realistic. Usually, there are dependent services like a database. Therefore, I want to expand the previous example with MariaDB.
In the root directory, I create a docker-compose.yml
file with the following content:
1version: "3.8"
2
3services:
4 devcontainer:
5 image: mcr.microsoft.com/devcontainers/go:1
6 userns_mode: keep-id
7 volumes:
8 - .:/workspaces/hello-devcontainers:cached
9 command: sleep infinity
10 depends_on:
11 - db
12 links:
13 - db
14 environment:
15 DB_DSN: "hello:.test.@tcp(db:3306)/hello?charset=utf8&parseTime=True"
16 db:
17 image: docker.io/library/mariadb:11
18 restart: unless-stopped
19 environment:
20 - MARIADB_DATABASE=hello
21 - MARIADB_USER=hello
22 - MARIADB_PASSWORD=.test.
23 - MARIADB_ALLOW_EMPTY_ROOT_PASSWORD=1
24 volumes:
25 - ../data:/var/lib/mysql
26 ports:
27 - 3306:3306
Additionally, .devcontainer.json
file needs to be modified.
1diff --git a/.devcontainer.json b/.devcontainer.json
2index dbca232..fc0eec9 100644
3--- a/.devcontainer.json
4+++ b/.devcontainer.json
5@@ -1,6 +1,7 @@
6 {
7 "name": "hello-devcontainers",
8- "image": "mcr.microsoft.com/devcontainers/go:1.22",
9+ "dockerComposeFile": "docker-compose.yml",
10+ "service": "devcontainer",
11 "features": {
12 "ghcr.io/devcontainers-contrib/features/apt-get-packages:1": {
13 "packages": "inotify-tools,bash-completion"
14@@ -14,8 +15,5 @@
15 ]
16 }
17 },
18- "runArgs": [
19- "--userns=keep-id"
20- ],
21 "containerUser": "vscode"
22 }
git diff .devcontainer.json
For testing reason, I adjusted the HTTP server.
1package main
2
3import (
4 "database/sql"
5 "fmt"
6 "log"
7 "net/http"
8 "os"
9
10 _ "github.com/go-sql-driver/mysql"
11)
12
13func main() {
14 dsn := os.Getenv("DB_DSN")
15 if dsn == "" {
16 log.Fatal("DB_DSN environment variable is not set")
17 }
18
19 db, err := sql.Open("mysql", dsn)
20 if err != nil {
21 log.Fatal(err)
22 }
23 defer db.Close()
24
25 http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
26 fmt.Fprintf(w, "Hello, World!\n")
27
28 var result int
29 err = db.QueryRow("SELECT 1").Scan(&result)
30 if err != nil {
31 log.Fatal(err)
32 }
33
34 if result == 1 {
35 fmt.Fprintf(w, "MariaDB was responding!\n")
36 }
37 })
38
39 if err := http.ListenAndServe(":9080", nil); err != nil {
40 fmt.Println("Error starting server:", err)
41 }
42}
The difference is that this program does a connection to the database. Then it executes a very simple SQL statement to prove if this connection is correctly established. A complete example is available on the same GitHub Gist.
Troubleshooting
In case there is no connection possible between the containers, I advise to check first if the container is running.
In my case, I open the MariaDB client CLI with podman exec -ti hellodevcontainers_db_1 /bin/bash -c 'mariadb -h 127.0.0.1 -u root --password=$MARIADB_ROOT_PASSWORD $MARIADB_DATABASE'
.
Please make sure, you are connecting to the correct container in case you copy and paste this command.
podman ps
shows you all running containers, and it should contain the MariaDB container as well.
On my Debian system, I had the issue that DNS was not working within the containers itself.
This is annoying because I do not want to rely on hard-coded IP addresses.
The package golang-github-containernetworking-plugin-dnsname
is a plugin that sets up DNS resolution by using Dnsmasq.
With this plugin containers are resolvable by their names.
I recommend installing bind9-dnsutils
for debugging DNS issues within containers (e.x. nslookup db
).
How to run Devcontainers without Visual Studio Code
At this point, I would like to introduce DevPod. DevPod is a very young open source project that I found on GitHub. The first commit was made in February 2023, so very young. The project is still in heavy development, which means that some features are not yet quite stable. Still, when I used the tool, it worked surprisingly well. In particular, it worked well on macOS, which helped me a lot to simplify the setup on that system. At this point in time of writing this blog article, I am using version v0.5.5.
Of course, you can also use Visual Studio Code with DevPod, but you do not depend on it. Instead, you can choose your favorite editor.
Interesting features are to prebuild container images upfront and customization of personal workspaces on the container. There is seen a benefit in case your work in a team. Every team member can set up a working environment with personal dotfiles. At the same time, everyone gets a standardized working environment with preconfigured linting or formatting rules.
Goal of the project is that you do not run into a vendor lock-in. It is possible to run the development environment on the local system or on a server. There are several providers out there you can choose from. Writing an own plugin for a specific cloud services or for a self-hosted server is easily possible. I wrote a simple plugin for vagrant which was not too complicated as a proof-of-concept to convince myself. Feel free to check it out my plugin and give me some feedback.
Technically, it uses ssh for the connection to the development environment.
Conclusion
With Devcontainers, I managed to simplified my development environment a lot. I am very satisfied with it. Because Visual Studio Code is usually my first choice, this setup fits very well into my working mode. Also, Devcontainers helped me a lot to set up a working environment which looks similar than production.
Another advantage is to reduce the effort into custom installation routines (for example: curl -sSLf url-to-a-script | sudo sh
).
With Devcontainers Containers, I have less friction because I can just download the latest containers.
As the next step, I want to uninstall a whole series of software from my laptop. Less software on the host system increases security on that system and I want to profit from it.
There are more things I have not tried out yet. For example, I have not looked at GitHub Codespaces. Another point is that I want to test how Docker-in-Docker works, especially with Podman. This would be beneficial to test a small Kubernetes cluster for example. Also, it would be interesting to see if nerdctl could be used instead of Podman or Docker.
How does your working environment look like when you start a small software project? I would highly appreciate hearing from you and your experience. Please, send me a comment or give me a thumbs up in the section below.