C++ Docker Multistage

“Every stage of life is the foundation for the next stage of life. Every stage of live must be fully-lived.” -- Lailah Gifty Akita

Leaves showing the changes of season

Photo by zero take on Unsplash

This is a tutorial covering how to build a docker multistage container https://www.docker.com/resources/what-container/ to test a C++ application built with Google Test https://github.com/google/googletest. This will be done in Ubuntu 24.04.

A previous article that introduced docker multistage builds can be found on this blog at: https://www.keypuncher.net/blog/docker-2-stage-builds.

Code Summary

The source code for this tutorial is on my GitHub here: https://github.com/mday299/keypuncher/tree/main/Docker/multistage/cpp-telemetry-app

Be sure to install docker, cmake, build-essential, git, and wget. On Ubuntu 24.04 this is done on the command line by:

sudo apt install docker.io cmake build-essential git wget

Optional packages include Visual Studio Code, vim, or the editor of your choice.

telemetry_processor.h

The beginning and end of this file consist of include guards https://en.wikipedia.org/wiki/Include_guard. It works using the C++ preprocessor, documented at https://en.cppreference.com/w/cpp/preprocessor

  • #ifndef checks if TELEMETRY_PROCESSOR_H is not already defined.

  • #define defines the macro TELEMETRY_PROCESSOR_H to prevent subsequent redefinitions.

  • The #endif at the end marks the completion of the guard.

Without this guard you could face "redefinition" errors during compilation if the header is included multiple times. Whether or not this is a Good Thing or a Bad Thing is debated places like here: https://softwareengineering.stackexchange.com/questions/192027/why-cant-a-compiler-avoid-importing-a-header-file-twice-by-its-own.

The next part is the telemetry information. It’s a struct https://en.wikipedia.org/wiki/Struct_(C_programming_language) containing data we’d like to store. In this particular case it is telemetry from a model airplane.

We end by declaring (NOT defining) a function. Differences between the two are covered on stack overflow: https://stackoverflow.com/questions/1410563/what-is-the-difference-between-a-definition-and-a-declaration

std::string formatTelemetry(const Telemetry &data);

  • The role of the function is to take a Telemetry struct as input and return a string representation of its contents. We’ll see the definition in a little later in the .cpp file.

  • A constant reference (const Telemetry &data) ensures that the input telemetry data is not modified by the function.

  • A std::string containing a formatted version of the telemetry data is returned.

telemetry_processor.cpp

Headers

Function Definition

