В продолжение предыдущей статьи про инструменты деплоя в Kubernetes, хочу рассказать вам про то как можно использовать Jsonnet для упрощения описания джоб в вашем .gitlab-ci.yml



Дано


Есть монорепа, в которой:


  • 10 Dockerfiles
  • 30 описанных деплоев
  • 3 окружения: devel, staging и production

Задача


Настроить пайплайн:


  • Сборка Docker-образов должна производиться по добавлении git-тэга с версией.
  • Каждая операция деплоя должна выполняться при пуше в ветку окружения и только по изменении файлов в конкретной директории
  • В каждом окружении установлен свой gitlab-runner с отдельным тэгом, который выполняет деплой только в своём окружении.
  • Не все приложения должны быть задеплоены в каждое из окружений, мы должны описать пайплайн так, чтобы иметь возможность делать исключения.
  • Некоторые деплойменты используют git submodule и должны запускаться с установленной переменной GIT_SUBMODULE_STRATEGY=normal

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


Решение


gitlab-ci.yml имеет встроенные возможности по сокращению описания повторяющихся джоб, например можно использовать extends или include, но он не предоставляет полноценный темплейтинг, что не позволяет описать джобы наиболее кратко и эфективно.


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


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

К примеру для vim есть плагин vim-jsonnet, который включает подсветку синтаксиса и автоматически выполняет jsonnet fmt при каждом сохранении (требует наличия установленно jsonnet).

Посмотрим на структуру нашего репозитория:


.
+-- deploy
¦   +-- analyse
¦   +-- basin
¦   +-- brush
¦   +-- copper
¦   +-- dinner
¦   +-- dirty
¦   +-- drab
¦   +-- drunk
¦   +-- education
¦   +-- fanatical
¦   +-- faulty
¦   +-- guarantee
¦   +-- guitar
¦   +-- hall
¦   +-- harmonious
¦   +-- history
¦   +-- iron
¦   +-- maniacal
¦   +-- mist
¦   +-- nine
¦   +-- pleasant
¦   +-- polish
¦   +-- receipt
¦   +-- shop
¦   +-- smelly
¦   +-- solid
¦   +-- stroke
¦   +-- thunder
¦   +-- ultra
¦   L-- yarn
L-- dockerfiles
    +-- dinner
    +-- drunk
    +-- fanatical
    +-- guarantee
    +-- guitar
    +-- harmonious
    +-- shop
    +-- smelly
    +-- thunder
    L-- yarn

Сборка docker-образов будет производиться с помощью kaniko


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


qbec apply <environment> --root deploy/<app> --yes

где:


  • <app> — название нашего приложения
  • <environment> — одно из наших окружений: devel, stage или prod.

В конечном итоге наши джобы должны выглядeть так:


Сборка:


build:{{ image }}:
  stage: build
  tags:
    - build
  image:
    name: gcr.io/kaniko-project/executor:debug
    entrypoint: [""]
  script:
    - echo "{\"auths\":{\"$CI_REGISTRY\":{\"username\":\"$CI_REGISTRY_USER\",\"password\":\"$CI_REGISTRY_PASSWORD\"}}}" > /kaniko/.docker/config.json
    - /kaniko/executor --context $CI_PROJECT_DIR --dockerfile $CI_PROJECT_DIR/dockerfiles/{{ image }}/Dockerfile --destination $CI_REGISTRY_IMAGE/{{ image }}:$CI_COMMIT_TAG
  only:
    refs:
      - tags

Где вместо {{ image }}, будет подставляться имя директории из dockerfiles


Деплой:


deploy:{{ environment }}:{{ app }}:
  stage: deploy
  tags:
    - {{ environment }}
  script:
    - qbec apply {{ environment }} --root deploy/{{ app }} --force:k8s-context __incluster__ --wait --yes
  only:
    changes:
      - deploy/{{ app }}/**/*
    refs:
      - {{ environment }}

Где вместо {{ app }}, будет подставляться имя директории из deploy,
а вместо {{ environment }} — имя окружения в которое нужно произвести деплой.


Давайте опишем прототипы наших джоб в виде объектов в отдельной либе lib/jobs.jsonnet


