十一月
5
木曜日
2020

Lambdaを部分的にTerraformで管理する

『ブログのインフラ構成を改善する』 にて残った以下の課題。

しかしこれによりWAFの ウェブACL x 1とルール x 1 の使用料が月6ドルが掛かってしまうので、そのうち(1)のLambda@Edgeでアクセス制限す流方法に変更しようと思う。

ということでWAFからLambda@EdgeによるBASIC認証へ移行する。


目的

今回の目的は以下4つ。

WAFをやめてBASIC認証で保護する

『ブログのインフラ構成を改善する』 では「TerraformからWAFv2を使ってみたい」ということでブログ開発環境のアクセス制限に使用してみた。
とりあえず使用感は分かったので無駄なコストを抑えるためWAFをやめ、代わりにBASIC認証用のLambda@Edgeでアクセス制限を行う。

TerraformでLambdaを管理するが関数コードは管理しない

Lambdaが持つ2つの側面

Lambdaには

  • 他AWSリソースと連携して動作するピタゴラスイッチ的なギミック
  • 関数コードと依存ライブラリを元にビルドしリリースが必要なアプリケーション

という二つの側面がある。
前者はまさにTerraformによる管理を必要とするところだが後者は逆にTerraformで管理すると辛くなるという二面性がある。

関数コードは管理しない

Lambdaの管理をガワ(側)と関数コードに分け、Terraformでは関数コードを管理しない。

とはいえ関数コード無しではLambdaを作成できないため、Terraform側では何もしないブランクな関数コードを持ちLambdaの初回構築時のみ使用する。

本物の関数コードは別のGitリポジトリで管理しリポジトリへのプッシュにCodeBuildが反応しデプロイを行う。
Terraform apply時にブランクな関数コードに戻ってしまうのを防ぐため関数コードを変更管理の対象外とし初期構築以降は関知しない様にする。


ブランクな関数コード

今回はBasic認証用なので認証エラーを固定で返すブランク関数コードを用意した。

exports.handler = (event, context, callback) => {
    const response = {
        status: '401',
    };
    callback(null, response);
};

S3とlambdaパッケージ

関数コードのデプロイはS3経由で行う。
今回はlambda管理用S3バケット上に basic_auth/latest.zip というオブジェクトキーでデプロイするlambdaパッケージを配置する。

resource "aws_s3_bucket_object" "basic_auth_bucket_object" {
  provider   = aws.global
  bucket     = var.lambda_bucket.id
  key        = "basic_auth/latest.zip"
  source     = data.archive_file.basic_auth_archive.output_path
  etag       = filemd5(data.archive_file.basic_auth_archive.output_path)
  lifecycle {
    ignore_changes = [
      etag,
      metadata
    ]
  }
}

data "archive_file" "basic_auth_archive" {
  type        = "zip"
  source_dir  = "${path.module}/src"
  output_path = "${path.module}/lambda.zip"
}

またapplyの度にブランク関数コードのlambdaパッケージを上げ直してしまうのを防ぐため ignore_changes にて etag metadata を指定しS3上のパッケージとの差分をチェックしない様にする。


Lambdaのガワ(側)の管理

Lambdaの関数コード以外を管理する。
ignore_changessource_code_hash を指定し、関数コードの変更を差分として扱わないようにする。

resource "aws_lambda_function" "basic_auth_lambda" {
  provider         = aws.global
  function_name    = format("%s-%s-basic-auth", var.meta.orch_name, var.meta.env_id)
  role             = aws_iam_role.basic_auth_role.arn
  handler          = "index.handler"
  runtime          = "nodejs12.x"
  publish          = true
  s3_bucket        = var.lambda_bucket.id
  s3_key           = "basic_auth/latest.zip"
  source_code_hash = aws_s3_bucket_object.basic_auth_bucket_object.etag
  tags             = var.meta.tags
  lifecycle {
    ignore_changes = [
      source_code_hash
    ]
  }
}

Lambdaのビルド&デプロイはTerraform外から行う

LambdaのコードはTerraformとは別のGitリポジトリで管理。
masterへのpush時にwebhookでCodeBuildプロジェクトを呼び出しデプロイフローを実行する。

buildspec.ymlは以下。

version: 0.2

phases:
  pre_build:
    commands:
      - make clean init package
  build:
    commands:
      - make deploy/lambda
      - make deploy/cf

各処理はMakeifleにまとめる。

デプロイ処理は、

  • 新しいLambdaのデプロイ ( make deploy/lambda )
  • CloudFrontに新しいLambdaを紐付ける ( make deploy/cf )

の二段階に別ける。

新しいLambdaのデプロイ

