Increase Development Productivity With Docker Build Environments

By Jake Freeland on May 27, 2022
Edited on June 2, 2022

Introduction

After getting started on my most recent hobby project, waytoi, I quickly realized the importance of maintaining a clean development environment. I do not want to clutter my system with countless development libraries, so I started thinking about containerized solutions to this problem.

My first idea included using virtual machines to create an isolated environment for compilation and testing. I spun up a Debian image, installed the necessary libraries, moved the source code over, and started compiling. By the time I was done nearly 30 minutes had passed. That process took way too long to be easily reproducible. Instead of wasting time initializing virtual machines, a reproducible solution could quickly generate isolated containers at convenience.

Docker Containers

This is where Docker makes an appearance. I found myself overwhelmed by Docker's excellent, albeit seemingly perpetual documentation. I am writing this article to aid those who want a kick start in learning to write Dockerfiles.

The first trace of guidance I came across was this short beginners guide by groda on Github. I recommend reading it before proceeding with this article to acquaint yourself with the command line basics of Docker.

Docker uses Linux cgroups to create sandboxed containers on top of your host's Linux kernel. These containers are spawned from Docker images that can be customized at will. Multiple containers from the same Docker image can run concurrently and are often less expensive in storage and resources than an equivalent virtual machine setup. Each Docker container communicates directly with the Docker engine which is able to divide resources dynamically and more efficiently than a virtual machine hypervisor.

Docker Images

After you've familiarized yourself with the basics of Docker, you're probably wondering how it is useful as a build environment. I mentioned Docker images earlier, but didn't elaborate on the customization. Creating your own Docker image is discussed extensively in the Dockerfile reference, but I am going to provide some of my own tips and experience below.

I started the waytoi project by experimenting with Weston, the Wayland Project's reference compositor. The rest of this tutorial will walk through creating a Dockerfile that builds Weston as an RPM package.

Creating a Dockerfile

First, gather the source code of the project that you want to experiment on. In my case, I cloned the Weston repository. Next, I created a new file named Dockerfile in the root of my newly cloned repository. Open the Dockerfile in your preferred text editor and add the following line:

# syntax=docker/dockerfile:1

This snippet is optional at the time of writing, but may come in handy later when using the more extensible BuildKit Dockerfile build backend. According to the aforementioned Dockerfile reference, "The syntax directive defines the location of the Dockerfile syntax that is used to build the Dockerfile".

Base Image

Next, you must choose a base Docker image. Various images can be found in the Docker hub. I would advise picking an image that fits your use-case the most. If your program is a CLI utility, your choice of image is trivial. Testing my project requires a display so I needed to find a way to transfer the compiled binary to my host system for testing. I figured that building an RPM would allow easy installation and testing on my Fedora base system. As a result, I chose the official fedora docker image.

Import the base docker image using the FROM keyword:

# syntax=docker/dockerfile:1
FROM fedora:latest

The :latest component of FROM fedora:latest is called a tag. Tags often represent release versions. If I wanted to build a Docker image on top of Fedora 33, I would use FROM fedora:33.

Environment Variables

I define a few environment variables using the ENV keyword to take advantage of variable substitution and make my file easily modifiable.

# syntax=docker/dockerfile:1
FROM fedora:latest

ENV name="weston"
ENV version="11.0.0"

These ENV variables will be passed into the Docker Image as standard shell environment variables.

Package Management

Now it is time to install packages on top of the chosen base image. Fedora uses the dnf package manager so I follow suit in the Dockerfile. Make sure to use the correct package manager for your base image. The RUN keyword will execute the given command and commit any filesystem changes to a new image layer that resides on top of the base image.

Docker caches almost every step of the Dockerfile for future building. If you change the last build instruction inside a Dockerfile but leave the preceding code intact, that preceding code will not need to be rebuilt. The Dockerfile will pick up where the change was made and recompile from that point on.

The code added below will use dnf to install compilation tools:

# compilation
RUN dnf install --refresh -y \
  gcc \
  gcc-c++ \
  meson \
  pkg-config \
  rpmdevtools

Note: The --refresh is important to leverage the Docker build cache. I recommend reading Docker's excellent cache-busting techniques using apt-get here.

Next, I use dnf to install the necessary development libraries for compilation. This and the previous step do not need to be split, but doing so will maximize cache usage if I need to append a library to the Dockerfile in the future. A further explanation of this behavior can be found here.

# libraries
RUN dnf install --refresh -y \
  libxkbcommon-devel \
  wayland-devel \
  pixman-devel \
  libinput-devel \
  libevdev-devel \
  libdrm-devel \
  wayland-protocols-devel \
  cairo-devel \
  libjpeg-devel \
  pango-devel \
  libwebp-devel \
  mesa-libEGL-devel \
  systemd-devel \
  dbus-devel \
  libseat-devel \
  lcms2-devel \
  mesa-libgbm-devel \
  libva-devel \
  freerdp-devel \
  xorg-x11-server-Xwayland-devel \
  libXcursor-devel \
  colord-devel \
  gstreamermm-devel \
  pipewire0.2-devel \
  pipewire-devel \
  pam-devel \
  mtdev-devel \
  poppler-devel \
  poppler-glib-devel

File Transfer to Container

The COPY keyword used below will copy files from the host into the docker image. The current base directory, containing the Weston project code, is copied into the image's variable completed /weston-11.0.0 directory.

