Привет! За последнее время вышло много классных инструментов автоматизации как для сборки Docker-образов так и для деплоя в Kubernetes. В связи с этим решил поиграться с гитлабом, как следует изучить его возможности и, конечно же, настроить пайплайн.


Вдохновлением для этой работы стал сайт kubernetes.io, который генерируется из исходных кодов автоматически, а на каждый присланный пул реквест робот автоматически генерирует preview-версию сайта с вашими изменениеми и предоставляет ссылку для просмотра.


Я постарался выстроить подобный процесс с нуля, но целиком построенный на Gitlab CI и свободных инструментах, которые я привык использовать для деплоя приложений в Kubernetes. Сегодня я, наконец, расскажу вам о них подробнее.


В статье будут рассмотрены такие инструменты как:
Hugo, QBEC, Kaniko, Git-crypt и GitLab CI с созданием динамических окружений.




Cодержание


  1. Знакомство с Hugo
  2. Подготовка Dockerfile
  3. Знакомство с Kaniko
  4. Знакомство с QBEC
  5. Пробуем Gitlab-runner с Kubernetes-executor
  6. Деплой Helm-чартов с QBEC
  7. Знакомство с git-crypt
  8. Создаём toolbox-образ
  9. Наш первый пайплайн и сборка образов по тэгам
  10. Автоматизация деплоя
  11. Артефакты и сборка при push в master
  12. Dynamic environments
  13. Review Apps



1. Знакомство с Hugo


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


Для тех, кто не знаком со статическими генераторами расскажу о них немного подробнее. В отличии от обычных движков сайтов с базой данных и каким-нибудь php, которые, при запросе пользователя, генерируют страницы на лету, статические генераторы устроенны немного иначе. Они позволяют взять исходники, как правило это набор файлов в Markdown-разметке и шаблоны тем, затем скомпилировать их в полностью готовый сайт.


То есть на выходе вы получите структуру директорий и набор сгенерированных html-файлов, которые можно будет просто залить на любой дешёвый хостинг и получить рабочий сайт.


Hugo можно установить локально и попробовать его в деле:


Инициализируем новый сайт:


hugo new site docs.example.org

И заодно git-репозиторий:


cd docs.example.org
git init

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


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


Отдельное внимание хочется уделить тому, что нам не требуется сохранять файлы темы в репозитории нашего проекта, вместо этого мы можем просто подключить её используя git submodule:


git submodule add https://github.com/matcornic/hugo-theme-learn themes/learn

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


Подправим конфиг config.toml:


baseURL = "http://docs.example.org/"
languageCode = "en-us"
title = "My Docs Site"
theme = "learn"

Уже на данном этапе можно запустить:


hugo server

И по адресу http://localhost:1313/ проверить наш только что созданный сайт, все изменения произведённые в директории автоматически обновляют и открытую страничку в браузере, очень удобно!


Попробуем создать титульную страницу в content/_index.md:


# My docs site

## Welcome to the docs!

You will be very smart :-)

Скриншот только что созданной страницы


Для генерации сайта достаточно запустить:


hugo

Содержимое директории public/ и будет являться вашим сайтом.
Да, кстати, давайте сразу внесём её в .gitignore:


echo /public > .gitignore

Не забываем закоммитить наши изменения:


git add .
git commit -m "New site created"



2. Подготовка Dockerfile


Настало время определить структуру нашего репозитория. Обычно я использую что-то вроде:


.
+-- deploy
¦   +-- app1
¦   L-- app2
L-- dockerfiles
    +-- image1
    L-- image2

  • dockerfiles/ — содержат директории с Dockerfiles и всем необходимым для сборки наших docker-образов.
  • deploy/ — содержит директории для деплоя наших приложений в Kubernetes

Таким образом наш первый Dockerfile мы создадим по пути dockerfiles/website/Dockerfile


FROM alpine:3.11 as builder
ARG HUGO_VERSION=0.62.0
RUN wget -O- https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_${HUGO_VERSION}_linux-64bit.tar.gz | tar -xz -C /usr/local/bin
ADD . /src
RUN hugo -s /src

FROM alpine:3.11
RUN apk add --no-cache darkhttpd
COPY --from=builder /src/public /var/www
ENTRYPOINT [ "/usr/bin/darkhttpd" ]
CMD [ "/var/www" ]

Как вы можете заметить, Dockerfile содержит два FROM, эта возможность называется multi-stage build и позволяет исключить из финального docker-образа всё ненужное.
Таким образом финальный образ у нас будет содержать только darkhttpd (легковесный HTTP-сервер) и public/ — контент нашего статически сгенирированного сайта.


Не забываем закоммитить наши изменения:


git add dockerfiles/website
git commit -m "Add Dockerfile for website"



3. Знакомство с Kaniko


В качестве сборщика docker-образов я решил использовать Kaniko, так как для его работы не требуется наличие docker-демона, а саму сборку можно проводить на любой машине и хранить кэш прямо в registry, избавлясь, тем самым, от необходимости иметь полноценное persistent-хранилище.


Для сборки образа достаточно запустить контейнер с kaniko executor и передать ему текущий контекст сборки, сделать это можно и локально, через docker:


docker run -ti --rm   -v $PWD:/workspace   -v ~/.docker/config.json:/kaniko/.docker/config.json:ro   gcr.io/kaniko-project/executor:v0.15.0   --cache   --dockerfile=dockerfiles/website/Dockerfile   --destination=registry.gitlab.com/kvaps/docs.example.org/website:v0.0.1

Где registry.gitlab.com/kvaps/docs.example.org/website — имя вашего docker-образа, после сборки он будет автоматически запушен в docker-регистри.


Параметр --cache позволяет кэшировать слои в docker registry, для приведённого примера они будут сохраняються в registry.gitlab.com/kvaps/docs.example.org/website/cache, но вы можете указать и другой путь с помощью параметра --cache-repo.


Скриншот docker-registry




4. Знакомство с QBEC


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


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


Qbec также позволяет рендерить Helm-чарты передавая им необходимые параметры и в дальнейшем оперировать ими также как и обычными манифестами, в том числе можно накладывать на них различные мутации, а это, в свою очередь, позволяет избавиться от необходимости использовать ChartMuseum. То есть можно хранить и рендерить чарты прямо из git, где им и самое место.


Как я говорил раньше, все деплойменты мы будем хранить в директории deploy/:


mkdir deploy
cd deploy

Давайте инициализируем наше первое приложение:


qbec init website
cd website

Сейчас структура нашего приложения выглядит так:


.
+-- components
+-- environments
¦   +-- base.libsonnet
¦   L-- default.libsonnet
+-- params.libsonnet
L-- qbec.yaml

посмотрим на файл qbec.yaml:


apiVersion: qbec.io/v1alpha1
kind: App
metadata:
  name: website
spec:
  environments:
    default:
      defaultNamespace: docs
      server: https://kubernetes.example.org:8443
  vars: {}

Здесь нас интересует в первую очередь spec.environments, qbec уже создал за нас default окружение и взял адрес сервера, а также namespace из нашего текущего kubeconfig.
Теперь при деплое в default окружение, qbec всегда будет деплоить только в указанный Kubernetes-кластер и в указанный неймспейс, то есть вам больше не придётся переключаться между контекстами и неймспейсами для того чтобы выполнить деплой.
В случае необоходимости вы всегда можете обновить настройки в этом файле.


Все ваши окружения описываются в qbec.yaml, и в файле params.libsonnet, где сказано откуда нужно брать для них параметры.


Дальше мы видим две директории:


  • components/ — здесь будут храниться все манифесты для нашего приложения, они могут быть описанны как в jsonnet так и обычными yaml-файлами
  • environments/ — здесь мы будем описывать все переменные (параметры) для наших окружений.

По умолчанию мы имеем два файла:


  • environments/base.libsonnet — он будет содержать общие параметры для всех окружений
  • environments/default.libsonnet — содержит параметры для окружения default

Давайте откроем environments/base.libsonnet и добавим туда параметры для нашего первого компонента:


{
  components: {
    website: {
      name: 'example-docs',
      image: 'registry.gitlab.com/kvaps/docs.example.org/website:v0.0.1',
      replicas: 1,
      containerPort: 80,
      servicePort: 80,
      nodeSelector: {},
      tolerations: [],
      ingressClass: 'nginx',
      domain: 'docs.example.org',
    },
  },
}

Создадим также наш первый компонент components/website.jsonnet:


local env = {
  name: std.extVar('qbec.io/env'),
  namespace: std.extVar('qbec.io/defaultNs'),
};
local p = import '../params.libsonnet';
local params = p.components.website;

