В этой публикации речь пойдет о том, как выполнить сборку C++ проекта, использующего GTest и Boost, при помощи Docker. Статья представляет собой рецепт с некоторыми поясняющими комментариями, представленное в статье решение не претендует на статус Production-ready.


Зачем и кому это может понадобиться?


Предположим, что вам, как и мне очень нравится концепция Python venv, когда все нужные зависимости расположены в отдельной, строго определенной директории; или же вам необходимо обеспечить простую переносимость среды сборки и тестирования для разрабатываемого проекта, что очень удобно, например, при присоединении нового разработчика к команде.


Эта статья будет особенно полезна начинающим разработчикам, кому необходимо выполнить базовую настройку окружения для сборки и запуска C++ проекта.


Представленное в статье окружение можно использовать как каркас для тестовых заданий или лабораторных работ.


Установка Docker


Все, что вам понадобится, для реализации проекта, представленного в этой статье — это Docker и доступ в интернет.


Docker доступен под платформы Windows, Linux и Mac. Официальная документация.


Так как я использую машину с Windows на борту, мне было достаточно скачать инсталятор и запустить.


Следует учесть, что на данный момент Docker под Windows использует Hyper-V для своей работы.


Проект


В качестве проекта будем подразумевать CommandLine приложение, выводящее строку "Hello World!" в стандартный поток вывода.


В проекте будет использован необходимый минимум библиотек, а также CMake в качестве системы сборки.


Структура нашего проекта будет выглядеть следующим образом:


project
|   Dockerfile
|
\---src
        CMakeLists.txt
        main.cpp
        sample_hello_world.h
        test.cpp

Файл CMakeLists.txt содержит описание проекта.
Исходный код файла:


cmake_minimum_required(VERSION 3.2)
project(HelloWorldProject)

# используем C++17
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -std=c++17")

# используем Boost.Program_options
# дабы не переусложнять, в качестве статической библиотеки
set(Boost_USE_STATIC_LIBS ON)
find_package(Boost COMPONENTS program_options REQUIRED)
include_directories(${BOOST_INCLUDE_DIRS})

# исполняемый файл нашего приложения
add_executable(hello_world_app main.cpp sample_hello_world.h)
target_link_libraries(hello_world_app ${Boost_LIBRARIES})

# включаем CTest
enable_testing()

# в качестве фреймворка для тестирования используем GoogleTest
find_package(GTest REQUIRED)
include_directories(${GTEST_INCLUDE_DIRS})

# исполняемый файл тестов
add_executable(hello_world_test test.cpp sample_hello_world.h)
target_link_libraries(hello_world_test ${GTEST_LIBRARIES} pthread)

# добавим этот файл в тестовый набор CTest
add_test(NAME HelloWorldTest COMMAND hello_world_test)

Файл sample_hello_world.h содержит описание класса HelloWorld, отправляя экземпляр которого в поток, будет выводиться строка "Hello World!". Такая сложность обусловлена необходимостью тестирования кода нашего приложения.
Исходный код файла:


#ifndef SAMPLE_HELLO_WORLD_H
#define SAMPLE_HELLO_WORLD_H

namespace sample {

struct HelloWorld {
  template<class OS>
  friend OS& operator<<(OS& os, const HelloWorld&) {
    os << "Hello World!";
    return os;
  }
};

} // sample

#endif // SAMPLE_HELLO_WORLD_H

Файл main.cpp содержит точку входа нашего приложения, добавим также Boost.Program_options, чтобы симулировать реальный проект.


Исходный код файла:


#include "sample_hello_world.h"

#include <boost/program_options.hpp>

#include <iostream>

// Наше приложение будет иметь один параметр командной строки - "--help"
auto parseArgs(int argc, char* argv[]) {
  namespace po = boost::program_options;
  po::options_description desc("Allowed options");
  desc.add_options()
    ("help,h", "Produce this message");

  auto parsed = po::command_line_parser(argc, argv)
    .options(desc)
    .allow_unregistered()
    .run();

  po::variables_map vm;
  po::store(parsed, vm);
  po::notify(vm);

  // В C++17 больше нет необходимости использовать std::make_pair
  return std::pair(vm, desc);
}

