PynamoDBで良い感じにTestableなモデルを定義して、DynamoDB Localを使ってテストする方法

f:id:TatchNicolas:20201211101137p:plain

TL; DR;

  • PynamoDBを使ったテストでローカルで動かすDynamoDBを叩きたい場合に
  • テーブルのキー定義やキャパシティ設定の管理はterraform/CDKなどに任せつつ、アプリケーションコードを汚さずにテストを実行したい
  • そんなときは getattr でmetaclassを取り出して setattr でテスト用の設定値を注入してあげましょう

サンプルコードはこちら

github.com

もうちょっと詳しく

背景

PythonでDynamoDBを使った開発していればPynamoDBはとても便利なライブラリです。非常に書きやすいAPIでDynamoDBを読み書きできますし、手軽にテーブル自体もPynamoDBで作成することも可能です。 *1

しかしPynamoDBでテーブルを作成・管理してしまうと if table.exists() みたいな条件を書いて毎回判断させたり、キャパシティの調整のたびにアプリケーションを動かす必要が出てきてしまいます。 *2

そもそもテーブルの作成やキャパシティの管理はアプリケーションの責務ではないため、本番環境ではPynamoDBの責務をあくまで「アプリケーションからDynamoDBを使うクライアント」ととしての用途に限定し、テーブル自体は

  • Terraformで定義する
  • CDKで定義する
  • Serverless Frameworkを使う場合は serverless.yml の中で定義する

あたりがよく採用される方法かと思います。Partition Key/Sort Keyの設定やインデックスの定義などのほか、キャパシティはProvisionedかOn Demandか、Provisionedであればどれくらい積んでおくのか、といった設定を上記のいずれかのInfrastructure as Codeな方法で管理することでしょう。

すると当然、PynamoDBを使ったアプリケーションコードの中にはキャパシティに関する記述は残したくありません。アプリケーションを動かす際はそれでOKですが、テストを書くときに少し面倒なことになります。

たとえばテストにpytestを使う場合、fixture定義の中で 「テスト用のDynamoDBテーブルをlocalstackやDynamoDB Localのようなツールを使って作成し、テストが終われば削除する」 ような処理を書きたくなると思います。

PynamoDBでは、class Meta の中で host を定義することで、boto3で endpoint_urlを指定したのと同じ効果が得られます。

from pynamodb.models import Model

class Thread(Model):
    class Meta:
        table_name = 'Thread'
        # Specifies the region
        region = 'us-west-1'
        # Optional: Specify the hostname only if it needs to be changed from the default AWS setting
        host = 'http://localhost'
        # Specifies the write capacity
        write_capacity_units = 10
        # Specifies the read capacity
        read_capacity_units = 10
    forum_name = UnicodeAttribute(hash_key=True)

(公式Doc より抜粋 )

host = 'http://localhost' if ENV == "test" else None など書けば、テスト時と本番とで設定を打ち分けることは出来そうです。しかし、 アプリケーションのコードに「これはテスト実行かどうか」を判定する条件文が入るのは好ましくありません

素直にboto3を使っていれば、上記スライド *3 の続きで解説されているのと同様に ddb = boto3.client('dynamodb', endpoint_url='http://localhost') のように作成したオブジェクトに切り替えることで、比較的簡単にアプリケーションからテストのためのロジックを追い出すことが可能でしょう。

しかしPynamoDBを使っている場合は、APIを実際に叩く部分はライブラリの中に隠蔽されてしまい、オブジェクトの切り替えによるテストが難しくなってしまいます。

また、テスト用のテーブルをどう作成するかという問題もあります。せっかくPynamoDBモデルを書いたのだから、できればうまく再利用したいものです。しかし、PynamoDBの Model.create_table() は、キャパシティに関する指定がないとエラーになります。

AttributeError: type object 'Meta' has no attribute 'read_capacity_units'

host の指定の時と同様、 billing_mode = "PAY_PER_REQUEST" if ENV =="test" else None などと書けば回避できそうですが *4 、そうするとまたアプリケーションに「テストのためのロジック」が混入してしまいます。

そこで、今回は

  • アプリケーションのコードにテストのためのロジックを入れない
  • PynamoDBのモデル定義をテスト用のテーブル作成に活用する

を同時に達成できるテストの書き方を考えてみました。

やってみる

サンプルのテーブルの定義はこんな感じです。(GitHubのほうのコードでは、リアリティを出すためにGSIとかも足しています)

from pynamodb.models import Model

class _UserModel(Model):
    class Meta:
        table_name = USER_TABLE_NAME

    uid: UnicodeAttribute = UnicodeAttribute(hash_key=True)
    name: UnicodeAttribute = UnicodeAttribute()
    group: UnicodeAttribute = UnicodeAttribute()

それっぽいテストコードを書きたいので、Repositoryパターンっぽく包んでみます。

class UserRepo:
    def __init__(self) -> None:
        self.model = _UserModel

    def add_user(self, uid: str, name: str, group: str) -> dict[str, str]:
        # 省略 実装はGitHubのサンプルを参照してください

    def get_user(self, uid: str) -> dict[str, str]:
        # 省略 実装はGitHubのサンプルを参照してください

    def get_users_in_group(self, group: str) -> list[dict[str, str]]:
        # 省略 実装はGitHubのサンプルを参照してください

この UserRepo の中の self.model を使って create_table しようとすると、billing_moderead_capacity_units / write_capacity_unitsの設定がないのでAttributeErrorになります。

そこで、pytestのfixtureをこんな感じで書いてみます。

@pytest.fixture(scope="function")
def user_repo() -> Iterable[UserRepo]:

    repo: UserRepo = UserRepo()

    model_meta_class = getattr(repo.model, "Meta") # 1
    setattr(model_meta_class, "host", DDB_LOCAL_HOST) #2
    setattr(model_meta_class, "billing_mode", "PAY_PER_REQUEST") # 3
    setattr(model_meta_class, "table_name", "user_table_for_test") # 4

    repo.model.create_table(wait=True)

    yield repo

    # Delete table after running a test function
    repo.model.delete_table()

