Создание динамического кластера ECS с помощью Terraform

Цель этой статьи предоставить вам наглядные примеры и инструкции по разработке динамического модуля ECS (Elastic Container Service) с помощью Terraform. При этом предполагается наличие у вас базового представления о данном инструменте.

“Динамический” в данном случае означает, что Terraform может легко масштабироваться для обработки большего числа сервисов и задач.

Архитектура ECS (источник)

0. Сеть

Определение самого кластера ECS не содержит сетевых требований.

resource "aws_ecs_cluster" "cluster" {
  name = "${var.environment}-cluster"
  tags = var.tags
}

Дело в том, что сетевые функции определены на уровне сервиса, так как относятся к эластичным компонентам, в которых размещаются задачи. https://nuancesprog.ru/media/f42f8dce5d069d978a2140db80cc6a34Обзор ECS (англ.) 

Сетевые требования указаны в определении сервиса:

resource "aws_ecs_service" "fargate-microservices" {
  for_each      = var.create_microservices == true ? var.fargate_microservices : {}
  name          = each.value["name"]
  cluster       = aws_ecs_cluster.cluster.id
  desired_count = each.value["desired_count"]
  launch_type   = each.value["launch_type"]
  depends_on = [aws_ecs_cluster.cluster,
  aws_ecs_task_definition.ecs_tasks]
  task_definition = each.value["task_definition"]

  network_configuration {
    subnets         = var.ecs_service_subnets
    security_groups = [aws_security_group.ecs_security_groups[each.value["security_group_mapping"]].id]
  }

  lifecycle {
    ignore_changes = [
      task_definition
    ]
  }
}

Здесь нужно многое объяснить, и я начну с конфигурации сети.

network_configuration {
    subnets         = var.ecs_service_subnets
    security_groups = [aws_security_group.ecs_security_groups[each.value["security_group_mapping"]].id]
  }

В этом инстансе (закрытые) подсети наследуются от модуля, определяющего наш VPC. При создании динамического кластера мы просто ссылаемся на список подсетей в модуле VPC, чтобы создать сервисы в собственном предпочтительном VPC.

Группы безопасности задействуют функцию for_each в Terraform, лежащую в основе многих механик этого модуля.

for_each

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

for_each      = var.create_microservices == true ? var.fargate_microservices : {}

В этом случае мы указали, что для создания сервисов create_microservice должен быть true. Далее переменная fargate_microservices выступает в качестве карты, содержащей все необходимые сервису аргументы (пример приводится в разделе “Динамические сервисы”).

Группы безопасности (security groups)

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

resource "aws_security_group" "ecs_security_groups" {
  vpc_id = var.vpc_id

  for_each = var.security_groups
  name     = "${var.environment}-${each.value["ingress_port"]}"

  ingress {
    from_port   = each.value["ingress_port"]
    to_port     = each.value["ingress_port"]
    protocol    = each.value["ingress_protocol"]
    cidr_blocks = each.value["ingress_cidr_blocks"]
  }

  egress {
    from_port   = each.value["egress_port"]
    to_port     = each.value["egress_port"]
    protocol    = each.value["egress_protocol"]
    cidr_blocks = each.value["egress_cidr_blocks"]
  }

  tags = var.tags
}

Этот блок ресурса будет перебирать определенный вне модуля объект var.security_groups и выбирать переменную для каждой переменной с приставкой each.value.

Вот как определяется одна группа безопасности внутри модуля:

"ecs_security_groups": {
    "prod-ecs-sg": {
        "ingress_port": "redacted",
        "ingress_protocol": "redacted",
        "ingress_cidr_blocks": [
            "redacted"
        ],
        "egress_port": "redacted",
        "egress_protocol": "redacted",
        "egress_cidr_blocks": [
            "redacted"
        ]
    }
}

Затем эти инструкции отображаются в соответствующие им сервисы через переменную security_group_mapping внутри каждого сервиса. Эта переменная сопоставляет id группы безопасности (pro-ecs-sg) с указанным сервисом. Таким образом можно легко добавить еще одну группу безопасности, просто присоединив этот объект карты.

Теперь мы еще раз вернемся к определению сервиса, чтобы посмотреть, как переменная security_group_mapping используется совместно с другими динамическими переменными.

