Рабочая C++ IDE в docker container

в 13:51, , рубрики: c++, containers, docker, IDE, QtCreator, virtualization

Привет! Программирую на C++ / Qt / QML в среде разработки QtCreator уже 6-ой год. У меня есть определенные пересечения мыслей с мозгом груга и еще мне постоянно хочется избавиться от глупой и рутинной работы, которая есть на разных этапах разработки. Одна из таких работ - возня с IDE и рабочим окружением, особенно в мире C++ разработки. В статье постараюсь раскрыть проблему и описать свой текущий подход к решению.

Проблема

Мы пишем кросс-платформенный десктоп C++ клиент-серверный продукт, и у наших разработчиков довольно развесистое рабочее окружение. В системе должно быть многообразие библиотек, некоторые переменные окружения. Иногда это добро расширяется. Плюс есть множество полезных настроек в самой IDE, многими плюшками с моей подачи пользуются коллеги: настроенные форматтеры кода определенных версий, некоторые удобные сниппеты, шаблоны создаваемых библиотек и файлов, конфиг статического анализатора и многие другие, тут на целую статью наберется.

  1. Если разработка идет на нескольких компьютерах (домашний, рабочий, ноутбук), то нужно везде поддерживать одни настройки, версии библиотек и прочие детали окружения. Где-то я обновил систему, где-то не обновлял, что-то случайно затерлось. В итоге на всех рабочих компьютерах рабочее окружение отличается, это раздражает

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

  3. Иногда проект изменяется таким образом, что у всех разработчиков внезапно ломается рабочее окружение, нужно что-то поменять. Добавилась необходимая переменная окружения, библиотечная системная зависимость и тд. И всю следующую неделю рабочий чатик разрывается одним и тем же вопросом "а шо делать", несмотря на то, что этот вопрос задавался 2-мя экранами выше. Справедливо, мы ведь работаем, а не чатики читаем :)

  4. Если нужно на рабочем компьютере переустановить операционную систему. Редко, но случается, особенно под Linux. Тогда даже опытный разработчик возвращается к проблеме номер 2 и кучу времени тратит чтобы восстановить свое рабочее окружение и продолжить работать, ведь последний раз он с нуля все настраивал с годик назад

  5. Иногда хочется поэкспериментировать с рабочим окружением, но это может привести к его окирпичиванию. На рефлекторном уровне гасится желание ковырять это окружение для улучшений, особенно после неприятных историй, которые приводят к п.4. Работает - не трожь!

Наступил день, когда эти проблемы меня доконали и я решил что-то с этим сделать.

Требования

Сформулировал требования к решению

  1. Достаточно поддержки одной операционной системы - Linux

    Продукт кросс-платформенный, но конкретный разработчик чаще всего сидит на одной системе. Распределение примерно 50 на 50 (Linux, Windows), зависит от предпочтения. Мой выбор продиктован личным предпочтением + пониманием, что автоматизировать разворачивание Linux среды будет сильно проще.

  2. Быстрое развертывание (пара минут)

  3. Поддержка нескольких версий

  4. Быстрая и удобная доставка обновлений до пользователя

  5. Коробочное решение - открыл и работаешь

  6. Плавность работы, как у нативного приложения

После недолгих раздумий я пришел к Docker.

Решение

DockerFile

Это был мой первый опыт написания Dockerfile. После пары первых попыток столкнулся с тем, что при билде контейнера система просит указать таймзону в интерактивном режиме (для каких-то зависимостей это нужно), на этом ожидаемо валится билд.

p.s. почему-то не вижу в настройках вставки кода язык "Dockerfile", пускай будет "C#" : - )

FROM ubuntu:20.04
ENV TZ=Asia/Yekaterinburg
ENV DEBIAN_FRONTEND=noninteractive

Пакеты. Я несколько раз полностью менял стратегию того, как я скачиваю пакеты. По итогу пришел к тому, что update и install должны всегда быть в рамках одного шага, потому что их разделение сработает только в первый билд, а если потом поменять зависимости, то update не отработает и будет проблемес, если он успел устареть. А еще после каждого скачивания в этом же шаге подчищать за собой, это я подсмотрел на форумах.