何をしているかというと、

  • # 1 でモデル定義の中のメタクラスを取り出し
  • # 2 でそのメタクラスにテスト用のhost(boto3でいうendpoint_url)を設定
  • # 3 でcreate_tableを通すためにbilling_modeを設定
  • # 4 でテスト用のテーブル名に名前を上書き

といった操作をしています。

テストコードはこんな感じで、

def test_user_repo(user_repo: UserRepo) -> None:
    alice = user_repo.add_user(uid="001", name="Alice", group="Red")
    assert alice == {"uid": "001", "name": "Alice", "group": "Red"}

    bob = user_repo.add_user(uid="002", name="Bob", group="Blue")
    chris = user_repo.add_user(uid="003", name="Chris", group="Blue")

    users_in_blue_group = user_repo.get_users_in_group(group="Blue")
    assert users_in_blue_group == [bob, chris]

実際にテストを実行してみると、

docker-compose up -d
docker-compose exec app pytest

結果:

f:id:TatchNicolas:20201210214630p:plain
テスト結果

pytestのfixtureでのテーブル作成が成功し、テストが通りました!

おまけ

また、以前のブログで「Serverless Framework +FastAPI」の開発環境を作った際に「せっかくserverless.ymlのなかでスキーマ定義してるのに、結局PynamoDBでテーブル作るためにモデル定義に余計なコードが混ざってしまうなあ...」という問題が残っていました。

tech.jxpress.net

この問題も、本記事と同じ方法でローカル開発用(≠テスト用)のテーブル作成スクリプトをアプリケーションコードとは別に切り出すことで解決できそうですね。コンテナイメージやLambdaには app/ 以下のコードだけ載せておけば良いので、「アプリケーションに余計なコードが混ざらない」を達成できます。

.
├── app
│   ├── __init__.py
│   ├── config.py
│   ├── main.py
│   └── repository.py
├── create_local_table.py
└── test
    └── test_repository.py

create_local_table.py スクリプトの中身はほとんどpytestの中身と同じです。

from os import environ

from app.repository import UserRepo


DDB_LOCAL_HOST = environ["DDB_LOCAL_HOST"]


if __name__ == "__main__":
    repo: UserRepo = UserRepo()

    model_meta_class = getattr(repo.model, "Meta")
    setattr(model_meta_class, "host", DDB_LOCAL_HOST)
    setattr(model_meta_class, "billing_mode", "PAY_PER_REQUEST")
    setattr(model_meta_class, "table_name", "user_table_for_dev")
    # or
    # setattr(model_meta_class, "read_capacity_units", 1)
    # setattr(model_meta_class, "write_capacity_units", 1)

    by_group_meta_class = getattr(repo.model.by_group, "Meta")
    setattr(by_group_meta_class, "host", DDB_LOCAL_HOST)

    repo.model.create_table(wait=True)

まとめ

Pythonのbuilt-inな関数である getattr / setattr を使うだけですが、当初のねらいであった「アプリケーションのコードにテストのためのロジックを入れない 」「PynamoDBのモデル定義をテスト用のテーブル作成に活用する」を達成することができました。

少しでも参考になれば幸いです。

最後に

JX通信社では、PythonやGoを使って「NewsDigest」の開発に参加してくれるインターン生を募集しています! サーバレス、コンテナなど色々な技術スタックに触れられる環境なので、興味のある方は是非お声かけください!

www.wantedly.com

*1:https://pynamodb.readthedocs.io/en/latest/quickstart.html#creating-a-model

*2:RDSの場合、Read Replicaの数やインスタンスサイズをInfrastracture as codeなツールに(=インフラの責務)、テーブルのスキーマはDjangoやAlembicなどにマイグレーション管理させる(=アプリケーションの責務)パターンになると思いますが、DynamoDBの場合は「テーブル自体の作成・キャパシティ設定(=インフラの責務)」と「キーやインデックスといった設定(=アプリケーションの責務)」という切り分けになり、どのツールに何を任せるかという問題かと思います

*3:Node.jsかつS3の例ですがポイントは同じで、「テストのためのロジックが紛れ込んでいる」ことを問題にしているので例として引用しました

*4:read_capacity_unitsとwrite_capacity_unitsの両方を適当な数値に指定しても回避できます

データ分析者たちのコードレビュー #とは - 散らかったJupyter notebookを片付けるかどうするか問題を考える

JX通信社シニアエンジニアの@shinyorkeです.

最近はチームの朝会でよく着ているTシャツにツッコミを受けてます.*1

JX通信社では, いい感じにデータを整備・運用しているデータ基盤を駆使して,

  • BI(Business Intelligence)文脈でのデータ分析・可視化. ダッシュボード作ったり.
  • 機械学習的なアプローチを使ったR&Dと機能開発(分類タスクなど)

といった業務・タスクを社員・インターン問わず行っています.

データ分析でSQLを書いたり, 「新しいアルゴリズム試すやで!」的なノリでPythonのコードをゴリゴリ書く・動かして結果を見て振り返ってまた臨む...って楽しいですよね.

チームの皆さんも, もちろん私もモチベーション高くやってるわけですが!?

あれ, notebookどこ行ったんや...🤔

よくありますよねー(震え)

自分もチームメイトも, 前のめりになって分析なり機械学習なりをやればやるほど, notebookはどこかに溜まっていき, 「そういえば前にやったnotebookどこだったっけ?🤔」ってなります.

これはきっと私(弊社)に限らずあるあるな問題だと思います.

また, 分析業務や機械学習的なR&Dなどをたくさんやればやるほど,

ワイのコード, 大丈夫だろうか(震え)

と心配になります.

