OTOBANK Engineering Blog

オトバンクはコンテンツが大好きなエンジニアを募集しています!

勉強会紹介: Mastodonについて

こんにちは。スマホ向けアプリを担当している けいま と申します。以後お見知りおきを。

オトバンクでは毎週、弊社エンジニア全員が集まる場で社内勉強会をしております。 engineering.otobank.co.jp

今回は同じくアプリエンジニアの s4kr4 さんが発表した "Mastodonについて" を紹介します。


Mastodonについて

f:id:p-side:20190708180859p:plain

2016年公開のミニブログシステム。TweetDeckに似たUIを持っています。 短文投稿をすることが目的のシステムです。 ユーザーをフォローすることによりタイムラインが形成でき、リプライやDMでコミュニケーションを取ることが出来ます。

ちなみに「マストドン」は絶滅した動物(象の祖先)の名前とのこと。

特徴

Mastodonは特定(単一)の企業が運営しているサービスではありません。 ソースコードやデプロイ方法が公開されており、誰でもサーバーを建てることが出来ます。

国内では mstdn.jp や Pixiv運営の pawoo.net、Qiitaが運営しているqiitadon.com などがあります。 かつてドワンゴが運営していた friends.nico もありましたが閉鎖されました...

アカウントを作成する際はどのインスタンスに所属するかを決める必要があります。 それはSlackのworkspeceに似ているかも知れません。

どこでアカウントを作っても、異なるインスタンス間でのコミュニケーションもできるようになっています。

技術要素

nginxがWebサーバーとして採用されている。 HTTPS転送やメディアファイルへのリクエストにキャッシュ要求ヘッダを追加するなど。

サーバー処理としてRailsが採用されており、Mastodonの主要部です。 ストリーミング以外のAPI提供をしています。

StreamingAPIの提供にNode.jsを採用しています。

データベースにはPostgreSQLを採用しています。

フロントエンドはReactで記述されています。

画像やユーザーアイコン、カスタム絵文字の保持にAWS S3かOpenstack Swiftが使えるようになっています。 (なおOpenstack SwiftはS3よりもちょっと安いらしい)

ジョブハンドラにsidekiqが採用されており、非同期処理のキューイングやリトライ処理で使われます。 小規模サーバーでユーザーが増えたときに重くなる原因になることが多いようで、 登壇者のアカウントを作成したサーバーにおいても、ユーザーが増えて重くなったので鯖主がsidekiqの対応をしていたようです。

ActivityPub

ActivityPubは、W3Cで標準化された非中央集権型のソーシャルネットワークプロトコルです。

異なるインスタンス間でどうやってコミュニケーションを取るのか?を定義するもので、Mastodonはその実装の1つです。

概念としては以下の2つがあります。

outbox: 自分が起こしたアクティビティ(トゥート, ブースト, フォロー)が集積されているコレクション inbox: 自分が受け取ったアクティビティが集積されているコレクション

outboxのアクティビティが一定のルールに応じて他のインスタンスユーザーのinboxに送信されます。 リプライを例に説明すると、 リプライを送ったユーザーのリプライがoutboxに蓄積され、リプライのActivityが相手のinboxに届き、 リプライされた側のユーザーがリプライを受けたことを気づくことができる

質疑応答のコーナー

  • sidekiqってRuby製?
    • Yes
  • 運用難しいって聞いたけど?
    • スケールさせるのが難しいらしい

まとめと雑感

Mastodonは私(keima)もTwitterからの避難地としてアカウントをひとつ持っています。 (現在はからあげクン新商品のレビューアカウントですが・・・)

私はMastodonそのものよりもActivityPub実装に関心があり(そのくせ自分で実装する根性はないのですが・・・)、 より広く使われるようになる未来が来ることを願っています。

個人的には既存サービスのインターフェースとしてActivityPub実装がなされるようになるかと期待したのですが、 そのようなムーブメントは起きず、ActivityPubを実装したシステムが産まれるに留まったのは物足りなく思いました(それだけでも凄いことですが)。

