Terraformを使って GCP Pub/Sub から AWS Lambda へのPush通知の仕組みを作成した

先日作ったのでその覚書的なものです。
簡単に言うとこんな感じのものを作りました。

GKE上のDockerアプリ -> Stackdriver logging -> Pub/Sub -> Lambda -> Slack

## 作成の動機

概ね以下の背景です。

- GCPをメインに、AWSも多少織り交ぜつつな感じのアプリケーションを作っている。
- Stackdriverでアプリの監視を行っているがエラーログを検知したらSlackに通知する仕組みが欲しかった。
- Stackdriver単体だとログ内容をSlackに通知できなさそうだった。
- StackdriverはフィルタリングしたログをPub/Subへ流すことができるらしい。
- Pub/Subはキューにデータが流れてきたときに指定したエンドポイントにPush通知できるらしい。

と、こんなかんじの前提があり Pub/Sub から lambda に流して、そこからさらにSlackに流せば実現できると目論んだ次第です。

LambdaにもPub/Subにも今回始めて触ったということもあってか結構迷走してしまいました。。

以降から本題となります。

## Terraformに読み込ませるHCL

これがほとんど全てな感じですが、実際に使ったHCLをコメントを交えつつ添付しておきます。
varになっているところは適宜置換が必要かと思われますのでご注意下さい。

基本的にはTerraformサイト上のサンプルを組み合わせているだけです。
Terraformマジ便利ですね、最高です。
もっといろいろな知見がネット上に溜まってくれるとうれしいです。

```
data "aws_caller_identity" "self" {}

#### ここから API Gateway 周り####
resource "aws_api_gateway_rest_api" "alert_api" {
  name = "alerter"
}

resource "aws_api_gateway_method" "root_post_method" {
  rest_api_id   = "${aws_api_gateway_rest_api.alert_api.id}"
  resource_id   = "${aws_api_gateway_rest_api.alert_api.root_resource_id}"
  http_method   = "POST"
  authorization = "NONE"
}

resource "aws_api_gateway_integration" "alert_integration" {
  rest_api_id             = "${aws_api_gateway_rest_api.alert_api.id}"
  resource_id             = "${aws_api_gateway_rest_api.alert_api.root_resource_id}"
  http_method             = "${aws_api_gateway_method.root_post_method.http_method}"
  integration_http_method = "POST"
  type                    = "AWS"
  uri                     = "arn:aws:apigateway:${var.aws_region}:lambda:path/2015-03-31/functions/${aws_lambda_function.alerter.arn}/invocations"
}

resource "aws_api_gateway_method_response" "alert_200_response" {
  rest_api_id = "${aws_api_gateway_rest_api.alert_api.id}"
  resource_id = "${aws_api_gateway_rest_api.alert_api.root_resource_id}"
  http_method = "${aws_api_gateway_method.root_post_method.http_method}"
  status_code = "200"
}

resource "aws_api_gateway_integration_response" "alert_response" {
  rest_api_id = "${aws_api_gateway_rest_api.alert_api.id}"
  resource_id = "${aws_api_gateway_rest_api.alert_api.root_resource_id}"
  http_method = "${aws_api_gateway_method.root_post_method.http_method}"
  status_code = "${aws_api_gateway_method_response.alert_200_response.status_code}"
}

## stage名に使う名前をランダム文字列として生成する
resource "random_id" "secret_path" {
  keepers = {
    ami_id = "${aws_lambda_function.alerter.id}"
  }

  byte_length = 40
}

resource "aws_api_gateway_deployment" "alert_deployment" {
  depends_on = ["aws_api_gateway_method.root_post_method"]

  rest_api_id = "${aws_api_gateway_rest_api.alert_api.id}"
  stage_name  = "${random_id.secret_path.hex}"
}

resource "aws_api_gateway_domain_name" "alert" {
  domain_name = "${var.alerter_domain}"

  certificate_name        = "alert-ssl"
  certificate_body        = "${file("cert_path")}"
  certificate_chain       = "${file("chain_path")}"
  certificate_private_key = "${file("key_path")}"
}

resource "aws_api_gateway_base_path_mapping" "alert" {
  api_id      = "${aws_api_gateway_rest_api.alert_api.id}"
  domain_name = "${aws_api_gateway_domain_name.alert.domain_name}"
}

#### ここから IAM 周り####
resource "aws_iam_role" "lambda_alert_role" {
  name               = "lambda_alert_role"
  assume_role_policy = <<POLICY
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "",
      "Effect": "Allow",
      "Action": "sts:AssumeRole",
      "Principal": {
        "Service": "lambda.amazonaws.com"
      }
    }
  ]
}
POLICY
}

#### ここから Lambda 周り####
resource "aws_lambda_permission" "apigw_alerter" {
  statement_id  = "AllowExecutionFromAPIGateway"
  action        = "lambda:InvokeFunction"
  function_name = "${aws_lambda_function.alerter.arn}"
  principal     = "apigateway.amazonaws.com"

  source_arn    = "arn:aws:execute-api:${var.aws_region}:${data.aws_caller_identity.self.account_id}:${aws_api_gateway_rest_api.alert_api.id}/*/${aws_api_gateway_method.root_post_method.http_method}/"
}

resource "aws_lambda_function" "alerter" {
  filename         = "${var.path_to_lambda_source}"
  function_name    = "alerter"
  role             = "${aws_iam_role.lambda_alert_role.arn}"
  handler          = "index.handler"
  runtime          = "nodejs4.3"
  source_code_hash = "${base64sha256(file(${var.path_to_lambda_source}))}"
  environment {
    variables = {
      webhook_url = "${var.slack_webhook_url}"
      channel = "${var.slack_channel}"
    }
  }
}

#### ここから Route53 周り ####
resource "aws_route53_record" "alerter" {
  zone_id = "${aws_route53_zone.primary.zone_id}"

  name = "${aws_api_gateway_domain_name.alert.domain_name}"
  type = "A"

  alias {
    name    = "${aws_api_gateway_domain_name.alert.cloudfront_domain_name}"
    zone_id = "${aws_api_gateway_domain_name.alert.cloudfront_zone_id}"
    evaluate_target_health = true
  }
}

####
#### ここから GCP
####

#### ここから Pub/Sub 周り ####
resource "google_pubsub_topic" "log_alert_topic" {
  name = "log-alert-topic"
}

## CAUTION: 以下のリンクの「他のエンドポイントの登録」の手順を行っていないドメインをendpointに入れているとapplyに失敗します
## https://cloud.google.com/pubsub/advanced#push_endpoints
resource "google_pubsub_subscription" "aws_lambda_subscription" {
  name  = "default-subscription"
  topic = "log-alert-topic"

  ack_deadline_seconds = 10

  push_config {
    push_endpoint = "https://${aws_api_gateway_domain_name.alert.domain_name}/${aws_api_gateway_deployment.alert_deployment.stage_name}"

    attributes {
      x-goog-version = "v1"
    }
  }
}
```

