Все началось с того, что тимлид одной из наших команд разработчиков попросил в тестовом режиме выставить наружу их новое приложение, которое накануне было подвергнуто контейнеризации. Я выставил. Примерно через 20 минут поступила просьба обновить приложение, потому что там допилили очень нужную штуку. Я обновил. Еще через пару часов… ну, вы и так догадываетесь, что стало происходить дальше…

Я, признаться, довольно ленив (я же ранее в этом признавался? нет?), и, учитывая тот факт, что тимлиды имеют доступ в Jenkins, в котором у нас весь CI/CD, подумал: да пусть он сам деплоит, сколько заблагорассудится! Вспомнил анекдот: дай человеку рыбу и он будет сыт день; назови человека Сыт и он будет Сыт всю жизнь. И пошел мастрячить джобу, которая бы умела деплоить в кубер контейнер с приложением любой успешно собранной версии и передавать в него любые значения ENV (мой дедушка, — филолог, преподаватель английского в прошлом, — сейчас бы покрутил пальцем у виска и очень выразительно посмотрел бы на меня, прочитав это предложение).

Итак, в заметке я расскажу о том, как я научился:

  1. Динамически обновлять задания в Jenkins'е из самого задания или из других заданий;
  2. Подключаться к облачной консоли (Cloud shell) с ноды с установленным агентом Jenkins'а;
  3. Деплоить рабочую нагрузку (workload) в Google Kubernetes Engine.

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

Это очередная моя шпаргалка. Такие заметки мне хочется писать лишь в одном случае: передо мной стояла задача, я изначально не знал, как ее решить, решение не нагуглилось в готовом виде, поэтому я его гуглил по частям и в итоге задачу решил. И для того, чтобы в будущем, когда я забуду, как я это сделал, мне не пришлось вновь все гуглить по кускам и компилировать воедино, я пишу себе такие шпаргалки.
Disclaimer: 1. Заметка писалась «для себя», на роль best practice не претендует. С удовольствием почитаю варианты «а лучше было сделать так» в комментариях.
2. Если прикладную часть заметки считать солью, то, как и все мои предыдущие заметки, эта — слабосолевой раствор.

Динамическое обновление настроек заданий в Jenkins


Предвижу ваш вопрос: а при чем тут вообще динамическое обновление джобы? Вписал ручками значение строкового параметра и вперед!

Отвечаю: я правда ленивый, не люблю, когда жалуются: Миша, деплой крашится, все пропало! Начинаешь смотреть, а там опечатка в значении какого-нибудь параметра запуска задания. Поэтому предпочитаю все делать максимально фулпруфно. Если есть возможность лишить пользователя возможности вводить данные напрямую, дав вместо этого список значений для выбора, то я организовываю выбор.

План таков: создаем задание в Jenkins, в котором перед запуском можно было бы из списка выбрать версию, указать значения для параметров, передаваемых в контейнер через ENV, далее оно собирает контейнер и пушает его в Container Registry. Далее оттуда контейнер запускается в кубере как workload с параметрами, заданными в джобе.

Процесс создания и настройки задания в Jenkins'е рассматривать не будем, это оффтопик. Будем исходить из того, что задание готово. Для реализации обновляемого списка с версиями, нам нужны две вещи: уже имеющийся список-источник с априори валидными номерами версий и переменная типа Choice parameter в задании. В нашем примере пусть переменная будет носить имя BUILD_VERSION, на ней останавливаться подробно не будем. А вот на списке-источнике давайте остановимся подробнее.

Вариантов не такое уж и множество. Мне сходу в голову пришли два:

  • Использовать Remote access API, который предлагает Jenkins своим пользователям;
  • Запрашивать содержимое удаленной папки репозитория (в нашем случае это JFrog Artifactory, что не принципиально).

Jenkins Remote access API


По сложившейся прекрасной традиции предпочту избежать пространных объяснений.
Позволю себе лишь вольный перевод куска первого абзаца первой страницы документации по API:
Jenkins предоставляет API для удаленного машинно-понятного доступа к своему функционалу. <...> Удаленный доступ предлагается в REST'оподобном стиле. Это означает, что отсутствует единая точка входа ко всем возможностям, а вместо нее используется URL вида ".../api/", где "..." означает объект, к которому применяются возможности API.
Иными словами, если задание на деплой, о котором мы в данный момент говорим, доступно по адресу http://jenkins.mybuild.er/view/AweSomeApp/job/AweSomeApp_build, то API-свистульки для этого задания доступны по адресу http://jenkins.mybuild.er/view/AweSomeApp/job/AweSomeApp_build/api/

