AWS Fargateで、タスク内のコンテナがlocalhostで通信できることを確認する

# TL;DR

* AWS Fargateで、同じタスクの中にあるコンテナは`localhost`で通信することができる

他のQiita記事にもあったのですが、これを自分で確認してみることにしました。


# Fargateのタスク定義と`awsvpc`ネットワークモード

AWS Fargateクラスタ内にある、同一タスク内のコンテナのネットワーク的な関係がよくわからなくていろいろ調べていたのですが、AWS Fargateの場合は`links`は使えないようです。

[ネットワーク設定](https://docs.aws.amazon.com/ja_jp/AmazonECS/latest/developerguide/task_definition_parameters.html#container_definition_network)

> このパラメータは、awsvpc ネットワークモードを使用するタスクや Windows コンテナではサポートされていません。

他のQiita記事を見ていると、同一タスク内のコンテナは`localhost`で通信可能だという記述があります。ですが、だいたい

> Fargateのタスク内のコンテナは`localhost`で通信可能

という結論だけが書かれていて、AWSのドキュメントへの参照が見当たらなかったので、こちらを調べてみました。

対象のドキュメントは、こちらですね。

[タスクネットワーキングと awsvpc ネットワークモード](https://docs.aws.amazon.com/ja_jp/AmazonECS/latest/developerguide/task-networking.html)

AWS Fargateを使う際には、ネットワークモードは`awsvpc`を指定する必要があります。

ここで、次の1文がタスク内のコンテナが`localhost`で通信できることが明記されています。

> さらに、同じタスクに属するコンテナが、localhost インターフェイス経由で通信できるようになります。ある時点でタスクに関連付けられている ENI は 1 つだけです。

では、こちらについて、自分でも確認してみるとしましょう。


# お題

今回やってみるお題は、以下とします。

* AWS Fargateでクラスタを構築する
* タスクの中には、Pythonで作成したシンプルなWebアプリケーションを配置する
* 同じタスクの中に、Webアプリケーションに対するリバースプロキシとしてnginxコンテナを置く
* 上記の環境を、Terraformで構築する


# 環境

今回の環境は、こちらです。

```shell
$ terraform version
Terraform v0.12.29
+ provider.aws v2.70.0
```

AWSのクレデンシャルは、環境変数で指定しています。

```shell
export AWS_ACCESS_KEY_ID=.....
export AWS_SECRET_ACCESS_KEY=.....
export AWS_DEFAULT_REGION=.....
```


# Amazon ECRを作成する

最初に、Amazon ECRを作成します。

作成するリポジトリは2つで、nginx用とアプリケーション用です。

`main.tf`

```go
terraform {
  required_version = "0.12.29"
}

provider "aws" {
  version = "2.70.0"
}

resource "aws_ecr_repository" "nginx_rp" {
  name = "nginx-rp"
}

resource "aws_ecr_repository" "app" {
  name = "app"
}
```

で、apply。

```shell
$ terraform apply
```


# DockerイメージをAmazon ECRにPushする

作成したAmazon ECRリポジトリに、DockerイメージをPushしましょう。

最初は、アプリケーション側から。

コードは、こんな感じで。

`index.py`

```python
import socket

def application(environ, start_response):
    start_response('200 OK', [('Content-Type', 'text/plain')])

    return [f"Hello from = {socket.gethostbyname(socket.gethostname())} / {socket.gethostname()}".encode()]
```

アクセスされたらIPアドレスとホスト名を返す、WSGIアプリケーションです。

Dockerfileはこちら。WSGIサーバーには、Gunicornを使用します。

`Dockerfile`

```dockerfile
FROM python:3.6.11-slim-buster

ADD index.py index.py

RUN pip install gunicorn==20.0.4

ENTRYPOINT ["gunicorn", "index:application"]
# CMD ["-b", "0.0.0.0:8000"]
```

コメントアウトしていますが、このアプリケーションは`localhost`で通信するnginxからのみ呼び出されるため、外部向けのインターフェースにはバインドしません。

Dockerイメージをビルドして、Amazon ECRにPush。

```shell
$ docker image build -t app:latest .
$ aws ecr get-login-password --region ap-northeast-1 | docker login --username AWS --password-stdin [AWS Account ID].dkr.ecr.ap-northeast-1.amazonaws.com
$ docker image tag app:latest [AWS Account ID].dkr.ecr.ap-northeast-1.amazonaws.com/app:latest
$ docker image push [AWS Account ID].dkr.ecr.ap-northeast-1.amazonaws.com/app:latest
```

nginx側はこちら。

`Dockerfile`

```dockerfile
FROM nginx:1.19.1

ADD default.conf /etc/nginx/conf.d/default.conf
```

設定ファイルのみ、変更しています。中身は、こんな感じです。

`default.conf`

```nginx
server {
    listen       80;
    listen  [::]:80;
    server_name  localhost;

    location / {
        proxy_pass   http://localhost:8000;

        add_header X-Server-Hostname $hostname;
        add_header X-Server-Address $server_addr;
    }
}
```

`localhost`のポート`8000`にプロキシします。`8000`ポートは、Gunicornのデフォルトポート、つまり先ほど作成したアプリケーションにプロキシします、と。

アプリケーション側と同じホストで動作しているのを確認するために、ホスト名とIPアドレスをレスポンスヘッダーに加えることにしました。

[Alphabetical index of variables](http://nginx.org/en/docs/varindex.html)

こちらも、Dockerイメージをビルドして、Amazon ECRにPushしておきます。

```shell
$ docker image build -t nginx-rp:latest .
$ docker image tag nginx-rp:latest [AWS Account ID].dkr.ecr.ap-northeast-1.amazonaws.com/nginx-rp:latest
$ docker image push [AWS Account ID].dkr.ecr.ap-northeast-1.amazonaws.com/nginx-rp:latest
```


# AWS Fargateクラスタを構築する

では、AWS Fargateクラスタを構築します。

作成したTerraformの構成ファイルから、AWS FargateとAmazon CloudWatch Logsに関する部分だけ、抜粋します（残りは後で記載）。

内容としては、

* Amazon ECRからイメージをPullするためのIAMロール
* コンテナのログを格納するAmazon CloudWatch Logs
* AWS Fargateのクラスタ、タスク定義、サービス

となります。

`main.tf `

```go
terraform {
  required_version = "0.12.29"
}

provider "aws" {
  version = "2.70.0"
}

## 省略

locals {
  vpc_id = module.vpc.vpc_id

  private_subnets                           = module.vpc.private_subnets
  app_with_nginx_rp_service_security_groups = [module.app_with_nginx_rp_service_sg.this_security_group_id]
  load_balancer_target_group_arn            = module.load_balancer.target_group_arns[0]
}

data "aws_iam_policy_document" "assume_role" {
  statement {
    actions = ["sts:AssumeRole"]

    principals {
      type        = "Service"
      identifiers = ["ecs-tasks.amazonaws.com"]
    }
  }
}

resource "aws_iam_role" "ecs_task_execution_role" {
  name               = "MyEcsTaskRole"
  assume_role_policy = data.aws_iam_policy_document.assume_role.json
}

resource "aws_iam_role_policy_attachment" "amazon_ecs_task_execution_role_policy" {
  role       = aws_iam_role.ecs_task_execution_role.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}

resource "aws_cloudwatch_log_group" "app_with_nginx_rp_cluster_log_group" {
  name = "app-with-nginx-rp-cluster-log-group"
}

resource "aws_ecs_cluster" "app_with_nginx_rp" {
  name = "app-with-nginx-rp-cluster"
}

resource "aws_ecs_task_definition" "app_with_nginx_rp" {
  family                   = "app-with-nginx-rp-task-definition"
  cpu                      = "512"
  memory                   = "1024"
  network_mode             = "awsvpc"
  requires_compatibilities = ["FARGATE"]
  execution_role_arn       = aws_iam_role.ecs_task_execution_role.arn

  container_definitions = <<JSON
  [
    {
      "name": "nginx-rp",
      "image": "[AWS Account ID].dkr.ecr.ap-northeast-1.amazonaws.com/nginx-rp:latest",
      "essential": true,
      "portMappings": [
        {
          "protocol": "tcp",
          "containerPort": 80
        }
      ],
      "cpu": 256,
      "memory": 512,
      "logConfiguration": {
        "logDriver": "awslogs",
        "options": {
          "awslogs-region": "ap-northeast-1",
          "awslogs-group": "app-with-nginx-rp-cluster-log-group",
          "awslogs-stream-prefix": "nginx-rp-log-stream"
        }
      }
    },
    {
      "name": "app",
      "image": "[AWS Account ID].dkr.ecr.ap-northeast-1.amazonaws.com/app:latest",
      "essential": true,
      "cpu": 256,
      "memory": 512,
      "logConfiguration": {
        "logDriver": "awslogs",
        "options": {
          "awslogs-region": "ap-northeast-1",
          "awslogs-group": "app-with-nginx-rp-cluster-log-group",
          "awslogs-stream-prefix": "app-log-stream"
        }
      }
    }
  ]
    JSON
}

resource "aws_ecs_service" "app_with_nginx_rp" {
  name             = "app-with-nginx-rp-service"
  cluster          = aws_ecs_cluster.app_with_nginx_rp.arn
  task_definition  = aws_ecs_task_definition.app_with_nginx_rp.arn
  desired_count    = 3
  launch_type      = "FARGATE"
  platform_version = "1.4.0"

  deployment_minimum_healthy_percent = 50

  network_configuration {
    assign_public_ip = false
    security_groups  = local.app_with_nginx_rp_service_security_groups
    subnets          = local.private_subnets
  }

  load_balancer {
    target_group_arn = local.load_balancer_target_group_arn
    container_name   = "nginx-rp"
    container_port   = 80
  }
}
```

ポイントは、`aws_ecs_task_definition`に2つのコンテナ定義があることですね。

```go
resource "aws_ecs_task_definition" "app_with_nginx_rp" {
  family                   = "app-with-nginx-rp-task-definition"
  cpu                      = "512"
  memory                   = "1024"
  network_mode             = "awsvpc"
  requires_compatibilities = ["FARGATE"]
  execution_role_arn       = aws_iam_role.ecs_task_execution_role.arn

  container_definitions = <<JSON
  [
    {
      "name": "nginx-rp",
      "image": "[AWS Account ID].dkr.ecr.ap-northeast-1.amazonaws.com/nginx-rp:latest",
      "essential": true,
      "portMappings": [
        {
          "protocol": "tcp",
          "containerPort": 80
        }
      ],
      "cpu": 256,
      "memory": 512,
      "logConfiguration": {
        "logDriver": "awslogs",
        "options": {
          "awslogs-region": "ap-northeast-1",
          "awslogs-group": "app-with-nginx-rp-cluster-log-group",
          "awslogs-stream-prefix": "nginx-rp-log-stream"
        }
      }
    },
    {
      "name": "app",
      "image": "[AWS Account ID].dkr.ecr.ap-northeast-1.amazonaws.com/app:latest",
      "essential": true,
      "cpu": 256,
      "memory": 512,
      "logConfiguration": {
        "logDriver": "awslogs",
        "options": {
          "awslogs-region": "ap-northeast-1",
          "awslogs-group": "app-with-nginx-rp-cluster-log-group",
          "awslogs-stream-prefix": "app-log-stream"
        }
      }
    }
  ]
    JSON
}
```

nginxの方はロードバランサから転送されるリクエストを受け付けるため`portMappings`の定義をしていますが、

```go
    {
      "name": "nginx-rp",
      "image": "[AWS Account ID].dkr.ecr.ap-northeast-1.amazonaws.com/nginx-rp:latest",
      "essential": true,
      "portMappings": [
        {
          "protocol": "tcp",
          "containerPort": 80
        }
      ],
      "cpu": 256,
      "memory": 512,
      "logConfiguration": {
        "logDriver": "awslogs",
        "options": {
          "awslogs-region": "ap-northeast-1",
          "awslogs-group": "app-with-nginx-rp-cluster-log-group",
          "awslogs-stream-prefix": "nginx-rp-log-stream"
        }
      }
    },
```

アプリケーションの方は`portMappings`は不要です。

```go
    {
      "name": "app",
      "image": "[AWS Account ID].dkr.ecr.ap-northeast-1.amazonaws.com/app:latest",
      "essential": true,
      "cpu": 256,
      "memory": 512,
      "logConfiguration": {
        "logDriver": "awslogs",
        "options": {
          "awslogs-region": "ap-northeast-1",
          "awslogs-group": "app-with-nginx-rp-cluster-log-group",
          "awslogs-stream-prefix": "app-log-stream"
        }
      }
    }
```

ホスト側（外）からのリクエストは、このコンテナは直接受けませんからね。

> ポートマッピングにより、コンテナはホストコンテナインスタンス上のポートにアクセスしてトラフィックを送受信できるようになります。

[ポートマッピング](https://docs.aws.amazon.com/ja_jp/AmazonECS/latest/developerguide/task_definition_parameters.html#container_definition_portmappings)

`aws_ecs_service`でのALBとの接続先は、nginxコンテナとなります。

```go
resource "aws_ecs_service" "app_with_nginx_rp" {
  name             = "app-with-nginx-rp-service"
  cluster          = aws_ecs_cluster.app_with_nginx_rp.arn
  task_definition  = aws_ecs_task_definition.app_with_nginx_rp.arn
  desired_count    = 3
  launch_type      = "FARGATE"
  platform_version = "1.4.0"

  deployment_minimum_healthy_percent = 50

  network_configuration {
    assign_public_ip = false
    security_groups  = local.app_with_nginx_rp_service_security_groups
    subnets          = local.private_subnets
  }

  load_balancer {
    target_group_arn = local.load_balancer_target_group_arn
    container_name   = "nginx-rp"
    container_port   = 80
  }
}
```

`desired_count`は3にしてあります。

この部分は、省略部分で作成した

* VPC
* セキュリティグループ
* ALB

の参照をローカル変数にまとめたものです。

```go
locals {
  vpc_id = module.vpc.vpc_id

  private_subnets                           = module.vpc.private_subnets
  app_with_nginx_rp_service_security_groups = [module.app_with_nginx_rp_service_sg.this_security_group_id]
  load_balancer_target_group_arn            = module.load_balancer.target_group_arns[0]
}
```

では、applyして

```shell
$ terraform apply
```

確認してみます。

```shell
$ curl -i [ALBのDNS名]
HTTP/1.1 200 OK
Date: Thu, 23 Jul 2020 15:44:53 GMT
Content-Type: text/plain
Transfer-Encoding: chunked
Connection: keep-alive
Server: nginx/1.19.1
X-Server-Hostname: ip-10-0-40-37.ap-northeast-1.compute.internal
X-Server-Address: 10.0.40.37

Hello from = 10.0.40.37 / ip-10-0-40-37.ap-northeast-1.compute.internal


$ curl -i [ALBのDNS名]
HTTP/1.1 200 OK
Date: Thu, 23 Jul 2020 15:45:08 GMT
Content-Type: text/plain
Transfer-Encoding: chunked
Connection: keep-alive
Server: nginx/1.19.1
X-Server-Hostname: ip-10-0-40-77.ap-northeast-1.compute.internal
X-Server-Address: 10.0.40.77

Hello from = 10.0.40.77 / ip-10-0-40-77.ap-northeast-1.compute.interna


$ curl -i [ALBのDNS名]
HTTP/1.1 200 OK
Date: Thu, 23 Jul 2020 15:45:19 GMT
Content-Type: text/plain
Transfer-Encoding: chunked
Connection: keep-alive
Server: nginx/1.19.1
X-Server-Hostname: ip-10-0-30-122.ap-northeast-1.compute.internal
X-Server-Address: 10.0.30.122

Hello from = 10.0.30.122 / ip-10-0-30-122.ap-northeast-1.compute.internal
```

ひとつピックアップしてみますが、nginxとアプリケーションが同じホスト内にいそうな感じがわかりますね。

```shell
X-Server-Hostname: ip-10-0-40-37.ap-northeast-1.compute.internal
X-Server-Address: 10.0.40.37

Hello from = 10.0.40.37 / ip-10-0-40-37.ap-northeast-1.compute.internal
```

そして、これでnginxとアプリケーションが`localhost`で通信できていることも確認できました。

nginxの設定ファイルは、これですからね。

```nginx
server {
    listen       80;
    listen  [::]:80;
    server_name  localhost;

    location / {
        proxy_pass   http://localhost:8000;

        add_header X-Server-Hostname $hostname;
        add_header X-Server-Address $server_addr;
    }
}
```

ちなみに、今回はホスト名はこんな感じで出力されていますが、

```shell
ip-10-0-40-37.ap-northeast-1.compute.internal
```

`enableDnsHostnames`を無効にすると、こんな感じのホスト名になり

```shell
8225b1f0-ca22-4704-9221-5cf6e97ccc99-527074092
```

先ほど書いていたPythonコードだとホスト名の解決がうまくできなくなり、以下の部分が失敗します。

```python
socket.gethostbyname(socket.gethostname())
```

[socket.gethostbyname(hostname)](https://docs.python.org/ja/3.6/library/socket.html#socket.gethostbyname)

これに関しても、こちらのドキュメントに記載があります。

[タスクネットワーキングと awsvpc ネットワークモード](https://docs.aws.amazon.com/ja_jp/AmazonECS/latest/developerguide/task-networking.html)

この部分ですね。

> enableDnsHostnames と enableDnsSupport オプションの両方が VPC で有効になっている場合、Amazon ECS はタスクネットワーキングを使用して Amazon が提供する (内部) DNS ホスト名でタスクのホスト名を設定します。これらのオプションが有効になっていない場合、タスクの DNS ホスト名はランダムなホスト名になります。

というわけで、AWS Fargateの同一のタスク定義にあるコンテナは、`localhost`で通信できることが確認できました、と。


# VPC〜ALBまで

先ほどの`main.tf`で、省略していた部分を載せておきます。

なお、先ほど言及した`enableDnsHostnames`については、VPCの定義時に有効化しています。

```go
module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "2.44.0"

  name = "my-vpc"
  cidr = "10.0.0.0/16"

  enable_dns_hostnames = true
  enable_dns_support   = true

  azs             = ["ap-northeast-1a", "ap-northeast-1c"]
  public_subnets  = ["10.0.10.0/24", "10.0.20.0/24"]
  private_subnets = ["10.0.30.0/24", "10.0.40.0/24"]

  map_public_ip_on_launch = true

  enable_nat_gateway     = true
  single_nat_gateway     = false
  one_nat_gateway_per_az = true
}

module "load_balancer_sg" {
  source  = "terraform-aws-modules/security-group/aws//modules/http-80"
  version = "3.11.0"

  name   = "load-balancer-sg"
  vpc_id = module.vpc.vpc_id

  ingress_cidr_blocks = ["0.0.0.0/0"]
}

module "app_with_nginx_rp_service_sg" {
  source  = "terraform-aws-modules/security-group/aws"
  version = "3.13.0"

  name   = "app-with-nginx-rp-service-sg"
  vpc_id = module.vpc.vpc_id

  ingress_with_cidr_blocks = [
    {
      from_port   = 80
      to_port     = 80
      protocol    = "tcp"
      description = "app-with-nginx-rp-service inbound ports"
      cidr_blocks = "10.0.0.0/16"
    }
  ]

  egress_with_cidr_blocks = [
    {
      from_port   = 0
      to_port     = 0
      protocol    = "-1"
      description = "app-with-nginx-rp-service outbound ports"
      cidr_blocks = "0.0.0.0/0"
    }
  ]
}

module "load_balancer" {
  source  = "terraform-aws-modules/alb/aws"
  version = "5.6.0"

  name = "app-with-nginx-rp"

  vpc_id             = module.vpc.vpc_id
  load_balancer_type = "application"
  internal           = false

  subnets         = module.vpc.public_subnets
  security_groups = [module.load_balancer_sg.this_security_group_id]

  target_groups = [
    {
      backend_protocol = "HTTP"
      backend_port     = 80
      target_type      = "ip"

      health_check = {
        interval = 20
      }
    }
  ]

  http_tcp_listeners = [
    {
      port     = 80
      protocol = "HTTP"
    }
  ]
}
```