このエントリーでは, 散らかったJupyter notebookを片付けるの諦めたもっとカッコよくいい感じにナレッジをシェアしながら分析したい私が,

  • JX通信社のデータ分析環境の今とこれから, を紹介しつつ
  • 結局, 分析屋さんのコードレビューとnotebookの管理って何のためにするんだったっけ?

というお話をいい感じにまとめて書きたいと思います.

TL;DR

  • モブプログラミング的なノリでレビューするとノウハウの共有・お互いを知る意味でも最高なのでオススメ(オンラインでイケる).
  • notebookの管理で神経と労力を使うのもいいけど「諦める」のも一つの手.

おしながき

JX通信社のデータ分析環境と作業スタイル

早速コードレビューの話をする...前に, JX通信社における

  • データ分析ってどういう環境でしてるの?
  • 現状の問題点

の話をサクッと整理しました.

なお, このエントリーの続きみたいな話です.

tech.jxpress.net

現在の環境

社員・インターンがデータ分析に使っている環境は, 以前のエントリーに触れたとおり, BigQueryを中心に回っています.

f:id:shinyorke:20200425131222p:plain
現状のデータ分析環境あれこれ(要約)

(一部のデータ*2を除き)大抵のデータはBigQueryのテーブルとして存在するので,

  • 単にデータを見たいだけ(≒SQLで事足りる)ならBigQueryのコンソール・Redashを使う
  • 機械学習や統計でちょっと凝ったことをする(≒コードを書かないと難しいタスク)ならColabを使う*3
  • ダッシュボード・レポート等の最終成果物をDataPortal, Redashで行う

といった感じで活用しています(+他のツール・サービスもよく使います*4).

ちなみに手段(ツール・サービスなど)の選択は原則として各人(社員のみならずインターンも対象)に任されています(メンターとしても必ずその話をしています*5).

なお, データの抽出・変換・出力, いわゆるETLについてはAirflow, prefect(一部Luigi)でWorkflowを組み立てて行っています.

ETLの詳細は以前のエントリーをご覧いただけると幸いです(本筋と離れるためこのエントリーでは特に触れません).

現状の課題とやりたいこと

ビッグデータ・データサイエンスのエコシステムの組み合わせでいい感じに分析したり成果出したりと一見すると順風満帆に見えるこの仕組ですが, 課題とやりたいことがいくつか存在します.

  • 現状の課題
    • notebookが散らかる. コード管理やシェアが各人に任されているため, チームの資産としての活用が難しい*6.
    • 分析コード・SQLのレビューが各人任せになっている. プロダクトのコードみたいなMerge Request(Pull Request)をベースとしたチーム作業にはなっていない.*7
    • アドホックなデータ分析・取得の依頼が増えた時の管理. できる人が限られている為ボトルネックになりがち.
  • やりたいこと
    • データマートの整備. 現状は「データレイクの生ログをいい感じにBigQueryにしました」的なデータを扱うことが多いためそれなりに難易度が高い*8.
    • ある程度の分析・可視化(select文の結果をピボットしてグラフ描く的な)をデータサイエンス・エンジニアな人以外にも開放. つまり民主化.

「やりたいこと」の話は後日機会あれば紹介ということで一旦さておいて.

データ分析に関わってるメンバー特に若い人やインターン達にとって不安要素になるかもしれない「コードレビュー」「notebookの管理」は無視できない問題です.

コードレビューとnotebook管理をいい感じにする

「コードレビュー」「notebookの管理」という課題に対して, 会社全体としてどうしていくか?...という問いについてはまだ答えが出ていないのですが.

私(@shinyorke)のチーム*9では,

  • notebookやSQLをモブプログラミング形式で作ったりレビューをしたりする
  • notebook, SQLはあくまで「中間成果物」と捉え, 敢えて管理を放棄する

という方策でやっています.

モブプログラミングの積極活用

f:id:shinyorke:20201109232209p:plain
モブプログラミングいいですよね

「チームで一つになってISSUEと向き合う」でお馴染みのモブプログラミングですが, これをデータ分析チームとして積極活用しています.

実際は対面じゃなくて, Zoomで一つの画面をシェアしながらやってます(みんなリモートしてる*10ので)

モブプログラミングのお題は「インターン生が書いたnotebook/SQLのレビュー」「メンターのshinyorkeがまとめたレポートのレビュー」などその日によって異なりますが大切なのは,

インターンや若い人が時折レビュアーに回る

ようなローテーションを回すようにしています.

私の場合, プログラミングそのものやシステム的・ビジネス的なスキル・知見は若い人よりありますが, 数学・統計・機械学習的なアプローチは現役で学んでいる学生さんの方が上だったりするケースも多々あるので「私もたまには見てもらう側に回る」ようにしています.

若い人やインターンの場合は「プログラミング・SQLの定石」「きれいなコードの書き方」「Pythonの便利ライブラリ」などが勉強になるケースが多いみたいです. つい先日は「コード・SQLをフォーマットして書くのが何故大事か?」という話題で30分くらい時間が溶けました*11.

この方式はそれなりに労力を使いますが, 成果物の最終チェックが進むだけでなく「関わった人の知見・スキルのシェア・勉強がはかどる」「個々人のノウハウがチームに還元される」のと何よりも楽しいので今後も継続していく感じになると思います.

notebookはあくまで「中間成果物」

そして 「散らかったnotebook」問題ですがこれについては,

すっぱり諦める

というお気持ちでやっています.

というのも,

  • notebookはあくまでもアドホックな「らくがき帳」「試行錯誤するためのサンドボックス」である*12
  • 「よっしゃこれはイケるぞ!」ってなった段階でちゃんとしたプロダクトのコード(テストコードを含める)に落とし込むべき
  • (notebookでdiffをとるソリューションはいくつかある*13とはいえ)神経質にdiffを気にするぐらいなら最初から管理そのものを放棄したほうが良い.

これらの定義・信念を考えると「わざわざ管理する方にもっていくのはまだ先でいいかな...」ってなります.