{
  // Задание на сборку docker-образа
  dockerImage(name):: {
    tags: ['build'],
    stage: 'build',
    image: {
      name: 'gcr.io/kaniko-project/executor:debug-v0.15.0',
      entrypoint: [''],
    },
    script: [
      'echo "{\\"auths\\":{\\"$CI_REGISTRY\\":{\\"username\\":\\"$CI_REGISTRY_USER\\",\\"password\\":\\"$CI_REGISTRY_PASSWORD\\"}}}" > /kaniko/.docker/config.json',
      '/kaniko/executor --cache --context $CI_PROJECT_DIR/dockerfiles/' + name + ' --dockerfile $CI_PROJECT_DIR/dockerfiles/' + name + '/Dockerfile --destination $CI_REGISTRY_IMAGE/' + name + ':$CI_COMMIT_TAG --build-arg VERSION=$CI_COMMIT_TAG',
    ],
  },
  // Задание на деплой qbec-приложения
  qbecApp(name): {
    stage: 'deploy',
    script: [
      'qbec apply $CI_COMMIT_REF_NAME --root deploy/' + name + ' --force:k8s-context __incluster__ --wait --yes',
    ],
    only: {
      changes: [
        'deploy/' + name + '/**/*',
      ],
    },
  },
}

Обратите внимание я намеренно не стал указывать refs и tags чтобы сделать нашу либу более гибкой и в полной мере продемонстрировать вам возможности jsonnet, их мы добавим позже уже из основного файла.


Теперь опишем наш .gitlab-ci.jsonnet:


// Импортируем нашу либу
local jobs = import 'lib/jobs.libsonnet';

// Определяем функции модификаторы
local ref(x) = { only+: { refs: [x] } };
local tag(x) = { tags: [x] };
local submodule(x) = { variables+: { GIT_SUBMODULE_STRATEGY: x } };

{
  // Cборка docker-образов
  ['build:' + x]: jobs.dockerImage(x) + tag('build') + ref('tags')
  for x in [
    'dinner',
    'drunk',
    'fanatical',
    'guarantee',
    'guitar',
    'harmonious',
    'shop',
    'smelly',
    'thunder',
    'yarn',
  ]
}
+
{
  // Деплой приложений которые должны быть развёрнуты только в 'prod'
  ['deploy:prod:' + x]: jobs.qbecApp(x) + tag('prod') + ref('prod')
  for x in [
    'dinner',
    'hall',
  ]
}
+
{
  // Деплой с git-submodule
  ['deploy:' + env + ':' + app]: jobs.qbecApp(app) + tag(env) + ref(env) + submodule('normal')
  for env in ['devel', 'stage', 'prod']
  for app in [
    'brush',
    'fanatical',
    'history',
    'shop',
  ]
}
+
{
  // Деплой всего остального
  ['deploy:' + env + ':' + app]: jobs.qbecApp(app) + tag(env) + ref(env)
  for env in ['devel', 'stage', 'prod']
  for app in [
    'analyse',
    'basin',
    'copper',
    'dirty',
    'drab',
    'drunk',
    'education',
    'faulty',
    'guarantee',
    'guitar',
    'harmonious',
    'iron',
    'maniacal',
    'mist',
    'nine',
    'pleasant',
    'polish',
    'receipt',
    'smelly',
    'solid',
    'stroke',
    'thunder',
    'ultra',
    'yarn',
  ]
}

Обратите внимание на функции ref, tag и submodule вначале файла, они позволяют сформировать переопределяющий объект.


Небольшое пояснение: использование "+:" вместо ":" для override-объектов позволяет добавить значение к уже существующему объекту или списку.


Например ":" для refs:


local job = {
  script: ['echo 123'],
  only: { refs: ['tags'] },
};
local ref(x) = { only+: { refs: [x] } };

job + ref('prod')

вернёт:


{
   "only": { "refs": [ "prod" ] },
   "script": [ "echo 123" ]
}

А вот "+:" для refs:


local job = {
  script: ['echo 123'],
  only: { refs: ['tags'] },
};
local ref(x) = { only+: { refs+: [x] } };

job + ref('prod')

вернёт:


{
   "only": { "refs": [ "prod", "tags" ] },
   "script": [ "echo 123" ]
}

Как видите, использование Jsonnet позволяет очень эффективно описывать и проводить слияние ваших объектов, на выходе вы всегда получаете готовый JSON, который мы сразу же можем записать в наш .gitlab-ci.yml файл:


jsonnet .gitlab-ci.jsonnet > .gitlab-ci.yml

Проверим количество строк:


# wc -l .gitlab-ci.jsonnet lib/jobs.libsonnet .gitlab-ci.yml
   77 .gitlab-ci.jsonnet
   24 lib/jobs.libsonnet
 1710 .gitlab-ci.yml

На мой взгляд очень неплохо!


Посмотреть больше примеров и пощупать Jsonnet можно прямо на официальном сайте: jsonnet.org
Если вам, как и мне, нравится Jsonnet то вступайте в нашу группу в телеграме t.me/jsonnet_ru