Привет! Программирую на C++ / Qt / QML в среде разработки QtCreator уже 6-ой год. У меня есть определенные пересечения мыслей с мозгом груга и еще мне постоянно хочется избавиться от глупой и рутинной работы, которая есть на разных этапах разработки. Одна из таких работ - возня с IDE и рабочим окружением, особенно в мире C++ разработки. В статье постараюсь раскрыть проблему и описать свой текущий подход к решению.
Проблема
Мы пишем кросс-платформенный десктоп C++ клиент-серверный продукт, и у наших разработчиков довольно развесистое рабочее окружение. В системе должно быть многообразие библиотек, некоторые переменные окружения. Иногда это добро расширяется. Плюс есть множество полезных настроек в самой IDE, многими плюшками с моей подачи пользуются коллеги: настроенные форматтеры кода определенных версий, некоторые удобные сниппеты, шаблоны создаваемых библиотек и файлов, конфиг статического анализатора и многие другие, тут на целую статью наберется.
-
Если разработка идет на нескольких компьютерах (домашний, рабочий, ноутбук), то нужно везде поддерживать одни настройки, версии библиотек и прочие детали окружения. Где-то я обновил систему, где-то не обновлял, что-то случайно затерлось. В итоге на всех рабочих компьютерах рабочее окружение отличается, это раздражает
-
Если в команду приходит новый человек, то на первоначальную настройку среды и сборку проекта уходит от одного до пары рабочих дней. Любой крупный проект обрастает нюансами, в которых нужно разобраться
-
Иногда проект изменяется таким образом, что у всех разработчиков внезапно ломается рабочее окружение, нужно что-то поменять. Добавилась необходимая переменная окружения, библиотечная системная зависимость и тд. И всю следующую неделю рабочий чатик разрывается одним и тем же вопросом "а шо делать", несмотря на то, что этот вопрос задавался 2-мя экранами выше. Справедливо, мы ведь работаем, а не чатики читаем :)
-
Если нужно на рабочем компьютере переустановить операционную систему. Редко, но случается, особенно под Linux. Тогда даже опытный разработчик возвращается к проблеме номер 2 и кучу времени тратит чтобы восстановить свое рабочее окружение и продолжить работать, ведь последний раз он с нуля все настраивал с годик назад
-
Иногда хочется поэкспериментировать с рабочим окружением, но это может привести к его окирпичиванию. На рефлекторном уровне гасится желание ковырять это окружение для улучшений, особенно после неприятных историй, которые приводят к п.4. Работает - не трожь!
Наступил день, когда эти проблемы меня доконали и я решил что-то с этим сделать.
Требования
Сформулировал требования к решению
-
Достаточно поддержки одной операционной системы - Linux
Продукт кросс-платформенный, но конкретный разработчик чаще всего сидит на одной системе. Распределение примерно 50 на 50 (Linux, Windows), зависит от предпочтения. Мой выбор продиктован личным предпочтением + пониманием, что автоматизировать разворачивание Linux среды будет сильно проще.
-
Быстрое развертывание (пара минут)
-
Поддержка нескольких версий
-
Быстрая и удобная доставка обновлений до пользователя
-
Коробочное решение - открыл и работаешь
-
Плавность работы, как у нативного приложения
После недолгих раздумий я пришел к 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, все происходит через один скрипт. Этот скрипт проверяет обновления и скачивает их, либо запускает текущий контейнер, если обновлений нет.
Мне думалось, что если я быстро решу ново-возникшую проблему с рабочим окружением, пользователь даже не заметит проблему. Заметили. Такой подход приводил к ряду проблем:
-
Никто не любит внезапных автоматических обновлений
-
Если крупно поменялись слои образа, пользователь может попасть на 5-10 минутное ожидание, вместо одной секунды, за которую обычно запускается IDE.
-
Я бы хотел иметь команду навроде docker check IMAGE_NAME latest, которая проверяла бы соответствие локального образа с образом на сервере по определенному тэгу, но ничего подобного не нашел (с кавалерийского наскока, а глубже не разбирался). Тогда можно было бы при запуске проверять версию и ненавязчиво предлагать обновиться, если есть желание.
Вместо этого я искал в стандартном выводе команды docker pull некоторые слова, как например *"Image is up to date for"*, чтобы делать вывод о наличии обновления. Проблема в том, что этот вывод я получу уже после фактического обновления, а еще в том, что красивый форматированный консольный вывод от команды docker pull, сильно ломается если прогонять его через те костыли, которыми я все это подпер
Я пришел к тому, что обновление должно быть сознательным процессом. Я пришел к этому форсированно, т.к. узнал что для одних это стало поводом запускать IDE напрямую через докер команды (минуя мой скрипт), для других это стало поводом стучаться в личку и "а шо так долго не запускается, в прошлый раз запускалось быстро, наверное сломалось". Поэтому обновление и запуск разделены на 2 скрипта
Что можно улучшить
-
Кэшировать больше пользовательских данных
Сессии, сохранение положения окон, кэш поисковых запросов, паттернов файлов для поиска, ... Сейчас при обновлении контейнера многие вещи затираются, т.к. они вперемешку с настройками, которые я хочу выставлять принудительно. Можно делать это только через crudini, сохраняя большинство пользовательской информации. А сами конфиги через volume использовать хостовые. Но это снижает независимость контейнера и в общем пока эту тему не трогаю -
Прокинуть драйвер звука
Возникла необходимость в разрабатываемом продукте запустить некий звук. С учетом что там дергаются драйверы, поведение отличалось от тестирования на обычном хосте. С кавалерийского наскока не разобрался как это сделать, быстрее было поставить окружение на хост, чтобы решить задачу. Но осадочек остался -
Добавить простенький браузер, файловый менеджер
Когда в коде есть ссылка или нужно открыть директорию с файлами, приходится возвращаться на хост из уютного контейнера -
Расширить линейку поддерживаемых версий сред разработки и различного инструментария
С учетом ограниченности ресурсов на текущий момент я выпускаю новые версии, когда наберется некоторое количество улучшений, которые уже недостаточно поддерживать только в локальном контейнере и хочется зафиксировать их. Старый релиз остается только в хранилище для тех кто им еще пользуется, но я не вношу туда изменения, даже если то окружение уже не соответствует действительности разработки. Жива только та версия, на которой я активно работаю, потому что я могу быстро заметить проблему. Жизнеспособность других версий под вопросом -
Сделать гигачад IDE С++ из VSCode
Я рассматривал этот вопрос, но уперся во многие вещи, которые в qtcreator есть из коробки, а в VSCode нет даже в формате расширений. Значит пришлось бы писать и поддерживать эти расширения. Если бы у меня было больше ресурса на эту задачу, я бы сделал контейнер на базе VSCode, как ультимативную C++ среду разработки. Ряд фич я подсмотрел у Clion, "можем повторить". И как игра в долгую VSCode как-то интуитивно больше нравится. Но это лишь ощущенения
Итого
Я в таком формате работаю уже больше года, и это реально доставляет мне удовольствие. Коллеги активно пользуются. Кто хоть раз вкусил свободу от мыслей про рабочее окружение, не остался равнодушным
Автор: simplepersonru