もちろん, 安易に消されたりやってくれた方が退職したりするとそれはそれで困ってしまうので,

  • 可能な限り社内限定でシェアする. 少なくともメンターの手元には残す.
  • 「もしかするとプロダクトコードになるかも」なスニペットはgitに残す

ぐらいはしています.

結び - レビューとメンタリングで大切なこと

というわけで,

分析者のコードレビューには「モブプログラミング」を, notebookの管理は「諦め」が肝心

という話を紹介させてもらいました.

もちろんこの両方が絶対的な答えじゃないです, もっといい方法・考え方があったらぜひフィードバックをいただけると幸いです.

このアプローチ・考え方に至るまで色々と試行錯誤をしたのですが確かなのは,

  • レビュアーはプロダクト・サービスの事に目を向けつつ, レビュイーの良い学び・気付きになるようなレビューに心がける
  • メンターはメンティーにとって「超える壁」になるようなタスクを提供し続け, メンティーの成長を支えること

という想い・思考が元になっています.

なお現在JX通信社では,

のインターンを募集しています!

※学生さん限定です🙇‍♂️

データサイエンスのコードレビューはこのエントリーで触れたとおりですが, エンジニア側もメンターやチームによっていいかんじにケアしたりしてるので興味ある方はぜひカジュアル面談にお越しください(フルリモートでもOKです!).

www.wantedly.com

www.wantedly.com

最後までお読みいただきありがとうございました.

*1:アカウント名(shinyorke)をUKの某バンドのボーカルから拝借する程度にUK Rock🎸好きな影響でいつもバンドTシャツ着てます(どうでもいい注釈).

*2:本文脈とは関係ないですが, 一部のデータはAWS AthenaだったりS3/GCSのストレージ上の生データです.

*3:後述の通り, Colabはマストって訳ではないので個々のPCで環境作ってやるケースもあります, 私が実はその派閥でして

*4:私の観測範囲ではGCPのAI Platformを使ったり, まだお試し中ですがstreamlitを使うこともあります.

*5:弊社はデータサイエンスのみならず, 通常の開発でもPythonを使うことが多く分析業務のデファクトスタンダードもPythonですが中にはRでタスクをこなしてバリューを出しているメンバーもいます.

*6:よくやる処理とか定石が車輪の再発明になってる的な意味での「資産活用が難しい」という話です

*7:余談ですがFASTALERTやNewsDigestといったプロダクトはこの辺しっかりやってます.

*8:正規表現頑張ったりJSONをParseしたりとかそういうレベルの難易度の高さ. もちろん仕様の話もあります.

*9:言い方を変えるとこのアプローチは会社オフィシャルでテンプレート化しているわけではないです&したいなっていう欲はあります.

*10:これはこれで苦労もありますがシフトや働く場所の成約が物理出社の時と比べて334倍以上の自由度があるので良いことのほうがおおい気がします. 首都圏以外の学生さんもインターンとして定期的に入れるとか.

*11:どのツールを使うか, 他社さんでどうやってやっていたか?という話題でもちきりになりました. お互いを知る意味でも有意義な30分でした

*12:notebookをバッチなどの定期実行処理にそのまま使うやり方もあるらしいですが個人的にはこのアプローチはとったことないです.

*13:nbdimeとかが代表的な手段なんですかね?他にもありそう.

Jamstack とサーバーレスで提供する「大阪都構想」特設サイトの舞台裏

開発担当役員(CDO)の小笠原(@yamitzky)です。

11月1日に予定されている「大阪都構想」の住民投票*1についての特設サイトを、先日、ABCテレビと共同リリースしました。JX独自開発のオートコール電話情勢調査システムを活用し、週一ペースでの情勢調査を発表するなど、今までにない取り組みを行っています。

www.asahi.co.jp

より詳細なデータは、ニュース速報アプリ「NewsDigest」でも配信しています。

なんだか宣伝っぽい導入になってしまいましたが、今回は NewsDigest の「大阪都構想」特設ページの技術的な舞台裏をご紹介します。

インフラ全体像

まず、特設サイトのインフラ概略図は次のようになってます。

f:id:yamitzky:20201029164459p:plain
インフラ全体

サイト全体としては、Next.js という React のフレームワークを使っています。特徴的なのは、

  • Next.js を Lambda@Edge を使ってデプロイ
  • 自作 Headless CMS と Firebase を活用した Jamstack 風なアーキテクチャ
  • Fargate を使ったワードクラウドの自動更新

など、最新技術をうまく組み合わせながら構築しています。

Next.js のデプロイ

サイトのデプロイには、 serverless-next.js を使っています。

github.com

serverless-next.js は Serverless Framework のプラグインで、非常に簡単に AWS のサーバーレスな環境へのデプロイが行えるものです。

serverless-next.js でデプロイをすると、次のような構成になります。

  • CloudFront(CDN) が前段に立つため、キャッシュ時間なども調整できる
  • API や SSR *2 は Lambda@Edge でハンドリングされる
  • SSG *3も Lambda@Edge でハンドリングされるが、事前に S3 に保存されている
  • 静的ファイルは S3 から配信

かなりアクティブに開発されており、比較的最新の Next.js の仕様*4にも対応しているのですが、 Incremental Staic Regeneration (自動での SSG の再更新)には対応していません。GitHub の Issue でも議論はされていますが、そんなに CloudFront × サーバーレスな構成と相性が良くないようにも感じます*5

Headless CMS によるサイト更新

今回、ABC テレビのサイト側での配信が静的なものだった*6ため、 SSG を前提とした構成にしました。一方で、週一で更新する情勢調査の解説コメントや、ユーザーからの質問受付など、動的な要素も含んでいました。これらのコンテンツ更新には CMS が欠かせないですし、同時に、コンテンツ更新のたびに HTML/JS を更新したくない、という課題がありました。

そのため、Headless CMS を使い、Jamstack な感じの構成 にしています。

