Jekyll2021-12-20T10:22:34+09:00https://k11i.biz/feed.xmlk11i.bizMy technical memorandumKOMIYA AtsushiSharedArrayBuffer とクロスオリジン分離の問題への対処の記録2021-12-20T00:00:00+09:002021-12-20T00:00:00+09:00https://k11i.biz/blog/2021/12/20/tackle-with-sharedarraybuffer-issue<p><a href="https://qiita.com/advent-calendar/2021/legalscape">Legalscape (リーガルスケープ) アドベントカレンダー 2021</a> の 12/20 (月) のエントリです。本日のエントリは、Legalscape が遭遇した SharedArrayBuffer とクロスオリジン分離の問題についてお送りします。</p>
<h2 id="何もしていないのに-legalscape-が壊れました">「何もしていないのに Legalscape が壊れました」</h2>
<p>それはある夏の暑い日のことでした。</p>
<p>あるお客様からのお問い合わせで「Legalscape が突然使えなくなったんですが…」というご連絡をいただいた我々は「あれ? 今日って何かプロダクション環境にデプロイしましたっけ? フロントエンドかな? それともバックエンドの API サーバかな?」などと Slack で会話しながら、どういう問題が発生しているのかを具体的に知るために調査に取り掛かりました。</p>
<p>このときの我々は何も知りませんでした。<em>何もしていないのに Legalscape が壊れた</em>ことに…</p>
<p>初期調査の結果は、環境依存で発生する問題であるように見えました。「僕の環境だと再現したんですけど、他の方はどうですか?」「いや、私の環境だと再現しないですね……」こんな調子で、あるメンバーの環境では確かに問題が再現するが、他のメンバーの環境では再現しない、我々開発者にとってはおなじみのやっかいな問題。</p>
<p>ひとまずフロントエンドとバックエンド API サーバのどちらに問題の原因があるのかあたりをつけるため、Chrome DevTools のコンソールタブとネットワークタブを開いて問題が発生する操作をしてみたところ、コンソールには以下のスタックトレースが表示されていました。</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ReferenceError: SharedArrayBuffer is not defined
at Object.<anonymous> (https://*****.legalscape.jp/*****/**********.js:2:*****)
at c (https://*****.legalscape.jp/*****/**********.js:1:*****)
(以下略)
</code></pre></div></div>
<p>「あれ、なんで SharedArrayBuffer が not defined になっているの…?」「そもそも SharedArrayBuffer なんて使ってましたっけ?」「いや覚えがないですね」「というか昨日まで動いていたのになんで今日になって動かなくなったの?」「いやさっぱり心当たりがない…」</p>
<p>そんなこんなで我々は薄々気づき始めたのです。そう、どうやら<em>何もしていないのに Legalscape が壊れた</em>ようであることに…</p>
<p>ひとまず SharedArrayBuffer という手がかりは得られたものの、これだけではなぜこの問題が発生したのかの原因究明には至りませんでした。そこに他のメンバーからの新たな情報提供が届きます。</p>
<p>「古い Chrome のバージョン 91 で動作確認したら再現しなかったけど、最新版のバージョン 92 に更新したら再現したっぽい」</p>
<p>そうです。ここまでお読みいただいて勘の鋭い方ならもうお気づきかと思いますが、かの <a href="https://developer.chrome.com/blog/enabling-shared-array-buffer/">Chrome 92 で必須となった、SharedArrayBuffer 利用時のクロスオリジン分離 (cross-origin isolation) 有効化</a>の影響でこの問題が生じていたのでした。</p>
<p>そしてこの Chrome バージョン 92 という手がかりを得て、我々も遂に問題の本質に気づきました。<em><strong>何もしていない</strong>から Legalscape が壊れた</em>、ということに…</p>
<h2 id="origin-trial-による暫定対処">Origin trial による暫定対処</h2>
<p>問題の原因が明らかになったことで、我々が応急処置としてとるべき対処方法も徐々に明らかになっていきました。</p>
<p>まず最初に検討したのが、この問題の正攻法と思われる<strong>クロスオリジン分離<sup id="fnref:1" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup>を達成して SharedArrayBuffer を再び使えるようにする</strong>方法です。しかし、この方法は明らかに影響範囲の調査や対応に時間がかかり過ぎることが想定できたために採用を諦め、早々に他の方法を模索することにしました。</p>
<p>続いて検討したのが、<strong>SharedArrayBuffer の利用を止める</strong>方法です。SharedArrayBuffer が使えないなら使わなければいいじゃん、という安易な発想ではありますが、これはこれで確実な手段となり得ます。しかし SharedArrayBuffer がどこで・どのライブラリで使われているのかを漏れなく確実に把握し、ライブラリを除去したりアプリケーションコードを書き換えたりするのは応急処置としてはやはり時間がかかり過ぎる懸念があったため、より暫定的ながらも容易に対処できる手段がないかどうか探すことにしました。</p>
<p>そして最終的にたどり着いたのが <strong>origin trial に登録する</strong>方法です。先の Chrome Developers のブログ記事にも、Chrome 92 までにクロスオリジン分離が間に合わない場合の対処法として案内されています。</p>
<p>実際に我々もこの origin trial に登録してトークンを発行し、フロントエンドを構成する HTML の meta 要素としてこのトークンを埋め込むことで Legalscape が壊れた状態を復旧するに至りました。</p>
<h2 id="sharedarraybuffer-を利用するライブラリの除去による対処">SharedArrayBuffer を利用するライブラリの除去による対処</h2>
<p>さて origin trial のトークンを利用する対処方法はあくまで応急処置であり、トークンの有効期限が切れるまでの間により適切な処置をする必要がありました。</p>
<p>トークンの有効期間は約 150 日とそれなりに余裕はありましたが、ビジネス的に優先すべき他の課題の存在と、Legalscape のプロダクトのフロントエンドで利用している・利用する予定でいるサードパーティーのサービスにおけるクロスオリジン分離の対応状況が不明瞭でありそれなりの期間の調査を要することから、本命であるクロスオリジン分離の期限内の対応は難しいと判断しました。</p>
<p>したがって今回は、SharedArrayBuffer (を利用しているライブラリ) の利用を止める方法によって origin trial のトークンを除去し、暫定対処を解消することにしました。</p>
<p>Legalscape における <code class="language-plaintext highlighter-rouge">SharedArrayBuffer is not defined</code> の問題の発端となった具体的なライブラリは、whatwg-url が依存している webidl-conversions<sup id="fnref:2" role="doc-noteref"><a href="#fn:2" class="footnote" rel="footnote">2</a></sup> でした。しかし whatwg-url のライブラリ自体が Legalscape における利用用途的に必ずしも必要とよべるほどのライブラリではなかったため、このライブラリを除去しアプリケーションコードを少々修正して SharedArrayBuffer-free な状態を実現しました。</p>
<h2 id="再発防止策">再発防止策</h2>
<p>そもそもこの問題が発生した根本的な原因としては「Chrome 92 からクロスオリジン分離された環境でしか SharedArrayBuffer が使えないという事実を事前に把握できていなかった」ことと、「SharedArrayBuffer に知らず知らず依存していた」ことに尽きます。</p>
<p>したがって今後同様の事態を引き起こしてしまうことを防ぐために、以下の再発防止策を講じることにしました。</p>
<h3 id="chrome-ベータ版を業務利用するブラウザとして推奨する">Chrome ベータ版を業務利用するブラウザとして推奨する</h3>
<p>普段から安定版としてリリースされる前の Chrome をメンバーの多くが利用していれば、安定版よりも一足先にブラウザ側の仕様変更の影響を把握することができます。ゆえに強制はせずとも、業務中だけでも <a href="https://www.google.com/intl/ja/chrome/beta/">Chrome ベータ版</a>を積極利用することを弊社では推奨しています。</p>
<h3 id="google-search-console-からの通知を多くのメンバーが受け取れるようにする">Google Search Console からの通知を多くのメンバーが受け取れるようにする</h3>
<p>今回の SharedArrayBuffer の件に限って言えば、<a href="https://techblog.zozo.com/entry/zozotown-shared-array-buffer">ZOZO テクノロジーズ社 (社名はエントリ公開当時) のこちらの記事</a> にあるように、事前にメッセージを受けることができていれば問題を事前に把握し、適切な対処が打てた可能性があります。</p>
<p>そのため、Google Search Console にアクセスできる権限を見直し、できるだけ多くのメンバーがメッセージを受け取れるようにしました。</p>
<h3 id="sharedarraybuffer-がプロダクションコードに含まれていることを検知する">SharedArrayBuffer がプロダクションコードに含まれていることを検知する</h3>
<p>今回の問題に特化した再発防止策になりますが、フロントエンドのコードから SharedArrayBuffer を除去したとしても、将来新たにライブラリを導入しようとしたときにそのライブラリ (およびその先にある依存ライブラリ) で SharedArrayBuffer をその利用可否チェックなしに利用しているようではまた問題が発生してしまいます。</p>
<p>そこでフロントエンドのビルド済みアセットの JavaScript ファイルに対し、SharedArrayBuffer を利用しているコードが混入していないかを CI の過程でチェックするようにしました。具体的には新たに用意した npm パッケージ <a href="https://github.com/komiya-atsushi/sab-usage-detector">sab-usage-detector</a> によって、AST に変換された JavaScript コードに対して SharedArrayBuffer が識別子として存在しているかどうかをチェックするようにしています。</p>
<h2 id="目指すべき未来">目指すべき未来</h2>
<p>ここまで記してきたように、現時点では SharedArrayBuffer の利用を控えるという、どちらかというと防御的な対策によって「Legalscape が壊れる」事態を解消しています。しかしこれもまた origin trial トークンと同様に暫定対処でしかないと我々は考えています。</p>
<p>本来は SharedArrayBuffer の要否に関わらず、Spectre の脅威からユーザとサービスを守るためにクロスオリジン分離を推し進めていく必要があるものと考えています。そのためには Legalscape の制御下にある各種リソースの配信を同一オリジンに揃えたり、サードパーティーのサービスのクロスオリジン分離の対応状況を見極めて着実に進めていく必要があるのですが、如何せん人の手が足りておらずなかなか進められていないのが現状です。</p>
<p>そういうわけで、この記事をお読みになられてクロスオリジン分離に興味を持たれた方がいましたら、ぜひ以下のリンクのページからソフトウェアエンジニアのポジションに応募、もしくはカジュアル面談をお申し込みいただければと思います!</p>
<p><a href="https://legalscape.notion.site/09aeb478072946c18249495b8fb63fcd">採用情報</a></p>
<p>最後までお読みいただきありがとうございました!</p>
<hr />
<div class="footnotes" role="doc-endnotes">
<ol>
<li id="fn:1" role="doc-endnote">
<p>具体的なクロスオリジン分離の方法については、2021 年 12 月の現時点では <a href="https://blog.agektmr.com/2021/11/cross-origin-isolation.html">Eiji Kitamura さんのエントリ</a> が大変参考になります (我々も大いに参考にさせていただきました)。 <a href="#fnref:1" class="reversefootnote" role="doc-backlink">↩</a></p>
</li>
<li id="fn:2" role="doc-endnote">
<p>より厳密には、クロスオリジン分離がなされていないブラウザ環境のように SharedArrayBuffer が利用できない環境を考慮せず、常に利用できるものとして同 API を参照しているバージョンの webidl-conversions に whatwg-url が依存していた、という話になります。webidl-conversions の <a href="https://github.com/jsdom/webidl-conversions/releases/tag/v7.0.0">最新バージョン</a> (2021 年 12 月時点) では、SharedArrayBuffer が利用できない環境を考慮した実装に修正されています。 <a href="#fnref:2" class="reversefootnote" role="doc-backlink">↩</a></p>
</li>
</ol>
</div>KOMIYA AtsushiLegalscape (リーガルスケープ) アドベントカレンダー 2021 の 12/20 (月) のエントリです。本日のエントリは、Legalscape が遭遇した SharedArrayBuffer とクロスオリジン分離の問題についてお送りします。一週間で構築できる! お手軽データウェアハウス2021-12-16T00:00:00+09:002021-12-16T00:00:00+09:00https://k11i.biz/blog/2021/12/16/data-warehouse-in-ls<p><a href="https://qiita.com/advent-calendar/2021/legalscape">Legalscape (リーガルスケープ) アドベントカレンダー 2021</a> の 12/16 (木) のエントリです。</p>
<p>本日のエントリは、突貫工事的に一週間程度<sup id="fnref:1" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup>で構築したデータウェアハウスについてお送りいたします。</p>
<h2 id="データウェアハウス構築前夜">データウェアハウス構築前夜</h2>
<p>2021 年 6 月に予定をしている Legalscape 正式版リリースが刻々と迫り、みなが慌ただしく仕事をしている 5 月下旬、ビジネス上の様々な理由から<strong>ユーザのアクティビティログを保持して分析・集計するデータ基盤、すなわちデータウェアハウス</strong>が必要になりました。</p>
<p>Legalscape ではそれまで、プロダクト上でのユーザの行動に伴って発生するアクティビティログはすべて (書籍の全文検索に用いているものと同じ) Elasticsearch クラスタにインデックスしていました。アクティビティログを利用する際は、このインデックスに対して Kibana を通じてクエリ (KQL) を投げ必要なアクティビティログを絞り込んで抽出したのちに、Python スクリプトでログを加工したり Excel で集計するなどしていました。</p>
<p>しかしながら、エンジニアメンバーだけでなくビジネスサイドのメンバーがアクティビティログに対してアドホックな分析をすることを考えると、Kibana を活用するよりも、今を生きるビジネスパーソン的に潰しが利く――もはや現代の必須スキルと言っても過言ではない―― SQL でクエリできること<sup id="fnref:2" role="doc-noteref"><a href="#fn:2" class="footnote" rel="footnote">2</a></sup>が望ましいと考えられました。</p>
<p>また BI ダッシュボードから参照されるデータソースとして考えたときに、負荷が予測しづらいクエリが Elasticsearch クラスタに投げられるのは避けたいという思惑もあり、正式版リリースまで一週間に満たない短期間ではありましたが新たにデータウェアハウスを構築する決断をしました。</p>
<h2 id="legalscape-のデータウェアハウスの概要">Legalscape のデータウェアハウスの概要</h2>
<p>実際どのようにデータウェアハウスを構築したのか? については後述するとして、現時点でのデータウェアハウスとその周辺のシステム構成を以下の図に示します。</p>
<p><img src="/images/2021/12/16/fig1.png" alt="データウェアハウス周辺のシステム構成" /></p>
<p>ご覧のとおり、Legalscape では <strong>BigQuery</strong> を用いてデータウェアハウスを構築しています。Google Cloud Platform を利用しているのであれば、この BigQuery は当然の選択と言えますね。Fully managed なので面倒な運用の手間がほとんどなく、かつデータの規模が小規模であれば非常に安価に利用を始められるのが何よりの利点として挙げられるでしょう。</p>
<p>App Engine 上の API サーバからは、フロントエンドから受信したアクティビティログを JSON のフォーマットで <strong>Cloud Logging</strong> に出力しています。Legalscape では API サーバのロギングライブラリとして Winston を採用しており、Google が公式に提供している Winston 向けのインテグレーションライブラリ <a href="https://www.npmjs.com/package/@google-cloud/logging-winston">@google-cloud/logging-winston</a> と組み合わせて利用しています。</p>
<p>そしてこの Cloud Logging からは<a href="https://cloud.google.com/logging/docs/export/configure_export_v2">シンク</a>を用いて、 アクティビティログを BigQuery に準リアルタイムでエクスポートしています。</p>
<p>それとは別に、Cloud SQL (MySQL) 上に存在するマスタテーブルを <a href="https://cloud.google.com/bigquery/docs/cloud-sql-federated-queries"><strong>Cloud SQL 連携クエリ</strong></a> の機能を用いて BigQuery から参照可能な状態にしています。加えて <a href="https://cloud.google.com/bigquery-transfer/docs/transfer-service-overview"><strong>BigQuery Data Transfer Service</strong></a> を利用して Cloud SQL のマスタテーブルのデータを BigQuery に日次でインポートしつつ、履歴を保持するようにしています。</p>
<h2 id="terraform-による構築">Terraform による構築</h2>
<p>さてこの BigQuery を中心としたデータウェアハウスですが、構築には <a href="https://k11i.biz/blog/2021/12/09/managing-ls-infrastructure-with-terraform/">12/9 のエントリでご紹介した Terraform</a> を大いに活用しています。具体的に利用しているリソースは以下のとおりです。</p>
<ul>
<li><a href="https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/bigquery_dataset">google_bigquery_dataset リソース</a>
<ul>
<li>BigQuery のデータセットの定義</li>
</ul>
</li>
<li><a href="https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/bigquery_table">google_bigquery_table リソース</a>
<ul>
<li>ログテーブルを除く BigQuery のテーブルの定義</li>
</ul>
</li>
<li><a href="https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/logging_project_sink">google_logging_project_sink リソース</a>
<ul>
<li>Cloud Logging シンクの定義</li>
</ul>
</li>
<li><a href="https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/bigquery_connection">google_bigquery_connection リソース</a>
<ul>
<li>Cloud SQL 連携クエリを利用する際に必要となる BigQuery 接続サービスの設定</li>
</ul>
</li>
<li><a href="https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/bigquery_data_transfer_config">google_bigquery_data_transfer_config リソース</a>
<ul>
<li>Cloud SQL から BigQuery にマスタテーブルをインポートするスケジュールクエリの定義</li>
</ul>
</li>
</ul>
<p>また Terraform コンフィギュレーションの具体例は以下になります。</p>
<div class="language-terraform highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">locals</span> <span class="p">{</span>
<span class="nx">project_id</span> <span class="p">=</span> <span class="s2">"GCP プロジェクトの ID をここに指定する"</span>
<span class="c1"># Cloud Logging に出力されるログ</span>
<span class="nx">log_names</span> <span class="p">=</span> <span class="p">[</span>
<span class="s2">"foo_log"</span><span class="p">,</span>
<span class="s2">"bar_log"</span><span class="p">,</span>
<span class="p">]</span>
<span class="c1"># Cloud SQL 上のテーブル</span>
<span class="nx">tables</span> <span class="p">=</span> <span class="p">[</span>
<span class="s2">"foo_table"</span><span class="p">,</span>
<span class="s2">"bar_table"</span><span class="p">,</span>
<span class="p">]</span>
<span class="p">}</span>
<span class="k">resource</span> <span class="s2">"google_bigquery_dataset"</span> <span class="s2">"this"</span> <span class="p">{</span>
<span class="nx">dataset_id</span> <span class="p">=</span> <span class="s2">"データセットIDをここに指定する"</span>
<span class="nx">location</span> <span class="p">=</span> <span class="s2">"asia-northeast1"</span>
<span class="p">}</span>
<span class="c1"># ログテーブルは google_bigquery_table リソースで明示的に出力先テーブルを定義する必要がなく、</span>
<span class="c1"># このシンクを定義をするだけで十分である</span>
<span class="k">resource</span> <span class="s2">"google_logging_project_sink"</span> <span class="s2">"this"</span> <span class="p">{</span>
<span class="nx">for_each</span> <span class="p">=</span> <span class="nx">toset</span><span class="p">(</span><span class="kd">local</span><span class="p">.</span><span class="nx">log_names</span><span class="p">)</span>
<span class="nx">name</span> <span class="p">=</span> <span class="nx">each</span><span class="p">.</span><span class="nx">key</span>
<span class="nx">destination</span> <span class="p">=</span> <span class="s2">"bigquery.googleapis.com/projects/</span><span class="k">${</span><span class="kd">local</span><span class="p">.</span><span class="nx">project_id</span><span class="k">}</span><span class="s2">/datasets/</span><span class="k">${</span><span class="nx">google_bigquery_dataset</span><span class="p">.</span><span class="nx">this</span><span class="p">.</span><span class="nx">dataset_id</span><span class="k">}</span><span class="s2">"</span>
<span class="nx">filter</span> <span class="p">=</span> <span class="s2">"logName=</span><span class="se">\"</span><span class="s2">projects/</span><span class="k">${</span><span class="kd">local</span><span class="p">.</span><span class="nx">project_id</span><span class="k">}</span><span class="s2">/logs/</span><span class="k">${</span><span class="nx">each</span><span class="p">.</span><span class="nx">key</span><span class="k">}</span><span class="se">\"</span><span class="s2">"</span>
<span class="nx">unique_writer_identity</span> <span class="p">=</span> <span class="kc">true</span>
<span class="nx">bigquery_options</span> <span class="p">{</span>
<span class="nx">use_partitioned_tables</span> <span class="p">=</span> <span class="kc">true</span>
<span class="p">}</span>
<span class="p">}</span>
<span class="c1"># シンクを作成したときに同時に作成されるサービスアカウントに、</span>
<span class="c1"># BigQuery にデータを追加することのできる権限を明示的に付与する必要がある</span>
<span class="k">resource</span> <span class="s2">"google_project_iam_member"</span> <span class="s2">"this"</span> <span class="p">{</span>
<span class="nx">for_each</span> <span class="p">=</span> <span class="nx">toset</span><span class="p">(</span><span class="kd">local</span><span class="p">.</span><span class="nx">log_names</span><span class="p">)</span>
<span class="nx">role</span> <span class="p">=</span> <span class="s2">"roles/bigquery.dataEditor"</span>
<span class="nx">member</span> <span class="p">=</span> <span class="nx">google_logging_project_sink</span><span class="p">.</span><span class="nx">this</span><span class="p">[</span><span class="nx">each</span><span class="p">.</span><span class="nx">key</span><span class="p">].</span><span class="nx">writer_identity</span>
<span class="p">}</span>
<span class="c1"># BigQuery から Cloud SQL に連携クエリ (federated queries) を投げるための接続設定</span>
<span class="k">resource</span> <span class="s2">"google_bigquery_connection"</span> <span class="s2">"this"</span> <span class="p">{</span>
<span class="k">provider</span> <span class="p">=</span> <span class="nx">google</span><span class="err">-</span><span class="nx">beta</span>
<span class="nx">connection_id</span> <span class="p">=</span> <span class="s2">"cloudsql-connection"</span>
<span class="nx">friendly_name</span> <span class="p">=</span> <span class="s2">"Cloud SQL connection"</span>
<span class="nx">location</span> <span class="p">=</span> <span class="s2">"asia-northeast1"</span>
<span class="nx">cloud_sql</span> <span class="p">{</span>
<span class="nx">database</span> <span class="p">=</span> <span class="s2">"接続先の MySQL のデータベース名"</span>
<span class="nx">instance_id</span> <span class="p">=</span> <span class="s2">"Cloud SQL のインスタンス ID"</span>
<span class="nx">type</span> <span class="p">=</span> <span class="s2">"MYSQL"</span>
<span class="nx">credential</span> <span class="p">{</span>
<span class="nx">username</span> <span class="p">=</span> <span class="s2">"MySQL 接続ユーザ名"</span>
<span class="nx">password</span> <span class="p">=</span> <span class="s2">"接続パスワード"</span>
<span class="p">}</span>
<span class="p">}</span>
<span class="p">}</span>
<span class="k">data</span> <span class="s2">"google_project"</span> <span class="s2">"project"</span> <span class="p">{</span>
<span class="p">}</span>
<span class="c1"># BigQuery Data Transfer Service が利用するサービスアカウントに適切な権限を付与する必要がある</span>
<span class="k">resource</span> <span class="s2">"google_project_iam_member"</span> <span class="s2">"permissions"</span> <span class="p">{</span>
<span class="nx">role</span> <span class="p">=</span> <span class="s2">"roles/iam.serviceAccountShortTermTokenMinter"</span>
<span class="nx">member</span> <span class="p">=</span> <span class="s2">"serviceAccount:service-</span><span class="k">${data</span><span class="p">.</span><span class="nx">google_project</span><span class="p">.</span><span class="nx">project</span><span class="p">.</span><span class="nx">number</span><span class="k">}</span><span class="s2">@gcp-sa-bigquerydatatransfer.iam.gserviceaccount.com"</span>
<span class="p">}</span>
<span class="c1"># Cloud SQL からインポートしたデータを入れるテーブルを定義する</span>
<span class="k">resource</span> <span class="s2">"google_bigquery_table"</span> <span class="s2">"this"</span> <span class="p">{</span>
<span class="nx">for_each</span> <span class="p">=</span> <span class="nx">toset</span><span class="p">(</span><span class="kd">local</span><span class="p">.</span><span class="nx">tables</span><span class="p">)</span>
<span class="nx">dataset_id</span> <span class="p">=</span> <span class="nx">google_bigquery_dataset</span><span class="p">.</span><span class="nx">this</span><span class="p">.</span><span class="nx">dataset_id</span>
<span class="nx">table_id</span> <span class="p">=</span> <span class="nx">each</span><span class="p">.</span><span class="nx">key</span>
<span class="c1"># Terraform コンフィギュレーションの可読性を維持するために、</span>
<span class="c1"># テーブルの具体的なカラム定義はコンフィギュレーションに直接記述せずに</span>
<span class="c1"># 別ファイルに記述する</span>
<span class="nx">schema</span> <span class="p">=</span> <span class="s2">"schemas/</span><span class="k">${</span><span class="nx">each</span><span class="p">.</span><span class="nx">key</span><span class="k">}</span><span class="s2">.json"</span>
<span class="nx">time_partitioning</span> <span class="p">{</span>
<span class="nx">type</span> <span class="p">=</span> <span class="s2">"DAY"</span>
<span class="p">}</span>
<span class="p">}</span>
<span class="c1"># 実際に Cloud SQL からテーブルのデータをインポートするスケジュールクエリを定義する</span>
<span class="k">resource</span> <span class="s2">"google_bigquery_data_transfer_config"</span> <span class="s2">"this"</span> <span class="p">{</span>
<span class="nx">for_each</span> <span class="p">=</span> <span class="nx">toset</span><span class="p">(</span><span class="kd">local</span><span class="p">.</span><span class="nx">tables</span><span class="p">)</span>
<span class="nx">depends_on</span> <span class="p">=</span> <span class="p">[</span><span class="nx">google_bigquery_table</span><span class="p">.</span><span class="nx">this</span><span class="p">[</span><span class="nx">each</span><span class="p">.</span><span class="nx">key</span><span class="p">]]</span>
<span class="nx">display_name</span> <span class="p">=</span> <span class="s2">"load-</span><span class="k">${</span><span class="nx">each</span><span class="p">.</span><span class="nx">key</span><span class="k">}</span><span class="s2">"</span>
<span class="nx">location</span> <span class="p">=</span> <span class="s2">"asia-northeast1"</span>
<span class="nx">data_source_id</span> <span class="p">=</span> <span class="s2">"scheduled_query"</span>
<span class="c1"># 毎日 0:05 JST にインポートクエリを実行する</span>
<span class="nx">schedule</span> <span class="p">=</span> <span class="s2">"every day 15:05"</span>
<span class="nx">destination_dataset_id</span> <span class="p">=</span> <span class="nx">google_bigquery_dataset</span><span class="p">.</span><span class="nx">this</span><span class="p">.</span><span class="nx">dataset_id</span>
<span class="nx">params</span> <span class="p">=</span> <span class="p">{</span>
<span class="c1"># パーティションの日付が JST になるように調整している</span>
<span class="nx">destination_table_name_template</span> <span class="p">=</span> <span class="s2">"</span><span class="k">${</span><span class="nx">each</span><span class="p">.</span><span class="nx">key</span><span class="k">}</span><span class="s2">_history</span><span class="err">$</span><span class="k">${</span><span class="nx">run_time</span><span class="o">+</span><span class="mi">9</span><span class="nx">h</span><span class="o">|</span><span class="err">\</span><span class="s2">"%Y%m%d</span><span class="se">\"</span><span class="s2">}"</span>
<span class="nx">write_disposition</span> <span class="o">=</span> <span class="s2">"WRITE_APPEND"</span>
<span class="err">#</span> <span class="err">テーブル定義同様にインポートクエリは別ファイルに記述する</span>
<span class="nx">query</span> <span class="o">=</span> <span class="s2">"queries/</span><span class="k">${</span><span class="nx">each</span><span class="p">.</span><span class="nx">key</span><span class="k">}</span><span class="s2">.json"</span>
<span class="k">}</span><span class="s2">
}
</span></code></pre></div></div>
<p>上記コンフィギュレーションはかなり行数があって読みにくく感じる方もいらっしゃるかと思いますが<sup id="fnref:3" role="doc-noteref"><a href="#fn:3" class="footnote" rel="footnote">3</a></sup>、fluentd の設定ファイルや長大なログ取り込みクエリ、アプリケーションコードなどをほとんど書くこともなく、これだけの記述で十分に活用できるデータウェアハウスが構築できるわけでして、随分とよい時代になったなあ… という気持ちでいっぱいです。</p>
<p>また上記のコンフィギュレーションのコメントにあるように、Cloud SQL からインポートしたデータを入れるためのテーブル定義とその具体的なインポートクエリはコンフィギュレーションには記述せず、以下のような内容で別ファイルに記述しています。</p>
<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// テーブル定義ファイルの例</span>
<span class="p">[</span>
<span class="p">{</span>
<span class="dl">"</span><span class="s2">name</span><span class="dl">"</span><span class="p">:</span> <span class="dl">"</span><span class="s2">id</span><span class="dl">"</span><span class="p">,</span>
<span class="dl">"</span><span class="s2">type</span><span class="dl">"</span><span class="p">:</span> <span class="dl">"</span><span class="s2">INT64</span><span class="dl">"</span><span class="p">,</span>
<span class="dl">"</span><span class="s2">mode</span><span class="dl">"</span><span class="p">:</span> <span class="dl">"</span><span class="s2">NULLABLE</span><span class="dl">"</span>
<span class="p">},</span>
<span class="p">{</span>
<span class="dl">"</span><span class="s2">name</span><span class="dl">"</span><span class="p">:</span> <span class="dl">"</span><span class="s2">name</span><span class="dl">"</span><span class="p">,</span>
<span class="dl">"</span><span class="s2">type</span><span class="dl">"</span><span class="p">:</span> <span class="dl">"</span><span class="s2">STRING</span><span class="dl">"</span><span class="p">,</span>
<span class="dl">"</span><span class="s2">mode</span><span class="dl">"</span><span class="p">:</span> <span class="dl">"</span><span class="s2">NULLABLE</span><span class="dl">"</span>
<span class="p">},</span>
<span class="p">{</span>
<span class="dl">"</span><span class="s2">name</span><span class="dl">"</span><span class="p">:</span> <span class="dl">"</span><span class="s2">created_at</span><span class="dl">"</span><span class="p">,</span>
<span class="dl">"</span><span class="s2">type</span><span class="dl">"</span><span class="p">:</span> <span class="dl">"</span><span class="s2">DATETIME</span><span class="dl">"</span><span class="p">,</span>
<span class="dl">"</span><span class="s2">mode</span><span class="dl">"</span><span class="p">:</span> <span class="dl">"</span><span class="s2">NULLABLE</span><span class="dl">"</span>
<span class="p">},</span>
<span class="p">{</span>
<span class="dl">"</span><span class="s2">name</span><span class="dl">"</span><span class="p">:</span> <span class="dl">"</span><span class="s2">updated_at</span><span class="dl">"</span><span class="p">,</span>
<span class="dl">"</span><span class="s2">type</span><span class="dl">"</span><span class="p">:</span> <span class="dl">"</span><span class="s2">DATETIME</span><span class="dl">"</span><span class="p">,</span>
<span class="dl">"</span><span class="s2">mode</span><span class="dl">"</span><span class="p">:</span> <span class="dl">"</span><span class="s2">NULLABLE</span><span class="dl">"</span>
<span class="p">},</span>
<span class="p">{</span>
<span class="dl">"</span><span class="s2">name</span><span class="dl">"</span><span class="p">:</span> <span class="dl">"</span><span class="s2">deleted_at</span><span class="dl">"</span><span class="p">,</span>
<span class="dl">"</span><span class="s2">type</span><span class="dl">"</span><span class="p">:</span> <span class="dl">"</span><span class="s2">DATETIME</span><span class="dl">"</span><span class="p">,</span>
<span class="dl">"</span><span class="s2">mode</span><span class="dl">"</span><span class="p">:</span> <span class="dl">"</span><span class="s2">NULLABLE</span><span class="dl">"</span>
<span class="p">}</span>
<span class="p">]</span>
</code></pre></div></div>
<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">-- インポートクエリの例</span>
<span class="k">SELECT</span>
<span class="k">cast</span><span class="p">(</span><span class="n">id</span> <span class="k">AS</span> <span class="n">INT64</span><span class="p">)</span> <span class="k">AS</span> <span class="n">id</span><span class="p">,</span>
<span class="n">name</span><span class="p">,</span>
<span class="n">created_at</span><span class="p">,</span>
<span class="n">updated_at</span><span class="p">,</span>
<span class="n">deleted_at</span>
<span class="k">FROM</span>
<span class="n">EXTERNAL_QUERY</span><span class="p">(</span><span class="s1">'cloudsql-connection'</span><span class="p">,</span> <span class="s1">'SELECT * FROM database_name.foo_table'</span><span class="p">)</span>
</code></pre></div></div>
<h2 id="現状の構成の課題">現状の構成の課題</h2>
<p>このデータウェアハウス構築から半年以上が経過し、いまのところ大きな不満はないものの、まだやり残していることが多々あります。</p>
<h3 id="elasticsearch-に入っている過去ログのインポート">Elasticsearch に入っている過去ログのインポート</h3>
<p>当初は Elasticsearch 上の過去ログについては参照することもそう多くないだろうし割り切って Kibana だけで扱えればいいかな… と考えていました。しかし実際には過去のログ含めて分析をしたいというケースがそれなりに発生しており、分析プラットフォームを統一するためにも過去ログを BigQuery に取り込む必要があると考えています。</p>
<h3 id="ログテーブルの細分化">ログテーブルの細分化</h3>
<p>現時点ではアクティビティログは種類ごとに分類せずに一つのテーブルにすべて入れています。これを種類ごとに別テーブルに分離して BigQuery に取り込むことで、<a href="https://cloud.google.com/logging/docs/export/bigquery#mismatch">スキーマ不一致の問題</a>のリスク低減を図ります。</p>
<h3 id="データパイプラインの整備">データパイプラインの整備</h3>
<p>日次や月次など定期的に実行したいクエリが複数あった場合、依存関係を考慮してクエリを順に実行したいのですが、BigQuery Data Transfer Service で利用できるスケジュールクエリではそのような依存関係の定義はいまのところできないと認識しています。そこで Apache Airflow のフルマネージドサービスである <a href="https://cloud.google.com/composer"><strong>Cloud Composer</strong></a> を導入し、依存関係を考慮したスケジュールクエリ実行環境を整備しようと画策しています。</p>
<h3 id="bi-ダッシュボードの整備">BI ダッシュボードの整備</h3>
<p>データウェアハウスを早々に整備したことで分析に必要なデータは続々と蓄積されてはいますが、定常的に把握したい KPI をすぐに確認できる BI ダッシュボードの整備がまだ進んでいません。GCP 上に作られたデータウェアハウスとの親和性を考えると<a href="https://marketingplatform.google.com/intl/ja/about/data-studio/">データポータル</a>もしくは <a href="https://cloud.google.com/looker">Looker</a> が有力な選択肢であると考えていて、まずは Looker のトライアルを早々に始める予定でいます。</p>
<h2 id="おわりに">おわりに</h2>
<p>このエントリでは Legalscape のデータウェアハウスの概要および構築方法についてご紹介しました。</p>
<p>最後のセクションで書いたように弊社のデータウェアハウスはまだ発展途上であり、これを少しでも完成形に近づけるためにはデータエンジニアリングに強いソフトウェアエンジニアの協力が必要不可欠です。</p>
<p>現時点では「データエンジニア」という明確なタイトルでのポジションの募集はありませんが、「ソフトウェアエンジニア」のポジションで担当できる領域ではありますので、ご興味ある方は以下の採用情報の案内からご応募、もしくはカジュアル面談をご利用くださいませ!</p>
<p><a href="https://legalscape.notion.site/09aeb478072946c18249495b8fb63fcd">採用情報</a></p>
<p>最後までお読みいただきありがとうございました!</p>
<hr />
<div class="footnotes" role="doc-endnotes">
<ol>
<li id="fn:1" role="doc-endnote">
<p>厳密には、ユーザのアクティビティログを Cloud Logging 経由で BigQuery に入れてクエリ可能にする最低限のところまでを一週間かけずに達成したわけで、Cloud SQL からのマスタテーブルのインポートはまた別途時間を確保して対応しています。 <a href="#fnref:1" class="reversefootnote" role="doc-backlink">↩</a></p>
</li>
<li id="fn:2" role="doc-endnote">
<p>Elasticsearch でも platinum プランを購入していれば、<a href="https://www.elastic.co/jp/downloads/jdbc-client">JDBC driver を使って SQL でクエリ</a> できるようです。 <a href="#fnref:2" class="reversefootnote" role="doc-backlink">↩</a></p>
</li>
<li id="fn:3" role="doc-endnote">
<p>実際に弊社で管理しているコンフィギュレーションはモジュール化していたりファイルを分割して管理しているので、これよりもだいぶ読みやすくなっています。 <a href="#fnref:3" class="reversefootnote" role="doc-backlink">↩</a></p>
</li>
</ol>
</div>KOMIYA AtsushiLegalscape (リーガルスケープ) アドベントカレンダー 2021 の 12/16 (木) のエントリです。Legalscape のインフラストラクチャー管理を支える Terraform2021-12-09T00:00:00+09:002021-12-09T00:00:00+09:00https://k11i.biz/blog/2021/12/09/managing-ls-infrastructure-with-terraform<p><a href="https://qiita.com/advent-calendar/2021/legalscape">Legalscape (リーガルスケープ) アドベントカレンダー 2021</a> の 12/9 (木) のエントリです。</p>
<p>このエントリでは Legalscape のインフラストラクチャー管理を支えている Terraform について、その導入背景と Legalscape での運用状況についてお送りいたします。</p>
<h2 id="terraform-導入に至った背景">Terraform 導入に至った背景</h2>
<p><a href="https://k11i.biz/blog/2021/12/03/history-of-ls-architecture/">先日のシステム構成のエントリ</a> でもご説明したように、Legalscape ではプロダクトのβ版提供開始当初から Google Cloud Platform (GCP) を採用していました。そして当初のクラウドリソース管理は利用している GCP のサービスも少なかったため、gcloud コマンドや Web の管理コンソール (https://console.cloud.google.com) を用いて牧歌的に行われていました。</p>
<p>しかし時間が経過するとともに、業務委託や正社員などプロダクトの開発に関わる人員が少しずつ増え、またシステムも徐々に複雑に進化していきます。すると、それに応じて <strong>クラウドリソースの最新の構成がどのようになっているのか</strong> をキャッチアップするのに時間的なコストがかかるようになりました。また、システム構成を変更しようとする際の影響範囲の把握が難しくなるため有識者にレビューを依頼したくなるものの、<strong>システム変更の概要や手順を漏れなく詳細に記述するのが大変だしそもそもレビューがし辛い</strong> といった問題が生じるようになりました。</p>
<p>ここでそれなりの大きさのエンジニアリング組織であれば、インフラエンジニア的な人員を用意してクラウドリソースの管理を主に担当してもらう… という選択肢もとりえるかとは思います。しかし当時の Legalscape は (そして今も) スタートアップゆえに潤沢なエンジニアリングリソースが存在しているとは言えず、インフラエンジニア的な人員を用意するのが難しいという事情がありました。</p>
<p>一方で世間的には、Amazon Web Services (AWS) や GCP などのクラウドプラットフォームを利用してプロダクトを構築することが標準的となり、それに合わせてインフラストラクチャーをコードで表現して管理する、いわゆる Infrastructure as Code (IaC) を実現するツールの利用が当たり前になりつつありました。これら IaC を実現する各種ツールの登場により、<strong>インフラストラクチャーはもはやソフトウェアエンジニア自らが管理できる、むしろ管理すべきもの</strong> になってきたと感じています。そしてその IaC を実現するツールの代表格 (の一つ) である <strong>Terraform は、特にバックエンドサービスの開発に関わる現代のソフトウェアエンジニアにとって必須のスキル</strong> となりつつあるのではないでしょうか?<sup id="fnref:1" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup></p>
<p>そのような社内や世間の事情を考慮し、Legalscape でも Terraform を導入し利用することにしました。</p>
<h2 id="legalscape-における-terraform-運用の詳細">Legalscape における Terraform 運用の詳細</h2>
<h3 id="運用方針の策定">運用方針の策定</h3>
<p>新しいツールを導入してその価値を最大限に引き出す運用を実現するには、最初の運用設計が肝心だと僕は考えています。Terraform 導入当時の Legalscape には Terraform を扱ったことがあるメンバーがあまりいなかったため、習熟度を考慮して以下の基本的な運用方針を定めることにしました。<sup id="fnref:2" role="doc-noteref"><a href="#fn:2" class="footnote" rel="footnote">2</a></sup></p>
<blockquote>
<ul>
<li>必ずしも常に・すべてのインフラを Terraform コンフィギュレーションで記述する必要はありません
<ul>
<li>記述にかかる手間やその必要性、また開発者自身の Terraform 習熟度を考慮して適宜記述の要否を決めるものとします</li>
</ul>
</li>
<li>Terraform コンフィギュレーション / ステート は小さく保つようにします
<ul>
<li>「全体で一つのコンフィギュレーション / ステート」のようなモノリシック構成にするのではなく、例えばマイクロサービスの単位に分割して記述・管理することとします</li>
</ul>
</li>
<li>新しくマイクロサービスを構築する場合など多量の Terraform コンフィギュレーションを書く際は、一度にすべての Terraform コンフィギュレーションを書ききる必要はありません
<ul>
<li>Pull request のでのコードレビューがしやすい規模に分けて徐々に書き足していきましょう</li>
</ul>
</li>
</ul>
</blockquote>
<h3 id="リソースの管理単位とディレクトリ構成">リソースの管理単位とディレクトリ構成</h3>
<p>Terraform を初めて導入する際に一番迷うポイントは、リソースの管理単位とそれに関連するディレクトリ構成をどのようにするか、ではないでしょうか?</p>
<p>すべての環境すべてのクラウドリソースを一つのコンフィギュレーションファイル (.tf) に収めるモノリシックな構成は考慮すべきことが少ないがために Terraform 導入当初にはうまくワークするかもしれません。しかし、システムを構成するリソースが増えてくると徐々に運用が辛いものになっていくことでしょう。では具体的にクラウドリソースの集合をどのように分割して管理すればいいのか? Terraform のモジュールはどのように利用するべきか? ステージング環境やプロダクション環境などの環境の違いをどのようにコンフィギュレーションで表現すればいいのか? など、考え始めると悩みは尽きません。</p>
<p>弊社もこのリソースの管理単位をどうするかで悩みましたが、初導入とは言え最初からモノリシックな構成にすると (不可能ではないものの) 後々のリファクタリングが辛いものになることが想像できたので、結果として以下の構成をとることにしました。</p>
<ul>
<li><strong>リソースの管理単位</strong>
<ul>
<li>「一つのマイクロサービス」など、まとめて管理したいクラウドリソースの集合 (便宜上コンポーネントと呼んでいる) ごとにディレクトリを分けてコンフィギュレーションファイルを配置する</li>
<li>加えて <a href="https://www.terraform.io/docs/language/state/index.html">Terraform のステート</a> をこのコンポーネント単位で分離することで、<code class="language-plaintext highlighter-rouge">terraform apply</code> の影響範囲を小さめに抑える</li>
<li>これにより、システムを構成するすべてのクラウドリソースが一つのコンフィギュレーションファイルに記述されてしまうモノリシックな構成を抑止する</li>
</ul>
</li>
<li><strong>コンポーネント内のコンフィギュレーションファイル構成</strong>
<ul>
<li>コンポーネント内の main.tf ファイルにはプロバイダとステートのバックエンドのみを記述する</li>
<li>具体的なクラウドリソースの宣言は、適切なファイル名を付けた (main.tf とは別の) コンフィギュレーションファイルに記述する</li>
<li>度々現れるクラウドリソースの組み合わせは Terraform モジュールを用意してそちらに宣言を記述する</li>
<li>これにより、コンポーネントの main.tf にすべてのクラウドリソースが記述されてしまうようなモノリシックなファイル構成をある程度抑止する</li>
</ul>
</li>
<li><strong>Terraform モジュール</strong>
<ul>
<li>主に可読性と DRY (Don’t repeat yourself) 原則を守る目的で、コンポーネントよりも小さい単位で一緒に管理したいクラウドリソースを宣言する際に利用する</li>
</ul>
</li>
<li><strong>環境ごとのコンフィギュレーション</strong>
<ul>
<li>原則として同一のコンフィギュレーションをそれぞれの環境で利用することで、環境ごとのクラウドリソースの差異を小さく保つようにする</li>
<li>ステージングやプロダクションそれぞれで固有の値などの具体的な環境の差異は、<a href="https://www.terraform.io/docs/language/values/variables.html">Terraform の変数</a>で表現する</li>
<li>クラウドリソースの維持コスト的にプロダクション環境とそれ以外の環境とで同一の構成をとるのが現実的ではない場合は、サブディレクトリを用意して個別のコンポーネントとして扱う
<ul>
<li>それぞれのコンポーネントはステージングの構成のみ、プロダクションの構成のみが記述される</li>
</ul>
</li>
</ul>
</li>
</ul>
<p>具体的なディレクトリ構成・ファイル構成は以下のようになっています。<sup id="fnref:3" role="doc-noteref"><a href="#fn:3" class="footnote" rel="footnote">3</a></sup></p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>(root)
├ modules/ ... マイクロサービスやコンポーネントに依存しない形で関連するリソースをモジュールとしてまとめて定義する。
│ │ ここに配置したモジュールは、一つ以上のマイクロサービス/コンポーネントの Terraform コンフィギュレーションで参照される。
│ ├ some_module/
│ │ ├ main.tf ... ここで具体的なリソースを定義する (provider や backend の記述は、この main.tf では不要)。
│ │ └ variables.tf ... このモジュールが要求する変数をここで宣言する。モジュールを利用する側の Terraform コンフィギュレーションでは、利用の際にこの変数の具体的な値を定義する必要がある。
│ └ .../
│
├ environments/ ... 環境別の tfvars ファイルを保持するディレクトリ。
│ │ マイクロサービスやコンポーネントに依らない変数 (GCP のプロジェクト ID など) はここで定義する。
│ ├ dev.tfvars ... 開発環境に関する変数をこのファイルで定義する。
│ └ prod.tfvars ... プロダクション環境に関する変数をこのファイルで定義する。
│
└ components/ ... マイクロサービス/コンポーネントに依存するインフラ構成の Terraform コンフィギュレーションをここで定義する。
├ some_app/
│ ├ main.tf ... provider や backend などをこのファイルで定義する。
│ │ 個々の都合に応じて、provider のバージョンを適宜選択して構わない。
│ ├ some_app.tf ... このマイクロサービス/コンポーネントに関する定義を適度にファイル分割して記述する。
│ │ 定義済みのモジュールを利用してもいいし、または直接ここでリソースを定義してもよい。
│ ├ variables.tf ... このマイクロサービス/コンポーネントの変数を宣言する。ここで宣言すべき変数は、基本的には環境に依存するものとなるはずである。
│ │ 宣言された変数は dev.tfvars/prod.tfvars にて定義する必要がある。
│ ├ dev.tfvars ... このマイクロサービス/コンポーネントに固有であり、かつ開発環境に依存する変数をここで定義する。
│ └ prod.tfvars ... プロダクション環境に依存する変数をここで定義する。
└ .../
</code></pre></div></div>
<h3 id="terraform-cli-のバージョンアップ">Terraform CLI のバージョンアップ</h3>
<p>以前から Terraform をお使いの方はご存知かと存じますが、Terraform はバージョン 1.0 に至るまでに頻繁にマイナーバージョンアップを繰り返してきました。バージョン 1.0 より前の Terraform を利用するにあたっては、この頻繁なバージョンアップとどう付き合うかが運用上のポイントの一つになり得ました。</p>
<p>Legalscape が Terraform 導入に向けて実際に動き始めたのは 2020 年 8 月の上旬で、ちょうどバージョン 0.13 のリリースを控えていた時期でした。この微妙なタイミングにおいてバージョン 0.13 のリリースを待つか現行の 0.12 で行くかを検討した結果、新機能よりも安定性を優先してバージョン 0.12 を採用することにしました。</p>
<p>Terraform を実行する環境を整備するにあたって、弊社では当初から <a href="https://hub.docker.com/r/hashicorp/terraform/">HashiCorp 公式の Docker イメージ</a> を利用し、以下のラッパースクリプト<sup id="fnref:4" role="doc-noteref"><a href="#fn:4" class="footnote" rel="footnote">4</a></sup>と Makefile を用意して Terraform のコマンドを実行できるようにしていました。</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">#!/usr/bin/env bash</span>
<span class="nv">COMMAND</span><span class="o">=</span><span class="s2">"</span><span class="nv">$1</span><span class="s2">"</span>
<span class="nv">BASE_DIR</span><span class="o">=</span><span class="s2">"</span><span class="si">$(</span><span class="nb">cd</span> <span class="s2">"</span><span class="si">$(</span><span class="nb">dirname</span> <span class="s2">"</span><span class="nv">$0</span><span class="s2">"</span><span class="si">)</span><span class="s2">"</span>/.. <span class="o">||</span> <span class="nb">exit </span>1<span class="p">;</span> <span class="nb">pwd</span> <span class="nt">-P</span><span class="si">)</span><span class="s2">"</span>
<span class="nv">TERRAFORM_DOCKER_IMAGE</span><span class="o">=</span><span class="s2">"hashicorp/terraform:0.12.31"</span>
docker run <span class="nt">-it</span> <span class="nt">--rm</span> <span class="se">\</span>
<span class="nt">--name</span> terraform-cli <span class="se">\</span>
<span class="nt">--env</span> <span class="nv">GOOGLE_APPLICATION_CREDENTIALS</span><span class="o">=</span>/adc.json <span class="se">\</span>
<span class="nt">--env</span> <span class="nv">TF_VAR_env</span><span class="o">=</span><span class="s2">"</span><span class="k">${</span><span class="nv">TF_VAR_env</span><span class="k">}</span><span class="s2">"</span> <span class="se">\</span>
<span class="nt">--mount</span> <span class="nb">type</span><span class="o">=</span><span class="nb">bind</span>,source<span class="o">=</span><span class="s2">"</span><span class="k">${</span><span class="nv">BASE_DIR</span><span class="k">}</span><span class="s2">"</span>,target<span class="o">=</span>/work <span class="se">\</span>
<span class="nt">--mount</span> <span class="nb">type</span><span class="o">=</span><span class="nb">bind</span>,source<span class="o">=</span><span class="s2">"</span><span class="k">${</span><span class="nv">GOOGLE_APPLICATION_CREDENTIALS</span><span class="k">}</span><span class="s2">"</span>,target<span class="o">=</span>/adc.json <span class="se">\</span>
<span class="nt">--workdir</span> <span class="s2">"/work/</span><span class="nv">$2</span><span class="s2">"</span> <span class="se">\</span>
<span class="k">${</span><span class="nv">TERRAFORM_DOCKER_IMAGE</span><span class="k">}</span> <span class="se">\</span>
<span class="s2">"</span><span class="k">${</span><span class="nv">COMMAND</span><span class="k">}</span><span class="s2">"</span> <span class="s2">"</span><span class="k">${</span><span class="p">@</span>:3<span class="k">}</span><span class="s2">"</span>
</code></pre></div></div>
<div class="language-makefile highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">TERRAFORM</span> <span class="o">=</span> <span class="nv">TF_VAR_env</span><span class="o">=</span><span class="nv">$(ENV)</span> bin/terraform.sh
<span class="nv">STATE_BACKEND_BUCKET</span> <span class="o">=</span> bucket-name-to-store-state
<span class="nl">.PHONY</span><span class="o">:</span> <span class="nf">init plan apply</span>
<span class="nl">init</span><span class="o">:</span>
<span class="nv">$(TERRAFORM)</span> init <span class="nv">$(TARGET)</span> <span class="se">\</span>
<span class="nt">-reconfigure</span> <span class="se">\</span>
<span class="nt">-backend-config</span><span class="o">=</span><span class="s2">"bucket=</span><span class="nv">$(STATE_BACKEND_BUCKET)</span><span class="s2">-</span><span class="nv">$(ENV)</span><span class="s2">"</span>
<span class="nl">plan</span><span class="o">:</span> <span class="nf">init</span>
<span class="nv">$(TERRAFORM)</span> plan <span class="nv">$(TARGET)</span> <span class="se">\</span>
<span class="nt">-var-file</span><span class="o">=</span>/work/environments/<span class="nv">$(ENV)</span>.tfvars <span class="se">\</span>
<span class="nt">-var-file</span><span class="o">=</span><span class="nv">$(ENV)</span>.tfvars
<span class="nl">apply</span><span class="o">:</span> <span class="nf">init</span>
<span class="nv">$(TERRAFORM)</span> apply <span class="nv">$(TARGET)</span> <span class="se">\</span>
<span class="nt">-var-file</span><span class="o">=</span>/work/environments/<span class="nv">$(ENV)</span>.tfvars <span class="se">\</span>
<span class="nt">-var-file</span><span class="o">=</span><span class="nv">$(ENV)</span>.tfvars
</code></pre></div></div>
<p>これにより、開発者の作業環境に Terraform CLI を事前にインストールをすることなく、かつ皆が同じ Terraform バージョンを使って気軽に <code class="language-plaintext highlighter-rouge">terraform apply</code> 相当の操作をできるようになっています。</p>
<p>しかし時が過ぎ Terraform のマイナーバージョンがどんどん上がるにつれて、新たなコンポーネントを作る際にバージョン 0.13 以降で導入された機能<sup id="fnref:5" role="doc-noteref"><a href="#fn:5" class="footnote" rel="footnote">5</a></sup>を使いたいという要望が出始めました。一方でこれまでに作られてきたコンポーネントのコンフィギュレーションおよびステートすべてを 0.13 以降のバージョンでも利用できるようにアップデートするのは骨の折れる作業であったため、コンポーネントごとに異なる Terraform バージョンを利用できる仕組みを導入して解消することにしました。</p>
<p>具体的には以下のようにラッパースクリプトを書き換え、各コンポーネントのディレクトリ直下に .tfversion ファイルが存在すればそこで指定された Terraform バージョンを利用するようにします。</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">#!/usr/bin/env bash</span>
<span class="nv">COMMAND</span><span class="o">=</span><span class="s2">"</span><span class="nv">$1</span><span class="s2">"</span>
<span class="nv">TARGET</span><span class="o">=</span><span class="s2">"</span><span class="nv">$2</span><span class="s2">"</span>
<span class="nv">BASE_DIR</span><span class="o">=</span><span class="s2">"</span><span class="si">$(</span><span class="nb">cd</span> <span class="s2">"</span><span class="si">$(</span><span class="nb">dirname</span> <span class="s2">"</span><span class="nv">$0</span><span class="s2">"</span><span class="si">)</span><span class="s2">"</span>/.. <span class="o">||</span> <span class="nb">exit </span>1<span class="p">;</span> <span class="nb">pwd</span> <span class="nt">-P</span><span class="si">)</span><span class="s2">"</span>
<span class="nv">TERRAFORM_VERSION</span><span class="o">=</span><span class="s2">"0.12.31"</span>
<span class="k">if</span> <span class="o">[</span> <span class="nt">-f</span> <span class="s2">"</span><span class="k">${</span><span class="nv">TARGET</span><span class="k">}</span><span class="s2">/.tfversion"</span> <span class="o">]</span><span class="p">;</span> <span class="k">then
</span><span class="nv">TERRAFORM_VERSION</span><span class="o">=</span><span class="s2">"</span><span class="si">$(</span><span class="nb">cat</span> <span class="s2">"</span><span class="k">${</span><span class="nv">TARGET</span><span class="k">}</span><span class="s2">/.tfversion"</span> | <span class="nb">tr</span> <span class="nt">-d</span> <span class="s1">'\n'</span><span class="si">)</span><span class="s2">"</span>
<span class="k">fi
</span><span class="nv">TERRAFORM_DOCKER_IMAGE</span><span class="o">=</span><span class="s2">"hashicorp/terraform:</span><span class="k">${</span><span class="nv">TERRAFORM_VERSION</span><span class="k">}</span><span class="s2">"</span>
docker run <span class="nt">-it</span> <span class="nt">--rm</span> <span class="se">\</span>
<span class="nt">--name</span> terraform-cli <span class="se">\</span>
<span class="nt">--env</span> <span class="nv">GOOGLE_APPLICATION_CREDENTIALS</span><span class="o">=</span>/adc.json <span class="se">\</span>
<span class="nt">--env</span> <span class="nv">TF_VAR_env</span><span class="o">=</span><span class="s2">"</span><span class="k">${</span><span class="nv">TF_VAR_env</span><span class="k">}</span><span class="s2">"</span> <span class="se">\</span>
<span class="nt">--mount</span> <span class="nb">type</span><span class="o">=</span><span class="nb">bind</span>,source<span class="o">=</span><span class="s2">"</span><span class="k">${</span><span class="nv">BASE_DIR</span><span class="k">}</span><span class="s2">"</span>,target<span class="o">=</span>/work <span class="se">\</span>
<span class="nt">--mount</span> <span class="nb">type</span><span class="o">=</span><span class="nb">bind</span>,source<span class="o">=</span><span class="s2">"</span><span class="k">${</span><span class="nv">GOOGLE_APPLICATION_CREDENTIALS</span><span class="k">}</span><span class="s2">"</span>,target<span class="o">=</span>/adc.json <span class="se">\</span>
<span class="nt">--workdir</span> <span class="s2">"/work/</span><span class="k">${</span><span class="nv">TARGET</span><span class="k">}</span><span class="s2">"</span> <span class="se">\</span>
<span class="k">${</span><span class="nv">TERRAFORM_DOCKER_IMAGE</span><span class="k">}</span> <span class="se">\</span>
<span class="s2">"</span><span class="k">${</span><span class="nv">COMMAND</span><span class="k">}</span><span class="s2">"</span> <span class="s2">"</span><span class="k">${</span><span class="p">@</span>:3<span class="k">}</span><span class="s2">"</span>
</code></pre></div></div>
<p>これによりすべてのコンポーネントの Terraform アップグレードをせずとも、コンポーネント単位で気軽に最新バージョンの Terraform を利用できるようになりました。</p>
<h3 id="terraform-で管理されているクラウドリソースの一例">Terraform で管理されているクラウドリソースの一例</h3>
<p>Terraform 導入当初はトライアル的に Cloud Monitoring の外部監視アラートをコンフィギュレーションで記述してみることから始めましたが、それから 1 年以上が経過して今では Legalscape のプロダクトを構成するクラウドリソースのほとんどが Terraform で管理されるまでになりました。</p>
<p>以下が現時点において Terraform 管理されているクラウドリソースの一例です。</p>
<ul>
<li>Compute Engine インスタンス</li>
<li>Cloud SQL インスタンス</li>
<li>Cloud Load Balancer (URL マップやバックエンドサービスなどの諸々)</li>
<li>Cloud DNS</li>
<li>サービスアカウント / ロール</li>
<li>Cloud Run</li>
<li>Cloud Tasks</li>
<li>Cloud Scheduler</li>
<li>BigQuery データセット / テーブルスキーマ</li>
</ul>
<h2 id="得られた教訓">得られた教訓</h2>
<p>以上が弊社における Terraform の運用状況になります。実際に Terraform を 1 年以上運用してみて得られた教訓は以下になります。</p>
<h3 id="よかったこと-">よかったこと 👍</h3>
<ul>
<li>原則として環境ごとのコンフィギュレーションを用意せずに共通化することで、環境間のシステム構成の差異を小さく留めることができる
<ul>
<li>これにより、開発環境で十分に検証されたコンフィギュレーションをほぼそのままプロダクション環境に展開できている</li>
</ul>
</li>
<li>コンポーネント単位でクラウドリソースを管理することで…
<ul>
<li>既存リソース変更時の影響範囲が把握しやすくなる</li>
<li>コンポーネントごとに自由に Terraform CLI のバージョンを指定したりアップグレードできるようになる</li>
</ul>
</li>
</ul>
<h3 id="よくなかったこと-">よくなかったこと 👎</h3>
<ul>
<li>具体的な「コンポーネント」が当初想定していた「マイクロサービス」ではなく、「同種のクラウドリソースをまとめたもの」となりがち
<ul>
<li>例えばサービスアカウントとそのサービスアカウントに付与する権限を宣言するだけの汎用的なコンポーネントが作られてしまった<sup id="fnref:6" role="doc-noteref"><a href="#fn:6" class="footnote" rel="footnote">6</a></sup></li>
</ul>
</li>
<li>コンポーネント単位でクラウドリソースを管理することで…
<ul>
<li><a href="https://www.terraform.io/docs/language/data-sources/index.html">Data source</a> を利用する必要が時々生じ、コンポーネント間に暗黙的な依存関係が生まれてしまっている</li>
<li>変更頻度の小さいコンポーネントの Terraform バージョンが古いまま取り残されている</li>
</ul>
</li>
</ul>
<h2 id="終わりに">終わりに</h2>
<p>というわけで、今回は Legalscape における Terraform の導入背景と運用状況についてお送りしてきました。</p>
<p>「得られた教訓」にもあるように、弊社の構成がすべてにおいて良い結果になるわけではありませんが、Terraform 導入に迷っていらっしゃる方々にとってこのエントリが多少なりとも参考になれば幸いです。</p>
<p>そして弊社における IaC の取り組みはまだまだ道半ばです。この Terraform の運用の改善はもちろんのこと、新たなツールを導入もしくは開発して、ソフトウェアエンジニアによるインフラストラクチャーの管理をより快適にしてくれる、そんな方を我々はお待ちしております 🤗</p>
<p><a href="https://legalscape.notion.site/09aeb478072946c18249495b8fb63fcd">採用情報</a></p>
<p>最後までお読みいただきありがとうございました!</p>
<hr />
<div class="footnotes" role="doc-endnotes">
<ol>
<li id="fn:1" role="doc-endnote">
<p>個人の感想であり、所属企業の見解を代表するものではございません。 <a href="#fnref:1" class="reversefootnote" role="doc-backlink">↩</a></p>
</li>
<li id="fn:2" role="doc-endnote">
<p>この運用方針は実際に Terraform コンフィギュレーションを管理している git リポジトリの README.md に記載されているものになります。 <a href="#fnref:2" class="reversefootnote" role="doc-backlink">↩</a></p>
</li>
<li id="fn:3" role="doc-endnote">
<p>こちらも git リポジトリの README.md に記載されているものになります。 <a href="#fnref:3" class="reversefootnote" role="doc-backlink">↩</a></p>
</li>
<li id="fn:4" role="doc-endnote">
<p>エントリ掲載の都合上、パラメータチェックやエラーハンドリングなどの記述を省いています。 <a href="#fnref:4" class="reversefootnote" role="doc-backlink">↩</a></p>
</li>
<li id="fn:5" role="doc-endnote">
<p>具体的には、<a href="https://www.hashicorp.com/blog/announcing-hashicorp-terraform-0-13#improvements-to-modules">モジュールを利用する際に for_each を記述できる機能</a> になります。 <a href="#fnref:5" class="reversefootnote" role="doc-backlink">↩</a></p>
</li>
<li id="fn:6" role="doc-endnote">
<p>何を隠そうこの僕がこのコンポーネントを作ってしまったわけですが… 🤦♂️ <a href="#fnref:6" class="reversefootnote" role="doc-backlink">↩</a></p>
</li>
</ol>
</div>KOMIYA AtsushiLegalscape (リーガルスケープ) アドベントカレンダー 2021 の 12/9 (木) のエントリです。Legalscape の現在のシステム構成とこれまでの歩み2021-12-03T00:00:00+09:002021-12-03T00:00:00+09:00https://k11i.biz/blog/2021/12/03/history-of-ls-architecture<p><a href="https://qiita.com/advent-calendar/2021/legalscape">Legalscape アドベントカレンダー 2021</a> のエントリ #3 です。</p>
<p>昨日の <a href="https://yo.eki.do/notes/legalscape-tech-stack/">城戸によるエントリ</a> では、Legalscape を支える技術スタックとその採用理由についてご紹介しました。本日のエントリでは、それらの技術をどのように組み合わせて Legalscape のシステムを構成しているのかを、β版提供開始から現在に至るまでの変遷を振り返りながらご紹介します。</p>
<h2 id="β版提供開始時点のシステム構成">β版提供開始時点のシステム構成</h2>
<p>Legalscape (リーガルスケープ) は 2019 年 9 月にβ版の提供を開始しました。この時点ではプロダクトの成熟度的にまだ仮説検証の段階であったこと、また開発リソースや時間、コストなど諸々の制約から、Google Cloud Platform を利用しつつも以下のような Compute Engine インスタンス一台構成のモノリシックなシステム構成としていました。</p>
<p><img src="/images/2021/12/03/fig1-beta.png" alt="β番提供開始時点のシステム構成" /></p>
<p>現在の Legalscape を構成する主要なコンポーネントはこのときから大きくは変化しておらず、JavaScript や CSS などの各種静的ファイルで構成される <strong>フロントエンド</strong>、フロントエンドから呼び出され、各種ミドルウェアとの連携を仲介する API を提供している <strong>API サーバ</strong>、Google や Microsoft の Azure AD などの認証プロバイダーと連携してユーザの認証を執り行う <strong>認証サービス</strong>、そして法文書の全文検索機能を実現する <strong>検索サーバ</strong> (Elasticsearch クラスタ) で構成されています。</p>
<p>このβ版提供開始の時点では、フロントエンドの静的ファイルは nuxt-ts のプロセス (nuxt-ts start) でビルドしてそのままホストされ、また API サーバと認証サービスはそれぞれ個別の ts-node (w/ express.js) のプロセスでサーブされていました。全文検索には当初から Elasticsearch を利用していましたが、この時点ではまだクラスタ構成をとっておらずシングルノード構成で稼動していました。インターネット側からの HTTPS リクエストはリバースプロキシとして機能する Nginx が受け付けて SSL 終端し、フロントエンド、API サーバ、認証サービスそれぞれのサーバプロセスに HTTP リクエストとして転送する構成となっていました。</p>
<p>なお、システムデザインの経験がある方ならこのシステム構成をみてすぐにお気づきになるかと思いますが、この当時のシステム構成はモノリシックな構成がゆえに障害耐性に難があり、安定的なサービス提供を実現するためにはクラウドプラットフォームが提供するマネージドサービスへの移行や冗長化によって障害耐性を高める必要がありました。</p>
<h2 id="認証サービスのマネージドサービス移行">認証サービスのマネージドサービス移行</h2>
<p>まずはじめに取り組んだのが、認証サービスのマネージドサービス移行です。</p>
<p>当初の認証サービスの実装では、認証処理に <a href="https://www.npmjs.com/package/passport">passport</a><sup id="fnref:1" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup> を利用していました。この認証処理を Firebase Authentication に移行し、認証サービスのプロセス自体も App Engine で稼働させるようにすることで最初のマネージドサービス化を達成しました。</p>
<p><img src="/images/2021/12/03/fig2-auth-managed.png" alt="認証サービスのマネージドサービス移行" /></p>
<h2 id="検索サーバのクラスタ化">検索サーバのクラスタ化</h2>
<p>β版の提供開始から時間が経過するとともに徐々にユーザ数が増加し、また書籍を始めとして Legalscape 上で閲覧できる法律文書の数も増加していきました。すると、法律文書の全文検索を実現している検索サーバの性能問題が顕在化し始めました。</p>
<p>Elasticsearch のプロセスは、この時点ではまだモノリシックな Compute Engine インスタンスに同居していました。そこで検索性能の改善と同時に障害耐性を高めることを目的に、Elasticsearch のプロセスを別の Compute Engine インスタンスに切り出し、またシングルノード構成をやめてクラスタ構成に移行することにしました。</p>
<p><img src="/images/2021/12/03/fig3-es-cluster.png" alt="検索サーバのクラスタ化" /></p>
<h2 id="ロードバランサー導入">ロードバランサー導入</h2>
<p>当初のモノリシック構成であった Compute Engine インスタンスから少しずつプロセスが引き剥がされていき、検索サーバのクラスタ化によってモノリシックな Compute Engine インスタンスにはフロントエンドと API サーバが残っている状態となりました。</p>
<p>このフロントエンドも API サーバもどちらもマネージドサービスに移行することで、より安定したサービス提供が見込めます。しかし Compute Engine インスタンス上で稼動する Nginx によって SSL 終端とバックエンドサービスへのルーティングが依然として行われている限りこの Compute Engine インスタンスが単一障害点であり続け、インスタンスの突然死などで Legalscape のサービス提供が全面的に停止してしまうリスクが存在し続けてしまいます。</p>
<p>ゆえにフロントエンドと API サーバのマネージドサービス移行を実施する前にロードバランサーを導入し、フロントエンドと API サーバのマネージドサービス移行とともに Nginx を完全に退役させる準備を進めることにしました。以下がロードバランサー導入時点のシステム構成図になります。</p>
<p><img src="/images/2021/12/03/fig4-lb.png" alt="ロードバランサー導入" /></p>
<p>なおこの図にあるように、ロードバランサーは稼動系と待機系の 2 系統を用意しています。この 2 系統のロードバランサーは障害発生への備えを目的としたもの<strong>ではなく</strong>、ロードバランサーのルーティング (URL マップ) を書き換える際に数十秒ほどバックエンドサービスに疎通できなくなる問題<sup id="fnref:2" role="doc-noteref"><a href="#fn:2" class="footnote" rel="footnote">2</a></sup> を回避することがその目的になります。</p>
<p>したがってロードバランサーの URL マップを書き換える際は、Blue-green デプロイメントの要領で待機系の URL マップを先に書き換え、DNS の設定変更によって稼動系と待機系を入れ替えるようにしています。</p>
<h2 id="フロントエンドapiサーバのマネージドサービス移行">フロントエンド/APIサーバのマネージドサービス移行</h2>
<p>ロードバランサー導入により、フロントエンドと API サーバのマネージドサービス移行への下準備が整いました。</p>
<p>API サーバに関しては認証サービスと同様に App Engine をマネージドサービスとして利用するのが妥当であると判断しましたが、フロントエンドのホスティングについては以下のように選択肢が複数存在しました。</p>
<ul>
<li>その 1: <strong>App Engine</strong> で <code class="language-plaintext highlighter-rouge">nuxt-ts start</code> のプロセスを稼動させる</li>
<li>その 2: <code class="language-plaintext highlighter-rouge">nuxt-ts build</code> で生成したアセット (JavaScript や CSS などのファイル) を <strong>Cloud Storage</strong> でホスティングする</li>
<li>その 3: <code class="language-plaintext highlighter-rouge">nuxt-ts build</code> で生成したアセットを <strong>Firebase Hosting</strong> でホスティングする</li>
</ul>
<p>結果的に Firebase Hosting を選択することにしましたが、この決断に至った理由としてはコスト面で App Engine よりも有利であったこと、また SPA (single page application) 特有の URL の問題に対して有効な解決策 (URL パスのリライト機能) を有していたことが挙げられます。</p>
<p><img src="/images/2021/12/03/fig5-fully-managed.png" alt="フロントエンド/APIサーバのマネージドサービス移行" /></p>
<h2 id="現在の構成">現在の構成</h2>
<p>β版提供開始当初からの逐次的な改善により、当初のゴールとしていた安定的なサービス提供が実現できるシステム構成に進化させることができました。その後も RDBMS の導入やデータ集計・分析基盤の整備により、現在のシステム構成はより進化したものになっています。</p>
<p><img src="/images/2021/12/03/fig6-latest.png" alt="現在の構成" /></p>
<p>なおシステム構成の進化はこれで終わりかというとそうでもなく、API サーバ全体もしくは CPU/memory-intensive な処理だけを App Engine から Cloud Run に移行するなどしてコストの最適化を試みる計画も進んでいます。</p>
<p>また今回ご紹介したシステム構成は主にエンドユーザーフェーシングな部分にフォーカスしたものであり、Legalscape に掲載する法律情報を自動的に収集・変換するシステムや、リーガル・ウェブを実現するためのアノテーションツールなど、Legalscape のプロダクトを支えているシステムすべてのご紹介には至っておりません。</p>
<p>そういうわけで、今回ご紹介できなかった Legalscape のまだ見知らぬシステムにご興味を抱いた方、または Legalscape を支えるシステム構成をより進化させてみたいという気概をお持ちの方、<a href="https://legalscape.notion.site/09aeb478072946c18249495b8fb63fcd">ちょうど SWE や SRE のポジションを現在募集中</a> でございますのでどうぞ覗いていってみてください!</p>
<h2 id="終わりに">終わりに</h2>
<p>最後までお読みいただきありがとうございました。Legalscape アドベントカレンダー 2021 の次回のエントリは久本から 12/6 (月)<sup id="fnref:3" role="doc-noteref"><a href="#fn:3" class="footnote" rel="footnote">3</a></sup> に公開となります。お楽しみに!</p>
<hr />
<div class="footnotes" role="doc-endnotes">
<ol>
<li id="fn:1" role="doc-endnote">
<p>express.js に統合可能な認証ミドルウェア <a href="#fnref:1" class="reversefootnote" role="doc-backlink">↩</a></p>
</li>
<li id="fn:2" role="doc-endnote">
<p>IssueTracker には <a href="https://issuetracker.google.com/issues/194981048">似たような事例</a> が報告されており、コンフィギュレーション伝搬の遅延がその問題の原因であるようですが、Google 的には「<a href="https://issuetracker.google.com/issues/194981048#comment7">ダウンタイムではない</a>」ということらしいので利用者側で対策を講じるしかなさそうです <a href="#fnref:2" class="reversefootnote" role="doc-backlink">↩</a></p>
</li>
<li id="fn:3" role="doc-endnote">
<p>弊社のアドベントカレンダーは業務の一環としてエントリを書いてるので、土曜・日曜のエントリ公開はございません <a href="#fnref:3" class="reversefootnote" role="doc-backlink">↩</a></p>
</li>
</ol>
</div>KOMIYA AtsushiLegalscape アドベントカレンダー 2021 のエントリ #3 です。timeout-minutes の指定忘れを指摘する GitHub Actions を書いた2020-12-08T00:00:00+09:002020-12-08T00:00:00+09:00https://k11i.biz/blog/2020/12/08/github-actions-to-prevent-waste-of-minute-quota<p><a href="https://docs.github.com/en/free-pro-team@latest/actions/reference/workflow-syntax-for-github-actions#jobsjob_idtimeout-minutes">GitHub Actions の <code class="language-plaintext highlighter-rouge">timeout-minutes</code></a> を明示的に指定しなかったことで minute quota 溶かしをやらかしたので作りました。</p>
<!-- k11i_biz_ia -->
<div align="center" style="margin: 1em 0">
<ins class="adsbygoogle" style="display:block; text-align:center;" data-ad-layout="in-article" data-ad-format="fluid" data-ad-client="ca-pub-6016543666404535" data-ad-slot="7968021645"></ins>
</div>
<script>
(adsbygoogle = window.adsbygoogle || []).push({});
</script>
<h2 id="背景">背景</h2>
<p>パブリックなリポジトリで GitHub Actions を使っていると出くわすことはないかと思われますが、プライベートなリポジトリで GitHub Actions を書いていると <code class="language-plaintext highlighter-rouge">timeout-minutes</code> を指定し損ねたジョブが延々と走り続けて minute quota (Team plan だと 3,000 分/月) を浪費してしまう 😭 みたいなとても悲しいできごとが発生することがまれにあります。</p>
<p>それというのも、<code class="language-plaintext highlighter-rouge">timeout-minutes</code> を指定しなかった場合のデフォルトのタイムアウトが 360 分 (すなわち 6 時間) という長大な値となっているがために、下手こいて終わらないジョブを 9 回走らせてしまうだけでこの quota を綺麗サッパリ食べ尽くしてしまうという落とし穴<sup id="fnref:1" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup>が GitHub Actions には存在するからですね<sup id="fnref:2" role="doc-noteref"><a href="#fn:2" class="footnote" rel="footnote">2</a></sup>。</p>
<h2 id="対策">対策</h2>
<p>こんな凄惨で悲しいできごとを二度と起こしてはいけない…! というわけで何某かの再発防止をしたいわけですが、生憎リポジトリや組織の単位で GitHub Actions ジョブのデフォルトタイムアウトを設定することが現時点ではできない模様です。したがって個別のジョブに <code class="language-plaintext highlighter-rouge">timeout-minutes</code> を設定していく以外の対策が存在せず、その対策も人間が作業するとなるとどうしても「設定漏れ」のリスクが生じてしまいます。</p>
<p>そういうわけで、今回は (minute quota の浪費を防止するために minute quota を 1 分/回消費するという若干本末転倒感が否めないのですが…) <a href="https://github.com/marketplace/actions/enforce-timeout-minutes"><code class="language-plaintext highlighter-rouge">timeout-minutes</code> が指定されていないジョブが存在したら fail させる GitHub Actions</a> を作ってみたのでした。</p>
<h2 id="使い方">使い方</h2>
<p>例えば以下のようなワークフローを用意して、GitHub Actions を利用している各リポジトリの <code class="language-plaintext highlighter-rouge">.github/workflows</code> ディレクトリに配置するだけです。Slack 通知が不要であれば <code class="language-plaintext highlighter-rouge">- name: Slack notification</code> 以降の行を削って構いません。</p>
<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">name</span><span class="pi">:</span> <span class="s">Enforce timeout-minutes</span>
<span class="na">on</span><span class="pi">:</span> <span class="s">push</span>
<span class="na">jobs</span><span class="pi">:</span>
<span class="na">enforce-timeout-minutes</span><span class="pi">:</span>
<span class="na">runs-on</span><span class="pi">:</span> <span class="s">ubuntu-latest</span>
<span class="na">timeout-minutes</span><span class="pi">:</span> <span class="m">2</span>
<span class="na">steps</span><span class="pi">:</span>
<span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Checkout</span>
<span class="na">uses</span><span class="pi">:</span> <span class="s">actions/checkout@v2</span>
<span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Enforce timeout-minutes</span>
<span class="na">id</span><span class="pi">:</span> <span class="s">enforce-timeout-minutes</span>
<span class="na">uses</span><span class="pi">:</span> <span class="s">komiya-atsushi/action-enforce-timeout-minutes@v1.0.0</span>
<span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Slack notification</span>
<span class="na">uses</span><span class="pi">:</span> <span class="s">rtCamp/action-slack-notify@v2</span>
<span class="na">env</span><span class="pi">:</span>
<span class="na">SLACK_MESSAGE</span><span class="pi">:</span> <span class="s">${{ steps.enforce-timeout-minutes.outputs.message }}</span>
<span class="na">SLACK_WEBHOOK</span><span class="pi">:</span> <span class="s">${{ secrets.SLACK_WEBHOOK_URL }}</span>
<span class="na">if</span><span class="pi">:</span> <span class="s">${{ failure() }}</span>
</code></pre></div></div>
<h2 id="余談-typescript-での-github-actions-開発">余談: TypeScript での GitHub Actions 開発</h2>
<p>GitHub Actions の開発は様々な手段があるのですが、今回は TypeScript を利用しました。</p>
<p>JavaScript であれば公式ドキュメントとして <a href="https://docs.github.com/en/free-pro-team@latest/actions/creating-actions/creating-a-javascript-action">Creating a JavaScript action</a> が存在するのでそれを少し参考にしつつ、TypeScript 固有の事情については <a href="https://github.com/actions/typescript-action/">typescript-action</a> という公式 (?) のテンプレート的なリポジトリを大いに参考にしました。</p>
<hr />
<div class="footnotes" role="doc-endnotes">
<ol>
<li id="fn:1" role="doc-endnote">
<p><del>これはどう考えても、なんとかしてアップセル的な課金をさせようとする GitHub によって巧妙に仕組まれた罠だと思うのですがどうなんでしょうか?</del> <a href="#fnref:1" class="reversefootnote" role="doc-backlink">↩</a></p>
</li>
<li id="fn:2" role="doc-endnote">
<p><del>というか、GitHub Teams の課金はユーザー数によって増加するのになんで各種 quota はユーザー数に比例して増加、とはならないんでしょうかね…?</del> <a href="#fnref:2" class="reversefootnote" role="doc-backlink">↩</a></p>
</li>
</ol>
</div>KOMIYA AtsushiGitHub Actions の timeout-minutes を明示的に指定しなかったことで minute quota 溶かしをやらかしたので作りました。Jest で fail したときのコードスニペットの行番号がずれる問題に悩まされる2020-11-06T00:00:00+09:002020-11-06T00:00:00+09:00https://k11i.biz/blog/2020/11/06/ts-jest-and-node-config<p><a href="https://jestjs.io/">Jest</a> でテストコードを書いていたら地味に嫌な挙動に悩まされてしまったので、その現象をメモしておきます。</p>
<!-- k11i_biz_ia -->
<div align="center" style="margin: 1em 0">
<ins class="adsbygoogle" style="display:block; text-align:center;" data-ad-layout="in-article" data-ad-format="fluid" data-ad-client="ca-pub-6016543666404535" data-ad-slot="7968021645"></ins>
</div>
<script>
(adsbygoogle = window.adsbygoogle || []).push({});
</script>
<h2 id="前提条件">前提条件</h2>
<ul>
<li>TypeScript で Jest を用いたテストコードを書いている</li>
<li><a href="https://github.com/lorenwest/node-config">node-config</a> を用いて環境ごとの設定を変更できるようにしている
<ul>
<li><strong>コンフィギュレーションファイルを TypeScript で記述している</strong></li>
</ul>
</li>
<li>Jest でのテスト実行に <a href="https://github.com/kulshekhar/ts-jest">ts-jest</a> を利用する</li>
</ul>
<h2 id="遭遇した現象">遭遇した現象</h2>
<p>(以下は問題を再現させるためのテストコードなので内容はあまり気にしないとして、) このテストコードを <code class="language-plaintext highlighter-rouge">npx jest</code> のようにして実行します。なお現象を再現させるコードすべてを含んだのリポジトリは <a href="https://github.com/komiya-atsushi/node-config-and-ts-jest-demo/">こちら</a> です。</p>
<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">import</span> <span class="o">*</span> <span class="k">as</span> <span class="nx">config</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">config</span><span class="dl">'</span><span class="p">;</span>
<span class="kd">const</span> <span class="nx">msg</span> <span class="o">=</span> <span class="nx">config</span><span class="p">.</span><span class="kd">get</span><span class="p">(</span><span class="dl">'</span><span class="s1">message</span><span class="dl">'</span><span class="p">)</span> <span class="k">as</span> <span class="kr">string</span><span class="p">;</span>
<span class="nx">test</span><span class="p">(</span><span class="dl">'</span><span class="s1">foo</span><span class="dl">'</span><span class="p">,</span> <span class="k">async</span> <span class="p">()</span> <span class="o">=></span> <span class="p">{</span>
<span class="c1">// 当然これは fail しない</span>
<span class="nx">expect</span><span class="p">(</span><span class="mi">1</span><span class="p">).</span><span class="nx">toBe</span><span class="p">(</span><span class="mi">1</span><span class="p">);</span>
<span class="c1">// これだって fail しない</span>
<span class="nx">expect</span><span class="p">({</span><span class="na">a</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span> <span class="na">b</span><span class="p">:</span> <span class="mi">2</span><span class="p">}).</span><span class="nx">toEqual</span><span class="p">({</span><span class="na">a</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span> <span class="na">b</span><span class="p">:</span> <span class="mi">2</span><span class="p">});</span>
<span class="c1">// ここで fail することを期待している</span>
<span class="nx">expect</span><span class="p">(</span><span class="nx">msg</span><span class="p">).</span><span class="nx">toEqual</span><span class="p">(</span><span class="dl">"</span><span class="s2">Hello, world.</span><span class="dl">"</span><span class="p">);</span>
<span class="p">});</span>
</code></pre></div></div>
<p>すると、以下の図に示すように fail した箇所を指し示すコードスニペットにて間違った行を指し示してしまう (fail したと示される箇所の行番号が実際の TypeScript と異なる) 現象が発生します。</p>
<p><img src="/images/2020/11/06/fig1.png" alt="Fail した箇所のコードスニペットを含む結果" /></p>
<p>これは WebStorm などの IDE 上でテストコードを実行した場合も同様で、スタックトレースをクリックして fail した行にジャンプしようとしても、やはり間違った行にジャンプしてしまうといった問題に遭遇します。</p>
<p>なおエラーメッセージ自体は正しい結果となっており、行番号に由来する諸々が壊れてしまっているように見受けられます。</p>
<h2 id="判明している事実">判明している事実</h2>
<p>いまのところ明らかになっているのは、<strong>node-config のコンフィギュレーションファイルを TypeScript で記述していて、テストコードで (もしくはテスト対象のプロダクションコード側でも?) node-config を利用</strong> した場合にこの問題が発生する、ということです。</p>
<p>これを深堀りして調べてみると、どうやら <strong>TypeScript で記述されたコンフィギュレーションファイルを node-config がロードする際に利用している <a href="https://github.com/TypeStrong/ts-node">ts-node</a> がこの問題に影響を及ぼしていそう</strong> だということがわかりました (node-config は、TypeScript のコンフィギュレーションファイルをロードするために <a href="https://github.com/lorenwest/node-config/blob/606830873bb1acbad9a8960b75647fbe00fe6e05/parser.js#L56-L61">ts-node を require</a> して利用 (register()) しています)。</p>
<p>また、ずれた行番号について調べてみると、どうやら <strong>トランスパイルされた結果の JavaScript コード側における当該アサーションの行番号</strong> に一致することがわかりました (これは偶然の結果かもしれませんが…)。</p>
<p>したがって、<em>ts-node によって TypeScript のコードが JavaScript にトランスパイルされる</em> ことが何かよからぬ影響を生み出しているのかな… と考えています (実際に、node-config 使わずテストコード内で ts-node を require してみたら同様の問題が発生したので、ts-node が影響している (ように見える) ことはほぼ確実です)。</p>
<h2 id="問題の回避方法">問題の回避方法</h2>
<p>node-config が利用する ts-node をどうこうできれば解決できる問題なのかもしれませんが、生憎良い手段が見つかっていません。したがって、手っ取り早い回避手段としては <strong>TypeScript でのコンフィギュレーション記述を諦めて JS or JSON で記述する</strong> になります。</p>
<p>もしくは <strong>node-config 以外のライブラリを使う</strong> になるかと思いますが、なにぶん TypeScript 歴が浅いがためによい alternative なライブラリを把握しておりません。どなたか良質なコンフィギュレーション系ライブラリの情報を教えてください…</p>KOMIYA AtsushiJest でテストコードを書いていたら地味に嫌な挙動に悩まされてしまったので、その現象をメモしておきます。TypeScript で CLI アプリケーションを作って GitHub repo でホストする2020-10-29T00:00:00+09:002020-10-29T00:00:00+09:00https://k11i.biz/blog/2020/10/29/cli-in-typescript<p>最近お仕事関連で TypeScript 製の CLI アプリケーションを実装することがあり、 <code class="language-plaintext highlighter-rouge">npm install</code> できるようになるまでに何かとハマりがちだったので、メモをここに遺しておきます。</p>
<!-- k11i_biz_ia -->
<div align="center" style="margin: 1em 0">
<ins class="adsbygoogle" style="display:block; text-align:center;" data-ad-layout="in-article" data-ad-format="fluid" data-ad-client="ca-pub-6016543666404535" data-ad-slot="7968021645"></ins>
</div>
<script>
(adsbygoogle = window.adsbygoogle || []).push({});
</script>
<h2 id="前提条件">前提条件</h2>
<ul>
<li>npm には公開せず、private な GitHub リポジトリでホストしたい
<ul>
<li>社内で使う内製アプリケーションなどを想定している</li>
</ul>
</li>
<li>GitHub Package Registry も利用しない
<ul>
<li>使ってもよかったが、諸々を整備するのに面倒だった (それ以外の特別な理由はない)</li>
</ul>
</li>
<li><strong>トランスパイルされた JavaScript コードは git リポジトリに含めたくない</strong>
<ul>
<li>ゆえに、<code class="language-plaintext highlighter-rouge">npm install</code> した時に自動的に <code class="language-plaintext highlighter-rouge">tsc</code> でトランスパイルして JS コードを生成する必要がある</li>
</ul>
</li>
<li>実行に不要な依存パッケージは <code class="language-plaintext highlighter-rouge">devDependencies</code> に押し込みたい
<ul>
<li><code class="language-plaintext highlighter-rouge">typescript</code> も (トランスパイルには必要だけど) 実行には不要なので <code class="language-plaintext highlighter-rouge">devDependencies</code> にする</li>
</ul>
</li>
</ul>
<h2 id="packagejson-の構成">package.json の構成</h2>
<p><a href="https://github.com/komiya-atsushi/typescript-cli-example">サンプルの git リポジトリ</a> にあるように、以下のような記述になります。</p>
<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span>
<span class="dl">"</span><span class="s2">name</span><span class="dl">"</span><span class="p">:</span> <span class="dl">"</span><span class="s2">typescript-cli-example</span><span class="dl">"</span><span class="p">,</span>
<span class="dl">"</span><span class="s2">version</span><span class="dl">"</span><span class="p">:</span> <span class="dl">"</span><span class="s2">1.0.0</span><span class="dl">"</span><span class="p">,</span>
<span class="dl">"</span><span class="s2">description</span><span class="dl">"</span><span class="p">:</span> <span class="dl">"</span><span class="s2">Minimal example of the CLI app written in TypeScript.</span><span class="dl">"</span><span class="p">,</span>
<span class="dl">"</span><span class="s2">bin</span><span class="dl">"</span><span class="p">:</span> <span class="p">{</span>
<span class="c1">// トランスパイルにより生成される JavaScript ファイルを直接指定しても構わない</span>
<span class="c1">// その場合は元の .ts ファイルにシバン (#!/usr/bin/env node) を記述する必要がある</span>
<span class="dl">"</span><span class="s2">typescript-cli-example</span><span class="dl">"</span><span class="p">:</span> <span class="dl">"</span><span class="s2">./bin/typescript-cli-example</span><span class="dl">"</span>
<span class="p">},</span>
<span class="dl">"</span><span class="s2">scripts</span><span class="dl">"</span><span class="p">:</span> <span class="p">{</span>
<span class="c1">// install/preinstall など **ではなく**、この prepare ライフサイクルで</span>
<span class="c1">// tsc を実行する必要がある</span>
<span class="dl">"</span><span class="s2">prepare</span><span class="dl">"</span><span class="p">:</span> <span class="dl">"</span><span class="s2">tsc -p .</span><span class="dl">"</span><span class="p">,</span>
<span class="dl">"</span><span class="s2">test</span><span class="dl">"</span><span class="p">:</span> <span class="dl">"</span><span class="s2">echo </span><span class="se">\"</span><span class="s2">Error: no test specified</span><span class="se">\"</span><span class="s2"> && exit 1</span><span class="dl">"</span>
<span class="p">},</span>
<span class="dl">"</span><span class="s2">license</span><span class="dl">"</span><span class="p">:</span> <span class="dl">"</span><span class="s2">ISC</span><span class="dl">"</span><span class="p">,</span>
<span class="dl">"</span><span class="s2">devDependencies</span><span class="dl">"</span><span class="p">:</span> <span class="p">{</span>
<span class="dl">"</span><span class="s2">@types/node</span><span class="dl">"</span><span class="p">:</span> <span class="dl">"</span><span class="s2">^14.14.0</span><span class="dl">"</span><span class="p">,</span>
<span class="dl">"</span><span class="s2">typescript</span><span class="dl">"</span><span class="p">:</span> <span class="dl">"</span><span class="s2">^4.0.3</span><span class="dl">"</span>
<span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>
<h2 id="インストール方法">インストール方法</h2>
<p>この CLI のパッケージをインストールする際は、 <code class="language-plaintext highlighter-rouge">npm install 'git+ssh://git@github.com/<organizationまたはユーザ名>/<リポジトリ名>#<ブランチ名もしくはタグ>'</code> のように指定します。以下はサンプルの CLI をインストールする際の指定になります。</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>npm <span class="nb">install</span> <span class="s1">'git+ssh://git@github.com/komiya-atsushi/typescript-cli-example#main'</span>
</code></pre></div></div>
<h2 id="ハマりポイント">ハマりポイント</h2>
<h3 id="トランスパイルに適したライフサイクル">トランスパイルに適したライフサイクル</h3>
<p><code class="language-plaintext highlighter-rouge">npm install</code> でインストールできるパッケージを作るために npm を触るのは実に久しぶり (GitHub のログを見る限りでは 9 年ぶりだった) なので以前のことは全く覚えていないしそもそも詳しくなかったのですが、まあ <code class="language-plaintext highlighter-rouge">install</code> あたりのライフサイクルで <code class="language-plaintext highlighter-rouge">tsc</code> すればいいんじゃね? ぐらいのノリで当初は考えていました。</p>
<p>でも実際に <a href="https://github.com/komiya-atsushi/typescript-cli-example/tree/bad-example/use-install-lifecycle-script">こんな感じ</a> で <a href="https://docs.npmjs.com/cli/v6/using-npm/scripts"><code class="language-plaintext highlighter-rouge">scripts</code> フィールド</a> の <code class="language-plaintext highlighter-rouge">install</code> ライフサイクルで <code class="language-plaintext highlighter-rouge">tsc -p .</code> と記述してみたところ、以下のように <code class="language-plaintext highlighter-rouge">sh: tsc: command not found</code> となってしまいトランスパイルできませんでした。つまりは <code class="language-plaintext highlighter-rouge">devDependencies</code> の依存パッケージは <code class="language-plaintext highlighter-rouge">install</code> ライフサイクルでは扱えない、ということですね。困った困った…</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>❯ npm i 'git+ssh://git@github.com/komiya-atsushi/typescript-cli-example#bad-example/use-install-lifecycle-script'
> typescript-cli-example@1.0.0 install /Users/atsushi/tmp/typescript-cli-install-dir/node_modules/typescript-cli-example
> tsc -p .
sh: tsc: command not found
npm WARN typescript-cli-install-dir@1.0.0 No description
npm WARN typescript-cli-install-dir@1.0.0 No repository field.
npm ERR! file sh
npm ERR! code ELIFECYCLE
npm ERR! errno ENOENT
npm ERR! syscall spawn
npm ERR! typescript-cli-example@1.0.0 install: `tsc -p .`
npm ERR! spawn ENOENT
npm ERR!
npm ERR! Failed at the typescript-cli-example@1.0.0 install script.
npm ERR! This is probably not a problem with npm. There is likely additional logging output above.
npm ERR! A complete log of this run can be found in:
npm ERR! /Users/atsushi/.npm/_logs/xxx-debug.log
</code></pre></div></div>
<p>この件について調べてみると「<code class="language-plaintext highlighter-rouge">postinstall-build</code> を使うといいよ」的な記事が見つかったものの、当の <a href="https://www.npmjs.com/package/postinstall-build"><code class="language-plaintext highlighter-rouge">postinstall-build</code> のページ</a> では <strong>This package has been deprecated</strong> と書かれていて「代わりに <code class="language-plaintext highlighter-rouge">prepare</code> ライフサイクルを使ってね」と綴られていました。</p>
<p>そういうわけで改めて <code class="language-plaintext highlighter-rouge">scripts</code> フィールドのドキュメントを確認してみると、<a href="https://docs.npmjs.com/cli/v6/using-npm/scripts#life-cycle-scripts">Life Cycle Scripts のセクション</a> の <code class="language-plaintext highlighter-rouge">prepare</code> のところには</p>
<blockquote>
<p>NOTE: If a package being installed through git contains a prepare script, its dependencies and devDependencies will be installed, and the prepare script will be run, before the package is packaged and installed.</p>
</blockquote>
<p>と書かれています。</p>
<p>つまり「<em>git リポジトリからパッケージをインストールする場合において <code class="language-plaintext highlighter-rouge">prepare</code> ライフサイクルのスクリプトが存在すると、<code class="language-plaintext highlighter-rouge">dependencies</code>/<code class="language-plaintext highlighter-rouge">devDependencies</code> 両方の依存パッケージがインストールされた上で <code class="language-plaintext highlighter-rouge">prepare</code> で指定されたスクリプトが実行され、その後にパッケージ本体がインストールされる</em>」ということで、確かに今回の前提条件 (git リポジトリからインストールするケース) には合致します。</p>
<p>ゆえに、<strong>git リポジトリからインストールする場合は <code class="language-plaintext highlighter-rouge">prepare</code> ライフサイクルで <code class="language-plaintext highlighter-rouge">tsc</code> を実行してトランスパイルするのが正解</strong> ということになります。</p>
<h3 id="gitignore-でのトランスパイル結果ファイルの除外">.gitignore でのトランスパイル結果ファイルの除外</h3>
<p>TypeScript でアプリケーションを書いていると、<code class="language-plaintext highlighter-rouge">tsc</code> によって生成されるトランスパイル結果の JavaScript ファイルを <code class="language-plaintext highlighter-rouge">.gitignore</code> ファイルで明示的に git リポジトリから除外する設定にしたくなるかもしれません。しかし <a href="https://github.com/komiya-atsushi/typescript-cli-example/tree/bad-example/gitignore-transpiled-files">実際にそのような設定をしてしまう</a> と、たとえトランスパイルが成功したとしても生成された JavaScript ファイルは最終的なインストール結果には含まれない模様です。</p>
<p>その結果、package.json の <code class="language-plaintext highlighter-rouge">bin</code> フィールドで直接トランスパイル結果の JavaScript ファイルを指定している場合には <code class="language-plaintext highlighter-rouge">chmod</code> できずに (<code class="language-plaintext highlighter-rouge">no such file or directory</code> になるやつ、下記エラーログ参照) CLI のインストールに失敗してしまったり、もしくは CLI のインストールはできても実際に CLI を叩くと JavaScript ファイルが見つからない的な実行時エラーが発生してしまったりします。</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>❯ npm i 'git+ssh://git@github.com/komiya-atsushi/typescript-cli-example#bad-example/specify-transpiled-entry-point-as-bin'
npm WARN typescript-cli-install-dir@1.0.0 No description
npm WARN typescript-cli-install-dir@1.0.0 No repository field.
npm ERR! path /Users/atsushi/tmp/typescript-cli-install-dir/node_modules/typescript-cli-example/src/cli.js
npm ERR! code ENOENT
npm ERR! errno -2
npm ERR! syscall chmod
npm ERR! enoent ENOENT: no such file or directory, chmod '/Users/atsushi/tmp/typescript-cli-install-dir/node_modules/typescript-cli-example/src/cli.js'
npm ERR! enoent This is related to npm not being able to find a file.
npm ERR! enoent
npm ERR! A complete log of this run can be found in:
npm ERR! /Users/atsushi/.npm/_logs/xxx-debug.log
</code></pre></div></div>
<p>この問題を回避する妙案を僕は把握しておらず、<strong><code class="language-plaintext highlighter-rouge">.gitignore</code> ファイルにはトランスパイル結果のファイルを指定しない</strong> ようにしています 😢</p>
<h2 id="まとめ">まとめ</h2>
<p>リリースアーティファクトであるパッケージを作らずに、TypeScript のコードをホスティングしている git リポジトリから CLI のパッケージをトランスパイルしつつインストールさせるのは通常はあまり採用しない手段かとは思うのですが、ハマりどころはあるにしても社内ツールを手軽に共有する手段としては悪くないかと思います。</p>KOMIYA Atsushi最近お仕事関連で TypeScript 製の CLI アプリケーションを実装することがあり、 npm install できるようになるまでに何かとハマりがちだったので、メモをここに遺しておきます。分岐命令なしに 256 ビット整数の下位 k ビットの population count を求める2020-04-08T00:00:00+09:002020-04-08T00:00:00+09:00https://k11i.biz/blog/2020/04/08/branchless-k-bit-population-counting-for-multi-word-integers<p>複数ワードで構成される一つの整数の下位 k ビットに対する population count を分岐命令なしに実現する方法について Java で考えてみました。</p>
<!-- k11i_biz_ia -->
<div align="center" style="margin: 1em 0">
<ins class="adsbygoogle" style="display:block; text-align:center;" data-ad-layout="in-article" data-ad-format="fluid" data-ad-client="ca-pub-6016543666404535" data-ad-slot="7968021645"></ins>
</div>
<script>
(adsbygoogle = window.adsbygoogle || []).push({});
</script>
<h2 id="はじめに">はじめに</h2>
<p>ランククエリを実現するランク辞書実装の中には、複数個 (n 個) のワードで構成される大きなビット数の整数に対して下位 k ビットだけの <a href="https://en.wikipedia.org/wiki/Hamming_weight">population count (a.k.a. hamming weight)</a> の計算を必要とする実装が存在します。具体的には <a href="https://dl.acm.org/doi/10.1002/spe.2198">Rank-1L</a> や <a href="https://link.springer.com/chapter/10.1007/978-3-642-38527-8_15">Poppy</a> がそれに相当し、例えば Rank-1L であれば、64 * 4 = 256 ビットの整数に対する下位 k ビットの population count の計算が必要となります。</p>
<p>単に 1 ワードだけを対象とした下位 k ビットの population count であれば、以下のようにビットシフト、除算、ビット積、POPCNT 命令をそれぞれ 1 下位ずつ実行するだけで容易に実現できます。</p>
<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">public</span> <span class="kd">interface</span> <span class="nc">SingleLongPopulationCount</span> <span class="o">{</span>
<span class="kd">static</span> <span class="kt">int</span> <span class="nf">popcnt</span><span class="o">(</span><span class="kt">long</span> <span class="n">value</span><span class="o">,</span> <span class="kt">int</span> <span class="n">k</span><span class="o">)</span> <span class="o">{</span>
<span class="k">return</span> <span class="nc">Long</span><span class="o">.</span><span class="na">bitCount</span><span class="o">(</span><span class="n">value</span> <span class="o">&</span> <span class="o">((</span><span class="mi">1L</span> <span class="o"><<</span> <span class="n">k</span><span class="o">)</span> <span class="o">-</span> <span class="mi">1</span><span class="o">));</span>
<span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>
<p>しかしながら 2 ワード以上を対象とする場合は事情が異なります。ワードをまたいで一括でビット演算を実現するのは (AVX ほげほげを利用すれば実現できるかもしれませんが) Java では難しく、愚直に実装するならば以下のようにループを用いたコードのようになります<sup id="fnref:1" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup>。しかしこの実装の場合は、ループカウンタの部分で <strong>1 回以上の分岐命令</strong> の実行が必要になります。</p>
<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">public</span> <span class="kd">interface</span> <span class="nc">LongsPopulationCount</span> <span class="o">{</span>
<span class="kd">static</span> <span class="kt">int</span> <span class="nf">popcnt</span><span class="o">(</span><span class="kt">long</span><span class="o">[]</span> <span class="n">values</span><span class="o">,</span> <span class="kt">int</span> <span class="n">k</span><span class="o">)</span> <span class="o">{</span>
<span class="kt">int</span> <span class="n">l</span> <span class="o">=</span> <span class="n">k</span> <span class="o">/</span> <span class="mi">64</span><span class="o">;</span>
<span class="kt">int</span> <span class="n">result</span> <span class="o">=</span> <span class="nc">Long</span><span class="o">.</span><span class="na">bitCount</span><span class="o">(</span><span class="n">values</span><span class="o">[</span><span class="n">l</span><span class="o">]</span> <span class="o">&</span> <span class="o">((</span><span class="mi">1L</span> <span class="o"><<</span> <span class="n">k</span><span class="o">)</span> <span class="o">-</span> <span class="mi">1</span><span class="o">));</span>
<span class="k">for</span> <span class="o">(</span><span class="kt">int</span> <span class="n">i</span> <span class="o">=</span> <span class="mi">0</span><span class="o">;</span> <span class="n">i</span> <span class="o"><</span> <span class="n">l</span><span class="o">;</span> <span class="n">i</span><span class="o">++)</span> <span class="o">{</span> <span class="c1">// ループカウンタのチェックで分岐命令が実行される</span>
<span class="n">result</span> <span class="o">+=</span> <span class="nc">Long</span><span class="o">.</span><span class="na">bitCount</span><span class="o">(</span><span class="n">values</span><span class="o">[</span><span class="n">i</span><span class="o">]);</span>
<span class="o">}</span>
<span class="k">return</span> <span class="n">result</span><span class="o">;</span>
<span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>
<p>分岐命令は <a href="https://www.infoq.com/articles/making-code-faster-taming-branches/">Making Your Code Faster by Taming Branches</a> の記事にもあるように、最近の CPU に備わっている分岐予測や投機的実行によってその速度パフォーマンスはある程度よいものが期待できるものの、分岐予測に失敗したときの時間的なコストはそれなりに高くつきます。そのため、速度パフォーマンスをよりよくする場合においては、可能な限り分岐命令の実行回数を削減する、もしくは完全に分岐命令を排除することが望まれます。</p>
<p>もちろん、ランク辞書の速度パフォーマンスにおいては <a href="http://highscalability.com/numbers-everyone-should-know">一般的に知られているレイテンシの差</a> にあるように、分岐予測ミスよりも辞書を参照する際の L1/L2 キャッシュミスの方が支配的となります。ゆえに分岐命令の有無が速度パフォーマンスに <em>深刻な</em> 影響を与えることはないのですが、分岐命令を削減・排除することで速度パフォーマンスの向上が見込めることに違いはない… ということで、このエントリでは分岐命令を排した 256 ビット整数下位 k ビットの population count を計算する Java 実装を紹介し、またその速度パフォーマンスをベンチマーク計測して評価してみます。</p>
<h2 id="分岐命令を排除する方法">分岐命令を排除する方法</h2>
<p>前述したように Java は複数ワードを一括してビット演算するのは難しいため、ここではワードそれぞれに対して異なるビットマスクを用意し、そのワードの個数だけビット演算をする方法を考えてみます。</p>
<p>つまり 256 ビット (64 ビット * 4 ワード) の整数に対して、例えば下位 130 ビットの population count を求めようと思えば、1 ワード目と 2 ワード目はすべてのビットが 1 のビットマスクを用意し、3 ワード目は <code class="language-plaintext highlighter-rouge">0x0000_0000_0000_0003</code> のビットマスク、4 ワード目はすべてのビットが 0 のビットマスクを用意して、それぞれのワードとのビット積を求めた後に 4 回ほど <code class="language-plaintext highlighter-rouge">Long.bitCount()</code> で POPCNT 命令を実行する、という処理になります。</p>
<p>これを分岐命令なしに実現する具体的な実装はいくつか考えられそうですが、今回取り上げるのはこちらです。</p>
<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">public</span> <span class="kd">interface</span> <span class="nc">Popcnt256</span> <span class="o">{</span>
<span class="kd">static</span> <span class="kt">int</span> <span class="nf">branchless</span><span class="o">(</span><span class="kt">long</span><span class="o">[]</span> <span class="n">values</span><span class="o">,</span> <span class="kt">int</span> <span class="n">offset</span><span class="o">,</span> <span class="kt">int</span> <span class="n">bitIndex</span><span class="o">)</span> <span class="o">{</span>
<span class="kt">long</span> <span class="n">b</span> <span class="o">=</span> <span class="mb">0b1110_1101_1011_0111__0000_0111_0011_0001</span><span class="no">L</span> <span class="o">>>></span> <span class="o">(</span><span class="n">bitIndex</span> <span class="o">>></span> <span class="mi">6</span><span class="o">);</span>
<span class="kt">long</span> <span class="n">rightShiftBits</span> <span class="o">=</span> <span class="mi">255L</span> <span class="o">-</span> <span class="n">bitIndex</span><span class="o">;</span>
<span class="kt">long</span> <span class="n">mask0</span> <span class="o">=</span> <span class="o">(</span><span class="mh">0x7fff_ffff_ffff_ffff</span><span class="no">L</span> <span class="o">|</span> <span class="o">(</span><span class="n">b</span> <span class="o"><<</span> <span class="mi">35</span><span class="o">))</span> <span class="o">>></span> <span class="n">rightShiftBits</span><span class="o">;</span>
<span class="kt">long</span> <span class="n">mask1</span> <span class="o">=</span> <span class="o">((</span><span class="mh">0x7fff_ffff_ffff_ffff</span><span class="no">L</span> <span class="o">|</span> <span class="o">(</span><span class="n">b</span> <span class="o"><<</span> <span class="mi">39</span><span class="o">))</span> <span class="o">+</span> <span class="o">(</span><span class="n">b</span> <span class="o">&</span> <span class="mi">1</span><span class="o">))</span> <span class="o">>></span> <span class="n">rightShiftBits</span><span class="o">;</span>
<span class="kt">long</span> <span class="n">mask2</span> <span class="o">=</span> <span class="o">((</span><span class="mh">0x7fff_ffff_ffff_ffff</span><span class="no">L</span> <span class="o">|</span> <span class="o">(</span><span class="n">b</span> <span class="o"><<</span> <span class="mi">43</span><span class="o">))</span> <span class="o">+</span> <span class="o">((</span><span class="n">b</span> <span class="o">>>></span> <span class="mi">4</span><span class="o">)</span> <span class="o">&</span> <span class="mi">1</span><span class="o">))</span> <span class="o">>></span> <span class="n">rightShiftBits</span><span class="o">;</span>
<span class="kt">long</span> <span class="n">mask3</span> <span class="o">=</span> <span class="o">((</span><span class="mh">0x7fff_ffff_ffff_ffff</span><span class="no">L</span> <span class="o">|</span> <span class="o">(</span><span class="n">b</span> <span class="o"><<</span> <span class="mi">47</span><span class="o">))</span> <span class="o">+</span> <span class="o">((</span><span class="n">b</span> <span class="o">>>></span> <span class="mi">8</span><span class="o">)</span> <span class="o">&</span> <span class="mi">1</span><span class="o">))</span> <span class="o">>></span> <span class="n">rightShiftBits</span><span class="o">;</span>
<span class="k">return</span> <span class="nc">Long</span><span class="o">.</span><span class="na">bitCount</span><span class="o">(</span><span class="n">values</span><span class="o">[</span><span class="n">offset</span><span class="o">]</span> <span class="o">&</span> <span class="n">mask0</span><span class="o">)</span>
<span class="o">+</span> <span class="nc">Long</span><span class="o">.</span><span class="na">bitCount</span><span class="o">(</span><span class="n">values</span><span class="o">[</span><span class="n">offset</span> <span class="o">+</span> <span class="mi">1</span><span class="o">]</span> <span class="o">&</span> <span class="n">mask1</span><span class="o">)</span>
<span class="o">+</span> <span class="nc">Long</span><span class="o">.</span><span class="na">bitCount</span><span class="o">(</span><span class="n">values</span><span class="o">[</span><span class="n">offset</span> <span class="o">+</span> <span class="mi">2</span><span class="o">]</span> <span class="o">&</span> <span class="n">mask2</span><span class="o">)</span>
<span class="o">+</span> <span class="nc">Long</span><span class="o">.</span><span class="na">bitCount</span><span class="o">(</span><span class="n">values</span><span class="o">[</span><span class="n">offset</span> <span class="o">+</span> <span class="mi">3</span><span class="o">]</span> <span class="o">&</span> <span class="n">mask3</span><span class="o">);</span>
<span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>
<p>この実装では、最上位ビットが立っている場合の算術右シフトの挙動 (最上位ビットに 1 が fill され続ける) を利用して、すべてのビットが 1 となるケースと下位の一部のビットだけが 1 となるケースを作り分けたり、すべてのビットが 1 であるビットマスクに +1 してすべてが 0 のビットマスクを作り出すといった泥臭いことをしています。</p>
<p>さてこちらの実装、確かに分岐命令を排除できてはいるものの、多数のビット演算・加算や必ず 4 回実行される POPCNT 命令があることから一見しただけではループで愚直に実装した場合よりも速度パフォーマンスが悪くなりそうな印象があります。そこで次は、ベンチマークを用いて実際の速度パフォーマンスを計測してみることにします。</p>
<h2 id="ベンチマーク">ベンチマーク</h2>
<p>今回ベンチマークで計測するタスクは以下になります (都合<sup id="fnref:2" role="doc-noteref"><a href="#fn:2" class="footnote" rel="footnote">2</a></sup> により、乱数のサンプリング処理も計測対象に含まれています)。</p>
<ul>
<li>64 ビット整数の乱数 4 つで構成される 256 ビットの乱数と、0 以上 256 未満の整数の乱数 k を毎回サンプリングして生成する</li>
<li>その 256 ビットの乱数に対して下位 k ビットの population count を計算をする</li>
</ul>
<p>ベンチマーク計測に用いたコードは <a href="https://github.com/komiya-atsushi/java-playground/blob/master/population-count/src/jmh/java/me/k11i/popcnt/Popcnt256Benchmark.java">こちら</a> です。今回はループによる実装 (loop) と前述した分岐命令を排除した実装 (branchless) を計測します。加えて、乱数のサンプリング処理に要する時間を測るために、乱数生成のみを計測対象とするベンチマーク (noop) を用意しています。こちらを用いて、乱数サンプリングの影響を除外したスループットを推定してみることにします。</p>
<p>ベンチマーク計測環境はこちらです。</p>
<ul>
<li>Intel(R) Core(TM) i5-6287U CPU @ 3.10GHz</li>
<li>macOS 10.14.6(18G3020)</li>
<li>OpenJDK Runtime Environment 18.9 (build 11.0.2+9)</li>
</ul>
<p>そして、ベンチマークの計測結果は以下になります。</p>
<table>
<thead>
<tr>
<th>実装</th>
<th style="text-align: right">スループット<br />[ops/ms]</th>
<th style="text-align: right">スループット (乱数生成を除外)<br />[ops/ms]</th>
</tr>
</thead>
<tbody>
<tr>
<td>loop</td>
<td style="text-align: right">48,145.78</td>
<td style="text-align: right">88,412.69</td>
</tr>
<tr>
<td>branchless</td>
<td style="text-align: right">55,907.20<br />(vs. loop +16.12%)</td>
<td style="text-align: right">118,664.43<br />(vs. loop +34.22%)</td>
</tr>
<tr>
<td>(noop)</td>
<td style="text-align: right">105,712.05</td>
<td style="text-align: right">-</td>
</tr>
</tbody>
</table>
<p>上記より明らかなように、分岐命令の排除は速度パフォーマンスの向上に寄与することがわかります。具体的には、ループによる実装に対して少なくとも 16%、おそらくは 30% 超ぐらいは速度性能が向上すると見込まれます。</p>
<h2 id="まとめ">まとめ</h2>
<p>複数ワードからなる整数の下位 k ビットのみの population count を、ループを使わずに分岐命令なしに実現する方法について考えてみました。結果として、256 ビットの整数を対象とした場合にループを用いる実装よりも <strong>少なくとも 16% 以上</strong> (おそらくは 30% 以上は期待できるほど) の速度性能を向上させることができました。</p>
<p>なお今回は 256 ビットの整数のみを対象にベンチマークを計測しましたが、128 ビットもしくは 512 ビットの整数を対象とする場合はまた状況が異なる可能性があることに注意が必要です。また、Intel 製以外の CPU の場合にも異なる結果となる可能性が十分にあります。</p>
<hr />
<div class="footnotes" role="doc-endnotes">
<ol>
<li id="fn:1" role="doc-endnote">
<p>実際に <a href="https://github.com/efficient/rankselect/blob/master/popcount.h#L67-L82">C による Poppy のリファレンス実装</a>では、ループによる実装が採用されています。 <a href="#fnref:1" class="reversefootnote" role="doc-backlink">↩</a></p>
</li>
<li id="fn:2" role="doc-endnote">
<p>JMH には、 <code class="language-plaintext highlighter-rouge">@Setup(Level.Invocation)</code> アノテーションを利用することでベンチマーク対象メソッドを呼び出す度にセットアップ処理を実行することができるのですが、この <code class="language-plaintext highlighter-rouge">Level.Invocation</code> は<a href="http://hg.openjdk.java.net/code-tools/jmh/file/tip/jmh-core/src/main/java/org/openjdk/jmh/annotations/Level.java#l56">ベンチマーク対象のメソッドが 1ms を超えるケースでの利用にのみ適しており</a>、今回のように μs オーダーで実行される処理で利用するには適していません。 <a href="#fnref:2" class="reversefootnote" role="doc-backlink">↩</a></p>
</li>
</ol>
</div>KOMIYA Atsushi複数ワードで構成される一つの整数の下位 k ビットに対する population count を分岐命令なしに実現する方法について Java で考えてみました。Spring Boot な Web アプリケーションでエラーレスポンスを確実に JSON 形式で返却したい2020-03-25T00:00:00+09:002020-03-25T00:00:00+09:00https://k11i.biz/blog/2020/03/25/the-easiest-way-to-return-error-response-in-json<p>極力手軽に実現する方法についてメモしています。</p>
<!-- k11i_biz_ia -->
<div align="center" style="margin: 1em 0">
<ins class="adsbygoogle" style="display:block; text-align:center;" data-ad-layout="in-article" data-ad-format="fluid" data-ad-client="ca-pub-6016543666404535" data-ad-slot="7968021645"></ins>
</div>
<script>
(adsbygoogle = window.adsbygoogle || []).push({});
</script>
<h2 id="背景">背景</h2>
<p>Spring Boot を使った API サーバにおいて、存在しないパスへの HTTP リクエストを受け付けたときの 404 などエラーレスポンスを返す場合に <strong>確実に JSON 形式でレスポンスを返す</strong> (すなわち、HTML を返却させない) ことを考えます。</p>
<p>Spring Boot は、Java の <a href="https://square.github.io/okhttp/">OkHttp</a> や Python の <a href="https://requests.readthedocs.io/en/master/">Requests</a> といった各種言語の HTTP クライアントライブラリや <code class="language-plaintext highlighter-rouge">curl</code> コマンドからの HTTP リクエストであれば、素の設定のままでも以下のようにいい感じに JSON 形式のエラーレスポンスを返すことができます。</p>
<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
</span><span class="nl">"timestamp"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2020-03-24T12:58:57.602+0000"</span><span class="p">,</span><span class="w">
</span><span class="nl">"status"</span><span class="p">:</span><span class="w"> </span><span class="mi">404</span><span class="p">,</span><span class="w">
</span><span class="nl">"error"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Not Found"</span><span class="p">,</span><span class="w">
</span><span class="nl">"message"</span><span class="p">:</span><span class="w"> </span><span class="s2">"No message available"</span><span class="p">,</span><span class="w">
</span><span class="nl">"path"</span><span class="p">:</span><span class="w"> </span><span class="s2">"/no-such-path"</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>
<p>一方で Web ブラウザ、より正確には <strong><code class="language-plaintext highlighter-rouge">Accept</code> ヘッダに <code class="language-plaintext highlighter-rouge">text/html</code> 的な値が設定されている HTTP リクエスト</strong> の場合は、Spring Boot が <del>余計な</del> 気を回すおかげで以下のような HTML レスポンスが返却されることになります。</p>
<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt"><html></span>
<span class="nt"><body></span>
<span class="nt"><h1></span>Whitelabel Error Page<span class="nt"></h1></span>
<span class="nt"><p></span>This application has no explicit mapping for /error, so you are seeing this as a fallback.<span class="nt"></p></span>
<span class="nt"><div</span> <span class="na">id=</span><span class="s">'created'</span><span class="nt">></span>Tue Mar 24 22:03:56 JST 2020<span class="nt"></div></span>
<span class="nt"><div></span>There was an unexpected error (type=Not Found, status=404).<span class="nt"></div></span>
<span class="nt"><div></span>No message available<span class="nt"></div></span>
<span class="nt"></body></span>
<span class="nt"></html></span>
</code></pre></div></div>
<p>この HTML レスポンスは <a href="https://github.com/spring-projects/spring-boot/blob/v2.2.5.RELEASE/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/error/AbstractErrorWebExceptionHandler.java#L208-L228">然るべきエラービューが存在せずフォールバックが発生した場合にレンダリングされる</a> ものなのですが、API サーバとしてはこれでも不都合はないにせよ、エラーレスポンスが JSON になりえないことがあるのは統一感がなく気持ちが悪いので、JSON に統一したいお気持ちがふつふつと湧いてきます。</p>
<p>そういうわけで、なるべく手間をかけずに簡単に、JSON 形式でエラーレスポンスを返却する方法を探ってみます。</p>
<h2 id="エラーレスポンスを-json-で返すようにする簡単な方法">エラーレスポンスを JSON で返すようにする簡単な方法</h2>
<p>結論から言うと、<em>Spring Boot には <a href="https://spring.io/blog/2013/05/11/content-negotiation-using-spring-mvc/">content negotiation の機能が備わっている</a> ので、それを利用して常に <code class="language-plaintext highlighter-rouge">Content-Type: application/json</code> な HTTP レスポンスを返却させるようにする</em>、という手段で容易に実現できます。</p>
<p>「常に <code class="language-plaintext highlighter-rouge">Content-Type: application/json</code> を返却させる」設定を実現するには、 <a href="https://docs.spring.io/spring-framework/docs/5.2.4.RELEASE/javadoc-api/org/springframework/web/servlet/config/annotation/WebMvcConfigurer.html"><code class="language-plaintext highlighter-rouge">WebMvcConfigurer</code> インタフェース</a> を用います。</p>
<p>このインタフェースを実装したクラスにて <a href="https://docs.spring.io/spring-framework/docs/5.2.4.RELEASE/javadoc-api/org/springframework/web/servlet/config/annotation/WebMvcConfigurer.html#configureContentNegotiation-org.springframework.web.servlet.config.annotation.ContentNegotiationConfigurer-"><code class="language-plaintext highlighter-rouge">configureContentNegotiation()</code> メソッド</a> をオーバーライドすると <a href="https://docs.spring.io/spring-framework/docs/5.2.4.RELEASE/javadoc-api/org/springframework/web/servlet/config/annotation/ContentNegotiationConfigurer.html"><code class="language-plaintext highlighter-rouge">ContentNegotiationConfigurer</code> オブジェクト</a> が引数経由で得られるので、これに <code class="language-plaintext highlighter-rouge">application/json</code> を常に返す <code class="language-plaintext highlighter-rouge">ContentNegotiationStrategy</code> オブジェクトを <a href="https://docs.spring.io/spring-framework/docs/5.2.4.RELEASE/javadoc-api/org/springframework/web/servlet/config/annotation/ContentNegotiationConfigurer.html#strategies-java.util.List-"><code class="language-plaintext highlighter-rouge">strategies()</code> メソッド</a> で設定することになります。</p>
<p>「<code class="language-plaintext highlighter-rouge">application/json</code> を常に返す ContentNegotiationStrategy オブジェクト」は、<a href="https://docs.spring.io/spring-framework/docs/5.2.4.RELEASE/javadoc-api/org/springframework/web/accept/FixedContentNegotiationStrategy.html"><code class="language-plaintext highlighter-rouge">FixedContentNegotiationStrategy</code> クラス</a> のオブジェクトを生成すれば用意できます。</p>
<p>具体的には以下のコードのようになります。</p>
<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">org.springframework.context.annotation.Configuration</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">org.springframework.http.MediaType</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">org.springframework.web.accept.FixedContentNegotiationStrategy</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">org.springframework.web.servlet.config.annotation.*</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">java.util.List</span><span class="o">;</span>
<span class="nd">@Configuration</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">WebConfig</span> <span class="kd">implements</span> <span class="nc">WebMvcConfigurer</span> <span class="o">{</span>
<span class="nd">@Override</span>
<span class="kd">public</span> <span class="kt">void</span> <span class="nf">configureContentNegotiation</span><span class="o">(</span><span class="nc">ContentNegotiationConfigurer</span> <span class="n">configurer</span><span class="o">)</span> <span class="o">{</span>
<span class="n">configurer</span><span class="o">.</span><span class="na">strategies</span><span class="o">(</span>
<span class="nc">List</span><span class="o">.</span><span class="na">of</span><span class="o">(</span><span class="k">new</span> <span class="nc">FixedContentNegotiationStrategy</span><span class="o">(</span><span class="nc">MediaType</span><span class="o">.</span><span class="na">APPLICATION_JSON</span><span class="o">)));</span>
<span class="c1">// configurer.defaultContentType(MediaType.APPLICATION_JSON) だと、結局 Accept: text/html の場合に</span>
<span class="c1">// エラーレスポンスを HTML で返してしまうのでダメ </span>
<span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>
<p>この方法であれば、Whitelabel やら <code class="language-plaintext highlighter-rouge">ErrorController</code> やらをゴニョゴニョせずとも目的を果たすことができます。</p>KOMIYA Atsushi極力手軽に実現する方法についてメモしています。Java で Rank9 を実装する2020-02-28T00:00:00+09:002020-02-28T00:00:00+09:00https://k11i.biz/blog/2020/02/28/rank9-java-implementation<p>Xor+ filter を実装するために、ランク辞書の一種である Rank9 を Java で実装したメモです。</p>
<!-- k11i_biz_ia -->
<div align="center" style="margin: 1em 0">
<ins class="adsbygoogle" style="display:block; text-align:center;" data-ad-layout="in-article" data-ad-format="fluid" data-ad-client="ca-pub-6016543666404535" data-ad-slot="7968021645"></ins>
</div>
<script>
(adsbygoogle = window.adsbygoogle || []).push({});
</script>
<h2 id="rank9-とは">Rank9 とは?</h2>
<p>Rank9 は、ビットベクトルに対する <a href="https://ja.wikipedia.org/wiki/%E7%B0%A1%E6%BD%94%E3%83%87%E3%83%BC%E3%82%BF%E6%A7%8B%E9%80%A0#%E7%B0%A1%E6%BD%94%E8%BE%9E%E6%9B%B8">ランククエリ</a> を定数時間で実現するランク辞書の一種です。提案論文は <a href="http://vigna.di.unimi.it/ftp/papers/Broadword.pdf">こちら</a>。</p>
<p>この Rank9 は 2-layer で構成される典型的なランク辞書ではあるのですが、64 ビット CPU での処理に適した構成をとっています。具体的には以下の図に示すとおりで、</p>
<p><img src="/images/2020/02/28/rank9.png" alt="rank9" /></p>
<ul>
<li>ビットベクトルを 64 ビットごとに区切り、それぞれを basic block として扱う</li>
<li>First layer では、 <strong>8 個の basic block (= 512 ビット) ごとのランク</strong> を 64 ビットで表現する</li>
<li>Second layer では、 <strong>連続する 8 個の basic block 内部における basic block ごとのランク</strong> (first layer のランクからの相対的なランクに相当する) を 9 ビットで表現する
<ul>
<li>ただし、最初の basic block のランクは常に 0 となるので省く</li>
<li>残り 7 個の basic block のランクを、1 ビットのパディングとともに 64 ビットに詰めて表現する</li>
</ul>
</li>
</ul>
<p>となります。なお、上記の図では first layer と second layer を分離して表現していますが、 <strong>実装上はこれらの値を交互に織り込んで (すなわち interleaved に) 一つの配列で表現</strong> します。この interleaved な構成により、ランクを求める際のキャッシュミスを最大 2 回に抑えられます<sup id="fnref:1" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup>。</p>
<p>このランク辞書を用いて $i$ ビット目のランクを求める手順は、次のようになります。</p>
<ol>
<li>First layer と second layer から、一つ手前の basic block までのランクを求める</li>
<li>ビットマスクとのビット積と <a href="https://en.wikipedia.org/wiki/SSE4#POPCNT_and_LZCNT">POPCNT 命令</a> などを用いて、$i$ ビット目が含まれる basic block 内でのランクを求める<sup id="fnref:2" role="doc-noteref"><a href="#fn:2" class="footnote" rel="footnote">2</a></sup>
<ul>
<li>最近の Java<sup id="fnref:3" role="doc-noteref"><a href="#fn:3" class="footnote" rel="footnote">3</a></sup> であれば、<code class="language-plaintext highlighter-rouge">Long.bitCount()</code> メソッドを利用することで POPCNT 命令の利用が期待できる</li>
</ul>
</li>
<li>1 と 2 の結果を合算して最終的なランクとする</li>
</ol>
<p>この手順を具体的に Java のコードで表現するならば、例えば以下のようになるでしょう。</p>
<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// それぞれ first layer, second layer, basic block を表す</span>
<span class="kt">long</span><span class="o">[]</span> <span class="n">first</span><span class="o">;</span>
<span class="kt">long</span><span class="o">[]</span> <span class="n">second</span><span class="o">;</span>
<span class="kt">long</span><span class="o">[]</span> <span class="n">blocks</span><span class="o">;</span>
<span class="kd">public</span> <span class="kt">long</span> <span class="nf">rank</span><span class="o">(</span><span class="kt">long</span> <span class="n">i</span><span class="o">)</span> <span class="o">{</span>
<span class="kt">int</span> <span class="n">blockIndex</span> <span class="o">=</span> <span class="o">(</span><span class="kt">int</span><span class="o">)</span> <span class="o">(</span><span class="n">i</span> <span class="o">>>></span> <span class="mi">6</span><span class="o">);</span>
<span class="kt">int</span> <span class="n">tableIndex</span> <span class="o">=</span> <span class="o">(</span><span class="kt">int</span><span class="o">)</span> <span class="o">(</span><span class="n">i</span> <span class="o">>>></span> <span class="mi">9</span><span class="o">);</span>
<span class="kt">int</span> <span class="n">shiftBits</span> <span class="o">=</span> <span class="mi">63</span> <span class="o">-</span> <span class="o">(</span><span class="n">blockIndex</span> <span class="o">&</span> <span class="mh">0x7</span><span class="o">)</span> <span class="o">*</span> <span class="mi">9</span><span class="o">;</span>
<span class="kt">long</span> <span class="n">mask</span> <span class="o">=</span> <span class="o">(</span><span class="mi">1L</span> <span class="o"><<</span> <span class="o">(</span><span class="n">i</span> <span class="o">&</span> <span class="mh">0x3f</span><span class="o">))</span> <span class="o">-</span> <span class="mi">1</span><span class="o">;</span>
<span class="k">return</span> <span class="n">first</span><span class="o">[</span><span class="n">tableIndex</span><span class="o">]</span>
<span class="o">+</span> <span class="o">((</span><span class="n">second</span><span class="o">[</span><span class="n">tableIndex</span><span class="o">]</span> <span class="o">>>></span> <span class="n">shiftBits</span><span class="o">)</span> <span class="o">&</span> <span class="mh">0x1ff</span><span class="o">)</span>
<span class="o">+</span> <span class="nc">Long</span><span class="o">.</span><span class="na">bitCount</span><span class="o">(</span><span class="n">blocks</span><span class="o">[</span><span class="n">blockIndex</span><span class="o">]</span> <span class="o">&</span> <span class="n">mask</span><span class="o">);</span>
<span class="o">}</span>
</code></pre></div></div>
<p>なお、second layer から 9 ビットの値で表現された 8 basic block 内のランクを抽出する処理では、「$i$ ビット目が存在する対象の basic block が、連続する 8 つの basic block のうち、最初の basic block に該当するか?」を判定する (すなわち条件分岐をする) 必要はなく、上記のように常に同じ単にビット演算を適用するだけで済みます。</p>
<h2 id="小規模なビットベクトル向けのちょっとした最適化">小規模なビットベクトル向けのちょっとした最適化</h2>
<p>先に示した論文における Rank9 では、first layer のランクの表現に 64 ビットの整数を用いていました。ゆえに Rank9 では、その長さが $2^{32}$ を超える (厳密には、ランクが $2^{32}$ を超える) ビットベクトルを取り扱うことができます。</p>
<p>このとき、ランク辞書の表現に要するビット数は、元のビットベクトルの長さを $n$ ビットとすると $0.25 n$ ビットとなり、元のビットベクトルに対して 25% のオーバーヘッドが生じることになります。</p>
<p>一方でアプリケーション次第にはなるのですが、実際に取り扱うビットベクトルの長さが $2^{32}$ を超えることがないのであれば、first layer のランクは 32 ビットの整数で表現することができます。この場合、ランク辞書の表現に要するビット数は $0.185 n$ ビットとなり、18.5% のオーバーヘッドに抑えることができます。ただしこの場合は first layer と second layer を interleaved な構成にするのが難しく、それぞれ別の配列で表現することになるため、キャッシュミスが最大 3 回となることに注意が必要です。</p>
<p>また、ビットベクトルの長さが同じく $2^{32}$ を超えないケースのさらなる別種として、first layer と second layer をそれぞれ 32 ビット値で表現しつつ、この 2 つを合わせて 64 ビットの値として取り扱う方法が考えられます。この場合のオーバーヘッドは 25% のままとなりますが、速度的に優位性があるか否かを後述するベンチマークで評価することにします。</p>
<h2 id="ベンチマーク">ベンチマーク</h2>
<h3 id="計測条件">計測条件</h3>
<p>ここでは以下の条件に基づいて、Rank9 の具体的な各種実装の速度性能 (スループット, ops/ms) を計測することにします。</p>
<ul>
<li>ビットの 0/1 が概ね半々の割合で含まれる、長さ $2^{30}$ のランダムなビットベクトルに対するランク辞書を用意する
<ul>
<li>ビットベクトルそのものに要するバイト数は 128MiB となる</li>
<li>ランク辞書に要するバイト数は、25% のオーバーヘッドであれば 32MiB、18.5% のオーバーヘッドであれば 24MiB となる</li>
</ul>
</li>
<li>上記のビットベクトル/ランク辞書に対して、都度ビットインデックスをランダムサンプリングしてランククエリを実行したときのスループットを計測する</li>
</ul>
<p>今回速度性能を評価する Rank9 の実装は、以下の 4 つになります。</p>
<table>
<thead>
<tr>
<th>名称</th>
<th style="text-align: right">オーバーヘッド</th>
<th>説明</th>
</tr>
</thead>
<tbody>
<tr>
<td><code class="language-plaintext highlighter-rouge">RANK9_32</code></td>
<td style="text-align: right">18.5%</td>
<td>First layer を 32 ビットで表現し、second layer とは別の配列で表現する</td>
</tr>
<tr>
<td><code class="language-plaintext highlighter-rouge">RANK9_32_INTERLEAVED</code></td>
<td style="text-align: right">25%</td>
<td>First / second layer をそれぞれ 32 ビットで表現しつつ、1 つの 64 ビット整数でまとめて表現する</td>
</tr>
<tr>
<td><code class="language-plaintext highlighter-rouge">RANK9_64</code></td>
<td style="text-align: right">25%</td>
<td>First / second layer を別々の配列で表現する</td>
</tr>
<tr>
<td><code class="language-plaintext highlighter-rouge">RANK9_64_INTERLEAVED</code></td>
<td style="text-align: right">25%</td>
<td>論文に基づくオリジナルの実装</td>
</tr>
</tbody>
</table>
<h3 id="計測結果">計測結果</h3>
<p>ベンチマークの計測に使ったコードは <a href="https://github.com/komiya-atsushi/java-playground/tree/master/rank-dictionary">こちら</a> です。結果は以下の通り。</p>
<p><img src="/images/2020/02/28/benchmark.png" alt="ベンチマーク結果" /></p>
<p>この結果をみて明らかなように、first layer と second layer を interleaved な構成にすることは速度性能にポジティブな影響を与え、また first / second layer それぞれを 32 ビットで表現して 64 ビットの数値で扱うことにより、さらなる速度性能の向上が見込めることがわかります。</p>
<p>一方で、first layer だけを 32 ビットで表現してオーバーヘッドを 18.5% に抑える実装は速度性能を犠牲にすることになるため、この実装の採用には慎重にならざるを得ないと考えられます。</p>
<h2 id="まとめ">まとめ</h2>
<p>今回は Rank9 (およびその派生) を Java 実装し、その速度性能をベンチマークで評価してみました。ビットベクトルの大きさがそれほど大きくない場合は、オリジナルの Rank9 よりも 32 ビットで first / second layer を表現し、64 ビットの数値として保持する実装の方が優れていることがわかりました。</p>
<p>なおこの Rank9 が提案されたのは 2008 年頃と随分と昔であり、それ以降も様々なランク辞書が提案されていることから、今後はこの Rank9 をベースラインとし、引き続きそれぞれのランク辞書を実装して速度性能を評価してみることにします。</p>
<hr />
<div class="footnotes" role="doc-endnotes">
<ol>
<li id="fn:1" role="doc-endnote">
<p>古典的な (interleaved ではない) 2-layer 構成のランク辞書だと、それぞれのレイヤを構成する配列へのアクセスで 1 回ずつ、またビットベクトルへのアクセスで 1 回、の合計で 3 回キャッシュミスが生じ得ます。 <a href="#fnref:1" class="reversefootnote" role="doc-backlink">↩</a></p>
</li>
<li id="fn:2" role="doc-endnote">
<p>POPCNT 命令が存在しない CPU の場合は、元論文にあるようなビット演算を用いたビットカウントを利用することになります。 <a href="#fnref:2" class="reversefootnote" role="doc-backlink">↩</a></p>
</li>
<li id="fn:3" role="doc-endnote">
<p><a href="https://bugs.java.com/bugdatabase/view_bug.do?bug_id=6378821">Java Bug Database JDK-6378821</a> を見るに、Java 1.6 時点で既に利用できたようです。 <a href="#fnref:3" class="reversefootnote" role="doc-backlink">↩</a></p>
</li>
</ol>
</div>KOMIYA AtsushiXor+ filter を実装するために、ランク辞書の一種である Rank9 を Java で実装したメモです。