CI/CD-пайплайн на примере одного небольшого проекта Уральской Дирекции ИТ

Действующие лица (Команда): разработчиков — 2 человека, админ — 1 человек.

Статья повествует об использовании таких технологий, как Ansible, Docker Swarm, Jenkins и Portainer для реализации CI/CD-пайплайна с возможностью контроля за ним с помощью красивого веб-интерфейса.

quo1_ujawmjkrogxr_xrqdb0k7s.jpeg

Вступление


Чего обычно хочет разработчик? Он хочет творить, не думая о деньгах, и максимально быстро видеть результаты собственного творчества.

С другой стороны, есть бизнес, который хочет денег, да побольше, и поэтому постоянно думает о снижении времени вывода продукта на рынок. Другими словами, бизнес мечтает об ускорении получения MVP (a.k.a. Minimum Viable Product) в новых продуктах или при обновлении существующих.

Ну, а чего же хочет админ? А админ — человек простой, он хочет, чтобы сервис не падал и не мешал играть в Кваку Танки и чтобы его пореже дергали разработчики и бизнес.
Поскольку для реализации желаний админа, как показывает правда жизни, его силами должны реализоваться и мечты других героев, представители ИТ-тусовки много работали над этим. Часто получалось достичь желаемого, придерживаясь методологии DevOps и реализуя принципы CI/CD (Continuous Integration and Delivery).

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

Часть 0. Описание задачи


Архитектура системы


После некоторых обсуждений командой была выбрана следующая двухуровневая архитектура:

  • Бекэнд-часть на Java, реализованная на фреймворке Spring Boot, общающаяся с различными БД и другими корпоративными системами (потому что легко, быстро и понятно как писать).
  • Фронтэнд-часть на NodeJS (и ReactJS — интерфейс в браузере), потому что очень быстро работает.


В свою очередь, к этим компонентам был добавлен еще NGINX-сервер, являющийся фронтэндом для NodeJS-приложения. Его роль заключалась в распределении запросов между самим приложением и другими инфраструктурными компонентами системы, речь о которых пойдет ниже.

Чего хотелось команде


Как только новому проекту был дан зеленый свет, появилась первая техническая задача, а именно — подготовка «оборудования» для запуска нового проекта. Поскольку всем участникам было очевидно, что без максимальной оперативности выкатки новых версий на серверы развитие проекта будет весьма непростым, сразу же было решено идти по пути полного CI/CD, т.е. хотелось достичь следующего пайплайна:

  • Разработчик публикует изменения (коммит) в систему контроля версий (гит);
  • гит проводит минимально необходимое тестирование содержимого коммита на наличие необходимой атрибутики (например, правильный формат commit message), соответствие стилю оформления Банка и другой бюрократии;
  • гит-сервер посредством механизма web-hook-ов дергает сервер непрерывной интеграции Jenkins;
  • Jenkins запускает операции скачивания актуальной версии исходников из гит-а и выполнения CI/CD-пайплайна:


  1. компиляция исходников и первоначальное тестирование;
  2. сборки новой версии Docker-образов (неприлично же что-то деплоить в 2018-м на голое железо или виртуалку, не поймут);
  3. публикация образов в Artifactory (система хранения и управления бинарными артефактами, рекомендую!);
  4. перезапуск новой версии приложения (или всего «стека» приложений) на сервере с «откатом» к предыдущей версии в случае не самого успешного обновления.


Рамки


У людей в теме наверняка уже возник вопрос: «А чего это они какими-то костылями пользуются, а не 'production-ready'-решениями а-ля Kubernetes или Mesos / Marathon?». Подобный вопрос вполне разумен, поэтому сразу же скажем, что описываемое решение было использовано по целому ряду причин, в том числе:

  • Оно проще (сильно-сильно проще);
  • Его было легче понять всей команде и развернуть админу.


Однако, мы не забываем, что выбранное нами решение относится к богатому семейству костылей, и надеемся в недалеком будущем переехать на более стандартный стек OpenShift + Bamboo.

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

