misc.tech.notes

主に技術的な雑記的な

PythonでLambda Functionを書く時にデコレータでイベントソース毎の共通処理をすると便利という話

この記事はServerless2 Advent Calendar 2018の21日目の記事です。

qiita.com

先日 AWS Lambda Custom Runtimes芸人 Advent Calendar 2018 の方はちょっとはっちゃけすぎた感もありつつ実戦にすぐに役に立つような内容ではなかったですが、今回はマジメにより実戦的な内容で行きたいと思いますw

marcy.hatenablog.com

はじめに

私は普段は主にPythonでLambda Functionを書いているのですが、イベントの処理方法を予め決めてデコレータで共通処理を行うようにしています。それによってServerlessな開発でよく話題になるトレーサビリティの問題やエラーハンドリングの煩雑さなどをある程度上手く解決できているので紹介したいと思います。

なお、出てくるコードは実際に使用しているものに近いですが、イメージしやすいようにあまり抽象化せずにその場で書いているものなので、動作保証はしませんのであしからず。

イベントの処理方法を予め決める

たとえばKinesis Streamsを処理するFunction、SNSからのPublishで動くFunction、API Gatewayから呼び出されるFunctionとでは受け取るイベントの形式が異なりますし、エラー時の挙動も異なります。そんな中で、ログをトレースするためのIDを仕込んだりをFunctionの中でやると冗長になりますし、エラー処理方法もマチマチになったりしがちです。それを上手くまとめるのにPythonだと特にDecoratorを使うのが都合が良いのです。

ロギング

全てのDecoratorに必ず入れるのが入ってきたEventと返り値をログに残す処理です。これはハイトラフィックなサービスでは必ずしも全て残すべきかは議論の余地がありますが、そうでなければ必ずやります。この際に、イベントの処理方法(Streams, SNS, API GWなど)毎に一連のログをトレースするためのIDを持たせる場所を決めておき、それが後で検索可能なようにログに吐かせます。

ログの出力先は一旦CloudWatch Logsに吐いて後で集約でも良いのですが、残念ながらCloudWatch Logsのログを別のKinesis Stream/Firehoseへ繋ぎこむSubscriptionの設定が非常に煩雑なので、私はLambdaから直接FirehoseかDatadog Logsに突っ込むことが多いです。オススメはDatadog Logsです。*1Pythonの場合はログ出力が非同期ではない*2のでこの方式はログが詰まれば処理も詰まるので良し悪しではありますが・・・

docs.aws.amazon.com

docs.datadoghq.com

Datadog Logsはこんな感じでTrace IDで串刺しで検索したりFunctionでフィルタしたりが簡単にできて便利。

f:id:FumblePerson:20181221180121p:plain

Datadog Logsにログを投げ込むためのライブラリも公開してるので良かったらどうぞ(本番導入済みのものです)

github.com

pypi.org

例: SNSで呼び出されるFunction

SNSでPublishされたイベントは SubjectMessage という2つの要素を持っています。例えばこのうちの Subject をTraced IDのようなメタデータを入れる場所とし、 MessageJSONやProtocolBufferで実際のイベントペイロードを表現するといった形です。最近はgRPCじゃなくてもProtocolBufferを使うとイベントの定義が発行側と購読側で共有できるので良いなーと思ったりしてます。直近のケースでは性能要件がかなり高く、PythonだとProtocolBufferの速いデシリアライザが見つからず、ujsonが未だに圧倒的な速さを見せつけてきたのでそっちにしちゃいましたがw*3

pypi.org

def sns_handler(func):
    @functools.wraps(func)
    def wrapper(event, context):
        ret = []
        for record in event['Records']:
            logger = get_logger()
            logger.set_trace_id(record['Sns']['Subject'])
            message = json.loads(record['Sns']['Message'])
            logger.info(message)
            try:
                result = func(message, context)
            except Exception as e:
                logger.exception(str(e))
                raise e
            logger.info(result)
            ret.append(result)
        return ret
    return wrapper

@sns_handler
def lambda_handler(message, context):
     pass

エラー処理

原則

エラー処理は原則AWS側が提供しているエラー処理機構に極力乗ります。非同期実行のLambdaでは3回までリトライし、それでもエラーになればDead Letter Queueの設定がなされていればそちらに流れます。*4コントロールするのはLambdaをFailさせて失敗をさせる条件です。未処理例外が発生すればFailするので、「基本的にはFailさせたいなら誰も処理しない例外を投げる」をベースにしています。とはいえ、ログにすら残らないのは問題なので、上記のSNSの例のようにDecorator内でそこは最低限カバーします。

try:
    result = func(message, context)
except Exception as e:
    logger.exception(str(e))
    raise e

API Gatewayのような同期実行のものは素直に500などを返すようにします。

例: API Gatewayから呼び出されるFunction

def api_handler(func):
    @functools.wraps(func)
    def wrapper(event, context):
        trace_id = event['headers'].get('X-Example-Trace-Id')
        logger = get_logger()
        logger.set_trace_id(trace_id)
        logger.info(event)
        try:
            ret = func(event, context)
            if 'headers' not in ret:
                ret['headers'] = {}
            ret['headers']['X-Example-Trace-Id'] = trace_id
        except Exception as e:
            logger.exception(str(e))
            ret = {
                'statusCode': 500,
                'headers': {'X-Example-Trace-Id': trace_id},
                'body': json.dumps({'result': str(e)})
            }
        return ret
    return wrapper

Kinesis Streamsのようなエラー時の挙動が特殊なものはそのイベントの処理方法を含め検討します。

例: Kinesis Streamsを処理するFunction

Kinesis StreamsではBatch Sizeで指定した最大数までのデータが一回の処理でまとめてFunctionに渡されてきます。ここで途中でLambdaをFailさせてしまうとその時に取得した全てのデータが再処理の対象となってしまい、これは都合の悪いことがあります。なので、実処理を行う関数にはBatchで取得したレコードを1件ずつ処理させ、順序保証したいもの以外の再処理可能なエラーはそのデータだけ再度Streamに入れ直すというようなルールで実装することがあります。

もちろんこれは要件次第です。SQSトリガーがない時にはKinesis Streamsをキュー代わりに使うケースも多かったので、SQSトリガーがある今は順序保証が必要なければSQSトリガー使ったほうが自然に実装できます。ただ、SQSトリガーもLambdaがFailすると一回のBatchで取得したデータが全てキューに戻るのでそこは注意が必要です。

def stream_handler(func):
    @functools.wraps(func)
    def wrapper(event, context):
        ret = []
        for record in event['Records']:
            payload = json.loads(base64.b64decode(record['kinesis']['data']))
            logger = get_logger()
            logger.set_trace_id(payload['trace_id'])
            logger.info(payload)
            try:
                result = func(payload, context)
            except Exception as e:
                logger.exception(str(e))
                client.put_record(
                    StreamName=payload['stream_name'],
                    Data=json.dumps(payload),
                    PartitionKey=payload['trace_id'])
            logger.info(result)
            ret.append(result)
        return ret
    return wrapper

@stream_handler
def lambda_handler(payload, context):
     pass

最後に

いかがでしょうか。Decoratorを使うとここまでに書いたメリット以外にも、Functionの実処理がシンプルになって見通しも良くなる、Decoratorを見るとそのFunctionが何からのイベントを処理するかパッと見で分かる、といったメリットもあります。

