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

61956049db447eb4d2160460fd519d13

Привет, Хабр! Программирую на 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 как-то интуитивно больше нравится. Но это лишь ощущенения

Итого

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

© Habrahabr.ru