現在においてメールアドレスを人格ごとに1つ以上持つ(例えば個人のGmailアドレスとは別に会社のメールアドレスをもつ)ように、 ActivityPubプロトコルで人格ごとに発信をする未来が理想ですが、流石に夢見すぎかなと我ながら思ったりもしました。

というわけで今回はこの辺で。また来週 ノシ

BEAR.Sunday を GAE flex 環境で使う際の tips

このようなことを偉そうにもつぶやいてしまったので、この記事を書く運びとなりました。

改めましてこんばんわ @kalibora です。

弊社では BEAR.Sunday を Google App Engine の Flexible Environment(以下 GAE)上で動かしているのですが、 実はどーにもこーにもずっと解決出来ない問題がありました。

それはGAEのオートスケールでインスタンスが増える際に、下記のような Ray.DI 起因だと思われるエラーがたまに出る。 というものです。

Dummy\Exception(Argument 1 passed to Dummy\Foo::__construct() must be an instance of Dummy\Bar, integer given, called in /app/var/tmp/prod-hal-api-app/Dummy_Foo-.php on line 5)

一部ファイル名やパスを書き換えていますが、 Ray.DI に生成を任せているクラスの引数に、期待通りのクラスではなく integer が与えられた。という内容のエラーです。

なぜこの様な事が起きるのか詳細は理解できていないのですが、

(本番環境では同時に多数のアクセスが来て、最初のアクセスでオンデマンドにコンパイルしている最中に、また次のリクエストが来て・・・という具合でエラーになるのでしょうか?)

プロダクション | BEAR.Sundayデプロイ の項目には下記のような記述があります。

セットアップを行う際にvendor/bin/bear.compileスクリプトを使ってプロジェクトをウオームアップすることができます。

コンパイルスクリプトはDI/AOP用の動的に作成されるファイルやアノテーションなどの静的なキャッシュファイルを全て事前に作成します。

全てのクラスでインジェクションを行うのでランタイムでDIのエラーが出ることもありません。

また.envには一般にAPIキーやパスワードなどのクレデンシャル情報が含まれますが、内容は全てPHPファイルに取り込まれるのでコンパイル後に消去可能です。コンパイルはdeployをより高速で安全にします。

ということで、本番環境ではコンパイルすべきだということが分かりました。

では GAE 環境へのデプロイではどのタイミングでコンパイルすべきでしょうか?

GAE のデプロイ

そもそも GAE のデプロイの流れを理解していないといけないので少し内部の挙動を追いました。

gcloud app deploy コマンドを打つと、内部では下記のような処理が走るようです。

  1. app.yaml の skip_files なども加味しつつ ソースファイルを src.tgz に固めて Google Cloud Storage にアップロード
  2. Google Cloud Build でビルド(docker イメージを生成)
  3. デプロイ(生成された docker イメージを使って新バージョンを立ち上げ)
  4. プロモート(新バージョンにトラフィックを切り替え)

2.3. のタイミングでコンパイルできれば良さそうですが、 3. の際にカスタム処理を入れる方法は私の方では見つけられませんでした。

2. のタイミングでは composer install 処理が走るので、 composer の post-install-cmd でコンパイル処理を追加すれば、 生成される docker イメージに vendor ファイルと共にコンパイルされたファイル(tmp/{context}/...)も追加されるので都合が良さそうです。

ですが、ここでいくつか自分たちが遭遇した罠(というか勝手に自分でハマっただけですが)がありました。

罠1. Cloud Build 時に includes している環境変数は使われない

GAE の app.yaml では includes を使って env_variables を別のファイルに書くことも可能で、共通の環境変数はそこで定義していたのですが、 これは Cloud Build 時に使われませんでした。

これに関しては下記のソースコードを見ると分かります。

https://github.com/GoogleCloudPlatform/php-docker/blob/master/builder/gen-dockerfile/src/Builder/GenFilesCommand.php

