I do most of my development work natively on MacOS, but I have an occasional need for a Linux build environment. In the past, I’d SSH to a Linux machine, or perhaps fire up a VM and set up a new build environment in there. These days I use Docker.
The approach is pretty simple, so it makes for a good introduction to Docker. I’ll show you how to add an easily accessible Dockerized Linux environment to your existing project.
First, in a new directory, we’ll add a Dockerfile. This describes the operating system that we want to use, with arbitrary changes we want to apply:
# Dockerfile FROM ubuntu:20.04 RUN apt-get update -y && DEBIAN_FRONTEND=noninteractive apt-get install -y bash-completion vim make gcc # un-parenthesize these slashes (CMS issue): RUN echo "source /etc(/)profile.d(/)bash_completion.sh" >> ~/.bashrc
Ubuntu is a convenient starting place, but there are several other options. If you go with Ubuntu:
- Make sure to tell apt that you really want it to be non-interactive. (Without both the
DEBIAN_FRONTENDenv var and the
-yflag, you may be prompted for input during installation.)
- Base images like
ubuntu:20.04are usually kept pretty small for the more-typical use case of packaging applications for deployment. Since we’re using Docker for development, you may want niceties like your favorite editor and smarter shell completion.
We can do a variety of things with a Dockerfile: build it, tag it, publish it, but all we really want today is to run it. So we’ll reference it directly from our docker-compose file:
# docker-compose.yml version: "3.8" services: dockerized-build: build: context: .. dockerfile: ./dockerized-build-env/Dockerfile volumes: - type: bind source: ../ target: /app
This file keeps track of runtime configuration decisions like network ports and filesystem mounts. Here, we bind mount the project’s root directory so we can access everything from inside the container.
We’re referencing the Dockerfile directly, which will rebuild as needed, but instead, you could reference an image that you build on a deliberate schedule.
Lastly, to avoid having to remember any commands, here’s a Makefile:
# Makefile linux: cd dockerized-build-env; docker-compose run -w /app dockerized-build .PHONY: linux
…and that’s it! Now, while working in MacOS, we can
make linux to pop over and try something out:
jrr@jrrmbp ~/foo> uname Darwin jrr@jrrmbp ~/foo> make gcc -o hello hello.c jrr@jrrmbp ~/foo> file hello hello: Mach-O 64-bit executable x86_64 jrr@jrrmbp ~/foo> ./hello Hello, World! jrr@jrrmbp ~/foo> make clean rm -f hello jrr@jrrmbp ~/foo> make linux cd dockerized-build-env; docker-compose run -w /app build-env Creating dockerized-build-env_build-env_run ... done root@897e963b7bea:/app# uname Linux root@897e963b7bea:/app# make gcc -o hello hello.c root@897e963b7bea:/app# file hello hello: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=8cba3c632621b559ed192f211dd03b5056bf8d9a, for GNU/Linux 3.2.0, not stripped root@897e963b7bea:/app# ./hello Hello, World! root@897e963b7bea:/app#
Though I’m using MacOS, you can do this in Windows, too.
This is a minimal-viable use case for Docker and can be a great way to dip your toe in, but there is much more you can do:
- Once you have a Docker image that contains everything you need to develop your app, you might want to use it for CI.
- If you find this isolated, deterministic, reproducible build environment to be useful, you could go all in and use it as your primary development environment.
- Package your app as a Docker image. It’s portable: Docker images run on Windows, Mac, and Linux, and can be deployed to various cloud hosts.
- Time travel: dial the upstream image back a few versions, and go back to the halcyon days when your old project’s native dependencies could still compile.
An example repo demonstrating the content of this post can be found on GitHub.