TerraformでAWS AppMeshを使ってECS Fargateのサービスを統合する(その1 基本編)

# はじめに
コンテナでマイクロサービスが増えてくると様々な問題に出くわし、それを解決する一つの手段がサービスメッシュであり、AWS でのマネージドなサービスメッシュが AWS AppMesh である。

↑の説明だけでは「なんのこっちゃ」な感じではあると思うので、前提知識として、AWS Developers Summit 2020の以下のスライドと動画を見ておくと良いかと思う。

- [サービスメッシュは本当に必要なのか、何を解決するのか](https://speakerdeck.com/toricls/service-meshes-do-we-really-need-them-what-problems-do-they-solve)

また、AppMesh の基礎については以下のサイトが概念として分かりやすく説明されていたと思うので、これもまた見ておくと良い。

- [[AWS] AWS AppMeshのSample Appを用いた動作検証](https://dx.nissho-ele.co.jp/blog/aws-aws-appmesh%E3%82%92%E5%8B%95%E3%81%8B%E3%81%97%E3%81%A6%E3%81%BF%E3%81%9F.html)

本記事の前提知識として、

- ECS のクラスタ、サービス、Fargate タスクを Terraform でサクッと作れること
- サービスディスカバリ の概念を理解していること

がある。以下の記事の内容は理解していることを前提としているのと、構成も以下の記事で作成したものをベースにしている。

- [サービスディスカバリを使ってECSタスクの負荷分散をする](https://qiita.com/neruneruo/items/9c44d44468a8fbf19e67)

![figure3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/111314/a6e97d49-ef91-ac4c-14e7-c693cf3eca3c.png)

なお、本記事を書いている 2021/1/2 時点で AWS AppMesh はまだまだ発展途上なサービスであり、情報が変わる可能性があることはご了承いただきたい。

# 基本編で目指す構成
基本編では、まずはバックエンドにあるコンテナに AppMesh を適用してみることを目指す。

![figure1_1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/111314/181bd1f0-edb5-8351-8706-6207d7933a78.png)


ECS の起動とサービスディスカバリがバックエンドサービス(サービスB)側に設定できているとした場合、大まかにやらなければいけないことは以下の2点である。Envoy プロキシについては、上述の記事や動画を確認すれば理解できているかと思う。

- AppMesh のサービスBに関連した項目の設定
- サービスBのコンテナ定義に対する Envoy プロキシの設定

さて、それでは設定していってみよう。

# AppMesh のサービスBに関連した項目の設定
AppMesh の構築は、今回は ```aws_appmesh_mesh```、```aws_appmesh_virtual_service```、```aws_appmesh_virtual_node``` を使う。

```aws_appmesh_virtual_service``` の ```port_mapping``` では、サービスアプリケーションが Listen しているポート番号を指定する。今回は、nginx のコンテナをデフォルト設定で使用しているため 80 としている。```health_check``` についてはサービスアプリケーションの設定に合わせよう。

```aws_appmesh_virtual_node``` の ```name``` は何でも良いが、```[サービス名]-vn``` を指定している例をよく見かけると思う。

```HCL
################################################################################
# AppMesh                                                                      #
################################################################################
resource "aws_appmesh_mesh" "trial" {
  name = local.appmesh_name
}

resource "aws_appmesh_virtual_service" "service_b" {
  name      = "${aws_service_discovery_service.service_b.name}.${aws_service_discovery_private_dns_namespace.internal.name}"
  mesh_name = aws_appmesh_mesh.trial.id

  spec {
      virtual_node  {
        virtual_node_name = aws_appmesh_virtual_node.service_b.name
      }
    }
  }
}

resource "aws_appmesh_virtual_node" "service_b" {
  name      = local.service_b_appmesh_virtualnode_name
  mesh_name = aws_appmesh_mesh.trial.id

  spec {
    listener {
      port_mapping {
        port     = 80
        protocol = "http"
      }
      health_check {
        protocol            = "http"
        path                = "/healthcheck"
        healthy_threshold   = 2
        unhealthy_threshold = 2
        timeout_millis      = 2000
        interval_millis     = 5000
      }
    }

    service_discovery {
      aws_cloud_map {
        namespace_name = [サービスディスカバリの名前空間]
        service_name   = [サービスBのサービス名]
      }
    }

    logging {
      access_log {
        file {
          path = "/dev/stdout"
        }
      }
    }
  }
}
```

これで AppMesh の構築は完了なので、今度はコンテナ側に AppMesh を使うための設定を入れていく。

# サービスBのコンテナ定義に対する Envoy プロキシの設定
Envoy プロキシの設定をするには、```aws_ecs_task_definition``` の設定を変更すれば良い。
まずは、```proxy_configuration``` ブロックを追加する。```container_name``` はタスク定義のコンテナ名と合わせておかないとタスク作成時に得エラーになるので注意。
```properties``` については、以下の通りである。

| コンフィグ名 | 意味 |
|:--|:--|
| AppPorts | サービスアプリケーションが Listen しているポート番号。今回は、nginx のコンテナをデフォルト設定で使用しているため 80 としている。 |
| EgressIgnoredIPs | Envoy がプロキシせずに直接処理をするためのIPアドレス。169.254.170.2 は[タスクメタデータエンドポイント](https://docs.aws.amazon.com/ja_jp/AmazonECS/latest/developerguide/task-metadata-endpoint-v2.html)といい、タスクの情報を取得するためのIPアドレス、169.254.169.254 は[インスタンスメタデータ取得のエンドポイント](https://docs.aws.amazon.com/ja_jp/AWSEC2/latest/UserGuide/instancedata-data-retrieval.html)だ。どちらも Envoy が使用するらしい。 |
| IgnoredUID | ここで指定した UID で起動したプロセスの通信はEnvoy がプロキシせずに直接処理をする。EgressIgnoredIPs 同様、Envoy のために設定しておくイメージだ。  |
| ProxyEgressPort | Envoy がアウトバウンド通信するために使用するポート。デフォルトは 15001 。 |
| ProxyIngressPort | Envoy がインバウンド通信するために Listen しているポート。デフォルトは 15000 。 |

要は、Envoy がそのままプロキシとして透過してしまったら困るものをここで定義すると考えれば良い。

他にもプロパティがあるが、詳細はここでは割愛する。もし全部の値を確認するなら、[ユーザーガイド](https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/aws-properties-ecs-taskdefinition-proxyconfiguration.html)を参照しよう。

```HCL
resource "aws_ecs_task_definition" "service_a_ecsfargate" {
  family             = local.service_a_ecs_task_family_name
  task_role_arn      = aws_iam_role.ecs.arn
  execution_role_arn = aws_iam_role.ecstaskexecution.arn
  network_mode       = "awsvpc"
  cpu                = "256"
  memory             = "1024"
  requires_compatibilities = [
    "FARGATE",
  ]
  container_definitions = data.template_file.service_a_ecsfargate.rendered

  proxy_configuration {
    type           = "APPMESH"
    container_name = "envoy"
    properties = {
      AppPorts         = 80
      EgressIgnoredIPs = "169.254.170.2,169.254.169.254"
      IgnoredUID       = "1337"
      ProxyEgressPort  = 15001
      ProxyIngressPort = 15000
    }
  }
}
```

タスク定義のJSONについては以下のように設定する。
X-Ray については Envoy 側でサブセグメントを出力してくれるのでタスク定義する。

```HCL
data "template_file" "service_b_ecsfargate" {
  template = file("${path.module}/34_service_b_taskdef.json")

  vars = {
    service_b_ecs_container_name     = local.service_b_ecs_container_name
    service_b_image                  = "${aws_ecr_repository.service_b_image.repository_url}:latest"
    service_b_ecstask_log_group_name = aws_cloudwatch_log_group.service_b_ecstask_log_group.name
    service_b_region_name            = data.aws_region.current.name
    appmesh_virtual_node_name        = "mesh/${aws_appmesh_mesh.trial.name}/virtualNode/${aws_appmesh_virtual_node.service_b.name}"
  }
}
```

```JSON
[
  {
    "name" : "${service_b_ecs_container_name}",
    "image": "${service_b_image}",
    "cpu": 0,
    "memoryReservation": 256,
    "portMappings": [
      {
        "containerPort": 80,
        "hostPort": 80,
        "protocol": "tcp"
      }
    ],
    "logConfiguration": {
      "logDriver": "awslogs",
      "secretOptions": null,
      "options": {
        "awslogs-group": "${service_b_ecstask_log_group_name}",
        "awslogs-region": "${service_b_region_name}",
        "awslogs-stream-prefix": "ecs"
      }
    },
    "dependsOn": [
      {
        "containerName": "envoy",
        "condition": "HEALTHY"
      }
    ]    
  },
  {
    "name": "envoy",
    "image": "111345817488.dkr.ecr.us-west-2.amazonaws.com/aws-appmesh-envoy:v1.9.1.0-prod",
    "user": "1337",
    "essential": true,
    "cpu": 64,
    "memoryReservation": 256,
    "ulimits": [
      {
        "name": "nofile",
        "hardLimit": 15000,
        "softLimit": 15000
      }
    ],
    "portMappings": [
      {
        "containerPort": 9901,
        "hostPort": 9901,
        "protocol": "tcp"
      },
      {
        "containerPort": 15000,
        "hostPort": 15000,
        "protocol": "tcp"
      },
      {
        "containerPort": 15001,
        "hostPort": 15001,
        "protocol": "tcp"
      }
    ],
    "environment": [
      {
        "name": "APPMESH_VIRTUAL_NODE_NAME",
        "value": "${appmesh_virtual_node_name}"
      },
      {
        "name": "ENVOY_LOG_LEVEL",
        "value": "info"
      },
      {
        "name": "APPMESH_XDS_ENDPOINT",
        "value": ""
      },
      {
        "name": "ENABLE_ENVOY_XRAY_TRACING",
        "value": "1"
      },
      {
        "name": "ENABLE_ENVOY_STATS_TAGS",
        "value": "1"
      }
    ],
    "logConfiguration": {
      "logDriver": "awslogs",
      "options": {
        "awslogs-group": "${service_b_ecstask_log_group_name}",
        "awslogs-region": "${service_b_region_name}",
        "awslogs-stream-prefix": "envoy"
      }
    },
    "healthCheck": {
      "command": [
        "CMD-SHELL",
        "curl -s http://localhost:9901/server_info | grep state | grep -q LIVE"
      ],
      "interval": 5,
      "timeout": 2,
      "retries": 3
    }
  },
  {
    "name": "xray-daemon",
    "image": "amazon/aws-xray-daemon",
    "user": "1337",
    "essential": true,
    "cpu": 32,
    "memoryReservation": 256,
    "portMappings": [
      {
        "hostPort": 2000,
        "containerPort": 2000,
        "protocol": "udp"
      }
    ],
    "logConfiguration": {
      "logDriver": "awslogs",
      "options": {
        "awslogs-group": "${service_b_ecstask_log_group_name}",
        "awslogs-region": "${service_b_region_name}",
        "awslogs-stream-prefix": "xray"
      }
    }
  }  
]
```

ポイントは、サービスアプリケーションのコンテナは Envoy 起動後に起動したいため、

```JSON
    "dependsOn": [
      {
        "containerName": "envoy",
        "condition": "HEALTHY"
      }
    ]    
```

を設定しておこう。

X-Ray や Envoy のコンテナは、前述の通り、処理をプロキシを通さずに直接行いたいため、```"user": "1337",``` を設定しておく。

## Envoy コンテナ定義の詳細
### コンテナイメージURI(image)
Envoy コンテナのイメージURIは記載の通りの固定値にしておく。
将来的にバージョンアップ等で変更がありそうだが、今のところは上記の内容で使える。

### 各環境変数(environment)
各種環境変数の設定については、[ユーザーガイド](https://docs.aws.amazon.com/app-mesh/latest/userguide/envoy.html)を参照しよう。```ENVOY_LOG_LEVEL``` は info だとややうるさいので、正常性確認が済んだら warning くらいに変えても良いと思う。

### ポートマッピング
ポートマッピングは、デフォルトであれば 9901, 15000, 15001 を開放しておく。
15000, 15001 については前述の通り。
9901 については Envoy の管理者コマンドの実行用である。

### ヘルスチェック
AppMesh ではサービスディスカバリをベースにしたトラフィックの振り分けを行うため、プロキシの正常性確認は ELB に任せず、上記の 9901 ポートのサーバ情報取得で行うことにする。

# 動かしてみる
さて、以上でコンテナの定義側も完了だ。早速動作確認をしてみよう。

フロントのサービスAに紐づく ELB に対して curl でトラフィックを投げ込むと、サービスBの情報が取得されれば、ひとまず設定は完了だ。

しかし、それだけでは単に NLB 経由で通信したのと見た目が変わらない。

ここは一つ、CloudWatch Logs で Envoy のログを確認してみよう。
※今回のケースでは、↑のJSONにも書いてある通りコンテナ側のログドライバは CloudWatch Logs にしている前提である。

```
[2021-01-02T07:31:13.742Z] "GET / HTTP/1.1" 200 - 0 413 0 0 "-" "curl/7.61.1" "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" "service-b.appmesh-trial.internal" "127.0.0.1:80"
```

といった感じでログが出力されていることが分かるだろう。

また、X-Ray のサービスマップを確認すると、以下の通り、アプリケーション側で X-Ray を取り込んでいなくても通信の結果を記録してくれている。

![キャプチャ1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/111314/8f330c74-208c-a410-870a-16fdd6c2f693.png)

ということで、最初のお題目にあったような、「ログや可観測性を後からアプリケーションで統一しようとした場合に、言語も仕様もバラバラに作っているマイクロサービスでは厳しい」という問題点の解決策として AppMesh は使えるのではないか、ということになる。

とは言え、現状ではログフォーマットの変更はできなかったり(多分)、組み合わせられるのがECS/EKSのみだったりするので、ゴリゴリにマネージドサービスを組み合わせて使っていたりすると、おそらく使いどころが限られてしまうのだろうな、というのを感じる。
[ロードマップ](https://github.com/aws/aws-app-mesh-roadmap/projects/1)では良い感じに Issues も挙げられているし、上記のアクセスログフォーマットについても Accepted のステータスになっているので、今後のアップデートに期待である。