AWS Lambda Functionをシングルコマンドでデプロイする

よく訓練されたアップル信者、都元です。AWS Lambdaはみなさんもう既にご利用でしょうか? 2014年のre:Inventで発表され、先日はJava8にも対応しました。CloudFormationによるAWS Lambda Functionリソースの定義にも対応し、東京リージョンにも登場。着々と都元のLambda対応が進んでいることを実感する日々です。あとは、CloudFormationでJava8ランタイムのFunctionを定義できるようになれば…。

AWS Lambdaシングルコマンドデプロイ

さて、都元と言えばシングルコマンドデプロイに命を燃やしていることで有名(?)です。下記を主張し続けてもう10年近くになります(遠い目)

リポジトリからコードをチェックアウトし、 必要に応じて環境固有の設定をした後、

コマンド1つで起動・デプロイができるべきである。Miyamoto, Daisuke (2006)

そりゃもう、AWS Lambdaだって例外ではありません。当然シングルコマンドでデプロイできるべきです。まぁ結論から言えば、まだ少々道半ばではありますが、現状での成果をアウトプットすべく、本エントリーを書いています。

みなさんも動かしてみることができるように、対象となるサンプルプロジェクトはGitHubに上げておきました。

実装したFunction

Javaで書いてみました。そして外部のライブラリ(google guava)に敢えて依存し、依存ライブラリがあるFunctionもきちんとデプロイできることを検証しました。

package jp.classmethod.aws.sample.lambda;

import java.nio.charset.Charset;

import com.amazonaws.services.lambda.runtime.Context;
import com.amazonaws.services.lambda.runtime.LambdaLogger;
import com.amazonaws.services.lambda.runtime.RequestHandler;
import com.google.common.io.BaseEncoding;

public class Base64Encoder implements RequestHandler<String, String> {

private static final Charset UTF_8 = Charset.forName("UTF-8");

@Override
public String handleRequest(String input, Context context) {
LambdaLogger logger = context.getLogger();
logger.log("input = " + input);
String output = BaseEncoding.base64().encode(input.getBytes(UTF_8));
logger.log("output = " + output);
return output;
}
}

というのがFunction本体の全文です。単純に、入力値となる文字列をBase64エンコーディングして返す、というシンプルな仕様です。

AWS Lambda Functionバンドルの作成

今回、プロジェクトのビルド及びデプロイにはGradleというツールを利用します。プロジェクト直下のbuild.gradleというファイルが、Gradleの設定ファイルです。先のプロジェクトではGradle Wrapperという仕組みを仕込んでいますので、試してみる環境にGradleがインストールされている必要はありません。

さて、外部の依存ライブラリを含むAWS Lambda Functionは、通常のjarファイル内の直下libディレクトリ内に依存ライブラリを突っ込む、という構造のjarファイルを作成します。通常の単純なJava環境では、このようなjarを作っても依存したクラスを見つけられずにエラーとなりますが、AWS Lambda環境下では、上手くクラスがロードできるように工夫がされているものと考えられます。

というわけで、このような構造のjarファイルを作成するためのGradleの設定は、下記のような感じです。

jar {
into('lib') {
from configurations.compile
}
}

これをファイルの中のどこかに書いておけばOKです。

$ git clone https://github.com/classmethod-aws/aws-lambda-single-command-deploy-example.git
$ cd aws-lambda-single-command-deploy-example
$ ./gradlew jar
$ unzip -l build/libs/aws-lambda-single-command-deploy-example-0.1-SNAPSHOT.jar
Archive: build/libs/aws-lambda-single-command-deploy-example-0.1-SNAPSHOT.jar
Length Date Time Name
-------- ---- ---- ----
0 07-07-15 14:15 META-INF/
25 07-07-15 14:15 META-INF/MANIFEST.MF
0 07-07-15 14:15 jp/
0 07-07-15 14:15 jp/classmethod/
0 07-07-15 14:15 jp/classmethod/aws/
0 07-07-15 14:15 jp/classmethod/aws/sample/
0 07-07-15 14:15 jp/classmethod/aws/sample/lambda/
1982 07-07-15 14:15 jp/classmethod/aws/sample/lambda/Base64Encoder.class
0 07-07-15 14:15 lib/
5538 07-07-15 11:38 lib/aws-lambda-java-core-1.0.0.jar
2256213 03-16-15 23:44 lib/guava-18.0.jar
-------- -------
2263758 11 files

という感じで、望み通りの形式になっていることが分かるでしょうか。

Functionバンドルをデプロイする(解説編)

さて、このjarファイルをAWSにデプロイしなければなりません。

今回のキモとなるのはgradle-aws-pluginです。裏でコソコソとAWSリソースを操作するGradleプラグインを作っているのですが、未だに仕様が固まっていないので今ひとつpublicに発表しづらい状態です。華やかなデビューはもうしばらくお待ちくださいw ドキュメントではなくexampleによる説明が大部分ですが、もしご興味がありましたらREADMEをざっとご覧頂ければ。

さて話を戻しまして。

aws {
profileName "default"
region "ap-northeast-1"
}

