「隙間家具」の発想でLambdaのデプロイをシンプルにする
株式会社カオナビ Advent Calendar 2023 25日目の記事です。
カオナビでインフラエンジニアをしている @miztch です。
Lambdaデプロイ事情
いくつかのサブシステムや共通基盤では、AWS Lambdaがアプリケーションの実行プラットフォームとして活用されています。
これらのLambda関数の管理やデプロイには、以下のように多様な手法が用いられています。
- マネジメントコンソールからのコード更新
- SAM
- Terraform
- GitLab CIでのAWS CLI実行
- 内製デプロイツール(Lambdaベース)
- 内製デプロイツール(CodePipeline/SAMベース)
時代の移り変わりや開発体制の差異、システムごとの要件からこれらの手法が逐次採用されてきました。システムごと(場合によってはLambda関数ごと)にデプロイフローが異なるだけでなく、開発者からの要望を取り入れて改修を重ねていった結果、メンテナンスが困難になってしまうほどに複雑化したツールも中には存在します。
こうした状況を改善する取り組みの一環として、Lambda関数をデプロイするための新たなパイプラインを実装し、導入しはじめています。
実装したLambdaデプロイパイプライン
やっていることはシンプルで、パイプラインというほど大層なものでもありませんが、CodeBuildとlambrollをベースに実装しました。Terraform moduleとしてこのパイプラインを実装しているため、Lambda関数のデプロイが必要なシステムでは、このmoduleを利用してパイプラインをすぐに準備することができます。
処理の流れ
- GitLab CI等でデプロイリソースをS3バケットに配置
- S3バケットへの配置をトリガーにCodeBuildのビルドプロジェクトを起動
- CodeBuildのビルドジョブ内でデプロイ処理を実行
- Zipアーカイブの展開と関数コードの再パッケージング
-
lambroll deploy
を実行
デプロイリソースをS3バケットに配置
デプロイリソースは以下を含むZipアーカイブです。
- 関数コード(必要に応じてビルド済)
- lambrollでのデプロイに用いる
function.json
,.lambraignore
例えばGoで書いたLambdaをカスタムランタイムでデプロイする際には、CIジョブ等で以下のような構造でパッケージングします。
lambda_package.zip
├── .lambdaignore
├── function.json
└── src
└── bootstrap
S3バケットへの配置をトリガーにCodeBuildのビルドプロジェクトを起動
S3バケットにZipアーカイブが配置されたことを検知して、EventBridgeルールがCodeBuildのビルドプロジェクトを起動します。
EventBridgeの入力トランスフォーマー(Input Transformer)を利用して、起動時にビルド単位で環境変数をオーバーライドすることが可能です。[1]
これにより、環境変数 (SOURCE_OBJECT_KEY
) を参照することでデプロイ対象のZipアーカイブを特定することができ、複数のLambda関数を単一のビルドプロジェクトでデプロイすることができます。
resource "aws_cloudwatch_event_target" "codebuild_lambda_deploy" {
target_id = "CodeBuildLambdaDeploy"
rule = aws_cloudwatch_event_rule.deploy_source_put.name
arn = aws_codebuild_project.lambda_deploy.arn
role_arn = aws_iam_role.eventbridge.arn
input_transformer {
input_paths = {
object_key = "$.detail.object.key"
}
input_template = jsonencode(
{
environmentVariablesOverride = [
{
name = "SOURCE_OBJECT_KEY",
value = "<object_key>",
type = "PLAINTEXT"
}
]
}
)
}
}
CodeBuildのビルドジョブ内でデプロイ処理を実行
builcspec.yml
に以下の処理を書いています。特に難しいことはしていません。
- S3バケットに配置されたZipアーカイブを展開
- 環境変数に必要なパラメータをSecrets Managerから取得して
function.json
を書き換え -
lambroll deploy
を実行
2 はSAMテンプレートでSecrets Managerのシークレットを動的な参照により取得[2]していたLambda関数への対応です。function.json
側では以下のように指定しておいて、指定のシークレット値を取得する自前のスクリプトを実装して実現しています。
<secret:secret-id:secret-string:json-key>
なぜCodeBuildか
lambrollは単一のバイナリファイルで実行可能で、GitHub ActionsやCircleCIでの実行もサポートされています。インターネット上で観測する限りではこれらのいずれかで利用されているケースが殆どに見えたため、当初はGitLab CI上でデプロイを実行する構成についても検討しました。[3]
一方で、
- Code系サービスをベースにパイプラインを構築しているシステムが多い
- CodeCommitをリポジトリとして利用しているLambda関数が意外と多い
- GitLab/CodeCommit双方からのデプロイを構成差分少なく可能にしたい
といった諸条件を踏まえ、「リポジトリ側ではS3へのパッケージ配置までを行い、lambrollを利用したデプロイ処理はCodeBuild上で実行する」ことにしました。
とにかくS3バケットにZipアーカイブが置ければよくて、そこまでは何でもよい、という意味でのシンプルさ、パイプライン開始のトリガーに依存しない切り離しのしやすさを意識しています。
ミニマルな「ベースライン」を作ること
隙間家具OSS
lambrollやecspressoの作者・fujiwaraさんが度々話されている「隙間家具OSS」という考え方があります。
そして「隙間家具」と似て非なる概念として「Glue」という言葉が使われています。両者は「コンポーネントの隙間を埋める」という目的を同じくしながら、本質は異なるものです。
Glue = 形がない、柔軟、オーダーメイド、個別に書くコード
隙間家具 = 形がある、そこまで柔軟ではない、レディメイド、単体ソフトウェア
「Glue」なコードはオーダーメイドで、特定のシステム(プロジェクト)のために書かれたコードです。システム固有の事情が入り込みがちであり、複数のシステムで同じような処理が必要になった時、Glueなコードはコピペされ、各所に伝播しながら少しずつ形を変えていきます。バグ修正やバージョンアップ等で何らかの修正が必要になれば、それらをひとつひとつ回って手を入れていかなければなりません。
パイプラインはGlue化しがち?
この話を聞くにつれ、パイプラインに関しても似たような発想ができそうだなと考えました。個別のシステムのために専用のCI/CDパイプラインが用意されるのが常ですが、同じような構成のシステムであれば、パイプラインの構成も使い回されることが多いはずです。
さらにLambdaをベースとしたシステムであれば、API GatewayやStepFunctionsなど、周辺のサービスを含めたデプロイサイクルを考えることが必要になります。ひとくちにサーバレスアーキテクチャといっても、Lambda + α の組み合わせも様々です。それゆえシステム個別の事情が多様化しやすく、より一層パイプラインに入り込みやすくなってしまいがちです。
SAMやServerless Frameworkは、こうした事情をうまくテンプレートに取り込み吸収する可能性を与えてくれるものだと思います。しかし、運用フローやDev/Opsの責任分界点など、アーキテクチャ以上の事情をひとつのパイプラインに押し込もうとすると、低い解像度で見れば同じようなパイプラインが、実際にはオーダーメイド化していってしまいます。
「隙間家具」としてのパイプライン
Lambdaを利用している社内のシステムを見渡してみると、
- 大半のリソースをTerraformで管理できている状況であること
- Lambda関数コードと他のリソースのライフサイクルに差がある場合が多いこと
が見えてきました。このことから、
- Lambda関数のデプロイだけを切り出すことができそう
- 単純にLambda関数をデプロイするだけなら、やることはどのシステムでも同じ
- lambrollを使うので、言ってしまえば
lambroll deploy
を実行するだけ
- lambrollを使うので、言ってしまえば
- 切り出したパイプラインは特定のシステム固有のデプロイパイプラインにしない
- = プロジェクト固有の事情を入れ込まない (「隙間家具」のコンセプトでもある)
という発想に至りました。ただし、「Lambda関数をデプロイするだけ」とはいっても、デプロイパッケージにZipアーカイブを利用するものとコンテナイメージを利用するものがあったり、レイヤーを使ったりするものもあります。
こうした事情に対して後々適切に応えられる設計としつつ、責務(=Lambda関数をデプロイする)に集中させることを意識しました。
例に挙げたものでいえば、以下のような整理でLambdaのデプロイパイプラインとそれ以外の責務を分けました。実際のところ、この境目はlambrollでできること/できないことの境界であり、 「できないことを無理して押し込もうとしない」 ことを念頭に置いています。
- デプロイパッケージにコンテナイメージを利用する場合
- イメージ作成・ECRリポジトリへのプッシュは別のパイプラインを用意する
- イメージがプッシュできたらトリガーとなるzipアーカイブをS3に配置し、Lambda関数のデプロイを開始する
- レイヤーを利用する場合
- レイヤーの更新には別のパイプラインや仕組みを用意する[4]
- TerraformでLambda関数とレイヤーを関連付ける
コンテナイメージをビルド・プッシュするパイプラインも、同じ思想で実装すればLambda以外にもそのまま再利用できるかもしれません。
「必要なくなったらキレイに取り外せる」ことが「隙間家具」の本質ですが、パイプラインはシステムの終わりまで必要なくなることは無いでしょう。一方でシステムはどんどん増えていくし、その要件も増えていきます。「再利用可能であり、『どんな家にもピッタリハマる』」 ことが「隙間家具的パイプライン」の理想の形だと考えています。
さいごに
複数のシステムでこのパイプラインを導入し始めていますが、すべてのLambda関数をデプロイするにはまだまだ道半ばです。
今後も改善を重ねつつ、開発体験の向上に取り組んでいきたいと思います!
Discussion