GrabDuck

Релизный цикл для Infrastructure as Code

:

На просторах интернета можно встретить немало статей на тему Infrastructure as Code, утилит SaltStack, Kitchen-CI и так далее, однако, сколько я не встречал различного рода примеров IaC, они зачастую остаются только кодом, как правило, с делением на бранчи в VCS соответствующие наименованию типа среды, например dev/int, возможно даже с тэгами, а говорить о полноценном цикле разработки конфигураций как правило не приходится. Во всяком случае с компаниями, с которыми знаком именно такая ситуация, да и статей не находил.
Может быть оно и понятно — тотальный Agile и "раз-раз и в продакшен".
Попробую исправить ситуацию данной статьей.


Краткое описание работы

Компания, в которой я работаю, предоставляет различного рода ИТ услуги, как в области разработки игр, 3D моделирования, так и разработки различного рода коробочных продуктов, платформенных решений.
В данной статье я расскажу именно о решениях в области конфигурирования и управления инфраструктурных решений проектов платформы, состоящей из 20 различных компонентов, не считая аналитический блок, общей численностью 70 "серверных единиц" — как раз в подобных системах простора для творчества и инженерной мысли хоть отбавляй.

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

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

Так, например, конфигурация самого обычного веб-сервера будет включать в себя не только nginx/httpd и саму по себе конфигурацию сайта, но и системных сервисов:


  • сетевая конфигурация
  • настройка ssh демона
  • авторизация на хосте
  • настройка tcp_wrapper
  • iptables
  • selinux/apparmor
  • синхронизация времени
  • логирование
  • audit

Не стоит также забывать о DNS записях для сайта, выпуске и перевыпуске сертификатов.
Если описывать конфигурацию для каждого сервиса отдельно, можно сойти с ума, даже если просто копипастить ее.

К счастью SaltStack, равно как и большинство прочих систем управления конфигурациями, имеет воспроизводимые блоки конфигураций, называемые [Formulas]. Однако, копировать формулы, либо стягивать их напрямую с репозитория, даже если по какому-либо тэгу или из специального релизного бранча, не совсем корректно с точки зрения стабилизации и воспроизводимости — тэг может быть переписан, бранч обновлен, а следить за валидной ревизией и стягивать именно ее слишком сложно, т.к. необходимо вести список соответствия зависимостей между формулами полагаясь на ревизии. Для этого уже давным давно был придуман процесс версионирования модулей, зависимостей. Да, конечно можно в купе с ревизией вести зависимость по версии, однако VCS были изобретены для поддержания истории изменений файлов, а никак не для фиксации релизных артефактов.

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

Данный компонент называется SaltStack Packet Manager или кратко SPM. По сути своей полученный пакет — tar, сжатый bzip с метаинформацией, за которую отвечает файл FORMULA. Стоит отметить, что для поддержания работы репозитория требуется также сформировать файл метаинформации, сгенерированный в присутствии всех хранящихся артефактов в репозитории, принцип тот же, что у Yum репозитория. Есть несколько недостатков в SPM, как например невозможность перечислить список доступных в репозитории формул, не смотря на то, что он стягивает файл метаданных который содержит эту информацию, задать версию зависимости формулы, но связано это лишь с молодостью самого по себе SPM, появился он, если память не изменяет, в версии 2016.3.


Объект релиза

Давайте все же перейдем ближе к делу. Для этого рассмотрим конфигурацию сервиса Gitbucket, кто не в курсе — open source система управления git репозиториями написанная на scala, с достаточно хорошим API, плагинной системой, приятным и удобным веб-интерфейсом, прекрасно идет в сравнение с такими мэтрами как Bitbucket, GitHub, GitLab(без CI) и особенно шикарен в сравнении с Beanstalk.

Итак, как я уже говорил, написан он на scala, представляет из себя веб-приложение, значит нам понадобятся минимум следующие конфигурации — java, tomcat, nginx.
Тестировать мы будем используя Kitchen-CI на хосте Docker, в качестве провайженера kitchen-salt.

