TerraformでFargateのSidecarを実現しよう

こんにちは!リクルートライフスタイルの共通クラウド基盤でリードクラウドアーキテクトをしている須藤です。

この記事は AWS Advent Calendar 2018 の2日目の記事です!( リクルートライフスタイル Advent Calendar 2018 2日目の記事でもあります)

リクルートライフスタイルの共通クラウド基盤では、サービスごとにアカウントを払い出して、サービス開発者がその環境を構築する、というスタイルです。クラウドアーキテクトチームはCCoE(Cloud Center of Excellence)であり、TerraformやFargateを使いたい、という開発者に対しては、ペアプログラミングやアーキテクチャレビューなど、規範的・助言的な活動を通して成長しあっていく、という活動をしています。

当然、re:Invent 2018で発表された トランジットゲートウェイAWS Resource Access Managerのクロスアカウントリソース共有 なども、適用することで幸せになるユースケースがあれば積極的に使っていきたいと思っています!

Terraform で構築するサービスの構成

まずは、Terraformで構築する新しいサービス myservice の構成図を紹介します。

Terraform で構築するサービスの構成

新しいAWSアイコンを使って作図してみましたが、まだちょっと慣れませんね。

2つのAZにそれぞれ3つのサブネットがあり、ALBを配置するパブリックサブネット、Webアプリケーションを動かすためのサブネット、データベースを置くためのサブネットに分かれています。Fargateで動作するTaskは、Webアプリケーションサブネットに配置します。また、これらのTaskはSidecar構成で、それぞれDataDog Agentのコンテナとセットで起動します。

DataDog AgentのSidecarを、どうTerraformで構成すれば良いのか?TerraformでFargateを利用したサービスやTask Definitionを記述するときに注意すべきはどこなのか?やったことがないと、試行錯誤してしまいそうなところです。順に見ていきましょう。

DataDog を利用するための構成

DataDog AgentでFargateのコンテナを監視するので、Webアプリケーション本体とDataDog AgentのSidecarをセットで扱うために、1つのTask Definitionで定義しておきます。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
[
  {
    "name": "myservice",
    "logConfiguration": {
      "logDriver": "awslogs",
      "options": {
        "awslogs-group": "${awslogs-group}",
        "awslogs-region": "ap-northeast-1",
        "awslogs-stream-prefix": "fargate"
      }
    },
    "portMappings": [
      {
        "hostPort": 3000,
        "protocol": "tcp",
        "containerPort": 3000
      }
    ],
    "environment": [
      {
        "name": "DATABASE_HOST",
        "value": "${db-endpoint}"
      },
      {
        "name": "DATABASE_PASSWORD",
        "value": "${db-password}"
      },
      {
        "name": "DATABASE_USER",
        "value": "${db-user}"
      },
      {
        "name": "NODE_ENV",
        "value": "production"
      },
      {
        "name": "RAILS_ENV",
        "value": "production"
      },
      {
        "name": "RAILS_LOG_TO_STDOUT",
        "value": "true"
      }
    ],
    "image": "${image-repository}",
    "essential": true,
    "dockerLabels": {
      "com.datadoghq.ad.instances": "[{\"host\": \"%%host%%\", \"port\": 3000}]",
      "com.datadoghq.ad.check_names": "[\"myservice\"]",
      "com.datadoghq.ad.init_configs": "[{}]"
    }
  },
  {
    "name": "datadog-agent",
    "logConfiguration": {
      "logDriver": "awslogs",
      "options": {
        "awslogs-group": "${awslogs-group}",
        "awslogs-region": "ap-northeast-1",
        "awslogs-stream-prefix": "fargate"
      }
    },
    "portMappings": [
      {
        "hostPort": 8126,
        "protocol": "tcp",
        "containerPort": 8126
      }
    ],
    "environment": [
      {
        "name": "DD_API_KEY",
        "value": "${dd-agent-key}"
      },
      {
        "name": "DD_APM_ENABLED",
        "value": "true"
      },
      {
        "name": "ECS_FARGATE",
        "value": "true"
      }
    ],
    "image": "datadog/agent:latest",
    "essential": true
  }
]

後半に、 datadog/agent:latest Dockerイメージを利用する datadog-agent を定義しています。 portMappings8126 ポートを開けているのは、Application Performance Monitoring(APM)を利用するためです。APMを利用する場合には、アプリケーション側でも設定が必要なのでご注意を。 Java、Python、Ruby、Go、Node.jsのサンプルが記載されている ので、悩むことはないでしょう。

また、ここでいくつかの環境変数を渡しています。

  • DD_API_KEY : DataDogのAPIキー
  • DD_APM_ENABLED : DataDog trace-agentを有効化してAPMを利用するかどうか
  • ECS_FARGATE : Fargateを利用する場合 true

これらとは別に、Webアプリケーション側の dockerLabels でもいくつかの情報を渡しています。DataDogのAutodiscoveryのドキュメントを見れば詳細を知ることができますが、ここでは %%host%% %%port%% myservice の3つが設定されていれば良いでしょう。

このWebアプリケーション myservice はRuby on Railsで作っているので、 containerPort とDataDogに渡しているポート情報を 3000 としています。また、Fargateは現在 awsvpc ネットワークモードのみに対応しているので、

awsvpc ネットワークモードを使用するタスク定義では、containerPort のみを指定する必要があります。hostPort は、空白のままにするか、containerPort と同じ値にする必要があります。

