病みつきエンジニアブログ

機械学習、Python、Scala、JavaScript、などなど

サーバーレスな Headless CMS を自作して始める Jamstack

この記事は Jamstack Advent Calendar 2020 の 23 日目の記事です。

Jamstack なサイトのアーキテクチャ

Jamstack の厳密な定義などは一旦置いといて、今回は、次のような構成のサイトについて考えます。あまりわかりやすい図ではないかと思うのですが、上段は非同期アクセス、下段はエンドユーザー(閲覧者)からのリアルタイムアクセスです。

f:id:yamitzky:20201222190053p:plain
Jamstack構成図

この構成はざっくりいうと、

  • データは「Headless CMS」と呼ばれる CMS で管理されており、エンドユーザーには見えない
  • エンドユーザーは静的な HTML のみを閲覧する *1
  • なんらかの方法で、「静的な HTML」は非同期に動的更新される (Webhook や Incremental Static Regeneration など)

といった特徴があります。DjangoRails などで作る「動的 HTML 返すサイト」とはちょっと異なります。

このうち、下2つの配信・更新部は Vercel や Netlify などの SaaS を使うことも一般的ですが、Firebase や Amplify のようなサーバーレスなソリューションを使えばかなり安価な従量課金&高いスケーラビリティを実現できます。しかし一方で、「Headless CMS」に関しては、

  • SaaS の場合、月額数百円/人/月の価格設定
  • OSS を自前ホストする場合、MySQL などのデータベース管理が必要
  • 海外製だとほぼ日本語対応されていなかったり、GitHub 前提だとエンジニアではない人に使いづらい

と、 サーバーレス的な課金体系、マネジメントコストで実現できる、使いやすいソリューションが存在しない のが現状です。

Headless CMS の役割

Headless CMS の主要機能は、主に、次のものに分解されます。特に、上の2つがコア機能です。

  • 記事を書いたり編集できる、CMSGUI 機能
  • 記事データなどのデータベースの機能
  • 編集権限などユーザー管理
  • Webhook による更新通知
  • DB を API を通じてアクセスできる機能

この役割は、Firebase などを使えば、ほぼ代替できます*2

  • データベース → Firestore などの BaaS
  • ユーザー管理 → Firebase Authentication などの認証プロバイダ
  • Webhook → GitHub Actions など。ISR であれば不要。
  • API アクセス → Firestore のような BaaS を使えば不要、または、Cloud Functions などで薄くラッピングする。

CMS のアクセス数は一般的に少ないので、月額のインフラコストはほぼゼロです。また、SaaS ではないのでデータのポータビリティも高いです。

残る開発しないといけない部分は CMSGUI 部のみ です。既存の SaaS/OSS の Headless CMS を使わないことによって、GUI の実装に余分な時間がかかることにはなるのですが、 ユースケースに合わせて使いやすい CMS を作るコスト と割り切ることもできるのではないでしょうか。

Headless CMS を自作したアーキテクチャ

業務委託(個人事業)で運営しているホテルのウェブサイトでは、次のような Jamstack の構成にしています。

f:id:yamitzky:20201222191330p:plain

ホテルのサイトにおいては、「動的なコンテンツ」はいわゆる「記事」ではなく、飲食メニュー等になってきます。Headless CMS のガワだけを自作したことによって、これらを使いやすい UI で管理できるようになりました。また、予約管理も Firestore をデータベースとして、同じ CMS 内で予約管理できるようになっています。もちろん、これらの月額固定コストはかなり安価です。

また、JX のプロジェクトでは都構想特設サイトを類似構成で実現し、質問受付システムも Headless CMS 上で対応できるようになりました。これらの「管理」に関してはかなり工数を抑えて実装できています。

tech.jxpress.net

OSS 化してみた

「自作 Headless CMS」に関しては OSS 化をしました*3。Firestore をインフラに使っていて、もちろん日本語対応しています。

github.com

*1:厳密には 、Jamstack では API=動的なものへのアクセスがあります

*2:僕はやったことないですが Amplify でもいけると思います

*3:厳密には OSS 版とホテルの CMS はコードベースが別もので、JX の業務では OSS 版を使っています。知識としての横展開はありますが、成果物としては完全に切り分けています

