В первой части мы разворачивали приложение в Oracle cloud. Теперь попробуем сделать то же самое в AWS и зададимся вопросом так ли уж нужны Rails.

Итак у нас: SPA приложение, REST api, Terraform как средство деплоя и управления ресурсами в облаке. Теперь с исходниками, поехали!

Отличия от Oracle Cloud

У Амазона гораздо больше облачных сервисов и есть из чего выбирать при проектировании приложения. По сути Амазон стал уже стандартом де факто и многие провайдеры приводят свои сервисы к соответствию с AWS сервисами.

Из ключевых отличий следует отметить что для создания лямбды в амазоне достаточно предоставить zipку с исходниками. Что позволяет исключить слой с Docker образами в нашем приложении. Также в нашем примере мы исключим пока возможность локального запуска, приложение будет жить только в облаке. Но помните, что есть LocalStack, так что наверное можно будет запустить все и локально. Я такое пока не пробовал, но вообще может будет удобнее завести отдельный набор ресурсов для разработки в облаке.

Также, следуя рекомендациям Амазона, в качестве хранилища будем использовать DynamoDb. Это самое существенное изменение, теперь у нас не реляционная бд. Зато можно держать схему в state Terraformа.

Структура

Структура такая же как и в Oracle решении. Лямбды живут в папке functions, js клиент в папке client, гем с моделями и общим кодом в папке retro.

Задача

Целью проекта будет борд для ретроспектив, что- то вроде https://www.reetro.app/. Борд, три колонки - "хорошо", "плохо", "действия". Анонимные пользователи пишут заметки в этих колонках, которые потом обсуждаются на совещании.

Пользователь может создавать борды (Board). У борда есть версия (BoardVersion), которая отображает состояние борда на какой то момент времени. Борды могут быть доступны по прямой ссылке.

Реализация

Disclaimer: прошу не судите строго реализацию, она писалась по фану на скорую руку в свободную минуту, лишь бы работало. Рассматривайте ее как PoC.

Для начала давайте настроим AWS CLI. Установите aws-vault (позволит не хранить локально ключи и более секьюрно обращаться к AWS API - подробнее тут), установите aws cli, создайте secret access key в AWS консоли. Теперь создайте файл ~/.aws/config, у меня он выглядит как то так:

[profile fwd-retro]
sts_regional_endpoints = regional
mfa_serial = arn:aws:iam::{your_aws_account_id}:mfa/{your_aws_username}
credential_process = aws-vault exec --json --prompt=osascript fwd-retro
role_session_name = {your_aws_username}
output = json

Как можете видеть, у меня включен mfa и заведен отдельный профиль (fwd-retro) для приложения. Это позволит мне явно указать в Terraform провайдере какие креденшелы использовать и не вызывать aws-vault exec при вызове terraform apply.

Далее настраиваем S3 хранилище для ремоут стейта Terraform и поехали.

Создаем динамодб табличку, которая будет хранить все данные проекта, поскольку это документное хранилище, так можно сделать:

resource "aws_dynamodb_table" "data" {
  name         = "retro-data"
  billing_mode = "PAY_PER_REQUEST"
  hash_key     = "pid"
  range_key    = "cid"

  attribute {
    name = "pid"
    type = "S"
  }

  attribute {
    name = "cid"
    type = "S"
  }

  global_secondary_index {
    name               = "cid_si"
    hash_key           = "cid"
    projection_type    = "KEYS_ONLY"
  }
}

pid - это партишен индекс, он же parent id; cid - это ключ сортировки, он же ключ child записи. Они оба uuid и нужны чтобы хранить в том числе иерархию обьектов. На вершине иерархии User, который one-to-many с Board. Board в свою очередь one-to-many с BoardVersion, а он уже one-to-many уже с Note.

secondary index нужен чтобы можно было найти детей по их прямым uuid, не заморачиваясь с родителем.

Поверх Ruby AWS SDK напишем обертку, чтобы поудобнее работать с данными как с моделями. Вообще можно использовать что то готовое, вроде https://github.com/Dynamoid/dynamoid, но мы же минимизируем количество зависимостей. Самое интересное в модели - это CRUD:

model Retro
  class Model
    ...
    
    def new?
      identifier.nil?
    end

    def destroy
      db.delete_item(api_params.merge(key: identifier_params, return_values: RETURN_OPTIONS[:all_old])).attributes
    end

    def put
      push_attributes = item_attributes
      push_attributes["updated_at"] = Time.now.to_i
      db.put_item(api_params.merge(item: push_attributes, return_values: RETURN_OPTIONS[:all_old]))
      @attributes = push_attributes
      attributes
    end
    alias :save :put

    def update(method: ATTR_TRANSFORMATIONS[:put], **updates)
      attribute_updates = prepare_attributes(updates).transform_values do |value|
        { value: value, action: method }
      end

      response = db.update_item(api_params.merge(
        key: identifier_params,
        attribute_updates: attribute_updates,
        return_values: RETURN_OPTIONS[:all_new])
      )
      @attributes = response.attributes
      attributes
    end
    
    ...
  end
