Введение
В этой статье хотелось бы поведать о настройке 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 и на этот случай есть сервис под названием 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 для репозитория.

Ниже приведу пример конфигурации джобы (это уже другой .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 стэк создастся и мы сразу будем иметь развернутое приложение со всеми хранилищами, логами и другими ресурсами.

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