Итоговый шаблон для скачивания пакетов:

RUN apt update -y && apt install 
lib1 lib2 ...  
-y && apt clean -y && apt autoremove --purge -y

Зависимости, которые понадобились для запуска QtCreator в докере, собраны опытным путем. Выставлял переменную окружения (вроде QT_DEBUG_PLUGINS=1) и это давало расширенный лог ошибки запуска. Там было видно какой библиотеки не хватает. Так было проделано N раз, по количеству недостающих библиотек

RUN apt update -y && apt install 
libgl1 libxkbcommon-dev libegl1 libfontconfig-dev libgssapi-krb5-2 
libdbus-1-3 libxkbcommon-x11-0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1  
libxcb-render-util0 libxcb-shape0 libxcb-xinerama0 libxcb-cursor-dev  
-y && apt clean -y && apt autoremove --purge -y

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

Чтобы внутри контейнера работал отладчик, добавил это, решение взял отсюда

RUN echo 0 > /etc/sysctl.d/10-ptrace.conf

Эту команду также нужно выполнить на хосте.

Локали. Видел разные решения на форумах, работали с переменным успехом, в итоге получилось что-то такое

RUN apt install locales -y && 
locale-gen en_US.UTF-8 ru_RU.UTF-8 && 
update-locale LANG=ru_RU.UTF-8 
ENV LC_ALL=ru_RU.UTF-8 
ENV LANG=en_US.UTF-8

Свою задачу выполняет и ладно

Создание юзера. Достаточно важный пункт, т.к. по умолчанию контейнер стартует под рутом. В дальнейшем мы будем прокидывать директории хоста в контейнер, например исходники проектов, над которыми идет работа. Любые изменения в файлах/директориях со стороны контейнера приведут к изменению прав на root, и с хоста они будут по умолчанию недоступны без sudo. Неудобненько.

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

ENV USER_NAME=uzver
RUN adduser $USER_NAME;
usermod -aG sudo $USER_NAME;
echo "$USER_NAME:123" | chpasswd
USER $USER_NAME
ENV HOME=/home/$USER_NAME
WORKDIR $HOME

После этих изменений, качать пакеты в Dockerfile не получится, т.к. у обычного юзера нет прав, поэтому все зависимости желательно качать до того как установим пользователя. С другой стороны, именно от лица пользователя нужно копировать в образ все что нам нужно (среду разработки, утилиты, сборочный комплект, настройки и тд), потому что пользоваться контейнером будем от имени созданного пользователя

Примерно так добавляем саму среду разработки. Я брал архив тут

COPY --chown=$USER_NAME qtcreator14 $HOME/qtcreator 
ENV PATH=$PATH:$HOME/qtcreator/bin

При копировании указываем принадлежность файлов юзеру. И добавляем пути в PATH если им там нужно быть. Для всего остального что нужно копировать принцип такой же.

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

Запуск контейнера с хоста

Чтобы открывались окошки, перед запуском контейнера необходимо сказать своему x-server, чтобы он давал к себе подключиться. Сначала сделал так

xhost +

Потом подумал, что "access control disabled, clients can connect from any host" наверное не хочу иметь подключения от any host. Можно свести команду к

xhost +local:$USER

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

Также перед запуском хочется иметь на хосте директории, которые мы пробрасываем через volume. Например, для утилиты ccache директорию ~/.ccache. Соберем всё в запускаемый скрипт my-ide.bash

#!/bin/bash
xhost +local:$USER
mkdir -p $HOME/.ccache
docker start -i CONTAINER_NAME

Точка входа

Сначала я думал сделать точку входа команду qtcreator, которая запускает среду разработки. Тогда при запуске контейнера сразу запускается окошко с IDE. Проблема в том, что не было прямого доступа к терминалу контейнера, а при останове самого qtcreator, контейнер завершал свое выполнение. Иногда очень нужно иметь доступ к терминалу внутри контейнера