## ハマったところとか困ったところを何点か

これだけだと味気ないので、いくつかハマってしまった点を記録しておきます

### Pub/Sub へ Push Subscriber への外部ドメインの登録

(おそらく)セキュリティ上の理由から Pub/Sub でPush通知するためにはそのエンドポイントの所有者であることを証明しないといけません。

HCLの方にもコメントをしていますが、AppEngine等のPub/Subと同じGCPプロジェクト内で管理されている以外のドメインを利用したい場合には事前に手作業でドメイン認証が必要となります。
このドメイン認証を行うために、 API Gateway をカスタムドメインで公開してやる必要がありました。
若干面倒ではありますが、認証できないエンドポイントにリクエストを投げられるようにしてしまうとDOS攻撃みたいなことにつかえてしまうのでこれは仕方がないのだと思います。

ともかく事前この作業をやっておかないと `terraform apply` に失敗してしまいます。
作業内容は別に難しいものではなく、大きく分けて以下の2ステップで完了します。

- Google Search Console をつかってRoute53をイジイジしてドメイン認証
  - DNSのTXTレコードを使ったよくある感じの認証です。 
- GCP Console の API Manager から API Gateway に設定したカスタムドメインを登録

### Pub/Sub と Lambda 間の認証

こちらは、単に自分の Pub/Sub と Lambda の知識不足な可能性があります。
HTTPヘッダーの中身をカスタムする等の方法による認証方法が見つけられずかなり悩んでしました。

結局、当座の対応としてHCL内で生成したランダム文字列を公開URLに含ませておき、これを知らないとAPIを叩けないという仕様にしています。
正直これについてはもっと上手な解決策があるような気がしてなりません。。