Настройка CI/CD с AWS и Github Actions

Kate

Administrator
Команда форума

Введение​

В этой статье хотелось бы поведать о настройке CI/CD процессов на примере личного грантового проекта. Сам проект посвящен автоматической обработке T2 взвешенных снимков МРТ поясничного отдела позвоночника и представляет собой набор веб-приложений. На проекте используются разные веб-фреймворки, в частности, Flask, Django и Spring Boot. Приложения разворачиваются в инфраструктуре AWS, потому что это удобно, ибо Amazon остается гегемоном среди публичных облаков из-за огромного количества сервисов, а также, потому что это стильно, модно и молодежно. Чтобы избавиться от монотонной работы по разворачиванию веб-приложений, а также от постоянных проверок на их жизнеспособность, было решено настроить CI/CD процессы.

Про Github Actions​

Для настройки CI/CD пайплайнов был выбран инструмент Github Actions, потому что все репозитории проекта размещены в Github, а еще, потому что не хочется держать свой Jenkins сервер. Учитывая то, что умельцы пишут свои собственные Actions, сейчас практически все потребности в разных операциях (например, подключение по SSH, работа с AWS CLI, сборка под разные ОС) удовлетворяются с лихвой. Определившись с инструментом разработки необходимо продумать требования к пайплайнам на примере Flask веб-приложения.

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

С CD пайплайном дела обстоят сложнее, ибо в данном случае необходимо продумать взаимодействие AWS сервисов между собой. Приложению нужно доменное имя (Route 53), которое будет смотреть на IP-адрес сервера (EC2) с самой программой. Также нужна база данных, в нашем случае реляционная (RDS). Для минимально жизнеспособного веб-приложения уже потребовалось 3 AWS сервиса. В существующем Flask проекте используется больше AWS сервисов. Ниже показана вольная схема AWS инфраструктуры для ранее описываемого проекта.

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

Конечно, можно настраивать эти сервисы каждый раз вручную, и при изменении программного кода мануально накатывать новую версию приложения на сервер, но лучше автоматизировать данный процесс, избавив себя от монотонной работы. У AWS и на этот случай есть сервис под названием CloudFormation, предоставляющий услуги вида «Infrastructure as a Code» (IaaC), позволяющий моделировать и управлять ресурсами AWS. Его и будем использовать.

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

Continuous Integration jobs​