Вариант 2 - стартовать bash, тогда пользователь при запуске оказывается внутри оболочки bash и может открывать и закрывать программы, оставаясь в этой оболочке. Но тогда пользователь всегда при запуске контейнера с IDE будет вынужден прописывать эту команду qtcreator руками.

Нашел вариант побороть проблему. Итоговый Entrypoint:

/bin/bash --init-file /home/uzver/utils/init_script.bash

Самое простое (к сложному чуть позже) возможное содержимое этого файла, в моем случае такое:

#!/bin/bash
qtcreator&

Таким образом окошко IDE запускается в фоновом режиме и пользователь сразу имеет приглашение ко вводу в терминале контейнера, идеально.

Я также использовал возможности такого способа для кастомизации IDE по некоторым параметрам. Например, некоторые коллеги используют автоформатирование, а некоторые нет. Для некоторых проектов нужен qbs одной версии, для других другой.

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

#!/bin/bash

if [ "$CONTAINERIDE_ALREADY_CONFIGURED" != "1" ]; then 
  export CONTAINERIDE_ALREADY_CONFIGURED=1

  if [ "$CONTAINERIDE_AUTOCLANGFORMAT" == "0" ]; then
    crudini --set "$HOME/.config/QtProject/QtCreator.ini" Beautifier "General\autoFormatOnSave" false 
  else
    crudini --set "$HOME/.config/QtProject/QtCreator.ini" Beautifier "General\autoFormatOnSave" true 
  fi

  if [[ "$CONTAINERIDE_QBSVERSION" == 1.20* ]]; then
    export PATH=$PATH:$HOME/qbs-1.20/bin 
  else      
    export PATH=$PATH:$HOME/qbs-2.3.0/bin 
  fi

fi

qtcreator&

утилита crudini отлично выполняет свою роль - протолкнуть значение по имени секции + по имени ключа в конфигурационный файл .ini

Создание контейнера из образа

Эту процедуру я вынес в скрипт my-ide-update.bash

#!/bin/bash

# Аргумент запуска - tag, по умолчанию latest
DEFAULT_TAG="latest"
TAG="${1:-$DEFAULT_TAG}"

# Имя образа
IMAGE_NAME=MY_IMAGE_NAME
# Имя контейнера
CONTAINER_NAME=MY_CONTAINER_NAME
# Программа, которая будет запущена
PROGRAMM="/bin/bash --init-file /home/uzver/utils/init_script.bash"

docker pull $IMAGE_NAME:$TAG
docker rm $CONTAINER_NAME

docker create 
-ti 
--cap-add=SYS_PTRACE 
--name=$CONTAINER_NAME 
--net=host 
-e DISPLAY 
-e CONTAINERIDE_AUTOCLANGFORMAT 
-e CONTAINERIDE_QBSVERSION 
-v $CONTAINERIDE_PROJECTS:/home/uzver/projects 
-v $HOME/.ccache:/home/uzver/.ccache 
-v /dev/dri:/dev/dri 
$IMAGE_NAME:$TAG $PROGRAMM

Некоторые пояснения:

#Чтобы иметь прямой доступ к терминалу системы внутри контейнера
-ti

#Чтобы работал отладчик
--cap-add=SYS_PTRACE

#ENV CONTAINERIDE_PROJECTS пользователь определяет на своем хосте, эта директория монтируется в контейнер как директория для проектов по умолчанию 
-v $CONTAINERIDE_PROJECTS:/home/uzver/projects

#ENV CONTAINERIDE_AUTOCLANGFORMAT, CONTAINERIDE_QBSVERSION пользователь определяет на своем хосте, они помогут в донастройке системы под эти параметры
-e CONTAINERIDE_AUTOCLANGFORMAT 
-e CONTAINERIDE_QBSVERSION 

Раньше эта процедура была связана с запуском контейнера и там был примерно такой код, опускаю лишние детали

DOCKER_PS=$(docker ps -a)

# это чтобы записать вывод команды pull в файл для дальнейшего анализа
docker pull $IMAGE_NAME | tee OUT_FILE OUT_STRING=$(cat OUT_FILE)

