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
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.
Let’s start building our
Dockerfile line by line.
ubuntu:16.04 from the official Canonical repo
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.
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
# 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:
passwdis not run to set the password, but still allows for login (unlike
fingerinformation (Gecos field), such as the user’s name, number etc. —we’ll set it to be blank.
adduser ubuntu sudoadds
%sudo ALL=(ALL) NOPASSWD:ALLin the
sudogroup, so users can run
sudowithout a password prompt
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
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
# 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
# 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
WORKDIRinstruction sets the working directory for any
ADDinstructions that follow it in the
In our case, we’ll set it to
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)
IMAGEwill be our image name
COMMITIMAGEis our image name when we run
docker commit(we’ll discuss this later)
MOUNTDIRis the variable we’ll use to mount our local filesystem paths inside the container for development
?= 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
Lets’ write the
build: docker build --rm -t $(IMAGE) .
IMAGE variable is passed in the command. The
--rm flag ensures that docker removes intermediate containers after a successful build.
For our basic
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.
run-with-strace: docker run -it -P --cap-add SYS_PTRACE $(IMAGE) bash
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 (
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
$characters need to be escaped with
-vis short for
--volume=[host-src:]container-dest]. Note that the
host-srcpath needs to be absolute.
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
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.
If we want to kill all running containers we can do something similar to what we did for the
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
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
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
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: docker build --rm --build-arg VIMRC=$(VIMRC) -t $(IMAGE) .
--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
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
Makefile. You can see what my current setup is on GitHub.