今どきの Python ならデフォルト引数に空の list や dict を指定しても良くない?

この記事はPython Advent Calendar 2020 の7日目の記事です。

今年の PyConJP 2020 では、Python の型ヒントについて登壇させていただきました。

speakerdeck.com

2020 年も終わりかけですから、「もう 2021 年からは Python のデフォルト引数に list 入れてもよくないか?」という提案をしてみたいと思います。

Python のデフォルト引数のアンチパターン

Python は引数にデフォルト値(デフォルト引数)を指定することができます。

def generate_zero(x=0):
    return x

zero = generate_zero()
print(zero)
# 0
print(zero + 1)
# 1
print(generate_zero())
# 0

当たり前のように、デフォルト引数に指定した 0 という数字が使われています。generate_zero の返す値は常に 0 です。当たり前ですね。

しかし、デフォルト引数に listdict のようなミュータブルなデータ構造を入れると、おかしなことになってきます。

def generate_empty_list(x=[]):
    return x

list1 = generate_empty_list()
print(list1)
# []
list1.append('append')
print(list1)
# ['append']

list2 = generate_empty_list()
print(list2)
# [] ではなく ['append'] になる

generate_empty_list が返す list1list2 は、同じインスタンスを参照しています。そのため、デフォルト引数に空のリスト等を入れて初期値としてしまうと、その値に後から破壊的な変更を加えたときに、初期値の値が書き換わってしまうのです!

この挙動は、例えば TypeScript などとは異なります

function generate_empty_list(x=[]): string[] {
  return x
}

const list1 = generate_empty_list()
console.log(list1)
// []
list1.push('append')
console.log(list1)
// ['append']

const list2 = generate_empty_list()
console.log(list2)
// []

この挙動があるために、Python のデフォルト引数には、空の list や dict などのミュータブルな構造を入れるべきでない といったプラクティスが提唱されています*1。結果として、Python での安全なプログラムは次のようになります。

def generate_empty_list(x=None):
    if x is None:
        return []
    return x

言語学習者にとって、この仕様は冗長というか、直感的ではない感じがします。実際、しばしば Python のハマりどころとして紹介されています。そこで今日はこのプラクティスに異論を提起してみます。

新しいプラクティス

提案したいプラクティスは イミュータブルな型ヒントをつけよう です。

デフォルト引数の値が書き換わってしまう理由は、厳密には「ミュータブルなものを指定したから」ではなく「破壊的変更を加えたから」です。したがって破壊的変更さえ加えなければ、今回の問題は発生しません。Python では 3.5 から型ヒントという機能が提供されており、mypy などの型チェッカーで検査することができます。破壊的変更を加えることにできない、append のない list を作ってしまえば、デフォルト引数の問題は解消します。

ちょうど Python では「イミュータブルな list」や「イミュータブルな dict」のような型が提供されており、コレクション抽象基底クラスとして提供されています。例えば Sequence は実質イミュータブルな list で、Mapping は実質イミュータブルな dict になっています。これは抽象クラスのパッケージではありますが、型チェッカー上では Protocol のような振る舞いをもたせられます。 *2

実際の例を見てみましょう。

from collections.abc import Sequence

immutable_list: Sequence = []
immutable_list.append('append')

# ***.py:4: error: "Sequence[Any]" has no attribute "append"
# Found 1 error in 1 file (checked 1 source file)

この例は mypy でチェックをするとエラーを吐きます。なぜかというと、 immutable_list の変数は Sequence 型であり、 append などの破壊的なメソッドを持たないからです。

しかし一方で、読み込み専用の list の振る舞いは問題なく持っています。また、Protocol のような振る舞いなので、Sequence やその継承したインスタンスそのものを代入する必要はありません。

from collections.abc import Sequence

immutable_list: Sequence = []
print([x for x in immutable_list])  # OK
print('element' in immutable_list)  # OK

# Success: no issues found in 1 source file

それでは、当初の問題であった「デフォルト引数に list / dict 入れる問題」について、「イミュータブルな型ヒントをつけよう」というプラクティスを適用してみます。

from collections.abc import Sequence

def generate_empty_list(x=[]) -> Sequence:
    return x

list = generate_empty_list()
print(list)

list.append('append')  # error: "Sequence[Any]" has no attribute "append"
print(list)

