Combining CMake with Docker


Welcome to the next pikoTutorial !

In one of the recent articles, I showed how to use CMake for setting up a Python project. Today, we will see how to further extend it by adding building of Docker images to the CMakeLists.txt files. Today’s project structure:

project/
├── app1/
│   ├── CMakeLists.txt
│   ├── Dockerfile
│   ├── main.py
│   ├── requirements.txt
├── app2/
│   ├── CMakeLists.txt
│   ├── Dockerfile
│   ├── main.py
│   ├── requirements.txt
└── build/
├── CMakeLists.txt

Top CMakeLists.txt file

Nothing special here, just assuring Python availability and adding sub-directories to the build:

CMake
# specify minimum CMake version
cmake_minimum_required(VERSION 3.28)
# specify project name
project(CMakeWithDocker)
# find Python
find_package(Python3 REQUIRED COMPONENTS Interpreter)
# include all subdirectoies into the build
add_subdirectory(app1)
add_subdirectory(app2)

Dockerfiles

Both app1/Dockerfile and app2/Dockerfile look the same:

Dockerfile
FROM python:3.9-slim
WORKDIR /app
COPY . /app
RUN pip install --no-cache-dir -r requirements.txt
CMD ["python", "main.py"]

CMake files

Both app1/CMakeLists.txt and app2/CMakeLists.txt look similar with the only difference being the application name:

CMake
set(IMAGE_TARGET image_app_1)
# add custom target to build image for app1
add_custom_target(${IMAGE_TARGET} ALL
    WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}/app1
    COMMAND docker build -t image_app_1 .
    COMMENT "Building image for app1"
)

Pay attention to ALL placed after the target’s name. CMake, by default, doesn’t build custom targets, so you must make them explicitly dependent on all target (the default CMake target executed when calling cmake without --target flag).

Building the project

Now when everything’s ready, you can build the project by calling:

Bash
cd build
cmake ..
cmake --build . -j

Note for beginners: because both of our applications are independent from each other, I’m adding -j to the build command to parallelize build and speed up the whole process.

After the build is done, you can run:

Bash
docker images

to check that 2 new images appeared:

REPOSITORY    TAG       IMAGE ID       CREATED             SIZE
image_app_1   latest    150d015d8915   3 minutes ago       150MB
image_app_2   latest    d16b1453dbcc   3 minutes ago       147MB

You can run them calling, e.g.:

Bash
docker run --rm --name container_app_1 image_app_1

Adding a dependent target

It’s often a good idea to initialize the project, build, run tests etc. before building the final image. Here, as an example, I’ll add initialization of the virtual environment of each Python application as a mandatory step for building the images:

CMake
set(VENV_TARGET venv_app_1)
set(IMAGE_TARGET image_app_1)
# add custom target to create virtual environment
add_custom_target(${VENV_TARGET} ALL
    WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}/app1
    COMMAND ${Python3_EXECUTABLE} -m venv venv
    COMMENT "Creating virtual environment for app1"
)
# add custom target to build image for app1
add_custom_target(${IMAGE_TARGET} ALL
    WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}/app1
    COMMAND docker build -t image_app_1 .
    COMMENT "Building image for app1"
    DEPENDS ${VENV_TARGET}
)

Notice that here I had to add DEPENDS argument to image_app_1 custom target definition. It assures that venv_app_1 target will be completed before image starts to build.

Running Docker container with CMake

But why stop here? Let’s add the target for running the container from CMake level:

CMake
set(VENV_TARGET venv_app_1)
set(IMAGE_TARGET image_app_1)
# add custom target to create virtual environment
add_custom_target(${VENV_TARGET} ALL
    WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}/app1
    COMMAND ${Python3_EXECUTABLE} -m venv venv
    COMMENT "Creating virtual environment for app1"
)
# add custom target to build image for app1
add_custom_target(${IMAGE_TARGET} ALL
    WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}/app1
    COMMAND docker build -t image_app_1 .
    COMMENT "Building image for app1"
    DEPENDS ${VENV_TARGET}
)
# add custom target to run container for app1
add_custom_target(container_app_1
    COMMAND docker run --rm --name container_app_1 image_app_1
    COMMENT "Running container for app1"
    DEPENDS ${IMAGE_TARGET}
)

When it comes to specifying dependencies for the container_app_1 target, you have 2 options:

  • you can add DEPENDS image_app_1 line, as I did above – this will make sure that the container being started, always bases on the newest version of the image, so it can be potentially re-built every single time when running the container
[ 33%] Creating virtual environment for app1
[ 33%] Built target venv_app_1
[ 66%] Building image for app1
[+] Building 1.1s (9/9) FINISHED
=> [internal] load build definition from Dockerfile
=> => transferring dockerfile: 162B
=> [internal] load metadata for docker.io/library/python:3.9-slim
=> [internal] load .dockerignore
=> => transferring context: 2B
=> [1/4] FROM docker.io/library/python:3.9-slim
=> [internal] load build context
=> => transferring context: 125.50kB
=> CACHED [2/4] WORKDIR /app
=> CACHED [3/4] COPY . /app
=> CACHED [4/4] RUN pip install --no-cache-dir -r requirements.txt
=> exporting to image
=> => exporting layers=> => writing image
=> => naming to docker.io/library/image_app_1
[ 66%] Built target image_app_1
[100%] Running container for app1
Hello from Python app1
[100%] Built target container_app_1
  • you can omit DEPENDS image_app_1 line – in such approach, the user running container_app_1 target is responsible for assuring that image_app_1 image already exists, but it will allow you to just run the container basing on whatever image version has been recently built
[100%] Running container for app1
Hello from Python app1
[100%] Built target container_app_1

I didn’t add ALL to container_app_1 custom target because most likely you want to run the container on demand, not during every project build. To run the container, call:

Bash
cmake --build . --target container_app_1