ECS Service ConnectをTerraformでデプロイしてみた

2023.01.03

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

こんにちは、つくぼし(tsukuboshi0755)です!

皆さんは、re:Invent期間中に発表された、ECSでのマイクロサービス間通信に関する新機能であるService Connectはご存知でしょうか。

本機能により、ECSサービス間の通信を、従来より簡単に設定できるようになっています。

今回はこのService Connectについて、既にTerraformでもリリースされているので実装してみたいと思います!

概要

1つのCloudMap名前空間に、クライアント用/サーバー用のNginxコンテナをそれぞれ配置するAWS構成を、Terraformコードでデプロイします。

また動作確認の手順としては、クライアント側のECSサービスに対してECS Execで接続した後、サーバー側のECSサービスに対してcurlコマンドでリクエストを送信し確認してみます。

なお、デプロイツールにTerraformを使用する点以外は、こちらのCDK記事とほぼ同様の実施内容となります。

Terraformコードのポイント

名前空間

Service Connectを使用する場合、Terraformでは事前にCloudMap名前空間を作成する必要があります。

CloudMap名前空間は、aws_service_discovery_http_namespaceリソースで作成できます。

今回は新たにtest-namespaceを作成します。

ecs.tf

resource "aws_service_discovery_http_namespace" "namespace" {
  name = "test-namespace"
}

タスク定義

ECSタスク定義用のJSONコンテナ定義ファイルにおいて、PortMappingに対し、Service Connect用の追加設定が必要になります。

nameには、Service Connectで使用するポート名を入力する必要があります。
今回はwebserverとしています。

appProtocolでは、Service Connectプロキシ用のプロトコルを"tcp/http/grpc"のいずれかから選択できます。
今回はhttpを選択します。

container_definitions.json

[
  {
    "name": "nginx-container",
    "image": "nginx:latest",
    "essential": true,
    "memory": 128,
    "portMappings": [
      {
        // ServiceConnectのポート名で必要
        "name": "webserver",
        "protocol": "tcp",
        "containerPort": 80,
        // ServiceConnectプロキシ用のプロトコルを、"tcp/http/grpc"のいずれかで指定可能
        "appProtocol": "http"
      }
    ],
    "logConfiguration": {
      "logDriver": "awslogs",
      "options": {
        "awslogs-group": "/ecs/nginx",
        "awslogs-region": "ap-northeast-1",
        "awslogs-stream-prefix": "nginx"
      }
    }
  }
]

サービス(Service Connect設定)

ECSサービスを設定するaws_ecs_serviceリソースに対して、service_connect_configurationの追記が必要になります。

クライアント側ECSサービスについては、以下の通りとなります。

初めに、Service Connect機能をONにするために、enabledtrueで指定します。

次に、Service Connectで作成されるEnvoyコンテナのログ設定を、log_configurationに記載します。(項目はJSONコンテナ定義のlogConfigurationフィールドとほぼ同義です)

最後に、namespaceに事前に作成したCloudMap名前空間のARNを指定します。

ecs.tf

resource "aws_ecs_service" "client" {
  name                   = "nginx-client"
  ...

  service_connect_configuration {
    enabled = true

    log_configuration {
      log_driver = "awslogs"
      options = {
        awslogs-group         = "/ecs/svccon-client"
        awslogs-region        = "ap-northeast-1"
        awslogs-stream-prefix = "svccon-client"
      }
    }

    namespace = aws_service_discovery_http_namespace.namespace.arn
  }
}

サーバー側ECSサービスについては、クライアント側ECSサービスの設定項目に加えて、serviceの設定が必要になります。

client_aliasでは、Service Connectプロキシで使用するポート番号を指定します。 今回は80を指定します。

port_nameでは、JSONコンテナ定義におけるportMappingsフィールドのnameで指定したポート名を入力します。

ecs.tf