list2 = generate_empty_list()
print(list2)

このように戻り値や引数に型ヒントをつけてあげると、mypy が「append できないよ」と注意してくれます。こうして、デフォルト引数が変わっちゃう問題は解消されました。めでたしめでたし

メリットとデメリット

メリットとしては、見通しが非常にすっきりするのではないかと思います。TypeScript 同様、デフォルト引数に list を入れて良いので、他言語学習者にとって直感的かもしれません。個人的な意見として、Python は明らかに型ヒントのマナー・役割を広げようとしているので、これは将来 "Pythonic" な書き方とされるかもしれません。

と、提案してみたものの、 デメリットも大きい と思ってます。これはランタイムレベルでのチェックではありません。なので、mypy 等による型チェックを行うこと、その型推論が正しく動くことが不可欠です。mypy の精度は、例えば TypeScript などと比べてもそれほど高くない、というのは正直な感想です(2020年の感想です)。

終わりに

本稿では「デフォルト引数にミュータブルなものを入れてはいけない」というプラクティスに対して、型ヒントによる解決策を提示しました。登壇内容のはてブのコメントが荒れたように「動的型付き言語に型を持ち込むこと」に対しては、様々な意見があろうと思います。

僕個人としては、Python の型周りのエコシステムが進化し、さらに使いやすくて安全な言語となることを願ってやみません。Guido さんも Microsoft 入ったし!

*1:Python デフォルト引数 ミュータブル」でググる記事がいっぱいでてきます

*2:ランタイム上では list だが、型チェックの上では append や __setitem__ などを持たない

gqlgen でのキャッシュ性能向上を考えてみる

NewsDigest ではアプリの BFF として GraphQL を使っていて、ライブラリとしては gqlgen を使ってます。で、なるべく CDN でのキャッシュヒット率を上げたいなぁということで、 gqlgen でできることをプロトタイプしてみました。

github.com

前提として、

  • Apollo とかは使ってなくて、curl でのコンセプトレベルの検証
  • 僕は Go を書く力が弱い
  • 頭の体操であって、実戦投入したものではない

1: Persisted Query をつかう

まず、Persisted Query を使います。

Add File-based Persisted Query · yamitzky/example-gqlgen-cached@5917721 · GitHub

Persisted Query は、簡単に言うと、クエリ自体をハッシュ文字列(0123456789abcdefの列)に置換して送信するものです。GraphQL のリクエストサイズは結構デカく(冗長)、数kb とかになったりするので、これを数文字のハッシュ文字列に置換できれば、送信サイズを大幅に圧縮できます。例えば、次のような curl です。

curl -g 'http://localhost:8080/query?extensions={"persistedQuery":{"version":1,"sha256Hash":"688a6c"}}'

理論上、どんなに大きなサイズのクエリであっても、上記のような定数長のクエリに置換できます。また、上記のように GET でリクエストすれば、CDN によってキャッシュしやすいです。

一般論として、gqlgen に搭載されている APQ の機能は、クライアント側がハッシュ値を勝手に登録することが期待されています。GraphQL のクエリは無限のパターンがあり得るためです。一方で、GitHub に上げた例では、事前にクエリ文字列をコミットしなければならない という前提を置きました(Cache.Add が何もしない)。プライベートな BFF として作るのであれば、どういうクエリを送りたいかは事前にコントロールできるためです。むしろこのように事前にファイルにコミットしておくことで、意図しないクエリ文字列の違いによるキャッシュヒット率の低下を防いだり、クエリ登録のネゴシエーション*1も減らすことができます。まぁ BFF のようなチーム開発するものなら許容できるでしょう。

余談ですが、 http_get.go を模して*2 /queries/abcdef みたいな感じで RESTful っぽくリクエストできるような GraphQL API を作ることもできると思います*3

2: Cache-Control を設定する

Persisted Query を使うだけだと、CDN のキャッシュ時間は固定値でしか設定できません。例えば頻繁に更新される TODO 一覧と、あんまり更新されないユーザー一覧が同じキャッシュ時間だと不便です。そこで、リゾルバー側から Cache-Control ヘッダーをリクエストできるようにしてみました。

Add cache-control · yamitzky/example-gqlgen-cached@f4e956f · GitHub