ということで、 includes は使わないことにしました。

罠2. Doctrine2 が勝手にコンパイル時にDB接続しに行く罠

さて、これで解決かと思いきや Doctrine2 ユーザーへの罠もありました。

DI のコンパイル時に本番環境のデータベース(Cloud SQL)に接続しに行こうとして、 Cloud Build 環境では接続出来ずにエラーになる。

というものです。

実際にデプロイされたGAE環境ではもちろん本番データベースに接続できるものの、Cloud Build環境では接続できないようなので(普通接続する必要はないので問題ない)どうしたものかと悩みましたが、

結局この原因は Automatic platform version detection という接続先のデータベースを自動で判定する機能が原因だったので、 この機能をやめ、明示的に接続先のプラットフォームを指定するようにしました。

罠3. src の タイムスタンプがコンパイル時と docker イメージ作成時で異なる?

ここまでで、Cloud Build 時に生成される docker イメージにコンパイルされたファイルを含めることができました。

これで最初のデプロイ時でもオートスケールでインスタンスが増えた際でも、コンパイル済みのファイルが最初から用意されていることになります。 (最初のリクエスト時にコンパイルで待たされることはありません!エラーも出ない!)

と思いきや、もう1つ遭遇した罠がありました。

プロダクション | BEAR.Sundayコンテキスト の項目には下記のような注意書きがあります。

重要: プロダクションではディプロイ毎に$appを再生成する必要があります。

$app を再生成するには src/ ディレクトリのタイムスタンプを変更します。 BEAR.Sundayはそれを検知して $apptmp/{context}/di のDI/AOPファイルの再生成を行います。

これは逆に言えば

src/ ディレクトリのタイムスタンプが変わると $apptmp/{context}/di のDI/AOPファイルが再生成される。

ということになります。

せっかく作った tmp/{context}/di のファイルを消して再生成したくはないので、 コンパイル時の src/ のタイムスタンプと docker イメージ内に含まれる src/ のタイムスタンプが同じでないといけません。

ですが私が試したところ、どうも1秒くらいズレるようです。 この原因や根本的に合わせる方法は分かりませんでした。

ですので、 Bootstrap クラスを独自のものに差し替え、そのクラスでは src/ のタイムスタンプを直接見るのではなく、 タイムスタンプを保存したファイルがあれば、それを見るようにしました。 (コンパイル時に src/ のタイムスタンプをファイルに出力しておく)

これで GAE デプロイ後も /tmp/{context} ディレクトリが削除されることなく、使い続けることができるようになりました。

まとめ

  1. composer の post-install-cmdvendor/bin/bear.compile を呼ぶようにする
  2. Cloud Build 環境と実際にデプロイして動作する環境の差異に注意する
    • Cloud Build 時は includes した環境変数が使えない
    • データベースに接続できる/できない
  3. デプロイ後にDI関連のファイルが再生成されないようにする
    • 自分は src/ のタイムスタンプを固定したが、単純に tmp/{context}/.do_not_clear ファイルを配置するだけでよかったかもしれない(未検証)

はい。そんな感じで現場からは以上です。

Guzzleにキャッシュやリトライは任せちゃおう

曇天が続きますね。 @kalibora です。

今日は小ネタを書かせていただきます。

PHPer のみなさんは HTTP クライアントは Guzzle を使うことが多いと思います。

昔は curl をそのまま使うことも多かったと思いますが、今はあまりそういう状況も少なくなってきたのではないでしょうか。

さて、そんな Guzzle ですが、 Middleware を使っている方はどれくらいいますでしょうか。

Middleware は Guzzle を機能強化する仕組みなんですが、僕が使って便利だなと思ったものを2つばかり紹介したいと思います。

1. kevinrob/guzzle-cache-middleware

kevinrob/guzzle-cache-middleware - Packagist

RFC 7234 に準拠した HTTP Cache の機能を追加するミドルウェアです。

