ECS Fargateを踏み台にしてRDSに接続する（Terraformで構築）

## はじめに

Session Magegerのリモートポートフォワード機能とECS Fargateの踏み台構成で、
プライベートサブネット内のRDSに接続します。
踏み台なのでスペックは最小、また料金を抑えるために業務時間外に自動停止するようにします。
なるべくコストを抑え、かつ管理が楽になる構成を目指しました。

以前はEC2＋SSHで接続する踏み台構成にしていましたが、
- 鍵ファイルの管理が面倒（開発要員が増えたときに鍵ファイルを共有したり新しく作ったり）
- EC2を再起動する度にIPアドレスが変わり、ローカルのSSH接続設定を変えないといけない
    （Elastic IPを付与してIPアドレスを固定すれば解決ですが、プラスで料金がかかる）
- 停止中もEBSのストレージ料金がかかる

こんな悩みがあったので、本記事の構成に変更することにしました。

本記事ではTerraformで構築していきます。
マネジメントコンソールでの設定手順は適宜[参考リンク](#参考記事)を貼ってますので、そちらを参考にしてください。

## 事前準備

Session Managerで接続するのに必要になるので、Terraformを使う使わないに関わらず以下のツールはインストールします。

- [AWS CLI][aws-cli]
- [Session Manager プラグイン][session-manager-plugin]

## 実行環境

- macOS Big Sur 11.4
- Terraform v1.3.6
- aws-cli 2.9.12

## 環境構築

AWS構成（関係するもののみ抜粋）

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1649149/e9993758-9d49-f698-31b3-5f516a8a4064.png)


### ECSの設定

まずは踏み台となるECSの設定をしていきます。
ECSでコンテナを起動する場合、自前のコンテナイメージをECRに保存して呼び出すことが多いかと思います。
今回は踏み台用途かつ、ECRのストレージ料金を発生させたくなかったので、[Amazon ECR Public Gallery][ecr-public-gallery]からDocker公式のコンテナイメージを使用しています。
パブリックリポジトリからのイメージのプルは無料です。

:::note
2023/07/19 コンテナイメージに公式のssm-agenetがあると
コメントいただいたので、イメージURIを変更しました。
@SF-28 さん、ありがとうございます。
:::

- クラスター

```terraform
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"
  }
}
```

- タスク定義

```terraform
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を指定してください。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1649149/35202a7e-cfb5-3e45-ce8a-52105f11fab0.png)

- サービス

```terraform
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周りの権限が必要です。

```terraform
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に設定するパブリックサブネットが２つ必要です。
->  (2023/07/19) 1つでも大丈夫なようです。

```terraform
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の定義も入れておきます。ご自身のシステムに合わせて変更してください。

```terraform
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を実行します。

### 参考記事

マネジメントコンソールから構築する場合は以下の記事を参考にされてください。

https://dev.classmethod.jp/articles/ecs-fargate-entry/

https://qiita.com/okubot55/items/b1fb07b2de08c354275b

https://docs.aws.amazon.com/AmazonECS/latest/developerguide/ecs-exec.html

### 設定の注意点（つまずきポイント）

- ECSが所属するサブネットはRDSが所属するVPCのパブリックサブネットにする
    
    参考にしたいろんな記事では言及がなかったのですが、別のVPCにすると接続できませんでした。

- RDSのセキュリティグループにECSのセキュリティグループからのアクセスを許可する
    Terraformの記述だとこの部分
    ```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に接続します。

参考にした記事はこちら。

https://zenn.dev/quiver/articles/1458e453118254

まずは実行中のECSタスクの情報を取得します。クラスター名とタスクIDはECSのコンソール画面から確認できます。

```bash
aws ecs describe-tasks \
  --cluster <クラスター名> \
  --task <タスクID>
```

これを実行すると実行中のタスクの情報が表示されるので、runtimeIdの記述を見つけ控えます。

```bash
"containers": [
                {
                    # 省略
                    "image": "public.ecr.aws/nginx/nginx:latest",
                    "runtimeId": "XXXXXXXXX", # ここ
                    "lastStatus": "RUNNING",
                    # 省略
                }
              ]
```

クラスター名、タスクID、ランタイムIDを指定してローカルにポートフォワードします。
`portNumber`と`localPortNumber`はご自身の環境に合わせて変更してください。

```bash
aws ssm start-session \
 --target "ecs:<クラスター名>_<タスクID>_<ランタイムID> \
 --document-name AWS-StartPortForwardingSessionToRemoteHost \
 --parameters '{"host":["<RDSのエンドポイント>"],"portNumber":["5432"], "localPortNumber":["15441"]}'
```

実行すると接続中はターミナルに以下が表示されます。

```bash
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`にすればいいようです

ファイル出力

```terraform
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]を利用します。
SAMをTerraformから実行する構成にしてますが、SAM単体で実行しても大丈夫です。

マネジメントコンソールから作成する方はこちらの記事を参考に。

https://hikari-blog.com/ecs-auto-start-and-stop/

- フォルダ構成

```
../
    sam.tf
    sam/
        template.yaml
        ecs_stop_function/
            __init__.py
            app.py
```

- SAM テンプレート

```template.yaml
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）

```terraform
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

```python
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][event-bridge-scheduler]がリリースされ、RDSやEC2の起動・停止を簡単に設定できるようになりました。

マネジメントコンソールから作成する場合はこちらから。

https://dev.classmethod.jp/articles/amazon-eventbridge-scheduler-rds-stop/

Terraform

```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を利用したことがなかったので、いい勉強にもなりました。
参考になれば幸いです。

[aws-cli]: https://docs.aws.amazon.com/ja_jp/cli/latest/userguide/getting-started-install.html
[session-manager-plugin]: https://docs.aws.amazon.com/systems-manager/latest/userguide/session-manager-working-with-install-plugin.html
[ecr-public-gallery]: https://gallery.ecr.aws/
[sam]: https://docs.aws.amazon.com/ja_jp/serverless-application-model/latest/developerguide/what-is-sam.html
[event-bridge-scheduler]: https://docs.aws.amazon.com/ja_jp/eventbridge/latest/userguide/scheduler.html