GraphQL では1度に複数のリソース(例:todos と users)を取得できることになるので、最終的には最もキャッシュ時間が短いものにします。例えば、todos は60秒のキャッシュ、users が1日のキャッシュで良いのであれば、

  • { todos { id } }・・・60秒
  • { users { id } } ・・・86400秒
  • { todos { id }, users { id } }・・・ 60秒

ということです。directiveで設定する方法もあると思いますが、動的に変えられるアプローチのほうが良いと思います。

ちなみに、同じようなコンセプトですが、もっと良い方法がプルリクされてます*4

3: resolver レベルのキャッシュ

2 番目の方法では、1度に複数のリソースを取得した場合、最終的に最も短いキャッシュ時間を採用していました。これは CDN のキャッシュとしては適切ですが、CDN のキャッシュが切れたときに少しもったいないです。例えば、 { todos { id }, users { id } } に対して60秒のキャッシュが設定されていますが、60秒経過した後、1日程度はキャッシュしていいはずの users を再計算することになります(実際には DB へのアクセスになるでしょう)。

そこで、gqlgen の extension の仕組みを使って、resolver レベルでキャッシュできるようにしてみました*5。これであれば、できるだけ CDN でキャッシュされ、CDN のキャッシュが切れたとしても適切な範囲で resolver レベルでメモリや Redis などにキャッシュできます。

Add resolver-level cache · yamitzky/example-gqlgen-cached@05c6e5e · GitHub

その他の採用しなかったアプローチ

Akamai とかの GraphQL 対応した CDN をつかうと、2 と 3 をもう少し綺麗に対応できそうな感じがします。

*1:APQ は Optimistic Request なので、メモリキャッシュであれば分散したサーバー分ネゴシエーションが増え、それを避けるには Redis のようなキャッシュサーバーへの余分なリクエストが必要になります

*2:これも余談ですが、最近の gqlgen はリファクタされたおかげで、あまりコピペせずにハンドラー作れるのですね

*3:RESTful 前提のメトリクスツールが使えるとかのメリットがあります。それ GraphQL である必要ないやんけという意見もありそうですが、リポジトリにクエリ文字列を保存さえすればクライアント側はほしいデータを柔軟にとってこれるので、十分に GraphQL のメリットは享受できてると思います。Apollo みたいな便利 GraphQL クライアントが動くかはわからん

*4:書き終えたあとに気づいた... gqlgen はミドルウェアからヘッダーを動的に書きかえる方法がないので、コアリポジトリを変更するか protocol コピペすることになります

*5:面倒くさかったので、TTL は設定してません

OSS 版 Redash ボットを Slack Bolt で作り直した

Redash のオンプレの bot を、Slack の Bolt というフレームワークを使って作り直しました。

github.com

Redash とは

Redash は様々なデータソースと接続して SQL/可視化に使える OSS/SaaS です。最近、Databricks に買収されたことで話題にもなりました。

redash.io

Redash 本体は OSS で公開されていますが、Slack ボットに関しては OSS ではありません(OSS でないものが公式提供されてる)。そのため、Redash 本体はオンプレ(クラウド)にデプロイできるにも関わらず、ボットがオンプレに置けないという課題がありました。このあたりの課題感は、ボットのフォーク元である、hakoberaさんのQiitaをご覧ください。

また、OSS 版 Redash を運用する工夫はこちらに記載しています。今回のリリースに伴い、JX通信社の Slack にもインストールしています。

tech.jxpress.net

Slack の Bolt とは

Slack の Bolt はボットのためのフレームワークで、とても生産性高く書けます。また、Python 版も最近 α 版がリリースされました。

slack.dev

さて、Slack の bot は、もともと RTM (Real Time Messaging)と呼ばれる、Websocket ベースのアーキテクチャでした。しかし新しい Slack のアプリでは、Events API という Webhook(HTTP) ベースの新しい bot を作る必要があります。これはアーキテクチャ的に後述する安定性などのメリットがあります。

使い方

詳しくは README を読んでください。Docker を使う場合は docker run yamitzky/redashbot:2.0.0 で使えます

書き直した理由

