Как стать автором
Обновить

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

Время на прочтение10 мин
Количество просмотров8.4K

Введение

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

Теги:
Хабы:
Если эта публикация вас вдохновила и вы хотите поддержать автора — не стесняйтесь нажать на кнопку
Всего голосов 3: ↑2 и ↓1+1
Комментарии7

Публикации

Истории

Работа

Python разработчик
137 вакансий
DevOps инженер
43 вакансии

Ближайшие события