Using Docker for Occasional Linux Access

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.

Dockerfile

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

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_FRONTEND env var and the -y flag, you may be prompted for input during installation.)
  • Base images like ubuntu:20.04 are 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.

Docker-Compose

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.

Using it

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.

What Else?

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.