Строим домашний CI-CD при помощи GitHub Actions и Python

в 15:07, , рубрики: ci/cd, flask, github, github actions, python, python3, микросервисы, системы сборки

Как то вечером, придя домой с работы, я решил немного позаниматься домашним проектом. Я сделал несколько правок и сразу захотел поэкспериментировать с ними. Но до экспериментов мне пришлось заходить на VPS, пулить изменения, пересобирать контейнер и запускать его. Тут я и решил, что пора разобраться с непрерывной доставкой.
Строим домашний CI-CD при помощи GitHub Actions и Python - 1

Изначально передо мной стоял выбор между Circle CI, Travis или Jenkins. Jenkins я практически сразу исключил из-за отсутствия необходимости в настолько мощном инструменте. Бегло прочитав про Travis пришел к выводу, что в нем удобно собирать и тестировать, но доставку с ним особо не придумаешь. О Circle CI я узнал из слишком навязчивой рекламы на youtube. Начал экспериментировать с примерами, но в какой-то момент я ошибся и у меня была вечное тестирование, на которое у меня ушло много драгоценных минуты сборки (В общем то там достаточный лимит, чтобы не беспокоиться, но меня это задело). Снова взявшись за поиски я наткнулся на Github Actions. Поиграв с Get Started примерами у меня сложилось положительное впечатление, а после беглого ознакомления с документацией, пришел к выводу что это очень круто что я могу хранить секреты для сборки, собирать и практически деплоить проекты в одном месте. С горящими глазами быстро нарисовал желаемую схему, и шестеренки закрутились.

plan

Сначала будем пробовать сделать тестирование. В качестве подопытного я написал простой веб сервер на Flask с 2мя эндпоинтами:

Листинг простого веб приложения

from flask import Flask
from flask import request, jsonify

app = Flask(__name__)

def validate_post_data(data: dict) -> bool:
    if not isinstance(data, dict):
        return False
    if not data.get('name') or not isinstance(data['name'], str):
        return False
    if data.get('age') and not isinstance(data['age'], int):
        return False
    return True

@app.route('/', methods=['GET'])
def hello():
    return 'Hello World!'

@app.route('/api', methods=['GET', 'POST'])
def api():
    """
    /api entpoint
    GET - returns json= {'status': 'test'}
    POST -  {
            name - str not null
            age - int optional
            }
    :return:
    """
    if request.method == 'GET':
        return jsonify({'status': 'test'})
    elif request.method == 'POST':
        if validate_post_data(request.json):
            return jsonify({'status': 'OK'})
        else:
            return jsonify({'status': 'bad input'}), 400

def main():
    app.run(host='0.0.0.0', port=8080)

if __name__ == '__main__':
    main()

И несколько тестов:

Листинг тестов для приложения

import unittest
import app as tested_app
import json

class FlaskAppTests(unittest.TestCase):

    def setUp(self):
        tested_app.app.config['TESTING'] = True
        self.app = tested_app.app.test_client()

    def test_get_hello_endpoint(self):
        r = self.app.get('/')
        self.assertEqual(r.data, b'Hello World!')

    def test_post_hello_endpoint(self):
        r = self.app.post('/')
        self.assertEqual(r.status_code, 405)

    def test_get_api_endpoint(self):
        r = self.app.get('/api')
        self.assertEqual(r.json, {'status': 'test'})

    def test_correct_post_api_endpoint(self):
        r = self.app.post('/api',
                          content_type='application/json',
                          data=json.dumps({'name': 'Den', 'age': 100}))
        self.assertEqual(r.json, {'status': 'OK'})
        self.assertEqual(r.status_code, 200)

        r = self.app.post('/api',
                          content_type='application/json',
                          data=json.dumps({'name': 'Den'}))
        self.assertEqual(r.json, {'status': 'OK'})
        self.assertEqual(r.status_code, 200)

    def test_not_dict_post_api_endpoint(self):
        r = self.app.post('/api',
                          content_type='application/json',
                          data=json.dumps([{'name': 'Den'}]))
        self.assertEqual(r.json, {'status': 'bad input'})
        self.assertEqual(r.status_code, 400)

    def test_no_name_post_api_endpoint(self):
        r = self.app.post('/api',
                          content_type='application/json',
                          data=json.dumps({'age': 100}))
        self.assertEqual(r.json, {'status': 'bad input'})
        self.assertEqual(r.status_code, 400)

    def test_bad_age_post_api_endpoint(self):
        r = self.app.post('/api',
                          content_type='application/json',
                          data=json.dumps({'name': 'Den', 'age': '100'}))
        self.assertEqual(r.json, {'status': 'bad input'})
        self.assertEqual(r.status_code, 400)