f:id:yamitzky:20201029190634p:plain
Jamstack構成

Headless CMS とは、WordPress のようなコンテンツ管理と配信がセットになった CMS ではなく、コンテンツ管理だけができるような CMS を指しています。今回の場合、配信部分は Next.js が担っていることになります。Headless CMS は、 配信するページにコンテンツ管理機能が紛れ込まないのでセキュアである、というメリットもあります。

今回、Headless CMS には、小笠原が個人開発しているサーバーレスな Headless CMS を導入 しました。この CMS *7 は、データベースに Firebase を使っているので、リアルタイム&安価&柔軟に連携できます。

serverless-headless-cms.vercel.app

ワードクラウドの自動更新

特設サイトでは、週1の情勢調査だけではなく、ワードクラウドを毎日更新しています。

f:id:yamitzky:20201029130720p:plain
特設サイト内ワードクラウド(左)

こちらは Python 製のプログラムを Docker 化し、Fargate を CloudWatch Events 経由で動かして S3 に保存しています。また、インフラ構成管理には Terraform を使っています。

Flourish によるデータの可視化

情勢調査の結果を大阪市民の方々にわかりやすくお伝えするため、 Flourish でデータの可視化を行いました。Flourish は Google Sheets をデータソースとしてグラフを自動更新できるなど便利です。

flourish.studio

ワードクラウドとデータ可視化に関しては、データ×ジャーナリズムを担当している阪神ファンの衛藤さん主導で作っています。

まとめ

振り返ってみると、 特設サイトは開発開始から1週間半程度でリリースすることができました。OSS を利用*8して、サーバーレスを活用しながら、JX通信社のバリューを体現できたかなと思います。また、今回もインターン生に多大なご協力をいただいております。

www.wantedly.com

www.wantedly.com

大阪市民の方、ぜひ特設サイトをご覧になって、11月1日の住民投票にお役立てください。

www.asahi.co.jp

*1:いわゆる「大阪都構想」とは、大阪市を廃止し4つの特別区に再編する構想です

*2:サーバーサイドレンダリング。リクエスト時に動的な HTML を生成する

*3:静的サイト生成。リクエスト時に動的な HTML を生成するのではなく、事前に HTML を生成しておくもの。ただし、動的にはならないので、ISR という仕組みが Next.js では提供されている

*4:redirect などは、Next.js 9.5 の仕様です

*5:Vercel のインフラがどうなっているのか気になります

*6:HTML と JavaScript を提供し、静的ページとして配信しています

*7:まだ名前がないので名前案ください

*8:少額ではありますが、serverless-next.js に寄付しました

GitHub Actionsで実現する、APIキー不要でGitOps-likeなインフラCI/CD

※ 今はGitHub ActionsでOIDCが使えるので、本記事の内容は少し古いです。*1 現場のルール等で「インフラを触るワークロードはオンプレでしか動かしてはならない」みたいなルールがある場合には多少参考になるかと思います。

SREのたっち(@TatchNicolas)です。

JX通信社では「インフラチーム」のようなものは存在せず、開発したチームが運用までやるFull-cycleなスタイルを取っています。AWS・GCPリソースの管理も特定のメンバーが担当するのではなく、必要とする人が必要な時に作成・修正等を行います。すると、terraformなどIaCのツールを利用する場合に「今リポジトリにあるコードは実態を正しく反映しているのか」「誰かが矛盾する変更を加えていないか」という問題が発生します。

CIツール上でterraformを実行することで、問題の一部は回避できるかもしれませんが、CIにSaaSを利用している場合、クラウド上のリソースを操作する強い権限を持ったAPIキーを外部に預けることになってしまいます。

GCPのCloudBuildを使った解決策 *2、AWSのCodeBuildを使った例*3はそれぞれあるのですが、せっかくterraformをつかうならできるだけ同じ仕組みに載せたいところです。

そこで、「CIの仕組みとしてはSaaS(今回はGitHub Actions)を利用しつつも、実行場所はクラウド内にとどめることでGitOpsの考え方を一部取り入れたterraform運用のしくみ」を作ってみました。

TL;DR;

f:id:TatchNicolas:20201005152126p:plain
GCPの場合

f:id:TatchNicolas:20201005152147p:plain
AWSの場合

  • Self-hosted runnerを使えば、GitHubにクラウドの認証情報を渡さなくともGitHub Actionsでterraformを実行できる
  • Pull Request作成でplan、masterへのマージでapplyして、また定期的にmaster最新コミットでplanして乖離をチェック することでGitOps-likeに運用できる *4
  • 発想としては単純で「GCP Service AccountまたはAWS IAM Roleを使ってCIのRunnerにterraform実行に必要な権限を与える」なので、GitLab.comでも同じようにできるはず *5

やってみる

GKE(GCP)・EKS(AWS)にて、実際にやってみます。 AWS・GCPの権限を引き受けることができれば良いので、k8sではなくEC2・GCEでも実現可能なのですが、できるだけクラウドベンダごとの違いがでないようにk8s上のPodとして動かしました。

  • GKE・EKSクラスタは構築済みの前提
  • RunnerにGCP・AWSの強い権限を持ったAPIキーを渡さない
    • GKE・EKSの機能を使ってAPIキー自体を発行しないで権限をRunnerに付与する
  • 定期的にplanを実行して、masterの最新commitと食い違いを検知する

1. Runnerをつくる

今回は下記の記事の中で紹介されている gh-runners/gke at master · bharathkkb/gh-runners · GitHub を参考にしました

github.blog

気を付けるポイントとしては、

  • あとで hachicorp/setup-terraform を使うために、unzipをDockerイメージにインストールする必要がある
  • 権限付与の動作確認用にgcloudやaws cliなども追加しておくと便利かもしれません

Podが削除されるときにGitHub上のからRunnerを除名する処理をシェルスクリプトで書いて、preStop のlifecycleで実行するようにしても良いでしょう。

