Leading Edge C++ Build Environments with Docker and Travis-CI

Using a Docker container as a build environment is certainly not a new idea and there are a number of great articles detailing the process. What I intend to add to the conversation is how to construct such an environment with leading edge tool sets and leverage that container with aging CI infrastructure.

A quick Google search for “c++ docker build environment” returns a wealth of information on the subject. As these resources already exist I will assume that the reader understands basic Docker usage. If not I recommend using the official Docker documentation as reference for this article.

The method presented here has the desired results but it may not be the most effective method. It is a naive implementation. The reader is encouraged to continue their Docker education.

Finally it should be noted that I believe this is a good solution for products that can tightly control their deployment targets. However this particular pattern is probably not a good idea for libraries. Certainly library maintainers can leverage Docker build environments but they will have different requirements and goals.

Goals

Our goal for this exercise is to build a C++ application with the latest stable versions of Clang and CMake, have that build execute on the Travis-CI services, and report code coverage to CodeCov.io. All source code for this exercise is available under the unlicense on GitHub.

The Application

For the purpose of example we have a trivial program that we will pretend – for the sake of argument – absolutely requires the latest version of CMake and a fairly recent version of Clang.

cmake_minimum_required(VERSION 3.7.2)
set(CMAKE_CXX_COMPILER clang++)
set(CMAKE_CXX_STANDARD 14)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
project(example VERSION 1.0.0 LANGUAGES CXX)
enable_testing()
add_executable(example ${CMAKE_SOURCE_DIR}/main.cpp)
add_test(NAME synthetic COMMAND example)

This is a fairly standard CMakeLists.txt except that it hard-codes the compiler selection. This greatly simplifies the build generation and fits our requirements neatly. Again this would be a poor choice in other scenarios.

int main() {
 return 0;
}

And the absolute minimum C++ program.

Docker Build Container

Our first step is to create a Docker container that is capable of building our application.

FROM         debian:jessie-slim
MAINTAINER   Norman B. Lancaster <qbradq@gmail.com>
CMD          bash

# Required system packages
RUN apt-get update && apt-get -y install \
  apt-utils \
  build-essential \
  curl \
  doxygen \
  git \
  tar \
  wget \
  xz-utils

# Install latest CMake
RUN wget -q -O /tmp/cmake.tar.gz --no-check-certificate \
  https://cmake.org/files/v3.7/cmake-3.7.2-Linux-x86_64.tar.gz && \
  tar -xaf /tmp/cmake.tar.gz --strip-components=1 -C /usr/local && \
  rm /tmp/cmake.tar.gz

# Install latest Clang
RUN wget -q -O /tmp/clang.tar.xz --no-check-certificate \
  http://releases.llvm.org/3.9.1/clang+llvm-3.9.1-x86_64-linux-gnu-debian8.tar.xz && \
  tar -xaf /tmp/clang.tar.xz --strip-components=1 -C /usr/local && \
  rm /tmp/clang.tar.xz

The big difference here is that this Dockerfile fetches pre-compiled binaries of CMake and Clang and manually installs them into the container. Docker makes it easy to create a repeatable process for constructing specialized systems like this but it is up to the developer to make sure it all works. For instance this Dockerfile is based on a reasonably up to date version of Debian Linux. We do this so that we have the required libc and libstdc++ ABI’s that Clang requires to execute. Please note that this can cause issues with the ABI version your program links to if the deployment target has an older ABI version.

When developing locally we can use the following commands.

# Initial repository clone, only required once
git clone https://github.com/qbradq/docker-build-example.git

# Pull the pre-built container, only required once
sudo docker pull qbradq/example-build:latest

# Run the container, required once per container rebuild or host restart
sudo docker run -itd --name build -v $(pwd)/docker-build-example:/repo \
  qbradq/example-build

# Configure the build, required once per repository clone
sudo docker exec build cmake -H/repo -B/build

# Execute a build and test cycle
sudo docker exec build cmake --build /build && \
sudo docker exec build cmake --build /build --target test

As the last example demonstrates the docker exec command returns the status code of the command executed. This allows us to compose shell expressions just like we would with local commands.

Travis-CI

