As a programmer, I think it’s essential to have a C compiler installed on my computer, however it is hard to manage multiple compilers, interpreters, and other tools. Docker is great to isolate development environments: with it, you can easily have all the tools you need, the exact version that you require, all without extra clutter, and in one place. Because C programs require few tools to build them, learning how to use Docker to containerize a C development environment is a great way to start with Docker.

If you want to jump straight to the code, I put everything in a GitHub repository, so you can clone it and try it yourself.

Start by writing a Dockerfile

To build a typical “Hello World!” C program with GCC you will need a Dockerfile like this:

FROM alpine:3.12.0

RUN apk add \
    gcc=9.3.0-r2 \
    libc-dev=0.7.2-r3 \
    make=4.3-r0

I have added Make just because it’s one of those tools that I use very often, but you could choose not to install it.

Notice how we’re using Alpine Linux? That’s because it’s a very small image relative to other popular ones like Ubuntu. On top of picking a small base image, I have also chosen to install specific packages rather than the recommended build-base (which includes GCC, libcdev, and Make). Our image is 146 MB; compare that to Alpine 3.12.0 with build-base installed, which is 213 MB.

Build the Docker image

Just as the Docker image size is important to consider when writing your Dockerfile, so is the build context size. I like to have my build.sh and run.sh scripts in the same directory as the Dockerfile, so I use a .dockerignore file to avoid having the Docker daemon load these scripts as it builds the image. In this case, the .dockerignore file should tell Docker to ignore everything except the Dockerfile:

*
!Dockerfile

And then, to build the image I use a build.sh script:

#!/bin/bash

VERSION=$(cat README.md | grep "Version" | awk '{print $2}')

docker build --tag ccompiler:$VERSION .

Instead of manually setting the version number in the tag, I like to have the script read the number from a README.md file.

Run a Docker container

At this point, the image has been built and we’re ready to run a container based on it. As it is right now, you need to build and run your C programs inside the container. An easy way to do this is to run the container with an interactive shell. To that end, we can use a run_interactive.sh script:

#!/bin/bash

if [ -z "$1" ]
then
    echo 'use: ./run.sh <PROJ-DIR>'
    exit 1
fi

VERSION=$(cat README.md | grep "Version" | awk '{print $2}')

cd $1

docker run \
    --rm \
    -i \
    -t \
    -v $(pwd):/home \
    -w '/home' \
    ccompiler:$VERSION

The -v flag tells Docker to mount the current working directory on /home in the container. Since we’ll do everything in /home, it’s easier to also set the working directory with the -w flag.

Test that it all works

To verify that everything works, I added a sample hello.c file to my repository, and wrote this test.sh script to execute docker run and docker exec to run Make and then the hello executable:

#!/bin/bash

if [ -z "$1" ]
then
    echo 'use: ./test.sh sample-c-files'
    exit 1
fi

VERSION=$(cat README.md | grep "Version" | awk '{print $2}')

cd $1

echo "Running new container..."

CONTAINER_ID=$(docker run \
    --name ccompiler_test \
    --rm \
    -i \
    -t \
    -d \
    -v $(pwd):/home \
    ccompiler:$VERSION)

echo "Executing command..."

docker exec \
    -w '/home' \
    ccompiler_test \
    sh -c 'make && ./build/hello'

echo "Stopping container..."

docker container stop -t 0 $CONTAINER_ID

The container is run in a detached mode so that the script can move on to the next command and run docker exec. It’s also necessary to remember to stop the container once you are sure you are done with it, otherwise, next time you run this script you’ll be unable to start it up. Depending on your use-case, you may want to leave the container running to use it later on.

Summarizing

Although this is a basic example, it illustrates how Docker can potentially simplify your build process by putting all the tools you need in one place. In this case, we’ve seen how to build and run C programs inside a Docker container. Furthermore, we’ve touched a little on topics such as image and build context size, and we have also explored how to use docker exec to automatically test some of our container’s features.

If you want to take a better look at some of the decisions made here, you can check out the repository and look at previous versions of it. At first, I tried to do the same thing with Ubuntu but eventually settled for Alpine as it resulted in a smaller image and a simpler Dockerfile.