Часть 1. Установка и базовая настройка ПО на хост-системе


С целью максимальной автоматизации и высокой воспроизводимости всей цепочки хост-систему (виртуальная машина на базe VMWare / qemu KVM / облако / что-то еще) было решено настраивать с использованием системы управления конфигурацией Ansible.

Стоит добавить, что в дополнение к легкой повторяемости и воспроизводимости, использование подобных систем (кроме Ansible, существуют также системы Puppet и Chef) обладает огромным преимуществом перед использованием разнообразных shell- или python- скриптов в виде наличия идемпотентности, т.е. свойства, при котором при повторных запусках итоговое состояние системы не изменяется.

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

1.1 ssh HostKeyChecking


По умолчанию, Ansible уважает безопасность и проверяет ssh-отпечаток удаленного конфигурируемого хоста. Т.к. в данном режиме отключена возможность авторизации по паролю, то при первоначальной настройке сервера требуется либо отключить HostKeyChecking, либо предварительно добавить отпечаток в локальный кеш. Последнего можно достичь целыми двумя путями:

или так:

Определить специальную переменную окружения:

$ export ANSIBLE_HOST_KEY_CHECKING=False


или по-другому:

Добавить в локальный конфигурационный файл ansible.cfg параметр host_key_checking:

[defaults]
host_key_checking = False


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

1.2 Inventory


Inventory — это сущность в системе Ansible, с помощью которой описываются хосты и их группы, конфигурацией которых необходимо управлять.

Inventory можно описывать в формате ini или yaml. В данном проекте был выбран последний.

Пример файла hosts.yml:

#_ Группа all существует всегда
all:
  hosts:
    #   имя хоста удаленной системы,  к которому будет подключаться Ansible
    some-cool-vm-host
  vars:
    #   имя пользователя, кем будет авторизоваться
    ansible_user: 'root'
    # очень плохо такое делать, но тут записан пароль открытым текстом :-(
    ansible_password: '12345678'
 
    # Это сертификат банковского СА
    corp_ca_crt: "-----BEGIN CERTIFICATE----- ... -----END CERTIFICATE-----"


Для впервые столкнувшихся с форматом yaml хотелось бы отметить, что все отступы в данном формате необходимо оформлять пробелами.

1.3 Playbook


Playbook — это еще одна сущность в Ansible, в которой непосредственно декларативно описывается желаемое конечное состояние хостов и групп из Inventory. Так же, как и почти все в Ansible, playbook описывается в файле (ах) в yaml-формате.

Для того чтобы запустить на выполнение playbook-файл, необходимо выполнить команду вида:

ansible-playbook -i ./hosts.yml tasks.yml


В данном playbook-е описывалась полная настройка базовой системы с созданием необходимых пользователей и установкой Docker-а:

