thoughts | THEDEN

Building a Local Linux Development Environment with Docker and Make

Often I’m on a MacBook Pro and need a sandboxed local linux environment that’s fast. Before, I’d rely on Vagrant—often running vagrant up && vagrant ssh, but in my experience it was often too slow and flaky for me.

Last year I switched vagrant with docker for my local linux environment and it’s worked pretty well insofar as productivity goes. So much that I’ve managed to write a linux network monitor using this setup. In this post, we’ll recreate the setup by building a Dockerfile and a Makefile and see how they can work together for what I think is an elegant workflow.

Dockerfile

Let’s start building our Dockerfile line by line.

Parent Image

We’ll use ubuntu:16.04 from the official Canonical repo

FROM ubuntu:16.04

Using RUN for our base packages

When building the Ubuntu image, we want run apt-get update and upgrade to ensure the packages are up to date.

RUN apt-get update && apt-get -y upgrade

We might as well chain the commands with && to install any additional base packages we would need for our environment

RUN apt-get update && apt-get -y upgrade \
 bash-completion \
 build-essential \
 curl \
 git \
 git-core \
 golang \
 htop \
 locales \
 man \
 nmap \
 python3-pip \
 ruby-full \
 strace \
 sudo \
 tig \
 vim \
 wget

Note the usage of \ so we can have linebreaks for each package. This is useful for when you want to add or remove packages.

Locales

You may have noticed we installed the locale package. This lets us run the locale-gen command that we can use to generate localisation files from templates. Let’s say we want to add en_US.UTF-8 as an available locale

RUN locale-gen en_US.UTF-8

The full list is in /etc/locale.gen. During runtime the LANG environment variable can be used to set a locale that’s needed.

Configuring sudo and disabling root as default

Let’s add the default user ubuntu and enable sudo

# Disable password and not ask for finger info
RUN adduser --disabled-password --gecos '' ubuntu
RUN adduser ubuntu sudo
RUN echo '%sudo ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers

Breaking it down:

Setting ubuntu as the entry USER

Since we don’t want the image to default to root, and the operations from now won’t require root, we’ll set the user to ubuntu

USER ubuntu

Hiding login banner messages

If you don’t want to see the default banner messages whenever you run a container interactively, we can create a .hushlogin file which will hide the motd (message of the day) or anything that’s stored in /etc/bash.bashrc

# Hush login messages
RUN touch ~/.hushlogin

In this container, it hides the default sudo message—if you’re curious how it works, here’s the code block that checks for .hushlogin by default in /etc/bash.bashrc

# sudo hint
if [ ! -e "$HOME/.sudo_as_admin_successful" ] && [ ! -e "$HOME/.hushlogin" ] ; then
    case " $(groups) " in *\ admin\ *|*\ sudo\ *)
    if [ -x /usr/bin/sudo ]; then
  cat <<-EOF
  To run a command as administrator (user "root"), use "sudo <command>".
  See "man sudo_root" for details.

  EOF
    fi
    esac
fi

Setting the WORKDIR

From the docs

The WORKDIR instruction sets the working directory for any RUN, CMD, ENTRYPOINT, COPY and ADD instructions that follow it in the Dockerfile

In our case, we’ll set it to /home/ubuntu

WORKDIR /home/ubuntu

With all that, our basic Dockerfile is ready

FROM ubuntu:16.04
	
RUN apt-get update && apt-get -y upgrade \
 bash-completion \
 build-essential \
 curl \
 git \
 git-core \
 golang \
 htop \
 locales \
 man \
 nmap \
 python3-pip \
 ruby-full \
 strace \
 sudo \
 tig \
 vim \
 wget
 
RUN locale-gen en_US.UTF-8
 
# Disable password and not ask for finger info
RUN adduser --disabled-password --gecos '' ubuntu
RUN adduser ubuntu sudo
RUN echo '%sudo ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers

USER ubuntu

# Hush login messages
RUN touch ~/.hushlogin

WORKDIR /home/ubuntu

We can build it by running this command in the same directory

$ docker build -t <image-name> .

and to get an interactive shell in a container

$ docker run -it <image-name> bash

But this would be cumbersome to type every time, so we’ll write a logical Makefile to help things easier.

Making a Makefile

Let’s go line by line for our Makefile, not unlike what we did for the Dockerfile. First we’ll create a file called Makefile in the same director as the Dockerfile, and add these variables in the header

IMAGE?="docker-dev-env"
COMMITIMAGE="docker-dev-env-commited"
MOUNTDIR?=$(shell pwd)

The ?= operator means that if no value is given for the variable, it will default to what is set—so for example with MOUNTDIR, it will default to the current working directory by invoking $(shell pwd).

Build Targets

Lets’ write the build target

build:
  docker build --rm -t $(IMAGE) .

The IMAGE variable is passed in the command. The --rm flag ensures that docker removes intermediate containers after a successful build.

Run targets

For our basic run target

run:
  docker run -it -P $(IMAGE) bash

It will run the container with a bash interactive shell. The -P flag ensures the container will publish all exposed ports to the host interfaces (useful for when you’re testing a web service locally).