[
  {
    apiVersion: 'apps/v1',
    kind: 'Deployment',
    metadata: {
      labels: { app: params.name },
      name: params.name,
    },
    spec: {
      replicas: params.replicas,
      selector: {
        matchLabels: {
          app: params.name,
        },
      },
      template: {
        metadata: {
          labels: { app: params.name },
        },
        spec: {
          containers: [
            {
              name: 'darkhttpd',
              image: params.image,
              ports: [
                {
                  containerPort: params.containerPort,
                },
              ],
            },
          ],
          nodeSelector: params.nodeSelector,
          tolerations: params.tolerations,
          imagePullSecrets: [{ name: 'regsecret' }],
        },
      },
    },
  },
  {
    apiVersion: 'v1',
    kind: 'Service',
    metadata: {
      labels: { app: params.name },
      name: params.name,
    },
    spec: {
      selector: {
        app: params.name,
      },
      ports: [
        {
          port: params.servicePort,
          targetPort: params.containerPort,
        },
      ],
    },
  },
  {
    apiVersion: 'extensions/v1beta1',
    kind: 'Ingress',
    metadata: {
      annotations: {
        'kubernetes.io/ingress.class': params.ingressClass,
      },
      labels: { app: params.name },
      name: params.name,
    },
    spec: {
      rules: [
        {
          host: params.domain,
          http: {
            paths: [
              {
                backend: {
                  serviceName: params.name,
                  servicePort: params.servicePort,
                },
              },
            ],
          },
        },
      ],
    },
  },
]

В данном файле мы описали сразу три Kubernetes-cущности, это: Deployment, Service и Ingress. При желании мы могли бы вынести их в разные компоненты, но на данном этапе нам хватит и одного.


Синтаксис jsonnet очень похож на обычный json, в принципе обычный json уже является валидным jsonnet, так что первое время вам возможно будет проще воспользоваться онлайн-сервисами вроде yaml2json чтобы сконвертировать привычный вам yaml в json, либо, если ваши компоненты не содержат никаких переменных, то их вполне можно описать в виде обычного yaml.


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

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

Всё готово, теперь можем начинать деплой:


Чтобы посмотреть что у нас получилось, выполним:


qbec show default

На выходе вы увидите отрендеренные yaml-манифесты, которые будут применены в кластер default.


Отлично, теперь применим:


qbec apply default

На выходе вы всегда увидите, что будет проделанно в вашем кластере, qbec попросит вас согласиться с изменениями, набрав y вы сможете подтвердить свои намерения.


Готово теперь наше приложение задеплоено!


В случае внесения изменений вы всегда сможете выполнить:


qbec diff default

чтобы посмотреть как эти изменения отразятся на текущем деплое


Не забываем закоммитить наши изменения:


cd ../..
git add deploy/website
git commit -m "Add deploy for website"



5. Пробуем Gitlab-runner с Kubernetes-executor


До недавного времени я использовал только обычный gitlab-runner на заранее подготовленной машине (LXC-контейнере) с shell- или docker-executor. Изначально мы имели несколько таких раннеров глобально определённых в нашем гитлабе. Они собирали docker-образы для всех проектов.


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


К счастью это вовсе не проблема, так как теперь мы будем деплоить gitlab-runner непосредственно как часть нашего проекта прямо в Kubernetes.


Gitlab предоставляет готовый helm-чарт для деплоя gitlab-runner в Kubernetes. Таким образом всё что вам нужно, это узнать registration token для нашего проекта в Settings --> CI / CD --> Runners и передать его helm:


helm repo add gitlab https://charts.gitlab.io

helm install gitlab-runner   --set gitlabUrl=https://gitlab.com   --set runnerRegistrationToken=yga8y-jdCusVDn_t4Wxc   --set rbac.create=true   gitlab/gitlab-runner

Где:


  • https://gitlab.com — адрес вашего Gitlab-сервера.
  • yga8y-jdCusVDn_t4Wxc — registration token для вашего проекта.
  • rbac.create=true — предоставляет раннеру необходимое количество привилегий, чтобы иметь возможность создавать поды для выполнения наших задач с помощью kubernetes-executor.

Если всё сделанно правильно, вы должны увидеть зарегистрированный раннер в секции Runners, в настройках вашего проекта.


Скриншот добавленного раннера


Вот так просто? — да, так просто! Больше никакой мороки с регистрацией раннеров вручную, с этой минуты раннеры будут создаваться и уничтожаться автоматически.




6. Деплой Helm-чартов с QBEC


Так как мы приняли решение считать gitlab-runner частью нашего проекта, настало время описать его в нашем Git-репозитории.


Мы могли бы описать его как отдельный компонент website, но в дальнейшем мы планируем деплоить разные копии website очень часто, в отличии gitlab-runner, который будет задеплоен всего-лишь один раз на каждый Kubernetes-кластер. Так что давайте инициализируем отдельное приложение для него:


cd deploy
qbec init gitlab-runner
cd gitlab-runner

На этот раз мы не будем описывать Kubernetes-сущности вручную, а возьмём готовый Helm-чарт. Одним из преимуществ qbec является возможность рендерить Helm-чарты прямо из Git-репозитория.


Давайте подключим его используя git submodule:


git submodule add https://gitlab.com/gitlab-org/charts/gitlab-runner vendor/gitlab-runner