コードを書き直したことにより、次のメリットが生まれました。

  • RTM API から Events API へ移行したことで、安定性が増加した
    • HTTP でロードバランサーにぶら下げればいいので負荷分散しやすい
    • Websocket に比べ、ヘルスチェックがしやすい
  • TypeScript へ移行
  • ソースコードを分割しやすくなった

特に、僕のフォークしたバージョンではダッシュボードが見れるなどの追加機能があったため、ソースコードが地獄でした(ソース)。

また、地味に次のような変更をしています

  • デフォルトブランチを master ではなく main に
  • Chromium 以外に、FirefoxWebkit のサポート (playwright を利用。テストできてない)
  • Serverless 環境へのデプロイ (途中...誰かテストして...)

playwright は Microsoft から出ている Puppeteer の後継パッケージみたいな感じで、マルチブラウザでの Puppeteer 相当のことができます。また、公式で Docker イメージが提供されているので、Docker 化も簡単でした。(文字化け対策で fonts-noto だけインストールした)

できること

公式のボットを使ってないのでわからないですが、

  • グラフのキャプチャ (これは当たり前ですね!)
  • ダッシュボードのキャプチャ
  • 表データを、キャプチャではなく、フォーマットした表テキストとして投稿する

などができます。また Bolt で作られている OSS なので、機能追加も簡単かと思います。せっかくなので、対話機能とかも使いこなしたいかもしれない。

@bot <URL> といった形式で使うので、もちろん Slack のリマインダーと組み合わせて使うこともできます。例: /remind #hoge @redash https://your-redash-url/dashboards/fuga *1

*1:リマインダーの対応については、公式の directMention だと先頭一致となっているため、現在は独自実装しています。こちらのスレッドで話しています

Python の型システムの上で Immutable な Python プログラムを作る

まえがき

今年の PyCon JP 2020 にて「Python 3.9 時代の型安全な Python の極め方」というタイトルで登壇させていただきます。本稿は、発表の補足となる「型ヒントを使って Immutable な Python を実現する方法」について紹介したものです。

Python の型ヒント

Python には「型ヒント」という機能があり、型をプログラム内に宣言することができます。

age: int = 28
name: str = 'Bruce Wayne'

Python は動的型付き言語であるため、この情報はランタイム(実行時)にはあまり意味がないのですが、 mypy などの型チェックツールをつかうと、型の誤りをチェックすることができます。

def check_batman(name: str) -> bool:
    return name == 'Bruce Wayne'

age = 28
check_batman(28)  # NG: 文字列型の引数に int を入れているため

さて、この機能をつかうと、 list や set のような mutable (要素が書き換え可能) なデータ構造であっても、型システム上においては imutable なものとして扱うことができます。ランタイム時は基本的なデータ構造である list や set などを使い続けながらも、型のチェック時だけ書き換えを防ぐことができるのです。

Immutable Python とは

例えば、 list を immutable にしたいというのは、こういうことを防ぐのが目的です。

def check_list(li: list):
    li.append('fuga')  # ← 勝手に書き換えないでえええええ

users = []
if check_list(users):  # ← 意図せず書き換わってしまう
    ...

さすがにこんなにことはしないと思いますが、例えば numpy の shuffle や JavaScript の sort など、配列に変更を加えて嫌な気持ちになるケースは結構紛れています。

これをランタイム(実行時)時に Immutable にする方法もあります。同僚の書いた記事をご覧ください。

tech.jxpress.net

しかし、あくまでデータ構造としては一般的なものを使い続けたり、ランタイムの挙動を変えずに Immutable にしたいこともあると思います。今回は クラスやリスト等のデータ構造が不変な状態を保つこと を目的とし、変数の書き換え防止や副作用全般のコントロール、ランタイム時の Immutability などは目的としていません。

Immutable な list とは

例えば、list に対する破壊的な変更は、次のようなものがあります。

name_list.append('New User')
name_list[1] = 'Renamed User'

つまり、変更できるメソッドが生えているから、list が変更できてしまうのです。逆に、変更できるメソッドが生えていない list を型宣言すれば、その list は型システムの世界においては実質 immutable です。

Protocol による型宣言

Python では 3.8 から Protocol という「継承によらない部分型」を宣言できるようになりました。つまり「何を継承してるか(名前的部分型)じゃなくて、何を持っているか(構造的部分型)で自分を語れよ!」ということです。

