🧚

Amazon ECS AWS FargateでポートフォワードしてRDSへ接続する

2024/09/10に公開

はじめに

個人開発にてRails(ECS上へデプロイ) x RDS(MySQL)を実装した際にメンテなどでRDSを直接触りたいと思い、せっかくであればモダンな方法で実現したいと思い、調べたところECSで踏み台サーバーのようなことができることを知り、実装してみました。
少し詰まった箇所もあるのでどなたかの助けになれば幸いです。

参考にした記事

https://qiita.com/hirooka622/items/d9ffb3aaf5fbba0a8a8d#ecsの設定

IAMロール周りを参考にさせていただきました。

https://zenn.dev/fuku710/articles/f4ef9ffe81c3ee

利用するコンテナイメージやssmコマンドの使い方などを参考にさせていただきました。

そもそもポートフォワードとは

ChatGPTに聞いてみました

ポートフォワード(Port Forwarding)は、特定のネットワークポートへの通信を別のポートやホストに転送する技術です。通常、ルーターやファイアウォールで設定され、内部ネットワーク内のデバイスやサービスに外部からアクセスできるようにするために使われます。

特定のポートとポートを繋ぎ合わせ通信を確立するためのものということでしょうか。

構成に関して

前提として一通りのリソース(ネットワーク周りやECSやRDS)は構築してある状態になります。

大まかな構成図を以下に示します。

今回実装した箇所としてPrivateSubnet内にあるECSになります。
またPubliSubnet内にNATGatewayを配置しインターネットとの通信を可能にしています。

実装したリソースの詳細(Terraform)

IAMリソース

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 = ["*"]
  }

  statement {
    effect    = "Allow"
    actions   = ["iam:PassRole"]
    resources = [aws_iam_role.bastion_task_execution_role.arn]
  }
}


resource "aws_iam_role" "bastion_task_execution_role" {
  name               = "bastion-role"
  description        = "ECS Task Execution"
  assume_role_policy = data.aws_iam_policy_document.ecs_task_role_policy_data.json
}

resource "aws_iam_policy" "bastion_task_execution_policy" {
  name   = "bastion-policy"
  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.bastion_task_execution_role.name
  policy_arn = aws_iam_policy.bastion_task_execution_policy.arn
}

上記今回SSMを用いてECS経由でポートフォワードするためECSの実行ロールとしてssmmessages:系のポリシーを渡しています。

ECSリソース

Terraform

# SecurityGroup
resource "aws_security_group" "bastion_security_group" {
  name        = "${var.common_name}-bastion-sg"
  description = "Bastion Security Group"
  vpc_id      = module.ass_vpc_stg.id

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

# ECSリソース
resource "aws_ecs_cluster" "ecs_cluster" {
  name = "${var.common_name}-bastion-${var.environment}"
  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                   = "${var.common_name}-bastion-taskdef-${var.environment}"
  cpu                      = 256
  memory                   = 512
  network_mode             = "awsvpc"
  requires_compatibilities = ["FARGATE"]
  container_definitions    = file("${path.module}/bastion_definitions.tpl.json")
  execution_role_arn       = aws_iam_role.bastion_task_execution_role.arn
  task_role_arn            = aws_iam_role.bastion_task_execution_role.arn
}

resource "aws_ecs_service" "ecs_service" {
  name                   = "${var.common_name}-bastion-service-${var.environment}"
  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.bastion_security_group.id]
    subnets          = {サブネットのIDをlist形式で入力}
  }
  lifecycle {
    ignore_changes = [task_definition]
  }
}

resource "aws_cloudwatch_log_group" "bastion" {
  name              = "/ecs/bastion"
  retention_in_days = 180
}

variableを利用している箇所はよしなに差し替えてください。
ECS系リソースに加え、ECSサービス用のセキュリティグループとログを出力するためのCloudWatchグループを作成しております。

container_definitions    = file("${path.module}/bastion_definitions.tpl.json")

上記にて以下タスク定義のファイルを読み込んでいます。

タスク定義(bastion_definitions.tpl.json)

[
    {
      "name": "bastion",
      "image": "alpine/socat",
      "cpu": 256,
      "memory": 512,
      "essential": true,
      "pseudoTerminal": true,
      "logConfiguration": {
        "logDriver": "awslogs",
        "options": {
          "awslogs-create-group": "true",
          "awslogs-group": "/ecs/bastion",
          "awslogs-region": "ap-northeast-1",
          "awslogs-stream-prefix": "/ecs/bastion"
        }
      },

      "portMappings": [],
      "command": [
        "tcp4-listen:3306,reuseaddr,fork",
        "tcp-connect:{RDSのエンドポイント(ex. xxxxx.ap-northeast-1.rds.amazonaws.com)}"
      ],
      "environment": [
        {
            "name" : "MANAGED_INSTANCE_ROLE_NAME",
            "value" : "ass-bastion-role"
        }
      ]
    }
]

コストの観点から最小のスペックで構成しております。

コンテナイメージは以下を利用しております。
https://hub.docker.com/r/alpine/socat

実際にポートフォワードし接続する

aws ecs describe-tasksruntimeIdを入手

$ aws ecs describe-tasks \
  --cluster {ECSクラスターの名前} \
  --task {ECSタスクのID} \
  --query 'tasks[*].containers[*].runtimeId' \
  --output text

--query--output textを用いてruntimeIdのみ出力するようにしています。

ssm start-session実行

$ aws ssm start-session \
 --target ecs:{ECSクラスターの名前}_{ECSタスクのID}_{前項で入手したruntimeId} \
 --document-name AWS-StartPortForwardingSessionToRemoteHost \
 --parameters '{"host":["{RDSのエンドポイント}"],"portNumber":["{RDSのポート}(ex, "3306")"], "localPortNumber":["{ローカルPCの任意のポート}"(ex. "1234")]}'

上記を実行し、以下挙動になることを確認します。

Starting session with SessionId: botocore-session-xxxxxx-kagek5goi6glg6dx6fpopea7na.
Port 1234 opened for sessionId botocore-session-xxxxxx-kagek5goi6glg6dx6fpopea7na.
Waiting for connections...

別シェルを開きMySQLへ接続します。

$ mysql -h 127.0.0.1 -P {ssm start-sessionにて設定したローカルPCのポート(ex. 1234)} -u {設定したMySQLのユーザーネーム} -p

MySQLにて設定したパスワードを入力して接続できれば成功です。

Enter password:
Welcome to the MySQL monitor.  Commands end with ; or \g.
Your MySQL connection id is 48
Server version: 8.0.35 Source distribution

Copyright (c) 2000, 2023, Oracle and/or its affiliates.

Oracle is a registered trademark of Oracle Corporation and/or its
affiliates. Other names may be trademarks of their respective
owners.

aws ssm start-sessionを実行したシェルは以下のようになっています。

Exiting session with sessionId: botocore-session-xxxxxxx-64gns3gidtgd5kvnmbfogerll4.

まとめ

今回ECSを用いてRDSへ接続する方法を実装してみました。
以前業務ではEC2を用いて踏み台サーバーを用意していましたが、踏み台のEC2自体の管理も行う必要があり大変だと感じており、今回のようにECSを使ってAWSマネージドにすれば管理の工数も削減できてよさそうです。
積極的にマネージドサービスを使い,楽できるところは楽をして機能開発などに集中したいですよね、、
AWSでの踏み台はECSがデファクトスタンダードなのではと思いました。

Discussion