task migrateFunction(type: AWSLambdaMigrateFunctionTask, dependsOn: build) {
functionName = project.functionName
role = "arn:aws:iam::${aws.accountId}:role/lambda-poweruser"
runtime = Runtime.Java8
zipFile = jar.archivePath
handler = "jp.classmethod.aws.sample.lambda.Base64Encoder"
}

build.gradle内に上記のような宣言をしておくと、./gradlew migrateFunction というコマンド1つでAWS Lambda Functionをデプロイ可能です。

この記述はそこそこ細かく説明しておきましょう。

  • profileNameという設定は、~/.aws/credentials内に宣言したプロファイル名です。詳しくは【鍵管理】~/.aws/credentials を唯一のAPIキー管理場所とすべし【大指針】等を御覧ください。
  • regionはデプロイ先のリージョンですね。難しいことはないと思います。
  • migrateFunctionタスクの定義で、AWSLambdaMigrateFunctionTask型を指定しています。また、dependsOn buildとすることによって、jarファイルを生成した後にこのタスクが走るように制御しています。
  • functionNameは任意のFunction名です。build.gradle23行目で宣言した変数を参照しているだけです。
  • role はFunction実行時に与えられるAWS APIを操作するためのロールです。今回の例ではFunction内でAWS APIをコールしないので利用しませんが、CreateFunction操作にあたってRoleの指定が必須であるために指定しています。lambda-poweruserという名前のロールは予め作成しておいてください。(というのがシングルコマンドデプロイに反します。 TODO いつか倒す。)
  • runtimeはJava8を選択しました。NodeJSにおけるシングルコマンドデプロイをしたい場合はこのあたりをご参考に。
  • zipFileは、jarファイルのローカル環境下でのパス指定です。jarタスクが位置を知っていますので、そのarchivePathプロパティから位置を取り出しています。
  • handlerはFunctionのエントリポイントとなるクラスの指定です。

Functionバンドルをデプロイする(実践編)

さてやってみましょう。まず、検証環境にはFunctionが一つも無いことを確認。

$ aws lambda list-functions
{
"Functions": []
}

そしてコマンド1つでデプロイします。

$ ./gradlew migrateFunction
:clean
:compileJava
:processResources UP-TO-DATE
:classes
:jar
:assemble
:compileTestJava UP-TO-DATE
:processTestResources
:testClasses
:test UP-TO-DATE
:check UP-TO-DATE
:build
:migrateFunction
Function not found: arn:aws:lambda:ap-northeast-1:000011112222:function:base64encoder:HEAD (Service: AWSLambda; Status Code: 404; Error Code: ResourceNotFoundException; Request ID: 617ce8cb-xxxx-xxxx-xxxx-4b61e6ab18b0)

BUILD SUCCESSFUL

Total time: 3.128 secs

Function not foundというメッセージが出ていますが、初回作成なので既存のFunctionが無かったことを伝えています。既に同名のFunctionがある場合は、実装を更新します。さて、Functionはデプロイされたでしょうか。

$ aws lambda list-functions
{
"Functions": [
{
"FunctionName": "base64encoder",
"MemorySize": 128,
"CodeSize": 2000474,
"FunctionArn": "arn:aws:lambda:ap-northeast-1:000011112222:function:base64encoder",
"Handler": "jp.classmethod.aws.sample.lambda.Base64Encoder",
"Role": "arn:aws:iam::000011112222:role/lambda-poweruser",
"Timeout": 3,
"LastModified": "2015-07-07T05:31:13.908+0000",
"Runtime": "java8",
"Description": ""
}
]
}

問題ありませんね。では呼び出してみましょう。このコマンドは、src/test/resources/input.txtを入力値として送信し、出力値(結果)をbuild/out.txtに書き出す、という指定をしています。

$ cat src/test/resources/input.txt
"よく訓練されたアップル信者、都元です。"
$ aws lambda invoke --function-name base64encoder --payload $(cat src/test/resources/input.txt) build/out.txt
{
"StatusCode": 200
}

200レスポンスということで、成功したようですね。内容もBase64っぽい。

$ cat build/out.txt
"44KI44GP6KiT57e044GV44KM44Gf44Ki44OD44OX44Or5L+h6ICF44CB6YO95YWD44Gn44GZ44CC"

jqを使ってダブルクオートを外し、base64コマンドでデコードしてみます。

$ cat build/out.txt | jq -r '.' | base64 -d
よく訓練されたアップル信者、都元です。

成功しましたね! ちなみにFunctionの呼び出しは、Gradleからも可能です。

$ ./gradlew invokeFunction
:invokeFunction
Lambda function result: "44KI44GP6KiT57e044GV44KM44Gf44Ki44OD44OX44Or5L+h6ICF44CB6YO95YWD44Gn44GZ44CC"

BUILD SUCCESSFUL

Total time: 3.335 secs

最後に、作ったFunctionを消しておきましょう。

$ ./gradlew deleteFunction
:deleteFunction

BUILD SUCCESSFUL

Total time: 1.778 secs

まとめ

Java8ランタイムのAWS Lambda Functionを、Gradleを使ってシングルコマンド(一歩手前)デプロイしてみました。これで、Functionの実装を書き換えては、すぐにdeployというサイクルを高速に回せますねー。