std::string formatTelemetry(const Telemetry &data) {

The function takes a constant reference to a Telemetry object (const Telemetry &data), ensuring:

  • The telemetry data cannot be modified inside the function.

  • The data is passed efficiently without copying.

The function returns a std::string containing the formatted telemetry data.

String Stream Setup

std::ostringstream oss;

oss << std::fixed << std::setprecision(2);

  • std::ostringstream oss: A string stream object used to build the formatted string efficiently.

  • std::fixed: Ensures that floating-point numbers are displayed in fixed-point notation (instead of scientific notation).

  • std::setprecision(2): Limits the floating-point numbers to two decimal places for consistent and readable output.

Appending Telemetry Details

The various elements of the telemetry data are spit out via an output stream https://www.geeksforgeeks.org/basic-input-output-c/

Returning the Formatted String

return oss.str();

This converts the contents of the string stream (oss) into a std::string and returns it.

main.cpp

Initializes a Telemetry object with sample data.

Calls the formatTelemetry function to process and format the telemetry data into a human-readable string.

Outputs the result to the console.

test_telemetry_processor.cpp

This test file uses the Google Test (gtest) framework to verify the behavior of the formatTelemetry function. See documentation at: http://google.github.io/googletest/.

Included Headers

telemetry_processor.h: Contains the Telemetry struct and formatTelemetry declaration.

<gtest/gtest.h>: Includes Google Test framework for writing and running test cases.

<cmath>: Provides mathematical constants and functions (e.g., NAN, INFINITY).

CMakeLists.txt

If you are unfamiliar with CMake or Makefiles, then please review my other posts on them: https://www.keypuncher.net/blog/introduction-to-gnu-make

https://www.keypuncher.net/blog/cmake-introduction

https://www.keypuncher.net/blog/advanced-gnu-make

Details pertinent to this particular file were obtained from:

https://cmake.org/cmake/help/latest/manual/cmake-variables.7.html

https://cmake.org/getting-started/

https://cmake.org/cmake/help/latest/guide/tutorial/A%20Basic%20Starting%20Point.html

Specify CMake Version

cmake_minimum_required(VERSION 3.10)

Ensures compatibility by requiring at least CMake version 3.10.

Project Declaration

project(TelemetryProcessor VERSION 1.0)

  • TelemetryProcessor: The project name.

  • VERSION 1.0: Specifies the project version, which can be used in configuration files or documentation.

C++ Standard

set(CMAKE_CXX_STANDARD 17)

set(CMAKE_CXX_STANDARD_REQUIRED True)

  • CMAKE_CXX_STANDARD: Ensures the compiler uses C++17. See C++17 Standard.

  • CMAKE_CXX_STANDARD_REQUIRED: Ensures that the specified standard is strictly enforced (no fallback to earlier standards).

Main Application

add_executable(telemetry_processor main.cpp telemetry_processor.cpp)

  • add_executable: Creates the main executable file, telemetry_processor.

  • Includes main.cpp (entry point) and telemetry_processor.cpp (logic implementation).

Include Directories

target_include_directories(telemetry_processor PRIVATE ${PROJECT_SOURCE_DIR})

  • Adds the project source directory (${PROJECT_SOURCE_DIR}) to the list of include paths for telemetry_processor.

  • PRIVATE meaning: Limits these directories to only this target, ensuring modularity.

Google Test Setup

enable_testing()

Enables CMake's testing framework to define and run tests.

Test Executable

add_executable(test_application test_telemetry_processor.cpp telemetry_processor.cpp)

  • add_executable: Creates an executable called test_application specifically for running unit tests.

  Source Files:

  • test_telemetry_processor.cpp: Contains unit tests.

  • telemetry_processor.cpp: The implementation being tested.

Link Google Test Libraries

find_package(GTest REQUIRED):

target_link_libraries (test_application GTest::GTest GTest::Main)

  • Searches for the Google Test library. See find_package — CMake Documentation

  • Marks it as required; terminates the configuration process if GTest is not found.

  • Links the Google Test libraries (GTest::GTest and GTest::Main) to the test_application executable.

Add Test Targets

add_test(NAME TelemetryTests COMMAND test_application)

  • Registers a testing target named TelemetryTests that runs the test_application executable.

  • Allows running unit tests via CMake's testing framework.

Dockerfile

Stage 1: Build Stage

FROM gcc:14 AS builder

Uses the gcc:14 as the base image, which provides a GNU compiler and essential tools for building C++ applications.

WORKDIR /app

Sets /app as the working directory where subsequent commands will operate.

RUN apt-get update && apt-get install -y cmake git wget && \

    wget https://github.com/google/googletest/archive/release-1.12.1.tar.gz && \

    tar -xzf release-1.12.1.tar.gz && \

    cd googletest-release-1.12.1 && mkdir build && cd build && cmake .. && make && \

    make install

  • Installs essential build tools: cmake, git, and wget.

  • Downloads and installs Google Test (release 1.12.1) from its source repository:

    • Extracts the archive.

    • Builds and installs Google Test using CMake.

COPY . .

Copies the entire project directory into the container's /app directory.

RUN mkdir build && cd build && \

    cmake .. && \

    make

  • Creates a build directory for building the project.

  • Runs CMake to generate the build system.

  • Executes make to compile the source code.

Stage 2: Test Stage

FROM builder AS tester

Inherits the builder stage as the base image to use.

WORKDIR /app/build

Navigates to the build directory, where the application and tests were built.

CMD ["./telemetry_processor"]

Specifies the default command to run when this image is executed.

  • Here, the compiled telemetry_processor is run.

Stage 3: Production Stage

FROM ubuntu:24.04 AS runner

Uses a minimal Ubuntu image (24.04) for running the final application.

RUN apt-get update && apt-get install -y libstdc++6

Dependency: Installs the libstdc++6 library required for running the compiled C++ application.

COPY --from=builder /app/build/test_application /app/test_application

COPY --from=builder /app/build/telemetry_processor /app/telemetry_processor

Binary Artifacts: copies the compiled test_application binary from the builder stage into the production image.

CMD ["./test_application"]

Command: Executes the test_application binary when the container is run.

.dockerignore file

.vscode/

*.code-workspace

.vscode/

excludes the .vscode folder, which typically contains workspace settings and configuration files for VS Code.

*.code-workspace

excludes any VS Code workspace files with a .code-workspace extension.

Building the Dockerfile

docker build -t cpp-telemetry-app:1.0 .

This command will build a Docker image named cpp-telemetry-app with the tag 1.0 using the Dockerfile in the current directory (.). Here's a quick summary of what happens during this build process:

  • Docker will execute each instruction in the Dockerfile sequentially, such as installing dependencies, building the application, and setting up for testing or production.

  • Naming: The -t cpp-telemetry-app:1.0 flag assigns a name (cpp-telemetry-app) and a version (1.0) to the image, making it easier to reference in subsequent Docker commands like docker run.

  • Context: The . specifies the build context, meaning Docker will use the files and subdirectories within the current directory when processing the Dockerfile.

Once the images build complete, you can verify that the images have been created by running:

docker images

If the build succeeds, you’ll see cpp-telemetry-app listed with the 1.0 tag in the output. I see the following images:

REPOSITORY          TAG       IMAGE ID       CREATED          SIZE

cpp-telemetry-app   1.0       8db39e844d7f   13 seconds ago   125MB

<none>              <none>    7c2fe14dfc78   21 seconds ago   1.5GB

ubuntu              24.04     a04dc4851cbc   7 weeks ago      78.1MB

gcc                 14        a4880268fb1a   7 months ago     1.42GB

The reason the second image is so large is because we are including all of the development tools we need in the first two stages. The cpp-telemetry-app has just the binaries we need to install the application without all of the analysis tooling. This reduces the attack surface area of the first image (cpp-telemetry-app). The security of docker containers is beyond the scope of this tutorial but you can get a sense of what’s involved by reading https://docs.docker.com/engine/security/.

to run the container enter:

docker run --rm cpp-telemetry-app:1.0

  • Launches a container using the cpp-telemetry-app image with the 1.0 tag.

Automatically Cleans Up:

  • The --rm flag ensures the container is automatically removed after it exits. This prevents leftover containers from occupying system resources unnecessarily.

Executes the Command:

  • Inside the container, the CMD specified in the Dockerfile will be executed. Based on the CMD in your production stage, it will run:

    • ./test_application

To debug or poke around you can the container inside a bash shell:

docker run -it cpp-telemetry-app:1.0 /bin/bash

This should bring up a shell with the ‘runner’ container. Typing ls should reveal:

root@a7f6ed8fa26f:/app# ls

telemetry_processor  test_application

To run through all tests type:

./test_application

To run a single test

./telemetry_processor

And that’s all there is to it!

Cleaning Up:

To remove all containers:

docker rm $(docker ps -a -q)

To remove all images:

docker rmi -f $(docker images -q)

Go nuclear:

docker system prune

Details For Interested Parties

To build the individual components of the multistage build:

docker build --target builder -t cpp-builder:1.0 .

docker run -it cpp-builder:1.0 /bin/bash

-------------

docker build --target tester -t cpp-tester:1.0 .

docker run --rm cpp-tester:1.0

-----------

docker build -t cpp-telemetry-app:1.0 .

Running These Tests Manually

Compile the program with Google Test using the following command line invocation of g++:

g++ -o test_telemetry telemetry_processor.cpp telemetry_processor_test.cpp -lgtest -lgtest_main -pthread

Execute the test suite:

./test_telemetry

Conclusion

There you have it! My implementation of a multistage build that builds, tests, and runs a simple C++ container using Google Test in Ubuntu 24.04.

Feedback

 As always, do make a comment or write me an email if you need assistance.

Credits

https://iximiuz.com/en/posts/container-learning-path/

Docker and Kubernetes Masterclass: From Beginner to Advanced

https://github.com/lm-academy/docker-course

Next
Next

Docker Two-Stage Builds