Let’s make a few more run commands for certain use-cases.

Allowing strace

run-with-strace:
    docker run -it -P --cap-add SYS_PTRACE $(IMAGE) bash

Allows strace to be used within the container—a tool I often like to use. the --cap-add flag allows you to add linux capabilities to the container. You can see the full list of capabilities you can add or remove by checking the manpage (man capabilities).

Mounting directories

Let’s say you want to mount your project’s directory in the container so you can work on it. We can utilise the MOUNTDIR variable we set in the header

run-mount:
  docker run -it -P -v $(MOUNTDIR):/$$(basename $(MOUNTDIR)) $(IMAGE) bash

An example run would be

$ make run-mount MOUNTDIR=/Users/den/
docker run -it -P -v /Users/den/:/$(basename /Users/den/) "docker-dev-env" bash
ubuntu@4a930810e1f4:~$ file /den/
/den/: directory

docker exec

Let’s say you have a container running with an interactive shell, and you want another shell login on the same container. Normally you’d need to run docker ps to see what the container ID is, then run docker exec -it <container-id> bash to get another prompt. We can automate that with make

exec:
  $(eval CONTAINER := $(shell docker ps -f "ancestor=$(IMAGE)" -f "status=running" -q))
  docker exec -it $(CONTAINER) bash

The first line sets the variable CONTAINER to the ID of the running container that matches the image name it’s descendant from. The second line gives the user another shell prompt to the running container.

Killing

If we want to kill all running containers we can do something similar to what we did for the exec target

kill:
    docker ps -f "ancestor=$(IMAGE)" -f "status=running" -q | xargs docker kill

This will grab all the running container IDs based on the image ancestor, and pipe them to xargs with docker kill.

Committing

Though I treat my docker development environments as ephemeral, relying on mounts from my host for stored data, sometimes I may need to maintain the state of the container—docker commit allows an image to be saved as-is. We can write a make target for this

commit:
  $(eval CONTAINER := $(shell docker ps -f "ancestor=$(IMAGE)" -f "status=running" -q))
  docker commit $(CONTAINER) $(COMMITIMAGE)

As before, we grab the container ID then run docker commit with the COMMITIMAGE being the variable we’ve set from the header.

Now putting it all together, here’s the Makefile we’ve built so far

IMAGE?="docker-dev-env"
COMMITIMAGE="docker-dev-env-commited"
MOUNTDIR?=$(shell pwd)

build:
  docker build --rm -t $(IMAGE) .

run:
  docker run -it -P $(IMAGE) bash
  
run-with-strace:
  docker run -it -P --cap-add SYS_PTRACE $(IMAGE) bash
  
run-mount:
  docker run -it -P -v $(MOUNTDIR):/$$(basename $(MOUNTDIR)) $(IMAGE) bash
  
exec:
  $(eval CONTAINER := $(shell docker ps -f "ancestor=$(IMAGE)" -f "status=running" -q))
  docker exec -it $(CONTAINER) bash

kill:
    docker ps -f "ancestor=$(IMAGE)" -f "status=running" -q | xargs docker kill
    
commit:
  $(eval CONTAINER := $(shell docker ps -f "ancestor=$(IMAGE)" -f "status=running" -q))
  docker commit $(CONTAINER) $(COMMITIMAGE)  

You can also alias the Makefile such that it can be run from any directory, for example

$ alias docker-dev='make -f ~/repo/docker-dev-env/Makefile'
$ docker-dev run-mount
docker run -it -P -v /Users/den:/$(basename /Users/den) "docker-dev-env" bash

It would be useful to place the alias in your .bashrc file.

Bonus

I’ll use one more example of adding a personal .vimrc to the image during build time.

Say we want to pull a .vimrc file from a GitHub repo and have available in the docker image. To do that we’ll first add the file’s URL as a variable in the Makefile

VIMRC?="https://raw.githubusercontent.com/TheDen/dotfiles/master/.vimrc"

This makes the specific .vimrc default, but it can always be changed when running make from the command line. Now let’s pass the variable to the Dockerfile—we’ll update the build target

build:
  docker build --rm --build-arg VIMRC=$(VIMRC) -t $(IMAGE) .

The --build-arg flag passes the VIMRC argument to the Dockerfile so the URL can be used. Now we just need to update the Dockerfile to pull the .vimrc (and install Vundle since my specific .vimrc uses packages)

USER ubuntu

# Install vundle
RUN git clone https://github.com/VundleVim/Vundle.vim.git ~/.vim/bundle/Vundle.vim

# Pull down .vimrc if a URL is passed
ARG VIMRC
RUN test "$VIMRC" && curl -sL $VIMRC -o ~/.vimrc || :

Not that this is done after USER ubuntu since we don’t need to be root anymore.

Conclusion

What we’ve built here is a basic workflow for running containers for development with high velocity. Depending on your use-case, you can build your development environment with any configuration between your Dockerfile and Makefile. You can see what my current setup is on GitHub.

Written May 2018.

← ELFs and magic in linux  On Withings & making my daily sleeping patterns public →