resource "aws_ecs_service" "server" {
  name                   = "nginx-server"
  ...

  service_connect_configuration {
    enabled = true

    log_configuration {
      log_driver = "awslogs"
      options = {
        awslogs-group         = "/ecs/svccon-server"
        awslogs-region        = "ap-northeast-1"
        awslogs-stream-prefix = "svccon-server"
      }
    }

    namespace = aws_service_discovery_http_namespace.namespace.arn

    ## 追加設定
    service {
      client_alias {
        port = 80
      }
      port_name = "webserver"
    }
  }
}

なお細かい箇所ですが、log_configurationで指定したCloudWatch Log Groupは自動では作成されないため、このままですとコンテナが起動できません。

optionsawslogs-create-grouptrueにする事でもLog Groupを作成できますが、今回はCloudWatchLog用のcwlogs.tfファイルを別途作成しておきます。

cwlogs.tf

resource "aws_cloudwatch_log_group" "log_nginx" {
  name = "/ecs/nginx"
}

# クライアント側ECSサービス用ServiceConnectロググループ
resource "aws_cloudwatch_log_group" "log_connect_client" {
  name = "/ecs/svccon-client"
}

# サーバー側ECSサービス用ServiceConnectロググループ
resource "aws_cloudwatch_log_group" "log_connect_server" {
  name = "/ecs/svccon-server"
}

動作確認

上記のポイントを踏まえた上でTerraformコードを作成し、ECSをデプロイした後、ECSサービス間通信が実現できているか動作確認します。

それではクライアント側のECSサービスにECS Execでログインします。

The Session Manager plugin was installed successfully. Use the AWS CLI to start a session.


Starting session with SessionId: ecs-execute-command-0c49e0c07efac4988

#

クライアント側のECSサービスで、サーバー側のECSサービスに対してcurlコマンドでリクエストを送信します。

# curl http://webserver.test-namespace:80
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
<style>
html { color-scheme: light dark; }
body { width: 35em; margin: 0 auto;
font-family: Tahoma, Verdana, Arial, sans-serif; }
</style>
</head>
<body>
<h1>Welcome to nginx!</h1>
<p>If you see this page, the nginx web server is successfully installed and
working. Further configuration is required.</p>

<p>For online documentation and support please refer to
<a href="http://nginx.org/">nginx.org</a>.<br/>
Commercial support is available at
<a href="http://nginx.com/">nginx.com</a>.</p>

<p><em>Thank you for using nginx.</em></p>
</body>
</html>

問題なく接続できました!
なお/etc/hostsの中身を見ると、サーバー側のECSサービスに関するDNS名が書き込まれている事が分かります。

# cat /etc/hosts
127.0.0.1 localhost
10.0.1.220 ip-10-0-1-220.ap-northeast-1.compute.internal
127.255.0.1 webserver.test-namespace
2600:f0f0:0:0:0:0:0:1 webserver.test-namespace

Terraformコード全体

最後に、今回AWSにデプロイしたTerraformコード全体を記載しておきます。

$ tree
.
├── container_definitions.json
├── cwlogs.tf
├── ecs.tf
├── iam.tf
├── provider.tf
├── sg.tf
├── version.tf
└── vpc.tf
container_definitions.json
[
  {
    "name": "nginx-container",
    "image": "nginx:latest",
    "essential": true,
    "memory": 128,
    "portMappings": [
      {
        "name": "webserver",
        "protocol": "tcp",
        "containerPort": 80,
        "appProtocol": "http"
      }
    ],
    "logConfiguration": {
      "logDriver": "awslogs",
      "options": {
        "awslogs-group": "/ecs/nginx",
        "awslogs-region": "ap-northeast-1",
        "awslogs-stream-prefix": "nginx"
      }
    }
  }
]
cwlogs.tf
# ロググループ
resource "aws_cloudwatch_log_group" "log_nginx" {
  name = "/ecs/nginx"
}

resource "aws_cloudwatch_log_group" "log_connect_client" {
  name = "/ecs/svccon-client"
}

