misc.tech.notes

主に技術的な雑記的な

ServerlessDays Tokyoは過去最高のServerless系イベントになる(かもしれない)

開催宣言

tokyo.serverlessdays.io

過去3回に渡り最高だったServerlessconf Tokyoですが、今年はServerlessDaysとして生まれ変わって開催が決まりました。

marcy.hatenablog.com

marcy.hatenablog.com

ちなみに2018年は個人的に色々あってイベントに参加するのが精一杯でブログまで書けなかっただけでイベント自体はちゃんと最高でしたw

ServerlessDaysとは?Serverlessconfと何が違うの?

ServerlessconfはServerlessに関する最大のカンファレンスとしての一種のブランドのような所があり、大きな会場と充実した設備と充実したフード・ドリンクの提供や、高額なスポンサー費用とそれに見合うスポンサーメリットなどを考えて運営されてきました(私は運営に入っていなかったので聞いた話)

それは、Serverlessという新しいテクノロジーの発展という意味では重要だったと思っています。Serverlessというものを世間に知らしめるべく、とにかく勢いの現れたイベントです。

しかし、Serverlessへの注目がある程度十分に集まった現在、本当に必要なイベントはなんだろうか?ということを考えた時に、それに合致するのはServerlessconfではなくServerlessDaysでした。それは、より熱量の高い実践者(あるいはそれを目指している、興味がある人)が集まる場ということです。

Serverlessconfもそういった側面はあったものの、ServerlessDaysはより明確にコミュニティが中心であり、その中の開発者達のために運営されるイベントです。そして、コミュニティが発展し、熱量を保つためには裾野の広さと寛容さが重要であると考えています。商業的メリットの高い国や都市以外でも世界中で開催され、多種多様な文化に触れてきたServerlessDaysは、関心するほどにその点についての気配りが行き届いており、それは日本のServerlessDaysでも踏襲されています。

serverlessdays.io

まず、CFPの選定はPapercallの匿名化機能を用いて、匿名化された状態で選定されます。それはつまり、スポンサーセッションを除いて、どのメガクラウドベンダーの所属であるとか、その中のどのクラウドを担いでいるとか、Serverlessで儲けているどうかとか、誰もが知っていて影響力のあるどこぞの会社に所属しているとか、本人が業界の有名人だとか、そういうことは一切関係なくコンテンツの内容だけで判断されます。

また、性別や年令のような基本情報も特に入力は求められないですし、匿名化されているため、趣味嗜好や身体的特徴、思想や宗教観なども分かりようがありません。そんなことは一切関係なく、選ばれたということはあなたのプロポーザルが刺さったから聞かせてほしいわけです。もちろん、今までのServerlessconfでも別にそれらを選定基準に設けたことなんて一切ないはずです。しかし、人間というのは個人が特定できるとその人の属性に関するバイアスが多少なり働くことはどうしても避けられないものですが、それが排除されていることがシステム的に担保されているわけです。なので、選ばれたからには自信を持って話してほしいですし、それらを気にして応募自体をやめる必要は一切ないのです。*1

そして、国籍も人種も関係ない。そうやって世界中で開催されてきたServerlessDaysへの関心はグローバルなものとなっており、既にかなりの数の海外からのプロポーザルが届いていると聞いています。日本人(厳密には匿名化されているため日本人かどうか分からないので、日本語で書かれているプロポーザルということにはなるけど)はまだ少ないのです。世界に日本の力を見せてやりましょう!

もちろん、私のように過去のServerlessconfで登壇した実績があるとかそんなことも関係ないわけです。他の領域での知名度だって関係ない。提出したプロポーザルの内容だけで判断される。だから、登壇経験が無いとか、クラウド・Serverless界隈はアウェーっぽいとか、そんなことも気にする必要も無いわけです。

また、そんな人達をフォローするためにメンター制度を設けています。自分の思いついたテーマがServerlessDaysで刺さるのか分からない、スピーカー経験が無い、あるいは自信が無いという方は japan@serverlessdays.io までご連絡いただければ、コミュニティから信頼のおけるメンターあるいはアドバイザーを紹介します(ただし、メンタリングを受けたからといって選定されるわけではないということだけはご了承ください)

私は、そんなServerlessDaysのポリシーにとても共感したため、今回は運営として関わらせてもらっています。CFPの選定メンバーだけは今年もスピーカーとして参加したい気持ちがあったので外してもらっていて応募内容も見えないようにしてもらっていますが。

