Dev Containers provide an excellent way to create portable, consistent development environments for your engineering teams. By defining the required languages, tools, services, and configurations in a Dockerfile, developers can spin up sandboxed environments that contain everything they need to be productive. This eliminates many of the "works on my machine" problems that can waste time and breed frustration when working on shared projects.
However, creating well-configured Dev Containers from scratch requires expertise in several domains - Docker, Linux, infrastructure automation, VS Code extensions, and more. The configuration is done via Bash scripts specified in the Dev Container format, which can be complex and tedious to write manually.
In this article, we will explore both the benefits and challenges of using Dev Containers, including a hands-on walkthrough of configuring a Python Flask app. We will also discuss the need for Dev Container helper libraries to simplify the process of authoring robust and reusable configurations.
Grab a GitHub repo here: https://github.com/metcalfc/simple-flask
Dev Container Benefits
Dev Containers provide several key benefits:
Portability - The environment travels with the code repository, enabling a consistent experience across different machines.
Encapsulation - Dependencies and configurations are encapsulated within the container, avoiding conflicts with the host or between projects.
Isolation - Containers provide a sandboxed environment isolated from the rest of the system.
Speed - Containers utilize layers and caching to initialize faster than virtual machines.
Flexibility - Different components like languages and tools can be compose into the desired environment.
Sharing - Dev Containers can be shared and reused across teams and organizations.
For example, a data science team could create a Dev Container with Python, Jupyter Lab, and common ML libraries baked in. This stable environment can then be reused by all data scientists in the organization, ensuring consistency and avoiding dependency conflicts.
Walkthrough: Python Flask App
Let's walk through configuring one for a simple Python Flask web application to see Dev Containers in action.
Create Flask app: We'll start by creating a simple
app.py
that just returns "Hello World" and some Daytona metadata:
1from flask import Flask2import socket34app = Flask(__name__)56@app.route('/')7def hello():8 return 'Hello, World!' + '\n' + str(socket.gethostname()) + '\n' + 'Daytona Rocks!'910if __name__ == '__main__':11 app.run(host='0.0.0.0')
2. Add Dockerfile: Next, we'll create a Dockerfile that installs Python and configures the container to run the app:
1FROM python:3.72WORKDIR /app3COPY . .4RUN pip install flask5EXPOSE 50006CMD ["python", "app.py"]
3. Convert to Dev Container: We'll convert this to a Dev Container format with a few tweaks:
Use a development-focused Python image
Install needed apt packages like git
Set VS Code Server as the default shell
1{2 "dockerFile": "Dockerfile",3 "customizations": {4 "vscode": {5 "settings": {},6 "extensions": []7 }8 },9 "containerEnv": {10 "FOO": "bar"11 },12 "postCreateCommand": "pip install -r requirements.txt",13 "remoteUser": "vscode",1415 "features": {16 "ghcr.io/devcontainers/features/github-cli:1": {}17 }18}
Rebuild on changes: Now when we make a change like installing a new package, we can rebuild the container to test it:
1devcontainer> git add .2devcontainer> git commit -m "Install cowsay"3devcontainer> git push45# Rebuild container6devcontainer> devcontainer rebuild
The container will rebuild with our changes and reconnect automatically, without having to start over!
Dev Container Challenges
However, while Dev Containers provide an excellent end-user experience, creating them still poses some challenges:
Bash scripting expertise required: All configuration is done through Bash scripts specified in the template. This requires Linux, Docker, Bash proficiency.
IDE integration complexity: Enabling full VS Code/IDE functionality requires special configuration like shell overrides, which can be tricky.
Lots of boilerplate: Common tasks like package installs, directory setup have to be reimplemented per container.
Idempotence issues: Scripts may be rerun and need to handle that gracefully.
Documentation dysync: Outdated docs and images lead to headaches.
Sharp edges: Care must be taken with paths, exit codes, permissions, and more.
While an experienced DevOps engineer may be comfortable navigating these complexities, they pose a significant barrier to rapid Dev Container authoring for many developers.
Need for Helper Libraries
To simplify and streamline Dev Container creation, we need to provide developers with helper libraries that abstract away common implementation details.
These libraries should handle concerns like:
Multi-OS support - Debian, Ubuntu, Alpine
Multi-architecture - x86, ARM
Idempotence guarantees
Permission and path management
Package installation and management
Bash scripting best practices enforcement
VS Code/IDE integration
Documentation and implementation alignment
With robust helper libraries, developers could create Dev Containers by composing high-level constructs representing the needed components. The complexity would be handled under the hood.
For example, we could install node.js with:
1devcontainer-helpers install_nodejs {2 version: "14"3}
Rather than hundreds of lines of complex Bash scripting!
Initial Exploration
As a starting point, we created an example Dev Container from scratch to build intuition. We initialized a Git repository and added the configuration files to spin up a Ubuntu-based container with Python, pip, and VS Code:
1# Initialize Git repo2git init34# Add Dev Container files5devcontainer init67# Open in container8devcontainer rebuild
To experiment with testing helper functions, we added the BATS testing framework and created a simple test for a check_file_contains
function:
1# Helper function2function check_file_contains() {3 if ! grep -q "$1" "$2"; then4 return 15 fi6}78# BATS test9@test "Check for foo in file" {10 check_file_contains "foo" "/path/to/file"11}
We successfully executed the test against our function within the Dev Container environment.
Conclusion
In closing, Dev Containers provide teams with a powerful way to standardize development environments and eliminate issues caused by gaps between environments. However, complexity in implementation details like Bash scripting makes creating custom configurations time-consuming.
Helper libraries that abstract these details and provide easy-to-use functions for common tasks could dramatically improve the authoring experience. Our initial example shows the potential for testing simple utilities as a starting point before building up more advanced helpers.
There is a significant opportunity for tools that bridge the gap between the simplicity of the Dev Container format itself and the underlying mechanics of bringing configurations to life. By raising the level of abstraction, we can help more developers take advantage of this impactful technology.
You can also watch the live stream I did around this exploration: