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:
# 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:
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:
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:
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:
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.:
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:
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:
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 runningcontainer_app_1
target is responsible for assuring thatimage_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:
cmake --build . --target container_app_1