ECS コンテナに VSCode Remote SSH で接続する

VSCode (および派生エディタ) では Remote - SSH という拡張機能が利用できます。これを利用すると、SSH で接続可能なホスト (たとえば EC2 インスタンス) に対して、そのホスト内で VSCode を起動して作業しているような開発体験が得られます。ECS で起動したサービスまたはタスクに対してもこの体験を得たいと考え、方法を検討しました。

要素技術 #

提案手法 #

  1. Task definition において init process を有効化しておきます。これは SSH サーバーをバックグラウンドプロセスとして稼働させるためです。
    resource "aws_ecs_task_definition" "this" {
      container_definitions = jsonencode([
        {
          linuxParameters = {
            initProcessEnabled = true
          }
          ...
        }
      ])
      ...
    }
    
  2. ECS Exec を有効化したタスクを起動しておきます。
    aws ecs run-task \
      --cluster YOUR_CLUSTER \
      --task-definition YOUR_TASK_DEFINITION \
      --enable-execute-command \
      ...
    
  3. SSH config において ProxyCommand に後述するシェルスクリプトを指定した構成を追加します。
    # ~/.ssh/config
    Host ecs-*
      StrictHostKeyChecking no
      UserKnownHostsFile /dev/null
    
    Host ecs-example
      IdentityFile ~/.ssh/YOUR_SSH_KEY
      User root
      ProxyCommand zsh ~/ecs-ssh-proxy.sh YOUR_CLUSTER YOUR_TASK_FAMILY YOUR_CONTAINER_NAME ~/.ssh/YOUR_SSH_KEY.pub
    
    # OR, profile, region を指定する場合
    Host ecs-example
      IdentityFile ~/.ssh/YOUR_SSH_KEY
      User root
      ProxyCommand zsh -c "export AWS_PROFILE=YOUR_PROFILE; export AWS_REGION=YOUR_REGION; zsh ~/ecs-ssh-proxy.sh YOUR_CLUSTER YOUR_TASK_FAMILY YOUR_CONTAINER_NAME ~/.ssh/YOUR_SSH_KEY.pub"
    
    Note. StrictHostKeyChecking を no に設定することはセキュリティ上のリスクがあり、利用するかはよく検討してください。特別に工夫しない限りタスクを起動し直すごとに host key は変化してしまうため、この設定を取り除くと接続ごとに警告がでるようになります。
  4. 設定したホストに対して ssh コマンドで接続します。
    ssh ecs-example
    
    また、このホストに対して VSCode Remote SSH セッションを確立することができます。
~/ecs-ssh-proxy.sh は次のような手続きを行います。
#!/bin/zsh
set -euo pipefail

CLUSTER_NAME="$1"
TASK_FAMILY="$2"
CONTAINER_NAME="$3"
PUBLIC_KEY_FILE="$4"

# get task by family
TASK_ARN=$(aws ecs list-tasks \
  --cluster "$CLUSTER_NAME" \
  --family "$TASK_FAMILY" \
  --desired-status RUNNING \
  --query 'taskArns[0]' \
  --output text | head -1)
if [ -z "$TASK_ARN" ] || [ "$TASK_ARN" = "None" ]; then
  echo "Task ARN not found. cluster_name=${CLUSTER_NAME}, task_family=${TASK_FAMILY}" >&2
  exit 1
fi
echo "Task ARN: $TASK_ARN" >&2

# get container runtime ID
CONTAINER_RUNTIME_ID=$(aws ecs describe-tasks \
  --cluster "$CLUSTER_NAME" \
  --tasks "$TASK_ARN" \
  --query "tasks[0].containers[?name=='$CONTAINER_NAME'].runtimeId | [0]" \
  --output text | head -1)
if [ -z "$CONTAINER_RUNTIME_ID" ] || [ "$CONTAINER_RUNTIME_ID" = "None" ]; then
  echo "Container runtime ID not found. container_name=${CONTAINER_NAME}" >&2
  exit 1
fi
echo "Container runtime ID: $CONTAINER_RUNTIME_ID" >&2

TASK_ID=$(echo "$TASK_ARN" | cut -d '/' -f 3)
TARGET="ecs:${CLUSTER_NAME}_${TASK_ID}_${CONTAINER_RUNTIME_ID}"
echo "Target: $TARGET" >&2