COPY . /"$name-$version"

Executing Commands

# setup RPM development environment
RUN rpmdev-setuptree && \
  mv /"$name-$version"/packaging/"$name.spec" /root/rpmbuild/SPECS/"$name.spec" && \
  tar -cJf /root/rpmbuild/SOURCES/"$name-$version.tar.xz" /"$name-$version"

It's likely that your Dockerfile implementation will need to execute a shell command. Both RUN and CMD exist to serve this purpose. I'd recommend reading the Dockerfile reference to determine which keyword is appropriate. If you're unsure, it's a safe bet to use RUN.

Since I'm building an RPM package, the RPM build environment must be prepared. I use the rpmdev-setuptree command to auto generate the proper build tree. I move the weston.spec file into the newly created /root/rpmbuild/SPECS/ directory. Then the source code is compressed into an xz tarball and moved into /root/rpmbuild/SOURCES/.

Note: If you're building an RPM package, the .spec file must be created according to the RPM packaging specifications. If you're unfamiliar, there is a guide on creating a .rpm package by Red Hat here.

Compilation

# build RPM package
RUN rpmbuild -bb /root/rpmbuild/SPECS/"$name.spec"

Note: Compilation instructions will vary based on the project that you're working with. If you're building a binary, you can list your project's build instructions in the Dockerfile using the RUN keyword.

The rpmbuild command is run with instructions from weston.spec to start bulding the final Weston RPM files. When Weston's compilation is complete, the RPM targets will be placed into the /root/rpmbuild/RPMS/ directory for retrieval. The RUN keyword will commit the build into the image when finished.

Removing Unnecessary Files

# remove dnf cache
RUN dnf clean all

Finally, unnecessary files can be cleared to shrink the image's final size. I chose to leave the build files intact, but they can be removed as desired. The package manager's cache can almost always be removed. Fedora's dnf clean all will clean dnf's temporary files.

Building the Dockerfile image

At this point, you should have the knowledge to construct a basic Dockerfile. Here is my complete file for reference:

# syntax=docker/dockerfile:1
FROM fedora:latest

ENV name="weston"
ENV version="11.0.0"

# compilation
RUN dnf install --refresh -y \
  gcc \
  gcc-c++ \
  meson \
  pkg-config \
  rpmdevtools

# libraries
RUN dnf install --refresh -y \
  libxkbcommon-devel \
  wayland-devel \
  pixman-devel \
  libinput-devel \
  libevdev-devel \
  libdrm-devel \
  wayland-protocols-devel \
  cairo-devel \
  libjpeg-devel \
  pango-devel \
  libwebp-devel \
  mesa-libEGL-devel \
  systemd-devel \
  dbus-devel \
  libseat-devel \
  lcms2-devel \
  mesa-libgbm-devel \
  libva-devel \
  freerdp-devel \
  xorg-x11-server-Xwayland-devel \
  libXcursor-devel \
  colord-devel \
  gstreamermm-devel \
  pipewire0.2-devel \
  pipewire-devel \
  pam-devel \
  mtdev-devel \
  poppler-devel \
  poppler-glib-devel

COPY . /"$name-$version"

# setup RPM development environment
RUN rpmdev-setuptree && \
  mv /"$name-$version"/packaging/"$name.spec" /root/rpmbuild/SPECS/"$name.spec" && \
  tar -cJf /root/rpmbuild/SOURCES/"$name-$version.tar.xz" /"$name-$version"

# build RPM package
RUN rpmbuild -bb /root/rpmbuild/SPECS/"$name.spec"

# remove dnf cache
RUN dnf clean all

Once your Dockerfile is finished, navigate to the file's directory and execute:

$ docker build -t [NAME]:[TAG] .

where [NAME] is the image name and [TAG] is the version. The standard output and standard error of the docker image during build time will be directed into your terminal for debugging.

When the build is complete, use

$ docker images

to view your newly created image. It should look something like this:

docker_images

File Transfer to Host

Unfortunately I could not find a secure way to automatically transfer the target RPM files onto the host. As a result, I created a simple script that uses the docker cp command on the host to grab a file out of a container.

#!/bin/sh

if [ -z $1 ] || [ "$1" = "--help" ]; then
  echo "USAGE: ${0##*/} [IMAGE ID] [CONTAINER PATH] [LOCAL PATH]" >&2
  exit 1
fi

container_id="$(docker create $1)"
container_path="$2"
local_path="$3"

docker cp ${container_id}:${container_path} ${local_path}
docker rm ${container_id}

The script above uses docker create to create a temporary container, then docker cps the given directory from the container onto the host, and uses docker rm to delete the container.

In my case, I want to grab the target RPMs:

$ ./docker_copy_rpm.sh 9a3cd6db393a /root/rpmbuild/RPMS/ ./packaging/

The command above successfully grabs the /root/rpmbuild/RPMS/ directory from the temporary Docker container and places it into my local ./packaging/ directory.

Final Notes

Using Docker can certainly be complex at times, but the quantity of available documentation is a testament to its power and potential. Using Docker as a build environment is a single use-case among many. The future is bright for containerized environments, especially with immutable Flatpak-based operating systems like Fedora's Silverblue and Steam's SteamOS 3.0 on the horizon. I look forward to the secure and organized future that there utilities promise.


Tags: docker waytoi