🔖

ECSでタスク内部でNginx-->php-fpmとのコンテナ間通信を簡単にTerraformで実現する

2024/10/11に公開

はじめに

以前、ECSでNginxコンテナを起動させる記事を作成しています。
https://zenn.dev/osachi/articles/5f124f9ca84e25
https://zenn.dev/osachi/articles/32f61afe7f350f

これらを基に、1つのタスク内部で Nginxコンテナ->php-fpmコンテナのコンテナ間の通信を実現します。
なるべく複雑な設定を省いて、簡単な設定のみで実現しようと試みました。

GOAL状態

Nginxにリクエストすると、<?php phpinfo();" >を返却する単純な処理を実現します

AWS構成

パブリックサブネットにロードバランサーを配置して、ECSタスクはプライベートサブネットに配置します。
プライベートサブネットに配置されたタスクがECRへイメージを取得しにいく必要があるので、NAT Gatewayも設置しています。

                                Internet
                                    |
                                    |
                        [Application Load Balancer]
                        (HTTP:80, HTTPS:443)
                                    |
                                    |
            +---------------------------+---------------------------+
            |                           |                           |
    [Public Subnet 1a]           [Public Subnet 1c]         [Elastic IP]
    10.0.1.0/24                  10.0.2.0/24                    |
            |                           |                   [NAT Gateway]
            |                           |                       |
    [Private Subnet 1a]          [Private Subnet 1c]            |
    10.0.10.0/24                 10.0.11.0/24                   |
            |                           |                       |
            +---------------------------+---------------------------+
                                    |
                            [ECS Cluster]
                                    |
                            [ECS Service]
                                    |
                        +---------------------------+
                        |                           |
                [Nginx Container]            [API Container]
                (Port 80)                    (Port 9000)

ネットワークモード=awsvpcのコンテナ間通信について

ECSのネットワークの仕組みを公式ドキュメントから勉強していきます。

ECSのネットワークモード

今回はawsvpc モードを利用します。
https://docs.aws.amazon.com/ja_jp/AmazonECS/latest/developerguide/task-networking.html

ネットワークモード = awsvpc

https://docs.aws.amazon.com/ja_jp/AmazonECS/latest/developerguide/task-networking-awsvpc.html

awsvpc モードは、ECSタスクに固有のElastic Network Interface(ENI)を割り当て、タスクごとに専用のIPアドレスを持たせます。

特徴:

  • 各タスクに独自のENI が割り当てられ、タスク自体がホストのように振る舞います。
  • タスクはVPC内の他のリソース(例: RDS、EC2、ELB)と直接通信できるようになります。
  • ENIごとにセキュリティグループを割り当てることができるため、きめ細かなネットワーク制御が可能です。

コンテナ間通信に関して重要なことが記載されています。

同じタスクに属するコンテナが、localhost インターフェイス経由で通信できるようになります。

Nginxコンテナ・php-fpmコンテナのコンテナ間通信の設定をしたイメージを作成

  • ECSのコンテナで利用するイメージを作成
  • TerraformでECS環境を構築

Localでのファイル構成

Localでのファイル構成はこのようになっています。
これら4つのファイルを利用して、Nginxコンテナ用のイメージと、php-fpm用のイメージ(api)を作成していきます。

L docker
    L api
        L php.ini
        L Dockerfile
    L nginx
        L default.conf
        L Dockerfile

php-fpmコンテナ(apiコンテナ)を作成

docker/api/php.ini

必要最小限の構成

[Date]
date.timezone = "Asia/Tokyo"
[mbstring]
mbstring.internal_encoding = "UTF-8"
mbstring.language = "Japanese"

memory_limit = 256M

docker/api/Dockerfile

/var/www/public/index.phpを作成し、index.phpの中身にphpinfo()を出力しています。
実際にアクセスした際に出力されるのはphpinfo()の内容です。

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

ビルドして、ECRへPUSH

aws ecr get-login-password --region ap-northeast-1 | docker login --username AWS --password-stdin <acount-id>.dkr.ecr.ap-northeast-1.amazonaws.com