ごく簡単なもの以外は、PythonでLambda Functionを書くときにはFunctionを書く前にイベントの処理方法を決めてDecoratorから書き始めるとLambda Functionの開発がより良いものになるかもしれないので、是非やってみてください。

*1:一旦CloudWatch Logsに吐いた場合もDatadog Logsに流すLambda Functionが公式に公開されています

*2:非同期に実装する方法もありますが素直に書くとという話

*3:そんなに性能要件高いならGoで書けよというのはホントその通りなんですが、言語の縛りのある案件だったんですよ・・・

*4:ちなみにここだけの話ですが、Lambda自体の起動失敗はぶっちゃけちょいちょい起こります。ですが、大半はリトライで上手く行くケースがほとんどで、万が一取りこぼした時のためにDLQは設定しておくことを強く推奨します。

Lambda Custom Runtimes上でbashを対話的に操作してその内部仕様を丸裸にする

この記事は AWS Lambda Custom Runtimes芸人 Advent Calendar 2018 の19日目です。

qiita.com

これは何?

「Lambdaの中に入ってみたいと思ったことはありませんか?」これはそんな歪んだ願望を叶える試みです。と同時にそれを足がかりにAWS Lambdaというシステムをより深く理解するための検証とその結果を記したものです。

ちなみに毎年恒例?のLambdaのリバースエンジニアリング(?)シリーズです。

2016年 marcy.hatenablog.com

2017年 marcy.hatenablog.com

Lambda Custom Runtimesとは

Lambda Custom Runtimesは一般的にはAWS Lambdaが公式に対応していないプログラミング言語のRuntimeを動かすための機能です。仕様は以下の通り。

docs.aws.amazon.com

docs.aws.amazon.com

3行でまとめる

  1. bootstrap 実行ファイルで起動
  2. Next Invocation APIでイベントを取得
  3. Invocation Response(or Error) APIに処理結果を伝えるまで動き続ける

ということです。この仕様さえ満たせばなんだって動かせるわけです(これは微妙に違ったんだけど詳しくは後述)

シェルを対話的に動かす方法

Custom Runtimesに限らずLambda上でbashを実行できることは周知の事実(?)*1かと思いますが、問題はそこに対話的にアクセスできるコネクションを確立することです。Custom RuntimesでOpenSSH Serverが仮に起動できたとしても外からの接続はできません。

そこで、ngrokを利用してtunnelingを行います。ngrokはhttpやtcpのトンネルをクラウド上のエンドポイントまで繋ぎ、そのクラウド上のエンドポイントへ接続させることでNATの裏にある個人端末上のサーバへの一時的なアクセスを提供する*2ような時によく使われるSaaSサービスです。

OpenSSH Serverは依存ライブラリの関係で起動は難しそうですし、ログインユーザやログイン情報の管理も面倒なので、TCPでコマンドを受信してbashと標準入出力でやりとりする簡易なサーバを自前で作って、そのサーバに向けてngrokでトンネルを掘ります。これでシェルが対話で操作できればもうやりたい放題です! ε=\_〇ノヒャッホーイ!! *3

f:id:FumblePerson:20181219014612p:plain

やり方

基本的にはREADMEを参照してください。ngrok アカウント*4が必要です。

github.com

ざっくり解説

Goで雑に書いた、TCPでコマンド受けてbashに流すサーバを起動し、ngrokでtunnelを掘り、Custom Runtimes APIとイベントとその結果をやり取りをするまでを一手に担う*5 bootstrap 実行ファイルのソースはこちら。

lambda-interactive/bootstrap.go at master · marcy-terui/lambda-interactive · GitHub

デプロイにはAWS SAMを利用しており、ngrokのアクセストークンは再現手順の簡略化のためローカルの環境変数をLambdaの環境変数--parameter-override で渡すスタイル*6でFunctionをデプロイしてます。そして、API GatewayのEndpointを生やしてCFnのOutputで出力しておく。ここをクエリしてそのエンドポイントへリクエストを投げることで最初の起動、および実行が止まった環境の再起動ができる。

lambda-interactive/template.yaml at master · marcy-terui/lambda-interactive · GitHub

GoのビルドやFunctionのデプロイまで含め一連の操作はMakefileでまとめてます。

lambda-interactive/Makefile at master · marcy-terui/lambda-interactive · GitHub

ちなみに ngrok コマンドもGoで書かれておりシングルバイナリで動くのでLinux 64bit向けをダウンロードして同梱してます。

ここからが本番

わざわざこんなことをするのは、もちろんただCustom Runtimeの中に入って色々見てみたいという単純な興味はもちろんのこと、確かめたいことがあったからです。

何を調べたいか

Custom Runtimes APIの公開によって、下記のような今までおぼろげだったLambda実行環境のライフサイクルに明確にフックできるようになるのではないかと考えました。これらのタイミングでのLambda実行環境の振る舞いを調べたいのです。

  • 作成(Bootstrap)
    新しい実行環境の立ち上げ。 bootstrap 実行ファイルが呼ばれる
  • 起動(Cold Start)
    bootstrapから最初の Next Invocation API 呼び出し
  • 停止(Suspend)
    Invocation Response(or Error) API 呼び出し(これは微妙に違ったんだけど詳しくは後述)
  • 再開(Warm Start)
    2回目以降の Next Invocation API 呼び出し
  • 破棄(Delete)
    Invocation Response(or Error) API が呼び出されてからしばらく次のイベントがない、またはデプロイ直後の最初の実行など

この中でDeleteはまあ良いとして、BootstrapからCold Startは起動から一連の処理が行われるのでまあ分かりやすいです。特に調べたいのはSuspendからのWarm Startの挙動です。

仮説

Lambdaをそれなりに使い込んでいる方はある程度察しはついているかもしれませんが、一度起動されたLambdaの実行環境はある程度使い回されます。起動してFunctionの実行が完了した後も数十秒程度はそのまま残り、その間に新たな呼び出しが行われればその起動済みの環境が割り当てられます。この起動済み環境からFunctionの処理を始めることをWarm Start、新しい環境の立ち上げから始まるFunctionをCold Startと呼んだりします。

このWarm Startが行われる際に、プログラム内のglobalスコープやstaticなオブジェクトは残っているので、都度オブジェクトを生成するのではなく使い回すことでFunctionの実行を高速・効率化する方法が知られています。また、それらのオブジェクトが所有しているTCPコネクションのようなものも残ります。なので、コネクションプーリングのような実装は限定的ではありますが有効です。

これ、内部的にはどうなってるんでしょうね?

一番簡単なのはバックグラウンドでプロセスを動かし続けることですが、それをやってしまうと課金はあくまでFunctionの実行時間に対するものになるので、結果を返した後もFunctionが動き続けてしまえるのはAWSからすれば取りっぱぐれです。*7

というか、そもそもそんなことはできないようになっています。Node.jsランタイムを使ったことがあるとご存知かもしれませんが、Node.jsでやりがちなミスとして「非同期処理で先に結果を返してしまって全ての処理が行われる前に停止させられてしまう」というケースがあります。つまり、結果を返した後のFunctionは処理が許されていません。でも、メモリ上のデータや状態は残る。