if __name__ == '__main__':
    unittest.main()

Вывод покрытия:

coverage report
Name                                           Stmts   Miss  Cover
------------------------------------------------------------------
src/app.py                                        28      2    93%
src/tests.py                                      37      0   100%
------------------------------------------------------------------
TOTAL                                             65      2    96%

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

$ mkdir -p .github/workflows
$ touch .github/workflows/test_on_push.yaml

Я хочу, чтобы этот экшен запускался при любом пуш эвенте в любой ветке, за исключением релизов(тэгов, потому что там будет отдельное тестирование):

on:
 push:
   tags:
     - '!refs/tags/*'
   branches:
     - '*'

Затем мы создаем задачу, которая будет запускаться в среде Ubuntu последней доступной версии:

jobs:
 run_tests:
   runs-on: [ubuntu-latest]

Шагами у нас будут чекаут кода, установка питона, установка зависимостей, запуск тестов и вывод покрытия:

steps:
  # Чекаутим код
 - uses: actions/checkout@master
   # Устанавливаем python нужной версии
 - uses: actions/setup-python@v1
   with:
     python-version: '3.8'
     architecture: 'x64'
 - name: Install requirements
   # Устанавливаем зависимости
   run: pip install -r requirements.txt
 - name: Run tests
   run: coverage run src/tests.py
 - name: Tests report
   run: coverage report

Все вместе

name: Run tests on any Push event
# Запуск при любом push евенте в любой ветке, за исключением релизных тэгов.
# Они будт тестироваться перед сборкой
on:
  push:
    tags:
      - '!refs/tags/*'
    branches:
      - '*'
jobs:
  run_tests:
    runs-on: [ubuntu-latest]
    steps:
      # Чекаутим код
      - uses: actions/checkout@master
      # Устанавливаем python нужной версии
      - uses: actions/setup-python@v1
        with:
          python-version: '3.8'
          architecture: 'x64'
      - name: Install requirements
        # Устанавливаем зависимости
        run: pip install -r requirements.txt
      - name: Run tests
        run: coverage run src/tests.py
      - name: Tests report
        run: coverage report

Попробуем создать коммит и посмотреть как работает наше действие.
Строим домашний CI-CD при помощи GitHub Actions и Python - 3
Прохождение тестов в интерфейсе Actions

Ура, у нас получилось создать первый экшен и запустить его! Попробуем сломать какой нибудь тест и посмотреть на вывод:
Строим домашний CI-CD при помощи GitHub Actions и Python - 4
Падение тестов в интерфейсе Actions

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

Примечание! Далее нам понадобится аккаунт в докере

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

Dockerfile

# Берем нужный базовый образ
FROM python:3.8-alpine
# Копируем все файлы из текущей директории в /app контейнера
COPY ./ /app
# Устанавливаем все зависимости
RUN apk update && pip install -r /app/requirements.txt --no-cache-dir
# Устанавливаем приложение (Подробнее смотри Distutils)
RUN pip install -e /app
# Говорим контейнеру какой порт слушай
EXPOSE 8080
# Запуск нашего приложения при старте контейнера
CMD web_server

# В качестве альтернативы distutils можно просто указать что выполнить
#CMD python /app/src/app.py

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

Строим домашний CI-CD при помощи GitHub Actions и Python - 5
Секреты в GitHub

DOCKER_LOGIN — логин в hub.docker.com
DOCKER_PWD — пароль
DOCKER_NAME — название докер репозитория для этого проекта (необходимо создать заранее)

Окей, подготовка выполнена, теперь создадим наш второй action:

$ touch .github/workflows/pub_on_release.yaml

Тестирование копируем из предыдущего экшена за исключением триггера запуска (я не нашел как импортировать экшены). Его мы заменяем на “Запуск при релизе”:

on:
 release:
   types: [published]

ВАЖНО! Очень важно сделать правильное условие on.event
Например если on.release не указать types, то этот эвент триггерит как минимум 2 события: published и created. То есть будет запущено сразу 2 процесса сборки.

Теперь в этом же файле сделаем еще одну задачу зависимую от первой:

build_and_pub:
  needs: [run_tests]

needs — говорит о том, что эта задача не начнется, пока не закончится run_tests

