株式会社クリアコード > ククログ

ククログ


グループポリシーでエンタープライズの証明書を配布した場合、IE(Edge)では閲覧できるページがFirefoxではエラーで閲覧できない場合があります

Firefox ESR52以降のバージョンは、security.enterprise_roots.enabledtrueに設定しておく事で、Active Directoryのグループポリシー機能を使って配布された証明書をFirefoxに自動的にインポートできます *1。また、Firefox ESR60ではグループポリシー経由である程度の設定の制御も行えるようになりました。以前のバージョンのFirefoxでは証明書のインポートや管理者による設定の制御にはCCK2や独自形式の設定ファイルなどを別途用意する必要がありましたが、Firefox ESR52以降では設定も証明書の管理もActive Directoryのグループポリシーに一元化できるようになったという事で、Internet Explorer(およびその後継ブラウザであるEdge)をメインで運用しつつFirefoxも併用するという使い方をしやすくなってきていると言えます。

ただ、そのために、「IEではこれで問題なく閲覧できるのに、Firefoxでは何故かエラーになる」という状況が顕在化しやすくなっています。組織内のWebサイトにSSL/TLSで接続できるようにするために証明書をグループポリシーで配布した場合に、IE(Edge)では期待通りにそのWebサイトを閲覧できるのにも関わらず、Firefoxでは何故か証明書のエラーになって閲覧できない場合がある、というのもその一つです。

この現象が発生する原因として典型的なのは、配布されている証明書自体に問題があるというものです。

証明書の安全性がFirefoxの求める要件を満たしていない場合

Firefoxをアップデートしたら急に、今まで問題なく閲覧できていたWebサイトで「安全でない接続」というエラーが表示されるようになり、閲覧できなくなってしまった。という場合、これはFirefoxの許容する証明書の安全性の水準が見直された事が原因となっている可能性があります

「この水準の安全性が確保されていれば妥当な証明書として認める」という基準は、IE/EdgeとFirefoxで異なります。また同じFirefoxであっても、バージョンによってその基準が見直される事があります。Firefoxのアップデート直後からSSL/TLSに関するトラブルが急増したという場合には、Firefoxの各バージョンのリリースノートや、開発者向けリリースノートの「セキュリティ」の見出し配下を確認し、問題が起こるようになったバージョンにおいて何らかのアナウンスがなされていないかを確認してみて下さい。

GoogleやYahooなどの一般的なWebサイトを閲覧しようとして「安全でない」というエラーが表示されるようになったというケースも、証明書の安全性が低い事が原因である場合が多いです。これは、以下の例のような「SSLロガー」や「SSLプロキシ」と言われるようなネットワーク機器やサービスを使用している場合によく見られるトラブルです。

この種の機器やサービスは、使用にあたっては原理上必ず、各クライアントに専用の認証局証明書をインポートする必要があります。この認証局証明書がFirefoxの求める安全性の水準を満たさなくなると、SSL/TLSを使用したあらゆるWebサイトで「安全でない接続」の問題が起こるようになります。
(いわゆるSSLプロキシがある場合の通信)
導入手順の一環として流されがちなためか、「証明書をインポートして使っている」という事実を忘れてしまいやすい模様ですので、殊更注意が必要です。

認証局証明書として本来は不正な証明書を、気付かずに使っている場合

証明書の安全性の水準に問題がなくても「安全でない接続」のエラーが発生する場面がもう1つあります。それは、認証局証明書(CA*2証明書、あるいはルート証明書)として使えないはずの証明書をエンタープライズの証明書として配布してしまっている場合です。