Теперь директория vendor/gitlab-runner содержит у нас репозиторий с чартом для gitlab-runner.


Подобным образом можно подключать и другие репозитории, например и целиком репозиторий с официальными чартами https://github.com/helm/charts

Давайте опишем компонент components/gitlab-runner.jsonnet:


local env = {
  name: std.extVar('qbec.io/env'),
  namespace: std.extVar('qbec.io/defaultNs'),
};
local p = import '../params.libsonnet';
local params = p.components.gitlabRunner;

std.native('expandHelmTemplate')(
  '../vendor/gitlab-runner',
  params.values,
  {
    nameTemplate: params.name,
    namespace: env.namespace,
    thisFile: std.thisFile,
    verbose: true,
  }
)

Первым аргументом к expandHelmTemplate мы передаём путь к чарту, затем params.values, которые возьмём из параметров окружения, затем идёт объект с


  • nameTemplate — название релиза
  • namespace — неймспейс передаваемый хельму
  • thisFile — обязательный параметр, передающий путь к текущему файлу
  • verbose — показывает команду helm template со всеми аргументами при рендеринге чарта

Теперь опишем параметры для нашего компонента в environments/base.libsonnet:


local secrets = import '../secrets/base.libsonnet';

{
  components: {
    gitlabRunner: {
      name: 'gitlab-runner',
      values: {
        gitlabUrl: 'https://gitlab.com/',
        rbac: {
          create: true,
        },
        runnerRegistrationToken: secrets.runnerRegistrationToken,
      },
    },
  },
}

Обратите внимание runnerRegistrationToken мы забираем из внешнего файла secrets/base.libsonnet, давайте создадим его:


{
  runnerRegistrationToken: 'yga8y-jdCusVDn_t4Wxc',
}

Проверим, всё ли работает:


qbec show default

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


helm uninstall gitlab-runner

и задеплоить его же, но уже через qbec:


qbec apply default



7. Знакомство с git-crypt


На данный момент структура нашей директории для gitlab-runner выглядит так:


.
+-- components
¦   +-- gitlab-runner.jsonnet
+-- environments
¦   +-- base.libsonnet
¦   L-- default.libsonnet
+-- params.libsonnet
+-- qbec.yaml
+-- secrets
¦   L-- base.libsonnet
L-- vendor
    L-- gitlab-runner (submodule)

Но хранить секреты в Git небезопасно, не так-ли? Так что нам нужно должным образом их зашифровать.


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

Кроме того в таком случае мне не удалось бы рассказать вам о таком замечательном инструменте как git-crypt.

git-crypt ещё удобен тем, что позволяет сохранить всю историю секретов, а также сравнивать, мерджить и разрешать кофликты так-же как мы привыкли делать это в случае с Git.

Первым делом после установки git-crypt нам нужно сгенерировать ключи для нашего репозитория:


git crypt init

Если у вас имеется pgp-ключ, то вы можете сразу добавить себя как collaborator'а для этого проекта:


git-crypt add-gpg-user kvapss@gmail.com

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


Если же pgp-ключа у вас нет и не предвидится, то вы можете пойти другим путём и экспортировать ключ проекта:


git crypt export-key /path/to/keyfile

Таким образом любой, кто обладает экспортированным keyfile сможет расшифровать ваш репозиторий.


Настало время настроить наш первый секрет.
Напомню, мы по прежнему находимся в директории deploy/gitlab-runner/, где у нас имеется директория secrets/, давайте же зашифруем все файлы в ней, для этого создадим файл secrets/.gitattributes с таким содержанием:


* filter=git-crypt diff=git-crypt
.gitattributes !filter !diff

Как видно из содержания, все файлы по маске * будут прогоняться через git-crypt, за исключением самого .gitattributes


Проверить это мы можем запустив:


git crypt status -e

На выходе получим список всех файлов в репозитории для которых включенно шифрование


Вот и всё, теперь мы можем смело закоммитить наши изменения:


cd ../..
git add .
git commit -m "Add deploy for gitlab-runner"

Для того чтобы заблокировать репозиторий достаточно выполнить:


git crypt lock

и тут же все зашифрованные файлы превратятся в бинарное нечто, прочесть их будет невозможно.
Чтобы расшифровать репозиторий, выполните:


git crypt unlock



8. Создаём toolbox-образ


Toolbox-образ — это такой образ со всеми инструментами который мы будем использовать для деплоя нашего проекта. Он будет использоваться гитлаб-раннером для выполнения задач деплоя.


Здесь всё просто, создаём новый dockerfiles/toolbox/Dockerfile с таким содержанием:


FROM alpine:3.11

RUN apk add --no-cache git git-crypt