COMMAND="docker create ..."

xhost + 
mkdir -p $HOME/.ccache

#Если контейнер на текущий момент не создан 
if [[ $DOCKER_PS != $CONTAINER_NAME ]]; then 
  echo "Создаем контейнер на основе свежего образа" 
  $COMMAND

#Иначе, если контейнер создан и мы обновили образ, удалим контейнер и создадим новый 
elif [[ $OUT_STRING != "Image is up to date for" ]]; then 
  echo "Образ обновлен. Удалим текущий контейнер и создадим новый" 
  docker rm $CONTAINER_NAME $COMMAND 
else 
  echo "Текущий контейнер использует свежий образ" fi

rm OUT_FILE 
docker start -i $CONTAINER_NAME

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

Мне думалось, что если я быстро решу ново-возникшую проблему с рабочим окружением, пользователь даже не заметит проблему. Заметили. Такой подход приводил к ряду проблем:

  1. Никто не любит внезапных автоматических обновлений

  2. Если крупно поменялись слои образа, пользователь может попасть на 5-10 минутное ожидание, вместо одной секунды, за которую обычно запускается IDE.

  3. Я бы хотел иметь команду навроде docker check IMAGE_NAME latest, которая проверяла бы соответствие локального образа с образом на сервере по определенному тэгу, но ничего подобного не нашел (с кавалерийского наскока, а глубже не разбирался). Тогда можно было бы при запуске проверять версию и ненавязчиво предлагать обновиться, если есть желание.

    Вместо этого я искал в стандартном выводе команды docker pull некоторые слова, как например *"Image is up to date for"*, чтобы делать вывод о наличии обновления. Проблема в том, что этот вывод я получу уже после фактического обновления, а еще в том, что красивый форматированный консольный вывод от команды docker pull, сильно ломается если прогонять его через те костыли, которыми я все это подпер

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

Что можно улучшить

  1. Кэшировать больше пользовательских данных
    Сессии, сохранение положения окон, кэш поисковых запросов, паттернов файлов для поиска, ... Сейчас при обновлении контейнера многие вещи затираются, т.к. они вперемешку с настройками, которые я хочу выставлять принудительно. Можно делать это только через crudini, сохраняя большинство пользовательской информации. А сами конфиги через volume использовать хостовые. Но это снижает независимость контейнера и в общем пока эту тему не трогаю

  2. Прокинуть драйвер звука
    Возникла необходимость в разрабатываемом продукте запустить некий звук. С учетом что там дергаются драйверы, поведение отличалось от тестирования на обычном хосте. С кавалерийского наскока не разобрался как это сделать, быстрее было поставить окружение на хост, чтобы решить задачу. Но осадочек остался

  3. Добавить простенький браузер, файловый менеджер
    Когда в коде есть ссылка или нужно открыть директорию с файлами, приходится возвращаться на хост из уютного контейнера

  4. Расширить линейку поддерживаемых версий сред разработки и различного инструментария
    С учетом ограниченности ресурсов на текущий момент я выпускаю новые версии, когда наберется некоторое количество улучшений, которые уже недостаточно поддерживать только в локальном контейнере и хочется зафиксировать их. Старый релиз остается только в хранилище для тех кто им еще пользуется, но я не вношу туда изменения, даже если то окружение уже не соответствует действительности разработки. Жива только та версия, на которой я активно работаю, потому что я могу быстро заметить проблему. Жизнеспособность других версий под вопросом

  5. Сделать гигачад IDE С++ из VSCode
    Я рассматривал этот вопрос, но уперся во многие вещи, которые в qtcreator есть из коробки, а в VSCode нет даже в формате расширений. Значит пришлось бы писать и поддерживать эти расширения. Если бы у меня было больше ресурса на эту задачу, я бы сделал контейнер на базе VSCode, как ультимативную C++ среду разработки. Ряд фич я подсмотрел у Clion, "можем повторить". И как игра в долгую VSCode как-то интуитивно больше нравится. Но это лишь ощущенения

Итого

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

Автор: simplepersonru

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js