resource "aws_cloudwatch_log_group" "log_connect_server" {
  name = "/ecs/svccon-server"
}
ecs.tf
# タスク定義
resource "aws_ecs_task_definition" "task" {
  family                   = "nginx-task"
  cpu                      = "256"
  memory                   = "512"
  network_mode             = "awsvpc"
  task_role_arn            = aws_iam_role.task_role.arn
  execution_role_arn       = aws_iam_role.execution_role.arn
  requires_compatibilities = ["FARGATE"]
  container_definitions    = file("./container_definitions.json")
}

# クラスター
resource "aws_ecs_cluster" "cluster" {
  name = "test-cluster"
}

# 名前空間
resource "aws_service_discovery_http_namespace" "namespace" {
  name = "test-namespace"
}


# サービス(クライアント側)
resource "aws_ecs_service" "client" {
  name                   = "nginx-client"
  cluster                = aws_ecs_cluster.cluster.arn
  task_definition        = aws_ecs_task_definition.task.arn
  desired_count          = 1
  launch_type            = "FARGATE"
  platform_version       = "1.4.0"
  enable_execute_command = true

  network_configuration {
    assign_public_ip = true
    security_groups  = [aws_security_group.sg.id]
    subnets          = [aws_subnet.public_1.id, aws_subnet.public_2.id]
  }

  service_connect_configuration {
    enabled = true

    log_configuration {
      log_driver = "awslogs"
      options = {
        awslogs-group         = "/ecs/svccon-client"
        awslogs-region        = "ap-northeast-1"
        awslogs-stream-prefix = "svccon-client"
      }
    }

    namespace = aws_service_discovery_http_namespace.namespace.arn
  }
}

# サービス(サーバー側)
resource "aws_ecs_service" "server" {
  name                   = "nginx-server"
  cluster                = aws_ecs_cluster.cluster.arn
  task_definition        = aws_ecs_task_definition.task.arn
  desired_count          = 1
  launch_type            = "FARGATE"
  platform_version       = "1.4.0"
  enable_execute_command = true

  network_configuration {
    assign_public_ip = true
    security_groups  = [aws_security_group.sg.id]
    subnets          = [aws_subnet.public_1.id, aws_subnet.public_2.id]
  }

  service_connect_configuration {
    enabled = true

    log_configuration {
      log_driver = "awslogs"
      options = {
        awslogs-group         = "/ecs/svccon-server"
        awslogs-region        = "ap-northeast-1"
        awslogs-stream-prefix = "svccon-server"
      }
    }

    namespace = aws_service_discovery_http_namespace.namespace.arn

    service {
      client_alias {
        port = 80
      }
      port_name = "webserver"
    }
  }
}
iam.tf
# ECSタスクロール
data "aws_iam_policy_document" "ecs_task_doc" {
  statement {
    effect = "Allow"
    principals {
      type        = "Service"
      identifiers = ["ecs-tasks.amazonaws.com"]
    }
    actions = ["sts:AssumeRole"]
  }
}

resource "aws_iam_role" "task_role" {
  name               = "nginx-task-role"
  assume_role_policy = data.aws_iam_policy_document.ecs_task_doc.json
}

data "aws_iam_policy_document" "ecs_exec_doc" {
  ## SSMサービス関連のアクセス許可
  version = "2012-10-17"
  statement {
    effect = "Allow"
    actions = [
      "ssmmessages:CreateControlChannel",
      "ssmmessages:CreateDataChannel",
      "ssmmessages:OpenControlChannel",
      "ssmmessages:OpenDataChannel"
    ]
    resources = ["*"]
  }
}

resource "aws_iam_policy" "ecs_exec_policy" {
  name   = "AmazonECSExecPolicy"
  policy = data.aws_iam_policy_document.ecs_exec_doc.json
}

resource "aws_iam_role_policy_attachment" "task_attachement" {
  role       = aws_iam_role.task_role.name
  policy_arn = aws_iam_policy.ecs_exec_policy.arn
}

