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
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 ifTELEMETRY_PROCESSOR_H
is not already defined.#define
defines the macroTELEMETRY_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
telemetry_processor.h: This includes the declaration of the Telemetry struct and formatTelemetry function.
<sstream>: Provides the std::ostringstream class for building strings piece by piece. https://www.geeksforgeeks.org/stringstream-c-applications/
<iomanip>: Provides manipulators like std::fixed and std::setprecision for controlling the formatting of numeric outputs. https://en.cppreference.com/w/cpp/header/iomanip
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 calledtest_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
andGTest::Main
) to thetest_application
executable.
Add Test Targets
add_test(NAME TelemetryTests COMMAND test_application)
Registers a testing target named
TelemetryTests
that runs thetest_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
, andwget
.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