Давайте на всякий случай рассмотрим структуру директории с конфигурацией:

gitbucket-formula
|-- gitbucket
|   |-- frontend
|   |   |-- templates
|   |   |   |-- nginx-http.conf
|   |   |   |-- nginx-https.conf
|   |   |   `-- upstream.conf
|   |   `-- init.sls
|   |-- plugins
|   |   `-- init.sls
|   |-- templates
|   |   |-- database.conf
|   |   `-- gitbucket.conf
|   |-- init.sls
|   `-- settings.jinja
|-- test
|   `-- integration
|       |-- fe
|       |   `-- serverspec
|       |       `-- frontend_spec.rb
|       |-- fe-ssl
|       |   `-- serverspec
|       |       `-- frontend_ssl_spec.rb
|       |-- gitbucket
|       |   `-- serverspec
|       |       `-- gitbucket_spec.rb
|       `-- helpers
|           `-- serverspec
|               |-- fe_shared_spec.rb
|               |-- fe_ssl_shared_spec.rb
|               `-- gitbucket_shared_spec.rb
|-- .editorconfig
|-- .gitattributes
|-- .gitconfig
|-- .gitignore
|-- .kitchen.yml
|-- pillar.example
|-- FORMULA
`-- README.md

Как видите, формула содержит не только основные параметры конфигурации сервиса, но и тесты написанные для serverspec в директории test, файл метаинформации FORMULA, дескриптор конфигурации для kitchen и служебные файлы конфигурации git-репозитория.

На данный момент нам интересен именно файл .kitchen.yml:

---
driver:
  name: docker
  use_sudo: false
  require_chef_omnibus: false
  socket: tcp://dckhub.devops.sperasoft.com:2375
  volume: /sys/fs/cgroup
  cap_add:
    - SYS_ADMIN

provisioner:
  name: salt_solo
  formula: gitbucket
  salt_bootstrap_options: 'stable 2016.11'
  dependencies:
    - path: ../tomcat-formula
      name: tomcat
    - path: ../oracle-jdk-formula
      name: oracle-jdk
    - path: ../nginx-formula
      name: nginx
  state_top:
    base:
      '*':
        - gitbucket
  pillars:
    top.sls:
      base:
        '*':
          - gitbucket
    gitbucket.sls:
      tomcat:
        instance:
          name:                         'gitbucket'
          connport:                     '8080'
          sslport:                      '8443'
          xms:                          '512M'
          xmx:                          '1G'
          mdmsize:                      '256M'
    frontend.sls:
      gitbucket:
        frontend:
          enabled: False
          ssl:
            enabled: False
            external: False
            crt: |
              -----BEGIN CERTIFICATE-----
              Cамоподписанный сертификат для тестирования ssl на localhost
              -----END CERTIFICATE-----
            key: |
              -----BEGIN RSA PRIVATE KEY-----
              Ключик к сертификату
              -----END RSA PRIVATE KEY-----

platforms:
  - name: debian
    driver_config:
      image: debian:8
      run_command: /sbin/init
      provision_command:
        - apt-get install -y --no-install-recommends
          wget curl tar mc iproute apt-utils apt-transport-https locales
        - localedef  --no-archive -c -i en_US -f UTF-8 en_US.UTF-8 &&
          localedef  --no-archive -c -i ru_RU -f UTF-8 ru_RU.UTF-8
  - name: centos
    driver_config:
      image: dckreg.devops.sperasoft.com/centos-systemd:7.3.1611
      run_command: /sbin/init
      provision_command:
        - yum install -y -q wget curl tar mc iproute
        - localedef  --no-archive -c -i en_US -f UTF-8 en_US.UTF-8 &&
          localedef  --no-archive -c -i ru_RU -f UTF-8 ru_RU.UTF-8
        - sed -i -r 's/^(.*pam_nologin.so)/#\1/' /etc/pam.d/sshd
        - sed -i 's/tsflags=nodocs//g' /etc/yum.conf