apiVersion: apps/v1
kind: Deployment
metadata:
  namespace: github-runner
  name: github-runner
  labels:
    app: github-runner
spec:
  replicas: 1
  selector:
    matchLabels:
      app: github-runner
  template:
    metadata:
      labels:
        app: github-runner
    spec:
      serviceAccount: github-runner
      containers:
      - name: runner
        image: gcr.io/your-project-name/github-runner
        env:
        - name: REPO_OWNER
          value: "your_github_name"
        - name: REPO_NAME
          value: "your_repo_name"
        - name: GITHUB_TOKEN
          valueFrom:
            secretKeyRef:
              name: name-of-secret
              key: GITHUB_TOKEN
        lifecycle:
          preStop:
            exec:
              command: ["sh", "-c", "'./remove.sh'"]

ちなみにPodが削除されたまま放っておいても、GitHub側で30日経てば自動で取り除いてくれるようです。しかしお片付けはきちんとしたほうが気持ちが良いでしょう。

A self-hosted runner is automatically removed from GitHub if it has not connected to GitHub Actions for more than 30 days.

About self-hosted runners - GitHub Docs

また、今回は使いませんでしたがRunnerの登録/削除などを良い感じに管理してくれる素敵なControllerもありました。

github.com

2. Actionを定義する

HashiCorpのチュートリアルにGitHub Actionsを使った自動化の例 があるので、そちらを参考にすれば環境やチームのリポジトリ構成に合わせてすんなり書けると思います。

learn.hashicorp.com

また、追加で定期的にplanを実行して、tfファイルと実態の乖離を検知するworkflowを定義してみます。

検知の方法は色々あるとおもいますが、planをファイルに書き出して、jsonとしてterraform showしたものをjqでパースして判断してみます。今回はついでに例として、乖離があったらIssueを作成してみます。

下記の例の通りやると乖離が解決されない限り重複するIssueを作成しつづけてしまうので、cronの頻度を落としたり通知先をGitHub IssueではなくSlackにする、復帰のためのapplyまで実行してしまうなど、チームの方針に合わせて調整してみてください。

name: check-drift

on:
  push:
  schedule:
    - cron:  '*/10 * * * *'

jobs:
  check-drift:
    runs-on:
      - self-hosted
    steps:
      - uses: actions/checkout@v2

      - uses: hashicorp/setup-terraform@v1
        with:
          terraform_version: 0.13.2

      - name: Terraform Init
        id: init
        run: terraform init

      - name: Terraform Plan
        id: plan
        run: terraform plan -out tfplan

      - name: Print the result of terraform plan as json
        id: print_json
        run:  terraform show -json tfplan

      - name: Check configuration drift
        id: check
        env:
          PLAN_JSON: ${{ steps.print_json.outputs.stdout }}
        run: |
          DIFF=$(echo "$PLAN_JSON"| jq -c '.resource_changes[] | select(.change.actions!=["no-op"])')
          if [ -z "$DIFF" ]; then
            echo 'No configuration drift detected'
            echo '::set-output name=STATUS::synced'
          else
            echo 'Configuration drift detected!'
            echo '::set-output name=STATUS::drifted'
            terraform show tfplan -no-color
          fi

      - uses: actions/github-script@0.9.0
        id: show_plan
        if: steps.check.outputs.STATUS == 'drifted'
        env:
          PLAN: "terraform\n${{ steps.plan.outputs.stdout }}"
        with:
          github-token: ${{ secrets.GITHUB_TOKEN }}
          script: |
            const output = `\`\`\`${process.env.PLAN}\`\`\``;

            github.issues.create({
              owner: context.repo.owner,
              repo: context.repo.repo,
              title: "Configuration drift detected",
              body: output
            });

3. Runnerに権限を渡す

GCPのservice accountまたはAWSのIAM roleと、k8s service account(KSA)の紐付けをするterraformのサンプルコードを示しておきます。どちらも github-runner というk8s namespaceにrunnerをデプロイすることを前提としています。 *6

GCPの場合

GKE上のPodにGCPリソースの操作権限を渡すには、Workload Identityを使います。

resource "google_service_account" "github_runner" {
  account_id   = "github-runner"
  display_name = "github-runner"
}

resource "google_project_iam_member" "github_runner_owner" {
  role   = "roles/owner"
  member = "serviceAccount:${google_service_account.github_runner.email}"
}

resource "google_service_account_iam_member" "github_runner_wi" {
  service_account_id = google_service_account.github_runner.name
  role               = "roles/iam.workloadIdentityUser"
  member             = "serviceAccount:${var.project_id}.svc.id.goog[github-runner/github-runner]"
}

紐付けるKSA

apiVersion: v1
kind: ServiceAccount
metadata:
  name: github-runner
  namespace: github-runner
  labels:
    app: github-runner
  annotations:
    iam.gke.io/gcp-service-account: github-runner@your-project-id.iam.gserviceaccount.com

AWSの場合

EKS上のPodにAWSリソースの操作権限を渡すには、こちらのドキュメントを参考にします。

resource "aws_iam_role" "github_runner" {
  name               = "GitHubRunnerRole"
  assume_role_policy = <<EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "${aws_iam_openid_connect_provider.this.arn}"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          "${replace(aws_iam_openid_connect_provider.this.url, "https://", "")}:sub": "system:serviceaccount:github-runner:github-runner"
        }
      }
    }
  ]
}
EOF
}

resource "aws_iam_role_policy_attachment" "github_runner" {
  role       = aws_iam_role.github_runner.name
  policy_arn = "arn:aws:iam::aws:policy/AdministratorAccess"

}

紐付けるKSA

apiVersion: v1
kind: ServiceAccount
metadata:
  name: github-runner
  namespace: github-runner
  labels:
    app: github-runner
  annotations:
    eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/GitHubRunnerRole

動かしてみる