そんなわけで、今までServerlessconfやServerless Meetupに登壇・参加したことのない人はもちろん、他の領域のコミュニティが主戦場と認識している人や、性別や年令や趣味嗜好その他も一切関係なく、少しでも「もしかして、これってServerless関係ある?」って思ったなら奮って応募してほしいわけです。思って無くても本当に全然関係なければ落とされるだけなので、とりあえず応募してほしいわけです(暴論)

つまるところ、私たちはとにかく面白い話が聞きたいんだ!!

www.papercall.io

※一応断っておくと、これは私の個人的な見解であり、運営全員の総意ではありません

今回のイベントが過去最高になる理由その①

毎年最高だったServerlessconfですが、ありがたいことに毎年スピーカーとして参加させてもらった身から、その中であえてどれが一番だったかを聞かれて答えるなら、なんだかんだやっぱり初回です。

それが何故かと言うと、これはServerlessDaysのポリシーとも合致するんですが、あえてカンファレンスホールではない会場を使い、1トラック進行だったのが大きいと思います。もちろんアーリーステージだったのもあると思いますが、大人しく座って聞くだけだとなんか違うと感じずにいられない会場の雰囲気の中、熱量の高い技術者が集まり、全員が同じコンテキストを共有していたからだと思います。現場はもちろん、Wifiも整備されていない会場でSNSでも議論が白熱しました。

今回の会場はServerlessconfの初回と同じTabroid(ライブやアートイベントが主に行われるイベント施設)です。そして、1トラック進行です。ServerlessDaysの寛容な運営ポリシーによってもたらされた新鮮さと一体感はServerlessconfの初回と同じかそれを上回る熱量を生み出すのではないかと思っています!

今回のイベントが過去最高になる理由その②

個人的にはこれが最大の理由なのですが、今回のServerlessDaysのWorkshop Day(2019/10/21)では、クラウドベンダーではなくコミュニティプレゼンツなコンテンツの一つとして、Serverlessなシステムパフォーマンスチューニング大会を行います!!

今回は初回でもあるので、問題のベースを私が作る都合上と運営側のやりやすさを考慮してAWS縛りにはなりますが、EC2(とFargate)さえ使わなければなんでもアリ!EC2さえ使わなければ、サービス構成を変えようが、アーキテクチャを変えようが、コードを書き換えようが、ランタイムや言語を置き換えようがなんでもアリ!AWS Lambdaの特性上、アプリケーションレイヤでの並列処理やインメモリ戦略には限界があり、アーキテクチャレベルでの最適化を行う必要のある真の総合戦!!*2

それはつまり、最強のServerless開発者・・・いや、クラウド技術者を決める大会です!本当は私自身が参加者になりたかったけど、誰もやってくれないから自分でやるんだ!w上手くいったら来年は誰か問題作成頼む!上手く行かなかったら本当にマジでゴメンナサイ!!

とはいえ、初回なので大規模では運営側が担保できる保証がないため、Workshopチケットを持っている人の中で希望する数十人での開催になる見込みです。「我こそは!」と思う各位はWorkshopチケットが販売開始されたら速攻で買うんだ!

ちなみに、優勝賞品(あるいは賞金)については調整中です。何かしらは出したいと思ってますが、とりあえずはその場の楽しみとわずかな名誉以外は期待しないでくださいwもし万が一、初回なので宣伝効果のようなメリットは一切保証できないですが、それでも良いと言ってくれる奇特なスポンサー様がいれば、一旦私 (@marcy_terui) までコンタクトしてください(本編のスポンサーと一緒くたにすると色々ややこしい話もあるので)

(あと、登壇資料を作るくらいなら問題作成と運営準備やれって話な気がするので、今回はCFP応募はしないかも)

つまり何が言いたかったかというと

みんなCFPに応募するんだ!あと、Workshopチケットを購入してまで大会に参加したい人の需要を知りたいので、リアクションお願いします!

*1:個人が特定された状態で選定したほうが良い場面もあるし、バイアスが働くことが悪いことであるという意図はありません。ただ、今回はよりオープンマインドな場を作りたいためにそうしているというだけです

*2:と思っているんですが、その想定を外れる離れ業が飛び出す可能性もあるとは思っていて、それはそれで勉強になるから良いかなってwそうならないようできる限り問題作成やレギュレーションの策定をがんばりますが・・・!

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:日本は佐藤さん、鈴木さんが多い的な