具体的には、実験や組織内での一時的な使用のために作られる事の多い、「サーバーの署名用」ではあるが「認証局証明所用」ではない自己署名証明書をエンタープライズの証明書として配布している場合に問題が起こります。実際に、各種の証明書エラーの様子を確認できるbadssl.comというサイトにおいて自己署名証明書を使ったサイトの例で使われている証明書の詳細な情報を見ると、

  • Subject(この証明書の識別子と証明する対象):C=US, ST=California, L=San Francisco, O=BadSSL, CN=*.badssl.com
  • Issuer(この証明書の署名者):C=US, ST=California, L=San Francisco, O=BadSSL, CN=*.badssl.com
  • X509v3 extensions(拡張属性), X509v3 Basic Constraints(基本の制約事項):CA:FALSE(認証局ではない

と、まさにそのような形式になっています。

このような証明書を使っているWebサイトを訪問した場合には、必ず「安全でない接続」のエラーが表示され、内容を閲覧するためにはそのサイトをセキュリティの例外として登録しなくてはなりません。しかしながら一般的に、セキュリティの例外はユーザー個々人が手動で操作して行う必要があります。組織内で多くのユーザーが共通して使用するWebサーバーでこの種の証明書を使っている場合*3、全ユーザーに手動で例外登録をさせるというのは非現実的です。そこで取られる事があるのが、この証明書自体をエンタープライズの証明書として配布するという方法です。

この証明書がエンタープライズの証明書として読み込まれているクライアントPCでは、IE/Edgeで当該Webサイトを訪問すると、

  1. そのWebサイトが送ってくる証明書の署名者を確認する。
  2. エンタープライズの証明書に登録されている証明書(Webサイトが送ってきた証明書と同じ物)が見つかる。
  3. 見つかった証明書が認証局証明書として使われ、Webサイトが送ってきた証明書が正しく認証局証明書によって発行された物であると判定される。

という形で検証され、安全な接続であるとしてWebサイトのコンテンツがそのまま表示される結果になります。

Firefoxでsecurity.enterprise_roots.enabledtrueに設定されていている場合も、当然IE/Edgeと同様の検証が行われて当該Webサイトの内容が警告無しに表示できるはず……と思ってしまう所ですが、実際にはそうはなりません。Firefoxでは、

  1. そのWebサイトが送ってくる証明書の署名者を確認する。
  2. エンタープライズの証明書に登録されている証明書(Webサイトが送ってきた証明書と同じ物)が見つかる。
  3. 見つかった証明書にCA:TRUE(認証局証明書であるというメタ情報)が無いため、認証局証明書ではないと判定される。
  4. 1の証明書の署名者の認証局証明書が見つからないため、証明書の妥当性を検証できず、エラーになる。

という形で検証が行われるため、安全な接続ではないと判定され、Webサイトのコンテンツの代わりにエラーページが表示される結果になります*4

ユーザーが手動操作でFirefoxの証明書マネージャから認証局証明書をインポートする場合、本来であれば、用途が認証局証明書でない証明書は「この証明書は認証局の証明書ではないため、認証局の一覧には追加できません。」というエラーメッセージが出て拒絶されるため、誤って認証局に登録してしまうという事も起こりません。しかしながら、グループポリシー経由でエンタープライズの証明書として配布された物を security.enterprise_roots.enabled の機能でインポートする場合には、このチェックが行われません。また、厄介な事に、そうしてエンタープライズの証明書として一度認識されてしまうと、この証明書を証明書マネージャから手動操作で認証局証明書として再インポートしようとした場合にも、用途のチェックがスキップされてインポートに成功してしまうという性質があります。

この問題がそもそも発生しないようにするためには、以下のどちらかの方法を取るしかありません。

  • サーバーの証明書を再発行する。その時に、認証局証明書としての用途も併せて設定することで、認証局証明書で、且つサーバーの証明書という状態にしておく。
  • 認証局証明書を新たに作成し、その認証局で署名された証明書としてサーバーの証明書を再発行する。エンタープライズの証明書としては、認証局証明書の方を配布する。

一般的な認証局で発行してもらう証明書には適切な用途情報が設定されていますが、組織内でのみ使用するために独自に証明書を発行する場合、この点のチェックがなおざりになりがちです。証明書の用途は適切に使い分けるように気をつけましょう。

まとめ

以上、Active Directoryのグループポリシー機能を使って配布したエンタープライズの証明書をIE/EdgeとFirefoxで共用する場合に起こりがちなトラブルについて解説しました。

当社では、Firefoxの法人での運用における不明点の調査、ご要望に合わせた適切な設定のご案内、本体に含まれない特別な運用を実現するアドオンの開発などを広く承っております。Firefoxの運用でお困りのシステム管理・運用ご担当者さまは、お問い合わせフォームよりお気軽にお問い合わせ下さい。

*1 なお、この機能はActive Directoryを運用していない環境でも、Windowsのレジストリを編集することで動作の検証が可能です

*2 Certificate Authority=認証局

*3 本来であればそのような運用を取るべきではありません。

*4 これはIE/EdgeとFirefoxで証明書の取り扱いのポリシーの厳格さに差があるためで、どちらかが正しくてどちらかが間違っているという事はありません。IE/Edgeは証明書の用途に関する検証がルーズなので、そちらを基準にして運用していると支障が生じる場合がある、という事です。

2018-10-02

Apache Arrow 0.11.0リリース

Apache Arrow 0.11.0のリリースマネージャーをした須藤です。

2018年10月8日にApache Arrow 0.11.0をリリースしました。

0.11.0の新機能

0.10.0のリリースから2ヶ月くらいしか経っていないのですが、今回のリリースはすごくアグレッシブです。まだ荒削りのものが多いのですが、新しく次の機能が入りました。

  • RPC機能
    • Apache Arrow Flightという名前がついている
  • Apache Parquet C++
    • 別リポジトリーで開発していたが密に連携しているのでApache Arrowのパッケージ含めることになった
  • Apache Parquet GLib
    • 別リポジトリーで開発していたがApache Parquet C++がApache Arrowに含まれることになったのでこれも含めることになった
  • LLVMを使った実行エンジン
    • Gandivaという名前がついている
  • CSVパーサー
    • CSVを高速に読み込んでApache Arrowのデータとして処理できるようになる
  • R実装
    • C++のバインディングとして実装
  • MATLAB実装
    • C++のバインディングとして実装

Apache Arrowの最新情報(2018年9月版)でまとめた内容のいくつかはすでに古いものになってしまいました。開発が活発ですね!リリース後に.NET実装も現れています。

開発者を増やしたい!

活発な開発をさらに活発にするために、12月8日(土)にApache Arrow東京ミートアップ2018という「開発者を増やすこと」が目的のイベントを開催します!

開発者を増やしたいプロジェクトはApache Arrowはもちろんですが、Apache Arrow以外のデータ処理関連ソフトウェアもです。たとえば、Apache SparkやApache Hadoop関連の開発者も増やしたいですし、R関連の開発者も増やしたいですし、Ruby用のデータ処理ツールの開発者も増やしたいです。

開発に参加したいけど踏み出せていなかったという人はこの機会をぜひ活用してください!開発に参加する人を増やすためのいろいろな仕掛け(?フォロー?)を準備しています。

「Apache Arrow東京ミートアップ2018」は今のところ以下のプロジェクトの開発者・関係者が協力しています。

  • Apache Arrow
  • Apache Spark
  • R
  • Ruby(Red Data Tools)

他のプロジェクトのみなさんにも協力して欲しいので、開発者が増えることに興味がある人はぜひ@ktouに連絡してください。たとえば、次のような界隈のプロジェクトです。

  • Python(pandasとか)
  • JavaScript
  • Julia
  • Go
  • Rust
  • MATLAB
  • GPU

Ruby用のデータ処理ツールを開発するRed Data Toolsプロジェクトもこのイベントに協力しています。

そんなRed Data Toolsは毎月開発イベントを開催しています。今月は来週の火曜日(2018年10月16日)開催です。「Apache Arrow東京ミートアップ2018」を待たずにApache Arrow本体やRuby用のデータ処理ツールの開発に参加したい人はぜひどうぞ。「Apache Arrow東京ミートアップ2018」後には翌週の火曜日(2018年12月11日)に開催します。

まとめ

Apache Arrow 0.11.0をリリースしたので自慢しました。

Apache Arrowを含むデータ処理関連プロジェクトの開発者を増やすためのイベントをすることを宣伝しました。

2018-10-10

macOS版FirefoxのIPCとドラッグ&ドロップ周りのバグを調査する

クリアコードの結城です。

先日、動画でバグ報告する方法の中で例として挙げていたmacOS版Firefoxのサイドバーの操作に関するバグですが、最終的には原因の特定とパッチの提出にまで至る事ができました。このエントリでは当該バグの調査の過程を辿りながら、macOS上のFirefoxにおいてマルチプロセスが絡む部分やドラッグ&ドロップに関わる部分のデバッグをどのように進めたかの一例をご紹介します。

問題の概要

当該バグはツリー型タブというアドオンに寄せられた報告に端を発しています。このアドオンはFirefoxのサイドバー内でタブバーの代替となるUIを提供するという物で、項目をクリックするとタブが切り替わり、ドラッグ&ドロップでタブを並べ替える事もできます。このサイドバーパネル上において「セッション復元後にまだ読み込まれていないタブをドラッグしようとするとドラッグできず、また、それ以後サイドバー内で一切のドラッグ操作が行えなくなる」という現象が発生する、というのが報告の要旨でした。

報告者の方と連絡を取りながら調査を進めた結果、この問題はmacOSでのみ発生するらしいという事と、アドオン側で可能と思われる対策を講じても現象を回避できない、Firefox自体のバグに起因する問題であるらしいという事が分かりました。当該アドオンの実装を参考に最小のテストケースを作成して検証した結果、確かに現象を再現できたため、Firefox自体のバグとしてBugzillaに報告しました。またその過程において、上記の再現手順の他に以下の事も事が分かりました。

  • サイドバーの内容(=アドオンが提供するコンテンツ)が別プロセスで動作している時だけ現象が再現する。
  • 既にセッションが完全に復元された状態のタブでは、現象は再現しない。
  • 現象発生時は、dragstartイベントは発生するが、その後に発生するはずのdragoverdragenterdragleavedragendの各イベントは発生しない。

調査前の時点での見立て

本格的に調査を始める前に、この時点で分かっていた情報から、Firefoxの内部で何が起こりどうしてこの問題が起こっているのかという事について、いくつかの仮説を立てました。

  • 仮説1:タブを切り替えてセッション復元処理が行われる時に、親プロセスと子プロセス群との間の「繋ぎ替え」が発生し、サイドバーのプロセスが「アクティブな子プロセス」でなくなるせいで、サイドバーのプロセスから親プロセスにドラッグ操作が伝わらなくなる。
  • 仮説2:タブを切り替えてセッション復元処理が行われる時に、ドラッグ&ドロップの処理に対して何らかの割り込みをかけるせいで、「何らかのフラグ」が内部で立ってしまい、以後ドラッグ&ドロップのイベントが発生しなくなる。

ただ、そもそも一連の処理に関わる実装がどのような設計になっているのか、「繋ぎ替え」や「フラグ」とここで呼んでいるような物が実際にあるのかどうかも、この時点では不明なままでした。これらはあくまで、過去の経験に基づく推測での見立てとなります。

また、筆者はmacOSのネイティブアプリ開発の知見を全く持っていないという問題もありました。筆者はWindows版のFirefoxの低レイヤ部分の調査は若干経験しているものの、macOSネイティブの開発経験は皆無です。今回のように特定プラットフォームでしか発生しない問題は、そのプラットフォーム向けのコード(WindowsではWin32 API、LinuxではGTK+、macOSではCocoa APIを使う部分)を調査する必要が出てきます。調査対象の事を全く知らない状態での手探りの調査は泥沼化しがちですので、これは大きなリスクです。

そのリスクを回避するために「macOSアプリでのドラッグ&ドロップはどのように実装するのか」という事を先に勉強してから調査に臨むというやり方もあります。しかしながら、今回の調査に必要な知識がその知識体系のどのあたりに存在しているかは全く予想が付きませんし、そこまでガッツリとmacOSでの開発を学びたいという訳でもありません*1。そもそもこれは「解決できれば儲け物だが、自身がmacOSユーザーではないので究極的には直らなくても困らない問題」という性質の物でしたので、失敗に終わったとしてもそれほどダメージは無いと言えます。ですので、知見の不足には一旦目を瞑って、Mozilla製品でのクロスプラットフォームな開発の知識だけを元に調査していく事にしました。

調査の出発点を探す

全く手がかりがない所から調査を行うのは、Firefoxほどの規模のソフトウェアではほぼ不可能です。そこで、ここまでに分かっていた事からヒントになりそうな要素はないかを検討してみました。

成功ケースと失敗ケースの両方の再現手順がはっきりしているという事は、その両者で起こっている事を子細に比較していけば、現象が起こる原因が分かる可能性は高いと考えられます。一連の操作の中で「共通してこのモジュールのこのメソッドが呼ばれている」という部分が特定できれば、そこを手がかりにして調査範囲を広げていけるはずです。

(ある一点を起点にして、成功ケースにまで辿り着くルート上のどこかで、失敗ケースへと分岐するポイントがあるはず)

ここで着目したのは、正常な場合でも異常な場合でもDOMのdragstartイベントは発行されているという点でした。Firefoxの内部ではこういったDOMイベントの名前は定数で定義されていますので、その定数を伴ってイベントを初期化したり発行したりしていそうな所を特定できれば、そこを起点に調査を進められます。

"dragstart"という文字列でざっとソースコード内を検索してみた所、dragstartを定数として定義しているらしい箇所がすぐに見つかりました。ここで、このイベント名は他の箇所からeDragStartという定数名で参照されているらしいという事が分かりました。

次に、eDragStartという定数名でソースコード内を検索してみた所、自動テスト用に擬似的にイベントを生成しているらしい箇所に混ざって、EventStateManagerというモジュールの中でイベントを生成しているらしい箇所が1箇所だけ見つかりました。この処理が含まれているのはEventStateManager::GenerateDragGesture()というメソッドでしたので、メソッド名から見ても、どうやらドラッグ&ドロップの操作が行われた時には必ずここを通過すると考えて良さそうです。

同様にeDragOverという定数名でソースコード内を検索した結果、成功ケースにおける「dragoverイベントが発行されるのはここ」という場所も特定できました*2

ところで、今回の問題は厳密に言うと、以下の2つの現象が起こっています。

  • セッション復元後にまだ読み込まれていないタブをドラッグしようとするとドラッグできない。(最初の問題)
  • それ以後、サイドバー内で一切のドラッグ操作が行えなくなる。(2回目以降の問題)

2つの現象は「タブをドラッグできない」「どちらもdragstartイベントだけが発行される」という症状は共通していますが、原因が同じかどうかは分かりません。ただ、再現が容易なのは2つ目の現象の方(1つ目の現象が再現したら、以後は何度やっても2つ目の現象が起こる事になるので)なので、先にこちらの方から原因を調べる事にしました。こちらの原因が分かれば、今度はそれが「その原因となっている状況を引き起こした犯人=1つ目の現象の原因」を特定する手がかりとなります。

低レベルのログを収集する

Firefoxには環境変数を指定する事でモジュール単位の低レベルのログを収集する機能があるため、成功ケースと失敗ケースの両方についてこのモジュールのログを取得して比較すれば、何か分かるかも知れません。この方法であれば、子プロセスが出力したログもそれぞれ個別に収集する事ができます。

低レベルのログを収集するには、macOSではターミナルを使います。Firefoxのアプリケーションファイルはファイルシステム上はフォルダ(ディレクトリ)になっているので、以下のようにして環境変数を設定して実行します。

$ MOZ_LOG=timestamp,sync,nsCocoaWidget:5 MOZ_LOG_FILE=~/debug.log /Applications/Nightly.app/Contents/MacOS/firefox

環境変数MOZ_LOGには、ログを取得したいモジュール名とログレベルを:で繋げたものを、,区切りのリストとして指定します。このようにしてFirefoxを起動すると、メインプロセスのログがMOZ_LOG_FILEで指定した名前のファイルに出力され、子プロセスのログはdebug.log.child-1のような名前で同じ位置に出力されます。ログ出力が始まった状態から、ターミナルの新しいタブでtail -F debug.log*と実行すれば、それぞれのログに出力される内容をリアルタイムで見る事もできます。

しかし、先程特定した「成功時と失敗で共通して呼ばれているモジュール」であったEventStateManager.cppのソース内をlogで検索してみても、ログ出力を行っているらしきコードは見つかりませんでした。また、dragoverイベントが発行される契機になっている処理の方についてはnsCocoaWidgetというログモジュールでログを収集できましたが、こちらは成功ケースのログしか出力されず、しかも知りたい核心部分の処理が全て終わった後の時点のログでした。これではログを取っても調査の役には立ちません。

モジュール単位でのログが役に立たないとなると、何か別の方法で「成功ケースと失敗ケースのそれぞれで、内部的に何が起こっているのか」を調べる必要があります。

デバッグビルドとデバッガを使用した調査

このような場面で使えるツールの1つがデバッガです。問題が発生するケース・期待通りに動くケースで必ずこの行を通る、という事がはっきり分かっている場合には、デバッガ上でそこにブレークポイントを仕掛けておく事で、処理を一時停止して各変数の値を詳細に見る事ができます。また、そこから1行ずつ処理を進めて流れをじっくり追うという事(ステップ実行)もできます。

デバッグビルドの準備

ただ、デバッガを使うにはmacOS版Firefoxのデバッグ用ビルドが必要です。オフィシャルに公開されているバイナリは無い様子でしたので、「Firefox macOS debug」と検索して出てきたMDNのmacOSでのデバッグ手順の解説ビルド手順の解説を見ながら自分でデバッグビルドを作成することにしました。

  • macOS版FirefoxのビルドのためにはXcodeが必要です。しかし、調査に使える環境はmacOS 10.12 Sierraだったため、App Storeにある最新のmacOS向けのXcodeはインストールできませんでした。そこで「Xcode for old macOS」などのキーワードで検索して見つかった情報を参考に、開発者向けのページからXcodeの旧バージョンをダウンロードしてインストールしました。
  • Homebrewなどのツール類については、解説にある通りbootstrap.pyを使って一括インストールする事ができました。
    • 初期設定時に選択するビルド対象は、Artifact Buildではないデスクトップ版Firefoxとしました。Artifact Buildとは、ビルド済みバイナリをダウンロードしてきて使う事によって、ビルドに要する時間を短縮できるという特殊なビルドです。しかし、今回はバイナリ部分をデバッグ用にビルドしたいので、Artifact Buildは不適当という事になります。
  • 既にNightlyで現象を再現できていたので、cloneするリポジトリはNightlyに対応する https://hg.mozilla.org/mozilla-central/ を使いました。

解説の通りに進めてデバッグビルドができた時点で*3./mach runでテスト実行してみました。最適化がなされないためなのかデバッグビルドだからなのか、一挙手一投足がもたつく程に非常に低速ではありますが、一応動く事は動いているため、準備が整った事にして次の行程に進みます。

ブレークポイントの設定とステップ実行

macOSでのデバッグ手順解説に記載の通りに準備した上で、Xcodeの「Product」メニューから「Run」を選ぶと、デバッグビルドのFirefoxが起動します。

(Xcodeでブレークポイントを設定した様子)

ブレークポイントを設定するためには、まずXcodeの左ペインのフォルダーアイコンをクリックし、ソースコードのファイル一覧から「ここで処理を止めたい」という処理が含まれているファイル(今回は dom/events/EventStateManager.cpp )を選択します。
右上のペインにソースコードが表示されますので、止めたい処理の行番号をクリックします。すると、行番号の部分に青いマークが付きます。これをブレークポイントといい、Xcode経由で起動したデバッグビルドのFirefoxの内部で処理がこの行に到達すると、処理がその場所で止まってXcodeに制御が移るようになります。

ブレークポイントで処理が止まった後は、左のペインの内容が関数呼び出しのスタック表示に切り替わり、ソースコードが表示されているペインの左下ペインには停止位置での各変数の値が表示されます。

(ブレークポイントで処理が停止した様子)

この時には、ツールバー上の各ボタンで以下の操作を行えます。

  • Deactivate breakpoints:全てのブレークポイントを一時的に無効化する。もう一度クリックすると、ブレークポイントを有効化する。
  • Continue program execution:次のブレークポイントまで処理を一気に進める(ブレークポイントでの一時停止を解いて、通常の実行に戻す)
  • Step over:処理をソースコード上で1行分次に進める(ステップ実行)。
  • Step into:関数の呼び出し行において、関数の中に入る(呼ばれた関数の1行目からステップ実行を行う)。
  • Step out:関数の外に出る(現在ステップ実行中の処理を関数の最終行またはreturnに到達するまで進めて、呼び出し元の関数で元の関数が呼ばれている行からステップ実行を行う)。

変数の値を調べても有用な情報を見付けられなかった場合には、ステップ実行を繰り返したり、別の位置にブレークポイントを設定したり、ブレークポイントを削除*4したりして、解析を続けていきます。同じ箇所で「期待通りの結果が得られている時の内部状態」と「問題が起こっている時の内部状態」を詳細に比較していけば、「何が原因で、処理が期待と異なる方向に進んでいってしまったのか」を明らかにできるというわけです。

ただ、実際には各変数の値はツリー構造になっていて、奥の奥の方に原因が潜んでいるという場合も多々あるため、余程「ここをピンポイントで調べたい」という事が事前にはっきり分かっている場合でもない限りは、この方法で一発で原因を見付けるというのは難しいです。今回の調査でも、闇雲に調べるだけでは残念ながら決定打となる情報に辿り着く事はできませんでした。

期待外れだったのは、成功ケースで実際にdragoverイベントが発行されている場面の詳細を調べられなかったという点です。この処理はCocoaに対して登録しておくイベントハンドラのような関数の中にあり、Cocoaから通知されたイベントをトリガーに実行されるため、ここにブレークポイントを置いても(関数呼び出しのスタックが切れてしまっているので)デバッガでは処理の呼び出し元を辿る事ができないのです。「成功ケースと失敗ケースで明らかに異なる部分」が目の前にあるにも関わらず、そこからは何の情報も得られないという、非常に残念な結末でした。

ただ、ここまでの調査過程で以下の事は分かりました。

(プロセス間でのドラッグデータの受け渡しの様子の図)

  • 成功ケースでは親プロセス側で認識できたドラッグデータ(アドオンで定義したdragstartイベントのリスナにおいて、event.dataTransfer.setData()で追加したデータ)の個数が1以上になっているが、失敗ケースでは0になっている。子プロセス側で設定したはずのドラッグデータが親プロセスからは認識されていない。そのため、EventStateManager::DoDefaultDragStart()falseを返して終了する形となっており、ドラッグセッションを開始するnsDragService::InvokeDragSessionWithImage()が呼ばれていない。
  • このドラッグデータはEventStateManager::DetermineDragTargetAndDefaultData()
    nsContentAreaDragDrop::GetDragData()
    DragDataProducer::Produce()
    TabParent::AddInitialDnDDataTo()
    TabParentmInitialDataTransferItemsというメンバ変数の値を取得する形で初期化されている。
  • TabParentmInitialDataTransferItemsの値は、TabParent::RecvInvokeDragSession()で設定されている。このメソッドは、プロセス間通信でPBrowser::Msg_InvokeDragSession__IDというメッセージを受け取った時に実行されている
    • この時のIPC周りのコードは自動生成されているため、デバッガでブレークポイントを設定できない。
  • PBrowser::Msg_InvokeDragSession__IDというメッセージは、子プロセス側でnsDragServiceProxy::InvokeDragSessionImpl()が呼ばれた時に(IPC周りの自動生成されたコードを経由して)親プロセスに向けて送出される模様。

以上の通り、成功するケースでは子プロセス側のInvokeDragSessionと親プロセス側のInvokeDragSessionが両方とも期待通りに処理されるのに対し、失敗するケースでは親プロセス側のInvokeDragSessionに到達する前に(ドラッグデータが0個という事で)処理が終わってしまっている、という事が分かりました。という事は、失敗ケースでは「子プロセスが送ったメッセージを何らかの理由で親プロセスが受け取れていない」か、もしくは「子プロセスがそもそもそのメッセージを送出していない」かのどちらかであるという事が言えます。

標準出力・標準エラー出力に現れるメッセージの監視

状況の把握のためにPBrowser::Msg_InvokeDragSession__IDというメッセージやその他のメッセージを親プロセスが受け取れているかどうかを調べたいと思ってコードを見ていると、TabParent::RecvInvokeDragSession()呼び出し元箇所mozilla::ipc::LogMessageForProtocol("PBrowserParent",...というコードがある事に気付きました。関数の定義を調べてみた所、これはデバッグビルドの実行時にMOZ_IPC_MESSAGE_LOGという環境変数にPBrowserParentまたは1が設定されている時に、fputsで標準エラー出力にログメッセージを出力するという物であることが分かりました。このログメッセージが現れるかどうかを調べれば、上記のIPCのメッセージを親プロセスが受け取っているかどうかが分かります。

fputsで直接標準出力や標準エラー出力に出力されたメッセージは、MOZ_LOGで収集するログには出力されません。ではどこを見ればよいかというと、Xcodeの右下のペインです。標準出力や標準エラー出力に出力されたメッセージは、ここで確認する事ができます。

デバッグ実行時の環境変数は、Xcodeのメニューの「Product」→「Scheme」→「Edit Scheme」→「Run」→「Environment Variables」で設定できます。ここでMOZ_IPC_MESSAGE_LOGPBrowserParentを設定して再度デバッグ実行して確認した所、成功ケースでは上記のログメッセージが出るのに対し失敗ケースでは出なかったという事から、親プロセス側がPBrowser::Msg_InvokeDragSession__IDというメッセージを受け取れていないという事が分かりました。

そうなると今度は、子プロセス側がIPCのメッセージを送出しているにも関わらず親プロセスが受け取れていない(IPCの仕組みの中での問題)のか、それとも子プロセス側でnsBaseDragService::InvokeDragSession()が実行されていないかのどちらかという点が問題になります。

任意のログを任意のタイミングで出力させる

ここで子プロセス側の処理に対してデバッガでブレークポイントを設定できればよかったのですが、弊社調査環境では何故か、MDNのマルチプロセス有効時のデバッグに関する説明通りに設定しているにも関わらず、設定したブレークポイントで処理を止める事ができないという状況でした。処理の流れを追いたい肝心のモジュールは前述した通りMOZ_LOGでの指定でログを出力してくれないため、これでは調査のしようがありません。

そのため、ここで初めてカスタムログに頼る事にしました。

MOZ_LOGで出力されるログは既存の物以外に、全く新しくログを出力する事もできます。C++製コンポーネントの開発に関わる人向けにMOZ_LOGで出力可能なログをC++製のモジュール内で定義する手順の説明が用意されていますので、これを参考に、EventStateManager.cppの冒頭で以下のようなログモジュールを定義するようにしました。

using mozilla::LogLevel;
static mozilla::LazyLogModule sEventStateManagerLog("EventStateManager");

その上で、ここまでに調査した範囲の中で特定していたドラッグを開始するかどうかの判定を何段階も行っている関数において、returnする直前にMOZ_LOG(sEventStateManagerLog, LogLevel::Info, ("DoDefaultDragStart - no drag service"));のような行を追加して、ログを見ればどのreturnで関数が終了したのかを分かるようにしました。

このようにして収集したログを調べて分かったのは、2回目以降の失敗ケースにおいては「現在進行中のドラッグセッションがまだあるならば、新たなドラッグセッションは開始しない」という判断の結果親プロセス側でドラッグセッションが開始されていない、という事でした。

つまり、1回目の失敗ケースから2回目以降の失敗ケースにかけての間では以下の事が起こっていたと考えられます。

  • 成功ケースや1回目の失敗ケースでは、子プロセス側では通常通りにドラッグセッションが開始されている。
  • その処理が親プロセスに引き継がれるまでの処理のどこかに問題があって、「子プロセスはドラッグセッションが開始されているが、親プロセスはドラッグセッションが始まっていない(終了した)と認識している」状態が発生した。
  • 本来であれば子プロセス側のドラッグセッションを終了させるための処理が行われるはずなのに、この一連の処理がどこかの時点で中断されてしまったために、子プロセス側のドラッグセッショが終了処理が呼ばれず、ゾンビドラッグセッションが残留してしまっている。
  • このゾンビドラッグセッションがあるせいで、親プロセスでドラッグセッションが開始されなくなっている。

ここまでで分かった事をまとめて、1回目の失敗ケースの原因を探る

ここまでの調査で、2回目以降の失敗ケースは「1回目の失敗ケースにおいて、子プロセス側のドラッグセッションが開始されたにも関わらず、親プロセス側ではドラッグセッションが開始されなかったために、子プロセス側で残ってしまったゾンビドラッグセッション」が原因で発生している事が分かりました。

そうなると今度は、1回目の失敗ケースで何故ゾンビドラッグセッションができてしてしまうのか、何故親プロセス側でドラッグセッションを開始できなかったのか(処理が中断されたのか)、という事を明らかにしなくてはいけません。

現時点までで、1回目の失敗ケースでは子プロセスから親プロセスへドラッグデータがきちんと引き渡されているという事が分かっています。そうなると、「ドラッグデータは受け取れているが、ドラッグセッションを開始できない」という状況が発生する条件は、一体何なのでしょうか?

以上の事を念頭に置きながら関連モジュールのコードを眺めていると、各プラットフォームで共通のドラッグセッション開始のための処理であるnsBaseDragService::InvokeDragSession()から呼び出されるmacOS版固有のnsDragService::InvokeDragSessionImpl()の実装の冒頭に、何らかの条件が満たされなかった時にNS_ERROR_FAILUREというエラーコードを返却するというコードがある事に気がつきました。

さらにその先を読み進めていくとbeginDraggingSessionWithItemsというCocoaのAPIを呼んでいる箇所があります。調べてみると、beginDraggingSessionWithItemsはまさにドラッグセッションを開始するためのCocoaのAPIであると書かれています。

ここまで分かってやっと、失敗ケースでdragoverイベントを発行するためのコードが何故呼ばれないかの謎が解けました。このコードはCocoaでのドラッグセッションが進行中である場合に呼ばれるdraggingUpdateというイベントハンドラの中にありますが、ドラッグセッションが開始されていなければ当然これらのイベントハンドラも呼ばれないという訳です。つまり最初から、beginDraggingSessionWithItemsの呼び出しに至るまでのコードパスを調査すればよかったのでした。Cocoa APIの知識を持たない状態で調査を始めたために、ひどく遠回りをしてしまった事になります。

ともあれ、これで調査が必要な範囲はだいぶ絞り込めました。今度はbeginDraggingSessionWithItemsが呼ばれなくなるパターンの分岐に絞ってMOZ_LOGを仕込んで再びログを収集してみた所、1回目の失敗ケースはまさに先程見付けたNS_ERROR_FAILUREを返す分岐に入っている、という事が分かりました。

その分岐に入る条件はgLastDragViewというグローバル変数が空である事で、これは意味としては、「どのビューでドラッグが開始されたか分からなければ、ドラッグセッションを開始せずにエラーを返す」という事です。

そこでこの変数に値を代入している箇所を検索した所、変数の初期化時を除くと、Cocoa API用のmouseDraggedというイベントハンドラの中でのみ値を設定している事が分かりました。具体的には、4728行目で「そのビュー自身」を代入し、イベントの処理を挟んだわずか10行後にnilを再代入しているという状況でした。そこでこの前後に絞ってMOZ_LOGをさらに仕込んで調査した所、成功ケースと失敗ケースでは非同期処理の実行順が異なっており、そのせいで失敗ケースでは、ドラッグが開始されたビューが分かるより前にドラッグセッションを開始しようとしてドラッグセッションを開始できずにいるらしいという事が分かりました。

詳しい人の意見を聞いてみる

Cocoa APIまわりの非同期処理の話になると、ますますこちらに知見がないため、これ以上の調査は難しく思えます。そこで一旦、識者に意見を求めてみる事にしました。

調査対象にしているファイルのコミット履歴を見ると、複数人が関与している様子が窺えます。この時に注目するのは、コミットした人やパッチを書いた人ではなく、パッチのレビューを行った人です。

パッチが投入されるまでの流れを見ると分かりますが、パッチのコミット自体はcheckin-neededという目印に沿って作業担当者が行っているだけなので、コミットした人は必ずしもそのモジュールのエキスパートという訳ではありません。また、パッチを書いた人もたまたま関わっただけの協力者という立場である可能性があります。それらに比べると、レビューはある程度の知見がないとできないため、複数のパッチでレビュー担当者として指名されている人ほど、信頼できる識者である可能性が高くなります。

Bugzillaではコメントを追記する時に「needinfo」という情報を設定できます。これはバグの報告者など他の人に情報を求める、つまり質問するときに使われる機能です。「Need more information from...」というラベルのチェックボックスをONにして、質問先の種別を「other」にし、上記の方法であたりを付けた識者の人のメールアドレスを入力して投稿すれば、これで「質問されている」という事がその人に通知されます。

数日待った所、その方が反応して下さり、「それぞれのケースのスタックトレースを収集してみてはどうか」というようなコメントを頂けました。

スタックトレースはXcodeのメイン画面の左ペインに表示されますが、これをそのままコピーする事はできません。文字列としてコピーできる形でスタックトレースを取得するには、ブレークポイントで処理を止めた状態でXcodeのウィンドウ右下のペインのコンソールの「(lldb)」と表示されている箇所(これが実はプロンプトになっています)にbtというコマンドを入力して実行すると、コンソール内にスタックトレースの情報が出力されます。これを選択してコピー&ペーストすれば、スタックトレースを容易に収集できます。

この方法でスタックトレースを収集してじっくり比較してみた所、成功ケースではCocoa APIのmouseDraggedハンドラからそのまま呼び出しが続いているのに対し、失敗ケースではそうなっておらず、RefreshDriverTimer::Tickなどのメソッドの呼び出しが親となっていたという事が分かりました。

(1つのイベントループの中で処理が完結する場合は成功する)
(1つのイベントループで処理が完結しなかった場合は失敗する)

成功ケースでも失敗ケースでもmouseDraggedから全ての処理が始まっているとばかり思い込んでいたため、これは盲点でした。

どうやら、何らかの条件に合致した場合には、ドラッグ時のイベントの処理が実際には後のイベントループに回されており、その場合は必要な情報であるgLastDragViewが既に失われてしまっているのでドラッグセッションを開始できない、という事がこの問題の根本的な発生原因である模様です。このような状況は、元々同期処理で書かれていた物を後から非同期処理の形に改修したというような場面でよく見られる物です。Firefoxは現在、体感的なパフォーマンスの向上のための改良が続いていますので、その中でそういった事が起こる事は十分にあり得そうです。

パッチの作成

ともかく、原因が分かったことでようやく問題を修正する段階に移れます。

ここまでの調査で分かった原因からは、以下のような改修案が考えられます。

  1. gLastDragViewをすぐにnilで破棄してしまわずに、ドラッグイベントの処理が終了するまで待ってから破棄するようにする。
  2. gLastDragViewに相当する情報をイベントの情報の一部として引き回す、または保持しておき、後のイベントループで続きが処理される時にその情報を参照するようにする。

1の方法は非常に単純なやり方です。確実にこの問題は直りますが、gLastDragViewがメモリ上に保持される期間が長くなるため、メモリリークや、ドラッグ開始操作として認識されて欲しくない物が誤ってドラッグ開始操作として扱われてしまうといった新しい別の問題の原因になる恐れがあります。

2は、そういった副作用の恐れがない安全な方法に思えます。一般的にはこちらの方針で改修を行う事が望ましいと言えるでしょう。ただ、今回はこの方針を取るのがためらわれる理由があります。それは、gLastDragViewを設定している箇所や参照している箇所がmacOS固有の実装の中であるのに対し、設定箇所から参照箇所までの間に通過する処理はほとんど全てクロスプラットフォームな実装であるという点です。クロスプラットフォームな実装の中に特定プラットフォーム向けのコードを入れるという変更はあまり行儀が良いとは言えず、また、その中には非同期処理の基盤的な実装も含まれていたため、変更の影響が想像以上に広い範囲に及ぶ恐れがあります。副作用の恐れがないはずの方法の方が、実際にはリスクが大きいという困った状況です。

以上の検討結果を踏まえ、1の方が調査が必要な範囲は狭い(macOS固有の実装だけに変更が閉じる)と考えられたため、まずはその前提で影響範囲を調査・検討しました。その結果、想定外のタイミングでドラッグセッションの開始処理が呼ばれてしまう事は理論上あり得ないという結論に至ったため、実際に1の方針でパッチを作成しました。しかし安全であると言い切れる確証は持てなかったため、前のコメントで紹介して頂けた別のこの件に詳しい方にneedinfoで意見を求めてみる事にしました。

時間はかかったものの無事にその方にパッチを見て頂けて、方針はこれで問題ないというコメントと、追加で修正が必要な箇所(コメント形式で書かれた説明文が実装と食い違うようになるため、それらも併せて更新する必要がある)の指摘を頂きました。現在は、その指摘に基づいて更新したパッチを再提出してレビューを待っているという状況です。この後特に問題がなければ、パッチが取り込まれるまでの流れなどで紹介ている通りに進行してマージに至ると期待されます*5

まとめ

以上、プロセス間通信やmacOSでのドラッグ&ドロップといった箇所の実装を調査して、バグの原因を特定しパッチを提出するまでの一通りの経緯をご紹介しました。

最終的なパッチは実質的には1行削除・2行追加しただけの内容ですが、そこに辿り着くまでの調査にはかなりの時間を要する結果となってしまいました。要因の1つに、Cocoa APIを使ったmacOSネイティブアプリにおけるドラッグ&ドロップの実装の一般的な作法を知らないまま調査を進めた事がある事は否めず、その部分の調査をスキップする選択をしたという調査初期の判断ミスが悔やまれます。

この記事で述べた情報そのものが直接的に役に立つ場面はあまり無いと思われますが、未知の部分が大きい調査対象に取り組む際の様々なアプローチの仕方や、調査方針の見直しのタイミングなど、メタな部分で知見を得るための資料、あるいは「しくじり先生」的な反面教師として参考にしていただけると、この紆余曲折の記録も無駄にはならないのではないかと思っております。

OSSの利点の1つとして、今回のように、不可解なトラブルに遭遇した場合でも第三者の立場で詳細な調査を実施できる余地があるという点が挙げられます。本体の開発チームは通常のリリースに向けての作業に注力している事が多く、今回の問題のように希な条件下でしか発生しない・一般のユーザーに与える影響の小さい問題の解決はどうしても後回しになってしまいがちです*6。しかし、その問題に遭遇した当事者にとってはまさに今直面している問題で、ともすれば死活問題ともなり得ます。そのような場合でもただ待つだけ以上の事ができるというのは、OSSならではの事と言えるでしょう。

当社は、そのようにOSS開発により積極的に関われるようになりたい人を支援するOSS Gateという取り組みを支援しています。このエントリを見て「普段使っているOSSに自分でもフィードバックできるのか! 自分もやってみたい!」と新鮮な驚きを感じた方や、「そうそう、こんな感じで調査するんだよね。このやり方が分からなくて困ってる人を手助けできればいいんだけど……」とお考えの方は、ぜひワークショップへの参加をご検討下さい。

また、当社ではFirefoxやThunderbirdの他、Fluentd、Groonga等のOSSの法人利用において発生する様々なトラブルや不具合について有償でのサポートサービスを提供しており、このエントリに記載しているようなソースコードレベルでの調査も承っております。業務上でのOSSの利用でお困りの場合、お問い合わせフォームよりお問い合わせ下さい。

*1 そこで学ぶ知識を今後も活用していける目処があるなら話は別ですが……

*2 ただ、こちらはCocoa(macOSでGUIアプリを実装する際に使われるAPIセット)のイベントハンドラにあたる関数で、単にCocoaでの`draggingUpdate`というイベントを`dragover`というDOMイベントにマッピングするためのものでしかありません。後述しますが、この事が後の調査を難航させる1つの原因になりました。

*3 使用した検証機ではフルビルドに4〜5時間程を要しました。

*4 ブレークポイントを左クリックすると、そのブレークポイントだけ一時的に無効化できます。右クリックして「Delete Breakpoint」を選択すると、ブレークポイントを削除できます。

*5 2019年9月13日追記:紆余曲折あり時間がかかりましたが、Firefox 71時点でようやくマージされました。

*6 とはいえ、今回取り組んだBugはpriorityがP3(中程度)と設定されており、それなりに重要な問題と認識されてはいたようです。

2018-10-17

サポートエンジニアNight vol.4

クリアコードでFirefox・Thunderbirdの法人向けサポート事業に従事している結城です。

去る10月18日、サポートエンジニアNight vol.4が開催されました。当日の発表資料はTECH PLAYのイベントレポートのページから閲覧できます。 (未見の方は、前回(サポートエンジニアNight vol.3)の内容の紹介も併せてご参照下さい。)

今回のテーマは「X人からY人までサポート組織が成長したときに発生した課題や失敗」という物で、Arm Treasure Data、Salesforce、Red Hat、MapRの4社のサポートエンジニアの方から発表がありました。 「一人サポート」から数人・十数人規模への成長の事例もあれば、100人以上の規模への成長の事例もあるという、ある意味でバラエティに富んだ物でした。

一人体制から複数人体制への移行、少人数体制から多人数体制に際しての課題や解決方法には、どの会社にも共通する傾向が見られました。 そこで本エントリでは、発表順ではなくトピック別に各社の取り組みをご紹介します。

なお、口頭のみで話されていた内容を重視して取った記録を元に作成しているため、スライドと口頭での説明の両方で語られていた内容の一部は、このエントリには含まれていない場合があります。 公開されている各発表のスライドおよび他の方の参加記録と併せてご覧頂くのがおすすめです。

サポート専門人員が置かれる以前の時点での取り組み

今回の発表の中で、Red Hat社の木村氏による発表の中に、サポート専門人員が一人体制になる以前の取り組みの紹介が含まれていました。これはRed Hat社に買収される前のJBoss社の話だったのかRed Hat社の話だったのかをきちんと控えそびれてしまったのですが、会社の規模が小さくサポート専門人員を置いていなかった頃には、サポートの質がサービスの評価に強く影響するという考えから、社内で 「エンジニアリングチームは作業時間の30%をサポート対応に充てる」というルールを設けていたそうです。

前回開催時の発表において、サポート専門の人員を置く前の状況として「回答できる人が回答する」といった運用を取っていた事例が紹介されていましたが、専門の担当者がおらず回答期限などがサービスの品質として明記されていない状況では、回答が後回しになるなどの問題が生じやすいのが問題だという事が語られていました。エンジニアリングチーム全体でのサポートへの参加を制度化するというのは、その解決策としてはずいぶん思い切った対応であるようにも思われますが、当時の会社全体でサポートをいかに重視していたかが窺えます。

この制度そのものはサポート専門部署の設立に伴って廃止されたとのことですが、そのような体制が確立されるより前の時点で一定のサポート品質を担保するための取り組み方として興味深い事例と言えるでしょう。

属人化の問題をどう回避するか?

サポート人員が一人またはごく少人数に限られていて、各人の負担が大きいフェーズにおいては、各社共に知識の属人化が課題となっていたようです。 知識が属人化するとその人が単一障害点になってしまうため、どの会社もその解消への取り組みについての話がありました。

その代表的な取り組みとして、やはり各社ともナレッジベースの構築に力を入れている旨の説明が為されていました。

Red Hat社では、ナレッジの量がサポートそのものおよびサポート人員の質の向上に繋がるという事から、再現性が少しでもありそうなものはナレッジにする方針を取っているそうです。 また、一般のエンジニア自身が各自でドキュメントを書く形にすると、実際にそのナレッジを参照する人にとって分かりやすくない書き方になる事もあるため、ドキュメント執筆を専門に担当するチームを設けているとのことでした。

また、日々のサポートの中での知識の共有だけでなく、サポートチームの新メンバーに対するトレーニングも重要です。 Red Hat社ではかつてはトレーニングは担当者の個人リソースに依存していて、やり方が担当者ごとにバラバラだったり、作成されたトレーニング用コンテンツも現状に合わない古い物が未整備で放置されているといった状況があり、サポートチームの規模拡大に伴ってそれを改め、サポートトレーニングチームという専門の部署を設けたそうです。

MapR社ではまだそこまでの規模に至っていないためかトレーニング用のドキュメントという物は用意されていない様子でしたが、新メンバーには社内ドキュメントだけでなく公開のドキュメントも読んでもらっているという事が語られていました。 MapR社では製品の直販を行っていないことから、販売パートナーのエンジニアに対するスキルアップのための情報共有も必要となっているとのことでしたが、そういう場面での資料に使えるという意味でも、公開可能なナレッジを公開しておくことは重要そうです。

Salesforce社の発表では、このような形で構築され公開されたナレッジにより、ユーザー同士の相互サポートによる解決が促進され、問い合わせの発生が減る事にも繋がったとの事でした。

当社の場合も、そういったノウハウの一部はこのククログに記事として公開しており、実際に、過去記事をナレッジベースとして使用する場面もあります。

属人化解消の次のステップとしての専門化

その一方で、Salesforce社の杉本氏の発表においては、特定の技術に専門のチームを設けるという取り組みが紹介されていました。

製品数や機能数が増加してくると、すべての知識を全員が持つというのは現実的に不可能になります。 また、全員で共通して把握できる程度の浅い知識では解決できない、より深い知識が必要となる種類の問題もあります。 特定の技術に精通した専門のチームを設ける事が、そういった問題に対する回答のスピードを上げるために有効だったとの話でした。

これは、一定以上の規模にサポートの人員数が増えた状況において再び専門ごとの分担を行うという事です。 技術が属人化した状態と比較すると、ある技術の専門として働くチームの中ではチームメンバー間での知識の共有により属人化が解消されるために、単一障害点が発生しないという事になります。 少人数での分担の結果として発生する属人化を解消した先で、再び分担が有効に作用するというのは、興味深い事です。

ただ、このようにチームごとに専門分野を分けた場合には、複数の分野にまたがる知識やどの分野にも属さない(チームの隙間に落ちてしまう)知識が求められる問い合わせに対する回答をどのようにすればよいか、という問題が生じます。 そのような問い合わせが増加してくるようであれば、チームの編成を見直したり、分野を横断して「広く浅く」取り扱うチームを新たに設けたりといった形で、何らかの対応を取る必要が生じてくるでしょう。

このような分野ごとの担当分けをSalesforce社の事例では「スキルグループ」、MapR社の事例では「テクニカルディビジョン」といった呼び方で表しているようでした。 Salesforce社ではスキルグループ間の移動は本人の希望を踏まえて行っているとのことで、おそらくはそうして分野をまたいだ知識を身に着けた人が、上記のような「チーム間の隙間に落ちてしまう」問題の解決にあたる事になるのでしょう。

サービスレベルの維持・向上のための施策

SLA*1の定めの有る無しに関わらず、サポートへの問い合わせに対する回答は早いに越したことはありません。 前述の専門分野ごとのチーム分けも、回答のスピードアップのためだった事は既に述べた通りです。

Salesforce社では当初、サポートチームが実際に技術サポートを提供するのにかかる時間そのものよりも、サービスレベルの確認や、翻訳、メールでの連絡といった雑務によるオーバーヘッドが負担となっていたそうです。 この状況を改善するために、

  • メールでの応答を廃止し、オンラインでの問い合わせ窓口をWebサービスに集約した。
  • 契約ごとのサービスレベルの差異を減らし、サービスレベルの確認の手間がそもそも不要になるようにした。
  • 深刻度による問い合わせの仕分けを専門的に行うチーム(トリアージチーム)を設けた。

といった変更を行い、サポートチームが技術的な回答に集中できるようにした事によって、実際に回答に要する時間を短縮することができたとの事でした。

Red Hat社では、製品が普及しデファクトスタンダード化するにつれてユーザー層が技術レベルの高くない人にまで広がっていったことから、技術的難易度の低い問い合わせや、無関係の物への問い合わせ、契約上のサービスレベルを超える対応を望んで食い下がるような問い合わせなどが増加し、技術職の人がその対応に長時間拘束されるという状況が発生していた事から、その解決方法としてエスカレーションマネージャーという担当者を置くようにしたそうです。 問い合わせ内容が契約上妥当かどうかなど、技術とは異なる観点での仕分けを行う専門家を置くことで、こちらも技術者が技術の事に集中して取り組みやすくなった模様です。

当社においても、契約上妥当かどうかの判断が難しかったり契約範囲に含まれなかったりする問い合わせ、交渉が必要な場面などがあり、契約関係を取り仕切っているスタッフに対応を一任する事があります。技術者が技術のことについて自信を持って迅速に回答できるのと同様に、契約や法律の専門化はそれらの問題に自信を持って迅速に回答できるという事で、可能であればやはりそのような体制を作っておくに越したことはないでしょう。

サービスの質の向上という観点からは、問い合わせを受けてから動く「サポート」に留まらず、先を見越してサービス提供者側から積極的にサポートを提供する「プロアクティブサポート」 に取り組んでいる、という説明も複数社からありました。

具体的には、Red Hat社では顧客に電話でコンタクトを取ったり実際に訪問したりといった活動をされていて、MapR社でもTAM*2が顧客訪問を行っているとの話がありました。 また、各社とも顧客環境のテレメトリデータを収集し、異常の兆候を捉えて先回りして提案を行うという取り組みをされているそうです。

問い合わせが発生する前に問題を解決するという意味では、ユーザー同士での相互サポートが促進されるような公開のナレッジの充実も、その一種と言えるかも知れません。

24時間サポートを実現するために

今回の発表を行っていた4社はいずれもグローバル企業で、中には24時間休み無しのサポート体制を敷いている会社もあります。 これは、タイムゾーンが近いエリアごとに現地時刻での日中を主な担当領域として実現しているそうです(例えば、アメリカが日中の時間帯はアメリカやカナダの拠点が対応し、アジア地域の夜が明けたら今度は日本の拠点が対応、という具合)。

ただ、各拠点の通常の業務時間のみで24時間をカバーしきれる体制が整うまでは、夜間対応などの負担が大きかったという声も聞かれました。 例えばRed Hat社では、当初はサポートエンジニアが稼働しているのはアメリカ・インド・ヨーロッパの3拠点のみだったため、アジア地域の問い合わせはアメリカの拠点の人が残業や夜勤で対応していたとのことでした。 その後発表者の木村氏が日本でサポートエンジニアとして稼働し始めたことから、アジア地域の問い合わせが一気に木村氏に集中したという苦労話を披露されていました。

Red Hat社は現在では、例えば日本では17時までにその日の対応内容の情報をまとめ、次の時間帯の担当として稼働を始める拠点に情報を引き継ぐ事で、残業や夜勤をしなくても済むという運用を取られているそうです。

夜勤などの特別なシフトの一例として、アメリカでは「4×10シフト」と呼ばれるシフトが広まりつつあるという話も紹介されていました。これは「日曜から水曜日まで、1日10時間勤務」と「水曜日から土曜日まで、1日10時間勤務」の2チームでシフトを組むという運用です。ただ、健康面の悪影響の懸念や、日本国内では保育園のお迎えの時間帯に間に合わない、労働基準法に抵触する恐れがあるなどの問題があり、日本国内ではまだ採用していないとのことでした。

顧客に喜んでもらえるようになるために

顧客の満足度についても、各社非常に重視している様子が窺えました。

Salesforce社のQ&Aにおいては、顧客満足度の向上に最も効果があったのは何だったかという問いに対して「回答の速度」と回答されていました。「専門チームの設置」といった施策も回答スピードの高速化のためだったという話は前述の通りです。 それ以外の施策として、アンケートの回答で評価が悪かった時にはマネージャーが自ら顧客に電話し、不満を詳しくヒアリングするといった事もされていたそうです。

Red Hat社では、顧客満足度の指標として、「顧客満足度」ではなく「CES(Customer Effort Score)」と「NPS(Net Promoter Score)」を用いているそうです。CESとは「サポートを利用するのが面倒か、簡単か」を数値化した物NPSは「そこの製品を他の人にお薦めできるかどうか」を数値化した物です。実際の所、「顧客満足度」は主観によるため定量化が難しいですが、顧客満足度が低くなる理由は「問い合わせてもなかなか回答が無い」「サポートに電話をかけても繋がらない」といった物がかなりの部分を占めているそうで、その点にフォーカスした指標であるCESに切り替えた事で、評価の定量化が容易になり改善を図りやすくなったとのことでした。

Arm Treasure Data社の高橋氏による発表においても、顧客の納得感を何より大事にするという事を念頭に置き、反応を早く・マメに返すことを意識されているとのお話がありました。また回答にあたっては、周囲の人の協力を得たり、エスカレーション先となる専門のエンジニアからの反応が悪ければ回答を催促したりといった具合に、社内の都合で手段を選んだり遠慮をしたりはしないよう心かげているとのことでした。

サポート部門のモチベーション維持、人事について

MapR社の発表の中では、社長などの経営層がサポート部門を重要視していて、また、そのように認識している事を公言しているという事を話されていました。 サポートをアウトソースせず自社内で育てていく方針を取っているのもそのためとのことで、そのようなメッセージが内外に向けて示されているという事は、サポートに関わる人のモチベーション維持にも良い効果があると言えるでしょう。

開発エンジニアなどの目立つ部分に比べると、サポートエンジニアの採用には人が集まりにくい事から、各社とも苦労している様子です。 それだけに離職者が発生するとダメージが大きく、Arm Treasure Data社では1on1を行ったり、他部門とのコミュニケーションを密に取ったり、会社の買収といった大きな出来事の前後でもメンバーが動揺しないように気を遣ったりと、メンバーの心理面のケアに関する取り組みが行われている旨のお話がありました。 興味深いところでは、サポートの業務だけをやっていると気分が滅入りやすいため、やる気が高まるような別のタスクも随時並行してこなすという事も行っているとのことでした。

サポートエンジニアの人事評価の難しさについても、各社から語られていました。 実際にRed Hat社の発表においては、ナレッジの執筆を数で評価すると質の低いナレッジが増産されてしまうというような形で、数値目標を設定すると必ずその裏をかかれるという事や、解決した問題の「難しさ」を第三者が客観的に評価できないという事などが例に挙がっていました。

この点についてArm Treasure Data社では、「チームの目標」と「個人の目標」をそれぞれ別々に設定した上で、チームの目標にのみKPI*3を設定しているとの事でした。これは、顧客の数や地域の顧客層などによって個人の目標の達成度合いに差が出やすいため、単純に評価すると却ってモチベーションの低下を招きかねないという事からだそうです。

まとめ

以上、各社の取り組みをトピックごとに整理する形で、サポートエンジニアNight vol.4の発表内容をまとめてみました。

Salesforce社の発表では、サポート部門のなかでTier1からTier3までの3段階にチームを分け、簡単な内容への回答はTier1で行い、そこで解決できなかった問題をTier2、Tier3へとエスカレーションしていくというサポート体制の説明がありました。 また、特別な顧客向けに別ラインのプレミアムサポート的な窓口も設けているという説明がありました。

当社のサポート業務は基本的には、ここでいうTier3やプレミアムサポートに特化して顧客企業向けにサポートを提供する物に近いです。 組織体制や事業内容的にも、サポート人員のみを10人、20人と増員していく事は現実的でなく、そのため残念ながら、今回拝聴した内容の多くの部分は直接的に当社のビジネスの参考にはなりにくいのは否めません。

もし当社のような規模・業態のサポート事業での知見にもニーズがあるようであれば、発表を通じてサポートエンジニアコミュニティへ何らかの有用なフィードバックを行っていければと思います。

*1 Service Level Agreement。提供する事を約束するサービスの品質についての事前の取り決め。

*2 Technical Account Manager

*3 Key Performance Indicator。目標の達成度を測る指標。

2018-10-23

Firefoxのメモリ消費量が右肩上がりで増加する場合の対策

FirefoxをWindows Serverベースのシンクライアント環境で使っている組織において、Firefoxを更新したりWindows Serverをリプレースしたりといったタイミング以降で、急にパフォーマンスが低下しだしたというお問い合わせを複数頂きました。調査の結果、それらの現象は同一の原因である可能性が高い事、同様の対策が有効に作用する事が分かりましたので、その際に得られた調査結果を知見としてご紹介します。

なお、本エントリで紹介する設定はすべてFirefox ESR52とFirefox ESR60向けの物です。 将来のバージョンのFirefoxでは有効でない可能性がありますので、ご注意下さい。

パフォーマンス低下の原因

お問い合わせを頂いた複数の事例において、サーバー全体のリソース状況を監視して頂いた所、業務の開始と共にメモリの消費量が右肩上がりに増大しており、退勤時刻になってユーザーがログオフし始めるまでメモリ不足の状況が続くという状況である事が分かりました。また、この時メモリを最も消費しているのはFirefoxのプロセスであるという事も判明しました。

(メモリ消費量の傾向のイメージ)

いくつかの事例では、FirefoxやWindows Serverのリプレース以前にはこのような現象は発生していなかったという事が分かっていました。そのため、これは単純にメモリ消費量が増大したというよりも、 使い終わったメモリ領域がいつまでも開放されないという問題(いわゆるメモリリーク) であると考えられました。

一般に、プログラムの動作速度と消費リソースの量は比例します。現在のFirefoxは体感速度の向上に努めているため、キャッシュを多めに保持したり、複数のプロセスを並行動作させたりといった形でリソースの消費が多くなりがちです。その過程で必ずしも必要でないような大量のメモリ領域を確保する事があり、それが何らかの理由で解放されないままとなってしまうと、物理メモリの枯渇に繋がり、ディスクドライブ上の仮想メモリとの間でスワップが頻発して却ってパフォーマンスが悪化してしまう事になります。

よって、この問題を解消するためには何らかの方法でメモリ消費量を削減する必要があります。

端的にメモリ消費量を抑える

Firefoxが消費するメモリの上限を強制的に設定する事はできないのでしょうか? 実は、できます。 Firefox全体での消費メモリ量の上限は設定できませんが、FirefoxのJavaScript実行エンジンが使用できるメモリの量は上限を設定できるようになっており、例えば以下のようにするとJavaScript実行エンジンの使用可能なメモリの上限が1GBに設定されます。

// 単位はbytes。
// 1GB=1024MB、1MB=1024KB、1KB=1024bytesのため、
// 1024 * 1024 * 1024 で1GBを表す。
lockPref("javascript.options.mem.max", 1024 * 1024 * 1024);

このlockPref()という書き方は、MCDと呼ばれる設定方法に特有の物です。Windows Serverであれば、そのマシン上のFirefoxのインストール先に上記の内容を記載したautoconfig.cfgを設置することで、そのWindows Severにログオンするすべてのユーザーに設定が反映されます。

ただし、この方法で上限を設定した場合に上限を超える量のメモリが必要な状況が発生すると、Firefoxがクラッシュしたり、フリーズしたり、Webページを表示できなくなったりといったトラブルが発生する可能性があります。 現代のWebページは1ページあたり数百MBのメモリを消費する事も多く、安心して動作できるだけの数値を設定すると、多人数でリソースを共有するWindows Serverにおいては結局は各ユーザーで物理メモリを容易に食い潰してしまうという結果になるでしょう。 ですので、この設定は今回のような事例では実際には有用ではありません。

Firefoxのプロセス数を減らす

安全に行える範囲の設定では、マルチプロセス機能の無効化がメモリ消費量の削減にある程度有効です。

現在のFirefoxは、体感速度や安定性、セキュリティを向上する事を目的に、処理を複数のプロセスで並行して行う設計となっています。 しかし基本的にFirefoxはそれなりの量のメモリを消費する設計のため、プロセス数が増えれば増えるほどその分余計なメモリを消費する事になります。 マルチプロセス機能を無効化すると、前述の利点を失う事と引き替えにメモリ消費量を削減する事ができます。

マルチプロセス機能についてはFirefoxの設定画面からもある程度の設定は変更できますが、抜本的な無効化には隠し設定の変更が必要です。 前述のMCDで行う場合は以下のようになります。

// マルチプロセス機能の無効化
lockPref("browser.tabs.remote.autostart", false);
lockPref("browser.tabs.remote.desktopbehavior", false);
lockPref("dom.ipc.multiOptOut", 1);
lockPref("browser.tabs.remote.force-enable", false);

ただ、メモリ消費量の増大がメモリリークに起因している状況では、これも根本的な対策とはなりません。 プロセス数が少ない分、メモリ消費量の増大ペースは緩やかになる事が予想されますが、それでもいつかはメモリの枯渇が発生します。

開けるタブの最大数を制限する

プロセス数の削減と似た対策として、Firefox上で開けるタブの最大数を制限するという方法もあります。 これはFirefox本体の設定のみでは不可能で、Lean Tab Limiterというアドオンを導入する事で行えます。 全体向けに導入する場合、ポリシー設定でpolicies.Extensions.Installを設定するか、特定のフォルダにファイルを設置することによるサイドローディング若干情報が古い日本語での解説)でインストールする必要があります。

ただし、2018年10月24日現在公開されているLean Tab Limiter 0.1は管理者による設定の変更に対応していないため、タブの最大数の制限は各ユーザーが設定しなくてはなりません。 管理者側でタブの最大数を設定したい場合は、Storage Manifestを使った管理者による設定に対応した改造版であるLean Tab Limiter Advancedを使う必要があります。以下は、タブの最大数を5に設定しつつ、制限を超えた数のタブを開こうとした場合に通知メッセージを端的に表示する場合の設定例です。

{
  "name": "lean-tab-limiter-advanced@clear-code.com",
  "description": "Managed storage manifest for lean-tab-limiter-advanced",
  "type": "storage",
  "data": {
    "tab-limit": 5,
    "notify-blocked": true,
    "notify-blocked-title": "%s+1個以上のタブ及びウィンドウは開けません",
    "notify-blocked-message": " "
  }
}

原因を踏まえてメモリの過大な消費を抑制する

Firefoxにおいてメモリリークに類する現象が発生する場合、ガーベジコレクションが適切に実行されていない事が原因となっている場合があります。

ガーベジコレクションはJavaScriptなどの言語において不要になったメモリ領域の回収・解放を行う仕組みですが、ガーベジコレクションを行うためには基本的に他のすべてを停止する必要があるため、単純に頻繁に実行すると体感のパフォーマンスが低下します。 そのため初期状態では、一定時間以上操作が行われていなかったり、改修が可能そうなメモリの量がある程度以上の量溜まっていると検出されたり、といった条件が満たされた時に初めて実行されるという設定になっています。 よって、何らかの理由からガーベジコレクションの実行条件が満たされにくい状態にあると、未使用メモリが回収されないまま新たにメモリが確保されるという事が繰り返されるために、見た目のメモリ消費量が右肩上がりに増大してしまうという状況が発生します。

また、Firefoxの見た目のメモリ消費量が急上昇する背景には、Firefoxの「こまめにメモリ領域を確保するのではなく、ある程度のまとまった量のメモリ領域を先んじて確保しておき、必要に応じてFirefox内部でそれを小分けにして使う」という戦略があります。 これは、OSに対してのメモリ確保要求は一般的に時間がかかる事が多いため、このような戦略をとった方が体感速度の向上に繋がるという理由によるものですが、この事とガーベジコレクションが実行されないという現象が重なると、見た目のメモリ消費量が異常な速度で上昇していくという状況が発生します。

よって、以下のような設定を行えば、体感速度の低下と引き替えにメモリ消費量の異常な増大を防ぐ事ができると考えられます。

  • ガーベジコレクションの実行頻度を上げる。
  • 一度に確保するメモリ領域の大きさを小さくする。

実際に、デスクトップPCに比べて搭載メモリ量が小さいAndroid端末用の初期設定は、Firefox 52時点ではそのような趣旨に沿った内容になっています*1

以上の事を踏まえての、効果が大きく且つ体感速度の低下度合いが小さいと考えられる設定項目とその設定値の例は以下の通りです。

// GCを実行する基準にするメモリ消費量(単位:MB)。0〜1000の間で設定。
// 数値を小さくする事で、GCの実行頻度を上げ、
// パフォーマンス低下と引き替えにメモリ使用量を削減できる可能性がある。
// 初期値は30。
lockPref("javascript.options.mem.gc_allocation_threshold_mb", 3);

// 高頻度でGCとメモリ領域の確保が実行される場面での、最大の
// メモリ確保増加量(パーセンテージ)。
// 値を小さくする事で、パフォーマンス低下と引き替えにメモリ使用量を
// 削減できる可能性がある。
// 初期値は300。
lockPref("javascript.options.mem.gc_high_frequency_heap_growth_max", 150);

// 高頻度でGCとメモリ領域の確保が実行される場面での、最小の
// メモリ確保増加量(パーセンテージ)。
// 値を小さくする事で、パフォーマンス低下と引き替えにメモリ使用量を
// 削減できる可能性がある。
// 初期値は150。
lockPref("javascript.options.mem.gc_high_frequency_heap_growth_min", 150);

// 高頻度でGCとメモリ領域の確保が実行される場面での、最大の
// メモリ確保増加量(単位:MB)。
// 値を小さくする事で、パフォーマンス低下と引き替えにメモリ使用量を
// 削減できる可能性がある。
// 初期値は500。
lockPref("javascript.options.mem.gc_high_frequency_high_limit_mb", 100);

// 高頻度でGCとメモリ領域の確保が実行される場面での、最小の
// メモリ確保増加量(単位:MB)。
// 値を小さくする事で、パフォーマンス低下と引き替えにメモリ使用量を
// 削減できる可能性がある。
// 初期値は100。
lockPref("javascript.options.mem.gc_high_frequency_low_limit_mb", 100);

// GCを行うしきい値となるゾーンごとのGC対象のメモリ使用量(単位:MB)。
// 数値を小さくする事で、より頻繁にGCが実行されるようになり、
// パフォーマンス低下と引き替えにメモリ使用量を削減できると考えられる。
// 初期値は128。
lockPref("javascript.options.mem.high_water_mark", 16);

また、まだお客様環境への反映実績はないものの、Firefox ESR60の時点でデスクトップ版とAndroid版の間で初期設定が変更されている項目として以下の設定も有効である可能性があります。

// GC対象になりやすい短命なオブジェクトを保持するためのメモリ領域の最大サイズ(単位:KB)。
// 数値を小さくする事で、単純に必要なメモリ領域の量を削減できると考えられる。
// また、空きメモリ領域がこの値より小さい時は常にアイドル時にGCが実行される。
// デスクトップ版Firefoxでの初期値は16384、Android版の初期値は4096。
lockPref("javascript.options.mem.nursery.max_kb", 4096);

実際に、お問い合わせがあったお客様の環境ではこれらの設定によってメモリ消費量の右肩上がりの増大が収まり、旧環境とほぼ同水準のメモリ消費量に収まるようになったという結果が得られています。 しかしその一方で、消費リソースを削減するという事から事前に予想された通り、体感速度の低下も発生しており、ユーザー体験の悪化が許容範囲内となるバランスを模索する必要があったようです。 本エントリを参考に設定を反映する場合でも、上記の設定を叩き台として、ピーク時にもFirefox以外のアプリケーションを実行できる程度の空きメモリ領域を確保できる程度の範囲で、なるべく体感速度が損なわれないよう数値を調整する事を強くお勧めします。

その他、メモリ消費量と体感速度に関係している可能性がある設定

なお、Firefox ESR52を対象とした調査の過程で、メモリ消費量と体感速度に影響を与える可能性がある設定項目として、前項に挙げた項目の他にも以下の物がある事が分かりました。 それぞれどの程度の効果があるかは不明ですが、参考として記載しておきます。

  • javascript.options.asmjs(初期値:true):asm.jsのコンパイラを有効にする。無効化する事で、パフォーマンス低下と引き替えに若干メモリ消費が減る可能性がある。
  • javascript.options.baselinejit(初期値:true):Firefox上で実行されるJavaScriptの全てについて、実行時コンパイルを行い処理を高速化する。無効化する事で、パフォーマンス低下と引き替えに若干メモリ消費が減る可能性がある。
  • javascript.options.compact_on_user_inactive(初期値:true):ユーザーが離席している時にGCを行う。無効化するとGCの実行頻度が下がる。
  • javascript.options.compact_on_user_inactive_delay(初期値:300000):ユーザーが離席していると判断する時間(ミリ秒)。初期値は30秒だが、これを小さくするとより頻繁にGCが実行され、メモリ消費が減る可能性がある。
  • javascript.options.discardSystemSource(初期値:false):コンパイルした関数のソースをメモリ上から破棄するかどうか。有効化する事でメモリ消費が減る可能性があるが、Function.prototype.toSource()が機能しなくなるため、この機能を使用しているスクリプトを実行する場合は期待通りに動作しなくなる恐れがある。
  • javascript.options.gc_on_memory_pressure(初期値:true):空きメモリが逼迫して充分な使用メモリ領域を確保できなかった時にGCを実行するかどうか。無効化するとGCの実行頻度が下がる。
  • javascript.options.ion(初期値:true):Firefox上でのJavaScriptの実行時コンパイルについて、新型のエンジンであるIonMonkeyを使用する。無効化する事で、パフォーマンス低下と引き替えに若干メモリ消費が減る可能性がある。
  • javascript.options.ion.offthread_compilation(初期値:true):IonMonkeyにおいて別スレッドでコンパイルを実行する。無効化する事で、パフォーマンス低下と引き替えに若干メモリ消費が減る可能性がある。
  • javascript.options.mem.gc_compacting(初期値:true):GCの実行時に使用メモリの縮小も行うかどうか。インクリメンタルGCの場合には影響しない。無効化するとメモリ使用量が増大する可能性があり、変更しない事が望ましいと考えられる。
  • javascript.options.mem.gc_dynamic_heap_growth(初期値:true):使用メモリ領域を確保する時の一度に確保するメモリの量を動的に変えるかどうか。無効化すると「300%」固定になる。使用メモリを最小化する事を優先する場合、300%固定にされるよりは、動的な計算でgc_low_frequency_heap_growthgc_high_frequency_heap_growth_maxで上限を110%などに設定した方が有効と思われる。
  • javascript.options.mem.gc_dynamic_mark_slice(初期値:true):高頻度でメモリ領域の確保が行われている時に、インクリメンタルGCにおいて処理単位を大きくする。無効化するとインクリメンタルGCの効率が落ちメモリ使用量が減りにくくなると予想される。
  • javascript.options.mem.gc_high_frequency_time_limit_ms(初期値:1000):GCとメモリ領域の確保が頻繁に行われていると判断する基準の時間(ミリ秒)。値を大きくすることで、パフォーマンス低下と引き替えにメモリ使用量を削減できる可能性がある。
  • javascript.options.mem.gc_incremental(初期値:true):インクリメンタルGC(段階的なGC)を行うかどうか。無効化するとGCの実行頻度が下がりメモリ使用量の増大に繋がる可能性がある。
  • javascript.options.mem.gc_incremental_slice_ms(初期値:10):インクリメンタルGCを行う時間間隔(ミリ秒)。既に充分小さい間隔なので、間隔を小さくして頻度をこれ以上上げてもメモリ使用量の削減という効果はあまり変わらないと予想される。逆に、間隔を大きくして頻度を下げるとメモリ使用量が増大すると考えられる。以上の事から、変更しない事が望ましいと考えられる。
  • javascript.options.mem.gc_low_frequency_heap_growth(初期値:150):低頻度(1秒に1回レベル)でGCとメモリ領域の確保が実行される場面での、メモリ確保増加量(パーセンテージ)。値を小さくする事で、パフォーマンス低下と引き替えにメモリ使用量を削減できる可能性がある。
  • javascript.options.mem.gc_max_empty_chunk_count(初期値:30):GCに使用するメモリ領域について、キャッシュ的に確保しておく領域の最大数。小さい値を設定すると、パフォーマンス低下と引き替えにメモリ使用量を削減できる可能性がある。
  • javascript.options.mem.gc_min_empty_chunk_count(初期値:1):GCに使用するメモリ領域について、キャッシュ的に確保しておく領域の最小数。小さい値(0)を設定すると、パフォーマンス低下と引き替えにメモリ使用量を削減できる可能性がある。
  • javascript.options.mem.gc_per_zone(初期値:true):GCをゾーンごとに行うかどうか。インクリメンタルGCが有効な場合はこの設定に関わらずインクリメンタルGCが行われれるので、設定変更の必要はないと考えられる。
  • javascript.options.mem.gc_refresh_frame_slices_enabled(初期値:true):インクリメンタルGCの進行中に、メモリの回収を細かい単位で行うかどうか。無効化するとメモリ使用量が増大する可能性がある。
  • javascript.options.parallel_parsing(初期値:true):JavaScriptの解釈を別スレッドで行う。無効化すると、パフォーマンス低下と引き替えにメモリ使用量を削減できる可能性がある。
  • javascript.options.showInConsole(初期値:true):JavaScriptの内部エラーをコンソールに表示する。無効化するとコンソールのためのバッファの消費が減るため、デバッグ情報を収集しにくくなる事と引き替えにメモリ使用量を削減できると考えられる。
  • javascript.options.wasm_baselinejit(初期値:false):WebAssemblyの実行をさらに高速化するかどうか。有効化するとパフォーマンス改善に繋がるが、メモリ使用量は増大すると予想される。

まとめ

以上、Firefoxのメモリ使用量が右肩上がりに増大する状況でのメモリ使用量削減に効果があると思われる対策をご紹介しました。

当社のOSSサポートサービスでは、このエントリに記載したような、企業でFirefoxを運用中に発生したトラブルについての原因および解決策の有無の調査を有償にて承っております。 FirefoxやThunderbirdの運用でお困りの情報システム担当社の方は、お問い合わせフォームよりお問い合わせ下さい。

*1 一方、現在はこれらのAndroid版用の設定はなくなっていて、一部を除くとデスクトップ版と共通の設定値になっています

タグ: Mozilla
2018-10-24

リーダブルなコードを目指して:コードへのコメント(5)

db tech showcase Tokyo 2018Apache Arrow 0.11.0のリリースとかしていたら2ヶ月ちょい経ってしまっていた須藤です。

リーダブルなコードを目指して:コードへのコメント(4)の続きです。前回はフレームレート関連の処理のところを読んでコメントしました。

リポジトリー: https://github.com/yu-chan/Mario

今回のコメントに関するやりとりをするissue: https://github.com/yu-chan/Mario/issues/5

ゲーム本体

今回はついにゲーム本体の処理を見ていきます。ゲーム本体の処理はメインループからはじまるのでまずはメインループの中をおさらいします。

	while(!InputInterface::isOn(KEY_INPUT_Q)) { //Qを押したら終了
		if(ProcessMessage() != 0) {
			break;
		}
		InputInterface::updateKey();
		Framerate::instance()->update();
		ClearDrawScreen();

		//ゲーム開始
		Sequence::Parent::instance()->update();

		ScreenFlip();
		Framerate::instance()->wait();
	}

「ゲーム開始」というコメントがあるのでSequence::Parentを見ていきましょう。

定義はSequence/Parent.hにありました。名前空間とパスが一致していて探しやすいですね。

#ifndef INCLUDED_SEQUENCE_PARENT_H
#define INCLUDED_SEQUENCE_PARENT_H

namespace Sequence {

class Child;

class Parent {
public:
	static void create();
	static void destroy();
	static Parent* instance();

	void update();

	enum NextSequence {
		NEXT_TITLE,
		NEXT_GAMEOVER,
		NEXT_ENDING,
		NEXT_GAME,

		NEXT_NONE
	};
	void moveToNextSequence(NextSequence);
	
	void deleteChild();

private:
	Parent();
	~Parent();
	static Parent* mInstance;

	NextSequence mNextSequence;
	Child* mChild;
};

}

#endif

以下の部分はシングルトンパターンを実現するためのコードなので今回は無視しましょう。

	static void create();
	static void destroy();
	static Parent* instance();
	Parent();
	~Parent();
	static Parent* mInstance;

ということで注目するのは以下の部分です。

namespace Sequence {

class Child;

class Parent {
public:
	void update();

	enum NextSequence {
		NEXT_TITLE,
		NEXT_GAMEOVER,
		NEXT_ENDING,
		NEXT_GAME,

		NEXT_NONE
	};
	void moveToNextSequence(NextSequence);
	
	void deleteChild();

private:
	NextSequence mNextSequence;
	Child* mChild;
};

}

メインループはupdate()を呼んでいるだけだったので、update()の中でmoveToNextSequence()deleteChild()を呼んでいるのでしょう。

名前から想像するとそれぞれのメンバー関数が実現する機能は次の通りです。

  • update()
    • ループ毎に適切な次のなにかを実行する。キャラクターを動かすとか。
  • moveToNextSequence()
    • NextSequenceNEXT_TITLEとかNEXT_GAMEOVERなので、ゲームの状態(開始とか終了とか)の遷移をするんだろう。
    • Sequenceという名前空間はこのNextSequenceSequenceと同じ意味なの?
    • 同じ意味ならSequence::Parent::NextSequenceというように複数回出現するのは冗長かなぁ。
    • 違う意味なら紛らわしいので違う単語を使うとわかりやすくなりそう。
  • deleteChild()
    • 子どもを削除?
    • このクラスはParentclass Child;という前方宣言もあるのでなにかしら親子関係がありそうだけど、なにとなにが親子関係になっているんだろう。

私はゲームを作らないのでゲーム固有の用語をよく知らないのですが、シーケンスという名前や(なにかわからないけど)親子関係にするのは普通なのかもしれません。実装も読んでみましょう。

update()は次のようになっています。

void Parent::update() {
	mChild->update(this);

	switch(mNextSequence) {
		case NEXT_TITLE:
			deleteChild();
			mChild = new Title();
			break;
		case NEXT_GAMEOVER:
			deleteChild();
			mChild = new GameOver();
			break;
		case NEXT_ENDING:
			deleteChild();
			mChild = new Ending();
			break;
		case NEXT_GAME:
			deleteChild();
			mChild = new Game::Parent();
			break;
	}

	//処理をしておかないと、次へ進めない
	mNextSequence = NEXT_NONE;
}

メインの処理はmChildが扱うようです。update()ではなにもしていませんでした。

caseの中を見るとそれぞれのシーケンス(状態?)毎にmChildのオブジェクトが変わっています。NEXT_TITLEならTitleオブジェクトになっていますし、NEXT_GAMEOVERならGameOverオブジェクトになっています。

update()内でmNextSequenceが変わっていないのが気になりますが、たぶん、deleteChild()内で変えているのでしょう。

caseNextSequenceのすべてをカバーしていないことが気になりました。NEXT_NONEだけがないのですが、おそらく、NEXT_NONEのときはなにもしないから省略したのでしょう。

caseですべての値をカバーしていないとコンパイラーが警告を出すはずなので私は書いておいた方がよいと思います。次のような感じです。

	switch(mNextSequence) {
		case NEXT_TITLE:
			// ...
			break;
		case NEXT_GAMEOVER:
			// ...
			break;
		case NEXT_ENDING:
			// ...
			break;
		case NEXT_GAME:
			// ...
			break;
		case NEXT_NONE:
			// Do nothing
			break;
	}

case NEXT_NONE:を使わずにdefault:を使う方法もあります。

	switch(mNextSequence) {
		case NEXT_TITLE:
			// ...
			break;
		case NEXT_GAMEOVER:
			// ...
			break;
		case NEXT_ENDING:
			// ...
			break;
		case NEXT_GAME:
			// ...
			break;
		default:
			// Do nothing
			break;
	}

私は使わなくて済むときはdefault:を使わないようにしています。そうすると、新しいenum値を追加したときにコンパイラーが(警告という形で)教えてくれるからです。これにより新しいenum値の対応漏れを減らせます。

たとえば、NextSequenceNEXT_CONTINUEを追加して次のようになったとします。

	enum NextSequence {
		NEXT_TITLE,
		NEXT_GAMEOVER,
		NEXT_ENDING,
		NEXT_GAME,
		NEXT_CONTINUE,

		NEXT_NONE
	};

default:を使っていると既存のswitchで特に警告はでませんが、使っていない場合は「NEXT_CONTINUEの処理がないよ」とコンパイラーが教えてくれるはずです。

それではdeleteChild()を見てみましょう。

//画面遷移する際に、今の画面を破棄する
void Parent::deleteChild() {
	delete mChild;
	mChild = 0;
}

mChildを解放しているだけでした。mNextSequenceParent内では変えないようです。

mChild = 0はC言語ではNULLを代入しているのと同じことですが、C++では0よりもnullptrを使った方がよいです。整数と区別できますし、ヌルポインターを使いたいという意図を表現できます。

mChild = nullptr;

ただ、C++11からしか使えないので古いコンパイラーもサポートしないといけないときは使えません。

一応、moveToNextSequence()も見てみましょう。

//画面遷移を更新
void Parent::moveToNextSequence(NextSequence next) {
	mNextSequence = next;
}

mNextSequenceを更新しているだけですね。外のオブジェクトからこのメソッドを呼んで状態を更新するのでしょう。でも誰がどこから?Sequence::Parentはシングルトンパターンになっているのでどこからでも更新できます。mChildのオブジェクトの実装を見ていくと見つかるでしょう。

Sequence::Parentは以上です。今回はわからないことが多かったです。どうしてSequenceという名前空間なんだろうとか、どうしてParentというクラス名なんだろうとかです。ParentmChildではなく、もう少し具体的な名前をつけられないでしょうか。たとえば、ScenariomStageとかです。今後、mChildのオブジェクトになるクラスを見ていくとなにかよい名前が浮かぶかもしれません。

まとめ

リーダブルコードの解説を読んで「自分が書いたコードにコメントして欲しい」という連絡があったのでコメントしています。今回はついにゲームの処理に入りました。しかし、まだわからないことが多く、名前がピンときていません。次回はより具体的な処理を1つ選んで読んでいきます。

「リーダブルなコードはどんなコードか」を一緒に考えていきたい人はぜひ一緒にコメントして考えていきましょう。なお、コメントするときは「悪いところ探しではない」、「自分お考えを押し付けることは大事ではない」点に注意しましょう。詳細はリーダブルなコードを目指して:コードへのコメント(1)を参照してください。

2018-10-26

«前月 最新記事 翌月»
タグ:
年・日ごとに見る
2008|05|06|07|08|09|10|11|12|
2009|01|02|03|04|05|06|07|08|09|10|11|12|
2010|01|02|03|04|05|06|07|08|09|10|11|12|
2011|01|02|03|04|05|06|07|08|09|10|11|12|
2012|01|02|03|04|05|06|07|08|09|10|11|12|
2013|01|02|03|04|05|06|07|08|09|10|11|12|
2014|01|02|03|04|05|06|07|08|09|10|11|12|
2015|01|02|03|04|05|06|07|08|09|10|11|12|
2016|01|02|03|04|05|06|07|08|09|10|11|12|
2017|01|02|03|04|05|06|07|08|09|10|11|12|
2018|01|02|03|04|05|06|07|08|09|10|11|12|
2019|01|02|03|04|05|06|07|08|09|10|11|12|
2020|01|02|03|04|05|06|07|08|09|10|11|12|