はじめに
Session Magegerのリモートポートフォワード機能とECS Fargateの踏み台構成で、
プライベートサブネット内のRDSに接続します。
踏み台なのでスペックは最小、また料金を抑えるために業務時間外に自動停止するようにします。
なるべくコストを抑え、かつ管理が楽になる構成を目指しました。
以前はEC2+SSHで接続する踏み台構成にしていましたが、
- 鍵ファイルの管理が面倒(開発要員が増えたときに鍵ファイルを共有したり新しく作ったり)
- EC2を再起動する度にIPアドレスが変わり、ローカルのSSH接続設定を変えないといけない
(Elastic IPを付与してIPアドレスを固定すれば解決ですが、プラスで料金がかかる) - 停止中もEBSのストレージ料金がかかる
こんな悩みがあったので、本記事の構成に変更することにしました。
本記事ではTerraformで構築していきます。
マネジメントコンソールでの設定手順は適宜参考リンクを貼ってますので、そちらを参考にしてください。
事前準備
Session Managerで接続するのに必要になるので、Terraformを使う使わないに関わらず以下のツールはインストールします。
実行環境
- macOS Big Sur 11.4
- Terraform v1.3.6
- aws-cli 2.9.12
環境構築
AWS構成(関係するもののみ抜粋)
ECSの設定
まずは踏み台となるECSの設定をしていきます。
ECSでコンテナを起動する場合、自前のコンテナイメージをECRに保存して呼び出すことが多いかと思います。
今回は踏み台用途かつ、ECRのストレージ料金を発生させたくなかったので、Amazon ECR Public GalleryからDocker公式のコンテナイメージを使用しています。
パブリックリポジトリからのイメージのプルは無料です。
2023/07/19 コンテナイメージに公式のssm-agenetがあると
コメントいただいたので、イメージURIを変更しました。
@SF-28 さん、ありがとうございます。
- クラスター
resource "aws_ecs_cluster" "ecs_cluster" {
name = local.ecs_cluster_name # 任意の名前に変更
setting {
name = "containerInsights"
value = "enabled"
}
}
resource "aws_ecs_cluster_capacity_providers" "ecs_cluster_capacity_providers" {
cluster_name = aws_ecs_cluster.ecs_cluster.name
capacity_providers = ["FARGATE"]
default_capacity_provider_strategy {
capacity_provider = "FARGATE"
}
}
- タスク定義
resource "aws_ecs_task_definition" "ecs_task_definition" {
family = local.ecs_task_family_name #任意の名前に変更
cpu = 256 # .25 vCPU
memory = 512 # 512 MiB
network_mode = "awsvpc"
requires_compatibilities = ["FARGATE"]
container_definitions = jsonencode([
{
name = local.ecs_container_name #任意の名前に変更
image = "public.ecr.aws/amazon-ssm-agent/amazon-ssm-agent:latest" # イメージURIを指定
cpu = 256
memory = 512
essential = true
portMappings = [
{
protocol = "tcp"
containerPort = 80
}
]
},
])
execution_role_arn = aws_iam_role.ecs_task_execution_role.arn
task_role_arn = aws_iam_role.ecs_task_execution_role.arn
}
メモリとCPUは最小にします。
portMappings
の設定はいらないのではとも思ったのですが、
指定しないと起動できなかったので入れています。
image = "public.ecr.aws/nginx/nginx:latest"
ここは前述の通り公式コンテナイメージを指定しています。
Linuxベースだったら何でもいいんじゃないでしょうか。
Public Galleryからイメージを選んだら、
public.ecr.aws~~~~
の部分をコピーして、
image = "public.ecr.aws/nginx/nginx:latest"
に指定してください。
自前のコンテナイメージを使いたい場合はECRのイメージURIを指定してください。
- サービス
resource "aws_ecs_service" "ecs_service" {
name = local.ecs_service_name #任意の名前に変更
cluster = aws_ecs_cluster.ecs_cluster.arn
enable_execute_command = true
task_definition = aws_ecs_task_definition.ecs_task_definition.arn
desired_count = 1
launch_type = "FARGATE"
network_configuration {
assign_public_ip = true
security_groups = [aws_security_group.ec2_security_group.id]
subnets = [
aws_subnet.public_subnet.id,
aws_subnet.public_subnet_2.id,
]
}
lifecycle {
ignore_changes = [task_definition]
}
}
ここで重要なのが、enable_execute_command
をtrue
にしている点です。
このecs execを有効にしていないとSession Manager経由でコンテナにアクセスすることができません。
関連リソース
続いて起動に必要な関連リソースの設定です。
- IAMロール
ECSに付与する実行ロールです。SSM周りの権限が必要です。
data "aws_iam_policy_document" "ecs_task_role_policy_data" {
statement {
effect = "Allow"
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["ecs-tasks.amazonaws.com"]
}
}
}
data "aws_iam_policy_document" "ecs_task_policy_data" {
statement {
effect = "Allow"
actions = [
"ssmmessages:CreateControlChannel",
"ssmmessages:CreateDataChannel",
"ssmmessages:OpenControlChannel",
"ssmmessages:OpenDataChannel"
]
resources = ["*"]
}
statement {
effect = "Allow"
actions = [
"ecr:GetAuthorizationToken",
"ecr:BatchCheckLayerAvailability",
"ecr:GetDownloadUrlForLayer",
"ecr:BatchGetImage",
"logs:CreateLogStream",
"logs:PutLogEvents"
]
resources = ["*"]
}
}
resource "aws_iam_role" "ecs_task_execution_role" {
name = local.iam_ecs_task_execution_name #任意の名前に変更
description = "ECS Task Execution"
assume_role_policy = data.aws_iam_policy_document.ecs_task_role_policy_data.json
}
resource "aws_iam_policy" "ecs_task_execution_policy" {
name = local.iam_ecs_task_execution_policy_name #任意の名前に変更
policy = data.aws_iam_policy_document.ecs_task_policy_data.json
}
resource "aws_iam_role_policy_attachment" "attach_ecs_task_execution_role" {
role = aws_iam_role.ecs_task_execution_role.name
policy_arn = aws_iam_policy.ecs_task_execution_policy.arn
}
- VPC
VPCの設定はシステム構成に合わせて変更してください。
踏み台ECSに設定するパブリックサブネットが2つ必要です。
-> (2023/07/19) 1つでも大丈夫なようです。
resource "aws_vpc" "vpc" {
cidr_block = "10.0.0.0/16"
assign_generated_ipv6_cidr_block = "false"
instance_tenancy = "default"
}
# subnet
resource "aws_subnet" "private_subnet" {
vpc_id = aws_vpc.vpc.id
cidr_block = "10.0.1.0/24"
availability_zone = "${var.aws_region}a"
}
resource "aws_subnet" "private_subnet_2" {
vpc_id = aws_vpc.vpc.id
cidr_block = "10.0.2.0/24"
availability_zone = "${var.aws_region}c"
}
resource "aws_subnet" "public_subnet" {
vpc_id = aws_vpc.vpc.id
cidr_block = "10.0.0.0/24"
availability_zone = "${var.aws_region}a"
tags = merge(
local.billing_group_tag, {
Name = "${local.vpc_name}-subnet-public"
}
)
}
resource "aws_subnet" "public_subnet_2" {
vpc_id = aws_vpc.vpc.id
cidr_block = "10.0.3.0/24"
availability_zone = "${var.aws_region}c"
tags = merge(
local.billing_group_tag, {
Name = "${local.vpc_name}-subnet-public-2"
}
)
}
#igw
resource "aws_internet_gateway" "internet_gateway" {
vpc_id = aws_vpc.vpc.id
}
# route table
resource "aws_route_table" "public_route_table" {
vpc_id = aws_vpc.vpc.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.internet_gateway.id
}
}
resource "aws_route_table_association" "public_association" {
subnet_id = aws_subnet.public_subnet.id
route_table_id = aws_route_table.public_route_table.id
}
resource "aws_route_table_association" "public_association_2" {
subnet_id = aws_subnet.public_subnet_2.id
route_table_id = aws_route_table.public_route_table.id
}
#security group
resource "aws_security_group" "ecs_security_group" {
name = local.vpc_ecs_sg_name # 任意の名前に変更
description = "ecs Security Group"
vpc_id = aws_vpc.vpc.id
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
# このルールは不要かもしれませんが、私の環境ではこれを入れないと接続できませんでした
# -> (2023/07/19) なくても接続できるようです。
resource "aws_security_group_rule" "ecs_security_group_rule" {
type = "ingress"
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = split(",", var.ecs_accessible_cidr) # ["<許可するIPアドレス>"]の形式で指定
security_group_id = aws_security_group.ecs_security_group.id
}
resource "aws_security_group" "rds_security_group" {
name = local.vpc_rds_sg_name
description = "rds Security Group"
vpc_id = aws_vpc.vpc.id
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
resource "aws_security_group_rule" "rds_security_group_rule_ecs" {
type = "ingress"
from_port = 5432
to_port = 5432
protocol = "tcp"
source_security_group_id = aws_security_group.ecs_security_group.id
security_group_id = aws_security_group.rds_security_group.id
}
- RDS
一応接続先のRDSの定義も入れておきます。ご自身のシステムに合わせて変更してください。
resource "aws_db_subnet_group" "db_subnet_group" {
name = local.rds_subnet_group_name # 任意の名前に変更
subnet_ids = [aws_subnet.private_subnet.id, aws_subnet.private_subnet_2.id]
description = "DB Subnet Group"
}
resource "aws_db_instance" "db_instance" {
identifier = local.rds_identifier #任意の名前に変更
allocated_storage = 20
storage_type = "gp2"
engine = "postgres"
engine_version = "13.6"
instance_class = "db.t4g.micro"
db_name = local.rds_db_name #任意の名前に変更
username = local.rds_master_username #任意の名前に変更
password = var.rds_master_password #任意の値に変更
availability_zone = "${var.aws_region}a"
port = 5432
copy_tags_to_snapshot = true
db_subnet_group_name = aws_db_subnet_group.db_subnet_group.name
vpc_security_group_ids = [aws_security_group.rds_security_group.id]
enabled_cloudwatch_logs_exports = ["postgresql"]
skip_final_snapshot = true
parameter_group_name = aws_db_parameter_group.db_parameter_group.name
}
resource "aws_db_parameter_group" "db_parameter_group" {
name = local.rds_parameter_group_name #任意の名前に変更
family = "postgres13"
description = "DB Parameter Group for"
parameter {
name = "timezone"
value = "utc+9"
}
parameter {
name = "orafce.timezone"
value = "utc+9"
}
}
terraformを実行します。
参考記事
マネジメントコンソールから構築する場合は以下の記事を参考にされてください。
設定の注意点(つまずきポイント)
-
ECSが所属するサブネットはRDSが所属するVPCのパブリックサブネットにする
参考にしたいろんな記事では言及がなかったのですが、別のVPCにすると接続できませんでした。
-
RDSのセキュリティグループにECSのセキュリティグループからのアクセスを許可する
Terraformの記述だとこの部分resource "aws_security_group_rule" "rds_security_group_rule_ecs" { type = "ingress" from_port = 5432 #ポートはRDSの設定で指定したポート to_port = 5432 protocol = "tcp" source_security_group_id = aws_security_group.ecs_security_group.id security_group_id = aws_security_group.rds_security_group.id }
この設定がないと接続できませんでした。
Session Managerで接続
環境が出来上がったので、ローカルからSession Manager経由でRDSに接続します。
参考にした記事はこちら。
まずは実行中のECSタスクの情報を取得します。クラスター名とタスクIDはECSのコンソール画面から確認できます。
aws ecs describe-tasks \
--cluster <クラスター名> \
--task <タスクID>
これを実行すると実行中のタスクの情報が表示されるので、runtimeIdの記述を見つけ控えます。
"containers": [
{
# 省略
"image": "public.ecr.aws/nginx/nginx:latest",
"runtimeId": "XXXXXXXXX", # ここ
"lastStatus": "RUNNING",
# 省略
}
]
クラスター名、タスクID、ランタイムIDを指定してローカルにポートフォワードします。
portNumber
とlocalPortNumber
はご自身の環境に合わせて変更してください。
aws ssm start-session \
--target "ecs:<クラスター名>_<タスクID>_<ランタイムID> \
--document-name AWS-StartPortForwardingSessionToRemoteHost \
--parameters '{"host":["<RDSのエンドポイント>"],"portNumber":["5432"], "localPortNumber":["15441"]}'
実行すると接続中はターミナルに以下が表示されます。
Starting session with SessionId: dx-hirooka-0bc911a2da449f200
Port 15441 opened for sessionId dx-hirooka-0bc911a2da449f200.
Waiting for connections...
Connection accepted for session [<awsプロファイル名>]
あとはローカルPCのDBクライアントツールから接続してみてください。
私は上記の実行コマンドをTerraformのlocal_file
リソースを使って、
実行時にシェルスクリプトで出力するようにしています。
こうしておけば毎回コマンドを打たずにシェルスクリプトを実行すればいいのと、
他の開発メンバーへの共有も楽です。
2023/3/29 追記 @SF-28 さん、情報提供ありがとうございます!
- ファイル出力コマンドを修正
- AuroraServerless v2の場合は
aws_db_instance.db_instance.address
をaws_rds_cluster.cluster.endpoint
にすればいいようです
ファイル出力
resource "local_file" "connect_bastion" {
filename = "./outputs/commands/connect_bastion.sh"
content = <<DOC
#!/bin/bash
TASK_ID=`aws ecs list-tasks \
--cluster ${aws_ecs_cluster.ecs_cluster.name} \
--profile ${var.aws_profile} \
| jq '.taskArns[0]' \
| sed 's/"//g' \
| cut -f 3 -d '/'`
RUNTIME_ID=`aws ecs describe-tasks \
--cluster ${aws_ecs_cluster.ecs_cluster.name} \
--task $TASK_ID \
--profile ${var.aws_profile} \
| jq '.tasks[0].containers[0].runtimeId' \
| sed 's/"//g'`
aws ssm start-session \
--target "ecs:${aws_ecs_cluster.ecs_cluster.name}_"$TASK_ID"_"$RUNTIME_ID \
--document-name AWS-StartPortForwardingSessionToRemoteHost \
--parameters '{"host":["${aws_db_instance.db_instance.address}"],"portNumber":["5432"], "localPortNumber":["${var.bastion_local_port}"]}' \
--profile ${var.aws_profile}
DOC
}
指定時刻に自動停止する
最後に起動中のECSサービスを指定時刻に自動停止するようにします。
踏み台は常時使うものではないので、ずっと起動させておくとその分料金が発生してもったいないです。
しかし手動で毎回消すとなると必ず忘れます。必ずです。かつ面倒です。
EventBridgeとLamndaを使って毎日20時に自動でサービスが停止する環境を作っていきます。
こちらの設定にはAWS SAMを利用します。
SAMをTerraformから実行する構成にしてますが、SAM単体で実行しても大丈夫です。
マネジメントコンソールから作成する方はこちらの記事を参考に。
- フォルダ構成
../
sam.tf
sam/
template.yaml
ecs_stop_function/
__init__.py
app.py
- SAM テンプレート
AWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::Serverless-2016-10-31
Description: >
SAM Template for Operation.
Parameters:
ECSClusterNameParameter:
Type: String
ECSServiceNameParameter:
Type: String
Globals:
Function:
Timeout: 15
MemorySize: 128
Runtime: python3.8
Resources:
ECSStopFunction:
Type: AWS::Serverless::Function
Properties:
FunctionName: "ecs-stop-func"
CodeUri: ecs_stop_function/
Handler: app.lambda_handler
Role: !GetAtt LambdaOperationFunctionRole.Arn
Environment:
Variables:
ECS_CLUSTER_NAME: !Ref ECSClusterNameParameter
ECS_SERVICE_NAME: !Ref ECSServiceNameParameter
Events:
DailyStopEvent:
Type: Schedule
Properties:
Name: "daily-stop-event"
Schedule: "cron(0 11 * * ? *)" #標準時なので実行時刻-9時間で指定
LambdaOperationFunctionRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: "Allow"
Action: "sts:AssumeRole"
Principal:
Service: lambda.amazonaws.com
Policies:
- PolicyName: "operation-func-policy"
PolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: "Allow"
Action:
- "logs:PutLogEvents"
- "logs:CreateLogStream"
- "logs:CreateLogGroup"
Resource: "*"
- Effect: "Allow"
Action:
- "ecs:DescribeServices"
- "ecs:UpdateService"
Resource: "*"
- TerraformのSAM定義(sam.tf)
resource "null_resource" "sam_build" {
provisioner "local-exec" {
command = "sam build --base-dir sam/ --template sam/template.yaml --use-container"
}
}
resource "null_resource" "sam_package" {
triggers = {
sam_build_id = join(",", [null_resource.sam_build.id])
}
provisioner "local-exec" {
command = "sam package --template-file .aws-sam/build/template.yaml --s3-bucket <S3バケット名> --s3-prefix <保存先のパス> --output-template-file ./sam-output.yaml --region ${var.aws_region} --profile=${var.aws_profile} "
}
depends_on = [null_resource.sam_build]
}
data "local_file" "sam_output" {
filename = "./sam-output.yaml"
depends_on = [null_resource.sam_package]
}
resource "aws_cloudformation_stack" "sam" {
name = "<SAMスタック名>"
template_body = data.local_file.sam_output.content
capabilities = ["CAPABILITY_NAMED_IAM", "CAPABILITY_AUTO_EXPAND"]
depends_on = [null_resource.sam_package, data.local_file.sam_output]
parameters = {
ECSClusterNameParameter : var.ecs_cluster_name
ECSServiceNameParameter : var.ecs_service_name
}
}
- app.py
import os
import boto3
from botocore.exceptions import ClientError
ECS_CLUSTER_NAME = os.environ.get("ECS_CLUSTER_NAME")
ECS_SERVICE_NAME = os.environ.get("ECS_SERVICE_NAME")
def lambda_handler(event, context):
print("event: {}".format(event))
try:
client = boto3.client("ecs")
service_update_result = client.update_service(
cluster=ECS_CLUSTER_NAME,
service=ECS_SERVICE_NAME,
desiredCount=0,
)
print(service_update_result)
except ClientError as e:
print("exceptin: %s" % e)
Terraform実行 or SAM実行すれば環境が出来上がります。
RDSの自動停止
おまけでRDSも毎日20時に停止するようにします。
開発環境やステージング環境は常時RDSを起動しておく必要がないので、少しコストを抑えられます。
これをしておくと開発に夢中になって時間を忘れている時も、
20時になると強制的にDBが停止するので、そろそろ切り上げようかなのアラームになります。
RDSの停止も以前はEventBridgeとLambdaを組み合わせて行っていましたが、
Amazon EventBridge Schedulerがリリースされ、RDSやEC2の起動・停止を簡単に設定できるようになりました。
マネジメントコンソールから作成する場合はこちらから。
Terraform
resource "aws_cloudwatch_event_rule" "stop_rule" {
name = "stop-rule"
description = "server stop the dev server at 20:00(JST)"
schedule_expression = "cron(0 11 * * ? *)"
}
resource "aws_cloudwatch_event_target" "stop_rds" {
target_id = "StopRds"
arn = "arn:aws:ssm:ap-northeast-1::automation-definition/AWS-StopRdsInstance"
rule = aws_cloudwatch_event_rule.stop_rule.name
role_arn = aws_iam_role.cloudwatch_event_ssm_role.arn
input = <<EOF
{
"InstanceId": ["<RDSのインスタンスID>"]
}
EOF
}
resource "aws_iam_role" "cloudwatch_event_ssm_role" {
name = "cw_event_ssm_role"
assume_role_policy = <<EOF
{
"Version": "2012-10-17",
"Statement": [
{
"Action": "sts:AssumeRole",
"Principal": {
"Service": [
"events.amazonaws.com"
]
},
"Effect": "Allow",
"Sid": ""
}
]
}
EOF
}
resource "aws_iam_role_policy" "cloudwatch_event_ssm_rds_policy" {
name = "cw_event_ssm_rds_policy"
role = aws_iam_role.cloudwatch_event_ssm_role.id
policy = <<EOF
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "",
"Effect": "Allow",
"Action": [
"rds:StopDBInstance",
"rds:DescribeDBInstances"
],
"Resource": "*"
}
]
}
EOF
}
resource "aws_iam_role_policy_attachment" "AmazonSSMAutomationRole" {
role = aws_iam_role.cloudwatch_event_ssm_role.id
policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonSSMAutomationRole"
}
まとめ
ECS + Session Manager の踏み台構成にすることで、
EC2 + SSHの構成よりも管理を楽に、
かつ使用時間外は踏み台を停止することで、コストを抑えることができました。
個人的にはECSを利用したことがなかったので、いい勉強にもなりました。
参考になれば幸いです。