はじめに
サーバーレスアプリケーションの開発に使えるフレームワークはいくつかあるけれども、アプリケーションそのものにフォーカスしたフレームワークって実は少ないと思ってます。もちろん知らないだけって可能性も高いですが。
例えばServerless FrameworkやClaudia.js、AWSの提供するServerless Application Modelなんかはアプリケーションフレームワークというよりも、サーバーレスアプリケーションの管理とデプロイのためのツールだと個人的には捉えています。
また、AWSが提供するものとしてChaliceというPython製のフレームワークがありましてこれはPythonでAWS Lambdaを使ったサーバーレスアプリケーションを開発するにあたって、Pythonで書いたコードに対してデコレータを指定していくことでリソース定義も同時に行われるという感じでして、いわゆるアプリケーションフレームワーク的に開発ができるので好きだったりします。
そんな中で日本の誇るサーバーレスフルコミットデベロッパーである堀家氏(@horike37)率いるServerless OperationsがJeffyというPython向けのアプリケーションフレームワークをリリースしたということで期待を込めて早速見ていきつつ簡単に紹介したいと思います。
期せずして今回もPython向けとなってはいます。ちなみに以前、個人的にTwitter上で2回ほどアンケート調査をしたのですがどちらも圧倒的にPythonが多かったです。Twitterなので多少のバイアスがかかっているとは思うものの体感としてもPythonが多い気がしています。一方で海外勢はNode.jsを使うことが多いって話を聞いたこともありますね。
一回目
あなたのLambdaファンクション、主にどの言語で書かれてる?その他はリプで. ちなみに僕はほぼPython
— Keisuke Nishitani (@Keisuke69) 2020年2月1日
二回目
というわけでLambdaファンクションをどの言語で書いてるかアンケートの第2回をやります。今回は前回あまり支持がなかったJavaと.NETを外して代わりにリプで多かったGoとRubyを。カスタムランタイムの人はリプで何使ってるかも含めてお願いします。なるべく広く集めたいからよければRTお願いします。
— Keisuke Nishitani (@Keisuke69) 2020年2月3日
Jeffy
Pythonでサーバーレスアプリケーションを開発するためのフレームワークです。サイトを見たところ以下のポイントにフォーカスして開発してるとのこと。
- Logging: 全てのデコレータは全イベント/レスポンス/エラーを補足してJSONフォーマットで簡単に見れるように。独自の属性も追加することも可
- Decorators: AWS Lambdaを使う上で毎回のように必要となる実装をPythonのデコレータで省力化
- Tracing:
correlation_id
を受け渡しすることで関連するLambda関数とAWSサービス内でイベントをトレース - Configurable: You can customize the framework settings easily.
Lambdaでアプリケーションを開発する場合、Lambda関数という言葉の通り1つ1つの関数はできるだけシンプルかつ単機能なものとして実装して複数の関数やAWSサービスなどを協調動作させていくわけですが、それがゆえに複雑になりがちな不具合調査とか、頻発する処理の抽象化あたりにフォーカスしている模様。
ちなみにざっと見た感じ開発に特化しているためか、デプロイ周りの機能はなさそう。なのでLambda関数なり連携するAWSサービスなりのリソースは別の手段で自分で作る必要がありそうです。今回はServerless Frameworkを使ってやってしまいます。
あと、CLIで対話的にコマンド実行することで雛形となるプロジェクトであったりそういったものが自動で作成される、といったものでもないです。
インストール
普通にpipなどでinstall可能です。僕はPoetryを使っているので以下のような感じでインストール。
$ poetry add jeffy
事前準備
前述のとおりJeffy自体にはリソースを作成したりデプロイしたりといった機能はないので、試すためのリソースを事前に用意しておきます。今回はServerless Frameworkを利用してやっていきたいと思います。
Serverless Frameworkの導入とか初期セットアップは割愛しますが、初めての人はこちらのQiitaの記事を参考にするといいかもです。ちなみにこれも@horike37氏の記事です。
とりあえず今回はこんな感じで作成しています。
$ serverless create --template aws-python3 --name jeffy-sample --path ./jeffy-sample
本来であればserverless.yaml
に作成する関数などの設定をしていくのですが今回はデフォルトのままでとりあえず行きます。デフォルトだとリージョンがus-east-1
になってしまうので東京リージョン使いたい人は以下のように指定するといいです。
$ cd jeffy-sample/ $ serverless deploy --region ap-northeast-1
マネージメントコンソール上でLambdaのコンソールを確認するとjeffy-sample-dev-hello
というLambda関数が作成されていることが確認できると思います。
実行してみます。
$ serverless invoke --function hello --region ap-northeast-1 { "statusCode": 200, "body" }
はい、というわけで基本的なものが作成できたのでここからはこの関数に対してJeffyを使ってごにょごにょ試していきたいと思います。
Serverless Frameworkで作成されたhandler.py
にまずはちょっと追加してみたいと思います。
追加するのはログ周りの基本的な処理です。
import json from jeffy.framework import get_app app = get_app() def hello(event, context): body = { "message": "Go Serverless v1.0! Your function executed successfully!", "input": event } response = { "statusCode": 200, "body": json.dumps(body) } app.logger.info({'foo': 'bar'}) return response # Use this code if you don't use the http event with the LAMBDA-PROXY # integration """ return { "message": "Go Serverless v1.0! Your function executed successfully!", "event": event } """
app.logger.info
で任意のログメッセージを出力できます。
早速これをデプロイするわけですが、今回のように依存するライブラリがある場合、それらをデプロイパッケージとしてまとめる必要があります。serverless deploy
そのままではデプロイできないのでそのあたりをよろしくやってくれるプラグインであるserverless-python-requirements
を利用します。
しかも僕の環境はPoetryなのでrequirements.txtを手動で出力してから実行とか必要かなと思ってたんですが、なんとこのプラグインはそれを自動でやってくれるんです。ありがてぇ。
$ sls plugin install -n serverless-python-requirements
serverless.yaml
にプラグインの設定を追加。
plugins: - serverless-python-requirements
インストールできたらserverless deploy
でデプロイした後、実行してみます。ちなみに実行はserverless invoke --function <ファンクション名>
でひとまずはOKです。このときも必要であれば--region
オプションで使いたいリージョンを指定できます。
というわけで実行結果なんですが以下のようなJSON形式のログがClouwdWatch Logsに出力されていることがわかります。ログって単純にprint
で標準出力でも出せますが情報量が圧倒的に違いますね。
また、公式ドキュメントではPython
のLogger
を使う方法を案内してますが、これもログレベル、タイムスタンプ、リクエスト IDが追加されるだけなのでやはり情報量が圧倒的に違うとも言えます。
{ "name": "jeffy", "msg": { "foo": "bar" }, "args": [], "levelname": "INFO", "levelno": 20, "pathname": "/var/task/handler.py", "filename": "handler.py", "module": "handler", "exc_info": null, "exc_text": null, "stack_info": null, "lineno": 17, "funcName": "hello", "created": "2020-06-09 11:21:29,206", "msecs": 206.1450481414795, "relativeCreated": 481.66751861572266, "thread": 140024725681984, "threadName": "MainThread", "processName": "MainProcess", "process": 7, "aws_region": "ap-northeast-1", "function_name": "jeffy-sample-dev-hello", "function_version": "$LATEST", "function_memory_size": "1024", "log_group_name": "/aws/lambda/jeffy-sample-dev-hello", "log_stream_name": "2020/06/09/[$LATEST]e48f95444f6746ce92e449231b3f38fa" }
ちなみに、任意のメッセージはmsg
の下に出力されます。今回はサンプル通りapp.logger.info({'foo': 'bar'})
としているのでJSON形式でネストされた感じで出力されてますが、ここの引数の部分は単なる文字列でも大丈夫でした。
その他、こんな感じでログに出力する属性を追加したり、
app.logger.update_context({ 'username': 'user1', 'email': 'user1@example.com' })
ログレベルを変更することもできるそう。
from jeffy.settings import Logging app = get_app(logging=Logging(log_level=logging.DEBUG))
出力されている内容も公式のサンプルより多かったりするけど、このあたりは追えてないです。このあたりのログの出力内容を減らしたりすることはできるのだろうか。
デコレータ
個人的にいいなと思ったのがこのデコレータ。Lambdaでアプリケーション書くときに必ずやるような処理を簡単にしてくれます。サーバーレスでコード減ってるのをさらに減らしてくれます。例えば、エラーハンドリングであったりKinesisやSQSをイベントソースとして使う場合のレコードをパースする処理なんかをやってくれます。こういうボイラープレート的な処理をやってくれるのはよりビジネスロジックに集中できていいのではないでしょうか。そもそもデコレータとかアノテーションとかって仕組みが個人的には好きだったりします。
早速試してみます。なお、ここからは適宜Lambda関数自体をわけて試していきます。あとここからはserverless invoke local
でローカル実行してます。
まずは共通系とも言えるエラーハンドリングを自動でやってくれるやつ。これは例外が発生したときにエラーの情報とともにイベントやレスポンスの情報も出力してくれるらしい。試すにあたってはとりあえずゼロ除算で試してみました。
だがしかし、つけただけでこける。なんだこれは何がいけないのか。 -> Issue切ったところすぐに直りました。試してた途中で見つけたもう1件もあわせて切ってこちらも対応済。速い!
というわけで気を取り直してやっていきます。
まずは何もつけないパターン。
import json from jeffy.framework import get_app app = get_app() def handler(event, context): return 1/0
これを実行するとこんな出力になります。
Traceback (most recent call last): File "/usr/local/lib/node_modules/serverless/lib/plugins/aws/invokeLocal/invoke.py", line 86, in <module> result = handler(input['event'], context) File "./decorator_sample.py", line 7, in handler return 1/0 ZeroDivisionError: division by zero
ま、普通ですね。これに対して以下のようにhandler
に対してデコレータを設定して実行してみます。
@app.handlers.common() def handler(event, context):
なお、ここではevent.json
というイベントの情報を静的に定義したファイルを用意して、実行時に-p event.json
をつけて実行しています。event.json
の中身はごくシンプルなJSONです。
{ "foo": "bar" }
実行するとこんな感じのログが自動的に出力されます。ちょっと見方に悩むかもですが、最初のエントリがイベントの中身のダンプを含むログで、2行目が例外発生時のスタックトレースですね。
{ "name": "jeffy", "msg": { "foo": "bar" }, "args": [], "levelname": "INFO", "levelno": 20, "pathname": "/usr/local/lib/python3.8/site-packages/jeffy/handlers/common.py", "filename": "common.py", "module": "common", "exc_info": null, "exc_text": null, "stack_info": null, "lineno": 30, "funcName": "wrapper", "created": "2020-06-10 10:42:23,786", "msecs": 786.0274314880371, "relativeCreated": 20.52927017211914, "thread": 140072547170112, "threadName": "MainThread", "processName": "MainProcess", "process": 93166, "aws_region": "ap-northeast-1", "function_name": "jeffy-sample-dev-decorator_sample", "function_version": "$LATEST", "function_memory_size": "1024", "log_group_name": "/aws/lambda/jeffy-sample-dev-decorator_sample", "log_stream_name": "2016/12/02/[$LATEST]f77ff5e4026c45bda9a9ebcec6bc9cad", "correlation_id": "1e1ca4b6-a204-4f05-b00d-53540d649570" } { "name": "jeffy", "msg": "division by zero", "args": [], "levelname": "ERROR", "levelno": 40, "pathname": "/usr/local/lib/python3.8/site-packages/jeffy/handlers/common.py", "filename": "common.py", "module": "common", "exc_info": "Traceback (most recent call last):\n File \"/usr/local/lib/python3.8/site-packages/jeffy/handlers/common.py\", line 32, in wrapper\n result = func(event, context)\n File \"./decorator_sample.py\", line 6, in handler\n return 1/0\nZeroDivisionError: division by zero", "exc_text": null, "stack_info": null, "lineno": 36, "funcName": "wrapper", "created": "2020-06-10 10:42:23,786", "msecs": 786.2870693206787, "relativeCreated": 20.788908004760742, "thread": 140072547170112, "threadName": "MainThread", "processName": "MainProcess", "process": 93166, "aws_region": "ap-northeast-1", "function_name": "jeffy-sample-dev-decorator_sample", "function_version": "$LATEST", "function_memory_size": "1024", "log_group_name": "/aws/lambda/jeffy-sample-dev-decorator_sample", "log_stream_name": "2016/12/02/[$LATEST]f77ff5e4026c45bda9a9ebcec6bc9cad", "correlation_id": "1e1ca4b6-a204-4f05-b00d-53540d649570" } (省略)
その他にもデコレータはいくつか用意されています。例えばKinesisやSQSのイベントをパースするなんてのはそれこそLambda関数を書く場合に頻発する処理なんですがそのあたりをやってくれてアクセスが楽になります。以下はSQSの場合です。
通常、SQSのイベントってRecordsに配列でデータが渡されてきます。
{ "Records": [ { "messageId": "059f36b4-87a3-44ab-83d2-661975830a7d", "receiptHandle": "AQEBwJnKyrHigUMZj6rYigCgxlaS3SLy0a...", "body": "Test message.", "attributes": { "ApproximateReceiveCount": "1", "SentTimestamp": "1545082649183", "SenderId": "AIDAIENQZJOLO23YVJ4VO", "ApproximateFirstReceiveTimestamp": "1545082649185" }, "messageAttributes": {}, "md5OfBody": "e4e68fb7bd0e697a0ae8f1bb342846b3", "eventSource": "aws:sqs", "eventSourceARN": "arn:aws:sqs:us-east-2:123456789012:my-queue", "awsRegion": "us-east-2" }, { "messageId": "2e1424d4-f796-459a-8184-9c92662be6da", "receiptHandle": "AQEBzWwaftRI0KuVm4tP+/7q1rGgNqicHq...", "body": "Test message.", "attributes": { "ApproximateReceiveCount": "1", "SentTimestamp": "1545082650636", "SenderId": "AIDAIENQZJOLO23YVJ4VO", "ApproximateFirstReceiveTimestamp": "1545082650649" }, "messageAttributes": {}, "md5OfBody": "e4e68fb7bd0e697a0ae8f1bb342846b3", "eventSource": "aws:sqs", "eventSourceARN": "arn:aws:sqs:us-east-2:123456789012:my-queue", "awsRegion": "us-east-2" } ] }
通常はこれをパースしてこの中のbodyだけを取り出して処理するといったようなことが必要です。それをこのデコレータでは簡単にしてくれるってことです。
取り急ぎこんな感じのコードを用意してみました。期待としてはbody
の値が取り出せることです。メッセージはひとまずSQSのコンソール上で送りました。
from jeffy.framework import get_app app = get_app() @app.handlers.sqs() def handler(event, context): return event['body']
が、こんなシンプルなコードなんですが全然思ったように動かず。。。json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)
っていうお決まりのアレが出てしまうんですよね。
ふと、後述のboto3のラッパー経由で送らないといけないのかも?と思いそれを試してみるも今度はKeyError
が出ます。つまりbody
っていうKeyがないって言われてるんですね。
まずそもそもまだドキュメントが充実しているとは言えず、どういうデータがどうパースされるのか全然わからず小一時間ほど悩みました。悩んだ結果、時間切れです。わかってません。現時点では正直お手上げです。
ということでIssueを切っておきました。この辺りは初期OSSあるあるってことで長い目で見ていきたいと思います。
トレース
トレース用のIDを渡すようにboto3のラッパーが提供されてます。このあたりの活用方法とか、X-Rayとの棲み分けというか使い分けとかは気になるところです。今回はSQSで試してみました。
まずは送り側。sqs-publish.py
というのを作り、ここにこのラッパーを使ってます。
from jeffy.framework import get_app from jeffy.sdk.sqs import Sqs def handler(event, context): Sqs().send_message( queue_url=os.environ['QUEUE_URL'], message='hello world' )
これでデプロイして実行してみます。送り側についてはこのsend_message()
がどういう仕様なのかドキュメントには書かれていないのでさっぱりわからなかったのですが、きっとmessage
で指定した文字列がSQSのメッセージではbody
の中に入ってくるんだろうと想像して試したところ、実際にはbody
の中にcorrelation_id
とともにJSON形式で格納されてました。
こんな感じ。
{ "correlation_id": "", "item": "hello world" }
見ての通りcorrelation_id
というフィールドはあるものの値は入ってないです。これが不具合なのかはちょっとわかってません。また、送った文字列がbody.item
というフィールドで入ってくるのは想定外でしたが、まあそういう仕様なんでしょう。今後はここが自由に設定できるといいかもしれません。
受信側は前述のコードをデプロイしていたのですが、前述のとおりうまく動いてないです。。。このあたりはアップデートがあればまた試してみます。
まとめ
今回使ったコードはこちらに置いておきました。
なお、今回は全てを試したわけではないです。他にもAPI GatewayやS3を利用するときのデコレータやバリデータなんかも存在しています。
Jeffyはいわゆるフルスタックなアプリケーションフレームワークみたいなものというよりは現時点では面倒な実装を楽にしてくれるユーティリティに近いと言えますね。これが今後どういう風に進化を遂げていくのかわからないけど応援していきたいと思います。あとはAWSのようなクラウドで利用するにあたって、現状ではリソース定義やデプロイ周りとは切り離せないと思うのでそのあたりをこのフレームワークではどういうアプローチにしていくのかも興味ある。今の所はServerless Frameworkだったり、AWS SAMなどとの共存、併用ってことになると思うけど。そういう意味ではChaliceとの併用が一番強力かもしれない。しらんけど。
正直、まだ不安定な感は否めません。でもこのあたりはきっと時間が解決してくれると思います。あとはドキュメント周りですね。その辺も初期リリースなので仕方ない部分もあるかと思ってます。余力があれば僕もコントリビューションしていきたいところです。
ところでJeffyという名前の由来はなんだろうか? また、現状ではJeffyって単語でググるとよくわからないキャラクターの動画で検索結果が埋まってしまうようなのでこの辺もなんとかしていかないといけないかも。