deploy/lambda ターゲットでは、AWS CLIの update-function-code を使って関数コードの更新、新しいLambdaの番号付きバージョンを発行している。latestの更新だけなく番号付きバージョンを発行しているのはCloudFront側に番号付きバージョンLambdaしか紐付けられないという制約があるため。
(『CloudFront開発者ガイド - Lambda 関数の CloudFront トリガー』参照)

CloudFrontに新しいLambdaを紐付ける

deploy/cf ターゲットでは、新しく作成したLambdaのQualified ARNを使ってCloudFrontとLambdaの紐付けを更新し新しいLambdaを利用可能な状態にする。

Lambdaの紐付けの更新はAWS CLIの update-distribution を使用するがこれが中々どうして仰々しい。
というものupdate-distributionにはCloudFrontの全設定を記述したJSONとETag(CloudFrontの現在のコンフィグレーションのバージョン)与える必要があるので、update-distributionを呼ぶ前に get-distribution-config で現在のCloudFrontのコンフィグレーションとそのETagを取得しておきLambdaのARNを書き換えた上update-distributionを実施する必要があるからだ。

ちなみにCloudFrontには最大4つまでLambdaを紐づけることができる。
今回CloudFrontが使用するのはBASIC認証用Lambda一つだけなので問題無いが、複数のLambdaを紐付ける場合はそれらを同じGitリポジトリで管理してデプロイフローを纏めた方が良さそうだ。
ETag必須の時間がかかるCloudFrontの更新処理を複数Gitリポジトリのデプロイフローから並列で行われた場合、おそらく後発のデプロイは失敗してしまうだろう。

ClojureScript + shadow-cljsでLambdaを作る

Lambdaのランタイムにnodejsを指定、ClojureScriptとshadow-cljsを使用する。

ClojureScriptでLambdaを書く

ClojureScriptでBasic認証のハンドラ関数( basic_auth.cljs ) を定義。

(ns basic-auth)

(goog-define USERNAME "notset")
(goog-define PASSWORD "notset")

(def expected-auth-header-value
  (let [userpass (str USERNAME ":" PASSWORD)
        credentials (-> js/Buffer (.from userpass) (.toString "base64"))]
    (str "Basic " credentials)))

(def error-response {:status "401"
                     :statusDescription "Unauthorized"
                     :body "Unauthorized"
                     :bodyEncoding "text"
                     :headers {"www-authenticate" [{:key "WWW-Authenticate"
                                                    :value "Basic"}]}})
(defn- auth
  [event]
  (let [request (get-in event [:Records 0 :cf :request])
        actual-auth-header-value (get-in request [:headers :authorization 0 :value])]
    (if (= expected-auth-header-value actual-auth-header-value)
      request
      error-response)))

(defn handler [event _ callback]
  (->> (js->clj event :keywordize-keys true)
       (auth)
       (clj->js)
       (callback nil)))

受け付けるBASIC認証ユーザは1種類のみ。
ユーザ名とパスワードはgoog-defineで定義されたコンパイル時に決定される定数で定義。
この値はshadow-cljsによってビルド時に埋め込まれる。

関数 handler はLambdaのエントリーポイントとなるClojureScriptの関数。
最終的にJavaScriptの関数としてエクスポートする必要があるが後述のshadow-cljsがビルド時に行ってくれる為、明示的なエクスポートは不要。

shadow-cljsの設定

shadow-cljs.ednは以下。

{:source-paths
 ["src/main"-
  "src/test"]

 :dependencies []

 :builds {:app {:target :node-library
                :exports {:handler basic-auth/handler}
                :output-dir "out"
                :output-to "out/index.js"
                :release {:closure-defines {basic-auth/USERNAME #shadow/env "BASIC_AUTH_USERNAME"
                                            basic-auth/PASSWORD #shadow/env "BASIC_AUTH_PASSWORD"}
                          :compiler-options {:optimizations :simple}}}
          :test {:target :node-test
                 :output-to "out/node-tests.js"
                 :ns-regexp "-test$"
                 :autorun true
                 :closure-defines {basic-auth/USERNAME "test_user"
                                   basic-auth/PASSWORD "test_pass"}}}}

:exports で JavaScriptの関数 handler として ClojureScriptの関数 basic-auth/handler をエクスポートしている。

:closure-defines ではコンパイラオプション closure-defines で先のコンパイル時に決定される定数の値を指定している。
また定数の値はリーダータグ#shadow/envで指定した環境変数の値が設定される。


以上でBASIC認証によるお手軽アクセス制限を適用することができた。
なお本エントリに出てくるコードは全て以下リポジトリにある。

ブログインフラ管理用Terraform
https://github.com/nijohando/blog-orchestration
Basic認証Lambda
https://github.com/nijohando/blog-basic-auth-lambda