1. Динамические сервисы

Разобравшись с настройками сети, давайте еще раз взглянем на определение сервиса:

resource "aws_ecs_service" "fargate-microservices" {
  for_each      = var.create_microservices == true ? var.fargate_microservices : {}
  name          = each.value["name"]
  cluster       = aws_ecs_cluster.cluster.id
  desired_count = each.value["desired_count"]
  launch_type   = each.value["launch_type"]
  depends_on = [aws_ecs_cluster.cluster,
  aws_ecs_task_definition.ecs_tasks]
  task_definition = each.value["task_definition"]

  network_configuration {
    subnets         = var.ecs_service_subnets
    security_groups = [aws_security_group.ecs_security_groups[each.value["security_group_mapping"]].id]
  }}

Обратили внимание на переменную depends_on? Этот список переменных обеспечивает создание задач и кластера до создания сервиса. В отсутствие любого из этих ресурсов собрать сервисы не удастся.

Как уже говорилось, для создания сервисов необходимо, чтобы переменная create_microsrvices была установлена как true. Если так и есть, то далее мы передаем карту переменных для определения наших сервисов:

"fargate_microservices": {
    "prod-service-one": {
        "name": "prod-service-one",
        "task_definition": "prod-task-one",
        "desired_count": "1",
        "launch_type": "FARGATE",
        "security_group_mapping": "prod-ecs-sg"
    },
    "prod-service-two": {
        "name": "prod-service-two",
        "task_definition": "prod-task-two",
        "desired_count": "1",
        "launch_type": "FARGATE",
        "security_group_mapping": "prod-ecs-sg"
    }
}

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

Переменные определяются следующим образом:

variable "fargate_microservices" {
  description = "Map of variables to define a Fargate microservice."
  type = map(object({
    name                   = string
    task_definition        = string
    desired_count          = string
    launch_type            = string
    security_group_mapping = string
  }))

В launch_type вы определяете, как ваш кластер должен запускать контейнеры: используя AWS Fargate или EC2. Чтобы понять, какой тип запуска лучше подходит под ваши требования, ознакомьтесь с руководством по этой ссылке (англ.)

Так как же мы создаем динамические задачи?

Общая схема задач, сервисов и кластеров (источник)

2. Динамические задачи

Вот полное определение динамических задач (англ.):

resource "aws_ecs_task_definition" "ecs_tasks" {
  for_each = var.create_tasks == true ? var.ecs_tasks : {}
  family   = each.value["family"]
  container_definitions = templatefile(each.value["container_definition"], "${merge("${var.extra_template_variables}",
    {
      container_name        = each.value["family"],
      docker_image          = "${var.docker_image}:${var.docker_tag}",
      aws_logs_group        = "/aws/fargate/${aws_ecs_cluster.cluster.name}/${each.value["family"]}/${var.environment}",
      aws_log_stream_prefix = each.value["family"],
      aws_region            = var.region,
      container_port        = each.value["container_port"]
  })}")

  task_role_arn            = aws_iam_role.ecs_task_role.arn
  network_mode             = var.task_definition_network_mode
  cpu                      = each.value["cpu"]
  memory                   = each.value["memory"]
  requires_compatibilities = [var.ecs_launch_type == "FARGATE" ? var.ecs_launch_type : null]
  execution_role_arn       = aws_iam_role.ecs_execution_role.arn

  tags = merge({
    "Name"        = "${each.value["family"]}-${var.environment}"
    "Description" = "Task definition for ${each.value["family"]}"
    }, var.tags
  )
}

В самом верху снова задействуется for_each, для чего var.create_tasks должна быть установлена как true, чтобы прочитать объект карты var.ecs_tasks.

Сложнейшая часть этого проекта в создании динамических container_definitions. Эти переменные определяют выполняющие ваши задачи образы. 

container_definitions = templatefile(each.value["container_definition"], "${merge("${var.extra_template_variables}",
    {
      container_name        = each.value["family"],
      docker_image          = "${var.docker_image}:${var.docker_tag}",
      aws_logs_group        = "/aws/fargate/${aws_ecs_cluster.cluster.name}/${each.value["family"]}/${var.environment}",
      aws_log_stream_prefix = each.value["family"],
      aws_region            = var.region,
      container_port        = each.value["container_port"]
  })}")