ВАЖНО! Если у вас несколько файлов с экшенами, и внутри них по несколько задач то все они запускаются одновременно в разных средах. Для каждой задачи создается отдельная среда, независимая от других задач. если не указывать needs то задача тестирования и сборки будут запущены одновременно и независимо друг от друга.

Добавим переменные окружения, в которых будут наши секреты:

env:
 LOGIN: ${{ secrets.DOCKER_LOGIN }}
 NAME: ${{ secrets.DOCKER_NAME }}

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

steps:
 - name: Login to docker.io
   run:  echo ${{ secrets.DOCKER_PWD }} | docker login -u ${{ secrets.DOCKER_LOGIN }} --password-stdin
 - uses: actions/checkout@master
 - name: Build image
   run: docker build -t $LOGIN/$NAME:${GITHUB_REF:11} -f Dockerfile .
 - name: Push image to docker.io
   run: docker push $LOGIN/$NAME:${GITHUB_REF:11}

${GITHUB_REF:11} — это переменная гитхаба, в которой хранится строка с референсом на событие по которому сработал триггер(название ветки, тэг и т.д.), если у нас тэги формата "v0.0.0" то необходимо обрезать первые 11 символов, тогда останется "0.0.0".

Пушим код и создаем новый тэг. И мы видим то, что наш контейнер был успешно собран и отправлен в registry, и мы нигде не засветили свой пароль.

Строим домашний CI-CD при помощи GitHub Actions и Python - 6
Сборка и отправка контейнера в интерфейсе Actions

Проверяем хаб:

Строим домашний CI-CD при помощи GitHub Actions и Python - 7
Сохраненный в docker hub контейнер

Все работает, но осталась самая сложная задача — deployment. Тут уже понадобится VPS и белый IP адрес куда будем деплоить контейнер и куда можно отправить хук. В теории на стороне VPS или домашнего сервера можно по крону запускать скрипт, который в случае наличия нового образа пулил бы его, или как то поиграться с телеграм ботом. Наверняка есть куча способов это сделать. Но я буду работать именно с внешним ip. Чтобы не мудрить, я написал небольшой веб-сервис на Flask c простым API.
Если коротко, то есть 1 эндпоинт “/”.
GET запрос возвращает json со всеми активными контейнерами на хосте.
POST — принимает данные в формате:

{
    "owner": "логин докер аккаунта",
    "repository": "имя докер репозитория",
    "tag": "v0.0.1",  #тэг который надо задеплоить
    "ports": {"8080": 8080, “443”: 443} #мапинг портов между хостом и контейнером
}

Что при этом происходит на хосте:

  1. из полученного json собирается имя нового image
  2. пулится новый образ
  3. если образ был скачан, то текущий контейнер останавливается и удаляется
  4. запускается новый контейнер, с публикацией портов (флаг -p)

Вся работа с докером осуществлена при помощи библиотеки docker-py

Было бы очень не правильно публиковать такой сервис в интернет без какой либо минимальной защиты, и Я сделал подобие API-KEY, сервис считывает токен из переменных окружения, и потом сравнивает его header’ом {Authorization: CI_TOKEN}

Листинг веб-сервера для деплоя

# coding=utf-8
import os
import sys
import logging
import logging.config
import logging.handlers

from flask import Flask
from flask import request, jsonify
import docker

log = logging.getLogger(__name__)
app = Flask(__name__)
docker_client = docker.from_env()
MY_AUTH_TOKEN = os.getenv('CI_TOKEN', None)  # Берем наш токен из переменной окружения

def init_logging():
    """
    Инициализация логгера
    :return:
    """
    log_format = f"[%(asctime)s] [ CI/CD server ] [%(levelname)s]:%(name)s:%(message)s"
    formatters = {'basic': {'format': log_format}}
    handlers = {'stdout': {'class': 'logging.StreamHandler',
                           'formatter': 'basic'}}
    level = 'INFO'
    handlers_names = ['stdout']
    loggers = {
        '': {
            'level': level,
            'propagate': False,
            'handlers': handlers_names
        },
    }
    logging.basicConfig(level='INFO', format=log_format)
    log_config = {
        'version': 1,
        'disable_existing_loggers': False,
        'formatters': formatters,
        'handlers': handlers,
        'loggers': loggers
    }
    logging.config.dictConfig(log_config)

def get_active_containers():
    """
    Получение списка запущенных контейнеров
    :return:
    """
    containers = docker_client.containers.list()
    result = []
    for container in containers:
        result.append({
            'short_id': container.short_id,
            'container_name': container.name,
            'image_name': container.image.tags,
            'created':  container.attrs['Created'],
            'status':  container.status,
            'ports':  container.ports,
        })
    return result