from typing import Protocol
class Vehicle(Protocol):
    def run(self):
        ...

class BatMobile:
    def run(self):
        ...

def run_vehicle(v: Vehicle):
    v.run()

run_vehicle(BatMobile())  # 継承してないけどOK

ここで、BatMobile と Vehicle の間には継承関係はありません(名前的部分型でない)。しかし、 run という共通のメソッドを持っていて、関数内ではそれを呼び出しています。したがって、構造的部分型としては OK なのです。

では「Immutable な list となるような構造的部分型」を定義してみましょう。「Immutable な list」は、「'a' in hoge」とか「for x in hoge」とかはできるのに、「 hoge[0] = fuga 」 はできないような list です。これらは Python では __contains____iter____setitem__ といったメソッドを class に定義いしてあげることで実現できます。

from typing import Protocol

class ImmutableList(Protocol):
    def __contains__(self, x):
        ...
    def __iter__(self):
        ...

def check_list(li: ImmutableList):
    print('a' in li)  # OK
    for x in li:  # OK
        ...
    li.append('fuga')  # NG

users: ImutableList = []
if check_list(users):
    ...

今回は ImmutableList というのを作ってみましたが、 Python 公式で用意されている ので、これを使いましょう。

from typing import Sequence

def check_list(li: Sequence):
    print('a' in li)  # OK
    for x in li:  # OK
        ...
    li.append('fuga')  # NG

users: ImutableList = []
if check_list(users):
    ...

同様に、Mapping (immutable な dict 相当)やSet(immutable な set 相当)なども用意されています。

Immutable な class

dataclass(frozen=True) や NamedTuple をつかうと、mypy はチェックしてくれます。もちろん、ランタイム上でも immutable になります。

from dataclasses import dataclass

@dataclass(frozen=True)
class User:
    name: str

batman = User(name='Bruce Wayne')
batman.name = 'Dick Grayson'  # NG

この方式の微妙な方法

__contains__ を持っているかどうかでしか宣言できないので、「真に set や dict がほしい、immutable で」というケースには使えません。例えば、 x in []x in {} では、計算量が違うので、contains を持っていても list は困る、という場合には使えません。

型の上で Immutable なデータ構造のメリット

と、ここまで書いて「Immutable な Python って意味あるの?」という疑問を持たれたかもしれません。このメリットは3つあります。

一つめは、意図しない副作用を発生させない(安全なプログラムを作る)という観点です。

2つめの理由は、list などのジェネリクスは、共変ではないということです。例えば、「犬リスト」は「動物リスト」として扱えるでしょうか?

from typing import List

class Animal:
    ...

class Dog(Animal):  # 継承関係にある
    ...

def check_animal(animal: List[Animal]):
    ...

dogs = [Dog()]
check_animal(dogs)  # NG: List[Dog]はList[Animal]として扱えない!

実は、「犬リスト」は「動物リスト」として扱うことはできません。なぜかというと、 check_animal の中で勝手に「うさぎリスト」等に書き換えてしまことができるからです。逆に、書き換えられないようにしてあげれば、「犬リスト」を「動物リスト」として扱うことができます。

from typing import Sequence

class Animal:
    ...

class Dog(Animal):  # 継承関係にある
    ...

def check_animal(animal: Sequence[Animal]):  # List から Sequence に変えただけ
    ...

dogs = [Dog()]
check_animal(dogs)

ある程度 Python で型ヒントをちゃんと書いていくと、ジェネリクスのこのあたりの挙動に引っかかることがあるかと思います。

(追記) 3つめのメリットとして、「デフォルト引数にミュータブルなものを入れてバグる」ということがなくなります。デフォルト引数には [] などは入れず、None などのイミュータブルなものだけ入れるべきというプラクティスがありますが、型チェックをするならばこのプラクティスは意味がなくなります。

宣伝

...みたいな Python の型の話を、PyCon JP 2020 にて「Python 3.9 時代の型安全な Python の極め方」というタイトルで 8/28(金) 11:50〜 発表します。チケットを買ってない方も、YouTube にて生配信を見れるとのことです。

pycon.jp

同僚の id:shinyorke も同日 16:50〜 「スポーツデータを用いた特徴量エンジニアリングと野球選手の成績予測 - PythonとRを行ったり来たり」というタイトルで発表がありますので、こちらも是非ご覧ください!