メモリの内容をダンプして復元するCRIUのような方法もありますが、TCPコネクションまで復元するのはなかなか難しいと聞きますし、そもそもWarm StartのFunctionの起動はms未満〜1桁ms(感覚値)で行われます。さすがにそこまでの早さは出せなそうな気がします。挙動としてはまるで 時間を止められ、また動き出している ようです。

github.com

そう、 時間を止めている んです。おそらく、結果を返した後のLambda実行環境内にあるプロセスにはCPU時間が一切割当てられなくなるのです。それは、Lambdaの実行環境基盤であるFirecracker MicroVMにCPUの割り込みタイマーを外部から操作できるIFが設けられていることからも、割り込みによってCPUの割当を停止しているのではないかと推測することができます。

2018-12-20 20:50 訂正

re:Inventでそんなこと聞いた記憶があって斜め読みして書いてると思ったんですが、よくよく読み返してみるとタイマーとAPIについて近い場所で語られているだけで両者の関連については書かれておらず、ソースコード読むと(これまた斜め読みですが)cgroupsも使っているようなのでそっちかもしれません・・・

github.com

この挙動を検証してみます。

検証

READMEの通りにデプロイまで終わっている所からスタートします。

bashに繋がるトンネル*8を起動します。

$ make start
curl `aws cloudformation describe-stacks \
       --stack-name interact-custom-runtime \
       --query "Stacks[0].Outputs[0].OutputValue" \
           --output text`

ngrokのダッシュボードを確認します。トンネルが起動していますね。

f:id:FumblePerson:20181219144019p:plain

これにtelnetでつなぎます。bashが起動して操作できます!

$ telnet 0.tcp.ngrok.io 15733
Trying 52.15.194.28...
Connected to 0.tcp.ngrok.io.
Escape character is '^]'.
bash: no job control in this shell
bash-4.2$ 

pstree結果。bootstrapを親プロセスとしてbashとngrokが起動してます。

bash-4.2$ pstree
pstree
init-+-bootstrap-+-bash---pstree
     |           |-ngrok---7*[{ngrok}]
     |           `-6*[{bootstrap}]
     `-5*[{init}]

挙動を調べるためにバックグラウンドで1秒おきに時間を書き込む別のプロセスを起動しておきます。停止している間は書き込まれない時間ができるはず。