Далее у нас есть выбор, в каком виде получать вывод. Остановимся на XML, поскольку API только в этом случае позволяет использовать фильтрацию.

Давайте просто так попробуем получить список всех запусков задания. Нас интересует только имя сборки (displayName) и ее результат (result):

http://jenkins.mybuild.er/view/AweSomeApp/job/AweSomeApp_build/api/xml?tree=allBuilds[displayName,result]

Получилось?

Теперь отфильтруем только те запуски, которые в итоге с результатом SUCCESS. Используем аргумент &exclude и в качестве параметра передадим ему путь до значения не равного SUCCESS. Да-да. Двойное отрицание — это утверждение. Исключаем все то, что нас не интересует:

http://jenkins.mybuild.er/view/AweSomeApp/job/AweSomeApp_build/api/xml?tree=allBuilds[displayName,result]&exclude=freeStyleProject/allBuild[result!='SUCCESS']

Скриншот списка успешных


Ну и просто для баловства убедимся, что фильтр нас не обманул (фильтры же никогда не врут!) и выведем список «не-успешных»:

http://jenkins.mybuild.er/view/AweSomeApp/job/AweSomeApp_build/api/xml?tree=allBuilds[displayName,result]&exclude=freeStyleProject/allBuild[result='SUCCESS']

Скриншот списка не-успешных


Список версий из папки на удаленном сервере


Есть и второй способ получить список версий. Он мне нравится даже больше, чем обращение к API Jenkins'а. Ну, потому что если приложение успешно собралось, значит его упаковали и положили в репозиторий в соответствующую папку. Типа, репозиторий это по умолчанию хранилище рабочих версий приложений. Типа. Ну вот и спросим у него, какие версии на храненнии. Удаленную папку будем curl'ить, grep'ать и awk'ать. Если кому-то интересен уанлайнер, то он под спойлером.

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

curl -H "X-JFrog-Art-Api:VeryLongAPIKey" -s http://arts.myre.po/artifactory/awesomeapp/ | sed 's/a href=//' | grep "$(date +%b)-$(date +%Y)\|$(date +%b --date='-1 month')-$(date +%Y)" | awk '{print $1}' | grep -oP '>\K[^/]+' )


Настройка заданий и файл конфигурации задания в Jenkins


С источником списка версий разобрались. Давайте теперь полученный список вкрутим в задание. Для меня очевидным решением было добавить шаг в задании по сборке приложения. Шаг, который бы выполнялся в случае результата «успех».

Открываем настройки задания на сборку и скроллим в самый низ. Жмакаем на кнопочки: Add build step -> Conditional step (single). В настройках шага выбираем условие Current build status, выставляем значение SUCCESS, выполняемое действие в случае успеха Run shell command.

И теперь самое интересное. Конфигурации заданий Jenkins хранит в файлах. В формате XML. По пути http://путь-до-задания/config.xml Соответственно, можно скачать файл с конфигурацией, отредактировать его нужным образом и положить на место, откуда взяли.

Помните, выше мы договорились, что для списка версий создадим параметр BUILD_VERSION?

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

Скриншот под спойлером.

У вас приведенный фрагмент config.xml должен выглядеть так же. За тем исключением, что содержимое элемента choices пока что отсутствует


Убедились? Ну все, пишем скрипт, который будет выполняться в случае успешной сборки.
Скрипт будет получать список версий, скачивать файл конфигурации, писать в него в нужное нам место список версий, а потом класть его обратно. Да. Все верно. Писать список версий в XML'ку в то место, где уже есть список версий (будет в будущем, после первого запуска скрипта). Я знаю, в мире еще живут лютые любители регулярных выражений. Я к ним не отношусь. Установите, пожалуйста, xmlstarler на ту машину, где будет редактироваться конфиг. Мне кажется, это не такая уж и большая плата за то, чтобы избежать редактирования XML с помощью sed'а.

