TerraformでECS Fargateの踏み台をつくる
お疲れさまです。とーちです。
何番煎じか分からないくらいのネタではあるのですが、Amazon ECS(以後ECS)のAWS Fargate(以後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(長いので折りたたみ)
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タスク停止の処理も含めることにしました。
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コード
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です。
シェルスクリプト
シェルスクリプト(長いので折りたたみ)
#!/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で踏み台サーバを作る方法についてお届けしました。この記事が誰かのお役に立てば幸いです。
以上、とーちでした。