Bugsnagを利用したエラートラッキング

Adventarを支える技術 Advent Calendar 2019 の20日目です。

今日はエラートラッキングについて書きます。

Bugsnag

エラートラッキングのサービスは色々あって、有名なのは SentryAirbrake あたりでしょうか。今回は Bugsnag というサービスを利用しました。これは Rails 時代から使っていて、無料で使える範囲が一番大きそう、という基準で選びました。

Bugsnag だと 250 events/day は無料枠で使えるので、Adventar ぐらいの規模であればスパイクしなければ余裕です。Sentry も今見たら無料で 5000 events/month なのでこっちでもいけそうな気がします(昔からこうだっけな)。

今回は Go の gRPC サーバー、Nuxt.js で SSR しているところに Bugsnag を使っています。フロントエンドの JS でも動くのですが、経験上フロントエンドでのエラートラッキングはノイズが多く、無料枠を食いつぶしてしまう可能性がありそうだったので今回は入れていません。もしかしたらそんなことはなくて、意外とさくっといける可能性はあります。

Rails では何もはまらずに使えていたのですが、Go と Node.js に有効にするのにけっこう苦労したので、それについて書いておきます。

Go/gRPCでの利用

Bugsnag の Go SDK はいくつかのフレームークに対応してますが、gRPC はありませんでした。

https://docs.bugsnag.com/platforms/go/

なのでOther Go appsを見て自力でどうにかする必要がありそうです。

Go のサーバーでエラーをトラッキングしたいのは主に

  • 予期せぬエラーになった場合
  • panic で死んだ場合

の2つで、この場合に

bugsnag.Notify(err, ctx)

を呼べばよさそうです。最終的には以下のようなコードを Interceptor に刺しこみました。

func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (_ interface{}, err error) {
    defer func() {
        if r := recover(); r != nil {
            err = grpc.Errorf(codes.Internal, "Internal Server Error")
            fmt.Printf("%s\n", r)
            if bugsnagAPIKey != "" {
                bugsnag.Notify(fmt.Errorf("%s", r), ctx)
            }
        }
    }()
    resp, err := handler(ctx, req)
    s, _ := status.FromError(err)
    if s.Code() == codes.Unknown {
        stacktrace := fmt.Sprintf("%+v\n", err)
        fmt.Print(stacktrace)
        if bugsnagAPIKey != "" {
            bugsnag.Notify(err, ctx, bugsnag.MetaData{"info": {"stacktrace": stacktrace}})
        }
        err = grpc.Errorf(codes.Internal, "Internal Server Error")
    }
    return resp, err
},

https://github.com/adventar/adventar/blob/c175ac9bd7fd9c12a74bd86202129394ba13e41f/api-server/grpc-server/service/service.go#L75-L96

今回エラーハンドリングには xerrors を使っているのですが、xerrors で wrap したエラーを投げると、bugsnag 上でのエラーが全部 *xerrers.Wrap になるの困っています。どうにかしたいのですが、エラーの量もそこまで多くなくて困らないので放置しています。

f:id:hokaccha:20191218223657p:plain

また、スタックトレースの情報がわかりづらく、例えば以下のエラーは

f:id:hokaccha:20191218223713p:plain

以下の箇所で発生していますが、entry.go:118スタックトレースに表示されません。

https://github.com/adventar/adventar/blob/c175ac9bd7fd9c12a74bd86202129394ba13e41f/api-server/grpc-server/service/entry.go#L118

この問題はメタデータとして、fmt.Sprintf("%+v\n", err)を送信することで一時しのぎしています。これは以下の表示されます。

f:id:hokaccha:20191218223734p:plain

わかりやすい...。もう少しちゃんとしたやり方があると思うので直したいところです。

Node.js/AWS Lambdaでの利用

SSR している Lambda では express を利用しているので、ドキュメントに沿って導入してみたが動きませんでした。どうやら bugsnag へのエラー送信が終わる前に Lambda が終了してしまうことが原因みたいだったようです。Issueもありました。

https://github.com/bugsnag/bugsnag-js/issues/495