docker build --platform linux/amd64 -f docker/api/Dockerfile -t ecs-navi-api .

docker tag ecs-navi-nginx:latest <acount-id>.dkr.ecr.ap-northeast-1.amazonaws.com/ecs-navi-api:latest

docker push <acount-id>.dkr.ecr.ap-northeast-1.amazonaws.com/ecs-navi-api:latest

Nginxコンテナを作成

docker/nginx/default.conf

一番重要なポイントです。
nginxのlocationに入ってきたら、fastcgi_pass 127.0.0.1:9000; に設定します。
これで、localhost環境の9000ポート(php-fpmのapiコンテナ)へリクエストします。

server {
  listen 80;
    index index.php index.html;
    root /var/www/src/public;

    location / {
        try_files $uri $uri/ /index.php?$args;
    }

    location ~ \.php$ {
        try_files $uri =404;
        fastcgi_split_path_info ^(.+\.php)(/.+)$;
        fastcgi_pass 127.0.0.1:9000;
        fastcgi_index index.php;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        fastcgi_param PATH_INFO $fastcgi_path_info;
    }
}

docker/nginx/Dockerfile

niginxの最小限構成

FROM nginx:stable-alpine
COPY ./docker/nginx/default.conf /etc/nginx/conf.d/default.conf

ビルドして、ECRへPUSH

aws ecr get-login-password --region ap-northeast-1 | docker login --username AWS --password-stdin <acount-id>.dkr.ecr.ap-northeast-1.amazonaws.com

docker build --platform linux/amd64 -f docker/nginx/Dockerfile -t ecs-navi-nginx .

docker tag ecs-navi-nginx:latest <acount-id>.dkr.ecr.ap-northeast-1.amazonaws.com/ecs-navi-nginx:latest

docker push <acount-id>.dkr.ecr.ap-northeast-1.amazonaws.com/ecs-navi-nginx:latest

Terraformでリソースを作成する

ベースとなる部分はNginxのコンテナ構成のときとそれほど変わりません。
https://zenn.dev/osachi/articles/5f124f9ca84e25
※主な変更点

  • プライベートネットワークが追加
  • apiのコンテナが追加
  • タスクロール・実行ロールを追加

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
}

IAMロール

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

セキュリティグループ

ロードバランサーのセキュリティグループ

resource "aws_security_group" "ecs_navi_sg" {
  vpc_id = aws_vpc.ecs_navi_vpc.id

  ingress {
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  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"
  }
}

タスクのセキュリティグループ

外部からは80ポート(Nginxで開いているポートのみで受けつるける)

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]
  }

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

ロードバランサー・ターゲットグループ関連

// ロードバランサー
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            = 30
    timeout             = 5
    healthy_threshold   = 5
    unhealthy_threshold = 2
    matcher             = "200"
  }
}

resource "aws_lb_listener" "http" {
  load_balancer_arn = aws_lb.ecs_navi_lb.arn
  port              = 80
  protocol          = "HTTP"

  default_action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.backend_tg.arn
  }
}

// https のリスナー
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             = "forward"
    target_group_arn = aws_lb_target_group.backend_tg.arn
  }
}

ECSの設定

クラスターとサービス

// 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
  }
}

タスク定義

Nginxコンテナ、apiコンテナ(php-fpm)の2つのコンテナを1つのタスク内部で起動させます。
外部からのリクエストはnginxが受け付けるので、ENIとのポートフォワーディングはNginxの80ポートだけでOKです。
apiコンテナ(php-fpm)はデフォルトで9000ポートで待機しています。(localでコンテナ起動してみると分かります。)

// 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
        }
      ]
    },
    {
      name = "api"
      image     = "<account-id>.dkr.ecr.ap-northeast-1.amazonaws.com/ecs-navi-api:latest"
    }
  ])

  execution_role_arn = aws_iam_role.ecs_navi_ecs_task_execution_role.arn
}

終わりに

今回もサービスのDNS名を取得して、ブラウザにリクエストしてください。

phpinfo(); の情報が表示されたら通信OKです。

Discussion