という制約を受けます。このため、 hostPort3000 としています。

その他のパラメータについても、詳細はTask Definitionのパラメータのドキュメントをご覧いただければだいたい分かります。

あとは、こうして記述したjsonテンプレートにTerraformから必要な変数をバインドしてあげればOKですね!

1
2
3
4
5
6
7
8
9
10
11
12
data "template_file" "myservice_task_definition_json" {
  template = "${file("task-definitions/myservice.json")}"
  vars {
    awslogs-group    = "${aws_cloudwatch_log_group.myservice.name}"
    db-endpoint      = "${aws_rds_cluster.myservice.endpoint}"
    db-user          = "${aws_rds_cluster.myservice.master_username}"
    db-password      = "${var.db_password}"
    image-repository = "${var.ecr_repository_uri}"
    dd-agent-key     = "${var.dd_agent_key}"
  }
}

Fargate 特有の記述

ECSのFargate Modeを利用する場合に、TerraformのリソースでFargate特有の記述が必要になるのは Task Definition( aws_ecs_task_definition )とService( aws_ecs_service )、そしてALBのTarget Group( aws_lb_target_group )リソースです。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
resource "aws_ecs_task_definition" "task_definition" {
  family                   = "myservice"
  network_mode             = "awsvpc"
  requires_compatibilities = ["FARGATE"]
  cpu                      = "${var.myservice_cpu}"
  memory                   = "${var.myservice_memory}"
  container_definitions    = "${data.template_file.myservice_task_definition_json.rendered}"
  task_role_arn            = "${var.myservice_task_role_arn}"
  execution_role_arn       = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:role/ecsTaskExecutionRole"
}
resource "aws_ecs_service" "service" {
  name                              = "myservice"
  cluster                           = "${aws_ecs_cluster.cluster.id}"
  launch_type                       = "FARGATE"
  task_definition                   = "${aws_ecs_task_definition.task_definition.arn}"
  desired_count                     = 1
  health_check_grace_period_seconds = 120
  lifecycle {
    ignore_changes = ["desired_count"]
  }
  load_balancer {
    target_group_arn = "${aws_lb_target_group.target_group.arn}"
    container_name   = "myservice"
    container_port   = 3000
  }
  network_configuration {
    subnets         = ["${data.terraform_remote_state.base.vpc01_webapp_subnet_ids.az1}", "${data.terraform_remote_state.base.vpc01_webapp_subnet_ids.az2}"]
    security_groups = ["${aws_security_group.ip_restrictions.id}"]
  }
}
resource "aws_lb_target_group" "target_group" {
  name        = "myservice"
  port        = 80
  protocol    = "HTTP"
  target_type = "ip"
  vpc_id      = "${data.terraform_remote_state.base.vpc01_vpc.id}"
  health_check {
    healthy_threshold   = 3
    unhealthy_threshold = 2
    timeout             = 5
    interval            = 10
    matcher             = "200,304"
    path                = "/health_check"
  }
}

requires_compatibilitieslaunch_type にそれぞれ FARGATE の記述をします。また、前述の通り network_modeawsvpc のみとなることにも注意しましょう。

requires_compatibilities には ["FARGATE","EC2"] のように2つの値を指定することもできます。基本的にはFargateを使うけれども、デバッグ用途でEC2モードでも動かしたいとか、開発用にSpotFleetの安価なクラスタ上で動かしたい、といった場合には活用できるでしょう。わたしたちはFargate一筋ですが!

network_configuration の中で assign_public_ip = true とすれば個々のTaskが利用するENIにパブリックIPを割り当てることは可能ですが、 load_balancer でALBの target_group_arn を指定している場合、通常 パブリックIPは不要 です。

ALBを利用している場合、クライアントからのアクセスはALBのパブリックIPを経由してTargetGroup内の各TaskのENIに割り当てられたプライベートIPに流すのが一般的な利用方法です。Terraformやecs-deployを利用してFargateの構築をする場合、本当に assign_public_ip = trueassignPublicIp=ENABLED が必要か、きちんと検討しましょう。

aws_lb_target_group で注意すべきなのは target_type = "ip" となる点です。FargateはENIを利用しているので、サブネットのローカルIPをENIに割り当て、そのIPを利用してTarget Groupを更新します。

サービスをオートスケールさせる場合、 terraform apply するときに初期値(上記の例では 1 )にリセットされないように、 desired_countlifecycleignore_changes で無視するよう設定しておくと安心ですね。(これはFargateに限らず、EC2モードのECSでも一緒ですね!)

さいごに

いかがでしたか?Terraform、Fargate、そしてSidecarでDataDogを使う、という方に届き、参考になれば幸いです。

わたしたち共通クラウド基盤のチームでは、 即戦力向けのクラウドアーキテクトポジションと、第二新卒やクラウド未経験者向けのクラウドアーキテクト(ポテンシャル)の2つのポジションを採用中 です!

一緒にリクルートライフスタイルの共通クラウド基盤を進化させていく仲間になりませんか?須藤はまだ入社5ヶ月ですが、年齢に関係なく期待に応えれば報われ、高いスキルを持った方々とより良いクラウド基盤について議論しながら日々の業務を回していく環境で働けるのは、本当に楽しいです。

この記事を読んでご興味を持っていただけた方は、上記リンクの中途採用ページからご連絡ください。お待ちしています!