エラーを送信する処理(bugsnagClient.notify)を自分で実行して、その終了を待てばよさそうなのですが、必要な情報を自分で詰めないといけないのでややめんどうでした。例えば express plugin を使えばこのあたりでやってくれます。

https://github.com/bugsnag/bugsnag-js/blob/96238c360d1f021af9d006fead5d10f827cf0079/packages/plugin-express/src/express.js#L64-L76

とりあえず最低限の情報だけ詰めて対応しました。

app.use((err, req, _, next) => {
  const opt = {
    request: {
      headers: req.headers,
      httpMethod: req.method,
      url: req.url
    }
  };

 // このコールバックでエラー送信の終了を待つ
  bugsnagClient.notify(err, opt, () => {
    next(err);
  });
});

https://github.com/adventar/adventar/blob/c175ac9bd7fd9c12a74bd86202129394ba13e41f/frontend/server.ts#L60-L69

また、もう一つの問題は source maps です。

https://docs.bugsnag.com/platforms/javascript/source-maps/

ブラウザの JS であれば、sourceMappingURL に書いてある URL に取りに行ってくれるので便利ですが、Node.js の場合は source maps のファイルを別途 Bugsnag にアップロードする必要があります。がんばればできそうですが、今回はめんどうなので諦めました。いつかやるかもしれません。

まとめ

今回は Bugsnag を導入するのにはまったことなどを書きました。実際 Bugsnag はかなり役に立っていて、Bugsnag のおかげでいくつかのエラーを特定して潰すことができました。いくつか中途半端になっているところがあるので今後改善していきたいと思います。

明日は UI のスマホ対応について書きます。

SSR の CDN によるキャッシュ戦略

Adventarを支える技術 Advent Calendar 2019 の19日目です。

今日は Server Side Redering (以下 SSR) した結果を CDN でキャッシュする戦略について書きます。SSR の概要については以下にも概要を書いたので一読しておくとよりわかりやすいと思います。

上記に書いてありますが、今回は SSR を Lambda で行っていて、Lambda の実行はそんなに速くないし、実行回数での課金になるので、前段の CDN でキャッシュすることで、パフォーマンスとコストを最適化することができます。

その設計について書こうと思いますが、、先に書いておくと今回は SSR の結果をキャッシュせずに毎回リクエストのたびに Lambda を実行しています。そのあたりの理由も含めて書いていきます。

キャッシュのパージ

キャッシュはやれば速くなるのは確実ですが、コンテンツが更新されたタイミングで適切にパージしないと、新しいコンテンツがユーザーに届きません。パージの戦略は提供するコンテンツやキャッシュする範囲によっても様々です。

例えばブログサービスのようなサイトでページキャッシュするケースを考えてみます。単純に考えると、ブログ記事が更新されたときに対象のブログページだけパージすればよさそうですが、キャッシュする範囲によってはその限りではありません。

例えばブログにコメントや Like のような機能があって、それらも含めてキャッシュしているならコメントや Like が更新されたタイミングでも必要かもしれません。また、トップページや最新記事一覧ページのようなところに記事のコンテンツが表示されていて、それらのページもキャッシュしているなら、すべての記事作成、更新でそれらのページのパージが必要です。

さらに、はてなブログのようなログイン機能があったらどうでしょう。ログインしている状態でキャッシュをつくると最初にキャッシュしたときにログインしているユーザー情報が全員に表示されてひどいことになります。そんなことないだろ、と思うかもしれませんが気をつけていてもミスによってそういった状態になってしまう例をいくつも見てきました。最近ではメルカリのニュースが記憶に新しいです。

なのでキャッシュは狭い範囲で必要最小限に留めるべきです。例えば上記のようなブログサービスの例であれば、コメントや Like、ユーザー情報の表示はクライアント側(JS)でレンダリングする、キャッシュするのは記事ページのみに留める、などです。