#_ указывается маска имен хостов и групп, для которых будут выполняться нижеописанные таски (задания)
- hosts: all
  tasks:
  # Список тасков
    - name: Удаляем все репозитории из системы
      shell: rm /etc/zypp/repos.d/* || exit 0
 
    - name: Добавляем банковские SLES-репы на хост REPOs...
      zypper_repository: repo="{{ item.repo }}" name="{{ item.name }}" disable_gpg_check="{{ item.disable_gpg_check|default('no') }}"
      with_items:
        - { repo: "http://...", name: "SLE-DISTRO-X" }
 
    - name: Обновляем все пакеты в системе автоматически
      zypper: 
        name: '*'
        state: latest
 
    - name: Помещаем сертификат банковского CA на сервер
      copy:
        # Берем сертификат из переменных Inventory
        content: '{{ corp_ca_crt }}'
        dest: /etc/pki/trust/anchors/сorpCA.crt
        owner: root
        group: root
        mode: 0644
 
    - name: ... и перечитываем системное хранилище
      shell: update-ca-certificates
 
    - name: Создаем юзеров и группы
      group: name="{{ item.name  }}" gid={{ item.gid }} state="present"
      with_items:
        - { name: "docker", gid: 1000 }
    - user: 
        name: "{{ item.name }}"
        uid: "{{ item.uid }}" 
        group: "{{ item.gid  }}"
        state: "present"
      with_items:
        - { name: "dockeradm", uid: 1000, gid: "docker" }
 
    - name: Меняем пароли пользователям
      user: 
        name: "{{ item }}" 
        password: "$6$..."
        generate_ssh_key: yes
      with_items:
        - root
        - dockeradm
 
    - name: Добавляем публичный ssh-ключ текущего пользователя ЛОКАЛЬНОЙ машины указанным юзерам на УДАЛЕННОМ сервере
      authorized_key: 
        user: "{{ item }}" 
        key: "{{ lookup('file', '~/.ssh/id_rsa.pub') }}"
        state: "present" 
      with_items:
        - root
        - dockeradm
 
    - name: Создаем нужные VG в системе
      lvg:
        vg: 'vgAPP'
        pvs: '/dev/sdb'
 
    - name: ... и LV
      lvol: 
        vg: "{{ item.vg }}" 
        lv: "{{ item.lv }}"
        size: "{{ item.size }}"
      with_items:
        - { vg: 'vgAPP', lv: "lvData", size: "10G" }
        - { vg: 'vgAPP', lv: "lvDockerData", size: "5G" }
 
    - name: Создаем ФС на созданных LV-ах
      filesystem: dev="/dev/{{ item }}" fstype="btrfs"
      with_items:
        - 'vgAPP/lvData'
        - 'vgAPP/lvDockerData'
 
    - name: Прописываем информацию об ФС в /etc/fstab и монтируем их
      mount: path="{{ item.dst }}" src="/dev/{{ item.src }}" state="mounted" fstype="btrfs" opts="noatime"
      with_items:
        - { src: "vgAPP/lvData", dst: "/APP" }
        - { src: "vgAPP/lvDockerData", dst: "/var/lib/docker" }
 
    - name: Создаем всякие важные каталоги
      file: 
        path: "{{ item.path }}" 
        state: "directory" 
        # внимание не дефолтные значения
        mode: "0{{ item.perms|default('755') }}" 
        owner: "{{ item.user|default('dockeradm') }}" 
        group: "{{ item.group|default('docker') }}"
      with_items:
        - { path: '/etc/docker', user: 'root', group: 'root' }
        - { path: '/APP' }
        - { path: '/APP/configs' }
        - { path: '/APP/configs/filebeat' }
        - { path: '/APP/logs' }
        - { path: '/APP/logs/nginx' }
        - { path: '/APP/jenkins' }
        - { path: '/APP/jenkins/master' }
        - { path: '/APP/jenkins/node' }
        - { path: '/APP/portainer_data' }
 
    - name: Ставим нужные пакеты в систему
      zypper: 
        name: '{{ item }}'
      with_items:
        - docker
        - mc
        # Java необходима для запуска Jenkins-слейва
        - java-1_8_0-openjdk-headless
        
 
    # Помним о необходимости настроить подсети для Докера
    - name: Копируем конфиг-файл с "правильными сетями"
      template:
        src: daemon.json
        dest: /etc/docker/daemon.json
        owner: root
        group: root
        mode: 0644
 
    - name: Активируем и запускаем сервисы...
      systemd: 
        name: "{{ item }}"
        state: 'restarted'
        enabled: 'yes'
      with_items:
        - docker
        - sshd
 
    - name: Помещаем на целевой сервер docker-compose через скачивание на локальную
      get_url: 
        url: "https://github.com/docker/compose/releases/download/1.18.0/docker-compose-Linux-x86_64"
        dest: "/tmp/docker-compose"
      delegate_to: 127.0.0.1
  
    - copy: 
        src: "/tmp/docker-compose"
        dest: "/usr/local/bin/docker-compose"
        mode: "u=rwx,g=rx,o=rx"
 
 
    - name: Копируем конфиг-файл NGINX, конструируя его из шаблона
      template: 
        src: nginx.conf
        dest: /APP/configs/nginx.conf
        owner: dockeradm
        group: docker
        mode: '0644'
 
    - name: Копируем описание docker-compose - сервиса, конструируя его из шаблона
      template: 
        src: docker-compose.yml
        dest: /APP/docker-compose.yml
        owner: dockeradm
        group: docker
        mode: '0644'
  
    - name: Запуск docker-compose - сервис с Jenkins, Portainer и NGINX перед ними
      shell: docker-compose -f /APP/docker-compose.yml up -d --force-recreate
 
    - name: Ожидаем окончания инициализации Jenkins-а
      wait_for:
        path: '/APP/jenkins/master/secrets/initialAdminPassword'
 
    - name: Получаем значение временного пароля Jenkins
      fetch: 
        src: '/APP/jenkins/master/secrets/initialAdminPassword'
        dest: initialJenkinsAdminPassword.txt
        flat: yes


Часть 2. Сервисы проекта


2.1 CI-сервер и процесс


За процесс «непрерывной интеграции и деплоймента» в проекте отвечает хорошо многим известный сервер Jenkins CI.

Код Ansible playbook-и выше устроен так, что в конце его выполнения на сервере уже запущен свежеустановленный Jenkins (в Docker-контейнере), а его временный пароль сохранен на ЛОКАЛЬНОЙ машине в файле initialJenkinsAdminPassword.txt.

Поскольку всей команде хотелось максимально приблизиться к идеальному случаю Infrastructure as code (IaC), то в проекте таски были реализованы в виде декларативных и скриптованных пайплайнов Jenkins-а, когда задачи описываются на языке Groovy Script, а сам их код хранится рядом с исходниками проекте в системе контроля версий (git).

Пример пайплайна сборки backend-части приложения на Spring Boot показан ниже:

pipeline {
    agent {
        # указываем, что выполнять задачу хотим внутри 
        # Docker-контейнера на базе указанного образа:
        docker {
            image 'java:8-jdk'
        }
    }
    
    stages {
        stage('Стягиваем код из ГИТа') {
            steps {
                checkout scm
            }
        }
        stage('Собираем') {
            steps {
                sh 'chmod +x ./gradlew'
                sh './gradlew build -x test'
            }
 
        }
        stage('Тестируем') {
            steps {
                script {
                    sh './gradlew test'
                }
            }
        }
    }
}
 
#_ Этап сборки нового Docker-образа и его загрузки с систему Artifactory:
node {
    stage('Собираем образ') {
        docker.withRegistry("https://repo.artifactory.bank", "LoginToArtifactory") {
            def dkrImg = docker.build("repo.artifactory.bank/dev-backend:${env.BUILD_ID}")
            dkrImg.push()
            dkrImg.push('latest')
        }
        }
    stage('Заливаем его в Artifactory') {
        docker.withRegistry("https://repo.artifactory.bank", "LoginToArtifactory") {
            sh "docker service update --image repo.artifactory.bank/dev-backend:${env.BUILD_ID} SMB_dev-backend"
        }
    }
}


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

2.2 Portainer


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

Например, в простейшем случае следующая команда приведет к запуску Портейнера на порту 9000 хост-системы:

docker run -d \
        -p 9000:9000 \
        -v /var/run/docker.sock:/var/run/docker.sock \
portainer/portainer


Однако, в текущем проекте было решено воспользоваться функциональностью средства «оркестрации» для одного хоста — Docker Compose.

2.3 Docker-контейнеры и сервисы


Все необходимые приложения и сервисы в данном проекте запускаются посредством простого файла docker-compose.yml.

Базовый набор «инфраструктурных» сервисов запускается посредством следующего описания:

version: '3.4'
 
services:
  # Непосредственно общающийся  с пользователями NGINX
  nginx:
    image: "nginx:1"
    container_name: fe-nginx
    restart: always
    volumes:
      - /APP/configs/nginx.conf:/etc/nginx/nginx.conf
      - /APP/logs/nginx:/var/log/nginx
      - /usr/share/zoneinfo/Europe/Moscow:/etc/localtime:ro
    networks:
      - int
    ports:
      - "80:80/tcp"
      - "8080:80/tcp"
 
  # Jenkins CI - сервер, который отвечает за CI/CD-процесс
  ci:
    image: "jenkins/jenkins:lts"
    container_name: ci-jenkins
    restart: always
    volumes:
        - /usr/share/zoneinfo/Europe/Moscow:/etc/localtime:ro
        - /APP/jenkins/master:/var/jenkins_home
    environment:
      JENKINS_OPTS: '--prefix=/jenkinsci'
      JAVA_OPTS: '-Xmx512m'
    networks:
      int:
        aliases:
          - srv-ci
 
  # Веб-интерфейс для Docker-а
  portainer:
    image: "portainer/portainer:latest"
    volumes:
      - type: bind
        source: /var/run/docker.sock
        target: /var/run/docker.sock
      - type: bind
        source: /APP/portainer_data
        target: /data
    networks:
      int:
        aliases:
          - srv-portainer
    command: -H 'unix:///var/run/docker.sock'
 
networks:
  int:
    external: true


2.4 Docker Swarm-кластер без кластера


Как можно видеть в файле docker-compose.yml выше, во-первых, отсутствуют упоминания бэкенд и фронтенд- частей приложения, а также присутствует ссылка на «внешнюю» (external: true) сеть по имени int. Внешними являются любые ресурсы (сети, тома и другие существующие сущности), не объявленные в одном файле.

Дело в том, что в проекте нам требовалась иметь возможность проводить рестарт «сервисов» при обновлении версии образа в Docker-репозитории Artifactory, а подобные функции присутствуют в сервисах Docker Swarm (встроенная в Docker multi-master (система оркестрации Docker-контейнеров) что называется «из коробки». Данная возможность реализуется через возможность изменить требуемый образ у запущенного сервиса, и при наличии новой версии образа в репозитории перезапуск произойдет автоматически. В случае же, если версия не изменилась — сервисный контейнер продолжает штатно выполняться.

Что касается сети, запуская приложение в виде сервиса Docker Swarm (yaml-описание представлено ниже), нам требовалось сохранить сетевую связность его компонентов и сервера NGINX, объявленного выше. Это было достигнуто через создание на сервере кластерой Overlay-сети, в которую включались и базовые сервисы, описанные выше, и непосредственно компоненты приложения:

docker network create -d overlay --subnet 10.1.2.254/24 --attachable int


(ключ --attachable является необходимым, т.к. без него базовые сервисы не имеют доступа к кластерной сети)

Описание компонентов приложения c двумя сервисами:

version: '3.2'
 
services:
  pre-live-backend:
    image:repo.artifactory.bank/dev-backend:latest
    deploy:
      mode: replicated
      replicas: 1
    networks:
      - int
  pre-live-front:
    image: repo.artifactory.bank/dev-front:latest
    deploy:
      mode: replicated
      replicas: 1
    networks:
      - int
networks:
  int:
    external: true


Заключение


Как было отмечено в начале, на старте проекта команде хотелось получить все преимущества DevOps-подхода, в частности организовать процесс непрерывной доставки кода от гит-репозитория до «боевого» сервера в виде запущенного на нем приложения. При этом на текущем этапе не хотелось полностью уходить от наработанных практик и перестраивать себя под жизнь в мире больших оркестраторов. Описанная архитектура системы, которая была продумана и реализована менее, чем за 2 недели (параллельно с другими проектами, над которыми трудились члены команды), в итоге позволила нам достичь желаемого. Мы полагаем, что данный материал должен быть интересным и полезным и другим командам, внедряющим DevOps-подходы в жизнь.

© Habrahabr.ru