Skip to content

container-definition module not usable on its own #147

@giom-l

Description

@giom-l

Description

This is kinda a reopening of #122.
I also hit the same issues and will try to explain as best as I can.

First thing first, the module is working perfectly fine.
For example (a very simplified example)

variable "subnet_ids" {
  type = list(string)
}

locals {
  name = "test-ecs-module"
  tags = {
    Env     = "test"
    Project = "ecs-module"
  }
}

module "cluster" {
  source = "terraform-aws-modules/ecs/aws//modules/cluster"

  cluster_name = local.name

  fargate_capacity_providers = {
    FARGATE = {
      default_capacity_provider_strategy = {
        weight = 100
      }
    }
  }
  tags = local.tags
}


module "nginx" {
  source                   = "terraform-aws-modules/ecs/aws//modules/container-definition"
  version                  = "5.7.3"
  name                     = local.name
  service                  = local.name
  essential                = true
  readonly_root_filesystem = false
  image                    = "public.ecr.aws/nginx/nginx:1.25.3"
  mount_points = [
    {
      containerPath = "/conf/"
      sourceVolume  = "conf"
      readOnly      = true
    }
  ]
  port_mappings = [
    {
      containerPort = 80
      hostPort      = 80
      protocol      = "tcp"
    }
  ]
  enable_cloudwatch_logging = true
  create_cloudwatch_log_group = false
}

module "service" {
  source      = "terraform-aws-modules/ecs/aws//modules/service"
  version     = "5.7.3"
  name        = local.name
  cluster_arn = module.cluster.arn

  cpu           = 256
  memory        = 512
  desired_count = 1
  launch_type   = "FARGATE"

  create_task_exec_iam_role = true
  create_tasks_iam_role     = true

  create_security_group = true
  security_group_rules = [
    {
      description = "Allow egress"
      type        = "egress"
      protocol    = "all"
      from_port   = 0
      to_port     = 65535
      cidr_blocks = ["0.0.0.0/0"]
    }
  ]
  subnet_ids       = var.subnet_ids
  network_mode     = "awsvpc"
  assign_public_ip = false

  container_definitions = {
    (local.name) = {
     essential                = true
     readonly_root_filesystem = false
     image                    = "public.ecr.aws/nginx/nginx:1.25.3"
     mount_points = [
       {
         containerPath = "/conf/"
         sourceVolume  = "conf"
         readOnly      = true
       }
    ]
     port_mappings = [
       {
         containerPort = 80
         hostPort      = 80
         protocol      = "tcp"
       }
     ]
     enable_cloudwatch_logging = true
   }
  }

  volume = [
    {
      name : "conf"
    }
  ]

  enable_autoscaling             = false
  ignore_task_definition_changes = false
  tags                           = local.tags
  propagate_tags                 = "TASK_DEFINITION"
}

This works.
However, in some of our services, we have up to 5 containers.
Each one of them may have its own port mappings, own volumes, own commands and so.
Having that within only one resource is extremely hard to read and maintain.

On our current stack, we split each container definition in its own module, and the service only refers to all container definitions.
This way, one can better identify the resource, its properties and so on.

Using the current module, it would give something like this :

variable "subnet_ids" {
  type = list(string)
}

locals {
  name = "test-ecs-module"
  tags = {
    Env     = "test"
    Project = "ecs-module"
  }
}

module "cluster" {
  source = "terraform-aws-modules/ecs/aws//modules/cluster"

  cluster_name = local.name

  fargate_capacity_providers = {
    FARGATE = {
      default_capacity_provider_strategy = {
        weight = 100
      }
    }
  }
  tags = local.tags
}


module "nginx" {
  source                   = "terraform-aws-modules/ecs/aws//modules/container-definition"
  version                  = "5.7.3"
  name                     = local.name
  service                  = local.name
  essential                = true
  readonly_root_filesystem = false
  image                    = "public.ecr.aws/nginx/nginx:1.25.3"
  mount_points = [
    {
      containerPath = "/conf/"
      sourceVolume  = "conf"
      readOnly      = true
    }
  ]
  port_mappings = [
    {
      containerPort = 80
      hostPort      = 80
      protocol      = "tcp"
    }
  ]
  enable_cloudwatch_logging = true
  create_cloudwatch_log_group = false
}

