/terraform-aws-ecs-service

ecs-fargate-service is used to create an ecs service and the corresponding codedeploy, log groups, codepipeline artifacts, etc. It is intended to be used with StratusGrid's multi-account ecs pipeline module to allow for container images to be passed immutably from cluster to cluster in different environments and accounts in a single contiguous pipeline.

Primary LanguageHCLApache License 2.0Apache-2.0

Contact Us | Stratusphere FinOps | StratusGrid Home | Blog

terraform-aws-ecs-service

GitHub: StratusGrid/terraform-aws-ecs-service

ecs-service is used to create an ecs service and the corresponding codedeploy, log groups, codepipeline artifacts, etc. It is intended to be used with StratusGrid's multi-account ecs pipeline module to allow for container images to be passed immutably from cluster to cluster in different environments and accounts in a single contiguous pipeline.

For this purpose, ecs-service outputs a map which can be used to provide configuration for an environment stage when provisioning the pipeline.

Examples

Example use of the module:

locals {
  ecs_cluster_name      = "my-ecs-cluster-dev"
  ecs_service_name      = "my-ecs-service-dev"
  ecs_service_log_group = "/ecs/${local.ecs_cluster_name}/${local.ecs_service_name}"
}

resource "aws_lambda_function" "test_lambda" {
  filename      = "lambda_function_payload.zip"
  function_name = "lambda_function_name"
  role          = aws_iam_role.iam_for_lambda.arn
  handler       = "index.test"

  source_code_hash = filebase64sha256("lambda_function_payload.zip")

  runtime = "nodejs12.x"

  environment {
    variables = {
      foo = "bar"
    }
  }
}

module "service_alb_sg" {
  source  = "registry.terraform.io/terraform-aws-modules/security-group/aws"
  version = "~>4.0"

  name            = "my-ecs-alb-sg-dev"
  use_name_prefix = false
  description     = "Security group to allow inbound traffic to the service load balancer."
  vpc_id          = data.aws_vpc.this.id

  ingress_cidr_blocks = ["192.168.0.0/16"]
  egress_rules = ["all-all"]
  ingress_with_cidr_blocks = [
    {
      from_port   = 80
      to_port     = 80
      protocol    = "tcp"
      description = "Allow access to service port."
    },
  ]
}

module "service_alb" {
  source  = "registry.terraform.io/terraform-aws-modules/alb/aws"
  version = "~> 6.0"

  name = "my-alb-dev"

  enable_deletion_protection = true
  load_balancer_type = "application"
  internal           = true
  vpc_id             = data.aws_vpc.this.id
  subnets            = data.aws_subnet_ids.private_subnets.ids
  security_groups    = [module.service_alb_sg.security_group_id]

  # Configure logging bucket if it is enabled
  access_logs = {
    bucket = "my-general-logging-bucket"
    prefix = "alb"
  }

  target_groups = [
    {
      name             = "my-tg-blue-dev"
      backend_protocol = "HTTP"
      protocol_version = "HTTP1"
      backend_port     = 80
      target_type      = "ip"

      health_check = {
        enabled             = true
        interval            = 30
        path                = "/heartbeat"
        port                = 80
        protocol            = "HTTP"
        healthy_threshold   = 3
        unhealthy_threshold = 3
        timeout             = 5
      }
    },
    {
      name             = "my-tg-green-dev"
      backend_protocol = "HTTP"
      protocol_version = "HTTP1"
      backend_port     = 80
      target_type      = "ip"

      health_check = {
        enabled             = true
        interval            = 30
        path                = "/heartbeat"
        port                = 80
        protocol            = "HTTP"
        healthy_threshold   = 3
        unhealthy_threshold = 3
        timeout             = 5
      }
    }
  ]

  tags = merge(local.common_tags, {})
}

resource "aws_alb_listener" "service_alb_listener_blue" {
  load_balancer_arn = module.service_alb.lb_arn
  port              = 80
  protocol          = "HTTP"

  default_action {
    type             = "forward"
    target_group_arn = module.service_alb.target_group_arns[0]
  }

  lifecycle {
    ignore_changes = [
      default_action
    ]
  }
}

resource "aws_alb_listener" "service_alb_listener_green" {
  load_balancer_arn = module.service_alb.lb_arn
  port              = 8080
  protocol          = "HTTP"

  default_action {
    type             = "forward"
    target_group_arn = module.service_alb.target_group_arns[1]
  }

  lifecycle {
    ignore_changes = [
      default_action
    ]
  }
}

resource "aws_ecs_cluster" "this" {
  name               = local.ecs_cluster_name
  tags               = merge(local.common_tags, {})
  capacity_providers = ["FARGATE", "FARGATE_SPOT"]

  setting {
    name  = "containerInsights"
    value = "enabled"
  }
}