実際にPull Requestを作成してみると、下記のようなコメントが自動でつきます。 show plan のところをクリックすれば terraform plan した内容が確認できます。 確認できたらマージすると、applyが実行されます。

f:id:TatchNicolas:20201005065742p:plain
自動でplanの結果がコメントされる

また、定期的にplanして定義と実態の乖離を検知すると、Issueが作成されます。

f:id:TatchNicolas:20201005075351p:plain
自動で作成されたIsuue

まとめ

得られたメリット

  • GCP・AWSの強い権限をもったAPIキーをGitHubに預けなくてよくなった
  • AWSでもGCPでも、仕組みの作り方がほぼ共通で利用・メンテナンスがしやすい
    • クラウドベンダごとの違いはterraformとk8sで吸収
  • terraformを運用者の手元で実行する必要がなくなった
    • 「これって反映済み?どれが実態?」「planが食い違ってしまった、今誰か作業してる?」が発生しなくなる
    • tfenvに頼らなくてもterraformのバージョンを指定して実行できる
  • terraformの定義と実態の乖離を検知できるようになった

注意点・残った課題

  • GitHubのself-hosted runnerはリポジトリ単位か組織単位でしか設定できない
    • 組織でterraform用runnerを共有すると、組織以下のすべてのリポジトリがCIでAWS・GCPの強い権限を利用できてしまう
    • GitLabにはgroup単位のrunnerを設定できるようですが、GitHubにはそのあたりを細かく設定できなさそうなのでもうひと工夫する必要がありそう
  • masterへpushできる人は(アプリケーションでもそうですが)しっかり管理する必要がある
    • masterへpushできる人は(与えた権限の範囲で)Runnerを通じて何でも出来てしまうため
    • 同じ理由でrunnerを動かしているk8sクラスタ上でのPodへの権限も、きちんと管理する必要がある
  • HashiCorpのチュートリアルにあったworkflow定義では、Pull Request作成時にplanした分はファイルへ書き出さず、マージ時に改めてapplyしているので、Pull Requestコメントに書き出したplanと実際に行われるapplyの内容が異なる可能性がある
    • planした結果を何処かへ保存して、その内容を参照するようにworkflowを改善できるかも

チームの体制やポリシーによって、持たせる権限やRunnerの運用方法には工夫が必要ですが、「credentialを外に出さない」「GitリポジトリをSingle Source of Truthにする」「乖離を検知する」というGitOpsの特徴を持ったterraform運用が実現できました。少しでも参考になれば幸いです。

*1:https://github.blog/changelog/2021-10-27-github-actions-secure-cloud-deployments-with-openid-connect/

*2:https://cloud.google.com/solutions/managing-infrastructure-as-code

*3:https://medium.com/mixi-developers/terraform-on-aws-codebuild-44dda951fead

*4:GitOpsの定義は https://www.weave.works/technologies/gitops/https://www.gitlab.jp/blog/2020/09/03/is-gitops-the-next-big-thing-in-automation/ など揺らぎがあるので、今回はその一部である「権限を外に出さない」「GitリポジトリをSingle Source of Truthとする」を取り入れつつもsyncはCI上で行っていることからGitOps-"like"としました

*5:調べてみると、事例がありました 絶対に止められない超重要サービスをGitOpsで安全に開発できるようにしている話

*6:本記事ではroles/ownerやAdministratorAccessといった強い権限を渡していますが、実際にはチームの方針にあわせて調整してください。

リモート下でチームのコミュニケーションを円滑にするための試み

こんにちは、サーバーサイドエンジニアの @kimihiro_n です。

新型コロナの影響で、オフィスに出社して働くというスタイルに大きな変化が生じてきています。 弊社でも原則リモートでの勤務が推奨となっており、従来通りのコミュニケーションを続けることが困難になってます。 今回はコミュニケーションを活性化するために、チームでどのような工夫をしているかを紹介したいと思います。

リモートワークだとなにができないか

最初にリモートワークへ移行して感じた課題を挙げてみます。

気軽な話しかけができない

f:id:nsmr_jx:20200821210659p:plain:w160

一番違いを感じるのはこの点でした。 仕様の相談だったり、困ったときにちょっと質問するみたいなことは、物理でオフィスに集まっているときは気軽に出来ていました。相談しやすいようにチームごとに座席が近くなるよう席替えしたりして、できるだけコミュニケーションの障壁となるものを廃していました。

しかし、リモートワークでお互いが別々の場所にいるとなると、話しかけるという行動が難しくなります。ボイスチャットをするために話し相手と通話部屋を都度用意し、お互い通話可能なタイミングを調整するというのは結構コストのかかる行為です。 忙しくなさそうなタイミングを見計らって隣の人に声をかけるのと比べるとどうしてもためらいがちになります。

空気感が伝わらない

f:id:nsmr_jx:20200821210319p:plain:w160

音声でのコミュニケーションのハードルが上がった結果、Slack などの文字ベースでのコミュニケーションが主流になってきます。文字として記録が残るのでメリットもあるのですが、話し手の意図が伝わりづらいというデメリットもあります。 対面レビューと比べて指摘の語気が強くとられてしまったり、やってもらったことに対しての感謝が伝えづらかったりします。

また 音声 VS 文字 だけでなく、実際の会話 VS 通話越し であっても空気感の伝わりづらさというのは感じています。 物理的に空間を共有して会話するというのはやはり強いですね。 みんなでOKR決めようとかブレストしようみたいな場合、やはりビデオ通話より会議室に集まってやった方が楽しいです。

顔を見ないとメンバーの調子が分からないという面もありますね。 出社していた頃は多少冗長でも朝会で集まって各自タスク共有をしてたりしました。

ランチ行けない

f:id:nsmr_jx:20200821210455p:plain:w160

チームの人を誘ってランチ行くことが多かったのですが、リモートだとこれが出来なくなりました。 業務に関係ないゲームの話とかをするのは昼休みの時間がうってつけだったのですが、リモート化が進むにつれこういった話がしにくくなりました。