Под спойлером привожу код, выполняющий вышеописанную последовательность целиком.

Пишем в конфиг список версий из папки на удаленном сервере
#!/bin/bash
############## Скачиваем конфиг
curl -X GET -u username:apiKey http://jenkins.mybuild.er/view/AweSomeApp/job/AweSomeApp_k8s/config.xml -o appConfig.xml

############## Удаляем и заново создаем xml-элемент для списка версий
xmlstarlet ed --inplace -d '/project/properties/hudson.model.ParametersDefinitionProperty/parameterDefinitions/hudson.model.ChoiceParameterDefinition[name="BUILD_VERSION"]/choices[@class="java.util.Arrays$ArrayList"]/a[@class="string-array"]' appConfig.xml

xmlstarlet ed --inplace --subnode '/project/properties/hudson.model.ParametersDefinitionProperty/parameterDefinitions/hudson.model.ChoiceParameterDefinition[name="BUILD_VERSION"]/choices[@class="java.util.Arrays$ArrayList"]' --type elem -n a appConfig.xml

xmlstarlet ed --inplace --insert '/project/properties/hudson.model.ParametersDefinitionProperty/parameterDefinitions/hudson.model.ChoiceParameterDefinition[name="BUILD_VERSION"]/choices[@class="java.util.Arrays$ArrayList"]/a' --type attr -n class -v string-array appConfig.xml

