TerraformでECS Fargateの踏み台をつくる

TerraformでECS Fargateの踏み台をつくる

何番煎じか分かりませんが、ECS Fargateを使った踏み台をTerraformでつくりました
Clock Icon2025.01.30

お疲れさまです。とーちです。

何番煎じか分からないくらいのネタではあるのですが、Amazon ECS(以後ECS)のAWS Fargate(以後Fargate)で踏み台(Bastion)を作ってみました。そもそも踏み台とはというところについては以下の記事をご参照いただければと思います。

https://dev.classmethod.jp/articles/aws-cdk-ecs-fargate-bastion/

今回踏み台を作成した理由は、プライベートサブネット上のWebサーバへのアクセスです。具体的には、ブラウザを使用してインターネット経由でアクセスする必要がありました。

要件

作成するにあたって自分の中での要件としては以下のあたりでした

  • Terraformで作る(単にTerraformが好きだから)
  • コンテナイメージのビルドとかしたくない(ビルドがからむと構築時に面倒だから)
  • 止め忘れた時のために数時間経ったら勝手に止まってほしい(コスト節約のため)
  • シェルスクリプト一発でECSタスクの起動からAWS Systems Manager Session Manager(以後SessionManager)での接続まで完了するようにしたい
  • Ctrl+Cを押したら、SessionManagerのセッションの終了とECSタスクの停止まで完了するようにしたい
  • シェルスクリプトの引数はなるべく少なめにしたい

上記の要件を満たすためにTerraformコードとシェルスクリプトを作ってみました。
Terraformコードでは踏み台用ECSタスク定義等のECSタスクを立ち上げるための関連リソースを作り、シェルスクリプトでECSタスクの起動停止とSessionManagerの接続を行う作りにしています。

Terraformコード

ECS関連リソースを作成するモジュール

Terraformコードはモジュールとして使用できるようにしました。

main.tf(長いので折りたたみ)
main.tf
data "aws_region" "current" {}

resource "aws_security_group" "main" {
  name_prefix = var.prefix
  description = "for ${var.prefix}-ecs-task"
  vpc_id      = var.vpc_id
  tags = {
    Name = "${var.prefix}-ecs-task"
  }
  lifecycle {
    create_before_destroy = true
  }
}

resource "aws_vpc_security_group_egress_rule" "main" {
  for_each          = var.security_group_rules.outbound_rules
  security_group_id = aws_security_group.main.id

  cidr_ipv4   = each.value.cidr_ipv4
  from_port   = each.value.from_port
  ip_protocol = each.value.ip_protocol
  to_port     = each.value.to_port
}

resource "aws_vpc_security_group_ingress_rule" "main" {
  for_each          = var.security_group_rules.inbound_rules
  security_group_id = aws_security_group.main.id

  cidr_ipv4                    = each.value.cidr_ipv4
  referenced_security_group_id = each.value.referenced_security_group_id
  from_port                    = each.value.from_port
  ip_protocol                  = each.value.ip_protocol
  to_port                      = each.value.to_port
}

## cloudwatchlog group
resource "aws_cloudwatch_log_group" "main" {
  name              = "${var.prefix}-ecs-logs"
  retention_in_days = 1
}

## cluster
resource "aws_ecs_cluster" "main" {
  name = "${var.prefix}-cluster"
  setting {
    name  = "containerInsights"
    value = "disabled"
  }
}