RUN QBEC_VER=0.10.3  && wget -O- https://github.com/splunk/qbec/releases/download/v${QBEC_VER}/qbec-linux-amd64.tar.gz      | tar -C /tmp -xzf -  && mv /tmp/qbec /tmp/jsonnet-qbec /usr/local/bin/

RUN KUBECTL_VER=1.17.0  && wget -O /usr/local/bin/kubectl       https://storage.googleapis.com/kubernetes-release/release/v${KUBECTL_VER}/bin/linux/amd64/kubectl  && chmod +x /usr/local/bin/kubectl

RUN HELM_VER=3.0.2  && wget -O- https://get.helm.sh/helm-v${HELM_VER}-linux-amd64.tar.gz      | tar -C /tmp -zxf -  && mv /tmp/linux-amd64/helm /usr/local/bin/helm

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


Также чтобы иметь возможность общаться с Kubernetes и выполнять в него деплой, нам нужно настроить роль для подов генерируемых gitlab-runner'ом.


Для этого перейдём в директорию с gitlab-runner'ом:


cd deploy/gitlab-runner

и добавим новый компонент components/rbac.jsonnet:


local env = {
  name: std.extVar('qbec.io/env'),
  namespace: std.extVar('qbec.io/defaultNs'),
};
local p = import '../params.libsonnet';
local params = p.components.rbac;

[
  {
    apiVersion: 'v1',
    kind: 'ServiceAccount',
    metadata: {
      labels: {
        app: params.name,
      },
      name: params.name,
    },
  },
  {
    apiVersion: 'rbac.authorization.k8s.io/v1',
    kind: 'Role',
    metadata: {
      labels: {
        app: params.name,
      },
      name: params.name,
    },
    rules: [
      {
        apiGroups: [
          '*',
        ],
        resources: [
          '*',
        ],
        verbs: [
          '*',
        ],
      },
    ],
  },
  {
    apiVersion: 'rbac.authorization.k8s.io/v1',
    kind: 'RoleBinding',
    metadata: {
      labels: {
        app: params.name,
      },
      name: params.name,
    },
    roleRef: {
      apiGroup: 'rbac.authorization.k8s.io',
      kind: 'Role',
      name: params.name,
    },
    subjects: [
      {
        kind: 'ServiceAccount',
        name: params.name,
        namespace: env.namespace,
      },
    ],
  },
]

Так же опишем новые параметры в environments/base.libsonnet, который теперь выглядит так:


local secrets = import '../secrets/base.libsonnet';

{
  components: {
    gitlabRunner: {
      name: 'gitlab-runner',
      values: {
        gitlabUrl: 'https://gitlab.com/',
        rbac: {
          create: true,
        },
        runnerRegistrationToken: secrets.runnerRegistrationToken,
        runners: {
          serviceAccountName: $.components.rbac.name,
          image: 'registry.gitlab.com/kvaps/docs.example.org/toolbox:v0.0.1',
        },
      },
    },
    rbac: {
      name: 'gitlab-runner-deploy',
    },
  },
}

Обратите внимание $.components.rbac.name ссылается на name для компонента rbac


Давайте проверим что изменилось:


qbec diff default

и применим наши изменения в Kubernetes:


qbec apply default

Так же не забываем закоммитить наши изменения в git:


cd ../..
git add dockerfiles/toolbox
git commit -m "Add Dockerfile for toolbox"
git add deploy/gitlab-runner
git commit -m "Configure gitlab-runner to use toolbox"



9. Наш первый пайплайн и сборка образов по тэгам


В корне проекта мы создадим .gitlab-ci.yml с таким содержанием:


.build_docker_image:
  stage: build
  image:
    name: gcr.io/kaniko-project/executor:debug-v0.15.0
    entrypoint: [""]
  before_script:
    - echo "{\"auths\":{\"$CI_REGISTRY\":{\"username\":\"$CI_REGISTRY_USER\",\"password\":\"$CI_REGISTRY_PASSWORD\"}}}" > /kaniko/.docker/config.json

build_toolbox:
  extends: .build_docker_image
  script:
    - /kaniko/executor --cache --context $CI_PROJECT_DIR/dockerfiles/toolbox --dockerfile $CI_PROJECT_DIR/dockerfiles/toolbox/Dockerfile --destination $CI_REGISTRY_IMAGE/toolbox:$CI_COMMIT_TAG
  only:
    refs:
      - tags

build_website:
  extends: .build_docker_image
  variables:
    GIT_SUBMODULE_STRATEGY: normal
  script:
    - /kaniko/executor --cache --context $CI_PROJECT_DIR --dockerfile $CI_PROJECT_DIR/dockerfiles/website/Dockerfile --destination $CI_REGISTRY_IMAGE/website:$CI_COMMIT_TAG
  only:
    refs:
      - tags

