Антон Рябов bio photo

Антон Рябов

Не люблю бриться и у меня умный взгляд.

Email Twitter Telegram Github RSS

Деплой на AWS Fargate

Последние пол года я активно работаю с Amazon Web Services, по большей части с serverless приложениями. Одна из задач, которую решает системный инженер в рамках DevOps, это настройка CI/CD пайплайна.

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

Итак, чтобы хранить код приложения предлагается использовать CodeCommit, постоянно интегрировать и собирать билды - CodeBuild, для деплоя - CodeDeploy. Ну а чтобы собрать все это вместе и получить пайплайн - CodePipeline.

Да, именно так, там где можно обойтись git репозиторием и Jenkins, они предлагают использовать 4 инструмента, каждый из которых имеет свою конфигурацию и особенности. Давайте заменим CodeCommit на Github, потому что он мне нравится больше, и нарисуем диаграмму как бы выглядел пайплайн для контейнезированного приложения, которое запускается на ECS Fargate.

Допустим, вы единственный разработчик, работа с ветками не принципиальна и пайплайн запускается при пуше в мастер ветку.

Вебхук из Github улетает в AWS и запускается CodePipeline, который в свою очередь запускает CodeBuild. CodeBuild устанавливает зависимости, запускает unit тесты и если все ок, собирает Docker образ.

Артефактом сборки в этом случае будет как раз Docker образ, его мы загрузим в ECR - рéгистри для образов, который предоставляет Amazon.

Если CodeBuild отработал успешно, CodePipeline переходит к следующему шагу и запускает CodeDeploy, который деплоит новую версию контейнера в Fargate.

В интерфейсе CodePipeline это будет выглядеть примерно так:

Давайте назовем этот способ Amazon Way. В одном из DevOps вторников в своем Telegram канале я писал:

На новом проекте активно работаю с AWS SDK и все больше убеждаюсь, что инфраструктурные инструменты типа Chef, Ansible или Terraform, в случае работы с облаками, только добавляют лишнюю прослойку и усложняют проект. Со временем крепнет идея, что инфра код должен быть полноценной частью кода разработки. Потому что через SDK, который Amazon написали под все популярные языки, я могу деплоить новую версию контейнера на ECS Fargate в пару строк, а через любой другой инструмент буду городить огород.

Этот пример с деплоем на Fargate из кода теоретический и высосан из пальца, но что мешает действительно это сделать? CodeBuild умеет обрабатывать вебхуки из Github напрямую, значит мы можем выкинуть CodePipeline из схемы, а CodeDeploy заменить, скажем, на Lambda функцию. Помимо упрощения конфигурации, мы станем гибче в опциях по деплою.

Например, к Lambda функции можно подключить API Gateway или Application Load Balancer, что сделает его доступным по HTTP. Что, в свою очередь, можно использовать в сторонней системе, допустим бот помощник в корпоративном чате сможет деплоить по команде.

Перерисуем диаграмму.

Чтобы написать Lambda функцию деплоя, нужно сначала немного разобраться в том, как устроен ECS. Грубо говоря, схема такая: Кластер -> Сервис -> Таск -> Контейнер (один или более).

Кластер - это своеобразное пространство, для логического разделения ресурсов. На данный момент, кластер может быть одного из двух типов - EC2 или Fargate. В случае с EC2 у вас больше контроля, но и всю работу по менеджменту вы берете на себя. С Fargate проще.

Сам Amazon дает такую рекомендацию: если у вас постоянная нагрузка и её уровень в течение времени заранее известен, выбирайте EC2. Если вы не знаете уровень нагрузки или хотите чтобы приложение работало по расписанию, выбирайте Fargate.

Сервис - на самом деле необязателен, таск можно запустить и без него. Задача сервиса, следить за состоянием тасков и при необходимости что-то с ними делать. Именно в сервисе мы задаем что хотим иметь два таска для отказоуйстойчивости, и если по какой-то причине таска будет не два, например один был убит по OOM, сервис будет пытаться исправить ситуацию.

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

Новую таск дефиницию можно создавать на базе старой, заменив версию контейнера. Будем считать, что все необходимые компоненты, кластер, сервис и изначальная такс дефиниция появляются магическим образом. Как будет выглядеть функция деплоя? Нам нужно учесть как минимум несколько факторов, давайте прикинем алгоритм:

Выйгрыш Алгоритм есть, можно и поесть код писать (21). Начнем с загрузки модуля aws-sdk и инициализации переменных:

'use strict'
const AWS = require('aws-sdk')
AWS.config.region = 'us-east-1'

const ecrRegistryId = process.env.ECR_REGISTRY_ID
const ecrRegistryName = process.env.ECR_REGISTRY_NAME
const ecsName = process.env.ECS_NAME

