『ブログのインフラ構成を改善する』 にて残った以下の課題。
しかしこれによりWAFの ウェブACL x 1とルール x 1 の使用料が月6ドルが掛かってしまうので、そのうち(1)のLambda@Edgeでアクセス制限す流方法に変更しようと思う。
ということでWAFからLambda@EdgeによるBASIC認証へ移行する。
目的
今回の目的は以下4つ。
- WAFをやめてBASIC認証で保護する
- TerraformでLambdaを管理するが関数コードは管理しない
- Lambdaのビルド&デプロイはTerraform外から行う
- ClojureScript + shadow-cljsでLambdaを作る
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_changes に source_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