ecspressoを活用してECSのコンテナ経由でRDSに接続する

2022/01/22に公開

ECSのデプロイツールとして普段からecspressoを使用している。
ecspressoのサブコマンドにexecというECS Execを使うための機能があるが、最近execコマンドのオプションにポートフォワーディングをする機能が追加された。
これをうまく使えばECSのコンテナ経由でプライベートサブネットに配置されているRDSに対して接続できるのではないか!?と思い試してみた。

実現方法

socatという通信をリレーするためのコマンド実行するコンテナを用意して、ローカル環境からecspresso execでコンテナをポートフォワーディングすることによって、ポートフォワーディングしたコンテナを経由してローカル環境からRDSに接続する。

想定する環境

今回検証する環境はVPC内にパブリックサブネットとプライベートサブネットが構築されており、プライベートサブネットにRDS、パブリックサブネットにsocatコマンドが動作しているコンテナを含むECSタスクを配置されている環境とする。
実運用ではNATゲートウェイを用意して、ECSもRDSもプライベートサブネットに配置となるだろうが、今回は検証のための最小限の環境で構築する。

構成図

環境の構築

RDSはMySQLを使用する想定。
ECS Execを使用するためSessionManagerPluginを事前に導入しておく必要がある。
https://docs.aws.amazon.com/systems-manager/latest/userguide/session-manager-working-with-install-plugin.html

ecspresso管轄外のリソースはTerraformで構築。

terraform.tf
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 3.0"
    }
  }
}

provider "aws" {
  region = "ap-northeast-1"
}