Serverless Components を使った少しだけ実践的なアプリケーションの作り方

Serverless Framework の新しい機能「Serverless Components」を使って、サーバーレスなアプリケーションを作ってみました。

いくつかつまずくところもあったので、ブログに残します。ちなみに今回作ったアプリケーションは(特に紹介しませんが) Nature Remo の API を監視するアプリケーションです。

github.com

また、今回は AWS Lambda やサーバーレス自体は知っている読者を対象にしています。

Serverless Components とは

Serverless Framework という、サーバーレスなアプリケーションを作るためのツール/SaaS に新しく搭載された新機能(?)です。今年の4月にGAとなりました。

www.serverless.com

Serverless Components は無料で使えますが、GA 版に伴い serverless.com へのログインが必要になっています。

Serverless Components は今までの Serverless Framework と違い、

  • CloudFormation に非依存でデプロイが速い
  • 自作 Component (プラグイン的な)の定義が容易で、ベンダー非依存 (紹介しません)
  • 新機能「dev mode」

といった特長があります。dev mode は、 serverless dev を走らせておくと、自動デプロイ(≒ Lambda へのリアルタイム反映)をしてくれる開発用機能 です。便利!

定義ファイルもかなりシンプル で、もっともシンプルな serverless.yml の定義は

component: aws-lambda
name: your-lambda-function-name

だけです。便利!

使える Component のリストは、次の GitHub のグループにリストアップされているものです。

github.com

一方で、 Serverless Component の GA 版では、1つの serverless.yml に 1 つのデプロイ対象(関数等)しか書けない制約があります。これは制限というよりは、規約という方が近く、そういう思想で作られているように見えます*1。したがって、Serverless Components を使ってアプリケーションを作る場合は、Component を組み合わせるような作り方です。

DynamoDB を使った、AWS Lambda のアプリケーション

Serverless Components の考え方を紹介するため、 DynamoDB を使った Lambda のアプリケーションを考えてみます。

通常の Serverless Framework の serverless.yml は、次のように resources の下に生やしていったはずです*2

service: app.yml

provider:
  name: aws
  ...

resources:
  Resources:
    hogeTable:
      Type: AWS::DynamoDB::Table

Serverless Components では、1つの serverless.yml にリソースをどんどん生やしていくのではなく、複数の Component を組み合わせていきます。今回は、Lambda のための「aws-lambda」と、DynamoDB を定義するための「aws-dynamodb」、さらに、Lambda から DynamoDB にアクセスできる IAM Role を定義するための「aws-iam-role」の3つの component を組み合わせます。

これらのインフラの依存関係は、次のようになっているので、この順番に紹介します。

f:id:yamitzky:20200810172149p:plain

DynamoDB のテーブル定義

次のように serverless.yml を作ってください。inputs の詳細などは、公式ドキュメントに乗っています、attributeDefinitions などはテーブルのキー定義なので、ユースケースによって異なります。

ここで大事なのは、 org や app を指定することです。Component は組み合わせて使うので、app を指定してグルーピングをしやすくするのです。(ちなみに文字列決め打ちじゃなく、引数や環境変数でも指定できます。)

component: aws-dynamodb@1.1.2
name: your-table-name
org: your-org-name
app: your-app-name

inputs:
  attributeDefinitions:
    - AttributeName: id
      AttributeType: S
  keySchema:
    - AttributeName: id
      KeyType: HASH

この状態で sls deploy をすると、AWS 上に DynamoDB のテーブルが作られます。また、 app.serverless.com を見に行くと、次のようにデプロイしたインフラの情報が保存されています。CloudFormation の代わりに、serverless.com 内に状態保存されるといった感じです。

f:id:yamitzky:20200810173040p:plain

IAM Role から DynamoDB を使う

DynamoDB は、他の Component に依存していないので簡単でした。しかし、IAM Role は「このテーブルARNに書き込んでもいいよ!」という指定をしたいと思います。つまり、コンポーネント間で変数(この場合は、テーブルのARN)をやり取りする必要があります。

また、1つの serverless.yml には 1 つの Component のことしか書けないので、フォルダ分けをして複数の serverless.yml を定義する必要があります*3。つまり、こういう状態です。

