はじめに
こんなこと言われてないです。ごめんなさい。
AWS Lambda のエイリアス機能について先日思ったことなんです。
インターネットでよく見かける紹介は、
開発環境は dev エイリアス、本番環境は prod エイリアスを利用して、環境を分けることができます!
って感じではないでしょうか?でも↑これ、無理じゃないですか?
いや、やろうと思えばできますよ。
でも、運用上怖い点があるし、何より IaC(ここでは CDK を念頭に置きます)との相性悪すぎじゃない?と思ったので、思ったことを書いてみます。1
前提知識
いくつか前提知識を整理しておきます。AWS Lambda についてある程度利用経験がある方はスキップいただいてOKだと思います。
AWS Lambda
何かしらのイベントをトリガーにコードを実行できる AWS のサーバーレスコンピューティングサービスです。
いくつか提供されているランタイムがあり、例えば Node.js で動くアプリケーションをサクッと書いて動かすことができます。
AWS Lambda Version
AWS Lambda は、コードを zip ファイルにしてアップロードしたり、あるいは Docker Image を使ってデプロイをすることができます。この時、ある時点での Lambda の状態をスナップショットのような感覚で固定して利用することができます。
これを Lambda 関数のバージョンと呼びます。
特別なバージョンの識別子として$LATEST
というものがありますが、これはバージョンを作成する前の状態の Lambda 関数のことです。つまり、コードを変えたりその他設定を変えるということは、$LATEST
に対して変更をすることになり、$LATEST
から Lambda バージョンを発行する形になります。
AWS Lambda エイリアス
発行した Lambda バージョンに対してポインタになるような識別子を定義することができます。
これを Lambda 関数のエイリアスと呼びます。
最初にも述べたように、インターネットで Lambda のエイリアスについて調べてみると、開発用の dev エイリアスと本番用の prod エイリアスを作成するような例を見かけることがあると思います。
さて、前提知識はざっくりこんな感じです。
それでは本題に入っていきたいと思います〜。
開発・本番でエイリアスを分けるのが無理だと思う理由
要点は以下になります:
- 環境変数の更新が怖いじゃん。
- 他の AWS リソース(DynamoDB とか)にエイリアス無いじゃん。
- インフラが宣言的に書けないじゃん。
以下、それぞれの点について考えたことを書いてみます。
なお、以下では断らない限り、 dev エイリアスは$LATEST
を参照しているものとし、prod
エイリアスは特定の Lambda の発行済バージョンを参照しているものとします。
理由1:環境変数の更新が怖いじゃん。
The Twelve Factor App でも言及されているように、アプリケーションが本番・開発などどのデプロイメントで稼働しているかを意識しなくて済むよう、環境変数を使って設定情報を注入する方法はよく使われるかと思います。
AWS Lambda においても環境変数を設定できる機能が備わっています。
↓画面で見るとこんなやつ。関数ごとに設定することができます。
ここで重要な点が、Lambda の環境変数は、バージョンを発行したときに固定されてしまうということです。
ゆえに、環境変数の設定変更は$LATEST
に対してしか行えないことがポイントです。
例えばもし dev/prod エイリアスを利用していて、手動でデプロイをしようとした時、以下のような手順を踏む必要があります:
- dev エイリアス(
$LATEST
)の環境変数の設定を、prod 用に変更する。 - 新しくバージョンを発行する。
- prod エイリアスを更新し、新しく発行したバージョンを参照するように設定する。
- dev エイリアス(
$LATEST
)の環境変数の設定を、dev 用に元に戻す。
↑1~3までは一見問題無いように見えるのですが、4番目の手順がかなり問題なのではないかと思っています。
もしこの手順を忘れてしまうと、開発段階のつもりで動作確認したりしたときに、誤って本番環境のデータを操作してしまったり、何かしら予期せぬ影響を及ぼしてしまう可能性があります。
この4つ目の手順が発生してしまう以上、そもそもの"設計"に問題があると感じます。
ここでの"設計"とはつまり、Lambda のエイリアスを開発・本番で使い分けるということです。
理由2:他の AWS サービス(DynamoDB とか)にエイリアス無いじゃん。
もし開発や本番をスムーズに切り替えられることを意図したものであるなら、他の AWS サービスにもエイリアスに相当するようなものがあって然るべきなのかなと安直に思うのですが、そんなことはないです。
Lambda だけで完結するシステムならそれでもいいかもしれないですが、実際には例えば DB を使うシステムはよく作ることと思います。
そんなときに、開発用・検証用の DB インスタンスを用意することもよくあるでしょう。
つまり(当たり前ですが)本番用の DB とは別の DB を用意することと思います。
この『Lambda はエイリアスで分ける』『DB はインスタンス自体を分ける』という"非対称さ"に違和感を覚えます。
何よりこの方針だと、開発環境と本番環境を別々の AWS アカウントに構築するといったような柔軟かつより安全な開発環境の構築ができないです。
そういった安全策が取れない以上、dev/prod でエイリアスを分けるのは旨味が少ないのではないかと考えています。
理由3:インフラが宣言的に書けないじゃん。
昨今、Terraform や CloudFormation など、インフラストラクチャーをコードで管理するツールが成熟してきました。僕は AWS CDK を使うことが多いのですが、コードでインフラを構築できるのはとても管理がしやすく、重宝しています。
ところで、IaC は宣言的にインフラストラクチャを定義できるところが1つの特徴だと理解しています。
↑この 『宣言的に』 がポイントで、先ほど述べたような手動で環境変数をちまちま変更する作業は、宣言的には書けないです。なぜならそれは『手続き』だからです。
例えば、CDK(typescript)で以下のようにインフラを定義したとします。なお、環境変数ENVIRONMENT
を参照することで、どのデプロイメントに対してインフラを構築するかを振り分けられることを意図しています。
import * as cdk from "aws-cdk-lib";
import { Construct } from "constructs";
import * as lambda from "aws-cdk-lib/aws-lambda";
import { NodejsFunction } from "aws-cdk-lib/aws-lambda-nodejs";
const getEnvironment = () => process.env.ENVIRONMENT || "dev";
export class SampleStack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
new NodejsFunction(
this,
"sample-stack-nodejs-function",
{
functionName: "sample-stack-nodejs-function",
handler: "index.handler",
entry: "lambda/sample-stack-nodejs-function.js",
environment: {
ENVIRONMENT: getEnvironment(),
},
}
);
}
}
とりあえず単に1つの Node.js ランタイムを利用する Lambda 関数を定義してみました。2
この例においては、まだ Lambda 関数のバージョンもエイリアスも発行していません。
なので、$LATEST
に対して、環境変数ENVIRONMENT
をそのまま設定することになります。もちろん$LATEST
なので、デプロイ後に好きに設定を変更できてしまいます。
では、バージョンの発行・エイリアスを定義してみましょう。ここではシンプルに以下のような実装にしてみたいと思います。
import * as cdk from "aws-cdk-lib";
import { Construct } from "constructs";
import * as lambda from "aws-cdk-lib/aws-lambda";
import { NodejsFunction } from "aws-cdk-lib/aws-lambda-nodejs";
const getEnvironment = () => process.env.ENVIRONMENT || "dev";
export class SampleCdkStack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
const sampleFunction = new NodejsFunction(
this,
"sample-stack-nodejs-function",
{
functionName: "sample-stack-nodejs-function",
handler: "index.handler",
entry: "lambda/sample-stack-nodejs-function.js",
environment: {
ENVIRONMENT: getEnvironment(),
},
}
);
// dev エイリアスを定義
new lambda.Alias(
this,
"sample-stack-nodejs-function-dev-alias",
{
aliasName: "dev",
version: sampleFunction.latestVersion,
},
);
// バージョンを発行して、prod エイリアスを定義
if (getEnvironment() === 'prod') {
const lambdaVersion = new lambda.Version(
this,
"sample-stack-nodejs-function-version",
{ lambda: sampleFunction },
);
new lambda.Alias(
this,
"sample-stack-nodejs-function-prod-alias",
{
aliasName: "prod",
version: lambdaVersion,
},
);
}
}
}
かなり安直な実装ですが、ENVIRONMENT === "prod"
がtrue
の場合には Lambda バージョンおよびprod
エイリアスを定義しております。また、dev
エイリアスはいつも使うものとして、常に定義しております。dev エイリアスの定義部分では、sampleFunction.latestVersion
を指定することにより、$LATEST
を参照しています。
このコードだけ見ると、なんか良さそうな気がしてもおかしくないですね。
ところがどっこい、例えばもし、
- まず
ENVIRONMENT == "dev"
でデプロイを実施。開発や動作確認をする。 - 上記が完了したのち、
ENVIRONMENT == "prod"
で本番デプロイを実施。
といった作業をした後、Lambda関数の環境変数はどういった状態になっているでしょう?(シンキングタイム)
・・・・
・・・・
・・・・
はい、そうですね。dev エイリアス($LATEST
)に本番環境と同じ内容の環境変数が設定されているという状態になってしまいます。
図解すると↓こんな感じ。
AWS Lambda のバージョン・エイリアスの仕組み上、まずは$LATEST
を更新した後でバージョンの発行・エイリアスの更新といった手順が必要なため、$LATEST
の環境変数が更新されてしまいます。
よって結果的に、dev エイリアスで本番環境の環境変数が残ったままになってしまいます。
この$LATEST
の設定を戻そうと思うと、どうするとよさそうでしょうか。
手っ取り早いのは、もう一度ENVIRONMENT = "dev"
でデプロイすることでしょうか。
でもこれをやってしまうと、このままでは今度はprod
エイリアスが削除されてしまいます。
ENVIRONMENT = "dev"
の状態では、CDK で出力されるテンプレート内にprod
エイリアスが存在しなくなってしまうからです。
じゃあ今度はprod
エイリアスを常に残すように・・・とか色々こねくり回すことをやり始めると、途端に厄介になりそうです。
これは仕組みで解決すべき課題に感じます。
ほな、どないしよ。
dev/prod エイリアスを使うようなやり方は、これまで述べてきたような問題があるような気がします。
では、どうしたらより安全に開発・運用できるだろうか?という点について考えてみたいと思います。
基本方針
Lambda についていえば、関数リソースごと分けてしまうのが良いと思います。
つまり、開発用の関数、本番用の関数といったものを別々で構築するというものです。
このようにしておけば、ここでは特に言及してなかったですが例えばステージング環境など、他の種類のデプロイメントを追加することも容易です。
また、エイリアスについては以下の図のような方式を考えました。
要点は以下の通りです:
- 開発用 Lambda 関数に定義するエイリアスは dev のみ。
$LATEST
を参照している。 - 本番用 Lambda 関数の
$LATEST
は、原則触らない。$LATEST
を参照するエイリアスは定義しない。 - システムのバージョン(Semantic Versioning に則るとする)に対応する名前のエイリアスを定義し、それぞれ別々の Lambda の発行したバージョンを参照するようにする。
基本方針で意図したこと
まず、開発用の関数については特に言うことは無いです。ごく自然な dev エイリアスではないでしょうか。
一方で、本番環境の Lambda 関数についてですが、まず意図したのは『Lambda のバージョンはちゃんと使おう』という点です。
Lambda のバージョンは、環境変数やデプロイしたコードなどの情報を固定します。
もし万が一$LATEST
を本番環境として使っていると、例えば AWS Management Console から直接コードを書き換えるといったことができてしまいます。環境変数の設定も変えられてしまいます。
悪意を持った人がチームにいないとしても、ヒューマンエラーが起きてしまう可能性はあるので、Lambda のバージョンをきちんと発行することで、デプロイメントの状態を固定し、より安全に運用することを実現することは価値があると思いました。
一方で、Lambda のバージョンの識別子は、ただの自然数の連番3となっており、これは正直あまり嬉しくないです。この連番に意味を持たせたくないですし、管理もしたくありません。
よって、自分達がシステムを運用していく上でより理解のしやすい名前をつける必要があると思います。
そのための手段として、Lambda エイリアスを使うのが良いのではないかと考えました。
ところで、自分達がシステムのリリースを管理する際に、Semantic Versioning のような方法でバージョンを管理することはよくあるのではないかと思います。
もしそうであれば、例えばシステムのバージョンが1.0.0
なのであれば、それに対応したエイリアス名prod_1-0-0
などをつけることで、どのバージョンの Lambda が稼働しているかを理解しやすくなると考えています。4
この基本方針で何が解消できたのか?
冒頭で述べたような問題点に対応して考えると、
- 開発用・本番用で Lambda関数を分けたので、それぞれの環境にデプロイする際に、他の環境のことを意識しなくて済む
- DBなど、他のAWSリソースたちと同様に、デプロイメントごとにリソースを定義した形になったことで、AWS アカウントを分割するといった体制も容易に実現できる。
といった点で改善ができると考えています。
AWS CDK での実装例
先ほどの基本方針をごく簡単に実装してみることにします。
まず関数の定義については、例えば以下のようになるのではないでしょうか。
import * as cdk from "aws-cdk-lib";
import { Construct } from "constructs";
import * as lambda from "aws-cdk-lib/aws-lambda";
import { NodejsFunction } from "aws-cdk-lib/aws-lambda-nodejs";
const getEnvironment = () => process.env.ENVIRONMENT || "dev";
export class SampleStack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
const sampleFunction = new NodejsFunction(
this,
"sample-stack-nodejs-function",
{
functionName: `sample-stack-nodejs-function-${getEnvironment()}`,
handler: "index.handler",
entry: "lambda/sample-stack-nodejs-function.js",
environment: {
ENVIRONMENT: getEnvironment(),
},
},
);
}
}
functionName
にsample-stack-nodejs-function-${getEnvironment()}
と定義している通り、ENVIRONMENT
の設定に応じて別々の Lambda リソースを定義するように工夫しています5。
あとはバージョン・エイリアスの定義になります。
以降は、先ほど実装例とほぼ同じ形になりますが、エイリアス名だけ少し工夫をします:
import * as cdk from "aws-cdk-lib";
import { Construct } from "constructs";
import * as lambda from "aws-cdk-lib/aws-lambda";
import { NodejsFunction } from "aws-cdk-lib/aws-lambda-nodejs";
const getEnvironment = () => process.env.ENVIRONMENT || "dev";
/**
* 現在のシステムのバージョンを定義
* あるいは、別のファイルから読み取るなどの工夫をする。
*/
const CURRENT_SYSTEM_VERSION = "0.0.0";
export class SampleStack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
const sampleFunction = new NodejsFunction(
this,
"sample-stack-nodejs-function",
{
functionName: `sample-stack-nodejs-function-${getEnvironment()}`,
handler: "index.handler",
runtime: lambda.Runtime.NODEJS_18_X,
entry: "lambda/sample-stack-nodejs-function.js",
environment: {
ENVIRONMENT: getEnvironment(),
},
},
);
if (getEnvironment() === 'prod') {
const lambdaVersion = new lambda.Version(
this,
`sample-stack-nodejs-function-version-${CURRENT_SYSTEM_VERSION}`,
{ lambda: sampleFunction },
);
new lambda.Alias(
this,
"sample-stack-nodejs-function-prod-alias",
{
aliasName: `prod_${CURRENT_SYSTEM_VERSION.replace(/\./g, "-")}`,
version: lambdaVersion,
},
);
} else {
new lambda.Alias(
this,
"sample-stack-nodejs-function-dev-alias",
{
aliasName: "dev",
version: sampleFunction.latestVersion,
},
);
}
}
}
クラス定義のすぐ上でCURRENT_SYSTEM_VERSION
という定数を定義しています。(注意:ここではハードコードしてますが、それぞれ管理しやすいように工夫していただけたらと思います)
今回は Lambda にのみ着目した非常に簡単な実装をしましたが、一応こんな形で基本方針に則った実装ができます。
そもそも AWS Lambda のエイリアスってなんのためにあるの?
ここで一度立ち返って、Lambda 関数のエイリアスってなんのための機能だったのか考えてみましょう。
公式ドキュメントから引用します:
You can create one or more aliases for your Lambda function. A Lambda alias is like a pointer to a specific function version. Users can access the function version using the alias Amazon Resource Name (ARN).
引用:https://docs.aws.amazon.com/lambda/latest/dg/configuration-aliases.html
まあ、あんまり大したこと言ってないですね笑
でもここで大事なのは「ユーザーはエイリアス ARN を使って関数のバージョンにアクセスできる」ってところですね。
使いたい実体としては Lambda 関数のバージョンなんだけど、それにアクセスするための手段として、自分で命名できるエイリアスが使えるよ〜という意味だと理解しています。
なので、自然数をまともに管理するのではなく、理解しやすい名前をつけるという意図でエイリアスを使うことはそれほどおかしな設計ではないかなと感じています。6
この方針のデメリット
基本的には、開発環境と本番環境でリソースを完全に分けるという方針なので、安全かつ柔軟に環境構築が行えるという点ではデメリットはあまりないのではないかと考えています。
1つ挙げるとするならば、自システムのバージョン管理をどの程度行うかきちんとチームで方針を決めないといけない点かと思います。
今回の基本方針で扱ったコードの例では、CURRENT_SYSTEM_VERSION
という定数にバージョンを直接書いていました。
例えばこれを、CDKのコードとは別に管理しているものがあるならそちらを参照するよう工夫しないといけないかもしれません。
あるいは、現在のバージョンだけじゃなくて、過去のバージョンも含めて残しておきたいんだという要件もあるかもしれません。その場合にも、バージョンの履歴をどう残すかという点についてあらかじめ方針を決めて管理しておく必要があるかなと思います。7
まとめ
一言でまとめれば、「素直に Lambda 関数ごと、もっと言えば AWS アカウントごと分けてしまうのが良いのだろう」と思った次第です。
普段 AWS を利用されているエンジニアの皆さんはこのあたりどう管理してるんだろうなぁ〜と気になったりしました。ご教示いただけますと幸いです🙇♂️
ここまで、私の長ったらしい駄文を読んでいただき、ありがとうございました。
-
「そんなことはみんな承知で書いてるし読んでるんだよば〜か!」という方はブラウザバック推奨。少なくともエンジニア初心者の頃の僕は「ほげ〜そういうもんなのか〜(ハナホジホジ)」と思っていたので、あの頃の僕に向けて書いてみたいと思います。 ↩
-
ちなみに、本当はランタイムの設定(Node.jsのバージョン)とか、実際に使う上ではもうちょいちゃんと指定する方がいいと思います。今回の記事の本筋から逸れるところは省略しているところが多いです。ご了承ください🙏 ↩
-
ここでは自然数は正の整数とします。 ↩
-
加えて、Stack の名前にも
prod
,dev
といった名前を入れることで別々のスタックに定義しておくことも必要だと思います。でないと、本番環境を構築しようとした際に開発環境を消し去ってしまいます。逆に言うとそれさえしておけば、開発と本番で別の AWS アカウントを使うことも容易ですし、仮に同じ AWS アカウントを使っているとしても、Stack や Lambda 関数自体が完全に分かれているので、ごちゃ混ぜになってしまうリスクもかなり低いと思います。 ↩ -
エイリアスの別機能で、重みをつけたルーティング機能もあります。これも、本番環境でのより柔軟なデプロイ戦略を実現するのに役立つ機能だと思うので、やはり dev/prod といった分け方をするための機能ではないのではないかという考えが強まりました。 ↩
-
今回のように
CURRENT_SYSTEM_VERSION
を1つだけ管理するやり方だと、例えば0.0.1
でデプロイしようとした時、0.0.0
でデプロイした Lambda 関数バージョンが消えてしまいます。なので、こういったことをしたくない場合には、過去の履歴を残すということを考える必要があるのかなと思います。 ↩