# ECSタスク実行ロール
resource "aws_iam_role" "execution_role" {
  name               = "nginx-execution-role"
  assume_role_policy = data.aws_iam_policy_document.ecs_task_doc.json
}

resource "aws_iam_role_policy_attachment" "execution_attachement" {
  role       = aws_iam_role.execution_role.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}
provider.tf
# プロバイダー情報
provider "aws" {
  region = "ap-northeast-1"
}
sg.tf
# デフォルトセキュリティグループ(デフォルトルール削除のため)
resource "aws_default_security_group" "dfsg" {
  vpc_id = aws_vpc.vpc.id
}

# ECSサービス用セキュリティグループ
resource "aws_security_group" "sg" {
  name        = "ecs-sg"
  description = "ecs-sg"
  vpc_id      = aws_vpc.vpc.id

  tags = {
    Name = "nginx-sg"
  }
}

resource "aws_security_group_rule" "ingress" {
  type              = "ingress"
  from_port         = 80
  to_port           = 80
  protocol          = "tcp"
  cidr_blocks       = ["0.0.0.0/0"]
  security_group_id = aws_security_group.sg.id
}

resource "aws_security_group_rule" "egress" {
  type              = "egress"
  from_port         = 0
  to_port           = 0
  protocol          = -1
  cidr_blocks       = ["0.0.0.0/0"]
  security_group_id = aws_security_group.sg.id
}
version.tf
# バージョン情報
terraform {
  required_version = ">=1.0.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 4.0"
    }
  }
}
vpc.tf
# VPC
resource "aws_vpc" "vpc" {
  cidr_block           = "10.0.0.0/16"
  enable_dns_hostnames = true
  enable_dns_support   = true

  tags = {
    Name = "test-vpc"
  }
}

# サブネット
resource "aws_subnet" "public_1" {
  vpc_id                  = aws_vpc.vpc.id
  cidr_block              = "10.0.0.0/24"
  map_public_ip_on_launch = true
  availability_zone       = "ap-northeast-1a"

  tags = {
    Name = "test-public-subnet-1"
  }
}

resource "aws_subnet" "public_2" {
  vpc_id                  = aws_vpc.vpc.id
  cidr_block              = "10.0.1.0/24"
  map_public_ip_on_launch = true
  availability_zone       = "ap-northeast-1c"

  tags = {
    Name = "test-public-subnet-2"
  }
}

# インターネットゲートウェイ
resource "aws_internet_gateway" "igw" {
  vpc_id = aws_vpc.vpc.id

  tags = {
    Name = "test-igw"
  }
}

# ルートテーブル
resource "aws_route_table" "rtb" {
  vpc_id = aws_vpc.vpc.id

  tags = {
    Name = "test-public-rtb"
  }
}

resource "aws_route" "tf_route_igw" {
  route_table_id         = aws_route_table.rtb.id
  gateway_id             = aws_internet_gateway.igw.id
  destination_cidr_block = "0.0.0.0/0"
}

resource "aws_route_table_association" "associate_1" {
  route_table_id = aws_route_table.rtb.id
  subnet_id      = aws_subnet.public_1.id
}

resource "aws_route_table_association" "associate_2" {
  route_table_id = aws_route_table.rtb.id
  subnet_id      = aws_subnet.public_2.id
}

最後に

今回はECS Service ConnectをTerraformで実装してみました。

ECSでのマイクロサービス間通信を実現する上で、Service ConnectはELB/Service Discoveryに代わる有力な選択肢になり得ます。

ECSでのマイクロサービス間通信設定に悩んでいる方は、ぜひService Connectの導入を検討してみてはいかがでしょうか。

なお、他の記事でAWSマネジメントコンソール/CDK/Copilot CLIでのService Connect設定方法が紹介されているので、ぜひご参照ください。

以上、つくぼし(tsukuboshi0755)でした!