Хабрахабр

[Из песочницы] Строим домашний CI/CD при помощи GitHub Actions и Python

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

Jenkins я практически сразу исключил из-за отсутствия необходимости в настолько мощном инструменте. Изначально передо мной стоял выбор между Circle CI, Travis или Jenkins. О Circle CI я узнал из слишком навязчивой рекламы на youtube. Бегло прочитав про Travis пришел к выводу, что в нем удобно собирать и тестировать, но доставку с ним особо не придумаешь. Снова взявшись за поиски я наткнулся на 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= 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

Попробуем создать коммит и посмотреть как работает наше действие.

Прохождение тестов в интерфейсе Actions

Попробуем сломать какой нибудь тест и посмотреть на вывод:

Падение тестов в интерфейсе 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

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


Секреты в 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}

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

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


Сборка и отправка контейнера в интерфейсе Actions

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


Сохраненный в docker hub контейнер

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

{ "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}}'"

Окей, мы собрали все вместе, теперь создадим новый релиз и посмотрим на экшены.

Вебхук к приложению деплоя интерфейсе Actions

Все получилось сделаем GET запрос к сервису деплоя (выводит все активные контейнеры на хосте):

Развернутый на VPS контейнер

Теперь отправим запросы к нашему задеплоеному приложению:

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

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

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

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

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

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

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть