Amazon ECS上でPrivateLinkとTerraformを使用してプライベートコンテナにアクセスする

多くの企業がコンテナオーケストレーションサービス(ECSやEKSなど)を使用して、マイクロサービス環境をホストしています。これらのマイクロサービスは、他の顧客がアクセスする必要があるAPIを提供することがあります。
顧客もAWSを使用している場合、すべての通信をAWS内でプライベートに保つのが最善の解決策です。_顧客_のVPC内のサービスが、APIを提供する_プロバイダー_のVPCにホストされているコンテナと通信できるべきです。このデモでは、APIホストにECSを使用します。

私はAWSアカウントを1つ使用しますが、VPCは2つ使用します。1つはAPIプロバイダー用、もう1つは顧客(=API利用者)用です。実際にはそれぞれが別々のアカウントを持っていますが、このセットアップでは大きな違いはありません(後述)。もう1つ重要な点は、基本的なものにするために、APIをHTTP経由で提供するということです。HTTPSの使用が推奨されます。

APIプロバイダーは、Fargate(サーバレス)を使用してECS内でコンテナをホストし、その前にプライベートなアプリケーションロードバランサー(ALB)を使用しています。顧客のVPCとAPIプロバイダーのVPCの間のプライベート接続を可能にするために、AWS PrivateLinkをセットアップする必要があります。AWS PrivateLinkは、VPC間またはAWSサービス間のプライベート接続を提供することができます。PrivateLinkはアプリケーションロードバランサーと直接接続することはできません。ネットワークロードバランサー(NLB)が必要です。これにより、アーキテクチャはこのようになります:

このデモで使用するサンプルAPIは、タスクAPIで、すべてのタスクとidに基づいた特定のタスクを印刷できます。

$ docker run -d -p 8080:8080 lvthillo/python-flask-api
$ curl localhost/api/tasks
[{"id": 1, "name": "task1", "description": "This is task 1"}, {"id": 2, "name": "task2", "description": "This is task 2"}, {"id": 3, "name": "task3", "description": "This is task 3"}]
$ curl localhost/api/task/2
{"id": 2, "name": "task2", "description": "This is task 2"}

インフラストラクチャをセットアップするために、私はTerraformを使用しています。コードは私のGitHubで利用可能です。2つのVPCを作成する必要があります。私はVPCを作成する基本的なモジュールを作成しました。これには2つのAZにわたる4つのサブネットが含まれており、NATゲートウェイとインターネットゲートウェイをデプロイし、ルートテーブルも適切に設定されています。

module "network" {
  source                 = "./modules/network"
  vpc_name               = "vpc-1"
  vpc_cidr               = var.vpc
  subnet_1a_public_cidr  = var.pub_sub_1a
  subnet_1b_public_cidr  = var.pub_sub_1b
  subnet_1a_private_cidr = var.priv_sub_1a
  subnet_1b_private_cidr = var.priv_sub_1b
}

次に、APIプロバイダーのVPC内にECSクラスター、サービス、タスク定義をデプロイする必要があります。その前にALBがデプロイされます。
ecsモジュールは2つのサンプルAPIコンテナをデプロイします。

module "ecs" {
  source               = "./modules/ecs"
  ecs_subnets          = module.network.private_subnets
  ecs_container_name   = "demo"
  ecs_port             = var.ecs_port # 我々は networkMode awsvpc を使用しているので、ホストとコンテナのポートは一致するべきです
  ecs_task_def_name    = "demo-task"
  ecs_docker_image     = "lvthillo/python-flask-api"
  vpc_id               = module.network.vpc_id
  alb_sg               = module.alb.alb_sg
  alb_target_group_arn = module.alb.alb_tg_arn
}

module "alb" {
  source         = "./modules/alb"
  alb_subnets    = module.network.private_subnets
  vpc_id         = module.network.vpc_id
  ecs_sg         = module.ecs.ecs_sg
  alb_port       = var.alb_port
  ecs_port       = var.ecs_port
  default_vpc_sg = module.network.default_vpc_sg
  vpc_cidr       = var.vpc # AWS PrivateLink経由でロードバランサーリスナーポートへのクライアントトラフィックを許可する
}

ECSサービスのセキュリティグループは、ALBからの接続のみを許可します。

resource "aws_security_group_rule" "ingress" {
  type                     = "ingress"
  description              = "Allow ALB to ECS"
  from_port                = var.ecs_port
  to_port                  = var.ecs_port
  protocol                 = "tcp"
  security_group_id        = aws_security_group.ecs_sg.id
  source_security_group_id = var.alb_sg
}

NLBにはセキュリティグループがないため、ALBのセキュリティグループで参照することはできません。ALBアクセスを制限する別の方法を見つける必要があります。
albモジュールは、ALBセキュリティグループのソースとしてAPIプロバイダーのVPCサブネットを使用します。これは、APIプロバイダーのVPC内のすべてがALBと通信できることを意味します。
本番環境では、NLBを個別のNLBサブネットにデプロイし、それらのサブネットCIDR範囲に制限することが有効です。
それでは、プライベートALBの前にこのプライベートNLBをデプロイしましょう。

