Skip to content
Aposente o SSH no ECS: Session Manager e ECS Exec com Terraform

Aposente o SSH no ECS: Session Manager e ECS Exec com Terraform

Acesse o shell dos hosts ECS e abra um Rails console dentro dos containers sem abrir nenhuma porta de entrada, sem distribuir chaves SSH e sem perder a trilha de auditoria.

Publicado em 7 min de leitura

Também disponível em English

Compartilhar

A maioria dos setups de ECS em EC2 que já vi ainda carrega algum legado de SSH: um key pair no launch template, a porta 22 aberta para um bastion e ninguém sabendo direito quem ainda tem a chave privada.

O Session Manager do AWS Systems Manager (SSM) substitui tudo isso por sessões controladas por IAM e registradas em log, sobre um canal apenas de saída. O ECS Exec usa a mesma base para abrir um shell dentro de um container em execução, que é exatamente o que você quer quando precisa de um rails console em staging.

Este post mostra como habilitar os dois com Terraform, incluindo as pegadinhas que encontrei pelo caminho. Todos os nomes, IDs e regiões abaixo são placeholders.

Duas coisas diferentes, a mesma base

  • Session Manager no host: shell na instância EC2 do cluster. Exige o SSM Agent no host e permissões na instance role.
  • ECS Exec: shell em um container específico de uma task em execução. Exige permissões na task role, enable_execute_command no service e, opcionalmente, logging configurado no cluster.

Ambos precisam de HTTPS de saída para os endpoints do SSM. Você pode habilitar cada um de forma independente.

Pré-requisitos

  • Instâncias do ECS rodando a AMI ECS-optimized Amazon Linux 2023 (ou AL2). O SSM Agent já vem instalado e habilitado.
  • Saída na porta 443 das instâncias e tasks para ssm, ssmmessages e ec2messages, via NAT ou VPC interface endpoints.
  • AWS CLI v2 e o plugin do Session Manager na sua máquina.

Passo 1: Dar acesso ao SSM para a instance role

Uma única managed policy basta para o agent se registrar:

resource "aws_iam_role_policy_attachment" "ecs_instance_ssm" {
  role       = aws_iam_role.ecs_instance.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}

Se as instance roles são criadas dentro de um módulo por capacity group (high-memory, general purpose etc.), coloque o attachment dentro do módulo para que todos os grupos recebam.

Verifique também se a role usa managed_policy_arns ou aws_iam_role_policy_attachments_exclusive. Se usar, adicione o ARN nessa lista, senão os dois vão brigar a cada apply.

Não precisa reiniciar. O agent tenta o registro de novo sozinho e normalmente aparece em poucos minutos.

Passo 2: Subnets privadas precisam de VPC endpoints

Se as instâncias ou tasks não têm NAT, adicione interface endpoints:

locals {
  ssm_services = ["ssm", "ssmmessages", "ec2messages"]
}

resource "aws_vpc_endpoint" "ssm" {
  for_each            = toset(local.ssm_services)
  vpc_id              = var.vpc_id
  service_name        = "com.amazonaws.${var.region}.${each.key}"
  vpc_endpoint_type   = "Interface"
  subnet_ids          = var.private_subnet_ids
  security_group_ids  = [aws_security_group.vpce.id]
  private_dns_enabled = true
}

O security group do endpoint precisa liberar entrada na 443 a partir dos security groups das instâncias e das tasks. Se você grava sessões no CloudWatch ou criptografa com KMS, adicione também os endpoints logs e kms.

Passo 3: Permissões na task role para o ECS Exec

O SSM agent dentro do container usa a task role, não a instance role:

data "aws_iam_policy_document" "ecs_exec" {
  statement {
    actions = [
      "ssmmessages:CreateControlChannel",
      "ssmmessages:CreateDataChannel",
      "ssmmessages:OpenControlChannel",
      "ssmmessages:OpenDataChannel",
    ]
    resources = ["*"]
  }

  statement {
    actions = [
      "logs:DescribeLogGroups",
      "logs:CreateLogStream",
      "logs:DescribeLogStreams",
      "logs:PutLogEvents",
    ]
    resources = ["*"]
  }
}

resource "aws_iam_role_policy" "ecs_exec" {
  name   = "ecs-exec"
  role   = aws_iam_role.task.id
  policy = data.aws_iam_policy_document.ecs_exec.json
}

Na vida real, restrinja o statement de logs ao ARN do log group de exec. Se o log group for criptografado com KMS, adicione kms:Decrypt nessa chave.

Passo 4: Logging no cluster e a flag no service

resource "aws_cloudwatch_log_group" "ecs_exec" {
  name              = "ecs-execute-command-${var.environment}"
  retention_in_days = 90
}

resource "aws_ecs_cluster" "this" {
  name = var.cluster_name

  configuration {
    execute_command_configuration {
      logging = "OVERRIDE"

      log_configuration {
        cloud_watch_log_group_name = aws_cloudwatch_log_group.ecs_exec.name
      }
    }
  }
}

resource "aws_ecs_service" "console" {
  name                   = "rails-console-${var.environment}"
  cluster                = aws_ecs_cluster.this.id
  task_definition        = aws_ecs_task_definition.console.arn
  enable_execute_command = true
  # ...
}

Importante: enable_execute_command só vale para tasks iniciadas depois da mudança. As tasks existentes continuam rodando sem ele. Force um novo deployment assim que o apply terminar.

Passo 5: Acesso para pessoas