# VPC
resource "aws_vpc" "main" {
  cidr_block = "10.0.0.0/16"
}
resource "aws_subnet" "main_public_1a" {
  vpc_id                  = aws_vpc.main.id
  cidr_block              = "10.0.1.0/24"
  availability_zone       = "ap-northeast-1a"
  map_public_ip_on_launch = true
  tags = {
    Name = "main_public_1a"
  }
}
resource "aws_subnet" "main_private_1a" {
  vpc_id            = aws_vpc.main.id
  cidr_block        = "10.0.2.0/24"
  availability_zone = "ap-northeast-1a"
  tags = {
    Name = "main_private_1a"
  }
}
resource "aws_route_table_association" "main_public_1a_rta" {
  subnet_id      = aws_subnet.main_public_1a.id
  route_table_id = aws_route_table.public_rt.id
}
resource "aws_route_table_association" "main_private_1a_rta" {
  subnet_id      = aws_subnet.main_private_1a.id
  route_table_id = aws_route_table.private_rt.id
}
resource "aws_subnet" "main_public_1c" {
  vpc_id                  = aws_vpc.main.id
  cidr_block              = "10.0.3.0/24"
  availability_zone       = "ap-northeast-1c"
  map_public_ip_on_launch = true
  tags = {
    Name = "main_public_1c"
  }
}
resource "aws_subnet" "main_private_1c" {
  vpc_id            = aws_vpc.main.id
  cidr_block        = "10.0.4.0/24"
  availability_zone = "ap-northeast-1c"
  tags = {
    Name = "main_private_1c"
  }
}
resource "aws_route_table_association" "main_public_1c_rta" {
  subnet_id      = aws_subnet.main_public_1c.id
  route_table_id = aws_route_table.public_rt.id
}
resource "aws_route_table_association" "main_private_1c_rta" {
  subnet_id      = aws_subnet.main_private_1c.id
  route_table_id = aws_route_table.private_rt.id
}
resource "aws_internet_gateway" "main_igw" {
  vpc_id = aws_vpc.main.id
  tags = {
    Name = "main"
  }
}
resource "aws_route_table" "public_rt" {
  vpc_id = aws_vpc.main.id
  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = aws_internet_gateway.main_igw.id
  }
}
resource "aws_route_table" "private_rt" {
  vpc_id = aws_vpc.main.id
}
resource "aws_default_security_group" "default" {
  vpc_id = aws_vpc.main.id
  ingress {
    protocol  = -1
    self      = true
    from_port = 0
    to_port   = 0
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}
resource "aws_security_group" "main_db" {
  name   = "main_db"
  vpc_id = aws_vpc.main.id
  ingress {
    from_port       = 3306
    to_port         = 3306
    protocol        = "tcp"
    security_groups = [aws_default_security_group.default.id]
  }
  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

# RDS
resource "aws_db_instance" "default" {
  allocated_storage    = 10
  engine               = "mysql"
  engine_version       = "5.7"
  instance_class       = "db.t3.micro"
  name                 = "mydb"
  username             = "test"
  password             = "password"
  parameter_group_name = "default.mysql5.7"
  skip_final_snapshot  = true
  db_subnet_group_name = aws_db_subnet_group.private_subnet_group.name
  vpc_security_group_ids = [
    aws_security_group.main_db.id
  ]
}
resource "aws_db_subnet_group" "private_subnet_group" {
  subnet_ids = [aws_subnet.main_private_1a.id, aws_subnet.main_private_1c.id]
}

# ECS
resource "aws_ecs_cluster" "relay" {
  name = "relay"
}

# IAM
resource "aws_iam_role" "ecs_task_exection_role" {
  name                = "ECSTaskExectionRole"
  assume_role_policy  = data.aws_iam_policy_document.ecs_assume_role_policy.json
  managed_policy_arns = ["arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"]
}
resource "aws_iam_role" "ecs_task_role_for_ssm" {
  name               = "ECSTaskRoleForSSM"
  assume_role_policy = data.aws_iam_policy_document.ecs_assume_role_policy.json
  inline_policy {
    name = "ssmpolicy"
    policy = jsonencode(
      {
        Version = "2012-10-17"
        Statement = [
          {
            Effect = "Allow"
            Action = [
              "ssmmessages:CreateControlChannel",
              "ssmmessages:CreateDataChannel",
              "ssmmessages:OpenControlChannel",
              "ssmmessages:OpenDataChannel"
            ]
            Resource = "*"
          }
        ]
      }
    )
  }
}
data "aws_iam_policy_document" "ecs_assume_role_policy" {
  version = "2012-10-17"
  statement {
    actions = ["sts:AssumeRole"]
    principals {
      type        = "Service"
      identifiers = ["ecs-tasks.amazonaws.com"]
    }
    effect = "Allow"
  }
}

ecspressoの設定ファイルは以下のように記述に。
pluginでtfstateからのリソース名の読み込みをサポートしているで、それを利用する。

ecspresso.yml
region: ap-northeast-1
cluster: relay
service: relay
service_definition: ecs-service-def.json
task_definition: ecs-task-def.json
timeout: 10m0s
plugins:
  - name: tfstate
    config:
      path: terraform.tfstate
ecs-service-def.json
{
  "deploymentConfiguration": {
    "deploymentCircuitBreaker": {
      "enable": false,
      "rollback": false
    },
    "maximumPercent": 200,
    "minimumHealthyPercent": 100
  },
  "desiredCount": 1,
  "enableECSManagedTags": true,
  "enableExecuteCommand": true,
  "launchType": "FARGATE",
  "loadBalancers": [],
  "networkConfiguration": {
    "awsvpcConfiguration": {
      "assignPublicIp": "ENABLED",
      "securityGroups": ["{{ tfstate `aws_default_security_group.default.id` }}"],
      "subnets": [
        "{{ tfstate `aws_subnet.main_public_1a.id` }}",
        "{{ tfstate `aws_subnet.main_public_1c.id` }}"
      ]
    }
  },
  "placementConstraints": [],
  "placementStrategy": [],
  "platformVersion": "LATEST",
  "schedulingStrategy": "REPLICA",
  "serviceRegistries": [],
  "tags": []
}
ecs-task-def.json
{
  "containerDefinitions": [
    {
      "command": [
        "tcp4-listen:3306,reuseaddr,fork",
        "tcp-connect:{{ tfstate `aws_db_instance.default.endpoint` }}"
      ],
      "environment": [],
      "essential": true,
      "image": "alpine/socat",
      "mountPoints": [],
      "name": "socat",
      "portMappings": [],
      "volumesFrom": []
    }
  ],
  "cpu": "256",
  "executionRoleArn": "{{ tfstate `aws_iam_role.ecs_task_exection_role.arn` }}",
  "family": "socat",
  "memory": "512",
  "networkMode": "awsvpc",
  "placementConstraints": [],
  "requiresCompatibilities": ["FARGATE"],
  "taskRoleArn": "{{ tfstate `aws_iam_role.ecs_task_role_for_ssm.arn` }}",
  "volumes": []
}

これらのファイルを同じディレクトリに配置すれば準備完了。

.
├── ecs-service-def.json
├── ecs-task-def.json
├── ecspresso.yml
└── terraform.tf

以下の手順で各種リソースを作成する。

# Terraformでのリソース作成
$ terraform init
$ terraform apply

# ecspressoでのリソース作成
$ ecspresso create

ECSのタスク定義で使用しているコンテナのイメージはalpine/socatを使用している。
commandで実行するコマンドの内容をtcp4-listen:3306,reuseaddr,fork tcp-connect:<RDSのエンドポイント名>とすることにより、立ち上がったコンテナは3306ポートへの通信をRDSへリレーすることになる。

接続確認

リソースが作成されるとECS上にsocatが動作しているコンテナのタスクが立ち上がるためecspresso execでポートフォワーディングを行う。

$ ecspresso exec --port-forward --local-port 3306 --port 3306

対象のコンテナを選んで接続すると「Waiting for connections...」というメッセージが表示されるため、そのShellはそのままで、新しく別のShellを開いて127.0.0.1に対してmysqlの接続を試みる。

$ mysql -h 127.0.0.1 -u test -ppassword

これでRDSに接続されれれば成功、ecspresso execをした方のShellでは「Connection accepted for session」というメッセージが表示されているはず。

リソースの削除

検証が完了したらリソースを削除。

$ ecspresso scale --tasks 0
$ ecspresso delete
$ terraform destroy

Discussion