Lambda×RDSで構成するサーバレスアーキテクチャのIaCツール選定
メディカルフォースでCTOをしている畠中です。
medicalforceの開発は2020年の10月ごろからスタートしており、Lambda×RDSで構成されています。
3年程運用してみてインフラ周りにおける所感を共有できたらと思います。
インフラ選定やIaCツール選定に迷われている方のお役に立てばと思います。
要旨
サーバレスを選定した場合、lambdaやapigatewayといったリソースを開発時に管理する必要がありその負荷が課題となることで採用を踏みとどまることはあると思います。
またローカル開発のしにくさというのも大きなデメリットの一つです。
またDBについては基本的に大規模アプリケーションではRDBMSを採用するべきだと考えていますが、その場合はVPCなどを含めたインフラの管理負荷が上がってしまいます。
サーバレスの素晴らしさはご存知の通りですが、サーバレスを採用しつつ普段の開発・管理負荷を下げるためにlambdaやapigatewayについてはslsを用いることで簡易に運用ができるようにし、それ以外のリソースにおいてはterraformを使うことで影響範囲を可視化でき、事故を防ぎ管理負荷を下げることができると考えています。
またローカル開発についてはslsのプラグインを活用することで簡単に実現ができるため、インフラのことを意識せずにサーバレス開発を実現することは可能だと考えています。
目的
アプリケーションにおいて、実現したい機能をできるだけ簡単に実現でき、ドメインの知識とアプリケーションの仕様を起こすことに集中できるようにすることは重要だと考えています。
サーバレスは素晴らしい概念ではある一方、現実には管理や開発が難しくなってしまうという側面もあります。特にLambda×RDSで構成する場合、サーバレスの良さを享受しつつ普段の開発や管理に負荷がかからない方法を模索したいです。
そのためにはインフラに変更が必要な場合に編集する箇所が明確であることと、編集した結果どのような影響があるかが明確になっていることは重要です。その意味でIaCツールには編集頻度が高いものの記述が簡単であることと、編集による影響が明示されることを求めており、以上を実現するためにどのIaCツールを選定するべきかを考察することが目的です。
背景
2020年10月当時はLambdaといえばDynamoDBなどのNoSQLとセットで使われることが多かったです。
LambdaとRDSをはじめとしたRDBMSの併用はコネクション数の上限に達してしまうという理由からアンチパターンとされてきたためです。
上記の記事にあるように2020年ごろにRDS Proxyが発表されLambda×RDSというインフラ構成をちょくちょく見るようになってきました。
ECSなどもある中でなぜLambdaを使いたかったかというと、イベントドリブンな動きをするため、必要なときに起動されるというコストパフォーマンスの高いサービスであるということと、運用が楽だからです。
次にDBについてですが実は弊社でも当初LambdaとDynamoDBでアプリケーションを組んでいました。
しかし、DynamoDBはとにかく設計が難しいです。
DynamoDBにおいてはテーブルの数をできるだけ少なくすることがベストプラクティスであり、それを実現するために高度な設計を都度行っていく必要があります。
その観点から長期でアプリケーションをメンテナンスするには設計の再現性がなく断念しました。
ほかにきつかった点としてマイグレーションです。
マイグレーションを行うためには都度スクリプトを自作する必要があり、テストも必要になるので結構辛いです。
基本的にはカラムを増やすなどのmigrationは行わないのですが、特定のカラムが存在するかもしれないし、しないかもしれない、ということになると例えばデフォルト値を設けたいときなど、アプリケーション側で担保することになるのですが、どんどんとコードから腐敗臭がし始めるのであまりおすすめはしません。
というわけでデータベースは大規模なソフトウェアを開発する時にはRDBMSを選択しておいた方が無難でしょう。
ただRDBMSを選択するデメリットもあります。
その中の一つとして、インフラ構築が面倒くさいことでしょう。
DynamoとLambdaだけだとインターネットにあるサービス同士なのでVPCなどのインフラを構築する必要がないため構築のオーバーヘッドは少ないという利点はあったのですが、RDSなどを使う場合はVPCの構築が必須です。
インフラ構成
ECSなどを使っている人からすると特段新しいことはないのと本題ではないので軽くだけ書きます。
こちらがうちのインフラ構成図です。まず大きく分けるとVPC,DB周りのインフラに分けられると思います。
SQSやS3、EC2などは今回は説明を省きます。
VPC周り
まずVPCが土台でそこにサブネットを切っていく感じです。
サブネットは、VPC の IP アドレスの範囲です。サブネットは、1 つのアベイラビリティーゾーンに存在する必要があります。AZは物理的に隔離されているデータセンターで、これを分けておくことで障害に強くできます。
なのでサブネットはAZごとに切っておきます。
さらに、サブネットの中でもプライベートサブネットとパブリックサブネットに切り分けます。
なのでプライベートサブネットとパブリックサブネットが各AZにあります。
プライベートサブネットは基本的に通信がインターネットと直接出入りできないサブネット、パブリックサブネットは通信がインターネットに直接出入りすることができるサブネットです。
これらはサブネットとしては並列なので、この区別をつけるためにはルートテーブルとインターネットゲートウェイいうリソースを用いる必要があります。
ルートテーブルにはサブネット間の通信経路を定義します。どのIPアドレスへの通信に対して、どういった経路を用いて接続するかを定義します。
インターネットゲートウェイは、VPCとインターネットとの間の双方向の通信を可能にします。
ルートテーブルにおいて、通信がインターネットゲートウェイへ接続されているパブリックサブネットの総称です。逆にインターネットに通信が接続できないように設定するとプライベートサブネットとなります。
ここまででVPCとプライベートサブネットとパブリックサブネットが各AZにそれぞれ定義できました。
セキュリティ上、データが入っているRDSなどはインターネットから接続できないプライベートサブネットにおくことにします。またLambdaも同様にプライベートサブネットにおくことにします。
ただここまで来ると困ったことが起きます。インターネット上に存在しているリソースであるDynamoDBやS3などにプライベートサブネットからアクセスできなくなってしまいます。
そのために用いるリソースがNATゲートウェイやVPCエンドポイントです。
NATゲートウェイはプライベートサブネット内のリソースがインターネットにアクセスするための方法を提供し、外部からの直接アクセスは防ぎます。
VPCエンドポイントとはNATゲートウェイを経由することなく、VPCと他のAWSのサービスとをプライベートに接続できるAWSのサービスです。
これによりプライベートサブネットから外部のリソースにアクセスできるようになりました。
また、開発者からするとプライベートサブネットにあるリソースにインターネットを通じてアクセスできないという問題が生まれます。
これに関してはパブリックサブネットに踏み台のEC2を建てておいて、アクセスできるようにしましょう。
以上でVPC周りは設定完了です。
DB周り
DB周りはまずRDSを作ります。今回はAuroraのpostgresを使うことにします。
RDSにはクラスターとインスタンスという概念があります。
クラスターは1つ以上のDBインスタンスと、これらのDBインスタンスのデータを管理する1つのクラスターボリュームで構成されます。
インスタンスは実際のコンピューティングリソースです。
細かいですが、ほかにサブネットグループというインスタンスを設置するサブネットのグループを定義します。
あとはRDS Proxyを設定して完了です。
IaCツール比較
ここまででお分かりのように、結構色々なリソースを定義する必要がありますし、リソース間の依存関係も複雑で全員でインフラ構成を理解したり、新しくシステムを作るときに再現することは困難そうです。
そこで出てくるのがIaCです。
エンジニアにとって理想はソースコードそのものがドキュメントとなっていることです。なのでインフラリソースもコードで管理してしまおうというのがIaCの考え方です。
とはいえ世の中にはさまざまなIaCツールがあります。
弊社では長くServerless framework(以下sls)というツールを用いていました。
slsについて
特にlambda, apigateway周りに関しては記述量がかなり少なく抑えられることが利点です。
さらにVPCやDBに関するインフラもデプロイすることができるので、全てのインフラをこちらに記載していました。
基本問題なく運用できていたものの、ある時大変なことが起こってしまいました、、、
一部のコードをコメントアウトしてしまったyamlをslsでデプロイしてしまい、開発環境が崩壊するという事件が発生してしまいました。
さらに、slsではコードに忠実にインフラを再現しようとするあまり、リソースのコンフリクトがあるままデプロイを複数人が行なってしまうと上書きされてしまい、せっかく作ったリソースが破壊されてしまうというような問題も起き、デプロイは特定の人が担当しムダなコミュニケーションが起きてしまうという問題も起きていました。
またslsにおいては一部のリソースについては依存関係を明記する必要があり、依存関係を知っておかないといけなかったり、依存関係を明記できていない部分に関してはリソースを構築する際に順序を追ってやっていかないといけなかったりとIaCの利点であるリソースの再構築などが面倒になってしまうという問題もありました。
terraformについて
terraformの特徴として、planというコマンドがあることが挙げられます。
planではslsでは面倒だったデプロイされるインフラの差分が明示できることと、デプロイの度にstateを更新してくれるため、変更点の明示が素早くできるというポイントからslsの問題点を解決できます。
またterraformでは依存関係は明示する必要がなく、とにかく書いておけば依存関係は解釈してくれるためインフラの詳しい知識がなくても管理できます。
何よりもインフラ吹き飛ばし事故が発生しないことが素晴らしいですね。
ただそんなterraformもデメリットがないわけではないです。
特にapigateway/lambda周りのコードが複雑になってしまいます。
terraformとslsの比較
記述量について
例としてchatgptに書いてもらうと
terraformでapigateway/lambda周りのコードを書いてもらうと
resource "aws_lambda_function" "example_lambda" {
function_name = "example_lambda"
handler = "index.handler"
runtime = "nodejs12.x"
role = aws_iam_role.lambda_exec_role.arn
// Lambda関数のコード
filename = "path/to/your/deployment/package.zip"
}
resource "aws_iam_role" "lambda_exec_role" {
name = "lambda_exec_role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = "sts:AssumeRole"
Principal = {
Service = "lambda.amazonaws.com"
}
Effect = "Allow"
Sid = ""
},
]
})
}
# CloudWatch Logs へのアクセス権限を持つポリシー
resource "aws_iam_policy" "cloudwatch_logs_policy" {
name = "CloudWatchLogsPolicy"
description = "IAM policy for logging from Lambda to CloudWatch Logs"
policy = jsonencode({
Version = "2012-10-17",
Statement = [
{
Effect: "Allow",
Action: [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents"
],
Resource: "arn:aws:logs:*:*:*"
},
]
})
}
# IAM ロールにポリシーをアタッチ
resource "aws_iam_role_policy_attachment" "logs_attachment" {
role = aws_iam_role.lambda_exec_role.name
policy_arn = aws_iam_policy.cloudwatch_logs_policy.arn
}
resource "aws_api_gateway_rest_api" "example_api" {
name = "ExampleAPI"
}
resource "aws_api_gateway_resource" "example_resource" {
rest_api_id = aws_api_gateway_rest_api.example_api.id
parent_id = aws_api_gateway_rest_api.example_api.root_resource_id
path_part = "examplepath"
}
resource "aws_api_gateway_method" "example_method" {
rest_api_id = aws_api_gateway_rest_api.example_api.id
resource_id = aws_api_gateway_resource.example_resource.id
http_method = "GET"
authorization = "NONE"
}
resource "aws_api_gateway_integration" "lambda_integration" {
rest_api_id = aws_api_gateway_rest_api.example_api.id
resource_id = aws_api_gateway_resource.example_resource.id
http_method = aws_api_gateway_method.example_method.http_method
integration_http_method = "POST"
type = "AWS_PROXY"
uri = aws_lambda_function.example_lambda.invoke_arn
}
# CloudWatch Logs グループの定義
resource "aws_cloudwatch_log_group" "lambda_log_group" {
name = "/aws/lambda/${aws_lambda_function.example_lambda.function_name}"
retention_in_days = 14
}
このようにリソースとそれらの紐付けを明示的に管理する必要があります。
一方で同じようにslsに書いてもらうと
provider:
iamRoleStatements:
- Effect: "Allow"
Action:
- "logs:CreateLogGroup"
- "logs:CreateLogStream"
- "logs:PutLogEvents"
Resource:
- "arn:aws:logs:${self:provider.region}:*:log-group:/aws/lambda/${self:service}-${self:provider.stage}-*:log-stream:*"
functions:
exampleFunction:
handler: index.handler
events:
- http:
path: /users
method: get
なんとこれだけで済みます。
lambdaおよびapi gatewayを一つ増やすことを考えるとさらに差は歴然です。
同じくchatgptに書いてもらいます。
terraformでは以下です。
# 新しい Lambda 関数
resource "aws_lambda_function" "example_lambda2" {
function_name = "example_lambda2"
handler = "index.handler"
runtime = "nodejs12.x"
role = aws_iam_role.lambda_exec_role.arn
// Lambda関数のコード
filename = "path/to/your/deployment/package.zip"
}
# 新しい API Gateway Resource
resource "aws_api_gateway_resource" "example_resource2" {
rest_api_id = aws_api_gateway_rest_api.example_api.id
parent_id = aws_api_gateway_rest_api.example_api.root_resource_id
path_part = "examplepath2"
}
# 新しい API Gateway Method
resource "aws_api_gateway_method" "example_method2" {
rest_api_id = aws_api_gateway_rest_api.example_api.id
resource_id = aws_api_gateway_resource.example_resource2.id
http_method = "GET"
authorization = "NONE"
}
# 新しい API Gateway Integration
resource "aws_api_gateway_integration" "lambda_integration2" {
rest_api_id = aws_api_gateway_rest_api.example_api.id
resource_id = aws_api_gateway_resource.example_resource2.id
http_method = aws_api_gateway_method.example_method2.http_method
integration_http_method = "POST"
type = "AWS_PROXY"
uri = aws_lambda_function.example_lambda2.invoke_arn
}
# 新しい CloudWatch Logs グループ
resource "aws_cloudwatch_log_group" "lambda_log_group2" {
name = "/aws/lambda/${aws_lambda_function.example_lambda2.function_name}"
retention_in_days = 14
}
これに対してslsでは
# 新しい Lambda 関数と API Gateway のエンドポイントの追加
newExampleFunction:
handler: newHandler.handler
events:
- http:
path: /new-path
method: get
なんとこれだけで済みます、素晴らしいですね。
ローカル開発
slsを推すもう一つの理由にlambdaのローカル開発の簡単さがあります。
サーバレスの最も大きな欠点としてローカル開発のやりにくさがありますが、serverless offlineというプラグインを入れるとローカルでlambdaのモックサーバーが立つのは開発者目線では嬉しいです。
結論
以上からまず現在ではLambda×RDSという構成のデメリットの一つであるインフラ管理のコストは少なく、十分選択できると思います。
またIaCツールには編集頻度が高いものの記述が簡単であることと、編集による影響が明示されることを求めていましたが、編集頻度の高いlambda/apigateway周りはslsを用いることで簡単に記述でき、それ以外のリソースにはterraformを使うことで編集による影響が明示され事故を防ぐことができると考えています。
課題
弊社では新しいプロダクトのインフラはこの構成で構築していますが、まだ十分に運用されていないため運用面での評価については後日追記できたらと思います。
また、ローカル開発においてdynamoやs3を使用する場合はそちらのモックも必要ですが、これについてはslsではsls offlineで簡単にできたもののterraformではscriptを用意する必要があり、そこに関してはすでに面倒そうだと感じています。
ただ、リソース(特にdynamoやs3などデータの入ったもの)を消し飛ばしてしまうリスクを負うよりは確実に良いのでterraformを採用しています。
こちらについても簡単な方法が見つかれば後日追記できたらと思います。
Discussion