############## Читаем в массив список версий из репозитория
readarray -t vers < <( curl -H "X-JFrog-Art-Api:Api:VeryLongAPIKey" -s http://arts.myre.po/artifactory/awesomeapp/ | sed 's/a href=//' | grep "$(date +%b)-$(date +%Y)\|$(date +%b --date='-1 month')-$(date +%Y)" | awk '{print $1}' | grep -oP '>\K[^/]+' )

############## Пишем массив элемент за элементом в конфиг
printf '%s\n' "${vers[@]}" | sort -r |                 while IFS= read -r line
                do
                    xmlstarlet ed --inplace --subnode '/project/properties/hudson.model.ParametersDefinitionProperty/parameterDefinitions/hudson.model.ChoiceParameterDefinition[name="BUILD_VERSION"]/choices[@class="java.util.Arrays$ArrayList"]/a[@class="string-array"]' --type elem -n string -v "$line" appConfig.xml
                done

############## Кладем конфиг взад
curl -X POST -u username:apiKey http://jenkins.mybuild.er/view/AweSomeApp/job/AweSomeApp_k8s/config.xml --data-binary @appConfig.xml

############## Приводим рабочее место в порядок
rm -f appConfig.xml


Если вам больше понравился вариант с получением версий из Jenkins'а и вы так же ленивы, как я, то под спойлером тот же самый код, но список из Jenkins'а:

Пишем в конфиг список версий из Jenkins'а
Только учтите момент: у меня имя сборки состоит из порядкового номера и номера версии, разделенных двоеточием. Соответственно, awk отрезает ненужную часть. Для себя эту строку измените под ваши нужды.

#!/bin/bash
############## Скачиваем конфиг
curl -X GET -u username:apiKey http://jenkins.mybuild.er/view/AweSomeApp/job/AweSomeApp_k8s/config.xml -o appConfig.xml

############## Удаляем и заново создаем xml-элемент для списка версий
xmlstarlet ed --inplace -d '/project/properties/hudson.model.ParametersDefinitionProperty/parameterDefinitions/hudson.model.ChoiceParameterDefinition[name="BUILD_VERSION"]/choices[@class="java.util.Arrays$ArrayList"]/a[@class="string-array"]' appConfig.xml

xmlstarlet ed --inplace --subnode '/project/properties/hudson.model.ParametersDefinitionProperty/parameterDefinitions/hudson.model.ChoiceParameterDefinition[name="BUILD_VERSION"]/choices[@class="java.util.Arrays$ArrayList"]' --type elem -n a appConfig.xml

xmlstarlet ed --inplace --insert '/project/properties/hudson.model.ParametersDefinitionProperty/parameterDefinitions/hudson.model.ChoiceParameterDefinition[name="BUILD_VERSION"]/choices[@class="java.util.Arrays$ArrayList"]/a' --type attr -n class -v string-array appConfig.xml

############## Пишем в файл список версий из Jenkins
curl -g -X GET -u username:apiKey 'http://jenkins.mybuild.er/view/AweSomeApp/job/AweSomeApp_build/api/xml?tree=allBuilds[displayName,result]&exclude=freeStyleProject/allBuild[result!=%22SUCCESS%22]&pretty=true' -o builds.xml

############## Читаем в массив список версий из XML
readarray vers < <(xmlstarlet sel -t -v "freeStyleProject/allBuild/displayName" builds.xml | awk -F":" '{print $2}')

############## Пишем массив элемент за элементом в конфиг
printf '%s\n' "${vers[@]}" | sort -r |                 while IFS= read -r line
                do
                    xmlstarlet ed --inplace --subnode '/project/properties/hudson.model.ParametersDefinitionProperty/parameterDefinitions/hudson.model.ChoiceParameterDefinition[name="BUILD_VERSION"]/choices[@class="java.util.Arrays$ArrayList"]/a[@class="string-array"]' --type elem -n string -v "$line" appConfig.xml
                done

############## Кладем конфиг взад
curl -X POST -u username:apiKey http://jenkins.mybuild.er/view/AweSomeApp/job/AweSomeApp_k8s/config.xml --data-binary @appConfig.xml

############## Приводим рабочее место в порядок
rm -f appConfig.xml


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

Корректно заполненный список версий


Если все отработало, то копипастите скрипт в Run shell command и сохраняйте изменения.

Подключение к Cloud shell


Сборщики у нас в контейнерах. В качестве средства доставки приложений и менеджера конфигураций мы используем Ansible. Соответственно, когда речь заходит о сборке контейнеров, вариантов в голову приходит три: установить Docker в Docker'е, установить Docker на машину с Ansible'ом, либо собирать контейнеры в облачной консоли. Про плагины для Jenkins мы договорились в этой заметке молчать. Помните?

Я решил: ну, раз контейнеры «из коробки» можно собирать в облачной консоли, то зачем городить огород? Keep it clean, верно? Хочу собирать контейнеры Jenkins'ом в облачной консоли, а потом оттуда же пулять их в кубер. Тем более, что внутри инфраструктуры у гугла ну ооочень жирные каналы, что благоприятно скажется на скорости деплоя.

Для подключения к облачной консоли необходимы две вещи: gcloud и права доступа к Google Cloud API для того экземпляра ВМ, с которой будет это самое подключение осуществляться.

Для тех, кто планирует подключаться вообще не из гуглового облака
Гугл допускает возможность отключения интерактивной авторизации в своих сервисах. Это позволит подключаться к консоли хоть с кофемашины, коли она под *nix'ами и у нее самой есть консоль.

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

Простейший способ дать права — через веб-интерфейс.

  1. Остановите экземпляр ВМ, с которого в дальнейшем будет выполняться подключение к облачной консоли.
  2. Откройте Сведения экземпляра и нажмите Изменить.
  3. В самом низу страницы выберите область действия доступа экземпляра Полный доступ ко всем Cloud API.

    Скриншот

  4. Сохраните изменения и запустите экземпляр.

По окончании загрузки ВМ, подключитесь к ней по SSH и убедитесь, что подключение происходит без ошибки. Воспользуйтесь командой:

gcloud alpha cloud-shell ssh

Успешное подключение выглядит примерно так


Деплой в GKE


Поскольку мы всячески стремимся полностью перейти на IaC (Infrastucture as a Code), докерфайлы у нас хранятся в гите. Это с одной стороны. А деплой в kubernetes описывается yaml-файлом, который используется только данным заданием, который сам по себе тоже как бы код. Это с другой стороны. В общем, я к тому, что план таков:

  1. Берем значения переменных BUILD_VERSION и, опционально, значения переменных, которые будут переданы через ENV.
  2. Качаем из гита докерфайл.
  3. Генерируем yaml для деплоя.
  4. Заливаем оба этих файла по scp в облачную консоль.
  5. Билдим там контейнер и пушаем его в Container registry
  6. Применяем файл деплоя нагрузки в кубер.

Давайте более конкретно. Раз заговорили об ENV, то предположим, нам надо будет передавать значения двух параметров: PARAM1 и PARAM2. Добавляем их задание на деплой, тип — String Parameter.

Скриншот


Генерировать yaml будем простым перенаправлением echo в файл. Предполагатся, разумеется, что в докерфайле у вас присутcnвуют PARAM1 и PARAM2, что имя нагрузки будет awesomeapp, а собранный контейнер с приложением указанной версии лежит в Container registry по пути gcr.io/awesomeapp/awesomeapp-$BUILD_VERSION, где $BUILD_VERSION как раз и был выбран из выпадающего списка.

Листинг команд
touch deploy.yaml
echo "apiVersion: apps/v1" >> deploy.yaml
echo "kind: Deployment" >> deploy.yaml
echo "metadata:" >> deploy.yaml
echo "  name: awesomeapp" >> deploy.yaml
echo "spec:" >> deploy.yaml
echo "  replicas: 1" >> deploy.yaml
echo "  selector:" >> deploy.yaml
echo "    matchLabels:" >> deploy.yaml
echo "      run: awesomeapp" >> deploy.yaml
echo "  template:" >> deploy.yaml
echo "    metadata:" >> deploy.yaml
echo "      labels:" >> deploy.yaml
echo "        run: awesomeapp" >> deploy.yaml
echo "    spec:" >> deploy.yaml
echo "      containers:" >> deploy.yaml
echo "      - name: awesomeapp" >> deploy.yaml
echo "        image: gcr.io/awesomeapp/awesomeapp-$BUILD_VERSION:latest" >> deploy.yaml
echo "        env:" >> deploy.yaml
echo "        - name: PARAM1" >> deploy.yaml
echo "          value: $PARAM1" >> deploy.yaml
echo "        - name: PARAM2" >> deploy.yaml
echo "          value: $PARAM2" >> deploy.yaml


Агенту Jenkins'а после подключения с помощью gcloud alpha cloud-shell ssh интерактивный режим не доступен, поэтому передаем команды в облачную консоль с помощью параметра --command.

Чистим домашнюю папку в облачной консоли от старого докерфайла:

gcloud alpha cloud-shell ssh --command="rm -f Dockerfile"

Кладем свежескаченный докерфайл в домашнюю папку облачной консоли с помощью scp:

gcloud alpha cloud-shell scp localhost:./Dockerfile cloudshell:~

Собираем, тегируем и пушаем контейнер в Container registry:

gcloud alpha cloud-shell ssh --command="docker build -t awesomeapp-$BUILD_VERSION ./ --build-arg BUILD_VERSION=$BUILD_VERSION --no-cache"
gcloud alpha cloud-shell ssh --command="docker tag awesomeapp-$BUILD_VERSION gcr.io/awesomeapp/awesomeapp-$BUILD_VERSION"
gcloud alpha cloud-shell ssh --command="docker push gcr.io/awesomeapp/awesomeapp-$BUILD_VERSION"

Аналогичным образом поступаем с файлом деплоя. Обратите внимания, что в командах ниже используются вымышленные имена кластера, куда происходит деплой (awsm-cluster) и имя проекта (awesome-project), где находется кластер.

gcloud alpha cloud-shell ssh --command="rm -f deploy.yaml"
gcloud alpha cloud-shell scp localhost:./deploy.yaml cloudshell:~
gcloud alpha cloud-shell ssh --command="gcloud container clusters get-credentials awsm-cluster --zone us-central1-c --project awesome-project && kubectl apply -f deploy.yaml"

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

Скриншот


А далее и успешный деплой собранного контейнера

Скриншот


Я умышленно обошел вниманием настройку Ingress. По одной простой причине: однажды настроив его на workload с заданным именем, он останется работоспособным, сколько деплоев с этим именем ни проводи. Ну и вообще, это немного за рамками истории.

Вместо выводов


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

А еще мне просто нравится расковырять какую-нибудь новую для меня тему. Текст выше — в том числе и способ поделиться находками, которые я сделал, решая описанную в самом начале задачу. Поделиться с теми, кто, как и, вовсе не лютый волк в девопсе. Если хотя бы кому-то мои находки помогут — буду доволен.