def get_container_name(item: dict) -> [str, str]:
    """
    Получение имени image из POST запроса
    :param item:
    :return:
    """
    if not isinstance(item, dict):
        return ''
    owner = item.get('owner')
    repository = item.get('repository')
    tag = item.get('tag', 'latest').replace('v', '')
    if owner and repository and tag:
        return f'{owner}/{repository}:{tag}', repository
    if repository and tag:
        return f'{repository}:{tag}', repository
    return '', ''

def kill_old_container(container_name: str) -> bool:
    """
    Перед запуском нового контейнера, удаляем старый
    :param container_name:
    :return:
    """
    try:
        # Получение получение контейнера
        container = docker_client.containers.get(container_name)
        # Остановка
        container.kill()
    except Exception as e:
        # На случай если такого контейнера небыло
        log.warning(f'Error while delete container {container_name}, {e}')
        return False
    finally:
        # Удаление остановленых контейнеров, чтобы избежать конфликта имен
        log.debug(docker_client.containers.prune())
    log.info(f'Container deleted. container_name = {container_name}')
    return True

def deploy_new_container(image_name: str, container_name: str, ports: dict = None):
    try:
        # Пул последнего image из docker hub'a
        log.info(f'pull {image_name}, name={container_name}')
        docker_client.images.pull(image_name)
        log.debug('Success')
        kill_old_container(container_name)
        log.debug('Old killed')
        # Запуск нового контейнера
        docker_client.containers.run(image=image_name, name=container_name, detach=True, ports=ports)
    except Exception as e:
        log.error(f'Error while deploy container {container_name}, n{e}')
        return {'status': False, 'error': str(e)}, 400
    log.info(f'Container deployed. container_name = {container_name}')
    return {'status': True}, 200

@app.route('/', methods=['GET', 'POST'])
def MainHandler():
    """
    GET - Получение списка всех активных контейнеров
    POST - деплой сборки контейнера
    Пример тела запроса:
    {
        "owner": "gonfff",
        "repository": "ci_example",
        "tag": "v0.0.1",
         "ports": {"8080": 8080}
    }
    :return:
    """
    if request.headers.get('Authorization') != MY_AUTH_TOKEN:
        return jsonify({'message': 'Bad token'}), 401
    if request.method == 'GET':
        return jsonify(get_active_containers())
    elif request.method == 'POST':
        log.debug(f'Recieved {request.data}')
        image_name, container_name = get_container_name(request.json)
        ports = request.json.get('ports') if request.json.get('ports') else None
        result, status = deploy_new_container(image_name, container_name, ports)
        return jsonify(result), status

def main():
    init_logging()
    if not MY_AUTH_TOKEN:
        log.error('There is no auth token in env')
        sys.exit(1)
    app.run(host='0.0.0.0', port=5000)

if __name__ == '__main__':
    main()

Для этого приложения я так же сделал setup.py чтобы установить его в систему. Установить его можно при помощи:

$ python3 setup.sy install

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

[Unit]
Description=Deployment web server
After=network-online.target

[Service]
Type=simple
RestartSec=3
ExecStart=/usr/local/bin/ci_example
Environment=CI_TOKEN=#<I generate it with $(openssl rand -hex 20)>

[Install]
WantedBy=multi-user.target

Осталось только запустить его:

$ sudo systemctl daemon-reload
$ sudo systemctl enable ci_example.service
$ sudo systemctl start ci_example.service

Посмотреть лог веб сервевра доставки можно при помощи команды

$ sudo systemctl status ci_example.service

Серверная часть готова, осталось только добавить хук нашему экшену. Для этого добавим в секреты ip-адрес нашего сервера и CI_TOKEN который мы сгенерировали когда устанавливали приложение.
Сначала я хотел использовать уже готовый экшен для curl из маркетплэйса github, но к сожалению, он убирает кавычки из тела POST запроса что приводило к невозможности парсинга json. Меня очевидно это не устроило, и я решил воспользоваться встроенным в ubuntu (на которой я собираю контейнеры) curl’ом, что кстати положительно сказалось на производительности, потому что не требует сборки дополнительного контейнера:

deploy:
  needs: [build_and_pub]
  runs-on: [ubuntu-latest]
  steps:
    - name: Set tag to env
       run: echo ::set-env name=TAG::$(echo ${GITHUB_REF:11})
    - name: Send webhook for deploy