module "service" {
  source      = "terraform-aws-modules/ecs/aws//modules/service"
  version     = "5.7.3"
  name        = local.name
  cluster_arn = module.cluster.arn

  cpu           = 256
  memory        = 512
  desired_count = 1
  launch_type   = "FARGATE"

  create_task_exec_iam_role = true
  create_tasks_iam_role     = true

  create_security_group = true
  security_group_rules = [
    {
      description = "Allow egress"
      type        = "egress"
      protocol    = "all"
      from_port   = 0
      to_port     = 65535
      cidr_blocks = ["0.0.0.0/0"]
    }
  ]
  subnet_ids       = var.subnet_ids
  network_mode     = "awsvpc"
  assign_public_ip = false

  container_definitions = {
    (local.name) = module.nginx.container_definition
  }

  volume = [
    {
      name : "conf"
    }
  ]

  enable_autoscaling             = false
  ignore_task_definition_changes = false
  tags                           = local.tags
  propagate_tags                 = "TASK_DEFINITION"
}

However, this does not work because of the case used in container-definition outputs for fields composed by multiple words.
Hence, instead of having portMappings, it should return the proper property name port_mappings

I made it work by modifying the local module
from

locals {
  is_not_windows = contains(["LINUX"], var.operating_system_family)

  log_group_name = "/aws/ecs/${var.service}/${var.name}"

  log_configuration = merge(
    { for k, v in {
      logDriver = "awslogs",
      options = {
        awslogs-region        = data.aws_region.current.name,
        awslogs-group         = try(aws_cloudwatch_log_group.this[0].name, ""),
        awslogs-stream-prefix = "ecs"
      },
    } : k => v if var.enable_cloudwatch_logging },
    var.log_configuration
  )

  linux_parameters = var.enable_execute_command ? merge({ "initProcessEnabled" : true }, var.linux_parameters) : merge({ "initProcessEnabled" : false }, var.linux_parameters)

  definition = {
    command                = length(var.command) > 0 ? var.command : null
    cpu                    = var.cpu
    dependsOn              = length(var.dependencies) > 0 ? var.dependencies : null # depends_on is a reserved word
    disableNetworking      = local.is_not_windows ? var.disable_networking : null
    dnsSearchDomains       = local.is_not_windows && length(var.dns_search_domains) > 0 ? var.dns_search_domains : null
    dnsServers             = local.is_not_windows && length(var.dns_servers) > 0 ? var.dns_servers : null
    dockerLabels           = length(var.docker_labels) > 0 ? var.docker_labels : null
    dockerSecurityOptions  = length(var.docker_security_options) > 0 ? var.docker_security_options : null
    entrypoint             = length(var.entrypoint) > 0 ? var.entrypoint : null
    environment            = var.environment
    environmentFiles       = length(var.environment_files) > 0 ? var.environment_files : null
    essential              = var.essential
    extraHosts             = local.is_not_windows && length(var.extra_hosts) > 0 ? var.extra_hosts : null
    firelensConfiguration  = length(var.firelens_configuration) > 0 ? var.firelens_configuration : null
    healthCheck            = length(var.health_check) > 0 ? var.health_check : null
    hostname               = var.hostname
    image                  = var.image
    interactive            = var.interactive
    links                  = local.is_not_windows && length(var.links) > 0 ? var.links : null
    linuxParameters        = local.is_not_windows && length(local.linux_parameters) > 0 ? local.linux_parameters : null
    logConfiguration       = length(local.log_configuration) > 0 ? local.log_configuration : null
    memory                 = var.memory
    memoryReservation      = var.memory_reservation
    mountPoints            = var.mount_points
    name                   = var.name
    portMappings           = var.port_mappings
    privileged             = local.is_not_windows ? var.privileged : null
    pseudoTerminal         = var.pseudo_terminal
    readonlyRootFilesystem = local.is_not_windows ? var.readonly_root_filesystem : null
    repositoryCredentials  = length(var.repository_credentials) > 0 ? var.repository_credentials : null
    resourceRequirements   = length(var.resource_requirements) > 0 ? var.resource_requirements : null
    secrets                = length(var.secrets) > 0 ? var.secrets : null
    startTimeout           = var.start_timeout
    stopTimeout            = var.stop_timeout
    systemControls         = length(var.system_controls) > 0 ? var.system_controls : null
    ulimits                = local.is_not_windows && length(var.ulimits) > 0 ? var.ulimits : null
    user                   = local.is_not_windows ? var.user : null
    volumesFrom            = var.volumes_from
    workingDirectory       = var.working_directory
  }

  # Strip out all null values, ECS API will provide defaults in place of null/empty values
  container_definition = { for k, v in local.definition : k => v if v != null }
}