end

Лямбды у нас живут каждая в своей папочке и имеют собственные .tf файлики, в которых описаны aws ресурсы относящиеся конкретно к этим лямбдам. Потом мы эти файлы подключаем как terraform модули в рутовом конфиге, например functions/users/main.tf:

...

resource "aws_lambda_function" "users_lambda" {
  filename      = local.dist_path
  function_name = "users_lambda"
  role          = aws_iam_role.lambda_role.arn
  handler       = "func.Retro.route"

  ...

  runtime = "ruby2.7"

  depends_on = [aws_iam_role_policy_attachment.attach_iam_policy_to_iam_role]
}

А уже в main.tf:

...

module "users_lambda" {
  source = "./functions/users"
  data_table = aws_dynamodb_table.data
  depends_on = [data.external.app_gem, aws_dynamodb_table.data]
}

Дальше создаем собственно gateway и прописываем path до лямбды:

...

resource "aws_apigatewayv2_api" "retro_api" {
  name          = "retro-api"
  protocol_type = "HTTP"
}

resource "aws_apigatewayv2_stage" "default" {
  api_id = aws_apigatewayv2_api.retro_api.id
  name   = "$default"
  auto_deploy = true
}

resource "aws_apigatewayv2_integration" "users_lambda_integration" {
  api_id           = aws_apigatewayv2_api.retro_api.id
  integration_type = "AWS_PROXY"

  connection_type           = "INTERNET"
  description               = "Users lambda integration"
  integration_method        = "POST"
  integration_uri           = module.users_lambda.lambda.invoke_arn
  request_parameters     = {
    "append:querystring.action" = "$request.path.action"
  }
}

resource "aws_apigatewayv2_route" "users_lambda_route" {
  api_id    = aws_apigatewayv2_api.retro_api.id
  route_key = "POST /users/{action}"

  target = "integrations/${aws_apigatewayv2_integration.users_lambda_integration}.id}"
}

Ну вот собственно почти все, пишем саму лямбду, описываем правило сборки ee zip и делаем terraform apply. После можем идти в AWS и проверять что же там такого насоздавалось.

Итого

Мы имеем структуру проекта, где каждая часть отделена в своей папке.

Мы имеем задеплоенное SPA приложение в облаке и Terraform как средство его проведения. Мы можем заливать приложение по частям - захотели обновили js клиент, захотели - обновили одну лямбду. Более того, технически есть возможность разным частям приложения иметь различные зависимости. Это позволяет минимизировать потребляемые ресурсы и уменьшить время запуска нашего приложения.

Взаимозависимости сервисов явно расписаны на языке терраформа и видны невооруженным глазом.

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

Фронтенд не зависит от бекенда никак.

Мы имеем минимум зависимостей и можем сами управлять ими. Что позволяет нам забыть про таски вроде "обновить везде все рельсы".

Наше приложение легко масштабируется средствами амазона, лямбды рулят. Мы можем с легкостью использовать в нашем приложении websockets, так как api gateway в них умеет. Мы можем собирать наше решение из кубиков AWS сервисов, инфраструктура это тоже код и лежит рядом с бизнес логикой. Можем легко начать использовать событийное взаимодействие между частями нашего приложения посредством AWS SQS, SNS, ...

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

О чем стоит еще подумать

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

Например средства для отложенного исполнения операций или замена ActiveJob. Тут могут быть варианты и все зависит от задачи. Можно использовать лямбды не только как обработчики http запросов, а собственно как service object. Если так, то сервис можно вызвать асинхронно.

Можно сделать SQS очередь, куда складывать джобы и описать лямбду, которая будет обрабатывать эти задачи. Так можно будет предусмотреть повторное исполнение в случае ошибки, мониторинг ошибок посредством dead letter queue. Также есть свои ограничения - 15 мин лимит на исполнение лямбды.

Можно также сделать ECS таск, который будет обрабатывать события из очереди. Таким образом избежим временного лимита, но появится вопрос с масштабированием, если событий в очереди станет слишком много.

А можно воспользоваться AWS step functions и сделать комбайн для обработки джобов. Столько вариантов, помните, как было хорошо в Oracle cloud - выбирать было не из чего.

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

Итак вопрос, так ли уж нужны Rails в мире современных облачных сервисов?

Комментарии (0)