ECS サービスディスカバリを利用してサービス間通信をTerrafromで実現
はじめに
以前、コンテナ間通信の記事を作成しました。
今回はコンテナ間ではなくて、サービス間の通信を実現します。
インターネットを介さずに、プライベートネットワーク内部でのサービス間通信を実現します。
サービスディスカバリ(サービス検出)とは
AWS Cloud Map というサービスを利用して、同じVPC内のECCServiceのタスクに対して名前解決をしてくれるサービスです。 ECS Serviceに対して設定していきます。
AWS構成図
今回、実現する構成図です。
2つのサービスを作成します。
backend_service
api.ecs-navi.siteに対するリクエストを受け取って、phpinfo() を返却します。 ※前回同様
nginx_proxy_service
ecs-navi.siteに対するリクエストを受け取って、サービスディスカバリを利用してbackend_serviceにリクエストします。
つまり、api.ecs-navi.siteにリクエストされても、ecs-navi.siteにリクエストされても、backend_serviceのphpinfo() を返却します。
ユーザー
|
| HTTPS (443)
v
[Route53]
|
v
[Application Load Balancer (ALB)] -- パブリックサブネット
| |
| Host: ecs-navi.site | Host: api.ecs-navi.site
v v
-------------------------------- -- プライベートサブネット
[nginx_proxy_tg] [backend_tg]
| |
v v
[nginx_proxy_service] [backend_service]
(プロキシnginx) (nginx + api)
| |
| HTTP (80) |
|------------------------>|
サービスディスカバリ
(backend.ecs-navi.local)
コンテナイメージを作成
backend_service
こちらは、以前の記事と同様です。
Dockerfile
FROM php:8.2-fpm
WORKDIR /var/www
COPY ./docker/api/php.ini /usr/local/etc/php/
RUN apt-get update && apt-get -y upgrade
RUN apt-get install -y zip \
unzip \
vim
WORKDIR /var/www/src/public
RUN touch index.php
RUN chmod 777 index.php
RUN echo "<?php phpinfo();" > index.php
php.ini
[Date]
date.timezone = "Asia/Tokyo"
[mbstring]
mbstring.internal_encoding = "UTF-8"
mbstring.language = "Japanese"
memory_limit = 256M
nginx_proxy_service
Dockerfile
FROM nginx:stable-alpine
COPY ./docker/nginx/default.conf /etc/nginx/conf.d/default.conf
default.conf
今回のポイントの1つは、nginx_proxyコンテナ内部で、passを http://backend.ecs-navi.local:80 に設定しています。後に記述しますが、サービスディスカバリによって、backend_serviceへの名前解決をしてくれるため、nginx_proxyコンテナ内からVPC内部のプライベートIPへリクエストが可能になります。
server {
listen 80;
location / {
proxy_pass http://backend.ecs-navi.local:80;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
ECRへPUSH
ECRのリポジトリへPUSHしてください。
<account-id>.dkr.ecr.ap-northeast-1.amazonaws.com/ecs-navi-nginx:latest
<account-id>.dkr.ecr.ap-northeast-1.amazonaws.com/ecs-navi-nginx-proxy:latest
TerraformでECSのリソース作成
NW
NWは以前と同様です.
// VPC
resource "aws_vpc" "ecs_navi_vpc" {
cidr_block = "10.0.0.0/16"
enable_dns_support = true
enable_dns_hostnames = true
tags = {
Name = "${var.service_name}-vpc"
}
}
// Subnet
resource "aws_subnet" "ecs_navi_subnet_public_1a" {
vpc_id = aws_vpc.ecs_navi_vpc.id
cidr_block = "10.0.1.0/24"
availability_zone = "ap-northeast-1a"
map_public_ip_on_launch = true
tags = {
Name = "${var.service_name}-subnet-public-1a"
}
}
resource "aws_subnet" "ecs_navi_subnet_public_1c" {
vpc_id = aws_vpc.ecs_navi_vpc.id
cidr_block = "10.0.2.0/24"
availability_zone = "ap-northeast-1c"
map_public_ip_on_launch = true
tags = {
Name = "${var.service_name}-subnet-public-1c"
}
}
resource "aws_subnet" "ecs_navi_subnet_private_1a" {
vpc_id = aws_vpc.ecs_navi_vpc.id
cidr_block = "10.0.10.0/24"
availability_zone = "ap-northeast-1a"
map_public_ip_on_launch = false
tags = {
Name = "${var.service_name}-subnet-private-1a"
}
}
resource "aws_subnet" "ecs_navi_subnet_private_1c" {
vpc_id = aws_vpc.ecs_navi_vpc.id
cidr_block = "10.0.11.0/24"
availability_zone = "ap-northeast-1c"
map_public_ip_on_launch = false
tags = {
Name = "${var.service_name}-subnet-private-1c"
}
}
// Internet Gateway
resource "aws_internet_gateway" "ecs_navi_igw" {
vpc_id = aws_vpc.ecs_navi_vpc.id
}
// Route Table
resource "aws_route_table" "ecs_navi_route_table" {
vpc_id = aws_vpc.ecs_navi_vpc.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.ecs_navi_igw.id
}
}
// Route Table Association
resource "aws_route_table_association" "ecs_navi_subnet_public_1a_association" {
subnet_id = aws_subnet.ecs_navi_subnet_public_1a.id
route_table_id = aws_route_table.ecs_navi_route_table.id
}
resource "aws_route_table_association" "ecs_navi_subnet_public_1c_association" {
subnet_id = aws_subnet.ecs_navi_subnet_public_1c.id
route_table_id = aws_route_table.ecs_navi_route_table.id
}
// eip
resource "aws_eip" "ecs_navi_eip" {
domain = "vpc"
}
// Nat Gateway
resource "aws_nat_gateway" "ecs_navi_nat_gateway" {
subnet_id = aws_subnet.ecs_navi_subnet_public_1a.id
allocation_id = aws_eip.ecs_navi_eip.id
}
// Route Table
resource "aws_route_table" "ecs_navi_route_table_private" {
vpc_id = aws_vpc.ecs_navi_vpc.id
route {
cidr_block = "0.0.0.0/0"
nat_gateway_id = aws_nat_gateway.ecs_navi_nat_gateway.id
}
}
// Route Table Association
resource "aws_route_table_association" "ecs_navi_subnet_private_1a_association" {
subnet_id = aws_subnet.ecs_navi_subnet_private_1a.id
route_table_id = aws_route_table.ecs_navi_route_table_private.id
}
resource "aws_route_table_association" "ecs_navi_subnet_private_1c_association" {
subnet_id = aws_subnet.ecs_navi_subnet_private_1c.id
route_table_id = aws_route_table.ecs_navi_route_table_private.id
}
POLICY
今回重要な点は、タスクにsevice discoveryをするための権限が必要であることです。この権限がないと名前入解決を出来ません。
// IAM
resource "aws_iam_role" "ecs_navi_ecs_task_execution_role" {
name = "${var.service_name}-ecs-task-execution-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
Service = "ecs-tasks.amazonaws.com"
}
}
]
})
tags = {
Name = "${var.service_name}-ecs-task-execution-role"
}
}
resource "aws_iam_role_policy" "ecs_navi_ecs_task_execution_policy" {
name = "${var.service_name}-ecs-task-execution-policy"
role = aws_iam_role.ecs_navi_ecs_task_execution_role.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = [
"ecr:GetDownloadUrlForLayer",
"ecr:BatchGetImage",
"ecr:GetAuthorizationToken",
"ecr:BatchCheckLayerAvailability",
"ecr:GetDownloadUrlForLayer",
]
Resource = "*"
},
{
Effect = "Allow"
Action = [
"logs:CreateLogStream",
"logs:PutLogEvents",
"logs:CreateLogGroup",
"logs:DescribeLogGroups",
"logs:DescribeLogStreams",
]
Resource = "*"
},
{
Effect = "Allow"
Action = [
"ecs:DescribeTasks",
"ecs:UpdateContainerInstancesState",
"ecs:SubmitContainerStateChange"
]
Resource = "*"
},
// sevice discovery権限
{
"Effect": "Allow",
"Action": [
"servicediscovery:RegisterInstance",
"servicediscovery:DeregisterInstance",
"servicediscovery:ListServices",
"servicediscovery:GetService",
"servicediscovery:GetInstancesHealthStatus",
"servicediscovery:DiscoverInstances"
],
"Resource": "*"
},
// exec権限
{
"Effect": "Allow",
"Action": [
"ssmmessages:CreateControlChannel",
"ssmmessages:CreateDataChannel",
"ssmmessages:OpenControlChannel",
"ssmmessages:OpenDataChannel"
],
"Resource": "*"
}
]
})
}
セキュリティグループ
3つのセキュリティグループを用意しました。
- ロードバランサーのSG
- backend_serviceのSG
- nginx_proxy_serviceのSG
backend_serviceのSGのインバウンドルールには、ロードバランサーだけではなくて、nginx_proxy_serviceからのアクセスも許可してあげます。
// セキュリティグループ
resource "aws_security_group" "ecs_navi_sg" {
vpc_id = aws_vpc.ecs_navi_vpc.id
ingress {
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "${var.service_name}-sg"
}
}
// セキュリティグループbackendサービス
resource "aws_security_group" "ecs_navi_backend_sg" {
vpc_id = aws_vpc.ecs_navi_vpc.id
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
security_groups = [aws_security_group.ecs_navi_sg.id, aws_security_group.nginx_proxy_sg.id]
}
ingress {
from_port = 3000
to_port = 3000
protocol = "tcp"
security_groups = [aws_security_group.ecs_navi_sg.id]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "${var.service_name}-backend-sg"
}
}
// セキュリティグループproxyサービス
resource "aws_security_group" "nginx_proxy_sg" {
vpc_id = aws_vpc.ecs_navi_vpc.id
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
security_groups = [aws_security_group.ecs_navi_sg.id]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "${var.service_name}-proxy-nginx-sg"
}
}
ROUTE53
※今回は、ホストゾーンのリソース作成・ACMのリソース作成は省略しました
resource "aws_route53_record" "ecs_navi_site" {
zone_id = aws_route53_zone.main.zone_id
name = "ecs-navi.site"
type = "A"
alias {
name = aws_lb.ecs_navi_lb.dns_name
zone_id = aws_lb.ecs_navi_lb.zone_id
evaluate_target_health = false
}
}
resource "aws_route53_record" "api_ecs_navi_site" {
zone_id = aws_route53_zone.main.zone_id
name = "api.ecs-navi.site"
type = "A"
alias {
name = aws_lb.ecs_navi_lb.dns_name
zone_id = aws_lb.ecs_navi_lb.zone_id
evaluate_target_health = false
}
}
ロードバランサー
// ロードバランサー
resource "aws_lb" "ecs_navi_lb" {
name = "${var.service_name}-lb"
internal = false
load_balancer_type = "application"
security_groups = [aws_security_group.ecs_navi_sg.id]
subnets = [aws_subnet.ecs_navi_subnet_public_1a.id, aws_subnet.ecs_navi_subnet_public_1c.id]
enable_deletion_protection = false
}
resource "aws_lb_target_group" "backend_tg" {
name = "${var.service_name}-backend-tg"
port = 80
protocol = "HTTP"
vpc_id = aws_vpc.ecs_navi_vpc.id
target_type = "ip"
health_check {
path = "/"
interval = 300
timeout = 120
healthy_threshold = 5
unhealthy_threshold = 10
matcher = "200"
}
}
resource "aws_lb_target_group" "nginx_proxy_tg" {
name = "${var.service_name}-nginx-proxy-tg"
port = 3000
protocol = "HTTP"
vpc_id = aws_vpc.ecs_navi_vpc.id
target_type = "ip"
health_check {
path = "/"
interval = 300
timeout = 120
healthy_threshold = 5
unhealthy_threshold = 10
matcher = "200"
}
}
resource "aws_lb_listener" "https" {
load_balancer_arn = aws_lb.ecs_navi_lb.arn
port = "443"
protocol = "HTTPS"
ssl_policy = "ELBSecurityPolicy-2016-08"
certificate_arn = aws_acm_certificate.ecs_navi_cert.arn
default_action {
type = "fixed-response"
fixed_response {
content_type = "text/plain"
message_body = "404 Not Found"
status_code = "404"
}
}
}
# ecs-navi.site Rule
resource "aws_lb_listener_rule" "ecs_navi_site_https_rule" {
listener_arn = aws_lb_listener.https.arn
priority = 100
action {
type = "forward"
target_group_arn = aws_lb_target_group.nginx_proxy_tg.arn
}
condition {
host_header {
values = ["ecs-navi.site"]
}
}
}
# api.ecs-navi.site Rule
resource "aws_lb_listener_rule" "api_ecs_navi_site_https_rule" {
listener_arn = aws_lb_listener.https.arn
priority = 200
action {
type = "forward"
target_group_arn = aws_lb_target_group.backend_tg.arn
}
condition {
host_header {
values = ["api.ecs-navi.site"]
}
}
}
AWSCloudMap
今回の重要なリソースの1つであるAWSCloudMapのリソースを作成します。
これらリソースを作成することによって、
- Route53のプライベートタイプに ecs-navi.local というホストゾーンが作成されます。
- AWSCloudMapにecs-navi.local という名前空間が作成されます。
resource "aws_service_discovery_private_dns_namespace" "ecs_navi_namespace" {
name = "ecs-navi.local"
vpc = aws_vpc.ecs_navi_vpc.id
description = "Private DNS namespace for ECS Navi services"
}
resource "aws_service_discovery_service" "ecs_navi_backend_service_discovery" {
name = "backend"
dns_config {
namespace_id = aws_service_discovery_private_dns_namespace.ecs_navi_namespace.id
dns_records {
type = "A"
ttl = 60
}
}
health_check_custom_config {
failure_threshold = 1
}
}
resource "aws_service_discovery_service" "nginx_proxy_service_discovery" {
name = "proxy-nginx"
dns_config {
namespace_id = aws_service_discovery_private_dns_namespace.ecs_navi_namespace.id
dns_records {
type = "A"
ttl = 60
}
}
health_check_custom_config {
failure_threshold = 1
}
}
ECS
ポイントは、ECS Serviceにservice_registriesを設定したことです。
これによって、先程のAWS Cloud Mapと連携して、名前解決をしてくれます。
// ECS Cluster
resource "aws_ecs_cluster" "ecs_navi_cluster" {
name = "${var.service_name}-cluster"
}
// ECS Service
resource "aws_ecs_service" "backend_service" {
name = "${var.service_name}-backend-service"
cluster = aws_ecs_cluster.ecs_navi_cluster.id
task_definition = aws_ecs_task_definition.ecs_navi_nginx_task_definition.arn
desired_count = 1
launch_type = "FARGATE"
network_configuration {
subnets = [aws_subnet.ecs_navi_subnet_private_1a.id, aws_subnet.ecs_navi_subnet_private_1c.id]
security_groups = [aws_security_group.ecs_navi_backend_sg.id]
}
load_balancer {
target_group_arn = aws_lb_target_group.backend_tg.arn
container_name = "nginx"
container_port = 80
}
service_registries {
registry_arn = aws_service_discovery_service.ecs_navi_backend_service_discovery.arn
}
enable_execute_command = true
}
// ECS タスク定義
resource "aws_ecs_task_definition" "ecs_navi_nginx_task_definition" {
family = "${var.service_name}-nginx-task"
requires_compatibilities = ["FARGATE"]
network_mode = "awsvpc"
cpu = 256
memory = 512
container_definitions = jsonencode([
{
name = "nginx"
image = "<account-id>.dkr.ecr.ap-northeast-1.amazonaws.com/ecs-navi-nginx:latest"
portMappings = [
{
containerPort = 80
hostPort = 80
}
]
logConfiguration = {
logDriver = "awslogs"
options = {
awslogs-group = "/ecs/nginx"
awslogs-region = "ap-northeast-1"
awslogs-stream-prefix = "ecs-nginx"
}
}
},
{
name = "api"
image = "<account-id>.dkr.ecr.ap-northeast-1.amazonaws.com/ecs-navi-api:latest"
logConfiguration = {
logDriver = "awslogs"
options = {
awslogs-group = "/ecs/api"
awslogs-region = "ap-northeast-1"
awslogs-stream-prefix = "ecs-api"
}
}
}
])
task_role_arn = aws_iam_role.ecs_navi_ecs_task_execution_role.arn
execution_role_arn = aws_iam_role.ecs_navi_ecs_task_execution_role.arn
}
# CloudWatch Logs
resource "aws_cloudwatch_log_group" "nginx" {
name = "/ecs/nginx"
retention_in_days = 7
}
# CloudWatch Logs
resource "aws_cloudwatch_log_group" "api" {
name = "/ecs/api"
retention_in_days = 7
}
resource "aws_ecs_task_definition" "nginx_proxy_task_definition" {
family = "${var.service_name}-nginx-proxy-task"
requires_compatibilities = ["FARGATE"]
network_mode = "awsvpc"
cpu = 256
memory = 512
container_definitions = jsonencode([
{
name = "nginx"
image = "<account-id>.dkr.ecr.ap-northeast-1.amazonaws.com/ecs-navi-nginx-proxy:latest"
portMappings = [
{
containerPort = 80
hostPort = 80
protocol = "tcp"
}
]
}
])
execution_role_arn = aws_iam_role.ecs_navi_ecs_task_execution_role.arn
task_role_arn = aws_iam_role.ecs_navi_ecs_task_execution_role.arn
}
resource "aws_ecs_service" "nginx_proxy_service" {
name = "${var.service_name}-nginx-proxy-service"
cluster = aws_ecs_cluster.ecs_navi_cluster.id
task_definition = aws_ecs_task_definition.nginx_proxy_task_definition.arn
desired_count = 1
launch_type = "FARGATE"
network_configuration {
subnets = [aws_subnet.ecs_navi_subnet_private_1a.id, aws_subnet.ecs_navi_subnet_private_1c.id]
security_groups = [aws_security_group.nginx_proxy_sg.id]
}
load_balancer {
target_group_arn = aws_lb_target_group.nginx_proxy_tg.arn
container_name = "nginx"
container_port = 80
}
service_registries {
registry_arn = aws_service_discovery_service.nginx_proxy_service_discovery.arn
}
enable_execute_command = true
}
確認
アクセス
api.ecs-navi.siteへのアクセスも、ecs-navi.siteへのアクセスも phpinfoの情報が返却されることが確認できます。
Aレコード作成
Route53の ecs-navi.local ホストゾーンでAレコードを確認してみます。
サービスディスカバリで生成されたAレコードが登録されています。
コンテナから名前解決
nginx-proxyのコンテナにログインしてみて、名前解決されているのかを確認してみます。
コンテナログイン方法はこちらです。
コマンド
aws ecs execute-command \
--profile private \
--cluster ecs-navi-cluster \
--task <task-id> \
--container nginx \
--interactive \
--command "sh"
コンテナ内部からnsloolup
コンテナ内部から nslookup backend.ecs-navi.local を実行します。
先ほどのRoute53で作成されたいIPが出力されました。
Non-authoritative answer:
Name: backend.ecs-navi.local
Address: 10.0.11.82
サービスディスカバリの自動検出機能
試しにbackend_serviceのタスク数を3つにしてみます。
その後に、Route53・名前解決を確認してみます。
Route53でAレコードが3つに増えています。
コンテナ内部からnsloolup
同様にnslookup backend.ecs-navi.local を実行すると名前解決されていることが分かります。
Non-authoritative answer:
Name: backend.ecs-navi.local
Address: 10.0.10.10
Name: backend.ecs-navi.local
Address: 10.0.11.82
Name: backend.ecs-navi.local
Address: 10.0.11.120
Discussion