## ecs task definition
resource "aws_ecs_task_definition" "main" {

  family                   = "${var.prefix}-task"
  requires_compatibilities = ["FARGATE"]
  network_mode             = "awsvpc"
  cpu                      = "256"
  memory                   = "512"
  execution_role_arn       = aws_iam_role.task_exec_role.arn
  task_role_arn            = aws_iam_role.task_role.arn
  container_definitions = jsonencode([
    {
      name = "bastion"
      image     = var.container_image_uri
      essential = true

      entryPoint = [
        "bash",
        "-c"
      ]

      command = [
        "sleep $TIMEOUT_SEC"
      ]

      environment = [
        {
          name  = "TIMEOUT_SEC"
          value = "10800" # 3h
        }
      ]

      # デバッグ用のログ設定
      logConfiguration = {
        logDriver = "awslogs"
        options = {
          "awslogs-group"         = aws_cloudwatch_log_group.main.name
          "awslogs-region"        = data.aws_region.current.name
          "awslogs-stream-prefix" = var.prefix
        }
      }

      # コンテナのリソース制限(必要に応じて)
      cpu    = 256
      memory = 512

      # 特権設定(SSM接続に必要な場合)
      privileged = false

      # その他のセキュリティ設定
      readonlyRootFilesystem = false # ecsexecではwriteが必要
    }
  ])

  runtime_platform {
    operating_system_family = "LINUX"
    cpu_architecture        = "X86_64"
  }
}
## ecs task execution role
### IAMロールに紐づける信頼ポリシー用のデータソース
data "aws_iam_policy_document" "task_exec_assume_role_policy" {
  statement {
    actions = ["sts:AssumeRole"]

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

### IAMロール
resource "aws_iam_role" "task_exec_role" {
  name               = "${var.prefix}-ecs-taskexec"
  assume_role_policy = data.aws_iam_policy_document.task_exec_assume_role_policy.json
}

### IAMロールに管理ポリシーを紐づけるためのリソース
resource "aws_iam_role_policy_attachment" "task_exec_attach_ecs_policy" {
  role       = aws_iam_role.task_exec_role.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}


## ecs task role
### IAMロールに紐づける信頼ポリシー用のデータソース
data "aws_iam_policy_document" "task_role_assume_role_policy" {
  statement {
    actions = ["sts:AssumeRole"]

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

### ecs exec用IAMポリシーを記載したデータソース
data "aws_iam_policy_document" "ecs_exec" {
  statement {
    effect = "Allow"

    actions = [
      "ssmmessages:CreateControlChannel",
      "ssmmessages:CreateDataChannel",
      "ssmmessages:OpenControlChannel",
      "ssmmessages:OpenDataChannel"
    ]

    resources = ["*"]
  }
}

### IAMロール
resource "aws_iam_role" "task_role" {
  name               = "${var.prefix}-ecs-task"
  assume_role_policy = data.aws_iam_policy_document.task_role_assume_role_policy.json
}

### IAMロールにインラインポリシーを紐づけるためのリソース
resource "aws_iam_role_policy" "ecs_exec" {
  name   = "task_exec_ssm_policy"
  role   = aws_iam_role.task_role.id
  policy = data.aws_iam_policy_document.ecs_exec.json
}

main.tfではECSクラスターやセキュリティグループ、IAMロールなどのECSタスクを立ち上げるための関連リソースを作成しています。
また、タスク定義も作っています。このタスク定義は public.ecr.aws/ubuntu/ubuntu:24.04_stable のイメージをpullして、sleepコマンドを実行するだけのものになっています。

余談ですが、このブログを見てコンテナの中でSessionManagerのプロセスを監視してプロセスがなければコンテナを終了させるというアイディアをみて素晴らしいと思ったので取り入れてみたのですが、自分の環境だとなぜかSessionManagerの接続が終了してもECSタスクが停止しないことがあったので、今回はシェルスクリプト側でECSタスク停止の処理も含めることにしました。

variables.tf
variable "vpc_id" {
  description = "VPC ID"
  type        = string
}
variable "prefix" {
  description = "Prefix"
  type        = string
}
variable "container_image_uri" {
  description = "Container Image URI"
  type        = string
  default     = "public.ecr.aws/ubuntu/ubuntu:24.04_stable"
}
variable "security_group_rules" {
  type = object({
    inbound_rules = map(object({
      from_port                    = number
      to_port                      = number
      ip_protocol                  = string
      cidr_ipv4                    = string
      referenced_security_group_id = string
    }))
    outbound_rules = map(object({
      from_port   = number
      to_port     = number
      ip_protocol = string
      cidr_ipv4   = string
    }))
  })
}

variables.tfはこんな感じです。ImageURIは変更可能な形にして、例えばECRプルスルーキャッシュで取得したイメージも指定できるようにしています。

上記のモジュールを呼ぶためのTerraformコード

main.tf
module "bastion_ecs" {
  source = "../../../modules/bastion-ecs"

  prefix          = "${var.prefix}-bastion"
  vpc_id          = var.network_vpc_id
  security_group_rules = {
    inbound_rules = {
    }
    outbound_rules = {
      all = {
        from_port   = -1
        to_port     = -1
        ip_protocol = "-1"
        cidr_ipv4   = "0.0.0.0/0"
      }
    }
  }

}

VPC(とNAT or VPCエンドポイント)が事前に作成されていることが前提になります。VPCさえ作成されていればVPCIDとセキュリティグループルール、各リソースに付与するプレフィックス名だけ指定すればOKです。

シェルスクリプト

シェルスクリプト(長いので折りたたみ)
run_ecs_task.sh
#!/bin/bash
set -exo pipefail

if [ $# -lt 2 ]; then
    echo "Usage: $0 <TARGET_PREFIX> <END_HOST> [TIMEOUT]"
    exit 1
fi

TARGET_PREFIX=$1
TARGET_PREFIX_SUBSYSTEM=${TARGET_PREFIX}-bastion
END_HOST=$2
TIMEOUT=$3
END_HOST_PORT=443
LOCAL_PORT=443
HOSTS_FILE="/private/etc/hosts"

# TIMEOUTの確認
if [ -z "${TIMEOUT}" ]; then
    echo "TIMEOUT is set to Default."
else
    echo "TIMEOUT is set to ${TIMEOUT} seconds."
fi

# クラスター名の取得
CLUSTER_NAME=$(aws ecs list-clusters \
    --query "clusterArns[?contains(@, '${TARGET_PREFIX_SUBSYSTEM}')]" \
    --output text | awk -F '/' '{print $2}')

# タスク定義ARNの取得
TASK_DEFINITION_ARN=$(aws ecs list-task-definitions \
    --query "taskDefinitionArns[?contains(@, '${TARGET_PREFIX_SUBSYSTEM}')]" \
    --output text)

# セキュリティグループIDの取得
SECURITYGROUP_ID=$(aws ec2 describe-security-groups \
    --query "SecurityGroups[?contains(GroupName,'${TARGET_PREFIX_SUBSYSTEM}')].GroupId" \
    --output text)

# サブネットIDの取得
SUBNET_ID=$(aws ec2 describe-subnets \
    --filters "Name=tag:Name,Values=*${TARGET_PREFIX}*private*" \
    --query 'Subnets[0].SubnetId' \
    --output text)

# 取得した値の確認
echo "CLUSTER_NAME: ${CLUSTER_NAME}"
echo "TASK_DEFINITION_ARN: ${TASK_DEFINITION_ARN}"
echo "SECURITYGROUP_ID: ${SECURITYGROUP_ID}"
echo "SUBNET_ID: ${SUBNET_ID}"

# 必要なパラメータが取得できているか確認
if [ -z "${CLUSTER_NAME}" ] || [ -z "${TASK_DEFINITION_ARN}" ] || [ -z "${SECURITYGROUP_ID}" ] || [ -z "${SUBNET_ID}" ]; then
    echo "Required parameters are missing"
    exit 1
fi

# network-configurationをJSON形式で作成
NETWORK_CONFIG=$(cat <<EOF
{
    "awsvpcConfiguration": {
        "subnets": ["${SUBNET_ID}"],
        "securityGroups": ["${SECURITYGROUP_ID}"],
        "assignPublicIp": "DISABLED"
    }
}
EOF
)

# タスクの実行
if [ -n "${TIMEOUT}" ]; then
    # TIMEOUTが設定されている場合
    TASK_ARN=$(aws ecs run-task \
        --cluster "${CLUSTER_NAME}" \
        --task-definition "${TASK_DEFINITION_ARN}" \
        --network-configuration "${NETWORK_CONFIG}" \
        --enable-execute-command \
        --launch-type FARGATE \
        --overrides "{\"containerOverrides\": [{\"name\": \"bastion\", \"environment\": [{\"name\": \"TIMEOUT_SEC\", \"value\": \"${TIMEOUT}\"}]}]}" \
        --platform-version "1.4.0" \
        --query 'tasks[0].taskArn' \
        --output text)
else
    # TIMEOUTが設定されていない場合
    TASK_ARN=$(aws ecs run-task \
        --cluster "${CLUSTER_NAME}" \
        --task-definition "${TASK_DEFINITION_ARN}" \
        --network-configuration "${NETWORK_CONFIG}" \
        --enable-execute-command \
        --launch-type FARGATE \
        --platform-version "1.4.0" \
        --query 'tasks[0].taskArn' \
        --output text)
fi

echo "Started task: ${TASK_ARN}"

# タスクが正常起動するまで待機
echo "Waiting for task to reach RUNNING state..."
MAX_ATTEMPTS=60  # 5分間待機
ATTEMPT=0

while [ $ATTEMPT -lt $MAX_ATTEMPTS ]; do
    TASK_STATUS=$(aws ecs describe-tasks \
        --cluster "${CLUSTER_NAME}" \
        --tasks "${TASK_ARN}" \
        --query 'tasks[0].lastStatus' \
        --output text)

    echo "Current task status: ${TASK_STATUS}"

    if [ "${TASK_STATUS}" = "RUNNING" ]; then
        echo "Task is now running"
        break
    elif [ "${TASK_STATUS}" = "STOPPED" ]; then
        echo "Task stopped unexpectedly"
        exit 1
    fi

    ATTEMPT=$((ATTEMPT + 1))
    sleep 5
done

if [ $ATTEMPT -eq $MAX_ATTEMPTS ]; then
    echo "Timeout waiting for task to start"
    exit 1
fi

echo "Task deployment completed successfully"

# タスクIDとコンテナIDの取得
TASK_ID=$(echo ${TASK_ARN} | awk -F '/' '{print $3}')
CONTAINER_ID=$(aws ecs describe-tasks \
    --cluster "${CLUSTER_NAME}" \
    --tasks "${TASK_ARN}" \
    --query 'tasks[0].containers[0].runtimeId' \
    --output text)

if [ -z "${TASK_ID}" ] || [ -z "${CONTAINER_ID}" ]; then
    echo "Failed to get task or container ID"
    exit 1
fi
# ssm-agentの起動待ち
sleep 5
# SSMセッションターゲットの構築
TARGET="ecs:${CLUSTER_NAME}_${TASK_ID}_${CONTAINER_ID}"
PARAMETERS="{\"host\":[\"${END_HOST}\"],\"portNumber\":[\"${END_HOST_PORT}\"],\"localPortNumber\":[\"${LOCAL_PORT}\"]}"

# SSMセッションの開始
echo "Starting port forwarding session..."
sudo -E aws ssm start-session \
    --target "${TARGET}" \
    --document-name AWS-StartPortForwardingSessionToRemoteHost \
    --parameters "${PARAMETERS}" &

SSM_PID=$!

# クリーンアップ処理の関数
cleanup() {
    echo "Cleaning up..."
    if [ -n "${SSM_PID}" ] && ps -p ${SSM_PID} > /dev/null; then
        kill ${SSM_PID} 2>/dev/null || true
    fi    
    # タスクが存在するか確認
    TASK_STATUS=$(aws ecs describe-tasks \
        --cluster "${CLUSTER_NAME}" \
        --tasks "${TASK_ARN}" \
        --query 'tasks[0].lastStatus' \
        --output text 2>/dev/null)

    if [ "${TASK_STATUS}" = "RUNNING" ]; then
        echo "Stopping ECS task..."
        aws ecs stop-task \
            --cluster "${CLUSTER_NAME}" \
            --task "${TASK_ARN}" \
            --reason "Cleanup process" > /dev/null

        echo "ECS task stopped."
    elif [ -n "${TASK_STATUS}" ]; then
        echo "ECS task is in ${TASK_STATUS} state. No action needed."
    else
        echo "ECS task not found or already removed."
    fi

    exit
}

# クリーンアップ処理の設定
trap cleanup INT TERM

echo "Port forwarding session started. Press Ctrl+C to stop."
wait $SSM_PID

ポイント

シェルスクリプトは上記のTerraformで関連リソースが作成されていることを前提とした作りになっており、ECSクラスター名やECSタスク名は上記のTerraformコードで付与したプレフィックスをもつリソースを探し、それをもとにECSタスクを起動する作りにしています。

またaws ecs run-taskコマンドの実行時に--overrides オプションを指定することでシェルスクリプト実行時の引数としてECSタスクのタイムアウトをシェルスクリプト側からも上書き指定できるような作りにしています。

ECSタスクの正常起動をシェルの中で待ち、起動が確認できたら aws ssm start-session でSessionManagerによる接続を開始する作りになっています。

また aws ssm start-session 実行時にプロセスIDを取得することで、aws ssm start-sessionが終了するまでシェルスクリプトの終了を待機し、aws ssm start-sessionが終了したときにtrapでECSタスクの停止処理が走るようにしています。

まとめ

ECS Fargateで踏み台サーバを作る方法についてお届けしました。この記事が誰かのお役に立てば幸いです。

以上、とーちでした。

Share this article

facebook logohatena logotwitter logo

© Classmethod, Inc. All rights reserved.