resource "aws_ecs_cluster_capacity_providers" "this" {
  cluster_name = aws_ecs_cluster.this.name
  capacity_providers = ["FARGATE", "FARGATE_SPOT"]
  default_capacity_provider_strategy {
    base              = 1
    weight            = 100
    capacity_provider = "FARGATE"
  }
}

module "ecs_service_sg" {
  source  = "registry.terraform.io/terraform-aws-modules/security-group/aws"
  version = "~> 4.3"

  name        = "my-ecs-service-sg-dev"
  description = "SG for the ECS Service"
  vpc_id      = data.aws_vpc.this.id

  egress_rules = ["all-all"]
  ingress_with_source_security_group_id = [
    {
      rule                     = "all-all"
      description              = "Allow all traffic from ALB."
      source_security_group_id = module.allowed_sg.security_group_id
    }
  ]
}

resource "aws_appautoscaling_target" "ecs_service" {
  resource_id        = "service/${local.ecs_cluster_name}/${local.ecs_service_name}"
  scalable_dimension = "ecs:service:DesiredCount"
  service_namespace  = "ecs"
  min_capacity       = 1
  max_capacity       = 10
  depends_on         = [module.ecs_fargate_service]
}

resource "aws_appautoscaling_policy" "ecs_service" {
  name               = "my-service-autoscaling-policy-dev"
  policy_type        = "TargetTrackingScaling"
  resource_id        = aws_appautoscaling_target.ecs_service.resource_id
  scalable_dimension = aws_appautoscaling_target.ecs_service.scalable_dimension
  service_namespace  = aws_appautoscaling_target.ecs_service.service_namespace

  target_tracking_scaling_policy_configuration {
    disable_scale_in   = false
    scale_in_cooldown  = 300
    scale_out_cooldown = 300
    target_value       = 60

    predefined_metric_specification {
      predefined_metric_type = "ECSServiceAverageCPUUtilization"
    }
  }
}

locals {
  taskdef_cpu                  = 512
  taskdef_memory               = 1024
  container_cpu                = 512
  container_memory_reservation = 512
  container_memory             = 1024
}

module "ecs_fargate_service" {
  source  = "registry.terraform.io/StratusGrid/ecs-service/aws"
  version = #insert version here

  input_tags       = merge(local.common_tags, {})
  ecs_cluster_name = aws_ecs_cluster.this.name
  service_name     = local.ecs_service_name
  taskdef_family   = local.ecs_service_name
  platform_version = "1.4.0"
  log_group_path   = local.ecs_service_log_group

  codedeploy_termination_wait_time         = 5
  codedeploy_deployment_configuration_name = "CodeDeployDefault.ECSLinear10PercentEvery1Minutes"

  desired_count  = 1
  taskdef_cpu    = local.taskdef_cpu
  taskdef_memory = local.taskdef_memory

  trusted_account_numbers = ["987654321098"]

  subnets         = data.aws_subnet_ids.private_subnets.ids
  security_groups = [module.ecs_service_sg.security_group_id]

  #NOTE: ALBs are not created by the module.
  health_check_grace_period_seconds = 60
  lb_listener_prod_arn              = aws_alb_listener.service_alb_listener_blue.arn
  lb_listener_test_arn              = aws_alb_listener.service_alb_listener_green.arn
  lb_target_group_blue_arn          = module.service_alb.target_group_arns[0]
  lb_target_group_blue_name         = module.service_alb.target_group_names[0]
  lb_target_group_green_name        = module.service_alb.target_group_names[1]
  lb_container_name                 = "service" # has to match name in container definition within task_definition
  lb_container_port                 = 80        # has to match name in container definition within task_definition

  codepipeline_source_bucket_id          = "my-source-bucket-name"
  codepipeline_source_bucket_kms_key_arn = "arn:aws:kms:us-east-1:123456789012:key/79bcff61-8df7-482f-81a8-fd133015758d"
  codepipeline_source_object_key         = "deployment/ecs/service-artifacts.zip"

  # Include Lambdas by name to invoke as deployment hooks in the appspec as required
  appspec_hook_after_install = aws_lambda_function.test_lambda.function_name

  taskdef_execution_role_arn = module.ecs_service_iam_role.iam_role_arn
  taskdef_task_role_arn      = module.ecs_service_iam_role.iam_role_arn

  # This is just an initial definition, not codedeploy
  ### This is only needed because you can't put <> in the image field
  initialization_container_definitions = <<EOF
    [
      {
        "name": "service",
        "image": "IMAGE1_NAME",
        "portMappings": [
          {
            "hostPort": 80,
            "protocol": "tcp",
            "containerPort": 80
          }
        ]
      }
    ]
    EOF