Обратите внимание, мы используем GIT_SUBMODULE_STRATEGY: normal для тех джоб, где нужно явно инициализировать сабмодули перед выполнением.


Не забываем закоммитить наши изменения:


git add .gitlab-ci.yml
git commit -m "Automate docker build"

Думаю можно смело назвать это версией v0.0.1 и повесить тэг:


git tag v0.0.1

Тэги мы будем вешать всякий раз тогда, когда нам потребуется зарелизить новую версию. Тэги в Docker-образах будут привязаны к Git-тэгам. Каждый push с новым тэгом будет инициализировать сборку образов с этим тэгом.


Выполним git push --tags, и посмотрим на наш первый пайплайн:


Скриншот первого пайплайна


Стоит обратить ваше внимание на то, что сборка по тэгам годится для сборки docker-образов, но не подходит для деплоя приложения в Kubernetes. Так как новые тэги могут назначаться и для старых коммитов, в этом случае инициализация пайплайна для них приведёт к деплою старой версии.

Чтобы решить эту проблему обычно сборка docker-образов привязывается к тэгам, а деплой приложения к ветке master, в которой захардкожены версии собранных образов. Именно в этом случае вы сможете инициализировать rollback простым revert master-ветки.



10. Автоматизация деплоя


Для того чтобы Gitlab-runner мог расшифровать наши секреты, нам понадобится экспортировать ключ репозитория, и добавить его в переменные окружения нашей CI:


git crypt export-key /tmp/docs-repo.key
base64 -w0 /tmp/docs-repo.key; echo

полученную строку сохраним в Gitlab, для этого перейдём в настройки нашего проекта:
Settings --> CI / CD --> Variables


И создадим новую переменную:


  • Type: File
  • Key: GITCRYPT_KEY
  • Value: <ваша строка>
  • Protected: true (на время обучения можно и false)
  • Masked: true
  • Scope: All environments

Скриншот добавленной переменной


Теперь обновим наш .gitlab-ci.yml добавив в него:


.deploy_qbec_app:
  stage: deploy
  only:
    refs:
      - master

deploy_gitlab_runner:
  extends: .deploy_qbec_app
  variables:
    GIT_SUBMODULE_STRATEGY: normal
  before_script:
    - base64 -d "$GITCRYPT_KEY" | git-crypt unlock -
  script:
    - qbec apply default --root deploy/gitlab-runner --force:k8s-context __incluster__ --wait --yes

deploy_website:
  extends: .deploy_qbec_app
  script:
    - qbec apply default --root deploy/website --force:k8s-context __incluster__ --wait --yes

Здесь мы задействовали несколько новых опций для qbec:


  • --root some/app — позволяет определить директорию конкретного приложения
  • --force:k8s-context __incluster__ — это магическая переменная, которая говорит что деплой будет происходить в тотже кластер в котором запущен gtilab-runner. Сделать это необходимо, так как в противном случае qbec будет пытаться найти подходяший Kubernetes-сервер в вашем kubeconfig
  • --wait — заставляет qbec дождаться, когда создаваемые им ресурсы перейдут в состояние Ready и только потом завершится с успешным exit-code.
  • --yes — просто отключает интерактивный шелл Are you sure? при деплое.

Не забываем закоммитить наши изменения:


git add .gitlab-ci.yml
git commit -m "Automate deploy"

И после git push мы увидим как наши приложения были задеплоены:


Скриншот второго пайплайна




11. Артефакты и сборка при push в master


Обычно вышеописанных шагов вполне хватает для сборки и доставки почти любого микросервиса, но мы не хотим вешать тэг каждый раз когда нам понадобится обновить сайт. Поэтому мы пойдём более динамическим путём и настроим деплой по digest в master-ветке.


Идея проста: теперь образ нашего website будет пересобираться каждый раз при push в master, а после этого автоматически деплоиться в Kubernetes.


Давайте обновим эти две джобы в нашем .gitlab-ci.yml:


build_website:
  extends: .build_docker_image
  variables:
    GIT_SUBMODULE_STRATEGY: normal
  script:
    - mkdir -p $CI_PROJECT_DIR/artifacts
    - /kaniko/executor --cache --context $CI_PROJECT_DIR --dockerfile $CI_PROJECT_DIR/dockerfiles/website/Dockerfile --destination $CI_REGISTRY_IMAGE/website:$CI_COMMIT_REF_NAME --digest-file $CI_PROJECT_DIR/artifacts/website.digest
  artifacts:
    paths:
      - artifacts/
  only:
    refs:
      - master
      - tags