Аргумент container_defintions получает объект JSON, где определено, какой образ docker запускать, а также дополнительные необходимые переменные среды.

Все это помещается в тот же динамический цикл for_each, что и остальная часть ресурса, а затем совмещается с extra_template_variables в объекте JSON, позволяя динамическое обнаружение переменных среды. 

Передаваемый в модуль объект карты для задач очень похож на объект сервиса:

"azure_ecs_tasks": {
    "prod-task-one": {
        "family": "prod-task-one",
        "container_definition": "./templates/task-definition-one.json",
        "cpu": "1024",
        "memory": "4096",
        "container_port": "redacted"
    },
    "prod-task-two": {
        "family": "prod-task-two",
        "container_definition": "./templates/task-definition-two.json",
        "cpu": "1024",
        "memory": "4096",
        "container_port": "redacted"
    }
}

Опять же здесь легко внедрить новую задачу и отобразить ее обратно на соответствующий сервис (имя семейства), просто добавив дополнительный элемент. 

Объект карты для задач определяется в переменных схожим с динамическими сервисами образом:

variable "ecs_tasks" {
  description = "Map of variables to define an ECS task."
  type = map(object({
    family               = string
    container_definition = string
    cpu                  = string
    memory               = string
    container_port       = string
  }))
}

3. Динамические логи

Вы могли заметить, что блок ресурса, определяющий задачи, содержит аргумент log_group.

aws_logs_group        = "/aws/fargate/${aws_ecs_cluster.cluster.name}/${each.value["family"]}/${var.environment}",

Здесь указывается, куда должны отправляться логи для соответствующей задачи. Чтобы гарантировать правильную настройку этих групп логов, нужно определить динамический ресурс Cloudwatch:

resource "aws_cloudwatch_log_group" "cw" {
  name              = "/aws/fargate/${aws_ecs_cluster.cluster.name}/${var.environment}"
  retention_in_days = var.cw_logs_retention
  tags = merge({
    "name"        = "${aws_ecs_cluster.cluster.name}-${var.environment}"
    "description" = "Task definition for ${aws_ecs_cluster.cluster.name}"
    }, var.tags
  )
}

Пока имя этих ресурсов будет соответствовать значению ключа aws_log_group внутри определения задач, мы будем получать логи в Cloudwatch.

4. IAM

Если для вас это полностью новый этап настройки, то в реализуемом нами решении понадобится определить две новых роли IAM. Ими являются task role и execution role динамических задач. 

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

Данные (policy):

data "aws_iam_policy_document" "ecs_task_policy" {
  statement {
    effect = "Allow"

    principals {
      type        = "Service"
      identifiers = ["ecs-tasks.amazonaws.com"]
    }

    actions = ["sts:AssumeRole"]
  }
}

Ресурс (role):

resource "aws_iam_role" "ecs_task_role" {
  name = "${var.environment}-ecs-task-role"

  assume_role_policy   = data.aws_iam_policy_document.ecs_task_policy.json
  permissions_boundary = "arn:aws:iam::<account>:policy/<policy>"
  tags = merge({
    "name" = "${var.environment}"
    }, var.tags
  )
}

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

Execution role устанавливает доступ для агента контейнера ECS и демона Docker:

resource "aws_iam_role" "ecs_execution_role" {
  name                 = "${var.environment}-exec-task-role"
  assume_role_policy   = data.aws_iam_policy_document.ecs_task_policy.json
  permissions_boundary = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:policy/ccoe/developer"
  tags = merge({
    "name" = "${var.environment}"
    }, var.tags
  )
}

Этой роли мы дали те же разрешения, что и предыдущей, с помощью того же объекта данных (ecs_task_policy).

Заключение

Рассмотренный модуль позволил моей команде ускоренно развертывать новые задачи и сервисы ECS, не требуя ручного изменения всех настроек через GUI.

Сам модуль активно задействует аргумент for_each внутри Terraform для масштабирования по мере необходимости. Такая структура оказывается очень полезна при реализации широкомасштабных решений Terraform.

Читайте также:

Читайте нас в Telegram, VK и Яндекс.Дзен


Перевод статьи Liam Hartley: How To Create a Dynamic ECS Cluster With Terraform