  codepipeline_container_definitions   = <<EOF
    [
      {
        "name": "service",
        "image": "<IMAGE1_NAME>",
        "cpu": ${local.container_cpu},
        "memory": ${local.container_memory},
        "memoryReservation": ${local.container_memory_reservation},
        "essential": true,
    		"logConfiguration": {
          "logDriver": "awslogs",
          "secretOptions": null,
          "options": {
            "awslogs-group": "${local.ecs_service_log_group}",
            "awslogs-region": "${data.aws_region.current.name}",
            "awslogs-stream-prefix": "ecs"
          }
        },
        "secrets": [
        ],
        "portMappings": [
          {
            "hostPort": 80,
            "protocol": "tcp",
            "containerPort": 80
          }
        ]
      }
     ]
    EOF
}

Example Outputs which can feed CodePipeline Module:

codepipeline_variables = {
  "artifact_appspec_file_name"       = "appspec.yaml"
  "artifact_bucket"                  = "my-bucket-name"
  "artifact_key"                     = "deployment/ecs/my-service-artifacts.zip"
  "artifact_kms_key_arn"             = "arn:aws:kms:us-east-1:335895905019:key/5fc4e28f-44f1-6f00-b3e8-142fbd61390c"
  "artifact_taskdef_file_name"       = "taskdef.json"
  "aws_account_number"               = "123456789012"
  "codedeploy_deployment_app_arn"    = "arn:aws:codedeploy:us-east-1:123456789012:application:my-service-name"
  "codedeploy_deployment_app_name"   = "my-service-name"
  "codedeploy_deployment_group_arn"  = "arn:aws:codedeploy:us-east-1:123456789012:deploymentgroup:my-service-name/my-service-name"
  "codedeploy_deployment_group_name" = "my-service-name"
  "trusting_account_role"            = "arn:aws:iam::123456789012:role/my-service-name-cicd"
}

Resources

Name Type
aws_cloudwatch_log_group.this resource
aws_codedeploy_app.this resource
aws_codedeploy_deployment_group.this resource
aws_ecs_service.this resource
aws_ecs_task_definition.this resource
aws_iam_role.cicd_account_role resource
aws_iam_role.this_codedeploy resource
aws_iam_role_policy.cicd_account_codedeploy_policy resource
aws_iam_role_policy.this_codedeploy resource
aws_iam_role_policy_attachment.codedeploy_role_additional_policies resource
aws_s3_object.artifacts_s3 resource

Inputs