const ecs = new AWS.ECS()
const ecr = new AWS.ECR()

На локальном компьютере aws-sdk придется установить, а вот в лямбде она есть из коробки.

ECR рéгистри нужно создать заранее, а его ID и имя положить в ENV переменные ECR_REGISTRY_ID и ECR_REGISTRY_NAME соответственно. Рядом нужно положить название нашего ECS. Эти переменные мы объявляем глобально, чтобы AWS мог их кешировать между запусками функции.

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

const newVersion = event.version
if (newVersion == '') {
  throw Error ('no version specified')
}

Затем проверяем, что образ есть в рéгистри:

const image = await ecr.describeImages({
  registryId: ecrRegistryId,
  repositoryName: ecrRegistryName,
  imageIds: [
    { imageTag: newVersion }
  ]
}).promise()
if (image.imageDetails[0].imageSizeInBytes == 0) {
  throw Error ('no image in registry')
}

Проверяем, что кластер и сервис созданы и активны:

const cluster = await ecs.describeClusters({
  clusters: [ ecsName ]
}).promise()
if (cluster.clusters.length == 1) {
  if (cluster.clusters[0].status !== 'ACTIVE') {
    throw Error ('no ACTIVE cluster found')
  }
} else {
throw Error ('cluster not found')
}

const service = await ecs.describeServices({
  cluster: ecsName,
  services: [ ecsName ]
}).promise()

if (service.services.length == 1) {
  if (service.services[0].status !== 'ACTIVE') {
throw Error ('no ACTIVE service found')
  }
} else {
throw Error ('service not found')
}

Теперь нам нужно найти предыдущую таск дефиницию:

const taskDefinitions = await ecs.listTaskDefinitions({
  familyPrefix: ecsName,
  sort: 'DESC',
  status: 'active'
}).promise()
const lastTaskDefinition = await ecs.describeTaskDefinition({
  taskDefinition: taskDefinitions['taskDefinitionArns'][0],
  include: ['TAGS']
}).promise()

На основании последней активной таск дефиниции мы создадим новую, с требуемой версией контейнера:

const newTaskDefintion = lastTaskDefinition.taskDefinition
const newTaskDefintionTags = lastTaskDefinition.tags
const newTaskDefintionContainerImage = lastTaskDefinition.taskDefinition.containerDefinitions[0].image.split(":").splice(0,1).concat([newVersion]).join(":")
newTaskDefintion.containerDefinitions[0].image = newTaskDefintionContainerImage

Некоторые поля динамические, поэтому их нужно удалить:

const extra_keys = [
  'taskDefinitionArn',
  'revision',
  'status',
  'requiresAttributes',
  'compatibilities'
]

extra_keys.forEach(key => {
  delete newTaskDefintion[key]
})

Тэги нужно переложить отдельно, все переносим как есть, а в Version записываем новое значение:

for (const tag of newTaskDefintionTags) {
  if (tag['key'] == 'Version') {
    newTaskDefintion.tags.push({key: 'Version', value: newVersion})
  } else {
    newTaskDefintion.tags.push(tag)
  }
}

После того как новая таск дефиниция подготовлена, её нужно зарегистрировать:

const registeredTaskDefinition = await ecs.registerTaskDefinition(newTaskDefintion).promise()

И остается только обновить сервис с новой таск дефиницией:

const serviceUpdate = await ecs.updateService({
    cluster: ecsName,
    service: ecsName,
    taskDefinition: registeredTaskDefinition.taskDefinition.taskDefinitionArn
  }).promise()
console.log(serviceUpdate)

return {
  statusCode: 200,
  body: {
    status: 'success',
    version: newVersion
  }
}

Вся функция целиком в Github Gist.

Получилось не пару строк, а сотня, да и многих вещей не хватает, например:

  • нормальной проверки на ошибки
  • таймаутов
  • отката на предыдущую версию, в случае проблем
  • оповещений

Это все остается на ваше усмотрение, библиотека AWS достаточно хорошо документирована. За рамками, также остались моменты, каким образом Lambda функция и конфигурация CodeBuild появятся в AWS, кто и когда создаст ECR рéгистри, кластер ECS и т.д.

В этой статье я хотел показать, что не обязательно идти предложенным вендором путем (Amazon Way). Управлять облачной инфраструктурой может быть проще из того инструмента, который вы уже знаете, вместо того чтобы учить кучу новых инструментов или Terraform.

Псс, нужно ли мне сделать шаблон приложения и инфраструктуры, которые будут делать то, что описано в статье, чтобы каждый мог попробовать воспроизвести? Если считаешь что да, то поделись ссылкой на статью с другом и напиши мне в Twitter, Почту или Telegram.

#DevOps #AWS #Serverless #CI/CD