suites:
  - name: gitbucket
  - name: fe
    provisioner:
      pillars:
        top.sls:
          base:
            '*':
              - gitbucket
              - frontend
        frontend.sls:
          gitbucket:
            frontend:
              enabled: True
  - name: fe-ssl
    provisioner:
      pillars:
        top.sls:
          base:
            '*':
              - gitbucket
              - frontend
        frontend.sls:
          gitbucket:
            frontend:
              enabled: True
              url: localhost
              ssl:
                enabled: True

В секции Driver мы указываем параметры работы с докер-хостом, также может быть указан локальный адрес, кроме того могут быть использованы другие драйверы, например vagrant, ec2 и другие.
Секция Provisioner отвечает за конфигурацию необходимую SaltStack для накатки конфигурации, именно здесь мы устанавливаем перечисленные ранее зависимости для данной формулы, конфигурации сервисов передаваемые через pillar.
Секция Platforms задает платформы, читать как операционные системы, на которых будет произведено тестирование.
И наконец Suites, в этой секции мы задаем типы конфигураций которые мы будем тестировать на перечисленных выше платформах. В приведенном дескрипторе для Kitchen настроено тестирование трех видов:


  • чистая настройка сервиса — Java + Tomcat 8 + Gitbucket
  • настройка с nginx только по HTTP
  • настройка с nginx с HTTP/S

Всего будет протестировано по 3 конфигурации для каждой ОС — Debian Jessie и CentOS 7.

Как видно из представления директории в ней есть папка test, которая содержит разделение по сьютам тестирования — gitbucket, fe, fe-ssl, также обратите внимание на папку helpers, она содержит shared_examples для каждого вида тестирования, дабы не писать одни и те же тесты.


Зависимости

Давайте заострим наше внимание на секции Provisioner, помимо конфигурации pillar и параметров бутстрапа для SaltStack, эта секция также содержит информацию о зависимостях этой конфигурации, которые должны быть доступны по относительным или абсолютным путям в процессе конфигурирования. Как видите перечислены только те конфигурации, которые непосредственно участвуют в тестировании этого сервиса.
Конечно мы можем добавить те 9 перечисленных выше системных сервисов для полного покрытия конфигурации инстанса, но во-первых — подобные тесты будут идти часами, для примера формула Jenkins с установкой всех необходимых плагинов для двух семейств ОС по 3 конфигурации на каждую длится около часа, во-вторых — в зависимости от конкретной инфраструктуры их набор будет различен и количество таких наборов стремится к бесконечности, а в третьих — подобное тестирование все равно будет произведено при накатке на интеграционный энвайронмент.

Теперь, когда мы знакомы с предметом релиза, мы можем перейти собственно к построению релизного процесса конфигураций.
В качестве скедулера будем использовать использовать привычный всем Jenkins, для хранения артефактов будет Nexus OSS 2.


Релизный пайплайн

Самое время написать Jenkinsfile с описанием всего необходимого для запуска и для так полюбившихся всем красивых квадратиков со стейджами.

В качестве ноды для запуска будем стартовать, при помощи Docker-cloud плагина, контейнер, в котором установлены все необходимые нам компоненты — ruby, test-kitchen, kitchen-salt, kitchen-docker и, собственно, сам docker-engine, чтобы иметь возможность работать с докер-хостом.

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