Все давно знают, что Github Actions пайплайны складируются в директории репозитория: .github/workflows/*.yml

Первым делом настроим прогон тестов на пулл реквестах, чтобы в main ветку не попал нерабочий код. Ниже представлен код пайплайна для вышеописанных действий.

name: Pipeline name # Имя пайплайна
env: # Переменные среды
VARIABLE: "var"

on: # Триггеры для запуска пайплайна
workflow_dispatch: # Ручной запуск через UI Гитхаба
pull_request: # Для пул реквестов

jobs: # Список джоб
validation: # Имя джобы
runs-on: ubuntu-latest
steps: # Действия для нашей job
- name: Clone repo # Клонируем наш репозиторий (можно клонировать и другие (даже приватные), только нужен другой action)
uses: actions/checkout@v2

- name: Set up Python 3.7 # Устанавливаем питончик нужной версии
uses: actions/setup-python@v1
with:
python-version: 3.7

- name: Configure AWS Credentials # Конфигурируем креды для работы с AWS
uses: aws-actions/configure-aws-credentials@v1
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY }} # Ниже будет картинка, где показана настройка secrets
aws-secret-access-key: ${{ secrets.AWS_SECRET_KEY }}
aws-region: ${{ env.AWS_REGION_NAME }}

- name: Get response of RDS DB structure # Получаем адрес БД через AWS CLI
run: aws rds describe-db-instances --db-instance-identifier ${{ secrets.MYSQL_SCHEMA_NAME }} >> rds_response.json

- name: Get database URL
id: url
uses: sergeysova/jq-action@v2
with:
cmd: "jq -r '.DBInstances[].Endpoint.Address' rds_response.json" # Фильтруем JSON

- name: Install dependencies # Устанавлиаем Python зависимости
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt

- name: Lint with pycodestyle # Проверяем соответствует ли наш код PEP8
run: |
source venv/bin/activate
pycodestyle --exclude=venv --max-line-length=150 .
- name: Run unit tests # Запускаем тестики
run: |
sudo apt upgrade
pytest
env: # Необходимые системные переменные, чтобы проект не крашнулся
# Configure AWS settings environments for tests
AWS_ACCESS_KEY: ${{ secrets.AWS_ACCESS_KEY }}
AWS_SECRET_KEY: ${{ secrets.AWS_SECRET_KEY }}
# Configure production MySQL settings
MYSQL_USER: ${{ secrets.MYSQL_USER }}
MYSQL_PASSWORD: ${{ secrets.MYSQL_PASSWORD }}
MYSQL_URL: ${{ steps.url.outputs.value }}
MYSQL_DB: ${{ secrets.MYSQL_DB }}
# Configure SQLAlchemy
SQL_ALCHEMY_SECRET_KEY: ${{ secrets.SQL_ALCHEMY_SECRET_KEY }}

Ниже показана настройка secrets для репозитория.

Github secrets репозитория
Github secrets репозитория
Ниже приведу пример конфигурации джобы (это уже другой .yml файлик) для сборки проекта с комментариями, которая скажет все за меня.

name: Pipeline name # Все еще имя пайлайна
env: # Все еще переменные среды, которые все также используются в виде: ${{ env.VAR_NAME }}
ENV_VARIABLE: "value"

on:
workflow_dispatch:
push:
branches: [ main ] # При merge в main ветку

jobs:
ci:
runs-on: ubuntu-latest # Я офигел, когда узнал что можно и на MacOS бесплатно запускать

steps:
- name: Git clone our repo
uses: actions/checkout@v1

- name: Install zip # Устаналиваем zip архиватор, т.к. проект на Python
run: sudo apt-get install zip gzip tar

- name: Archive project # Архивируем наш проект из рабочей директории *
run: sudo zip -r project.zip *

- name: Configure my AWS Credentials
uses: aws-actions/configure-aws-credentials@v1
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_KEY }}
aws-region: ${{ env.AWS_REGION_NAME }}

- name: Copy app to S3 bucket # Копируем архив с проектом в AWS S3 bucket
run: aws s3 cp "project.zip" s3://${{ env.BUCKET_NAME }}/project.zip

- name: Print Happy Message for CI finish
run: echo "CI Pipeline part Finished successfully"
Таким образом, имеем CI джобы, которые прогоняют тестики для пул реквестов, а при коммите в main ветку, архивируют проект и отправляют его в AWS S3 bucket. Из облачного хранилища уже можно брать актуальную версию проекта через AWS CLI и накатывать на EC2 инстансы.

Continious delivery job & CF template​

Весь интерес и сложность CD джобы для проекта кроется не в пайплайне для Github Actions, а в CloudFormation шаблоне. Так исторически сложилось, что официальная документация для CloudFormation ужасна. Так считаю не только я, но и другие программисты. Даже имеющийся редактор шаблонов для CloudFormation не спасает положение. Поэтому приходится искать готовые сниппеты умельцев по всему Интернету и отсекать от сниппетов все ненужное. Например, для лендинг-страниц есть готовый CloudFormation шаблон с CDN (AWS CloudFront). Но для полноценных веб-приложений дела обстоят немного интереснее. Поскольку в существующем Flask проекте используется нестандартный Django-style менеджер запуска (Flask-Script), то использование AWS ELB для автоматического развертывания отпадает, потому что в конфигурации этого сервиса сам черт ногу сломит. Используется стандартный EC2.

Итак, начну с демонстрации CD джобы, которая заканчивается разворачиванием CloudFormation шаблона в AWS инфраструктуре. Эта джоба является продолжением предыдущего пайплайна.

...
cd_part: # Имя джобы
runs-on: ubuntu-latest
needs: [ci_part] # Запустится после успешного выполнения предыдущей джобы

steps:
- name: Git clone our repo # Нам понадобится наш репозиторий, т.к. в нем хранится CloudFormation шаблон
uses: actions/checkout@v1

- name: Configure my AWS Credentials # Все еще конфигурируем AWS креды, т.к. они сбрасываются после завершения предыдущей джобы
uses: aws-actions/configure-aws-credentials@v1
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_KEY }}
aws-region: ${{ env.AWS_REGION_NAME }}

- name: Deploy main stack # AWS CLI команда для запуска CloudFormation шаблона, который хранится у нас в репозитории
run: |
aws cloudformation deploy \
--stack-name main-stack \ # Имя стека (у AWS есть ограничения на спец. символы для имен CloudFormation стэков)
--template-file cloudformation/main_stack.json \ # Путь до файла с CloudFormation шаблоном
--capabilities CAPABILITY_NAMED_IAM \ # Магический параметр, про который мне лень писать
--parameter-overrides YourParameterName=${{ secrets.PARAMETER }} \ # Наши параметры, которые можно прокидывать в CloudFormation шаблон
--no-fail-on-empty-changeset # Околомагический параметр, который нужен, чтобы при следующем старте джобы CloudFormation шаблон не падал с ошибкой, если не было изменений в шаблоне
- name: Print Happy Message for CD finish
run: echo "CD Pipeline part Finished successfully"
Это весьма простой пайплайн по сравнению с предыдущими. Просто нужно знать как использовать AWS CLI команду для работы с CloudFormation. Можно читать документацию по AWS CLI либо с официального сайта, либо непосредственно из консольки. Теперь самое интересное - CloudFormation шаблон:

{
"Description": "AWS CloudFormation stack",
"Parameters": {
"ParameterName": {
"Type": "String"// в 99% случаев используем String
}
},
"Resources": { // Создаем наши AWS ресурсы
"S3Bucket": { // Файловое хранилище
"Type": "AWS::S3::Bucket",
"Properties": {
"BucketName": "S3BucketName",
"PublicAccessBlockConfiguration": { // Эти настройки нужны для конфигурирования открытости/закрытости S3 bucket (можно ли обращаться к файлам из хранилища по URL)
"BlockPublicAcls": false,
"BlockPublicPolicy": false,
"IgnorePublicAcls": false,
"RestrictPublicBuckets": false
}
}
},
"LogGroup": { // Это облачное хранилище логов, запись в них настаивается в коде приложения
"Type": "AWS::Logs::LogGroup",
"Properties": {
"LogGroupName": "LogGroupName"
}
},
"SecurityIAMRole": { // Это настройки безопасности для EC2
"Type": "AWS::IAM::Role",
"Properties": {
"AssumeRolePolicyDocument": {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Service": [
"ec2.amazonaws.com"
]
},
"Action": [
"sts:AssumeRole"
]
}
]
},
"Policies": [
{
"PolicyName": "S3Policy",
"PolicyDocument": {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "s3:*",
"Resource": "arn:aws:s3:::YOUR_S3_BUCKET_FOLDER/*"
}
]
}
}
],
"RoleName": "IAMRoleName"
}
},
"InstanceProfile": { // Такая же околобезопасная штука для EC2
"Type": "AWS::IAM::InstanceProfile",
"Properties": {
"Roles": [
{
"Ref": "SecurityIAMRole"
}
]
}
},
"SecurityGroup": {
"Type": "AWS::EC2::SecurityGroup",
"Properties": {
"GroupName": "SecurityGroupName",
"GroupDescription": "SecurityGroupDescription",
"SecurityGroupIngress": [
{ // Порты, которые мы можем выставлять наружу для EC2 инстанса
"IpProtocol": "tcp",
"CidrIp": "0.0.0.0/0",
"FromPort": 22,
"ToPort": 22
},
{
"IpProtocol": "tcp",
"CidrIpv6": "::/0",
"FromPort": 22,
"ToPort": 22
},
{
"IpProtocol": "tcp",
"CidrIp": "0.0.0.0/0",
"FromPort": 80,
"ToPort": 80
},
{
"IpProtocol": "tcp",
"CidrIpv6": "::/0",
"FromPort": 80,
"ToPort": 80
},
{
"IpProtocol": "tcp",
"CidrIp": "0.0.0.0/0",
"FromPort": 443,
"ToPort": 443
},
{
"IpProtocol": "tcp",
"CidrIpv6": "::/0",
"FromPort": 443,
"ToPort": 443
}
]
}
},
"CertificateManagerCertificate": { // HTTPS сертификат
"Type": "AWS::CertificateManager::Certificate",
"Properties": {
"DomainName": {
"Ref": "ParameterUrlName" // Ваш url адрес
},
"ValidationMethod": "DNS",
"SubjectAlternativeNames": [
{
"Ref": "ParameterUrlName"
},
{
"Fn::Sub": [
"www.${Domain}", // Чтобы работало обращение через www.
{
"Domain": {
"Ref": "ParameterUrlName"
}
}
]
}
],
"DomainValidationOptions": [
{
"DomainName": {
"Ref": "ParameterUrlName"
},
"HostedZoneId": {
"Ref": "HostZoneId" // Параметр на HostedZone id в Route53
}
},
{
"DomainName": {
"Fn::Sub": [
"www.${Domain}",
{
"Domain": {
"Ref": "ParameterUrlName"
}
}
]
},
"HostedZoneId": {
"Ref": "HostZoneId"
}
}
]
}
},
"Ec2Instance": { // Сам сервак с приложенькой
"Type": "AWS::EC2::Instance",
"Properties": {
"IamInstanceProfile": {
"Ref": "InstanceProfile"
},
"AvailabilityZone": "us-east-1c", // Можете поставить свое
"ImageId": "ami-047a51fa27710816e", // Amazon Linux ОС
"InstanceType": "t2.micro", //
"KeyName": "key-pair", // ssh ключик
"SecurityGroups": [
{
"Ref": "SecurityGroup"
}
],
"Tags": [
{
"Key": "Name",
"Value": "Имя вашего EC2 инстанса"
}
],
"UserData": { // Команды запуска и настройки ec2 под наши нужды
"Fn::Base64": {
"Fn::Join": [
"",
[
"Content-Type: multipart/mixed; boundary=\"//\"\n", // Фигня без которой ничего не работает
"MIME-Version: 1.0\n\n",
"--//\n",
"Content-Type: text/cloud-config; charset=\"us-ascii\"\n",
"MIME-Version: 1.0\n",
"Content-Transfer-Encoding: 7bit\n",
"Content-Disposition: attachment; filename=\"cloud-config.txt\"\n\n",
"#cloud-config\n",
"cloud_final_modules:\n",
"- [scripts-user, always]\n\n",
"--//\n",
"Content-Type: text/x-shellscript; charset=\"us-ascii\"\nMIME-Version: 1.0\n",
"Content-Transfer-Encoding: 7bit\n",
"Content-Disposition: attachment; filename=\"userdata.txt\"\n\n",
"#!/bin/bash\n",
"sudo yum -y install gcc openssl-devel bzip2-devel libffi-devel libssl-dev\n", // Устанавливаем python 3.7.2 и pip3
"sudo yum -y install python37 python3-devel\n",
"sudo yum -y install mysql-devel\n",
"sudo yum -y install vim\n",
"sudo yum -y install libXext libSM libXrender\n",
"sudo curl -O https://bootstrap.pypa.io/get-pip.py\n",
"sudo python3 get-pip.py\n",
"pip3 install awsebcli --upgrade --user\n",
"pip3 install jmespath==0.7.1 python-dateutil\n",
"pip3 install awsebcli --upgrade --user\n",
"sudo echo 'export ENV_VARIABLE=", // Сетаем переменную среды
{
"Ref": "ParameterName"
},
"'>>/home/ec2-user/.bash_profile\n",
// Удалил отсюда часть команд, т.к. они особой роли не играют
"aws s3 cp s3://bucket/file.zip /home/ec2-user/file.zip\n", // Копируем исходный код проекта
"nohup gunicorn -w 1 --reload -b 127.0.0.1:5000 --chdir /home/ec2-user 'app:create_app()' > log.txt 2>&1 &\n", // запускаем gunicorn
"sudo yum install -y https://dl.fedoraproject.org/pub/epel/epel-release-latest-7.noarch.rpm\n", // Устанавливаем Let's encrypt
"sudo yum install -y epel-release\n",
"sudo yum install nginx -y\n",
"sudo amazon-linux-extras install -y epel\n",
"sudo yum-config-manager --enable epel*\n",
"sudo yum install -y certbot\n",
"sudo yum install -y python-certbot-nginx\n",
"pip3 install certbot-nginx\n",
"sudo sed -i '2 a server_name ", // Вставить url адрес для Let's Encrypt в nginx конфигурацию на 2 строчку
{
"Ref": "ParameterUrlName"
},
" www.",
{
"Ref": "ParameterUrlName"
},
";' /home/ec2-user/deploy/app.conf\n",
"sudo cp /home/ec2-user/deploy/app.conf /etc/nginx/conf.d/app.conf\n",
"sudo certbot --nginx --non-interactive --agree-tos -d ",
{
"Ref": "ParameterUrlName"
},
" -d www.",
{
"Ref": "ParameterUrlName"
},
" -m ",
{
"Ref": "YourEmailParameter"
},
"\n",
"sudo certbot renew --dry-run\n",
"sudo systemctl stop nginx\n",
"sudo pkill -f nginx & wait $!\n", // Костыль без которого у меня не работало
"sudo systemctl start nginx\n",
"sudo systemctl enable nginx\n"
]
]
}
}
},
"DependsOn": [ // EC2 не должен создаваться раньше чем логи и s3 хранилище
"LogGroup",
"S3Bucket"
]
},
"ElasticIP": { // постоянный ip адрес (можно и без него)
"Type": "AWS::EC2::EIP",
"Properties": {
"Domain": "vpc",
"InstanceId": {
"Ref": "Ec2Instance"
},
"Tags": [
{
"Key": "Name",
"Value": "YourElasticIPName"
}
]
}
},
"DNSRecord": { // Запись DNS
"Type": "AWS::Route53::RecordSetGroup",
"Properties": {
"HostedZoneId": {
"Ref": "HostZoneId"
},
"RecordSets": [
{
"Name": {
"Ref": "ParameterUrlName"
},
"Type": "A",
"TTL": 900,
"ResourceRecords": [
{
"Ref": "ElasticIP"
}
]
},
{
"Name": {
"Fn::Sub": [
"www.${Domain}",
{
"Domain": {
"Ref": "ParameterUrlName"
}
}
]
},
"Type": "A",
"TTL": 900,
"ResourceRecords": [
{
"Ref": "ElasticIP"
}
]
}
]
}
}
}
}}
Наш ClodFormation шаблон отправится прямиком в AWS и там скомпилится. Отработав приличное количество времени, CloudFormation стэк создастся и мы сразу будем иметь развернутое приложение со всеми хранилищами, логами и другими ресурсами.

Пример развернутого CloudFormation stack с MySQL RDS БД
Пример развернутого CloudFormation stack с MySQL RDS БД

Заключение​

В данной статье была рассмотрена настройка CI/CD пайплайнов на основе Github Actions в инфраструктуре AWS. Автоматизация развертывания AWS ресурсов проводилась с использованием сервиса CloudFormation. У данного подхода есть минус в виде программирования на JSON (YAML) в CloudFormation шаблонах, но также имеется и плюс в виде гибкости конфигурации.

Кому будет интересно покопаться еще, то я создал Github Gist, где написал CloudFormation шаблон для реляционной базы данных (AWS RDS MySQL) и простейшую nginx конфигурацию, которую использует ранее представленный CloudFormation template.

 
Сверху