Name Description Type Default Required
appspec_hook_after_allow_test_traffic Name of the Lambda function to invoke during the AfterAllowTestTraffic application deployment hook string "" no
appspec_hook_after_allow_traffic Name of the Lambda function to invoke during the AfterAllowTraffic deployment hook string "" no
appspec_hook_after_install Name of the Lambda function to invoke during the AfterInstall application deployment hook string "" no
appspec_hook_before_allow_traffic Name of the Lambda function to invoke during the BeforeAllowTraffic deployment hook string "" no
appspec_hook_before_install Name of the Lambda function to invoke during the BeforeInstall application deployment hook string "" no
assign_public_ip Boolean to indicate whether to assign public IPs to task network interfaces bool false no
codedeploy_auto_rollback_enabled Boolean to determine whether CodeDeploy should automatically roll back when a rollback event is triggered bool true no
codedeploy_auto_rollback_events CodeDeploy rollback events which will trigger an automatic rollback list(string)
[
"DEPLOYMENT_FAILURE",
"DEPLOYMENT_STOP_ON_ALARM",
"DEPLOYMENT_STOP_ON_REQUEST"
]
no
codedeploy_deployment_configuration_name CodeDeploy predefined deployment configuration name. See https://docs.aws.amazon.com/codedeploy/latest/userguide/deployment-configurations.html for valid predefined configurations. string "CodeDeployDefault.ECSAllAtOnce" no
codedeploy_role_additional_policies Map of additional policies to attach to the CodeDeploy role. Should be formatted as {key = arn} map(string) {} no
codedeploy_termination_wait_time Wait time in seconds for CodeDeploy to wait before terminating previous production tasks after redirecting traffic to the new tasks number 300 no
codepipeline_container_definitions This is the template container definition which CodePipeline will interpolate and deploy the service with CodeDeploy. string n/a yes
codepipeline_source_bucket_id S3 bucket where the output artifact zip should be placed (appspec and task definition) to be pulled into pipeline as a source. This bucket should be the same for all services which are deployed from a single contiguous CodePipeline because CodePipeline needs a single bucket to use for all artifacts across all Actions. Must be reachable by principal applying TF and the CodeDeploy Group role. string n/a yes
codepipeline_source_bucket_kms_key_arn ARN of the KMS key used to encrypt objects in the bucket used to store and retrieve artifacts for the codepipeline. This KMS key should be the same for all services which are deployed from a single contiguous CodePipeline because CodePipeline needs a single KMS key to use for all artifacts across all Actions. If referencing the aws_kms_key resource, use the arn attribute. If referencing the aws_kms_alias data source or resource, use the target_key_arn attribute. string n/a yes
codepipeline_source_object_key Key for zip file inside of S3 bucket whhich CodePipeline pulls in as a source stage. Must be reachable by principal applying TF and the CodeDeploy Group role. string n/a yes
custom_capacity_provider_strategy Map to define the custom capacity provider strategy for the service. This would be used to utilize Fargate Spot for instance. map(string) {} no
desired_count Number of tasks to run before autoscaling changes number 2 no
ecs_cluster_name Name of the ECS cluster to deploy the service to string n/a yes
enable_execute_command Enable ecs container exec for container cli access bool true no
health_check_grace_period_seconds Number of seconds before a failing healthcheck on a new ecs task will kill the task number 60 no
initialization_container_definitions This is the placeholder container definition that the cluster will be provisioned with. It does not need to be working and will be replaced on the first CodeDeploy execution. string n/a yes
input_tags Map of tags to apply to resources map(string)
{
"Developer": "StratusGrid",
"Provisioner": "Terraform"
}
no
lb_container_name Name of container in the task's container definition which is attached to the load balancer string n/a yes
lb_container_port Exposed container port, must match the task's container definition and will be attached to the load balancer number n/a yes
lb_listener_prod_arn CodeDeploy group production traffic listener string n/a yes
lb_listener_test_arn CodeDeploy group test traffic listener string n/a yes
lb_target_group_blue_arn ARN of target group to be used as blue in CodeDeploy deployment style string n/a yes
lb_target_group_blue_name Name of target group to be used as blue in CodeDeploy deployment style string n/a yes
lb_target_group_green_name ARN of target group to be used as green in CodeDeploy deployment style string n/a yes
log_group_path Cloudwatch log group path string n/a yes
log_retention_days Number of days CloudWatch Log Group should retain logs from this service for number 30 no
platform_version ECS platform version to use string "1.4.0" no
propagate_tags Setting to determine where to replicate tags to string "TASK_DEFINITION" no
security_groups Security groups to attach to task network interfaces list(string) n/a yes
service_name Name of ECS Service string n/a yes
service_registries Service discovery registries to attach to the service. AWS currently only supports a single registry. map(string) {} no
subnets Subnets to attach task network interfaces to list(string) n/a yes
taskdef_cpu CPU units to allocate to the task number n/a yes
taskdef_execution_role_arn Execution role for ECS to use when provisioning the tasks. Used for things like pulling ecr images, emitting logs, getting secrets to inject, etc. string n/a yes
taskdef_family Task Definition name which is then versioned. Should match the service name. string n/a yes
taskdef_memory MB of memory to allocate to the task number n/a yes
taskdef_network_mode Network mode for task network interfaces, should always be awsvpc for Fargate string "awsvpc" no
taskdef_requires_compatibilities ECS compatibilities to help determine task placement list(string)
[
"FARGATE"
]
no
taskdef_task_role_arn Role attached to ECS tasks to give them access to resources string n/a yes
trusted_account_numbers List of 12-digit AWS account numbers which can assume the IAM Role which has rights to trigger the CodeDeploy Deployment. This can be used to allow the CodeDeploy to be triggered from another account(s). String type for use in IAM policy. list(string) n/a yes
use_custom_capacity_provider_strategy Boolean to enable a custom capacity provider strategy for the ecs service. This would be used to utilize Fargate Spot for instance. bool false no

Outputs

Name Description
codepipeline_variables Map for values needed for CodePipeline to do deploys on this service

Contributors

Ideas for future enhancements

  • Potentially make the kms key optional to better support same account options with less inputs?
  • Have the iam-cicd-account iam resources be optional and default to not creating via count
  • Move autoscaling into the module. To add autoscaling to module, I would:
    • Move the appautoscaling target and policy into the module
    • Have two policies which it selects based off of a string or didn't do if set to false (or left blank?) on autoscaling
  • Put the initialization container definition into the module by making it an optional variable which has a local with the config so it matches ports and then coalesces the value
  • Add in other codedeploy strategies?

Note, manual changes to the README will be overwritten when the documentation is updated. To update the documentation, run terraform-docs -c .config/.terraform-docs.yml .