run: "curl --silent --show-error --fail -X POST ${{ secrets.DEPLOYMENT_SERVER }} -H 'Authorization: ${{ secrets.DEPLOYMENT_TOKEN }}' -H 'Content-Type: application/json' -d '{"owner": "${{ secrets.DOCKER_LOGIN }}", "repository": "${{ secrets.DOCKER_NAME }}", "tag": "${{ env.TAG }}", "ports": {"8080": 8080}}'"

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

Action сборки и деплоя

name: Publish on Docker Hub and Deploy

on:
  release:
    types: [published]
  # Запуск только при публиковании нового релиза

jobs:
  run_tests:
    # Первую джобу смело можем копипастить из экшена для тестирования
    runs-on: [ubuntu-latest]
    steps:
      # Чекаутим код
      - uses: actions/checkout@master
      # Устанавливаем python нужной версии
      - uses: actions/setup-python@v1
        with:
          python-version: '3.8'
          architecture: 'x64'
      - name: Install requirements
        # Устанавливаем зависимости
        run: pip install -r requirements.txt
      - name: Run tests
        # Запускаем тесты
        run: coverage run src/tests.py
      - name: Tests report
        run: coverage report

  build_and_pub:
    # Если тесты были пройдены успешно
    needs: [run_tests]
    runs-on: [ubuntu-latest]
    env:
      LOGIN: ${{ secrets.DOCKER_LOGIN }}
      NAME: ${{ secrets.DOCKER_NAME }}
    steps:
      - name: Login to docker.io
        # Сначала мы логинимся в docker.io
        run:  echo ${{ secrets.DOCKER_PWD }} | docker login -u ${{ secrets.DOCKER_LOGIN }} --password-stdin
        # Чекаутим код
      - uses: actions/checkout@master
      - name: Build image
        # Собираем image и называем его так как указано в hub.docker т.е. login/repository:version
        run: docker build -t $LOGIN/$NAME:${GITHUB_REF:11} -f Dockerfile .
      - name: Push image to docker.io
        # Пушим образ в registry
        run: docker push $LOGIN/$NAME:${GITHUB_REF:11}

  deploy:
    # Если мы успешно собрали контейнер и отправили в registry, то делаем хук деплоймент серверу
    # Попробуем готовый экшен curl из маркетплэйса
    needs: [build_and_pub]
    runs-on: [ubuntu-latest]
    steps:
      - name: Set tag to env
        run: echo ::set-env name=RELEASE_VERSION::$(echo ${GITHUB_REF:11})
      - name: Send webhook for deploy
        run: "curl --silent --show-error --fail -X POST ${{ secrets.DEPLOYMENT_SERVER }} -H 'Authorization: ${{ secrets.DEPLOYMENT_TOKEN }}' -H 'Content-Type: application/json' -d '{"owner": "${{ secrets.DOCKER_LOGIN }}", "repository": "${{ secrets.DOCKER_NAME }}", "tag": "${{ env.RELEASE_VERSION }}", "ports": {"8080": 8080}}'"

Окей, мы собрали все вместе, теперь создадим новый релиз и посмотрим на экшены.
Строим домашний CI-CD при помощи GitHub Actions и Python - 8
Вебхук к приложению деплоя интерфейсе Actions

Все получилось сделаем GET запрос к сервису деплоя (выводит все активные контейнеры на хосте):
Строим домашний CI-CD при помощи GitHub Actions и Python - 9
Развернутый на VPS контейнер

Теперь отправим запросы к нашему задеплоеному приложению:
Строим домашний CI-CD при помощи GitHub Actions и Python - 10
GET запрос к развернутому контейнеру

POST запрос к развернутому контейнеру

POST запрос к развернутому контейнеру

Выводы
GitHub Actions очень удобный и гибкий инструмент, с помощью которого можно сделать много вещей, которые очень сильно могут упростить жизнь. Все зависит только от фантазии.
Они поддерживают возможность интеграционного тестирования при помощи services.
Как логичное продолжение этого проекта можно добавить в webhook возможность передачи кастомных параметров для запуска контейнера.
В дальнейшем я попробую взять за основу этот проект для деплоя helm charts когда буду изучать и экспериментировать с k8s

Если у вас есть какой то домашний проект, то GitHub Actions может сильно упростить работу с репозиторием .

Подробности синтаксиса можно найти тут
Все исходники проекта

Автор: Денис Д.

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js