むしろこれを入れないと、いくらAPIなどのサーバー側で Cache-Control ヘッダを返そうが何しようが Guzzle 側ではキャッシュしません。

これを入れれば、レスポンスヘッダーに max-age があると、その間APIにアクセスしないでキャッシュしたレスポンスを使いますし、ETag などがあって 304 が返ってくれば、それもまたキャッシュしたレスポンスを使いまわしてくれます。

キャッシュストレージとキャッシュ戦略(Cache Strategy)とを自分で選ぶことができるので、だいたいどんなケースにも対応できるんじゃないでしょうか。

選択可能なキャッシュストレージ

  1. Psr16CacheStorage
  2. Psr6CacheStorage
  3. DoctrineCacheStorage
    • Doctrine\Common\Cache\Cache を使う
  4. CompressedDoctrineCacheStorage
    • Doctrine\Common\Cache\Cache を使うが、値の保存に gzcompress で圧縮を行う
  5. LaravelCacheStorage
    • Laravel用
    • Illuminate\Contracts\Cache\Repository を使う
  6. FlysystemStorage
  7. WordPressObjectCacheStorage
    • wordpress用
    • 内部で wp_cache_get などのグローバルなWP関数を使う
  8. VolatileRuntimeStorage
    • arrayを使うので、デバッグや開発時の用途かな

キャッシュ戦略

  1. PublicCacheStrategy
    • 共有キャッシュ(複数のユーザーで使い回せるもの)をキャッシュする
  2. PrivateCacheStrategy
    • プライベートキャッシュ(特定の一人のユーザーのためのキャッシュ)をキャッシュする
  3. GreedyCacheStrategy
    • 自分で設定したTTLでキャッシュする
  4. NullCacheStrategy
    • 何もキャッシュしない

2. RetryMiddleware または caseyamcl/guzzle_retry_middleware

下記の2つはどちらも通信に失敗した場合にリトライしてくれるミドルウェアです。

サーバーとの通信は不安定なので必ず成功するわけでもないですし、単純にリトライしたら成功するケースも多いかと思います。

その場合、自前でリトライ処理を書いてしまうと、インフラ的なコードがアプリケーション層に漏れ出る形になってソースコードが読みづらくなるので、こういうものに任せてしまったほうが楽ですね。

この2つのミドルウェアの違いは

  • \GuzzleHttp\RetryMiddleware
    • リトライするかどうかの判断は自前(クロージャー)で設定
      • 例えばステータスコードで判断したり、リトライ回数で判断したりすることができる
    • リトライ時のsleep時間は自前でも設定できる
      • デフォルトはリトライ回数に応じてべき乗で増える(1, 2, 4, 8, 16 ...)
  • \GuzzleRetry\GuzzleRetryMiddleware
    • リトライするかどうかの判断は自動(ただし設定である程度変更できる)
      • 下記の条件に当てはまるとリトライしない
        • max_retry_attempts (デフォルトは10)で設定した最大リトライ回数を超えた
        • retry_only_if_retry_after_header の設定で必須にしているのに Retry-After ヘッダーがない
        • retry_on_status (デフォルトは 429, 503)で設定したステータスコードではない
        • 接続がタイムアウトしたが、 retry_on_timeout(デフォルトは false) の設定がされていない
        • タイムアウト以外の接続時の例外
    • リトライ時のsleep時間は下記のように決定される
      • default_retry_multiplier (デフォルトは1.5)* リトライ回数(1.5, 3, 4,5, ...)
      • Retry-After ヘッダーがあればその値を使う

といった感じです。

Retry-After ヘッダーを吐くサーバーと通信するなら後者、そうでないなら前者といった使い分けでしょうか。

外部サービスのAPIクライアントのSDKを使っている場合でも、内部的には Guzzle を使用しているケースも多く、 そういった場合でもミドルウェアを設定すればいいので便利ですね。

(ちなみに私は Zendesk API を使用するケースで \GuzzleRetry\GuzzleRetryMiddleware にお世話になりました)

といった感じで、今日はこの辺でまた。