Associe esta policy a um grupo IAM (por exemplo staging-operators), não a usuários individuais:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["ecs:ExecuteCommand", "ecs:DescribeTasks", "ecs:ListTasks"],
      "Resource": [
        "arn:aws:ecs:<REGION>:<ACCOUNT_ID>:cluster/staging",
        "arn:aws:ecs:<REGION>:<ACCOUNT_ID>:task/staging/*"
      ]
    },
    {
      "Effect": "Allow",
      "Action": "ssm:StartSession",
      "Resource": [
        "arn:aws:ec2:<REGION>:<ACCOUNT_ID>:instance/*",
        "arn:aws:ssm:<REGION>:<ACCOUNT_ID>:document/SSM-SessionManagerRunShell"
      ],
      "Condition": {
        "StringEquals": { "ssm:resourceTag/Environment": "staging" }
      }
    },
    {
      "Effect": "Allow",
      "Action": ["ssm:TerminateSession", "ssm:ResumeSession"],
      "Resource": "arn:aws:ssm:*:<ACCOUNT_ID>:session/${aws:username}-*"
    }
  ]
}

Antes de adicionar qualquer coisa, confira o que você já tem:

aws iam simulate-principal-policy \
  --policy-source-arn arn:aws:iam::<ACCOUNT_ID>:user/<YOUR_USER> \
  --action-names ecs:ExecuteCommand ssm:StartSession \
  --query 'EvaluationResults[].[EvalActionName,EvalDecision]' --output table

Passo 6: Suba pelo CI, não pelo seu laptop

Se o time roda Terraform só via pipeline (e deveria), valide localmente sem tocar no state:

terraform fmt -recursive
terraform init -backend=false
terraform validate

Depois abra um merge request e leia o plan com atenção. Você deve ver apenas as suas adições. Qualquer outra coisa é drift de mudanças manuais: sinalize em vez de aplicar.

Pegadinha: "log group already exists"

Se alguém já tinha habilitado o logging de exec pelo console, o apply falha com ResourceAlreadyExistsException. Não apague o grupo. Importe com um bloco import (Terraform 1.5+) para que o pipeline passe a gerenciá-lo:

import {
  to = aws_cloudwatch_log_group.ecs_exec
  id = "ecs-execute-command-staging"
}

O esperado é 1 to import, 0 to add, 0 to change. Remova o bloco em um MR seguinte. Atenção: blocos de import valem para todo workspace que usa o root module, então confira os outros ambientes antes.

Passo 7: Conectando

Instale o lado cliente no macOS:

brew install awscli
brew install --cask session-manager-plugin

Confirme que os hosts estão registrados:

aws ssm describe-instance-information \
  --query 'InstanceInformationList[].[InstanceId,PingStatus,AgentVersion]' \
  --output table

Shell em um host:

aws ssm start-session --target <INSTANCE_ID>

Shell em um container (olá, rails console):

TASK=$(aws ecs list-tasks --cluster staging \
  --service-name rails-console-staging \
  --query 'taskArns[0]' --output text)

aws ecs execute-command --cluster staging --task "$TASK" \
  --container rails-console-staging --interactive \
  --command "/bin/bash"

Troubleshooting

ErroCausa provável
execute command was not enabled when the task was runA task é anterior ao enable_execute_command. Force um novo deployment.
TargetNotConnectedExceptionTask role sem ssmmessages:*, configuração de logging de exec quebrada (log group ou permissões de log faltando), sem rota para ssmmessages, ou task antiga. Rode o script amazon-ecs-exec-checker da AWS.
Instância não aparece no describe-instance-informationInstance role sem AmazonSSMManagedInstanceCore, ou sem caminho de rede. Veja /var/log/amazon/ssm/amazon-ssm-agent.log.
Erro de regex InvalidInstanceId ao filtrar por IDsA saída em texto da CLI separa IDs com tab, e o zsh não faz word-splitting. Normalize com tr -s '[:space:]' ','.
SessionManagerPlugin is not foundPlugin fora do PATH. Reinstale ou abra um novo shell.
Os recursos existem mas a CLI não enxergaRegião errada. Confira AWS_REGION e o seu profile.

Antes de levar para produção

  • Ative o logging de sessões e confirme que os streams estão chegando de fato. Acesso a shell em produção precisa ser auditável.
  • Grupo IAM separado para produção, de preferência atrás de SSO com MFA.
  • Restrinja o exec por nome de container com a condition ecs:container-name, limitando o acesso aos containers de console.
  • Rode os containers com um USER não root. O ECS Exec te coloca como o usuário com que o container roda.
  • Bloqueie o acesso dos containers às credenciais IMDS do host (bloqueio de IMDS no awsvpc ou hop limit 1), para que as tasks não peguem carona nas permissões de SSM da instance role.
  • Remova a porta 22 dos security groups e o key pair do launch template. Essa é a recompensa.
  • Mantenha o SSM Agent atualizado: uma association do State Manager com AWS-UpdateSSMAgent, ou refresh regular das AMIs.

Conclusão

A mudança inteira é um punhado de statements IAM, três VPC endpoints, um bloco no cluster e uma flag no service. O tempo se perde em outros lugares: tasks anteriores à flag, log groups criados à mão e manias do shell. Codifique uma vez em um módulo, suba pelo CI e você nunca mais vai precisar distribuir uma chave SSH.

Comentários

Faça login com GitHub para comentar.

Ainda não há comentários.