int main(int argc, char* argv[]) try {
  auto [vm, desc] = parseArgs(argc, argv);

  if (vm.count("help")) {  
    std::cout << desc << std::endl;
    return 0;
  }

  std::cout << sample::HelloWorld{} << std::endl;

  return 0;
} catch (std::exception& e) {
  std::cerr << "Unhandled exception: " << e.what() << std::endl;
  return -1;
}

Файл test.cpp содержит необходимый минимум — тест функциональности класса HelloWorld. Для тестирования используем GoogleTest.
Исходный код файла:


#include "sample_hello_world.h"

#include <sstream>

#include <gtest/gtest.h>

// Простой тест, выводим HelloWorld в поток, сравниваем вывод с ожидаемым
TEST(HelloWorld, Test) {
  std::ostringstream oss;
  oss << sample::HelloWorld{};
  ASSERT_EQ("Hello World!", oss.str());
}

// Точка входа в тестовое приложение
int main(int argc, char **argv) {
  testing::InitGoogleTest(&argc, argv);
  return RUN_ALL_TESTS();
}

Далее, переходим к самому интересному — настройке сборочного окружения при помощи Dockerfile!


Dockerfile


Для сборки будем использовать gcc последней версии.
Dockerfile содержит два этапа: сборка и запуск нашего приложения.
Для запуска используем Ubuntu последней версии.


Содержимое Dockerfile:


# Сборка ---------------------------------------

# В качестве базового образа для сборки используем gcc:latest
FROM gcc:latest as build

# Установим рабочую директорию для сборки GoogleTest
WORKDIR /gtest_build

# Скачаем все необходимые пакеты и выполним сборку GoogleTest
# Такая длинная команда обусловлена тем, что
# Docker на каждый RUN порождает отдельный слой,
# Влекущий за собой, в данном случае, ненужный оверхед
RUN apt-get update &&     apt-get install -y       libboost-dev libboost-program-options-dev       libgtest-dev       cmake     &&     cmake -DCMAKE_BUILD_TYPE=Release /usr/src/gtest &&     cmake --build . &&     mv lib*.a /usr/lib

# Скопируем директорию /src в контейнер
ADD ./src /app/src

# Установим рабочую директорию для сборки проекта
WORKDIR /app/build

# Выполним сборку нашего проекта, а также его тестирование
RUN cmake ../src &&     cmake --build . &&     CTEST_OUTPUT_ON_FAILURE=TRUE cmake --build . --target test

# Запуск ---------------------------------------

# В качестве базового образа используем ubuntu:latest
FROM ubuntu:latest

# Добавим пользователя, потому как в Docker по умолчанию используется root
# Запускать незнакомое приложение под root'ом неприлично :)
RUN groupadd -r sample && useradd -r -g sample sample
USER sample

# Установим рабочую директорию нашего приложения
WORKDIR /app

# Скопируем приложение со сборочного контейнера в рабочую директорию
COPY --from=build /app/build/hello_world_app .

# Установим точку входа
ENTRYPOINT ["./hello_world_app"]

Полагаю, пока переходить к сборке и запуску приложения!


Сборка и запуск


Для сборки нашего приложения и создания Docker-образа достаточно будет выполнить следующую команду:


# Здесь docker-cpp-sample название нашего образа
# . - подразумевает путь к директории, содержащей Dockerfile
docker build -t docker-cpp-sample .

Для запуска приложения используем команду:


> docker run docker-cpp-sample

Увидим заветные слова:


Hello World!

Для передачи параметра достаточно будет добавить его в вышеприведенную команду:


> docker run docker-cpp-sample --help
Allowed options:
  -h [ --help ]         Produce this message

Подводим итоги


В результате мы создали полноценное C++ приложение, настроили окружение для его сборки и запуска под Linux и завернули его в Docker-контейнер. Таким образом, освободив последующих разработчиков от необходимости тратить время на настройку локальной сборки.