シャッフルランチというランダムなメンバーでご飯いくという制度もあったのですが、こちらもコロナの影響で実施できていない状況です。

あと単純に神保町のおいしいランチが食べられないのは期待損失が大きいです。

そのほか

  • 同時に複数人が話すと聞き取れない
  • 通話環境に左右されやすい
  • MTG がスムーズに開始できない
  • 打ち上げができない
  • 業務後ゲーム(ボードゲーム、スマブラ)ができない

などなどリモートならではの問題が生じてきています。

チームで取り組んでること

上記の問題すべてを解決することは出来ないですが、少しでもコミュニケーションを円滑にするためチームで取り組んでることを紹介していきます。

朝会

f:id:nsmr_jx:20200905145210p:plain:w160

出社時にやっていた朝会はリモートでも毎日継続してやるようにしました。 最初は音声のみでやっていたのですが、新しいメンバーが入ってきて「顔見ながらやりたい」という意見があったため、朝会はカメラありで実施するようにしています。やはりメンバーの顔色が分かった方が安心感がありますね。

ツールとしては Zoom をつかってビデオ通話しています。Zoom の場合バーチャル背景の機能が組み込まれているため、周囲の状況を気にせずビデオ通話ができます。ビデオ通話するのに部屋を片付けたり洗濯物をどけたりする必要がなくて便利です。

朝会の内容としては当日やるタスクの確認と、相談・周知したいことの共有をしています。

常時通話

f:id:nsmr_jx:20200905145758p:plain:w160

気軽に話しかけるのが難しいことの対策として、常時通話状態にしておくというのをやってみました。 都度音声通話に来てもらうのではなくて、話せばチームにすぐ声をかけられる仕組みですね。 ツールとしては Discord というツールを使ってます。

Zoom とちがって気軽に音声通話の部屋を移動できるので、相談したいからこっちの部屋で、とか集中モード( or 休憩中)だから話しかけないでみたいなことがサッとできます。画面共有もできるので後述するペアプロとかも Discord をつかってやっています。 通話のハードルが下がったことで気軽な相談がしやすくなったように思います。1対1のつもりで話す内容でもグループ通話で聞いてる人が増えることで思わぬ助け舟が降ってくることもあります。 ただ他のチームから何をやっているかが見えづらいという意見ももらったので、文字に残すべきもの(仕様や決定事項)はちゃんと文字に残こすよう注意すべきだなと思いました。

ペアプロ・モブプロ

f:id:nsmr_jx:20200905144529p:plain:w160

ちょうど1年前くらいからチームでペアプロ、モブプロを導入し始めました。 PCを操作してコードを書く人(ドライバー)と、画面をみて指示を出す人(ナビゲーター)に分かれて一緒にコーディングを進めていく手法です。 個別に実装を行ってあとからレビューする形式と比べて出戻りが少なく、メンバーの知見を共有しつつ開発を進められるので大きな機能を実装するときによく活用しています。

出社してモブプロするときは、1台のモニタ使ってみんなで見ながらナビゲートする形で進めていました。 今にして思うとなかなか密な作業環境ですね。 リモートになってからは、Discord の画面共有機能を主につかってモブプロを実施しています。メンバーが Discord で待機していることもあり、モブプロで進めようとなったときはスムーズに開始することができます。 ただやはりオフラインでやっていた頃と比べると、ワイワイと実装を進めている感覚が乏しくなったように思います。3人以上のモブプロだと、モニタ越しにドライバーが実装していくのを見守るだけということになりやすく、複数人が参加するシナジーが得られにくいです。そのため最近はペアで作業することが多くなってます。

ティータイム

f:id:nsmr_jx:20200905145545p:plain:w160

最近導入してよかったのがこのティータイム制度です。 リモート下で「雑談が少ない」という振り返りから、意図的に雑談する時間を業務内に用意することにしてみました。

毎日16:00から15分間、Discord に集まって雑談するというだけなのですが、これが結構チームの健全性に貢献しているように思います。ランチや飲み会といった業務外の話ができる場がなくなってしまったので、こういったブレイクタイムを意図的にもたせることは大事だなと感じました。特にインターンで来てくれている方ともお話できるのが大きいですね。やってもらっているタスクによってはメンターとメンティの1対1のやりとりで完結してしまうことがあるのですが、こういった雑談タイムに参加してもらうことで、お互いを知る機会を作れています。

ちなみに最初は「おやつタイム」という名称だったのですが、16時に間食すると夕飯に支障がでるという理由からティータイムへと変更になりました。

物理出社

f:id:nsmr_jx:20200905145621p:plain:w160

リモートが続いてしんどくなってきたときの最終手段です。 リモート推奨下でもオフィスは空いているので、一人での作業が堪えてきたら出社することもできます。 自分のチームでもだいたい週0.5〜1くらいの頻度でバラバラと出社している印象があります。 出社するとチーム外の人とも会話できるのでいい気分転換になります。 やはり人と実際に話せる場というのは大切なのかもしれませんね。

KPT

f:id:nsmr_jx:20200907105305p:plain:w160

KPT という振り返りのミーティングを毎週実施しています。 Keep(継続すべきよかったこと)とProblem(課題に感じていること)を共有して、Try(改善策)を続けていくやりかたですが、こちらもリモート前から継続してやっています。 形式としてはおのおの事前に GoogleDoc へKとPを書いておき、MTGの際に「前回のTはどうだったか」「K、Pの共有」「今回のTの決定」を話しています。

「朝会でカメラオンにしよう」「ティータイムつくろう」みたいな試みはこのKPTの中で生まれました。 継続的な振り返りを通して試行錯誤を続けていたことで、フルリモートへの移行のような大きな変化でも柔軟に適応できたように思います。

おわりに

以上がチームでやっているリモートのコミュニケーション施策でした。 これからもより良いリモート開発体制を求めて試行錯誤していきたいと思ってます。