module "nlb" {
  source            = "./modules/nlb"
  nlb_subnets       = module.network.private_subnets
  vpc_id            = module.network.vpc_id
  alb_arn           = module.alb.alb_arn
  nlb_port          = var.nlb_port
  alb_listener_port = module.alb.alb_listener_port
}

ここで少し新しいAWSの魔法を使います。2021年9月以降、ALBをNLBのターゲットに登録することが可能になりました
過去にはこれが不可能でした。私は、ALBのIPを監視し、必要に応じてNLBターゲットグループを更新するLambdaをデプロイしたTerraformモジュールを作成しました。もはやそれが必要なくなったことをとても嬉しく思います!
今では、NLBターゲットグループのtarget_typeとしてalbを定義することができます。

resource "aws_lb_target_group" "nlb_tg" {
  name        = var.nlb_tg_name
  port        = var.alb_listener_port
  protocol    = "TCP"
  target_type = "alb"
  vpc_id      = var.vpc_id
}

次に、AWS PrivateLinkで動作するサービス(エンドポイントサービスとして参照)をデプロイすることができます。本番環境では、allowed_principalsを追加して、顧客のアカウントIDをホワイトリスト登録する必要があります。

resource "aws_vpc_endpoint_service" "privatelink" {
  acceptance_required        = false
  network_load_balancer_arns = [module.nlb.nlb_arn]
  #checkov:skip=CKV_AWS_123:For this demo I don't need to configure the VPC Endpoint Service for Manual Acceptance
}

APIプロバイダーの観点からすべての準備が整いました。VPC、ECSインフラ、ALB、NLB、およびPrivateLinkをデプロイしました。
顧客のVPCは同じnetworkモジュールを使用してデプロイできます。顧客はPrivateLinkに接続するためのVPCエンドポイントが必要です。
VPCエンドポイントFQDNは、AWS PrivateLinkエンドポイントFQDNを指すようになります。
これにより、DNSエンドポイントがアクセスできるVPCエンドポイントサービスへの弾性ネットワークインタフェースが作成されます。

resource "aws_vpc_endpoint" "vpce" {
  vpc_id             = module.network_added.vpc_id
  service_name       = aws_vpc_endpoint_service.privatelink.service_name
  vpc_endpoint_type  = "Interface"
  security_group_ids = [aws_security_group.vpce_sg.id]
  subnet_ids         = module.network_added.public_subnets
}

最後に、顧客VPC(公開サブネット)にEC2をデプロイして、統合をテストします。これには、このTerraformモジュールを使用しています。
variables.tfkey変数を、お持ちのAWS SSHキーの名前に更新してください。

module "ec2_instance_added" {
  source                      = "terraform-aws-modules/ec2-instance/aws"
  version                     = "~> 3.0"
  name                        = "test-instance-vpc-2"
  associate_public_ip_address = true
  ami                         = "ami-05cd35b907b4ffe77" # eu-west-1 specific
  instance_type               = "t2.micro"
  key_name                    = var.key
  vpc_security_group_ids      = [aws_security_group.ssh_sg_added.id]
  subnet_id                   = element(module.network_added.public_subnets, 0)
}

セットアップをテストするためにTerraformスタックをデプロイしてください。

$ git clone https://github.com/lvthillo/aws-ecs-privatelink.git
$ cd aws-ecs-privatelink
$ terraform init
$ terraform apply

Terraformのアウトプットをチェックし、顧客のVPCにデプロイされたEC2インスタンスにSSHで接続します。次に、VPCエンドポイントでcurlを試してみてください。

$ ssh -i your-key.pem ec2-user@54.247.23.167
$ curl http://vpce-07715651ecce291f6-x6r0avfj.vpce-svc-0beea6830e4c68cd2.eu-west-1.vpce.amazonaws.com/api/tasks
[{"id": 1, "name": "task1", "description": "This is task 1"}, {"id": 2, "name": "task2", "description": "This is task 2"}, {"id": 3, "name": "task3", "description": "This is task 3"}]
$ curl http://vpce-07715651ecce291f6-x6r0avfj.vpce-svc-0beea6830e4c68cd2.eu-west-1.vpce.amazonaws.com/api/task/2
{"id": 2, "name": "task2", "description": "This is task 2"}

それで終わりです!顧客はAWS内にとどまりながら、PrivateLinkを使用してECSでホストされているAPIにアクセスすることができました。
いくつか重要な注意点がありますが、それについてはこの投稿の下部に記述されています。デモスタックをterraform destroyを使って削除するのを忘れないでください!

お楽しみいただけましたか!

こちらの記事はdev.toの良い記事を日本人向けに翻訳しています。
https://dev.to/aws-builders/access-private-containers-on-amazon-ecs-using-privatelink-and-terraform-2odf