node("kitchen-ci") {
  try {
    stage('Checkout repository') {
      checkout changelog: false,
        poll: false,
        scm: [
          $class                           : 'GitSCM',
          branches                         : [
            [
              name: "${branch}"
            ]
          ],
          doGenerateSubmoduleConfigurations: false,
          extensions                       : [
            [
              $class: 'RelativeTargetDirectory', relativeTargetDir: "${repository}"
            ]
          ],
          userRemoteConfigs                : [
            [
              url: "${gitRepositoryUrl}/${repository}.git"
            ]
          ]
        ]
      dir("${repository}") {
        gitCommit = sh(returnStdout: true, script: "git rev-parse HEAD").trim()
      }
      currentBuild.displayName = "#${env.BUILD_NUMBER} ${repository}"
      currentBuild.description = "${branch}(${gitCommit.take(7)})"
    }
  }
  catch (err) {
    currentBuild.result = failedStatus
  }

  def kitchenConfig = readYaml(file: "${repository}/.kitchen.yml")
  def formulaMetadata = readYaml(file: "${repository}/FORMULA")
  def dependencies = kitchenConfig.provisioner.dependencies
  def platforms = kitchenConfig.platforms

  try {
    stage('Get formula dependencies') {
      for (dependency in dependencies) {
        depRepo = dependency['path'] - ~/\.{2}\//
        println "Prepare \"${depRepo}\" as dependency"
        checkout changelog: false,
          poll: false,
          scm: [
            $class                           : 'GitSCM',
            branches                         : [
              [
                name: '*/master'
              ]
            ],
            doGenerateSubmoduleConfigurations: false,
            extensions                       : [
              [
                $class: 'RelativeTargetDirectory', relativeTargetDir: "${depRepo}"
              ]
            ],
            userRemoteConfigs                : [
              [
                url: "${gitRepositoryUrl}/${depRepo}.git"
              ]
            ]
          ]
      }
    }
  }
  catch (err) {
    currentBuild.result = failedStatus
  }

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

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

Следующим шагом собственно запускаем тестирование таргеттированной платформы командой kitchen verify. Эта команда запускает сборку контейнера, затем накатку конфигурации SaltStack, после чего Kitchen запускает тесты написанные на Serverspec:

  for (targetPlatform in targetPlatforms) {
    try {
      stage("Test integration with ${targetPlatform}") {
        dir("${repository}") {
          if (platforms['name'].containsAll(targetPlatform.toLowerCase())) {
            ansiColor('xterm') {
              sh "kitchen verify ${targetPlatform.toLowerCase()}"
            }
          } else {
            println "Skipping checks for \"${targetPlatform}\" platform"
          }
        }
      }
    }
    catch (err) {
      currentBuild.result = failedStatus
    }
  }

В конфигурации fe-ssl запускается 42 теста, в том числе те, что приведены ниже:

require 'serverspec'
set :backend, :exec

host_name = `cat /etc/hostname`
instance_name = 'gitbucket'
nginx_confd = '/etc/nginx/conf.d'
nginx_keyd = '/etc/nginx/keys'
status_host = '127.0.0.1'

gitbucket_version = '4.10'

ssl_vhost = nginx_confd + '/90-' + instance_name + '-https.conf'

certs = [
  nginx_keyd + '/' + host_name.chomp + '.crt',
  nginx_keyd + '/' + host_name.chomp + '.key',
]

ports = %w[
  443
]

shared_examples_for 'gitbucket nginx ssl frontend' do
  describe '### Frontend SSL configuration ###' do
    describe file(ssl_vhost) do
      it { should be_file }
      it { should be_owned_by 'root' }
      it { should be_grouped_into 'root' }
      it { should be_mode 644 }
    end

    certs.each do |cert|
      describe file(cert) do
        it { should be_file }
        it { should be_owned_by 'root' }
        it { should be_grouped_into 'root' }
        it { should be_mode 600 }
      end
    end
    ports.each do |port|
      describe port(port) do
        it { should be_listening.with('tcp') }
      end
    end
  end

  describe '### Frontend SSL status ###' do
    describe command('curl -Iso /dev/null -w "%{http_code} %{redirect_url}" '+ status_host ) do
      its(:stdout) { should match '301 https://localhost/' }
    end

    describe command('curl -ILso /dev/null -w "%{http_code}" --insecure '+ status_host ) do
      its(:stdout) { should match '200' }
    end
    describe command('curl -sL --insecure ' +\
                     status_host +\
                     '|grep "header-version"|awk -F\'>|<\' \'{print $3}\'' ) do
      its(:stdout) { should match gitbucket_version }
    end
  end
end

Итак, все тесты пройдены, значит настало время собрать пакет и задеплоить его в репозиторий бинарных артефактов.
Сборка артефакта осуществляется командой spm build, ну а поскольку мы уже запустили shell стадию, то и задеплоим артефакт curl'ом.

  try {
    stage('Build and deploy SPM artifact') {
      if (currentBuild.result != failedStatus) {
        withCredentials([[$class          : 'UsernamePasswordMultiBinding',
                          credentialsId   : binaryRepoCredentials,
                          usernameVariable: 'USERNAME', passwordVariable: 'PASSWORD']]) {
          dir("${repository}") {
            sh """#!/usr/bin/env bash
            sudo spm build .
            formula=\$(ls -1A ${spmBuildDir}|grep '.spm')
            if [[ -n \$formula ]];
              then
                response=\$(curl --compressed \
                  -u ${env.USERNAME}:${env.PASSWORD} \
                  -s -o /dev/null -w "%{http_code}\n" \
                  -A 'Jenkins POST Invoker' \
                  --upload-file ${spmBuildDir}/\$formula \
                  ${binaryRepoUrl});
                if [[ \$response -ne 201 ]];
                  then
                    echo "Something goes wrong! HTTP_CODE: \$response";
                    exit 1;
                  else
                    echo "Formula \$formula deployed.";
                fi
              else
                echo "Couldn't find file";
                exit 1;
            fi
            """
          }
        }
      }
      else {
        println "We will not packing failed formulas"
      }
    }
  }
  catch (err) {
    currentBuild.result = failedStatus
  }

На этом практически все закончено, осталось поставить тэг на текущий коммит и запустить kitchen destroy для удаления всех созданных контейнеров.

  try {
    stage('Tag version in SCM') {
      currentTag = formulaMetadata['version']
      if (currentBuild.result != failedStatus) {
        dir("${repository}") {
          sh """#!/usr/bin/env bash
          if [[ \$(git tag -l|grep ${currentTag}) ]];
            then
              if [[ "\$(git show-ref --tags|grep ${currentTag}|awk '{print \$1}'|cut -b-7)" != "${gitCommit.take(7)}" ]];
                then
                  echo "Tag \"${currentTag}\" will be updated."
                  git tag -d ${currentTag}
                  git push origin :${currentTag}
                else
                  echo "Tag \"${currentTag}\" already exists. Skipping."
              fi            
            else
              echo "Creating tag \"${currentTag}\""
              git tag ${currentTag}
              git push origin --tags
          fi
          """
        }
      }
      else {
        println "We will not tag revision for failed formulas"
      }
    }
  }
  catch (err) {
    currentBuild.result = failedStatus
  }

  try {
    stage('Cleanup after tests') {
      dir("${repository}") {
        sh "kitchen destr"
      }
    }
  }
  catch (err) {
    currentBuild.result = failedStatus
  }

Вообще в Kitchen-CI есть команда test, которая запускает такие стадии как destroy, create, converge, setup, verify и снова destroy, однако некоторые наши формулы используют линкование контейнеров в процессе тестирования, например FreeIPA, которая стартует сначала конфигурацию сервера, проверяет его работоспособность, после чего стартует контейнер с конфигурацией клиента и вводит его в домен прилинкованного сервер, если же запускать test, сервер подымется, протестируется, а затем будет удален до поднятия клиента. Именно поэтому мы используем команду verify и самой последней стадией в пайплайне является именно очистка через destroy.


Работаем с репозиторием

Формула протестирована, в репозитории поставлен тэг, артефакт задеплоен в бинарный репозиторий, однако он все еще не доступен для SaltStack — необходимо обновить метаинформацию, для чего требуется в директории репозитория со всеми формулами запустить команду spm create_repo $DIR.
Nexus у нас тоже конфигурируется через SaltStack, а потому SPM установлен на нем локально, чем мы и воспользуемся.
Устанавливаем на хост с Nexus модуль python-inotify и для salt-minion настраиваем SaltStack Beacon, который будет следить раз в пять секунд за изменениями в директории с формулами:

#/etc/salt/minion.d/beacons.conf
beacons:
  inotify:
    interval: 5
    disable_during_state_run: True
    /opt/nexus/data/storage/com.sperasoft.devops.salt.formulas:
      mask:
        - create
        - modify
        - delete
        - attrib
      recurse: True
      auto_add: True
      exclude:
        - /opt/nexus/data/storage/com.sperasoft.devops.salt.formulas/SPM-METADATA
        - /opt/nexus/data/storage/com.sperasoft.devops.salt.formulas/.nexus/

На данном этапе нам необходимо перезапустить salt-minion, после этого, в случае изменения файлов в директории, salt-master получит событие об этом:

salt/beacon/nexus.devops.sperasoft.com/inotify//opt/nexus/data/storage/com.sperasoft.devops.salt.formulas   {
    "_stamp": "2017-07-09T10:31:11.283771", 
    "change": "IN_ATTRIB", 
    "id": "nexus.devops.sperasoft.com", 
    "path": "/opt/nexus/data/storage/com.sperasoft.devops.salt.formulas/gitbucket-1.0.0-1.spm"

На salt-master'е же настраиваем reactor, который следит за событиями от настроенного beacon'а и запускает настроенный state:

#/etc/salt/master.d/reactor.conf
reactor:
  - 'salt/beacon/nexus.devops.sperasoft.com/inotify//opt/nexus/data/storage/com.sperasoft.devops.salt.formulas':
    - /srv/salt/reactor/update-spm-metadata.sls

И собственно state запускающий необходимую нам команду:

#/srv/salt/reactor/update-spm-metadata.sls
update spm repository metadata:
  local.cmd.run:
    - tgt: {{ data['id'] }}
    - arg:
      - spm create_repo /opt/nexus/data/storage/com.sperasoft.devops.salt.formulas

Не забываем перезапустить salt-master, salt-minion и собственно все, через каждые пять секунд beacon проверяет на события через inotify в директории и запускает обновления метаданных репозитория.
Для тех кто использует Nexus 3 тоже есть возможность реализовать это, смотрите в сторону webhooks Nexus и Salt-API.


Устанавливаем формулы

Настроим на salt-master'е репозиторий в /etc/salt/spm.repos.d/nexus.repo, учтите, что пароль устанавливается plain-текстом, не забудьте установить на этот файл соответствующие разрешения, 400 например, и использовать прокси пользователя с ограниченным набором прав, ну либо допишите SPM на использование шифрованных паролей:

#/etc/salt/spm.repos.d/nexus.repo
nexus.devops.sperasoft.com:
  url: https://nexus.devops.sperasoft.com/content/sites/com.sperasoft.devops.salt.formulas
  username: saltmaster
  password: supersecret

Обновляем данные о формулах на salt-master'е и устанавливаем формулу:

spm update_repo
spm install gitbucket
Installing packages:
    gitbucket

Proceed? [N/y] y
... installing gitbucket

Собственно все, в директорию на salt-master была установлена протестированная, затэггированная и упакованная формула.

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

Нет предела совершенству и можно еще много что добавить, будь то автоматический запуск обновления данных репозитория на salt-master'е и автоматическое обновление формул на нем, тестирование с зависимостями с жестко заданной версией для большей совместимости, добавление в SPM поддержки шифрования пароля, добавление в SPM возможности отображения списка доступных формул, добавление в SPM возможности установки зависимостей конкретной версии, расширение количества тестируемых платформ и так далее и тому подобное.

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

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