deploy_website:
  extends: .deploy_qbec_app
  script:
    - DIGEST="$(cat artifacts/website.digest)"
    - qbec apply default --root deploy/website --force:k8s-context __incluster__ --wait --yes --vm:ext-str digest="$DIGEST"

Обратите внимание, мы добавили ветку master к refs для джобы build_website и мы теперь используем $CI_COMMIT_REF_NAME вместо $CI_COMMIT_TAG, то есть мы отвязываемся от тэгов в Git и теперь будем пушить образ с названием ветки коммита инициализировашего пайплайн. Стоит заметить, что это также будет работать и с тэгами, что позволит нам сохранять снапшоты сайта с определённой версией в docker-registry.


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


Опция --vm:ext-str digest="$DIGEST" для qbec — позволяет передать внешную переменную в jsonnet. Мы хотим чтобы с каждым релизом нашего приложения оно передеплоивалось в кластере. Использовать имя тэга, которое теперь может быть неизменным, мы здесь больше не можем, так как нам нужно завязываться на конкретную версию образа и триггерить деплой при её изменении.


Здесь нам поможет возможность Kaniko сохранять digest образа в файл (опция --digest-file)
Затем этот файл мы передадим и прочитаем в момент деплоя.


Обновим параметры для нашего deploy/website/environments/base.libsonnet который теперь будет выглядить так:


{
  components: {
    website: {
      name: 'example-docs',
      image: 'registry.gitlab.com/kvaps/docs.example.org/website@' + std.extVar('digest'),
      replicas: 1,
      containerPort: 80,
      servicePort: 80,
      nodeSelector: {},
      tolerations: [],
      ingressClass: 'nginx',
      domain: 'docs.example.org',
    },
  },
}

Готово, теперь любой коммит в master инициализиует сборку docker-образа для website, а затем его деплой в Kubernetes.


Не забываем закоммитить наши изменения:


git add .
git commit -m "Configure dynamic build"

Проверим, после git push мы должны увидеть что-то подобное:


Скриншот пайплайна для master


В принципе нам без надобности передеплоивать gitlab-runner при каждом push, если, конечно, ничего не изменилось в его кофигурации, давайте исправим это в .gitlab-ci.yml:


deploy_gitlab_runner:
  extends: .deploy_qbec_app
  variables:
    GIT_SUBMODULE_STRATEGY: normal
  before_script:
    - base64 -d "$GITCRYPT_KEY" | git-crypt unlock -
  script:
    - qbec apply default --root deploy/gitlab-runner --force:k8s-context __incluster__ --wait --yes
  only:
    changes:
      - deploy/gitlab-runner/**/*

changes позволит следить за изменениями в deploy/gitlab-runner/ и будет тригерить нашу джобу только при наличии таковых


Не забываем закоммитить наши изменения:


git add .gitlab-ci.yml
git commit -m "Reduce gitlab-runner deploy"

git push, так-то лучше:


Скриншот обновлённого пайплайна




12. Dynamic environments


Настало время разнообразить наш пайплайн динамическими окружениями.


Для начала обновим джобу build_website в нашем .gitlab-ci.yml, убрав из неё блок only, что заставит Gitlab тригеррить её при любом коммите в любую ветку:


build_website:
  extends: .build_docker_image
  variables:
    GIT_SUBMODULE_STRATEGY: normal
  script:
    - mkdir -p $CI_PROJECT_DIR/artifacts
    - /kaniko/executor --cache --context $CI_PROJECT_DIR --dockerfile $CI_PROJECT_DIR/dockerfiles/website/Dockerfile --destination $CI_REGISTRY_IMAGE/website:$CI_COMMIT_REF_NAME --digest-file $CI_PROJECT_DIR/artifacts/website.digest
  artifacts:
    paths:
      - artifacts/

Затем обновим джобу deploy_website, добавим туда блок environment:


deploy_website:
  extends: .deploy_qbec_app
  environment:
    name: prod
    url: https://docs.example.org
  script:
    - DIGEST="$(cat artifacts/website.digest)"
    - qbec apply default --root deploy/website --force:k8s-context __incluster__ --wait --yes --vm:ext-str digest="$DIGEST"

Это позволит Gitlab ассоциировать джобу с prod окружением и выводить правильную ссылку на него.


Теперь добавим ещё две джобы:


deploy_website:
  extends: .deploy_qbec_app
  environment:
    name: prod
    url: https://docs.example.org
  script:
    - DIGEST="$(cat artifacts/website.digest)"
    - qbec apply default --root deploy/website --force:k8s-context __incluster__ --wait --yes --vm:ext-str digest="$DIGEST"

deploy_review:
  extends: .deploy_qbec_app
  environment:
    name: review/$CI_COMMIT_REF_NAME
    url: http://$CI_ENVIRONMENT_SLUG.docs.example.org
    on_stop: stop_review
  script:
    - DIGEST="$(cat artifacts/website.digest)"
    - qbec apply review --root deploy/website --force:k8s-context __incluster__ --wait --yes --vm:ext-str digest="$DIGEST" --vm:ext-str subdomain="$CI_ENVIRONMENT_SLUG" --app-tag "$CI_ENVIRONMENT_SLUG"
  only:
    refs:
    - branches
  except:
    refs:
      - master

stop_review:
  extends: .deploy_qbec_app
  environment:
    name: review/$CI_COMMIT_REF_NAME
    action: stop
  stage: deploy
  before_script:
    - git clone "$CI_REPOSITORY_URL" master
    - cd master
  script:
    - qbec delete review --root deploy/website --force:k8s-context __incluster__ --yes --vm:ext-str digest="$DIGEST" --vm:ext-str subdomain="$CI_ENVIRONMENT_SLUG" --app-tag "$CI_ENVIRONMENT_SLUG"
  variables:
    GIT_STRATEGY: none
  only:
    refs:
    - branches
  except:
    refs:
      - master
  when: manual

Они будут запускаться при push в любые бренчи кроме master и будут деплоить preview версию сайта.


Мы видим новую опцию для qbec: --app-tag — она позволяет тэгировать задеплоенные версии приложения и работать только в пределах этого тэга, при создании и уничтожении ресурсов в Kubernetes qbec будет оперировать только ими.
Таким образом мы можем не создавать отдельный энвайромент под каждый review, а просто переиспользовать один и тот же.


Здесь мы так же используем qbec apply review, вместо qbec apply default — это как раз тот самый момент когда мы постараемся описать различия для наших окружений (review и default):


Добавим review окружение в deploy/website/qbec.yaml


spec:
  environments:
    review:
      defaultNamespace: docs
      server: https://kubernetes.example.org:8443

Затем обьявим его в deploy/website/params.libsonnet:


local env = std.extVar('qbec.io/env');
local paramsMap = {
  _: import './environments/base.libsonnet',
  default: import './environments/default.libsonnet',
  review: import './environments/review.libsonnet',
};

if std.objectHas(paramsMap, env) then paramsMap[env] else error 'environment ' + env + ' not defined in ' + std.thisFile

И запишем кастомные параметры для него в deploy/website/environments/review.libsonnet:


// this file has the param overrides for the default environment
local base = import './base.libsonnet';
local slug = std.extVar('qbec.io/tag');
local subdomain = std.extVar('subdomain');

base {
  components+: {
    website+: {
      name: 'example-docs-' + slug,
      domain: subdomain + '.docs.example.org',
    },
  },
}

Давайте также повнимательнее посмотрим на джобу stop_review, она будет тригериться при удалении брэнча и чтобы gitlab не пытался сделать checkout на неё используется GIT_STRATEGY: none, позже мы клонируем master-ветку и удаляем review через неё.
Немного заморочно, но более красивого способа я пока не нашёл.
Альтернативным вариантом может быть деплой каждого review в отельный неймспейс, который всегда можно снести целиком.


Не забываем закоммитить наши изменения:


git add .
git commit -m "Enable automatic review"

git push, git checkout -b test, git push origin test, проверяем:


Скриншот созданных environments в Gitlab


Всё работает? — отлично, удаляем нашу тестовую ветку: git checkout master, git push origin :test, проверяем что джобы на удаление environment отработали без ошибок.


Здесь сразу хочется уточнить, что создавать ветки может любой девелопер в проекте, он также может изменить .gitlab-ci.yml файл и получить доступ к секретным переменным.
По этому настоятельно рекомендуется разрешить их использование только для protected-веток, например в master, либо создать отдельный сет переменных под каждое окружение.



13. Review Apps


Review Apps это такая возможность гитлаба, которая позволяет для каждого файла в репозитории добавить кнопку для его быстрого просмотра в задеплоенном окружении.


Для того чтобы эти кнопки появились, необходимо создать файл .gitlab/route-map.yml и описать в нём все трансформации путей, в нашем случае это будет очень просто:


# Indices
- source: /content\/(.+?)_index\.(md|html)/ 
  public: '\1'

# Pages
- source: /content\/(.+?)\.(md|html)/ 
  public: '\1/'

Не забываем закоммитить наши изменения:


git add .gitlab/
git commit -m "Enable review apps"

git push, и проверяем:


Скриншот кнопки Review App


Job is done!


Исходники проекта:



Спасибо за внимание, надеюсь вам понравилось image

Комментарии (1)


  1. namee
    27.12.2019 07:19

    КПДВ не соответствует законодательству