to

locals {
  is_not_windows = contains(["LINUX"], var.operating_system_family)

  log_group_name = "/aws/ecs/${var.service}/${var.name}"

  log_configuration = merge(
    { for k, v in {
      logDriver = "awslogs",
      options = {
        awslogs-region        = data.aws_region.current.name,
        awslogs-group         = try(aws_cloudwatch_log_group.this[0].name, ""),
        awslogs-stream-prefix = "ecs"
      },
    } : k => v if var.enable_cloudwatch_logging },
    var.log_configuration
  )

  linux_parameters = var.enable_execute_command ? merge({ "initProcessEnabled" : true }, var.linux_parameters) : merge({ "initProcessEnabled" : false }, var.linux_parameters)

  definition = {
    command                = length(var.command) > 0 ? var.command : null
    cpu                    = var.cpu
    depends_on              = length(var.dependencies) > 0 ? var.dependencies : null # depends_on is a reserved word
    disable_networking      = local.is_not_windows ? var.disable_networking : null
    dns_search_domains       = local.is_not_windows && length(var.dns_search_domains) > 0 ? var.dns_search_domains : null
    dns_servers             = local.is_not_windows && length(var.dns_servers) > 0 ? var.dns_servers : null
    docker_labels           = length(var.docker_labels) > 0 ? var.docker_labels : null
    docker_security_options  = length(var.docker_security_options) > 0 ? var.docker_security_options : null
    entrypoint             = length(var.entrypoint) > 0 ? var.entrypoint : null
    environment            = var.environment
    environment_diles       = length(var.environment_files) > 0 ? var.environment_files : null
    essential              = var.essential
    extra_hosts             = local.is_not_windows && length(var.extra_hosts) > 0 ? var.extra_hosts : null
    firelens_configuration  = length(var.firelens_configuration) > 0 ? var.firelens_configuration : null
    health_check            = length(var.health_check) > 0 ? var.health_check : null
    hostname               = var.hostname
    image                  = var.image
    interactive            = var.interactive
    links                  = local.is_not_windows && length(var.links) > 0 ? var.links : null
    linux_parameters        = local.is_not_windows && length(local.linux_parameters) > 0 ? local.linux_parameters : null
    log_configuration       = length(local.log_configuration) > 0 ? local.log_configuration : null
    memory                 = var.memory
    memory_reservation      = var.memory_reservation
    mount_points            = var.mount_points
    name                   = var.name
    port_mappings           = var.port_mappings
    privileged             = local.is_not_windows ? var.privileged : null
    pseudo_terminal         = var.pseudo_terminal
    readonly_root_filesystem = local.is_not_windows ? var.readonly_root_filesystem : null
    repository_credentials  = length(var.repository_credentials) > 0 ? var.repository_credentials : null
    resource_requirements   = length(var.resource_requirements) > 0 ? var.resource_requirements : null
    secrets                = length(var.secrets) > 0 ? var.secrets : null
    start_timeout           = var.start_timeout
    stop_timeout            = var.stop_timeout
    system_controls         = length(var.system_controls) > 0 ? var.system_controls : null
    ulimits                = local.is_not_windows && length(var.ulimits) > 0 ? var.ulimits : null
    user                   = local.is_not_windows ? var.user : null
    volumes_from            = var.volumes_from
    working_directory       = var.working_directory
  }

  # Strip out all null values, ECS API will provide defaults in place of null/empty values
  container_definition = { for k, v in local.definition : k => v if v != null }
}

Versions

  • Module version [Required]: 5.7.3
  • Terraform version: v1.6.5
  • Provider version(s): provider registry.terraform.io/hashicorp/aws v5.30.0

Reproduction Code [Required]

Provided above.

Steps to reproduce the behavior:
Just run
terraform init && terraform apply -subnet_ids='[<your own list>]'

Expected behavior

It would be better for the container-definition module to be usable on its own.

Actual behavior

We can only use the service module with all container definition inside it.
No split available.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions