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:
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 cp
s 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.