.
├── table
│   └── serverless.yml
└── iam-role
    └── serverless.yml

iam-role/serverless.yml は、次のように定義をします。inputs の詳細などは、公式ドキュメントに乗っています。

component: aws-iam-role@2.0.2
name: your-iam-role-name
org: your-org-name
app: your-app-name

inputs:
  policy:
    - Effect: Allow
      Action:
        - sts:AssumeRole
      Resource: '*'
    - Effect: Allow
      Action:
        - logs:CreateLogGroup
        - logs:CreateLogStream
        - logs:PutLogEvents
      Resource: '*'
    - Effect: Allow
      Action:
        - dynamodb:GetItem
        - dynamodb:PutItem
      Resource: ${output:your-table-name.arn}

ここで、output という概念が出てきました。output を使うと、同じ app/organization/stage でグルーピングされた別リソースから、出力結果を持ってくることができます*4。このために、app、organization の指定をすべきだったのです。

こちらも同様に、serverless deploy すると、AWS 上に変更がデプロイされ、app.serverless.com 内にインフラの情報が登録されます。

Lambda のデプロイ

ここまで来るともはや面白いことはないですが、次のようなフォルダ構成で、

.
├── table
│   └── serverless.yml
├── function
│   ├── package.json
│   ├── serverless.yml
│   └── src
└── iam-role
    └── serverless.yml

serverless.yml を定義し、

component: aws-lambda@2.0.0
name: your-lambda-name
org: your-org-name
app: your-app-name

inputs:
  src: ./src
  roleArn: ${output:youriam-role-name.arn}
  env:
    TABLE_NAME: ${output:your-table-name.name}

src/index.js に適当に関数を書いていくだけです。無理やり TypeScript に対応したバージョンのサンプルもあります。

app.serverless.com には、こんな感じで登録されていきます(名前などは異なりますが)。

f:id:yamitzky:20200810175110p:plain

デプロイ方法まとめ

少し長くなりましたが、

  • フォルダをわけて serverless.yml を定義
  • 順番に serverless deploy を実行

していくだけです。Tips として、 .env ファイルは、親ディレクトリまでさかのぼります(organization を .env に入れるときとかに便利)。

また、一部の Component は、複数のリソースを1度に定義できるので(例:API Gateway と S3 と Lambda と...)、さらに簡単な場合もあります。

良かったところ

実践的な例では、Serverless Components のメリットが伝わりづらかったかもしれませんが、 デプロイの速さや、 dev mode などは便利です。

また、Component ごとにドキュメントが別れているのもわかりやすいなと感じました。DynamoDB の定義なども、旧型の resources に書くよりも、Serverless Component で書いた方がわかりやすいのではないでしょうか。

良くないところ

Serverless Components は GA になったとはいえ、Component が量/質ともに十分に用意されているとは言えません

Component の種類はここに定義されているものだけしかありません。たとえば GCP 用のものはまだ定義されていないようです。

そして各 Component の実装もまちまちです。例えば aws-lambda では IAM Role をカスタマイズする機能はありますが、aws-lambda-cron にはない機能です。また、aws-lambda はネイティブに TypeScript をサポートしていなかったり、Python もサポートされていません。

最後に、注意点として、GA 版とベータ版の Serverless Components には定義ファイルの互換性がありません。例えば、Next.js をサーバーレス環境にデプロイする serverless-next.js は GA 版に未対応です。(といっても、β版のまま使い続けることはできます)

まとめ

今までの Serverless Framework は、ある種完成品でしたが、 Serverless Components はまだ発展途上であるように感じます。タイトルに「少しだけ実践的」と書いた通り、実務では不足があるケースもあるかと思います。そのため、無理に移行する必要はないのではないでしょうか(事故りそうだし)。

とはいえ、デプロイの速さ、dev mode、定義ファイルの書きやすさなど、ユースケースにぴったりとハマることもあるかと思います。

それでは、よいサーバーレスライフを!

*1:リソースを1つのインフラスタックに詰め込むのではなく、分割するのが大事であるため、これを推奨している、と書いてある

*2:動かしてないので自信はない

*3:本当にこの方法しかないんでしょうか、、、

*4:ちなみに app、organization、stage を明示的に指定することもできます