Комментарии (9)


  1. Hokum
    14.06.2018 18:11
    +2

    При изменении исходного кода пересобирать контейнер, серьезно?

    Я ничего не имею против виртуализации сборки, так как это во-первых решает вопрос настройки окружения, во-вторых изолирует процесс сборки, т.е. есть уверенность, что если собралось на его машине, то соберется и на билд сервере, и у других разработчиков.

    Лучше собрать контейнер со всем необходимым, при запуске контейнера монтировать каталог с исходным кодом (можно и каталог куда будут складываться результат сборки, чтобы они сохранялись между перезапусками контейнера). А разработчик вручную запускает сборку в контейнере (контейнер предоставляет консоль, X Server или в нем запущен ssh сервер). При изменнии исходного кода разработчик заново запускает сборку без пересборки контейнера.


    1. Shtsh
      14.06.2018 19:44

      Ну, ничего плохого в сборке контейнера нету, но в этом примере стоило бы сделать дополнительные 2 контейнера с установленными зависимостями, чтобы не тратить зря кучу времени. Особенно, если деплой осуществляется при помощи этих контейнеров.

      В результате для сборки будет только копироваться исходный код и скомпиленый бинарник, что заметно ускорит процесс.

      А вот запуск сборки вручную — очень странная идея. Сборка должна быть по коммиту, как минимум, чтобы удостовериться, что код компилируется и тесты проходят. В противном случае добавляется человеческий фактор, который обычно выстреливает в самый неподходящий момент.


      1. SilentBob
        14.06.2018 21:27

        Я думаю под "вручную запускает сборку в контейнере" подразумевалось на хардкодить вызов cmake в Dockerfile, а использовать что-то типа: docker run build-container cmake /app && cmake --build ....


        Кто и как будет выполнять эту команду — вопрос отдельный.


        1. Hokum
          15.06.2018 11:42

          Возможны варианты. На текущем месте есть контейнер к которому я подсоединяюсь по VNC и запускаю сборку, запускаю приложение. Это удобно, так как развернуть все необходимое окружение, точнее осбрать все библиотеки довольно длителное занятие плюс они других версий от того, что используется для других проектов и могут возникнуть проблемы из-за этого.

          Это было сделано в первую очередь, чтобы вновь пришедшим разработчикам не пришлом возиться со всем этим. Опять же, можно делать инкрементальную сборку, что может быть весьма актуально на больших проектах.


          1. RPG18
            15.06.2018 12:06

            точнее осбрать все библиотеки довольно длителное занятие плюс они других версий от того, что используется для других проектов и могут возникнуть проблемы из-за этого.

            У нас решается это просто, есть отдельный репозиторий depends, в котором уже собраны либы для osx, windows, linux, asm.js. Цепляем все через cmake, поэтому для развертки требуется всего лишь компилятор и cmake.


            1. Hokum
              15.06.2018 14:04

              Это хорошее решение, просто я пришел не давно, а тут так сложилось. В данный момент пробую все перетащить на менеджер зависимостей conan.


      1. RPG18
        14.06.2018 21:30

        Сборка по коммиту при помощи docker реализовано в GitLab CI, поэтому тут говорить не о чем. Автор говорит именно об освобождении от настройки окружения:


        Таким образом, освободив последующих разработчиков от необходимости тратить время на настройку локальной сборки.


    1. OnYourLips
      15.06.2018 17:31

      При изменении исходного кода пересобирать контейнер, серьезно?
      Для продакшн-сборок компилируемого кода — однозначно.

      разработчик вручную запускает сборку в контейнере
      Для тестовых сборок это будет совсем другой контейнер, с нструментами для сборки. Вы же не потащите контейнер с инструментами для сборки в продакшн?


      1. Hokum
        16.06.2018 12:20

        Я с Вами согласен, но автор статьи или их смешивает в один, или говорит только про контейнер для локальной сборки на машине разработчика:

        В результате мы создали полноценное C++ приложение, настроили окружение для его сборки и запуска под Linux и завернули его в Docker-контейнер. Таким образом, освободив последующих разработчиков от необходимости тратить время на настройку локальной сборки.