# configure ssh server
COMMAND="/bin/bash -c \"set -exuo pipefail
if [ ! -f /usr/sbin/sshd ]; then
  apt-get update
  apt-get install -y --no-install-recommends curl ca-certificates openssh-server
  echo 'PasswordAuthentication no' >> /etc/ssh/sshd_config
  echo 'ListenAddress 127.0.0.1' >> /etc/ssh/sshd_config
fi
if [ ! -f ~/.ssh/authorized_keys ]; then
  mkdir -p ~/.ssh
  chmod 700 ~/.ssh
  echo '$(cat "$PUBLIC_KEY_FILE")' >> ~/.ssh/authorized_keys
  chmod 600 ~/.ssh/authorized_keys
fi
if [ ! -f /var/run/sshd.pid ]; then
  mkdir -p /run/sshd
  nohup /usr/sbin/sshd -e -D < /dev/null > /var/log/sshd.log 2>&1 &
fi
\""
PARAMETERS=$(jq -nr --arg command "$COMMAND" '{command: [$command]}')

/usr/bin/script -q /dev/null \
  aws ssm start-session \
    --target "$TARGET" \
    --document-name AWS-StartNonInteractiveCommand \
    --parameters "$PARAMETERS" \
  < /dev/null >&2

# start SSH proxy
aws ssm start-session \
  --target "$TARGET" \
  --document-name AWS-StartSSHSession
  1. 入力パラメータに基づいて接続する対象となる ECS タスク, コンテナを検索し start-session の target の値を特定します。
  2. ドキュメント AWS-StartNonInteractiveCommand を利用し、コンテナ内でコマンドを実行します。
    • このステップでは、SSH サーバーのインストールおよび起動、公開鍵の配置を行います。
    • Note. script コマンドを介して実行しているのは、ProxyCommand として動作する際に stdin/stdout を奪わないためです。これがないと SSH 通信に干渉してエラーになります。
  3. ドキュメント AWS-StartSSHSession を利用して SSH proxy session を開始します。
(参考) ECS task definition の構成例 (Terraform)
data "aws_iam_policy_document" "ecs_tasks_assume_role" {
  statement {
    actions = ["sts:AssumeRole"]
    effect  = "Allow"
    principals {
      identifiers = ["ecs-tasks.amazonaws.com"]
      type        = "Service"
    }
  }
}

resource "aws_iam_role" "task_execution" {
  assume_role_policy = data.aws_iam_policy_document.ecs_tasks_assume_role.json
  name               = "my-example-task-execution"
}

resource "aws_iam_role_policy_attachment" "task_execution" {
  policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
  role       = aws_iam_role.task_execution.name
}

resource "aws_iam_role" "task" {
  assume_role_policy = data.aws_iam_policy_document.ecs_tasks_assume_role.json
  name               = "my-example-task"
}

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

resource "aws_iam_policy" "task" {
  name   = "my-example-task"
  policy = data.aws_iam_policy_document.task.json
}

resource "aws_iam_role_policy_attachment" "task" {
  policy_arn = aws_iam_policy.task.arn
  role       = aws_iam_role.task.name
}

resource "aws_ecs_cluster" "this" {
  name = "my-example-cluster"
}

resource "aws_ecs_cluster_capacity_providers" "this" {
  capacity_providers = ["FARGATE_SPOT"]
  cluster_name       = aws_ecs_cluster.this.name
}

resource "aws_ecs_task_definition" "this" {
  family                   = "example"
  cpu                      = "1024"
  memory                   = "2048"
  network_mode             = "awsvpc"
  requires_compatibilities = ["FARGATE"]
  execution_role_arn       = aws_iam_role.task_execution.arn
  task_role_arn            = aws_iam_role.task.arn

  runtime_platform {
    operating_system_family = "LINUX"
    cpu_architecture        = "X86_64"
  }
  container_definitions = jsonencode([
    {
      name    = "main"
      image   = "ubuntu:24.04"
      command = ["tail", "-f", "/dev/null"]
      linuxParameters = {
        initProcessEnabled = true
      }
    }
  ])
}

まとめ #

このページでは、ECS コンテナに対して Session Manager を利用して SSH 接続を確立する手法について整理しました。