bash-4.2$ sh -c "while sleep 1; do date >> /tmp/date.txt ; done" &
sh -c "while sleep 1; do date >> /tmp/date.txt ; done" &
[1] 24
bash-4.2$ pstree
pstree
init-+-bootstrap-+-bash-+-pstree
     |           |      `-sh---sleep
     |           |-ngrok---7*[{ngrok}]
     |           `-6*[{bootstrap}]
     `-5*[{init}]

セッションを切ります。セッションを切ると結果を Invocation Response API に投げて停止するようになってます。停止の目印に一時ファイルを使っているのがダサいですが、bashプロセスを終了してしまうとその子プロセスも終了されてしまったり色々問題があるので、一時ファイルを目印にbootstrapプロセスからbashプロセスは残してTCPのセッションだけ切るようにしています。

bash-4.2$ touch /tmp/exit
touch /tmp/exit
bash-4.2$ 
bash-4.2$ Connection closed by foreign host.

もう一回繋ごうとするとListenはされていて接続拒否はされませんが、停止されているのでbashに入れません。

$ telnet 0.tcp.ngrok.io 15733
Trying 52.15.194.28...
Connected to 0.tcp.ngrok.io.
Escape character is '^]'.

もう一度起動すると・・・

$ make start
curl `aws cloudformation describe-stacks \
       --stack-name interact-custom-runtime \
       --query "Stacks[0].Outputs[0].OutputValue" \
           --output text`

再接続の必要なく待ち状態のコマンドからそのままbashに入れます。Lambda <-> ngrok間のTCPコネクションは生きていたという証左ですね(ngrokはコネクションが切れるとトンネルを破棄して接続が拒否されるようになります)

ちなみにここで起動のタイミングを誤ると別の環境が立ち上がってしまい、次回以降はそちらの新しい方が起動されるようになってしまいます。ngrokの無料アカウントでは同時に起動できるトンネルは1つだけなので、Lambdaがタイムアウトして破棄されるまでの数分間何もできず、ただ課金のみが発生する無力感に苛まれることになります(私はなりました)

$ telnet 0.tcp.ngrok.io 15733
Trying 52.15.194.28...
Connected to 0.tcp.ngrok.io.
Escape character is '^]'.

bash: no job control in this shell
bash-4.2$ 
bash-4.2$ pstree
pstree
init-+-bootstrap-+-bash
     |           |-bash---pstree
     |           |-ngrok---7*[{ngrok}]
     |           `-6*[{bootstrap}]
     |-sh---sleep
     `-5*[{init}]

時間を書き込んでいたファイルを見てみます。

$ cat /tmp/date.txt
cat /tmp/date.txt
<snip>
Wed Dec 19 05:52:40 UTC 2018
Wed Dec 19 05:52:41 UTC 2018
Wed Dec 19 05:52:42 UTC 2018
Wed Dec 19 05:52:43 UTC 2018 <-- 停止
Wed Dec 19 05:53:00 UTC 2018 <-- 再開
Wed Dec 19 05:53:01 UTC 2018
Wed Dec 19 05:53:02 UTC 2018
Wed Dec 19 05:53:03 UTC 2018
Wed Dec 19 05:53:04 UTC 2018
Wed Dec 19 05:53:05 UTC 2018
Wed Dec 19 05:53:06 UTC 2018
Wed Dec 19 05:53:07 UTC 2018
Wed Dec 19 05:53:08 UTC 2018
Wed Dec 19 05:53:09 UTC 2018
Wed Dec 19 05:53:10 UTC 2018
<snip>

バックグラウンドのプロセスも含め停止していたことを示しています。思ったとおりの挙動ですね! CloudWatch Logsでこの間のログを見てみます。*9たしかに 05:52:44 に停止、 05:53:00 に再開してます。

05:50:53 START RequestId: 0c3bd497-0352-11e9-83e4-2167b3748717 Version: $LATEST
05:50:53 read the event data ===>
05:50:53 connection open ===>
05:51:03 bash start ===>
05:52:44 close connection ===>
05:52:44 post the result ===>
05:52:44 read the result ===>
05:52:44 {"status":"OK"}
05:52:44 get the next event ===>
05:52:44 END RequestId: 0c3bd497-0352-11e9-83e4-2167b3748717 <- 停止
05:52:44 REPORT RequestId: 0c3bd497-0352-11e9-83e4-2167b3748717 Duration: 110871.63 ms  Billed Duration: 110900 ms Memory Size: 1024 MB Max Memory Used: 48 MB
05:53:00 START RequestId: 57e81627-0352-11e9-941d-3dc2342c79e3 Version: $LATEST <- 再開
05:53:00 read the event data ===>

ちなみに分かりづらいと思いますが、一応処理の段階をログに残しています。「 Invocation Response(or Error) APIに処理結果を伝えるまで動き続ける」と思っていたのですが、それを示す post the result, read the result から、さらに get the next event とあるので Next Invocation APIで次のイベントの取得まで行ってしまってますね・・・ レスポンスが得られたことを示す read the event data は次の実行時のようですが、これは仕様なのでしょうか?もしかしたら微妙な時間差でそこまで処理が進んでしまっている(レスポンスが得られるまでには止まるから問題ない)だけかも?

Invocation Response API を呼んだ後に1秒スリープを入れてみます。

$ git diff
diff --git a/src/bootstrap.go b/src/bootstrap.go
index 7a349ff..dec1307 100644
--- a/src/bootstrap.go
+++ b/src/bootstrap.go
@@ -88,6 +88,7 @@ func main() {
                fmt.Println("get the result ===>")
                body, _ = ioutil.ReadAll(resp.Body)
                fmt.Println(string(body))
+               time.Sleep(1 * time.Second)
        }
 
 }

変わらず。

07:53:13 START RequestId: 22e5ec6b-0363-11e9-8263-5fe7d4dd840f Version: $LATEST
07:53:13 read the event data ===>
07:53:13 connection open ===>
07:53:28 bash start ===>
07:53:37 close connection ===>
07:53:37 post the result ===>
07:53:37 get the result ===>
07:53:38
07:53:38 get the next event ===>
07:53:38 END RequestId: 22e5ec6b-0363-11e9-8263-5fe7d4dd840f
07:53:38 REPORT RequestId: 22e5ec6b-0363-11e9-8263-5fe7d4dd840f Init Duration: 59.46 ms Duration: 24958.54 ms   Billed Duration: 25100 ms Memory Size: 1024 MB  Max Memory Used: 47 MB

どうやら結果を Invocation Response(or Error) API にPostして次の Next Invocation API を呼び出してレスポンスを待っている状態で止まるようです。わざわざTCPコネクションを開かせて止めるなんて贅沢な!とか思ってしまったけど、よく考えたらきっとCustom Runtimes APIは各LambdaのWorkerあるいはその上位に居るWorker Manager毎にあるイメージな気がするので、1つの各Workerは言わずもがな各Worker ManagerがハンドリングするWorkerの数も限られてるだろうから良いのか(この辺のイメージは下記のre:Invent資料を参照)

www.slideshare.net

ネットワークIO待ちというのは停止点を作りやすいというのもありそう。かといって Invocation Response(or Error) API のレスポンス待ちで止めてしまうとResponse返すのに成功したか検査できなくなっちゃうし。

まとめ

Custom Runtimeは任意の言語を動かせるのもそうだけど、Runtimeの挙動とライフサイクルが明確というのがLambda上で実験をするのに最適。

仮説検証結果①:ライフサイクルまとめ
  1. bootstrap 実行ファイルで起動
  2. Next Invocation APIでイベントを取得
  3. Invocation Response(or Error) APIに処理結果を伝える
  4. Next Invocation APIで次のイベントを待つ -> 即時実行できるイベントがなければ停止 -> さらに数十秒イベントがなければ破棄
仮説検証結果②:停止時の状態

プロセスは生かしたまま割り込みによってCPU時間が割り当てられなくなりその環境上の時が止まったような状態(未だブラックボックスな部分はあるので状況からくる推測の域は出ないので確実にそうだとは言い切れないけど)

おまけ

TunnelingすればLambdaの中にだってbashで入れる。bashで入れればもうやりたい放題、内部は丸裸です。他にも色々おもしろい実験ができそうですね!

ついでに「俺は今あのLambdaの中に入っているんだ」という謎の高揚感を得ることが出来ます。ぜひお試しあれ!(自己責任で!)

*1:ちなみにre:InventでCustom RuntimesとLayersの開発者セッションで「よーし、おじさんBash "Internet Ready" LanguageでAPI Gatewayからレスポンス返すFunction作っちゃうぞー」的なデモを繰り広げて大ウケしてました

*2:プロトタイプを誰かに見せるとか

*3:もはや完全なバックドアですね。怒られないかな・・・?w

*4:無料で作れます

*5:といっても数十行で書けるからGoは便利

*6:機密情報なのでKMSで暗号化したssm parameterが理想ではある

*7:そういえばLambdaの公開当初Cold StartのFunctionに入る前のスタートアップ処理は課金対象外となっていたけど、そこでマイニングとかしだす輩が続出して課金対象になったって話があった気がするw

*8:パワーワード感ある

*9:一部出力を除外してます

re:Invent 2018でServerlessの世界は何が変わったか

この記事は(一応)Serverless Advent Calendar 2018の11日目の記事です。 qiita.com

下記イベントでre:Invent 2018でのServerless関連のリリースについて独断と偏見に満ちたお話させていただきました。

jawsug-nagoya.doorkeeper.jp

すいません。完全に言い訳なんですが、この登壇と被ってしまってネタを仕込めなかったので一旦資料リンクでお茶を濁させてください。。。

speakerdeck.com

2の方のカレンダーが空いてたのでちゃんとネタ仕込んでもう一本書きます!!

qiita.com

#AWSDevDay と #jft2018 で満を持してDynamoDBデータモデリングの話をしてきました

題の通り、AWS Dev Day Tokyo 2018, JAWS Festa Osaka 2018で満を持してDynamoDBデータモデリングの話をしてきました。

資料

AWS Dev Day

speakerdeck.com

JAWS Festa

speakerdeck.com

経緯

これをどこかで一つにまとめて語り尽くしたい気持ちがあって、とはいえDynamoDBのデータモデリングだけにフォーカスして語るには良いイベントが無かったので、Dev DayのCFP募集を見て「これだ!」と思って飛びつきました。 marcy.hatenablog.com

で、ダメ元で申し込んでまさかの採択された後、別途内定をもらっていたJAWS Festaの3日前であることに気が付き、さすがに全く別の話を用意するのは難しいし、2回喋っても語り尽くせないくらい言いたいことが一杯あったので、どちらも同じテーマでいかせてもらうことにしました。

ということで、それぞれ内容的にはかなり近いものとなってしまってはいるんですが、Dev Dayの方が40分だったのでちょっとじっくり、JAWS Festaの方が25分だったけどコミュニティイベントなのでわりと好き勝手にぶっちゃけトーンで喋らせてもらいました。

振り返り

Dev Day

全トラック錚々たるメンバーの中、私みたいのがお話させてもらえてとても良い経験になりました。ストリーミングもあったのでけっこう幅広く聞いてもらえ、あの錚々たるメンバーの中でもポジティブな反応をたくさんいただけたので自信になりました。

自分以外もめちゃくちゃ面白い話が目白押しで、これは毎年というか半期に一回くらいやってほしいなって気持ちです。完全にイチ参加者としてでも行きたい。全セッション配信も今後も続けてほしいですね。今回は話す立場なので満を持して現地に行けましたが、地方在住としてはそうでなければなかなか調整が難しいので。

JAWS Festa

最近、お仕事で大阪に行くことが多いので、地元よりも参加しているんじゃないかという感じな縁で、パワフルで優しい大阪メンバーに1枠いただきました。ありがとうございます。

「Serverless関係で」というゆるいオーダーはいただいていたのですが、申し訳程度のServerless要素で大変申し訳ないと思いつつも、結果こんな感じだったのでまあある意味面白い展開だったのかもしれません・・・w(ごめんなさいごめんなさい)

とりあえず、あの開放的な空間で25分ノンストップでゴリ押しは大変気持ちが良かったです。

こんなこと言うと、聴いてくれている人に失礼と思われてしまうかもしれませんが、私のJAWS-UGでの原体験は2013年?のJAWS Daysで、当時はAWSの本格普及も始まりつつある頃、Chefが流行り始め、DevOpsという言葉も出始めの頃に、イケてるアーリーアダプター達と自分との圧倒的なレベルの違いに危機感と憧れを覚えたことなので、観客全員の理解度を合わせながらの話はしないのです。それをやっていると時間が足りなくて深い所に入れない。

私の本気が全員ではなく何人かで良いので強く刺さってほしい。

そんな気持ちを持ちつつやっていたら、懇親会である人に以前同じようなことを言っていた私の原体験の頃からのすごい人(過去の人みたいな言い方ですが、もちろん今もすごいw)と被るとフィードバックもらえたのでとても嬉しかったです。

自由にGiveし続けられる場所であってほしい

私一度、ある所で「お前が好きに喋ると周りがついていけないからダメ」と言われたことがあって、それはそれで一つのあり方なので仕方ないと思いつつも、そこは私の居場所ではなくなってしまったと感じたことがあります。

基調講演であったこれ、ほんと完全同意なので、今後も自由にGiveし続けられる場所であってほしいと願いますし、そのためなら一緒に戦いたいと思いますので、また今後ともよろしくおねがいします。

最後に

講演という形ではもう十分に場をいただいたので、ずっと語りたかったDynamoDBのデータモデリング話は十分にお腹いっぱいになりました。 両イベントの運営の皆様、マジでありがとうございますありがとうございます!!

が、DBとそのデータモデルの話はまだ尽きないので、まだ虎の巻の続きを書かないと・・・!(謎の使命感) 今回で今までの3巻でも手直ししたくなった所もあるし・・・

災害復旧供給状況マップというサービスを作りました

こんなものを作りました。

supply-map.willy.works

きっかけ

お察しの通り?北海道胆振東部地震がきっかけです。

私自身、北海道の札幌市に住んでいるのですが、幸か不幸か地震発生当時は出張に行っていました。私の家は札幌市内では比較的停電の復旧が遅かった地域で、朝一に家族の安否確認だけはできたのですが、その後奥さんの携帯電話の電池が切れてしまったり長時間の停電で携帯の電波塔も落ちてしまったのか電波の状態が悪くなったりして、しばらく連絡がつかない状況が続きました。札幌の各地の電気が復旧していく中、なかなか連絡がつかない状況はかなり不安にさせられました。

そして、なんとか連絡がつくようになり、幸いにも実家のほうが停電が比較的早く復旧していたので出張から帰ってきてそちらで合流しました。そして、翌朝には家の方も停電が解消していたので家に帰り、長時間の停電によって傷んでしまっている可能性が高いものを破棄して買い物に出たのです。

すると、食料品が全然売ってないのです。そもそも営業していない店も多く、営業している店でも生鮮食品が停電でダメになったため破棄され、残った保存食品に需要が集中して品薄・・・という状況です。

そんな中、色々な店を回ったのですが、店によって全然品揃えが違うんです。そもそも営業していない店、売れるものがなくほぼ開店休業状態の店、余った商品をかき集めて今は売れているけど次の入荷予定が立っていない店、独自の調達網があるのかある程度の生鮮食品を継続的に売れている店などなど・・・

半日を買い物だけに費やしました。それでも、確保できていないものもありましたし、何がどこにあるか分からないので念の為と無駄に買ってしまったものもありました。ずっと車で移動したのでガソリンもなくなってきてしまったものの、スタンドも営業している所が限られているので数時間待ち・・・

それでも私は良い方です。生鮮食品を継続的に売っている店を見つけたのですから(この店はもちろんサービスに登録してありますw)SNSなどを見ると生鮮食品が買えなくて困っている人の投稿も目立ちます。

あの時あれば良かったものを

震災発生直後に感じた無力感、2018年9月8日(土)に半日買い物して感じた徒労感、それらを解消するサービスを作れないだろうかと考えて作り始めました。

簡単にどんなサービスかと言うと、インフラの復旧状況や物資調達の可否などをマップ上で簡単に報告でき、それらを検索することができるサービスです。似たような誰かが作った簡単なサービスのようなものは他にもあったりしますが、特定の災害や特定の情報に特化していて使いにくかったり、日々変化する状況に対応するにはイマイチだったりするので、様々な情報をカテゴリ分けして色で状況が俯瞰的に把握でき、同じ位置に新しい情報が投稿されればそれに合わせて色を変える(古い情報は履歴を残しつつ上書きできる)、そのステータスやコメントの変化を追える、といったあたりを重視して作りました。また、位置情報で検索しながら表示するため、関係のない地域のデータを取得しないので、複数の災害地域で使用されてもクライアントに不要な負荷をかけたりはしない・・・はずです。

その後の週末で作ったので最低限ができて公開したのは翌日の2018年9月9日(日)の夕方です。その時には一部地域を除き全道的に停電も復旧しており、前日は休業した店舗も次第に開店し始めて状況は良い方に向かっていました。だから、きっとこのサービスはもうそれほど需要はないということはなんとなく分かっていました。

でも、まあ良いのです。このサービスはVue.jsとVuetify(最初はBootstrap)で作られ、Firebase Hostingでホストされ、Google Maps JavaScript APIを中核とし、Firebase Authenticationで認証し、データベースとしてFirestoreにクライアントが直接通信して動きます。いわゆる2-TierのServerless SPAです。

jp.vuejs.org

vuetifyjs.com

firebase.google.com

firebase.google.com

developers.google.com

Google Maps JavaScript API, Vuetify, Firestoreあたりは始めて使いました。それ以外も今までお遊び程度にしか使っていませんでした。普通にお勉強になりました。

余談ですが、私の得意なAWSではなくGCPのサービスを使ったのは、Google Maps JavaScript APIがまず必須だったのでそのサインアップをGoogle Cloudで行う必要があったことと、2-Tierで作ろうということは初めから決めていて、位置情報を扱うデータベースとしてDynamoDBを採用するには一工夫居ることを知っていて、それに対してFirestoreがいつの間にか位置情報を扱えるようになっていた(一応チェックだけはしてて、たしか公開時にはなかったはず)ため、開発効率的にもGCPのサービスを使うことにメリットが高そうと判断したからです。

aws.amazon.com

Supported data types  |  Firebase

そして、このサービスを構成するクラウドサービスはすべて従量課金で、使われなければほとんどお金はかかりません。お金がかかっている時は誰かの役に立っている時です。だから、そんなことは起きないでほしいですが、いつか誰かのためになるかもしれないのでこのまま公開し続けます。そして、気が向いた時や時間を見つけて改修します。何かリクエストや不具合を見つけたら教えてもらえると嬉しいです。

今回の地震で被災された皆様に、心よりお見舞い申し上げます。 まだ停電含め予断を許さない状況が続いていますが、一刻も早く平穏な日々が戻ってくることを祈っています。

DynamoDBデータモデリング虎の巻:第参巻 〜実践編〜

前巻のおさらい

前巻はDynamoDBのデータモデリングをするにあたって必要な考え方について、RDBとの対比を交えながら触れてきました。

marcy.hatenablog.com

今回は

この巻では、実際にどのようにデータモデリングしていけば良いのかといった実践的な内容を、いくつかの汎用的と思われるパターンを例にしつつ書き記していきたいと思います。

たぶん、今までの3巻の中で一番今までの巻を理解していないとピンと来ない内容だと思うので、まずは第壱巻第弐巻を読むことをオススメします。

Partition Keyの決め方

まずは、DynamoDBでテーブルを作る際に必ず必要な Partition Key の決め方です。前巻では以下のように書きましたが、じゃあ実際にその識別子はどうやって採番するのか?ということです。

ECサイトなら「ユーザ」「商品」「注文」といった特定の意味のある存在を表す実体です。これはMicroservices的な各サービスが担うべき特定の業務ドメイン領域にも多くの場合に合致します。そして、これら1つの実体は基本的にユーザIDや注文番号、SKUといった識別子を持っているものです。これをPartition Key とするのです。

では、実際にユーザ系の情報を扱うテーブルを例にとって解説していきたいと思います。

一意なIDの決め方

考えられる方法はいくつかありますが、極端に大別すると以下になるかと思います。

  • 連番
  • UUIDのような順序性の無いランダムな値

この2つでまず採用すべきでないものは「連番」です。DynamoDBで連番カウンターを作ることが厳しいことはカウンターアイテムのPartitionが偏ることを考えれば想像に難くないかと思いますが、カウンターを用いないMAX + 1を取る方法はもっと現実的ではありません。 Partition Key はHash Tableなデータ構造なので、B+Treeな Sort Key と違ってフルスキャンすることでしかMAXが取れませんし、MAXした結果が被らないようにロックを取ることもできません。

よって、基本的にUUIDのような衝突する可能性が極小となっているランダム値が良いです。ちなみに「Snowflake」もイマイチです。なぜなら、繰り返しになりますが Partition Key はHash Tableなデータ構造なのでフルスキャンを避けてソートできないからです。「Snowflake」が活きるかもしれない場としては Sort Key のSuffixに付けて生成順にしつつデータを分ける時くらいでしょうか。

Twitter IDs (snowflake) — Twitter Developers

ちなみに今回例にしたユーザのIDについては、Serverlessな実装だと認証とユーザのID管理にCognitoを使うケースも多いと思います。その場合はCognitoが採番するUser IDを使いましょう。Cognito User Poolの情報と紐付けられるのみならず、ユーザが自分の対象アイテムだけにアクセスできるようアクセス制御するのも簡単に実装できます。

docs.aws.amazon.com

Partition KeyだけでSort Keyが要らないパターン

今回の例からはズレますが、シンプルにKeyに対するValueだけが引ければ良いような場合は、プライマリキーを Partition Key のみとしてHash Tableだけでアクセスできるようにした方が高速にアクセスできます(NWレイテンシ等も考えると微々たるものですが)2つの要素の組み合わせで一意となる場合でも、それが常に組み合わせて一意な条件でしか使わないのなら Sort Key と組み合わせたプライマリキーにするよりも結合した値を Partition Key としてしまって良いです。後から別の条件で検索したくなったときに対応が困難になるので、完全に明確であるケースだけにはなりますが。

射影としてのGSIについて

実際に例を出して見ていく前に、GSI の射影について前巻でも触れましたが、もう少し詳しく触れておきたいと思います。

GSI がテーブルから射影する属性は下記のオプションを選択することができます。

  • KEYS_ONLY : テーブルのプライマリキーの値、およびインデックスキーの値のみで構成される
  • INCLUDE : KEYS_ONLY + 選択した非キー属性のみ含まれる
  • ALL : 全ての属性が含まれる

docs.aws.amazon.com

このうち、どれを選択すべきかは、どのようなデータアクセスが多いかによって決まります。まず、GSI に含める属性が増えれば増えるほどインデックスの容量と更新コストが増大します。 GSI の更新は実テーブルの更新に対して自動的に非同期で適用されますが、その際に使用されるキャパシティユニットが増大するということです。もし含めなかったとしても、最低限含まれるプライマリキーによって実テーブルからデータが都度フェッチされるため見かけ上のオペレーションとしての差異はありません

では、どのような場合にインデックスに含める属性を増やすかというと、そのインデックスに対してクエリを行う際に頻繁に取得する属性がある場合です。インデックスに含まれている属性は実テーブルからデータをフェッチする必要がなくなり、特定のPartition内だけで読み込みが完了するため読み込み効率が上がります。いわゆる、MySQLで言う所の Covering IndexPostgreSQLで言う所の Index Only Scan です。そのようなクエリが効果的かつ頻繁に想定される場合、更新コストや容量の効率および課金と天秤にかけて KEYS_ONLY 以外を選択します。

ちなみにここから先は実際のデータを模した二次元表が出てきますが、 GSI には暗黙的にプライマリキーが含まれていると思って見てください。

Sort Keyの決め方と汎用的なGSI

前巻で書いた「できる限りテーブルを少なくする」というものは、同じように GSI にも適用されます。 前巻でも述べたように、 GSI は元となるテーブルに対する射影として実体を持つ別のテーブルと言えるものであり、独立したキャパシティの管理が必要となります。つまり、多ければ多いほど管理コストが増します。そして何より、 GSI1テーブルあたり5つまでという制限を超えないよう気を使わなくてはならず、これは「できる限りテーブルを少なくする」という方針と対立します。だから、 GSI の使い方はとても重要なのです。

Partition Keyある実体に対する一意な識別子と比較的シンプルに決めることができますが、 Sort Key についてはその実体に対するアクセスパターンを十分に踏まえる必要があります。とはいえ、ある程度順序立てて決めていくことが可能です。

まず、前巻で述べたようにある実体に対して必要なデータを列挙して一つの Sort Key に押し込めた状態から考えます。

f:id:FumblePerson:20180804020600p:plain

そして、前巻であった、この中で更新頻度やタイミングが明らかに異なる状態系のデータなどを分離するとした場合にはこのようになります(実際に全部でこれしか属性が無かったら別に分けるほどでもないんですけどねw)

f:id:FumblePerson:20180804020730p:plain

次に、この中で name で検索をしたい要求があったとしましょう。素直に name をキーとする GSI とすることもできますが、そのように次々と要求が増える度に GSI を定義していけばあっという間に増えて5つという制限を使い切ります。そこで、以下のようにデータの種別を表すプライマリの Sort Keyname という値を持つアイテムを追加します。そして、プライマリの Sort Key である keyPartition Key とし、その検索対象となる値を入れるための valueSort Key とする GSI を作成します。

f:id:FumblePerson:20180804230628p:plain

key = "name" AND value = "Terui" のように検索できる GSI になりました。そして、ついでに status でも検索できるようになっています。つまり、同様に email でも検索する要求もあるのであれば、同じようにアイテムを作ることで対応可能となるのです。

f:id:FumblePerson:20180804230731p:plain

Sort Key とすることで、検索だけではなく「登録日時」のように最新(降順ソートの先頭)や範囲検索した一覧を取りたいような場面でも使えます。

f:id:FumblePerson:20180804230827p:plain

GSIのシャーディング

一つのテーブルに対して汎用的に検索できる GSI が出来ましたが、この GSI には弱点があります。それは、検索項目毎にPartitionが偏ってしまっていることです。この GSI に対する検索トラフィック1Partitionの上限を超える可能性がある場合、 Sharding を検討する必要があります。

ちなみに1Partitionの物理的な上限は 3000RCU, 1000WCU, Index Size 10GB です。これを超えるユニットやサイズを確保した場合はPartitionが分割されます。その場合は分割されたPartitionに対して確保したユニットが均等に割り振られることに注意が必要です。特定のPartitionにアクセスが集中してスロットリングが発生した場合に、物理的な上限を超えない範囲で他のPartitionに割り振られた余剰分を回す機能(Adaptive Capacity)も持っていますが、スロットリングが実際に発生しないと発動しないので、基本的に安全を見積もっておく必要があります。現在のPartition数を計算する計算式は下記資料のp21, 22に書いています。

www.slideshare.net

この Sharding をどうやるかというと、計算可能なSuffixを付与する方法があります。例えば、「Unicodeのコードポイント化して積算した数値を200で割った余りに1を足す」などです。

Pythonで雑に書くとこんな感じの計算

import functools

functools.reduce(lambda x,y:x*y, [ord(x) for x in 'Terui']) % 200 + 1 # => 161

あえて剰余を取っているのは Partition Key の数を固定するためです。 Partition Key の数が不定になる計算だと分散度合いは高くなりますが、範囲検索や集計を取りたいような場合にテーブル全体をフルスキャンする必要が出てしまうからです。数が分かっていればその数だけのインデックスをスキャンするだけに収めることができるので、それだけでもコストは大幅に違います。逆に言うと範囲検索や集計を取ったりインデックス全体に対するオペレーションが必要が全く無ければ不定でも良いです。

それでも現実の名前の分布が均一ではない*1以上ある程度の偏りは想定されますが、一般的なシステムではその程度分散してくれれば十分です。むしろ、その程度の計算で済まないレベルで分散が必要なら、範囲検索や集計を捨てられるなら値そのもの(より大きなサイズを取り得る属性ならハッシュ値)を Partition Key に含めた別の GSI を用意するか、別のテーブルに DynamoDB Streams から連携するべきでしょう。

f:id:FumblePerson:20180804230945p:plain

docs.aws.amazon.com

本筋とはズレますが公式の日本語翻訳の "注文 ID の文字の UTF-8 コードポイント値を乗算して、それを 200 で割って + 1 するなど" は間違ってますね。剰余を取らないと固定数Shardingできない・・・(英語はちゃんと"modulo 200"って書いてるので合ってます。Feedback送っておきましたw)あと、いくらユニークな別の属性と併せて頻繁にクエリすることがあると言っても別の属性を使って計算するのは他の場面で全く使えないので制約強すぎて厳しいと思うんですよね・・・たしかにその方が偏らないけど・・・

nameemail のように、一つ実体につき一つである属性を意図的に分散させるケースとは違い、そもそも複数の要素を持ち得る属性であるため分散させざるをえない場合もあります。例えば「好きな食べ物 (favorite_foods)」のような任意に複数個設定できるものです。これを検索可能とする場合は少々工夫が必要で、計算可能な固定範囲のSuffixをプライマリの Sort Key および GSIPartition Key としてしまうと、プライマリの Sort Key衝突する可能性があります。そのため、範囲検索や集計を捨てられるなら、値そのものや大きなサイズを取り得る属性ならハッシュ値なSuffixを付けるのが無難です。もし、範囲検索や集計が必要なら別途取り得る値の範囲が固定で計算可能なSuffixを持つ属性を別途追加してそれを Partition Key とした GSI を持たせます。

f:id:FumblePerson:20180804234442p:plain

次巻に向けて

今回は例を出しながら実際にテーブル設計を行う際の基本的なアプローチやポイントに触れてきました。書いていると「あ、あのパターンも必要かな」とかけっこうあったけど汎用性の低いものは今回は触れないようにしたので、次巻はもう少しピンポイントな問題を解決するアドバンスドなパターンの中から、わりとよく使うものを紹介する感じになるかもしれません。その前にもうちょっと触れておいた方が良いこともあるような気もするので、そのあたり思いついたらそっちになるかも。

*1:日本は佐藤さん、鈴木さんが多い的な

DynamoDBデータモデリング虎の巻:第弐巻 〜考え方編〜

前巻のおさらい

前巻はDynamoDBのデータモデリングをする前に知っておいた方が良いDynamoDB自体の仕組みやデータ構造のお話でした。

marcy.hatenablog.com

今回は

今回はデータモデリングを行う際に必要なマインドセット、つまり「考え方」について書き記したいと思います。非常によく聞かれる「RDBとの考え方の違い」といった切り口で進めていきたいと思います。

RDBとはアプローチが真逆

RDBのデータモデリングをする場合、まず正規化されたデータのスキーマを決めることから始めると思います。慣れてくると律儀に第一正規化から始めずにいきなり第三正規形あたりから設計しだすことも多いと思います(私もそうです)

そして、データのスキーマが決まってからそれに対してどのようにアクセスするか(=SQL)をアプリケーションを設計する際に考えていくというのがRDBでの一般的なアプローチではないかと思います。

これ、DynamoDBでは全く逆になります

DynamoDBのデータモデリングでは、まず先にデータにどのようにアクセスするかを考えます。そして、それに合わせたデータモデルを作るのです。つまり、データモデルを設計してからアプリケーションの設計を行うことはDynamoDBではしません。アプリケーションとデータモデルは同時に設計するのです。

これは、 スキーマレス なDynamoDBだからこそできることです。アプリケーションの仕様が変わってもスキーマを変更するコストは極小で済みます。というか、スキーマが無いんだから変更コストも何も無いと言えばそうなんですがw

かといって、既存のデータを変換する必要があるような変更をしてしまうとそれは大きなコストがかかります。それを避けるために大枠として抑えておくべきポイントもあるので、そのあたりにも触れていきたいと思います。

テーブルの数を少なくする

RDBでは正しく正規化していくとテーブルの数は基本的に増えていきます。同じ実体に対して論理的に分かれたテーブル間は リレーションシップ という概念とそれに付随した 外部キー制約 という整合性を守る仕組みによって整合性が取られます。 外部キー制約 使ってますか?使わないとRDBでも整合性は守られないですからね??(煽り芸)

しかし、DynamoDBには リレーションシップ という概念はありません。だから、極論テーブルを分けても別に良いことなんて無いのです。

テーブルが少なくなると何が良いかというと、キャパシティの最適化ができます。アクセス頻度が少ないテーブルと多いテーブルに分かれているとそれぞれのキャパシティの管理が難しくなります。特にAutoScaleはキャパシティが少ないと効果的に動作しません。*1テーブルを少なく保つことでキャパシティを合計で考えることができ、AutoScaleも効果的に使えるようになります。

テーブルが少ないと負荷の集中が気になりますか?前巻の内容をよく思い出してみてください。Partitionが分散さえすればテーブルを分けなくても負荷は分散するのです。

そして、DynamoDBは スキーマレス です。 スキーマレス であるということは、データにListやMapといったデータ構造を持たせられることでも、後から属性を自由に追加・変更・削除できるということでもありません。プライマリキーの型さえ合っていれば同じテーブルになんでも突っ込めるのです。

DynamoDBの公式ドキュメントにあるベストプラクティス集にはこう書いてあります。

DynamoDB アプリケーションではできるだけ少ないテーブルを維持する必要があります。設計が優れたアプリケーションでは、必要なテーブルは 1 つのみです。

docs.aws.amazon.com

さすがにアプリケーションにつき1つはプライマリキーの型を合わせないといけないことも考えるとやりすぎ感もあるし(全部文字列にしちゃえばできないことはないんですが)、Serverlessである程度の規模以上のシステムを設計していくとMicroservices的なサービス分割は切っても切れない関係になるので、サービス間で同じテーブルを共有するのはなんとも気持ちが悪い部分があります。

そこで、私は少なくとも1つの実体について1つのテーブルに収めるようにしています。この「1つの実体」というのはECサイトなら「ユーザ」「商品」「注文」といった特定の意味のある存在を表す実体です。これはMicroservices的な各サービスが担うべき特定の業務ドメイン領域にも多くの場合に合致します。そして、これら1つの実体は基本的にユーザIDや注文番号、SKUといった識別子を持っているものです。これをPartition Key とするのです。

トランザクションが無くても1アイテムの更新はアトミック

トランザクションとは何のためにあるのでしょうか?RDBトランザクションが守るのは主にデータの 原子性(Atomicity)独立性(Isolation) です。*2それが問題になるのは複数のレコードを更新する一連の処理における話です。単一のレコード(DynamoDB的にはアイテム)を更新する処理についてはDynamoDBでも 原子性 が保証されています。また、1アイテムしか更新しない処理は当然独立しています。

つまり、ListもMapも扱えるDynamoDBではある実体に関連するデータを全て一つのアイテムに押し込めることができ、それを更新している限りは原子性と独立性が崩れることはないということです。さらに言えば、一貫性(Consistency) もデータ間の関連性がそのアイテムに適切に詰め込まれていれば 条件付き書き込み によって担保することができます。400KBという1アイテムのサイズ制限に気を使う必要はありますが、そうそう超えるものじゃありません。

docs.aws.amazon.com

docs.aws.amazon.com

とはいえ完全に一つに突っ込んでしまうとキャパシティの無駄遣いや性能面での懸念もあります。それをどう分けるかという面でもまたデータアクセスが重要となってくるのです。例えば、あるユーザの基本的な「名前」や「住所」といった「情報」と、「ログイン中」などといった「状態」は明らかに更新タイミングが違うはずです。こういったデータは分割することでキャパシティ効率がよくなります。更新タイミングが違うのであれば1回の操作につき1つの更新になるため以下同文です。

そして、同じ実体に対する Partition Key のデータを分割するならどうするか?そこで Sort Key の出番というわけです。

user_id (PK) data_key (SK) attrs
aaa info 〜〜〜
aaa status 〜〜〜
bbb info 〜〜〜
bbb status 〜〜〜

それでも整合性の担保ができないものもある

それは、トランザクションの代表例とも言える口座間取引のような実体として複数に跨って整合性を取る必要のある処理です。全く別の実体である複数の口座を一つのアイテムにすることは現実的ではありません。この場合は、諦めてRDBを使う、まず必ず担保すべき「移動元の口座の残高が0を下回らないこと」だけを条件付き書き込みで担保してそれ以外は結果整合性を受け入れる、頑張ってトランザクションを再実装する、といったちょっとつらめの対策になります。

f:id:FumblePerson:20180802001414p:plain

www.slideshare.net

ただし、私はServerlessでもRDBを使うことは全く問題ないと考えています。Serverless(Lambda)とRDBの相性が悪いのはコネクションモデルと現状避けられないVPC内で起動するLambda FunctionのENI生成オーバヘッドによるものです。つまり、オンラインで使おうとするから悪いのです。これらは、トランザクションが必要な処理をKinesisを挟んだバックグラウンド処理に置き換えることで、コネクション数をコントロールすると共に一定数が常時起動となることによってオーバヘッドも避けることが可能です。そして、RDBによるトランザクションの成功を以てDynamoDBのデータも更新して、オンラインの参照はそちらを見るようにすれば良いのです。結果整合さえ受け入れられればやり方はいくらでもあります。

このように、DynamoDBでも限定的ではありますがACIDを守る方法はあります。「DynamoDBはBASEで結果整合性だからACIDは捨てなきゃいけない」とか言っちゃうなんちゃってアーキテクトが如何に厚顔無恥か分かりますね!(煽り芸)

書き込むデータと読み込むデータは別で良い

RDBは正規化することで特定の事実を示すレコードを一箇所とし、データの整合性を守ります。これは、 リレーションシップ外部キー制約 という整合性を守る仕組みと、必要なら JOIN をすることでその一箇所にしかない事実を的確に取得できる仕組みに裏打ちされたものです。そして、DynamoDBは JOIN ができません。また、前述したようにテーブルは極力少なくしなくてはなりません。

そんな リレーションシップJOIN も無いDynamoDBで、どのように整合性を守りつつ読み込みの効率化をすれば良いのでしょうか?その答えが、読み込みと書き込みの分離です。

CQRS

CQRS (Command Query Responsibility Segregation) という考え方があります。Command(更新操作)とQuery(データ読み込み)を責任分界を行い、データそのものも分けてしまおうという考え方です。 書き込みに都合が良いデータ読み込みに都合が良いデータ は異なります。両者を分離し、前者を更新するイベントをイベントソースに積み上げて、それを以て後者を更新するのが CQRS の一つの形です。

martinfowler.com

postd.cc

書き込みに都合が良いデータ というのは前述したように1度のオペレーションで更新されるべきデータが一つのアイテムに詰まったものです。対して、 読み込みに都合が良いデータ はそうではありません。様々な属性が詰まったアイテムに対して検索に使用する全ての属性に GSI を張っていてはあっという間に GSI の5つという制限を使い切ってしまいます。そこで、 書き込みに都合が良いデータ が更新されたことをトリガーとして、 読み込みに都合が良いデータ を生成するというわけです。

非常に都合の良いことに、DynamoDBにはそれを実現するためにとてもマッチする DynamoDB Streams という仕組みを持っています。DynamoDBのあるテーブルに対する更新操作が流れてくるStreamです。ここに流れてきた更新情報を元に、 読み取りに都合が良いデータ を生成するコマンドを発行していくというわけです。

f:id:FumblePerson:20180801234414p:plain

データに対する整合性は属性の詰まったアイテムを更新することで書き込み時に担保されているので、それを元に更新している限りは整合性が崩れることはありません。もちろん、非同期更新となるので結果整合にはなりますが。

また、オンラインの書き込みのオペレーションとしては一度で済むためとても処理がシンプルになります。読み込み用のデータを生成する処理は一つ一つが独立したバックグラウンドで行われる処理となるため、仮に失敗してもリトライすれば良い話です。もしこれがオンラインで全て同期でやるとしたらハンドリングしたくないですが、適切に分解することで一つ一つの処理はシンプルに保つことができます。

そして、読み込みのためのデータを生成する処理が分かれていることにより、要求の変化によってデータ構造を変えないといけないといったことを避けることができます。今あるデータや処理を変えずに新しい要求に合わせたデータを生成する処理を追加するだけで良いのです。

次巻に向けて

次巻は「じゃあ、 読み込みに都合の良いデータ ってなんだ」ということで、次こそ具体的なデータ構造とインデックスの使い方に入っていきたいと思います。

次巻

書きました

marcy.hatenablog.com

*1:極端な例を出すと1000RCUの90%閾値でAutoScaleにすると900でスケールしてくれますが、10だと9になるまでスケールしません。残り1しかない。。。

*2:独立性についてはRDBもPhantom Readを妥協する等していますが