I chose Travis-CI for this example because it is free and transparent for open-source projects and – at the time of writing – has aging C++ support. At my job I am working with very old CI platforms that take months to get even the smallest changes made. For my purposes building C++ applications on Travis-CI is a good analog for building, well, anything at work. One thing my production CI infrastructure has in common with Travis-CI is the ability to use Docker containers.

The Travis-CI file for a container-based build is surprisingly similar to a normal build. There is a bit more setup and the build commands are executed using the “docker exec” command.

sudo: required
language: cpp
services:
  - docker
before_install:
  - docker pull qbradq/example-build:latest
  - docker run -itd --name build qbradq/example-build
  - docker exec build git clone https://github.com/qbradq/docker-build-example.git /repo
script:
  - docker exec build cmake -H/repo -B/build
  - docker exec build cmake --build /build
  - docker exec build cmake --build /build --target test

Now we have a complete working example.

Third Party Service Integration

I decided to include this in the example because it was hard for me to get working and was poorly documented elsewhere. I hope others can use the pattern presented here for integrating third-party services into their containerized CI builds.

CodeCov.io offers very nice code coverage reporting, visualization, and history services. Better still the service is free for public GitHub repositories. For standard builds integration with the service is as easy as a shell one-liner. To make use of Clang’s AST-based code coverage it requires a few extra arguments to said one-liner.

First we enable code coverage of our three line program. The bottom of our CMakeLists.txt now looks like this:

add_executable(example ${CMAKE_SOURCE_DIR}/main.cpp)
set_target_properties(
  example PROPERTIES
  COMPILE_FLAGS "-fprofile-instr-generate -fcoverage-mapping"
  LINK_FLAGS "-fprofile-instr-generate -fcoverage-mapping"
)
add_test(NAME synthetic COMMAND example)
add_custom_target(
  run-tests
  ${CMAKE_COMMAND} -E env CTEST_OUTPUT_ON_FAILURE=1 ${CMAKE_CTEST_COMMAND}
    -C $<CONFIG>
  COMMAND llvm-profdata merge -sparse default.profraw -o coverage.profdata
  COMMAND llvm-cov show example -instr-profile=coverage.profdata >
    app.coverage.txt
  WORKING_DIRECTORY ${CMAKE_BINARY_DIR}
)

This is the basic pattern I have started using to execute tests with CMake for my personal projects. Now instead of executing the “test” target to execute our tests we execute the “run-tests” target which not only executes the tests but also dumps stdout and stderr in the event of a test failure and generates the coverage report. Note that the file name “app.coverage.txt” is significant to the CodeCov.io service at the time of writing.

To get this to work with Travis-CI we need that CodeCov one-liner I mentioned, but that one-liner involves redirecting output from curl into a bash sub-process. I am not sure this is even possible with the “docker exec” command. Either way I could not figure it out and just threw it in a shell script that is executed.

#!/usr/bin/env bash
# Intended only for use in Travis-CI build
cd /repo && \
  bash <(curl -s https://codecov.io/bash) \
  -f /build/app.coverage.txt \
  -t <your-api-key> \
  -X gcov \
  -X coveragepy \
  -X search \
  -X xcode \
  -R /repo \
  -F unittests \
  -Z

A very small CodeCov.io-specific configuration file is required to tell the service how to make sense of the absolute file paths that CMake passes into the compiler.

fixes:
  - "/repo/::"

And finally we patch up our Travis-CI configuration to run the new test target and to execute the CodeCov.io script on successful builds.

  - docker exec build cmake --build /build --target run-tests
after_success:
  - docker exec build /repo/codecov.sh

The example is almost feature complete. What would fancy CI and automated code coverage be without GitHub badges? Head on over to shields.io and embed the Markdown links at the top of README.md to show off all that hard work. Very satisfying.

Conclusions

For products that have the luxury of tightly controlling their deployment targets – which is often the case for internal enterprise software – utilizing a fixed build environment that is controlled by the development team and versioned as code can have huge value. Docker enables just that, and more and more CI providers are enabling the use of this pattern.

The next article in this series will explore abusing this build container as a pre-configured IDE. We’ll start with VIM – which is all a real man needs – and see how far we can push things from there.