今回の Adventar でも、キャッシュはカレンダーページ( https://adventar.org/calendars/3860 のようなページ)だけに留め、ログイン情報も SSR では扱わず、クライアント側で認証情報を付け足す、という設計にしました。認証については以下に書きました。

これで、キャッシュする範囲は最低限ですが、それでもパージするタイミングは少なくありません。

  • カレンダーのタイトル、概要の更新
  • エントリの登録・編集・削除
  • ユーザー情報(名前、アイコン)の変更
  • デプロイ

などです。このような操作が行われたときに、どうやってパージするかを見ていきましょう。

Fastly によるパージ

まず最初にこの設計を考えたときに一番手にあがったのは Fastly です。Fastly には Instant Purge という機能があり、パージリクエストをして 150ms 以内にキャッシュをパージしてくれます。

https://www.fastly.com/products/web-and-mobile-performance/caching-and-purging

パージの速さはユーザー体験につながるので、ぜひこれを使いたいところです。Fastly で具体的にどのようにパージするかを説明します。

  • カレンダーのタイトル、概要の更新
  • エントリの登録・編集・削除

これらは単純で、該当カレンダーページのキャッシュだけをパージすればいいだけです。Fastly のパージは非常にシンプルで、該当の URL に PURGE メソッドでリクエストするだけです。

https://docs.fastly.com/api/purge

$ curl -X PURGE https://adventar.org/calendars/1

だけで済みます。特別なクライアントもいらず、非常に簡単です。

  • ユーザー情報(名前、アイコン)の変更

はもう少し複雑で、そのユーザーが登録しているカレンダーページをすべてパージする必要があります。一つ一つ丁寧に上記のPURGEメソッドでパージすることもできますが、数が多くなると負荷も大きくなりますし、fastly の API Limit もあります。こういうときに使えるのが Surrogate-Key という機能です。

https://docs.fastly.com/en/guides/getting-started-with-surrogate-keys

この機能を使うと、キャッシュをタグのようなものでグルーピングして一括パージすることができます。リクエスト時にこのヘッダに複数の値を指定します。例えばSurrogate-Keyの値にu1 u2 u3のように、そのカレンダーに登録しているユーザー ID のをもたせておきます。そして user_id: 1 のユーザーがプロフィールを更新したら、u1 を指定してキャッシュをパージすることができます。

  • デプロイ

デプロイ時にはすべてのキャッシュを消す必要がありますが、それは purge_all という API があるのでこれを使います。

https://docs.fastly.com/api/purge#purge_bee5ed1a0cfd541e8b9f970a44718546

このように、やりたいことは完璧に実現できそうだったので Fastly を使いところではあったのですが、Fastly の料金は最低料金が $50/month ということで、コスト面が折り合わず今回は断念しました。

一方 CloudFront は、最低利用料金がなく、転送量だけであれば先日の記事にも書いたように、最もアクセスが多い12月でも$30〜$40ぐらいで落ち着きそうで、1月〜10月はほぼアクセスがないのでこれの 1/10 ぐらいに収まると思っています。個人サービスの上に1円も稼いでいないサービスなので $600/year はけっこうきついので、今回は CloudFront を採用しました。

なお、Fastly にはオープンソース向けの無償アカウントがあるので、これを申請してみようかと思っています。

https://docs.fastly.com/en/guides/accounts-and-pricing-plans#free-open-source-developer-accounts

CloudFront によるパージ

まず、CloudFront のパージは Fastly ほど高機能ではありません。パージの速度も Fastly ほど速くありません(公式のドキュメントが見つけられませんでしたが、少なくとも ms のオーダーではない)し、Surrogate-Keyのような機能もありません。

それだけであれば許容できると思ったのですが、調べてみると思ったよりパージに料金がかかることがわかりました。

https://aws.amazon.com/cloudfront/pricing/

月間で無効をリクエストしたパスの最初の 1,000 パスまでは追加料金なし。それ以降は、無効をリクエストしたパスごとに 0.005 USD かかります。

どのぐらいパージ処理が走りそうかを概算してみます。

  • カレンダーのタイトル、概要の更新
  • ユーザー情報(名前、アイコン)の変更
  • デプロイ

については、回数も多くないし一旦無視します。問題は

  • エントリの登録・編集・削除

です。去年ベースで考えるとエントリの数は13767、そのうちコメントが更新されているもの12063、URL が更新されているものが11893でした。Adventar ではこれらの更新処理はタイミング的に別々に行わられるので、単純に足し算になります。さらに削除や、コメントやURLを複数回変更する場合もあります。ここではざっくり全体の10%ぐらいで計算してみます。

  • 13767 + (12063 * 1.1) + (11893 * 1.1) = 40118.6

これらはほぼすべて11月、12月で呼ばれるので、その2ヶ月でどのぐらいかかるかを出してみます。また、無料枠が 1000req/month あるので、それも加味します。

  • (40118.6 - 2000) * 0.005 = $190.593

だいたい $100/month くらいといったところです。ざっくり計算ですが、カレンダーや登録数は年々線形に伸びているし、もっと多くなる可能性もありそうです。

11月と12月以外は無料枠に収まると思うので、トータルで見ると Fastly よりは安く済みそうですが、ピーク時のコストを10,000円前後ぐらいで考えていたので少し厳しい金額です。

なので、今回は CDN によるキャッシュを諦め、毎回 Lambda を実行することにしました。ちなみにこれによって Lambda の実行回数は多くなりますが、昨日の記事にも書いたように Lambda のコストは $0.4 ぐらいになりそうです。爆安。

まとめ

SSR の結果を CDN でキャッシュする戦略と、コスト面でその戦略を諦めた話を書きました。キャッシュによるパフォーマンスの最適化はユーザー体験にかなり寄与すると思っているので、Fastly のオープンソース向けの無償アカウントなどでコスト面の折り合いがつけばまたチャレンジしたいと思っています。

明日は Bugsnag によるエラートラッキングについて書きます。

インフラコストの最適化

Adventarを支える技術 Advent Calendar 2019 の18日目です。

昨日の記事で実際にかかっているコストを紹介しましたが、今日はコストを最適化するためにやったことを書きます。

Lambda を活用とスペックの調整

昨日の記事を見ても分かる通り、ECS などと比べると Lambda のコストは爆安です。今回、Nuxt.js のサーバーサイドレンダリングを Lambda でやっていて、キャッシュはしていないので、カレンダーページへのリクエストごとに Lambda が実行されます。実行回数はこの function が支配的ですが、その他にも画像サーバーやバッチジョブなどにも Lambda を利用しています。

現時点で実行回数は15万回ぐらいで、おそらく30万回ぐらいで着地すると思います。無料利用枠が10万回分、その後は10万回ごとに $0.2 なので、$0.4 ぐらいで収まる計算です爆安ですね。

実行回数だけでなく、実行時間でも課金されますが、こちらは現在無料枠の半分以下なので無料枠で収まりそうです。なお、実行時間の課金は利用しているメモリの容量が関係します。

https://aws.amazon.com/lambda/pricing/

Serverless Framework ではデフォルトのメモリが 1024 MB になるので、大量に呼ばれる関数には適切に設定してあげないと、思いがけずコストがかかってしまうことがありそうです。今回は大量に Nuxt.js の SSR は 512 MB に設定しました。

https://github.com/adventar/adventar/blob/c175ac9bd7fd9c12a74bd86202129394ba13e41f/frontend/serverless.yml#L16

これがデフォルトの 1024 MB だと実行時間の計算は倍になり、無料枠の倍ぐらいの実行時間になりそうなので、400,000 * 0.0000166667 で $6.6 ぐらいになりそうです。大した額ではないですが。

メモリの使用量は、Lambda のログに出力されます。

REPORT RequestId: ee702bc9-c647-4d45-95c9-db8a2e6ba8bc Duration: 55.51 ms Billed Duration: 100 ms Memory Size: 512 MB Max Memory Used: 224 MB

現状 200 MB 〜250MB くらいなのでもう少し下げてもメモリは足りそうですが、メモリ使用量に比例して CPU の性能も決まるので、あまり下げすぎると性能が劣化します。実際、以下のように影響が出ています。

f:id:hokaccha:20191217221330p:plain

カレンダーページのパフォーマンスに直結するので $6 ぐらいであれば 1024 MB に上げてもよさそうだなとこれを書きながら思いました。下げたときはどのぐらい呼び出しがあるか読めなかったのでビビってたのです。

Fargate か EC2 か

これはコストを削減したというよりは、コスト削減と天秤にかけてメンテナンスコストが安いほうを選んだという話です。

ECS はタスクを実行するのに EC2 インスタンスか Fargate を選べます。Fargate だと EC2 のインスタンスを管理しなくてよくなるし、スケールなども楽にできますが、EC2 より少し高くなります。また、EC2 の場合はデプロイ時に複数タスクを配置する必要があるので、余分にリソースを空けておきたいところです。

Fargate の一番小さいサイズでもさばけそうだったので、Fargate は 0.25 CPU, 0.5 GB Memory、EC2 の場合は t3.micro(2vCPU, 1GB Memory)で比較しました。

  • Fargate 約 $11/month
  • EC2(t3.micro) 約 $10/month

これだとほぼ変わらないので、管理の手間などを考えて Fargate を選ぶことにしました。EC2 はもう一つ下の t3.nano でも足りるかも知れませんが、リソースが足りなくなってスケールアップ/アウトが必要になったときの手間を考えると選びづらい選択でした。

また、スポットインスタンスなどを使えばもっと安く済みますが、さらに管理コストが増えるので採用は見送りました。ちなみにこのときはまだ Fargate Spot が発表されてなかったので試していませんが、時間があったら試してみたいと思っています。

ちなみに以下は一番小さいサイズで余裕で捌けている図です。

f:id:hokaccha:20191217221344p:plain

RDS のストレージサイズを小さくする

これはコスト最適化というよりは単純にミスったのですが、ストレージサイズを無駄に100GB確保しているのにリリースしたあとに気づいてしまって、途中で最小の20GBに落としました。

https://github.com/adventar/adventar/commit/47d336c02a1619591ac33062e34022ab1a6ec356

ストレージサイズを小さくするのは動かしながらはできないので、2台立ててアクセスが少ない時間帯に手動でデータをマイグレーションして切り替えました 😅

以下が切り替えたときのコストの変動です。

f:id:hokaccha:20191217221404p:plain

これで $11/month ぐらい節約?できました。

private subnet をおかない

普通、VPC を設計する際、public subnet と private subnet を作り、インターネットから見える必要があるのもは public へ、そうでないものは private に置くことでセキュリティを強固にします。

しかし、private subnet に置かれたサーバーからインターネットにアクセスしようとすると、Nat Gateway が必要になります。

https://docs.aws.amazon.com/vpc/latest/userguide/vpc-nat-gateway.html

この Nat Gateway が地味に高くて、起動しっぱなしだと、$40/month ぐらいかかります。必要なときだけ起動するみたいな方法もあるかもしれませんが、手間を考えて public subnet だけの運用にして Security Group でがんばる方針にしました。

基本的な方針として、インターネットからのアクセスは ALB だけに許可して、その他のリソースはすべて VPC 内部からしかアクセスできないようにます。

しかし、DB はデータや設定を見たりする用途で、手元からつなぎたいケースがけっこうあります。ただ、DB はインターネットからのアクセスを許可したくないリソース一番手でもあります。

つなげるときだけ必要なものを詰め込んだ Fargate のタスクを起動して、一時的な踏み台として使う、という手も考えましたが、そこまでしなくてもいいかと思い止まり、手元の IP を許可した Security Group を作って、必要なときだけ RDS に刺すようにしました。

https://github.com/adventar/adventar/blob/275aca335a2b195ca92d8ece131678dd8860f0a5/terraform/rds.tf#L23

この Security Group は terraform で管理せずに手積みで管理しています。

まとめ

今日はインフラのコストを最適化する話を書きました。明日は SSRCDN でキャッシュする戦略について書きます。

Adventar のインフラコスト

Adventarを支える技術 Advent Calendar 2019 の17日目です。

Adventar の運用コストが、何にどのぐらいかかっているかを書きます。AWS 以外は無料のサービスしか利用していないので、かかっているのは 100% AWS のコストです。AWS の構成については先日書いたので、こちらも参照してください。

今年の11月に新システムに移行したので、11月からのコストを見てみます。12月16日の時点でコストはこのような感じです。

f:id:hokaccha:20191216232645p:plain

ピーク時(12月)で月10,000円以内ぐらいを目安に設計していて、この中に Adventar 以外のサービスのコストが $10 ぐらい含まれているので、概ね予定通りといったところです。内訳はこのような感じです。

f:id:hokaccha:20191216232442p:plain

(月半ばの金額なので月額ではこれの倍かかる予想)

今回 EC2 のインスタンスは使っていないので、EC2 となっているのは ALB です。その他については以下のようになっています。

f:id:hokaccha:20191216232914p:plain

Lightsail は Adventar と関係ない個人で使っているやつなので、その他では

  • Fargate: $5
  • Route53: $3
  • データ転送量: $3
  • CloudWatch: $1

といったところです。

RDS や ALB、Fargate については、このぐらいかかると見込んでいたので予想通りでした。思ったよりもかかっていたのが CloudFront と API Gateway です。これらはリクエスト数がもろに料金に反映されます。アクセス数と料金の変異を見るとばっちり一致します。

アクセス

f:id:hokaccha:20191217001555p:plain

料金

f:id:hokaccha:20191217001545p:plain

f:id:hokaccha:20191217001758p:plain

なので、ピーク時期がすぎればもう少し落ち着いて、コスト的には半分ぐらいで落ち着くのではと思っています。逆に、そうなってくると Fargate や RDS などの、常時起動しておく必要があるリソースのほうが支配的になってくるはずです。

また、S3 や Lambda はヘビーに使っているにも関わらず、ほぼ無料で使えているのもわかります。

まとめ

AWS のコストについて書きました。明日は AWS のコストを削減するためにやった細かい話を書きます。

Adventar のインフラ概要

Adventarを支える技術 Advent Calendar 2019 の16日目です。

今年の Adventar のインフラはほとんど AWS を使って構築しています。AWS 以外だと、 Firebase Authentication や Bugsnag などのサービスも使っていますが、今回は AWS の構成について説明します。

インフラの構成は基本的に terraform で管理していて、コードは以下にあります。API Gateway や Lambda の管理には Serverless Framework、ECS の管理には ecs-cli を使っていたりするので、それらは terraform 外での管理になっています。

https://github.com/adventar/adventar/tree/619f222b9348e1cbfcfe50cc731fb8184e84ab2d/terraform

構成図は以下のような感じです。

f:id:hokaccha:20191216214615p:plain

それぞれ説明していきます。

www.adventar.org

これは最も簡単なシステムで、www.adventar.org というドメインの旧 URL を www なしの adventar.org にリダイレクトするためだけの存在です。S3 には、空の bucket を使ってホスト名のリダイレクトができるという機能があるのでこれを使っています。

https://docs.aws.amazon.com/AmazonS3/latest/dev/website-hosting-custom-domain-walkthrough.html

S3 の設定は簡単で、これだけです。

https://github.com/adventar/adventar/blob/619f222b9348e1cbfcfe50cc731fb8184e84ab2d/terraform/s3.tf#L42-L54

http だけであればこの bucketエイリアスレコードを設定すれば終わりですが、httpsも対応したいので、CloudFront に ACM の証明書を刺して https も受けられるようにしています。

img.adventar.org

画像のリサイズサーバーです。詳細は以下に書きました。

上記の記事でも書いたとおり、DB や API サーバーに依存しないので、構成をシンプルに保つことができます。

adventar.org

ユーザーフェイシングな HTML や静的ファイルのリクエストを受けます。詳細は以下に書きました。

図にもあるように、SSR をしている Lambda が API サーバーへリクエストしています。このリクエストは本来は VPC 内部で Lambda を起動して内部通信するのがいいのですが、めんどくさいのでインターネット経由になっています。

api.adventar.org

API サーバーです。VPC の中に配置した ALB がリクエストを受けて、その後ろにいる ECS がいて、ECS では Fargate でタスクがいて、Envoy と Go による gRPC のサーバーが動いています。DB は同じ VPC に配置されている RDS です。gRPC や Envoy の話は以下に書きました。

バッチジョブ

VPC の中にいる CloudWatch Events と Lambda は、定期実行されるバッチジョブです。詳細は以下に書きました。

まとめ

Adventar の AWS の構成についての概要を書きました。明日はインフラのコストについて書こうと思います。

schemalex による DB のスキーマ管理

Adventarを支える技術 Advent Calendar 2019 の15日目です。

今日は DB のスキーマ管理について書きます。

Rails の DB マイグレーションと Ridgepole

Adventar は昨年まで Rails で作っていて、DB の マイグレーションRails デフォルトの機能を使っていました。Rails の DB マイグレーションは、それなりによくできてはいますが、差分を積み上げていくので大量のマイグレーションファイルができて煩雑になる、多人数での開発の場合にコンフリクトしやすいなど、いくつか問題があります。

個人的にはこういった問題もあるので、Ridgepole のように DB のスキーマ定義だけを管理し、現在のスキーマとの差分を計算して ALTER 文を発行してくれるような仕組みのほうが好きです。

最初は Ridgepole を使おうと思ったのですが、API サーバーは Go で書こうと思っていたので、スキーマ管理のためだけに Ruby 依存を入れるのは微妙かな、と思い他のツールを検討しました。

schemalex と sqldef

Go 製の Ridgepole 的なツールは

の2つがあるのを知っていたので、どちらかを使うことにしました。これらのツールは、Ridgepole と違って、RubyDSL でなく、SQLスキーマを管理できる点、Ruby のランタイムなどが不要でバイナリ単体で実行できて便利です。

ほとんど機能の違いはなのですが、大きい違いは schemalex が MySQL だけをサポートしているのにたいして、sqldef は PostgreSQL もサポートしています。今回は MySQL なのでどちらでもよかったのですが、schemalex のほうが歴史が深く、どちらかといえば安定していそうだったので schemalex を選びました。

ちなみに、偶然にも Ridgepole, schemalex, sqldef は全部同僚 or 元同僚が作っています。

schemalex による DB のマイグレーション

スキーマの管理には、以下のような普通の SQLDDL を使います。

CREATE TABLE `users` (
  `id` int unsigned NOT NULL AUTO_INCREMENT,
  `name` varchar(255) NOT NULL,
  `auth_uid` varchar(255) NOT NULL,
  `auth_provider` varchar(20) NOT NULL,
  `icon_url` text NOT NULL,
  `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `index_users_on_uid_and_provider` (`auth_uid`,`auth_provider`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

実際の Adventar のスキーマは以下のような感じです。

https://github.com/adventar/adventar/blob/619f222b9348e1cbfcfe50cc731fb8184e84ab2d/db/schema.sql

これはただの SQL なので、schemalex を使わずとも、初回のテーブル作成はできます。

$ mysql -u root adventar_dev < schema.sql

ここで新しくカラムを追加したい場合、例えば以下のような変更を加えます。

--- a/db/schema.sql
+++ b/db/schema.sql
@@ -1,6 +1,7 @@
 CREATE TABLE `users` (
   `id` int unsigned NOT NULL AUTO_INCREMENT,
   `name` varchar(255) NOT NULL,
+  `description` text NOT NULL,
   `auth_uid` varchar(255) NOT NULL,
   `auth_provider` varchar(20) NOT NULL,
   `icon_url` text NOT NULL,

schemalex を使うと、既存の DB のスキーマと上記 SQL の diff を計算して ALTER 文を作ってくれます。

$ schemalex 'mysql://root@tcp(127.0.0.1:13306)/adventar_dev' schema.sql

BEGIN;

SET FOREIGN_KEY_CHECKS = 0;

ALTER TABLE `users` ADD COLUMN `description` TEXT NOT NULL AFTER `name`;

SET FOREIGN_KEY_CHECKS = 1;

COMMIT;

schemalex がやってくれるのは ALTER 文の生成だけなので、MySQL に食わせるのは自分でやります。

$ schemalex 'mysql://root@tcp(127.0.0.1:13306)/adventar_dev' | mysql -u root adventar_dev

実際の運用

複数人で運用するときのフローや、デプロイ時に適用場合のフローなども紹介できればよかったのですが、Adventar はほぼ一人で開発しているし、プロとして恥ずべき行為ではありますが、デプロイも手元から手動でやっているので、特に運用について言及できることはありませんでした(つまり手動 ALTER でも十分そうということですね...!)

まとめ

DB のスキーマ管理とマイグレーションについて書きました。明日は Adventar のインフラ構成について書きます。

Lambda を使った画像のリサイズサーバー

Adventarを支える技術 Advent Calendar 2019 の14日目です。

今日は Lambda , API Gateway, CloudFront などを使って、画像のリサイズサーバーを作る話を書きます。

Adventar における画像のリサイズ

Adventar は記事を投稿した際に、記事の関連画像を取ってきて表示する機能があります。

f:id:hokaccha:20191214213045p:plain

これは OGP などのメタタグから画像の URL を取得していますが、取得した URL をそのまま使用して画像を取得すると、いくつか問題があります。

  • 毎回リクエストが飛ぶので行儀が悪い
  • 画像の URL がhttp(非 SSL) の場合に mixed contents になる
  • 画像サイズが大きいとパフォーマンスに影響する(OGP の画像は 1000px 超えで設定してるケースも多い)
    • Adventar でほしいのはせいぜい、100px〜200pxぐらい

これを解決するために、取得した画像をhttpsで配信し、適切なサイズにリサイズし、キャッシュするようなサーバーがあるとよさそうです。

画像のリサイズは FastlyImageFlux などのサービス、ngx_small_light などの OSS を使うなど、様々な方法がありますが、今回は自前で画像を持っているわけではなく、外部サイトの画像を取得して利用したいという少し違うユースケースというのもあり、Lambda と CloudFront で自作することにしました。

といっても全然難しい仕組みではなく、画像 URL を受け取ってリサイズして画像を返す Go のプログラムを Lambda で動かして、CloudFront でキャッシュしているだけです。Go のコードも100行ちょっとの簡単なものです。

https://github.com/adventar/adventar/blob/10b9b2386f76d1fd66c10c31d4dd0550f6d0527d/image-server/main.go

いくつか工夫していているとこがあるので説明します。

DB に依存させない

まず、一つ目が DB に依存せずに動作させるということです。画像の URL は、DB に保存されていますので、画像 URL を知るためには、カレンダーの投稿 ID を受け取って DB にアクセスし画像 URL を取ってくる、ということが必要になりますが、そのためだけに DB にアクセスするのはめんどくさいので、今回はリクエスト URL に直接画像の URL を埋め込んでいます。

https://img.adventar.org/?url=<画像URL>

のような感じです。Lambda でこの画像 URL を fetch して、適当なサイズにリサイズして返します。本当はサイズの指定もクエリでできるようにしたいのですが、とりあえず今は1サイズしかないので固定にしています。

https://github.com/adventar/adventar/blob/10b9b2386f76d1fd66c10c31d4dd0550f6d0527d/image-server/main.go#L84

ハードコードでだいぶひどい感じですが、かなりギリギリになって実装したので雑コードです(投稿が始まる12月までにあればいいので実装を一番最後に回した)。

ダイジェスト値による検証

ただ、これだと任意の URL のプロキシになってしまってよろしくないので、ダイジェスト値を設定して URL が改ざんされていないかを検証しています。OSS なのでコードを読めばわかりますが、単に画像 URL に SALT を混ぜて sha1 を取っているだけの簡単なものです。なので最終的な URL はこのような感じになっています。

https://img.adventar.org/img/<digest>?url=<画像URL>

実際の URL はこんな感じです。

https://img.adventar.org/img/c96546f133a9e2803dd7bb033681ffcf81146cb0?url=https%3A%2F%2Fcdn-ak.f.st-hatena.com%2Fimages%2Ffotolife%2Fh%2Fhokaccha%2F20191203%2F20191203213418.png

デプロイ

フロントエンドのデプロイバッチジョブのデプロイ にも利用した Serverless を利用しました。今回 Serverless 大活躍です。設定はこんな感じで何も難しくない。

https://github.com/adventar/adventar/blob/10b9b2386f76d1fd66c10c31d4dd0550f6d0527d/image-server/serverless.yml

デプロイスクリプト

$ serverless deploy

で終了。

https://github.com/adventar/adventar/blob/10b9b2386f76d1fd66c10c31d4dd0550f6d0527d/image-server/Makefile#L10

まとめ

Lambda を使った画像のリサイズサーバーについて書きました。明日は DB のスキーマ管理について書きます。