production.log

株式会社リブセンスでエンジニアをやっている星直史のブログです。

InstagramグラフAPIのプラットフォーム利用規約違反対応をしました

概要

SnapmartアプリではInstagramグラフAPIを使用しているのですが、8月下旬にMeta社より「プラットフォーム違反しているからAPI利用停止するね」とお達しがありました。*1
Instagram連携不具合解消のお知らせ | Snapmart(スナップマート)公式ブログ

この記事では、InstagramグラフAPIのプラットフォーム利用規約違反対応内容を紹介していきます。

Meta社からの指摘事項

Meta社からは以下の指摘をされました。

Platform Terms 6.a.i.1: You must always have in effect and maintain administrative, physical, and technical safeguards that do the following: Meet or exceed industry standards given the sensitivity of the Platform Data

For more information, visit:
- Developer Policies: https://developers.facebook.com/devpolicy
- Platform Terms: https://developers.facebook.com/terms

In particular, for your app we require your compliance with Steps A, B, C, D, E, F, G, and H. Ensure you label your evidence accordingly - so, for example, if a screenshot shows the results of a recent security vulnerabilities test you conducted, label that attachment C.

  • [A] Enforce encryption at rest for all Platform Data storage (e.g., all database files, backups, object storage buckets) - Please attach a screenshot of how you implement encryption at rest on your system such as a screenshot of the encryption controls enabled on your data server, etc.
  • [B] Enforce TLS 1.2 encryption or greater for all network connections where Platform Data is transmitted – Please upload evidence of how you enforce TLS 1.2 encryption such as a screenshot of the encryption controls enabled on your servers or logs that monitor your encryption of data in transit.
  • [C] Test your app and systems for vulnerabilities and security issues at least every 12 months – Screenshots of any vulnerability and/or security scans and assessments performed in the last 12 months.
  • [D] Protect sensitive data like credentials and access tokens – Screenshots of the system/tool that you use to protect sensitive data like credentials and access tokens such as a vault or secrets manager.
  • [F] Require multi-factor authentication for remote access – Screenshot of the tools/configurations that you use that prove that you implement multi-factor authentication for remote access.
  • [H] Have a system for keeping system code and environments updated, including servers, virtual machines, distributions, libraries, packages, and anti-virus software – Screenshots of any updates performed on the system; security patches and any additional evidence to validate if the system code and environments are updated promptly. For example dependabot on GitHub.

Meta社が何を基準にSnapmartアプリに目をつけたのかは不明ですが、兎にも角にも主にセキュリティ面において業界標準以上の水準を目指しましょうということですね(白目)

[A] Enforce encryption at rest for all Platform Data storage (e.g., all database files, backups, object storage buckets) - Please attach a screenshot of how you implement encryption at rest on your system such as a screenshot of the encryption controls enabled on your data server, etc.

広義のデータストレージにおいて、暗号化してくださいということですね。
SnapmartではAWSを利用しているので、対象になるのはS3とRDSです。

S3

SSE(Server Side Encryption)を有効にします。
こちらはマネジメントコンソールから設定することができます。

2023年1月5日以降は、デフォルトで暗号化されるようになったようです。

docs.aws.amazon.com

RDS

RDSに関しても暗号化を行います。
また、S3と同様にマネジメントコンソールから操作が可能です。
具体的な手順は以下の通りです。

  1. インスタンスを停止
  2. 暗号化するRDSのスナップショットを作成
  3. 作成したスナップショットを暗号化を有効にしてコピーする
  4. コピーしたスナップショットからRDSを復元
  5. 元RDSのインスタンス名を適当な名前に変更
  6. 復元したRDSのインスタンス名をもともと使っていたインスタンス名に変更

RDSの暗号化はインスタンスを新規作成する際にしか設定できないため、暗号化設定されていないインスタンスの場合は必ずサービス停止をする必要があります。
また、手順6の通り、インスタンス名を変更さえすればエンドポイントが変わらないため、アプリケーションの変更は不要になります。

[B] Enforce TLS 1.2 encryption or greater for all network connections where Platform Data is transmitted – Please upload evidence of how you enforce TLS 1.2 encryption such as a screenshot of the encryption controls enabled on your servers or logs that monitor your encryption of data in transit.

TLS1.2以上で通信をしてくださいということですね!
こちらに関しては、ELBの設定で対応します。
手順は以下の通りです。

  1. マネジメントコンソールから EC2 -> ロードバランシング -> ロードバランサーにアクセス
  2. ロードバランサーを指定し、リスナータブをクリック
  3. HTTPS のプロトコルを探し、変更をクリック

TLSのセキュリティポリシーは様々なのですが、公式のドキュメントに記載されている対応状況がわかりやすかったです。

docs.aws.amazon.com

今回は、TLS 1.2以上で通信させたいため、以下のポリシーを適用させる必要があります。

  • ELBSecurityPolicy-TLS-1-2-Ext-2018-06
  • ELBSecurityPolicy-TLS-1-2-2017-01

ただし、クライントの利用環境(使用しているブラウザなど)によってはTLS 1.0でしか通信できない -> 「いきなり見れなくなった!」となる可能性があります。
それを避けるために以下の確認し、どのポリシーを適用するか判断する必要があります。

  1. ブラウザごとにTLS1.2の使用状況を確認
  2. GAなどでユーザーのブラウザ使用分布を確認

また、設定変更後しばらくの間はWebサーバーのアクセス数の変化などを監視してビジネスへ悪影響が出ていないか確認するとより安心できます。

[D] Protect sensitive data like credentials and access tokens – Screenshots of the system/tool that you use to protect sensitive data like credentials and access tokens such as a vault or secrets manager.

アクセストークンを暗号化しましょう!ということですね。
SnapmartではRuby on Railsを使用しているため、アクセストークンの保存と取得をattr_encryptedとうgemを使用することにしました。

GitHub - attr-encrypted/attr_encrypted: Generates attr_accessors that encrypt and decrypt attributes

[F] Require multi-factor authentication for remote access – Screenshot of the tools/configurations that you use that prove that you implement multi-factor authentication for remote access.

リモートアクセスする際は多要素認証を設定してください!ということですね。
対応の対象は2点です。

  1. AWS マネジメントコンソールへのアクセス時のMFA設定
  2. Webサーバーへのアクセス時のMFA設定

1は当たり前すぎるので、割愛します。

Webサーバーへのアクセス時のMFA設定

大まかな手順は以下の通りです。

  1. サーバーにGoogle Authenticatorのインストール
  2. sshdの設定ファイル(/etc/ssh/sshd_config)修正
  3. sshdのPAM設定ファイル(/etc/pam.d/sshd)修正
  4. ログイン時のbash profileファイル(/etc/profile.d/google-authenticator)修正
  5. 1Password登録

具体的な設定は以下のサイトが大変参考になりました。

dev.classmethod.jp

[H] Have a system for keeping system code and environments updated, including servers, virtual machines, distributions, libraries, packages, and anti-virus software – Screenshots of any updates performed on the system; security patches and any additional evidence to validate if the system code and environments are updated promptly. For example dependabot on GitHub.

セキュリティパッチをしっかり当てられる環境にしましょう!ということですね。
対象はアプリケーションとシステム(サーバー)とアプリケーションの2つです。

アプリケーション

そもそもGitHubのdependabotを使うといいよと、例を挙げています。

docs.github.com

GitHubのdependabotにはいくつか種類があります。

  • Dependabot alerts: 脆弱性が検知し通知を行う
  • Dependabot security updates: 検知された脆弱性対策としてPRを作成する
  • Dependabot version updates: 以前するパッケージのバージョンのPRを作成する

以上のような機能があるので、至れり尽くせりですね。

システム(サーバー)

後述します。

[C] Test your app and systems for vulnerabilities and security issues at least every 12 months – Screenshots of any vulnerability and/or security scans and assessments performed in the last 12 months.

12ヶ月に一度は、アプリとシステムの脆弱性とセキュリティの問題をテストしましょう!とのことですね。 前述のセキュリティパッチを当てる項目と合わせて考えると、脆弱性とセキュリティパッチの検知 -> 適用という仕組みを作れば、プラットフォームポリシーのCとHを同時に満たせそうです。

システムの脆弱性管理とセキュリティパッチ

システムに関しては、AWSの以下のサービスを利用するアプローチをとりました。

  • Amazon Inspectorで脆弱性管理をする
  • AWS Systems Manager(SSM) Patch Managerでパッチを自動適用する

純粋に2つのサービスを利用すれば良い...と思うのですが!SSMの独自概念を理解するのに多少時間がかかりました。
こちらでも、クラスメソッドさんの記事が大変参考になりました。

Amazon Inspector の開始方法 - Amazon Inspector

AWS Systems Manager エージェント(SSM Agent)の現行のバージョンを確認して最新バージョンにアップデートする | DevelopersIO
SSM Agent ステータスの確認とエージェントの起動 - AWS Systems Manager

【AWS Systems Manager】パッチマネージャーの パッチベースライン と パッチグループ の概念を勉強する | DevelopersIO
【AWS Systems Manager】パッチマネージャー実行時の関連リソースを、絵で見て(完全に)理解する。 | DevelopersIO
【脆弱性対応】AWS Systems Manager Patch Manager を使ったパッチ戦略の例 | DevelopersIO

アプリケーションの脆弱性検知

アプリケーションの脆弱性検知はOWASP ZAPを使用することにしました。
OWASP ZAPは界隈では有名な統合ペネトレーションテストツールです。

www.zaproxy.org

OWASP ZAPを最小限の設定で行おうとすると、ページ数の多いサイトでは非常に時間がかかってしまいます。
例えばSnapmartの素材販売サイトでは、特に以下のPathのページ数が多く、600万ページ以上あります。

  • https://snapmart.jp/photos?keyword={検索キーワード}
  • https://snapmart.jp/photos/{素材ID}

ただし、両方Pathのレスポンスは検索キーワードもしくは素材IDに合致したデータを返すだけであり、基本構造に変わりはないページであるため、
必ずしも全ページを対象にしなくても良く、任意のページを1ページ検証させるだけで、必要な要件を満たせます。
このように、検出対象の絞り込みを行うことで、効率良く脆弱性検出が可能になります。

検出結果は任意の形式でエクスポートできるので、提出が非常に楽でした。

まとめ

以上、Meta社のプラットフォーム違反に対して対応を行ってきました。
脆弱性対策とセキュリティ強化が求められたのですが、多くの場合AWSの各種サービスで対応できたのは非常に良かったです。
その後、Meta社へ申請を行い、無事に通ったことや、恒久対応ができたため、次にお達しがあった際は迅速に対応できそうです。
また、個人的にはこれまでセキュリティに関して門外漢だったのですが、今回の対応によって足を踏み入れることができたのは良かったです。

*1:せめて事前通知してほしかった

AWS EC2 Ubuntu 14.0.4インスタンスをT2からNitro世代(T3)に移行で「FATAL: Could not load /lib/modules」または「BusyBox」 (カーネルモジュールの欠如)によりステータスチェックが1/2になってしまった時の対応方法

概要

年に一度RI購入のためサーバーの棚卸しをするのですが、その時にT2インスタンスがちらほら...。 AWSも最新のファミリータイプを使用することを推奨しているので、ここいらでT3にするぞと対応したらドハマりしました。

今回はAWS EC2 Ubuntu 14.0.4インスタンスをT2からT3に移行で「FATAL: Could not load /lib/modules」または「BusyBox」 (カーネルモジュールの欠如)によりステータスチェックが1/2になってしまった時の対応方法を紹介します。

発生したこと

発生したことは大きく2つです。

  • ENAを有効にしていないがために、T3ファミリータイプに変更して起動しようとすると警告が表示される
  • インスタンスのステータスチェックが失敗し、SSHができない。

ENAを有効にしていないのは、割とあるあるなようですが、インスタンスのステータスチェックの失敗を解決するのに時間を取られました。

対応したこと

ENAを有効にする

公式ドキュメントを参考に作業をしました。

docs.aws.amazon.com

まず、ENAが有効になっているか確認します。

aws ec2 describe-instances --instance-ids インスタンスID --query "Reservations[].Instances[].EnaSupport"

有効になっていない場合、空配列が返ってきます。(有効になっている場合は、trueが返ってきます。)

次に、ENAを有効にします。ENAを有効にするには、

  1. インスタンスを停止する
  2. 別のインスタンスまたはローカルから以下のコマンドを実行する
aws ec2 modify-instance-attribute --instance-id インスタンスID --ena-support

最後に再度、ENAが有効になっているか確認します。有効になっている場合は、trueが返ってきます。

インスタンスのステータスチェックが失敗し、SSHができない

インスタンスのステータスチェックが失敗する理由の一覧はこちらです。

docs.aws.amazon.com

これらの中で、自分は「FATAL: Could not load /lib/modules」または「BusyBox」 (カーネルモジュールの欠如)を引き当てました。ありがとうございます。
推奨する対処がなかなかの塩っぷりなので、かなり厳しいです。

しかし、Q&Aに答えがありました! aws.amazon.com

どうやらNitroInstanceChecksスクリプトなるものがあり、これを全てパスするとNitroInstance(T3ファミリータイプ)にできるということです。

こちらのスクリプトは以下を確認しています。

  • ENAが有効になっているか
  • NVMeモジュールが有効になっているか
  • fstabファイルが存在し、デバイス名が含まれていないこと

ENAは先述の通り有効にしているため、NVMeモジュールかfstabファイル周りが原因であることがわかります。 さらに!こちらのスクリプトはfstabをいい感じに(デバイス名をUUIDに置換)してくれます。

すると、残る問題はNVMeモジュールを有効にするだけです。
こちらについても、AWSの公式ドキュメントがありました!

docs.aws.amazon.com

これらを解決すると、先ほどのNitroInstanceChecksスクリプトで以下のような実行結果を得られます。

------------------------------------------------
OK  NVMe Module is installed and available on your instance
OK  ENA Module is installed and available on your instance
OK  fstab file looks fine and does not contain any device names.
------------------------------------------------

対応は以上です。

まとめ

AWS EC2のT2ファミリータイプをNitro世代(T3)に移行する際に発生した問題は2つです。

  • ENAを有効にしていないがために、T3ファミリータイプに変更して起動しようとすると警告が表示される
  • インスタンスのステータスチェックが失敗し、SSHができない。

問題の多くはENAを有効にするだけで解決するはずです。しかし、Amazon Linux以外のOSを使用していると、インスタンスチェックが失敗することがあります。
自分の場合はUbuntu 14.0.4を使用していました。

トラブルシューティング方法は基本的にはAWS公式ドキュメントで事足りました(しかし情報が散らばっていたので、発生している事象からたどり着くしかなかったので時間がかかった)。

インスタンスのステータスチェックのトラブルシューティングは多岐に渡るため、一概にこの解決方法で!というわけにはいきませんが、自分の場合は色々調べた結果、以下の方法が近道だと感じました。

  1. まずはステータスチェックに失敗したインスタンスのトラブルシューティング を確認する
  2. 推奨する対処を確認する
  3. 推奨する対処に記載されている語句でAWSのドキュメントを検索する

Novathのモニターになり、数学を学ぶ楽しさとAI(機械学習)を扱えるようになりました。

概要

2021年4月より、Novathの第2回モニターとしてカリキュラムを受講しました。
Novathは、AI×データ時代の武器としての数学を学ぶカリキュラムを提供するサービスです。

novath.co.jp

これまでに以下のタイトルを獲得しています。

  • TOKYO STARTUP GATEWAY 2020オーディエンス賞
  • TOKYO STARTUP DEGAWA 2021優勝 & 出川賞 受賞

たまたま上記のテレビ放送を観ていて「これだ!」と思い、すぐさまモニター応募をしたのがきっかけです。
この記事では、Novathのモニターになり、数学を学ぶ楽しさとAI(機械学習)を扱えるようになったので、その過程を紹介します。

Novathとは

Novathとは、AIで使用する数学の知識を専属コーチとのマンツーマンレッスンで最短距離で学べるサービスです。

なぜ受講しようと思ったか

先述の通り、認知のきっかけはたまたまテレビ放送を観ていたからです。
モニターに応募しようと思った動機は大きく2つあります。

  1. 直近仕事で使うデータを扱う機会が格段に多くなったので、それに活かしたかった
  2. 最後の機械学習入門にしたかった

業務に活かしたかった

2021年1月よりCTO兼Snapmart事業責任者になりました。
これまではCTOとしてプロダクトをどのように開発するか?(How)に注力していたのですが、今年からは何をなぜ作るべきか?(What / Why)について注力する必要がありました。
これまでSQLを使ってDBからデータを抽出する作業は行っていましたが、データの解釈や何かしらの示唆を得るようなことは、さほどしてきませんでした。
そのため、今年の方針を策定するにあたりデータを使って自分なりに根拠を示すも、「本当にこれが正しいと言えるのか?」という疑念がありました。
その状態を脱却したいという思いがあり、モニターの応募をしました。

最後の機械学習入門にしたかった

これまでのキャリアは、Webのバックエンド領域を主軸に、AWSを使ったインフラ、React Nativeを使ったスマホアプリなどと、領域を広げてきました。
ソフトウェア開発の技術を磨いていると様々な情報が入ってきます。機械学習も例外ではありませんでした。
機械学習領域は本職ではないにせよ関連領域である認識はありましたし、触り程度を知っておく必要はあると思っていたため、過去に何冊か機械学習関連の入門書を読んだこともありました。

しかし、何冊入門書を読んでもプロローグを読んだ後の「最低限の数学の知識をおさらいする章」に差し掛かった途端に読むことを諦めてしまっていました。
また、「どうせscikit-learn使えばいいでしょ?AWS ML使えばいいでしょ?」のような、本来すべきことから目を背けて挫折してしまうことを繰り返していました。

そしてNovathでは「ゴール逆算型のカリキュラム」「コーチングとレッスンによる知識習得」を謳っていたので、

  • 機械学習を行う上で、本当に必要な数学な知識のみ習得できて効率的である
  • もし理解できないことがあってもコーチに泣きつける

という、他力本願ではありますが、これでダメだったら習得は諦めよう...という覚悟を持って受講を決めました。

受講前の数学レベル

先述の通り、いくつかの機械学習入門書で挫折してきたわけですが、その理由は、私の数学のレベルと書籍の想定読者の数学レベルに乖離がありすぎたからだと思います。

具体的には、私は中学数学で「変数x」が出た時点で学校に行くことをやめたため、以下の知識がない状態でした。*1

  • 分配法則
  • 式展開

一次関数、二次関数、微分は、単語としては知っている程度で、それが何なのかは知らない状態です。
このようなレベルの私が、書籍の"数学のおさらい"を見ても理解に苦痛が伴うため、読むことを諦めてきました。

Novathの"数学のおさらい"

Novathでは、数学の知識がない方に向けて「数学超入門」という教材(動画)も用意されています。
「数学超入門」では、関数、一次関数、二次関数、微分、ベクトル、行列をおさらいする内容でした。さすがに分配法則と式展開については軽く触れる程度でした。
ただ、先述の通り私の数学の知識は中1以下だったわけですが、そのレベルの自分が見てもすんなり理解できる内容であったため、現時点で数学の知識に不安がある方でも、安心して良いと思います。
具体的には、四則演算ができれば乗り切ることができると思います(できました)。

どのようなことを行ったか

f:id:watasihasitujidesu:20210919192934p:plain

Novathでは、実際の業務でありえそうな課題を機械学習で解決するというアプローチで学んでいきます。
具体的に習得できる機械学習の手法は以下の通りです。

  • 単回帰分析
  • 重回帰分析
  • ロジスティック回帰分析
  • k-means法

これらの手法(予測、分類、クラスタリング)は数学を使って計算をしていくのですが、使用する数学の知識について、一つずつ解説を受けることができます。

  • 数学の何の知識を使うのか
  • その知識はなぜ必要なのか
  • どのように数学の知識を使うのか

そのため、各機械学習の手法のロジック(どのような処理をなぜ行うのか)を説明できるようになります。

学習の流れ

Novathでは1つの機械学習モデルを2週間で学びます。*2

学習方法は、動画によるインプット(自学習)と、2週間に一度のコーチングがセットになります。
また、インプット途中でわからなくなっても、Slackでコーチにわからないところを聞くことができるので、ヘルプを上げれば迷子になることはないと思います。

インプット用の動画について

動画は1本あたり20〜25分ぐらいで、各回全体で120分ぐらいのボリュームにまとまっています。
先述の通り、機械学習モデルで使用する数学の知識を、なぜ、どのように使うかを詳細に解説した動画が視聴できます。不必要な数学の知識を余分に学ぶことはなく、最短で学んでいる感覚が個人的には嬉しい作りでした。

私は、動画を使った学習が初めてだったのですが、以下のメリットを感じました。

  • テキストと比較すると視覚的な情報量が多く、概念など抽象的なもののイメージがしやすい
  • 再生速度が可変なので視聴者の理解力に合わせてスピードを調整できる
  • 耳だけでもインプットできるため歩きながらでも多少は学習できる

そのため、学習途中で困ることはありませんでした。
また、動画内での数学の説明が非常にわかりやすく、理解できないような難易度のものはありませんでした。
教材(動画内での解説)の質の高さを感じました。

コーチングについて

2週間に一度コーチングを受けることができます。
コーチングの時間は、学習した内容をコーチに説明するアウトプットの時間になります。
コーチに説明をしていく中で、うまく説明できなかった部分や理解が浅い部分については、コーチから理解を促すための問いかけや解説を受けることができます。

コーチングについての受講後の感想は以下の通りです。

  • コーチへの説明(アウトプット)が伴うことで、インプットの質(理解度)が高まる(後述します)
  • 100%理解した状態でコーチングの時間に臨んでもコーチがさらに理解を深めるために様々な角度から質問をしてくれる
  • それ故に知的欲求が満たされ、コーチングの時間が楽しみになる

アウトプットがあることで、インプットの質の向上や数学を学ぶ楽しさを体感できるような仕掛けになっているので、毎回非常に有意義な時間を過ごすことができました。

f:id:watasihasitujidesu:20210920194034j:plain
分配法則を教わっている図

f:id:watasihasitujidesu:20210920194152j:plain
コーチに説明をしている図

f:id:watasihasitujidesu:20210920194213j:plain
ロジスティック回帰分析の仕組みを数式を用いて説明できるようになった図

私の過ごし方

具体的に私が2週間をどのように過ごしたかを紹介します。

全体感の把握

まずは学習のペース配分やリズム掴むために、レッスン終了後の帰り道に動画を2倍速で視聴しました。 これをすることによって、全体像と理解に時間がかかりそうな箇所を洗い出し、インプットにかかる時間をおおよそ見積もれるので、「学習時間が足りない!」みたいな状態になることを避けることができました。

動画の視聴(インプット)

「理解できない」という内容が「動画を見る(説明を受ける)」という行為の中で存在しなくなることを到達目標にし、ひたすら動画を視聴しました。
先述の通り、動画自体は60~90分なのですが、私の数学レベルが低いこともあり何度も繰り返しみたり、隙間時間や散歩中に音だけ聞き流したりしていました。
ちなみに、動画内の解説スピードは1倍速で見ると結構ゆっくりです。私は1.75倍速を基本スピードとして、わからなくなったら1.5倍速にしてもう一度見るということを繰り返していました。
正確に時間を計測したわけではありませんが、インプットにかかる時間は12時間前後だったと思います。

コーチングに向けて説明の練習をする(アウトプット)

私は、実はDay1のレッスンで「動画を見て理解できたから説明とか楽勝でしょww」というノリでコーチングの時間に臨みました。
ところが、実際に説明をすると以下のような問題がありました。

  • そもそも理解できていない内容があることに気づいた
  • 動画で行っていた計算処理をなぜ使うのか説明できない
  • 動画内でコーチが導いていた答えと、説明しながら出した自分の答えが違う(!)

コーチの大いなるサポートを受けながらなんとか1回目のコーチングの時間が終わりましたが、自分に対する情けなさや学習を怠った後悔の念が残りました。
また、このような状態にも関わらず懇切丁寧に解説をしていただいたコーチの方に対する申し訳なさや、Novathを運営する皆さんの想いに応えるべく、自分も最大限努力をするべきだと、心を入れ替えました。

具体的に行ったコーチングに向けての説明の練習は、機械学習の処理の説明を口に出しながらノートに書いていくことです。
これを行うと「説明に困った部分は理解が足りていない部分」だと認識することができます。
そして、理解が足りていない部分に関する動画を再度視聴し、もう一度説明の練習を行います。
これを繰り返し行うことで、理解度を限りなく100%に近づけることができました。
この練習の到達目標は以下の通りです。

  • 動画で行っていた計算処理や結果と、何も見ずに自分で導いたものが合致していること
  • 何も見ずに自分が理解したことを数学の計算処理をしながら相手に説明できること

この状態に到達するまでには、説明の練習は3, 4回程度(機械学習モデルをはじめから最後まで説明しきって1回とカウント)行う必要がありました。
時間にすると6~8時間程度だったと思います。

振り返ると、このアウトプットの練習がなければ(純粋に動画視聴のインプットのみだと)理解度は60%くらいに留まっていたのではないかと思います。
この行いをすることで過不足なく学習を進めることができたので、これから受講する方にオススメの学習方法だと思います!

f:id:watasihasitujidesu:20210919205344j:plain
実際に使用したノート

ちなみに、ノートはインプット時には使用しませんでした(概念や計算処理などは動画に収録されているため、改めてノートに書く必要性を感じなかったため)。
完全にアウトプット用として使用していました。
Day7までに普通のキャンパスノートの2/3程度は使ったと思います。*3

コーチング後の復習

コーチングが終わった後は、復習も行います。
コーチングを受けると、その場では理解したつもりになってしまうのですが、時間の経過とともに記憶が薄れてしまうので、帰宅してから復習を行います。

私の場合はDay1で分配法則と式展開がうまくできないという非常にまずい状態だったので、帰宅後に復習しました(復習というより改めて学びました)。
また、Day1はコーチの助言によりゴールまでたどり着けたので、一人で説明できるようになるため先述のノートに説明の練習をするということを、レッスン後に行いました。(これを行ったのはDay1だけです。)

さらに深堀 / 業務に適用

隙間時間でこまめに学習することや、可処分時間は最優先でNovathの学習に使っていたので、若干時間が余ることがありました。

余った時間は以下のように使いました。

  1. 動画で触れていた知識をさらに深堀すべく、異なる媒体(他の人が書いたブログなど)で同じ内容を学んだり調べる
  2. 仕事で適用できそうな課題を見つけて、実際に機械学習を適用してみる

1に関しては、説明する人が違えば説明の仕方も異なります。
具体的には、数学記号を用いて解説されている方が大半であることがわかりました。
逆にいうとNovathでは、理解を促すことを目的にしているからか、数学記号は必要最小限に抑えられていると感じました。
そのため、Novathで学んだ数式を、数学記号を使った表記にした場合でも理解できるようになる必要性を感じました。
最初は数学記号を一つずつ調べるので時間がかかりますが、一旦理解すると数式がざっくり何を行っているのかがわかるようになります。
また、数学アレルギーを持っている方(私も含め)にとっては、この数学記号でアレルギー反応が出てしまうということがわかりました。
数学記号は、概念を省略して表記するために使われているものだとわかると、落ち着いて読み解こうとする姿勢になれた(=数学アレルギーがなくなる)のは大きな収穫でした。

2に関しては「習うより慣れろ」という言葉があるように、実際に習った機械学習を使うことで定着を促すことができると感じました。
私の場合は、k-means法を学習中にたまたまデータの標準化をしなければ解けない業務やクラスタリングをして解釈(=他者へ説明)する機会がありました。*4

めちゃくちゃ感動しましたし、数学の面白さをここで味わいました。
そして、学生の時に数学をしっかり勉強していれば...と後悔もしました。

どのような状態になったか / 受講後に変わったこと

Novathのカリキュラムを終えて振り返ると、自分自身に以下の変化がありました。

  • 「機械学習の学習」の成功体験を積むことができた
  • 機械学習の計算プロセスを説明できるようになった
  • 数学アレルギーが払拭できた(数式や数学記号を見ても怯まずに意味を理解しようと調べるという行動に移れるようになった)
  • 純粋に数学の楽しさを感じることができるようになった
  • 何かしらの課題やデータを与えられた場合に、機械学習の適用可否を判断できるようになった
  • 機械学習が適用できる課題やデータに対して、小規模なデータセットであれば、習得した知識の範囲においては解決できるようになった

特に、挫折しかしてこなかった「機会学習の学習」について成功体験を積めたことや、数学アレルギーが払拭できたことは、言うなればコンプレックスが解消されたといっても過言ではありません。
コンプレックスは一朝一夕にして解消できるものではないので、Novathには本当に感謝しています。

今後の学習計画

Novathによって機械学習の入門ができたので、さらに学びを深めたり活用するための方法をいくつか検討しています。

  • プログラムで機械学習を行えるように学ぶ(インプットドリブン)
  • 業務上の課題を機械学習を使って解決する(アウトプットドリブン)
  • 統計を学ぶ

NovathではケーススタディにおいてExcelを使用します。しかしExcelでは操作が煩わしく感じることや、データ量が増えると処理が重くなり扱えるデータ量に制限が出てきます。
実際の業務では扱うデータ量が大きいため、プログラムで機械学習を扱えるようにする必要性を感じています。また、課題解決の幅を広げるために、scikit-learnなどで他のモデルを扱えるように学んでいこうと考えています。
一方、目の前の課題をこなす中で必要になるスキルを磨いていくアプローチもあるので、業務上の課題を機械学習を使って実際に解決していくことも考えています。これらの両アプローチについてバランスを取りながら進めていくのが良いのではないかと思います。

また、統計についても学習を始めようと考えています。
こちらも業務上、統計を知っていると解けたであろう課題が過去にあったので、数学の知識を使った学問の一つである統計学を学んでみようと考えています。

まとめ

Novathのサービスページにある受講後の到達地点は以下の通りです。

  • AI(機械学習)の数学的な本質(ロジック)を理解し、人に説明できる
  • ビジネスの現場で役に立つ統計手法(重回帰分析、ロジスティック回帰分析、クラスタリング等)をExcelで使いこなせるようになる
  • AIに必要な中学~大学の基礎数学の習得(関数、微分、線形代数、確率統計等)

このレベルには間違いなく到達できると思います(もちろん適切な量努力する必要はあります)。

上記以外で私が伝えたいことは、Novathで数学の苦手意識の払拭にとどまらず、数学の面白さを知れたことや、数学が実社会で役に立つことを体感できたことを強く強調したいです。

学生時代に「なんのために勉強をするのかわからない」と感じてしまい、学習することを辞め挫折を繰り返すことで、やがてコンプレックスになってしまった数学ですが、Novathに出会ったことで数学に対する見方が180度変わったので感動しました。

もし私が学生時代にNovathに出会っていたのなら、データ分析に関する職業に就くことが有力な選択肢になっていたのではないかと正直思います。
もちろん、今の自分にとっても、エンジニアとして戦うための武器を手に入れた感覚があるのでキャリアがさらに好転する確度が高まったという感覚を持っています。

現時点で多少なりともデータを扱っている方には間違いなくオススメできるサービスです!

人生が変わったと言える貴重な時間と学びの機会をいただけたNovathのみなさんには感謝してもしきれません。本当にありがとうございました!!!

f:id:watasihasitujidesu:20210920200619j:plain
Novathのみなさんと私

紹介割引(受講料金より5万円割引)について

今ならNovathモニターの紹介割引があるようです!
受講料金より5万円割引されるのはかなりデカいですね!! 利用されたい方はTwitterのDMよりご連絡いただければと思います!

novath.co.jp

*1:なかなか強烈ですね

*2:ロジスティック回帰は4週間で学ぶ

*3:こんなにノートを使ったのは人生初

*4:その他にもロジスティック回帰を使ったこともありました

Railsのエラー通知をアプリケーションコードを変更せずに検知と通知をする方法

概要

サービスを運用していると、エラーを検知/通知する仕組みがほしくなります。
少し調べてみると、RailsでエラーをSlackに通知したいのであれば、以下の2つのgemを入れるのが多い印象を受けました。

一見良さげに見えたのですが、gemを入れて、設定ファイルとアプリケーションコードを変更しなければなりません。
作業自体は一瞬なのですが、もっとシンプルに「ログファイルにERRORが現れたら通知する」という解決方法求めたくなりました。
今回は、Railsのエラー通知をアプリケーションコードを変更せずに検知と通知をする方法について紹介します。

前提

  • Amazon Linux 2
  • Mackerelが導入されている

環境はAmazon Linux 2で、Mackerelが導入されていることが前提に紹介します。

手順

プラグインをインストール

Mackerelでログ監視をするには、プラグインをインストールする必要があります。

sudo yum install mackerel-check-plugins

Mackerelの設定ファイル(mackerel-agent.conf)の変更

Mackerelの設定ファイルであるmackerel-agent.confにログ監視用の設定を追加します。

# /etc/mackerel-agent/mackerel-agent.confに以下を追加

[plugin.checks.access_log]
command = ["check-log", "--file", "{Railsアプリケーションのルートディレクトリのパス}/log/production.log", "--pattern", "ERROR"]

--patternで指定した条件が--fileで指定したファイル内に出現したことを検知するための設定です。

mackerel-agentの再起動

設定ファイルを更新後はmackerel-agentを再起動します。

sudo systemctl restart mackerel-agent

確認

Mackerelのホスト詳細ページのMonitorsに今回追加したaccess_logが表示されていれば設定完了です。

f:id:watasihasitujidesu:20210911135222p:plain

まとめ

Rails エラー 通知で調べると、大半がgemをインストールして、アプリケーションコードに手を入れる必要がありました。
今回はMackerelを運用していることを前提に、アプリケーションコードに手を入れずに検知と通知をする方法を紹介しました。

AWS Elasticsearch Service(AES)のシャードの未割り当て(UNASSIGNED)が原因でクラスター状態が黄色になってしまった場合の対応方法

概要

タイトルの通り、ある日突然Amazon Elasticsearch Service(以下AES)のクラスター状態が黄色になってしまいました。 今回はクラスター状態が黄色になった場合の対処方法について、紹介します。

クラスター状態が黄色になってしまった原因

AWSのドキュメントを参考にすると、以下の状態になっているようです。

黄色のクラスター状態は、すべてのインデックスのプライマリシャードがクラスター内のノードに割り当てられ、少なくとも 1 つのインデックスのレプリカシャードは割り当てられていないことを意味します。

docs.aws.amazon.com

問題の特定

まずは、クラスターの状態を確認します。 以下のコマンドを使用して確認します。

$ curl -X GET {エンドポイント}/_cluster/health?pretty=true
{
  "cluster_name" : {クラスター名},
  "status" : "yellow",
  "timed_out" : false,
  "number_of_nodes" : 4,
  "number_of_data_nodes" : 4,
  "active_primary_shards" : 41,
  "active_shards" : 80,
  "relocating_shards" : 0,
  "initializing_shards" : 0,
  "unassigned_shards" : 2,
  "delayed_unassigned_shards" : 0,
  "number_of_pending_tasks" : 0,
  "number_of_in_flight_fetch" : 0,
  "task_max_waiting_in_queue_millis" : 0,
  "active_shards_percent_as_number" : 97.5609756097561
}

unassigned_shardsが2つ存在することがわかりました。 さらに以下のコマンドで、シャード情報について深ぼってみます。

$ curl -X GET {エンドポイント}/_cat/shards?h=index,shard,prirep,state,unassigned.reason
index1              1 p STARTED    
index1              1 r STARTED    
index1              2 p STARTED    
index1              2 r STARTED    
index1              4 p STARTED    
index1              4 r STARTED    
index1              3 r STARTED    
index1              3 p STARTED    
index1              0 p STARTED    
index1              0 r STARTED    
index2 1 p STARTED    
index2 1 r STARTED    
index2 2 p STARTED    
index2 2 r UNASSIGNED ALLOCATION_FAILED
index2 4 p STARTED    
index2 4 r STARTED    
index2 3 p STARTED    
index2 3 r UNASSIGNED ALLOCATION_FAILED
index2 0 r STARTED    
index2 0 p STARTED 

こちらの結果から、index2のシャード2, 3のreplicaノードが割り当てられていないことがわかりました。 さらに詳細を追います。 上記のシャードについて、ALLOCATION_FAILEDとなった原因を以下のコマンドで調べます。

$ curl -XGET {エンドポイント}/_cluster/allocation/explain?pretty
{
  "index" : "index2",
  "shard" : 3,
  "primary" : false,
  "current_state" : "unassigned",
  "unassigned_info" : {
    "reason" : "ALLOCATION_FAILED",
    "at" : "2021-04-21T17:26:41.698Z",
    "failed_allocation_attempts" : 5,
    "details" : "failed to create shard, failure IOException[failed to obtain in-memory shard lock]; nested: ShardLockObtainFailedException[[index2][3]: obtaining shard lock timed out after 5000ms]; ",
    "last_allocation_status" : "no_attempt"
  },
  "can_allocate" : "no",
  "allocate_explanation" : "cannot allocate because allocation is not permitted to any of the nodes",
  "node_allocation_decisions" : [ {
    "node_id" : "4CKlBkmrSOqZIGwfSPgufQ",
    "node_name" : "4CKlBkm",
    "node_decision" : "no",
    "deciders" : [ {
      "decider" : "max_retry",
      "decision" : "NO",
      "explanation" : "shard has exceeded the maximum number of retries [5] on failed allocation attempts - manually call [/_cluster/reroute?retry_failed=true] to retry, [unassigned_info[[reason=ALLOCATION_FAILED], at[2021-04-21T17:26:41.698Z], failed_attempts[5], delayed=false, details[failed to create shard, failure IOException[failed to obtain in-memory shard lock]; nested: ShardLockObtainFailedException[[index2][3]: obtaining shard lock timed out after 5000ms]; ], allocation_status[no_attempt]]]"
    } ]
  }, {
    "node_id" : "6AhVfEvhRziT3i5kaoaPfg",
    "node_name" : "6AhVfEv",
    "node_decision" : "no",
    "deciders" : [ {
      "decider" : "max_retry",
      "decision" : "NO",
      "explanation" : "shard has exceeded the maximum number of retries [5] on failed allocation attempts - manually call [/_cluster/reroute?retry_failed=true] to retry, [unassigned_info[[reason=ALLOCATION_FAILED], at[2021-04-21T17:26:41.698Z], failed_attempts[5], delayed=false, details[failed to create shard, failure IOException[failed to obtain in-memory shard lock]; nested: ShardLockObtainFailedException[[index2][3]: obtaining shard lock timed out after 5000ms]; ], allocation_status[no_attempt]]]"
    }, {
      "decider" : "awareness",
      "decision" : "NO",
      "explanation" : "there are too many copies of the shard allocated to nodes with attribute [zone], there are [2] total configured shard copies for this shard id and [2] total attribute values, expected the allocated shard count per attribute [2] to be less than or equal to the upper bound of the required number of shards per attribute [1]"
    } ]
  }, {
    "node_id" : "Wl2W4K1ESxGUWdiZQE65zQ",
    "node_name" : "Wl2W4K1",
    "node_decision" : "no",
    "deciders" : [ {
      "decider" : "max_retry",
      "decision" : "NO",
      "explanation" : "shard has exceeded the maximum number of retries [5] on failed allocation attempts - manually call [/_cluster/reroute?retry_failed=true] to retry, [unassigned_info[[reason=ALLOCATION_FAILED], at[2021-04-21T17:26:41.698Z], failed_attempts[5], delayed=false, details[failed to create shard, failure IOException[failed to obtain in-memory shard lock]; nested: ShardLockObtainFailedException[[index2][3]: obtaining shard lock timed out after 5000ms]; ], allocation_status[no_attempt]]]"
    } ]
  }, {
    "node_id" : "kORYnIFEQX-yZB0SFrcnIQ",
    "node_name" : "kORYnIF",
    "node_decision" : "no",
    "deciders" : [ {
      "decider" : "max_retry",
      "decision" : "NO",
      "explanation" : "shard has exceeded the maximum number of retries [5] on failed allocation attempts - manually call [/_cluster/reroute?retry_failed=true] to retry, [unassigned_info[[reason=ALLOCATION_FAILED], at[2021-04-21T17:26:41.698Z], failed_attempts[5], delayed=false, details[failed to create shard, failure IOException[failed to obtain in-memory shard lock]; nested: ShardLockObtainFailedException[[index2][3]: obtaining shard lock timed out after 5000ms]; ], allocation_status[no_attempt]]]"
    }, {
      "decider" : "same_shard",
      "decision" : "NO",
      "explanation" : "the shard cannot be allocated to the same node on which a copy of the shard already exists [[index2][3], node[kORYnIFEQX-yZB0SFrcnIQ], [P], s[STARTED], a[id=88w2bSjVTiGrreCA9UzgeQ]]"
    }, {
      "decider" : "awareness",
      "decision" : "NO",
      "explanation" : "there are too many copies of the shard allocated to nodes with attribute [zone], there are [2] total configured shard copies for this shard id and [2] total attribute values, expected the allocated shard count per attribute [2] to be less than or equal to the upper bound of the required number of shards per attribute [1]"
    } ]
  } ]
}

これをみると、インメモリシャードロックを取得できない状況であったことがわかります。ノード間の疎通が失敗していたことが原因のようです。

対処方法

/_cat/shardsでシャードの状態を確認しましが、delayed_unassigned_shardsが0でした。 delayed_unassigned_shardsは、割り当てを待っているシャードの数です。 この値が1以上である場合は、ESが割り当てを行うのですが、0であるため(unassigned_shardsが1以上であるため)、この後に自動で復旧することはありません。

また、unassigned_shardsはシャード自体は存在するが、ノードに割り当てられていないだけであるため、再割り当てを実行する必要があります。 再割り当ては以下のコマンドを実行します。

$ curl -X PUT {エンドポイント}/{インデックス名}/_settings -d '
{
  "index.allocation.max_retries" : 10
}
'

これは割り当てに失敗した場合の再試行回数なのですが、この値を更新するとリーダーノードがクラスター上の指定されたインデックスのシャードの割り当てを再試行してくれます。 このコマンドを実行後に、再びシャードの情報を確認します。

$ curl -X GET {エンドポイント}/_cat/shards?h=index,shard,prirep,state,unassigned.reason
index1              1 p STARTED    
index1              1 r STARTED    
index1              2 p STARTED    
index1              2 r STARTED    
index1              4 p STARTED    
index1              4 r STARTED    
index1              3 r STARTED    
index1              3 p STARTED    
index1              0 p STARTED    
index1              0 r STARTED    
index2 1 p STARTED    
index2 1 r STARTED    
index2 2 p STARTED    
index2 2 r INITIALIZING ALLOCATION_FAILED
index2 4 p STARTED    
index2 4 r STARTED    
index2 3 p STARTED    
index2 3 r INITIALIZING ALLOCATION_FAILED
index2 0 r STARTED    
index2 0 p STARTED 

状態がUNASSIGNEDだったシャードが、INITIALIZINGに変わっていますね! もう少し待って、確認してみるとSTARTEDに切り替わり、unassigned.reasonもなくなります。

$ curl -X GET {エンドポイント}/_cat/shards?h=index,shard,prirep,state,unassigned.reason
index1              1 p STARTED    
index1              1 r STARTED    
index1              2 p STARTED    
index1              2 r STARTED    
index1              4 p STARTED    
index1              4 r STARTED    
index1              3 r STARTED    
index1              3 p STARTED    
index1              0 p STARTED    
index1              0 r STARTED    
index2 1 p STARTED    
index2 1 r STARTED    
index2 2 p STARTED    
index2 2 r STARTED
index2 4 p STARTED    
index2 4 r STARTED    
index2 3 p STARTED    
index2 3 r STARTED
index2 0 r STARTED    
index2 0 p STARTED 

最後に、クラスター状態を確認してみます。

$ curl -X GET {エンドポイント}/_cluster/health?pretty=true
{
  "cluster_name" : "{クラスタ名}",
  "status" : "green",
  "timed_out" : false,
  "number_of_nodes" : 4,
  "number_of_data_nodes" : 4,
  "active_primary_shards" : 41,
  "active_shards" : 82,
  "relocating_shards" : 0,
  "initializing_shards" : 0,
  "unassigned_shards" : 0,
  "delayed_unassigned_shards" : 0,
  "number_of_pending_tasks" : 0,
  "number_of_in_flight_fetch" : 0,
  "task_max_waiting_in_queue_millis" : 0,
  "active_shards_percent_as_number" : 100.0
}

無事にクラスター状態が緑になりました!

まとめ

今回は、突然AESのクラスター状態が黄色になってしまいました。 原因は、シャードのいくつかが、ノードに割当されなくなってしまったことが原因でした。 クラスター状態を緑に戻す対処方法としては、割り当てられていないシャードをノードに割り当てることです。 これは、index.allocation.max_retriesを変更することで、リーダーノードが割り当て処理を再試行してくれます。 シャード自体に問題がない場合は有効な復旧方法です。

AWS Elasticsearch Service(AES)のデータノードが消失しStatusがRedになった場合の対応方法

概要

info.snapmart.jp
私が運用しているサービスの一つであるSnapmartで障害が発生しました。
原因はAmazon Elasticsearch Service(以下AES)のデータノードが消失しStatusがRedになったためです。
Elasticsearchのデータノードが消失しているので、検索周りで多大な影響が出てしまいました。
この記事では上記の障害を振り返り、類似の問題が発生した場合のトラブルシューティングの一助としたいと思います。

Elasticsearchの基本概念

まずはざっくりElasticsearchの構成で出てくる用語を整理しましょう!
今回の障害の登場人物は以下の通りです。

  • クラスタ
  • ノード
  • インデックス
  • ドキュメント
  • シャード&レプリカ

詳しい解説は本家ドキュメントとクラスメソッドさんの解説に譲ります。

www.elastic.co
dev.classmethod.jp

障害内容

タイトルの通りAESのデータノードが消失しStatusがRedになったのですが、具体的には以下の問題が発生しました。

日本時間3月8日10時20分:

  1. 特定のノードに問題が発生。このノードは検索システムで使用しているインデックスのプライマリシャードが割り当てられていた。
  2. 1のノード(プライマリシャード)とそのレプリカがノードに割り当てられず、クラスターヘルスがRedに

検索システムのプライマリシャードに問題が発生してしまったため、以下のような不具合が発生しました。

  • (1/ノード数)%の確率で読み込み/書き込みが失敗
  • (1/ノード数)%のデータが消失

対応内容

プライマリシャードが障害により消失してしまったため、復旧しなければなりません。
また、StatusがRedになった場合は、まずは問題を取り除き、ステータスGreenに戻す必要があります。
無闇にクラスタに変更を加えてProcessing状態にすると復旧不可になる可能性があります。

docs.aws.amazon.com

ドメインを再設定する前にAmazon ES、赤のクラスター状態を解決することです。赤のクラスター状態のドメインを再設定すると、問題が複雑化し、状態を解決するまで、設定状態が [Processing (処理中)] のままドメインがスタックする可能性があります。

StatusがRedのインデックスを特定

該当クラスタにアクセスできる環境から以下のコマンドを実行し、インデックスの状態を確認します。

$ curl -X GET http://<endpoint>/_cat/indices?v
health status index uuid pri rep docs.count docs.deleted store.size pri.store.size
green open index1 p2mhaaaCA4uId7GEZzN-AQ 5 1 12345 0 1.2mb 3.4kb
red open index2 GLwW836AOm61FOQp6jIgre 5 1 67890 0 6.7mb 5.6kb

出力結果を確認し、healthがredになっているものが問題が発生しているインデックスです。

スナップショットの確認

消えてしまったデータはスナップショットから復元するしかありません。

以下のドキュメントを参考に復元をしていきます。

docs.aws.amazon.com

スナップショットの情報は以下のコマンドで確認します。
まずはリポジトリから確認します。

$ curl -XGET 'elasticsearch-domain-endpoint/_snapshot?pretty'
{
  "cs-automated" : {
    "type" : "s3"
  }
}

今回問題となったクラスタでは自動でスナップショットを取る設定をしていました。 そのため1時間ごとに新たなスナップショットが作成されていきます。
1時間ごとのスナップショットの情報を確認します。

$ curl -XGET 'elasticsearch-domain-endpoint/_snapshot/{スナップショットの一覧が見たいリポジトリ名}/_all?pretty'
...
, {
    "snapshot" : "スナップショット名-001",
    ...
    "indices" : [ "インデックス一覧" ],
    "state" : "SUCCESS",
    "start_time" : "2021-03-08T00:52:06.288Z",
    "start_time_in_millis" : 1615164726288,
    "end_time" : "2021-03-08T00:52:39.672Z",
    "end_time_in_millis" : 1615164759672,
    "duration_in_millis" : 33384,
    "failures" : [ ],
    "shards" : {
      "total" : 36,
      "failed" : 0,
      "successful" : 36
    }
  }, {
    "snapshot" : "スナップショット名-002",
    ...
    "indices" : [ "インデックス一覧" ],
    "state" : "FAILED",
    "reason" : "Indices don't have primary shards [問題のインデックス名]",
    "start_time" : "2021-03-08T01:51:56.473Z",
    "start_time_in_millis" : 1615168316473,
    "end_time" : "2021-03-08T01:51:57.678Z",
    "end_time_in_millis" : 1615168317678,
    "duration_in_millis" : 1205,
    "failures" : [ {
      "index" : "問題のインデックス名",
      "index_uuid" : "問題のインデックスのuuid",
      "shard_id" : 1,
      "reason" : "primary shard is not allocated",
      "status" : "INTERNAL_SERVER_ERROR"
    } ],
    "shards" : {
      "total" : 36,
      "failed" : 1,
      "successful" : 35
    }
  },
...

上記コマンドで、以下のことがわかります。

  • 問題のあったインデックス名とその原因
  • 復元対象のスナップショット名
  • スナップショットから復元後、どの時点からドキュメントを復旧するか

スナップショットから復元

スナップショットから復元するにはまず、既存の問題のあるインデックスを削除する必要があります(なぜなら、リストアするコマンドを実行すると同一のインデックスを作成しようとしてしまうため、インデックス名が重複してエラーになるためです)。
特定のインデックスのみ削除するコマンドは以下の通りです。

$ curl -XDELETE 'elasticsearch-domain-endpoint/{削除対象のインデックス名}'

続いて、スナップショットを指定して、インデックスを復元します。

$ curl -XPOST 'elasticsearch-domain-endpoint/_snapshot/{スナップショットのリポジトリ名}/{スナップショット名}/_restore' -d '{"indices": "問題となったインデックス名"}' -H 'Content-Type: application/json'

これで2021-03-08T00:52:06.288Zまでのデータが復元できます。 2021-03-08T00:52:06.288Zから先のデータについては、自前で復旧する必要があります。

また、AESでは追加料金なしで自動でスナップショットを取得します。自動スナップショットは1時間ごとにスナップショットを取得し、14日間保持されます。 そのため、インデックスが壊れたとしても、14日までに復元できれば被害を少なくすることができます。 14日間以上保持したい場合や、クラスター間でのデータの移行は手動でスナップショットを取得する必要があります。 今回は自動スナップショットに助けられました...。

注意点

ドキュメントを参考にした一連の流れは、インデックス削除~スナップショット復元まで、検索機能が失われます(インデックスに登録されているドキュメントが消えるため)。
そのため、巨大な検索データの場合は、インデックス削除もスナップショット復元も時間がかかると思うので、スナップショットから復元時に別名で復元し、それをアプリケーションから参照するようにすると良いと思います。

まとめ

Elasticsearchのプライマリシャードがいきなり消失したので非常に驚きました。
今回は「データが消えた場合の対応」であるため、基本に忠実に以下を行います。

  1. 問題となったインデックスの特定
  2. スナップショットの取得状況の確認
  3. 問題が発生前のスナップショットの特定
  4. スナップショットからデータ復元

データ復旧でのポイントは2点です。「スナップショットを取得しているか、復元できるスナップショットは存在するか」です。

今回がAESの自動スナップショット機能に助けられました。
バックアップは重要ですね...!

.bundle/configの内容を公開

概要

新規にRailsプロジェクトを作ったり、別のプロジェクトに参加する場合に.bundle/configの内容を更新したあとにbundle installすることが多い。 また、MySQL入らないから調べることが5億回やってたり、nokogiriが入るのが遅いとか思ったりすることが多いから、.bundle/configの内容を記す。

---
BUNDLE_PATH: "vendor/bundle"
BUNDLE_BUILD__MYSQL2: "--with-ldflags=-L/usr/local/opt/openssl/lib --with-cppflags=-I/usr/local/opt/openssl/include"
BUNDLE_JOBS: "4"
BUNDLE_DISABLE_SHARED_GEMS: "1"
BUNDLE_WITHOUT: "staging:production"

BUNDLE_BUILD__MYSQL2: "--with-ldflags=-L/usr/local/opt/openssl@3/lib"

VPCにあるAWS Elasticsearch ServiceのPrivate IPをALBに追加するシェルスクリプト

概要

Snapmartの検索サーバーはAmazon Elasticsearch Service(以下ESS)で運用しているのですが、Kibanaを使ってデータ分析をしたくなりました。

ESSはVPC内にあるため、アプリケーションサーバーからElasticsearch APIで検索実行は問題なく行えますが、 管理者PCからKibanaのエンドポイントへのアクセスはできません。

そのため、ESSの前段にALBを配置してアクセスする必要があります。 設定は以下の記事を参考にしました。 dev.classmethod.jp

しかし、

ESSのPrivate IPは可変なので、定期的にALBのターゲットとなるPrivate IPをメンテナンスする必要があります

と記載があるため、この処理を行うシェルスクリプトを書きました。

シェルスクリプト

#!/bin/sh
ALB_ARN=arn:aws:elasticloadbalancing:**

# ALBに登録されているIPを全て削除
aws elbv2 describe-target-health \
--target-group-arn $ALB_ARN \
--query 'TargetHealthDescriptions[].Target.Id' \
--output json \
| grep -o '".*"' \
|awk '{ print "aws elbv2 deregister-targets --target-group-arn '"$ALB_ARN"' --targets Id="$1"" }' \
|sh

# ESSのPrivateIPをALBに登録
aws ec2 describe-network-interfaces \
--filters "Name=status,Values=in-use" \
--query 'NetworkInterfaces[?Description==`ES ドメイン名`].PrivateIpAddress' \
--output json \
| grep -o '".*"' \
|awk '{ print "aws elbv2 register-targets --target-group-arn '"$ALB_ARN"' --targets Id="$1"" }' \
|sh

解説

ALBに登録されているIPを全て削除

現在ALBに登録されているターゲットのIDを全て取得するAWS CLIコマンドを実行し、その結果を使ってALBからターゲットを削除していきます。 現在ALBに登録されているターゲットのIDを全て取得するAWS CLIコマンドはaws elbv2 describe-target-healthです。 ALBからターゲットを削除するためには、IDが必要になるので、--queryオプションでIDを取得します。

aws elbv2 describe-target-health \
--target-group-arn $ALB_ARN \
--query 'TargetHealthDescriptions[].Target.Id' \
--output json

この部分が、ALBからターゲットを削除するAWS CLIコマンドです。 aws elbv2 register-targetsを使用します。

|awk '{ print "aws elbv2 register-targets --target-group-arn '"$ALB_ARN"' --targets Id="$1"" }' \

ESSのPrivateIPをALBに登録

現在ESSがVPC内で使用しているPrivateIPを取得します。AWSマネジメントコンソール-EC2-Network interfacesの画面で確認できる項目なので、AWS CLIはaws ec2 describe-network-interfacesを使用します。
絞り込み条件はStatusがin-useのPrivateIPでとします。 また、ESSで使用しているPrivateIPはDescriptionが"ES ドメイン名"となるため、--queryオプションでそれを指定しつつ、PrivateIpAddressを取得します。

aws ec2 describe-network-interfaces \
--filters "Name=status,Values=in-use" \
--query 'NetworkInterfaces[?Description==`ES ドメイン名`].PrivateIpAddress' \
--output json

上記のIPをALBに登録します。

|awk '{ print "aws elbv2 register-targets --target-group-arn '"$ALB_ARN"' --targets Id="$1"" }' \

これをcronなどで定期実行することで、手作業がなくなります。

Managed workflowのExpoアプリに導入するE2Eテストフレームワークの比較と導入

概要

運用しているアプリの規模が大きくなってくると、ちょっとした修正が思わぬところに影響してしまったり、ツールのバージョンアップをする際に組織全体を巻き込んだ大規模な手動テストを行わなければならないことがしばしばあります。

自分が運用しているSnapmartアプリも例外ではありません。 SnapmartアプリはManaged workflowのExpoアプリであり、ejectする予定はありません。 今回はManaged workflowのExpoアプリに導入するE2Eテストフレームワークの比較と導入についての記事を書きます。

比較

React NativeにおけるE2Eテストフレームワークは以下の3つです。

  1. Appium
  2. Detox
  3. Cavy

SnapmartアプリをManaged workflowのExpoアプリとして運用していくことにこだわる理由は大きく2つです。

  1. iOSやAndroidに知見があるエンジニアが社内に少ない
  2. そもそもエンジニアが少ない

面倒な処理は極力Expoに押しつけ、iOS/Androidからは距離を置くことで、主戦場であるWebで相撲を取るという選択をしています。

そのためツール選ぶ際の制約として「expo ejectコマンドを避ける」が出てきます。
それを踏まえて考えてみます。

Appium

github.com

AppiumはSeleniumを使用して、クロスプラットフォームテストフレームワークです。 Seleniumを使ったことがあるエンジニアの方は多いのではないかと思います。

しかし、iOS/Android共にビルドしなければならず、Expo Clientで動かしながら開発が進められなさそうなので、断念しました。 これについて、ハックもできるようですが情報が少なすぎるので避けました。

Testing Your React Native App With Expo & Appium | by Colin Wren | The Startup | Aug, 2020 | Medium

Detox

github.com DetoxはWiが開発しているツールです。 Detoxの特徴の一つとしてFlakyなテストに対しての対策をツールが用意していることです。 Flakyなテストとは、例えば非同期処理や処理速度の問題で、テスト実行のたびに成功/失敗が分かれてしまうテストです。 Detoxはこれにたいして、内部で非同期処理を検知し、終了まで待機することで、回避しているようです。

当初Expoもhelperを用意していたようですが、2018年から開発が止まっています。 また、DetoxもExpoアプリのサポートをしていないと明言しています。

試しにdetox-expo-helpersを使って動かしてみましたが、そもそも64bitのSimulatorで動作しなかったので、断念しました。

Cavy

github.com CavyはReactのRefsを使用してテストを行うフレームワークです。 *1 テストランナーはjestに限定されます。

純粋なJavaScriptで書かれているので、yarn addをすればManaged workflowのExpoアプリでも導入することができます。 消去法になりましたが、Cavyに決定です。

Cavyの導入

基本的にはGettingStartedに書かれている内容と同じです。 ダイジェストでどうぞ!

cavy.app

前提

$ node -v 
v12.18.1

$ expo -V
3.27.4

$ cavy -V
1.1.0

インストール

$ yarn global add cavy-cli
$ yarn add cavy --dev #TypeScriptを使用している場合は、@types/cavyも追加
$ cavy init

設定

Cavyの登場人物は、大きく3つです。

  1. RootComponentをラップするTesterコンポーネント
  2. テスト対象(コンポーネント)
  3. テストコード

Cavyの動作

Cavyはcavy-cliを使用してテストを実行すると、以下のスクリプトが走ります。

$ cp App.js App.notest.js
$ mv App.test.js App.js

そして、テストが実行された後に、元に戻す処理が走ります。

$ cp App.js App.test.js
$ cp App.notest.js App.js
$ rm App.notest.js

1. RootComponentをラップするTesterコンポーネント

CavyはApp.jsをApp.test.jsで置き換えた後にテストを実行するため、まずはテストを動かすApp.test.jsを作ります。

import { StatusBar } from 'expo-status-bar';
import React from 'react';
import { StyleSheet, Text, View, TextInput } from 'react-native';
+++ import TestableScene from './src/TestableScene';
+++ import { AppRegistry } from 'react-native';
+++ import { Tester, TestHookStore } from 'cavy';
+++ import ExampleSpec from './specs/exampleSpec';

+++ const testHookStore = new TestHookStore();

export default () => {
  return (
+++     <Tester specs={[ExampleSpec]} store={testHookStore}>
      <View style={styles.container}>
+++         <TestableScene />
        <Text>Open up App.js to start working on your app!</Text>
        <StatusBar style="auto" />
      </View>
+++     </Tester>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#fff',
    alignItems: 'center',
    justifyContent: 'center',
  },
});
+++ AppRegistry.registerComponent('cavyTest', () => AppWrapper);

2. テスト対象(コンポーネント)

次に、テスト対象のコンポーネント(例としてTestableSceneを用意しました)を修正します。

import React from 'react';
import { View, TextInput } from 'react-native';
import { hook } from 'cavy';

class Scene extends React.Component {
  render() {
    return (
      <View>
        <TextInput
          ref={this.props.generateTestHook('Scene.TextInput')}
          onChangeText={console.log("")}
        />
      </View>      
    );
  }
}

const TestableScene = hook(Scene);
export default TestableScene;

Cavyの特徴は、ReactのRefsを使用すると先に述べましたが、ここで登場です。
CavyはgenerateTestHookを使用して、TestHookStoreにRefsを渡すことで、テスト時にTextInputコンポーネントに対してテストを行える様にしています。

また、generateTestHookの引数として渡した文字列が、テスト実行時の対象コンポーネントの識別子となります。

上記のコードでは、hook()を使用しましたが、この他にも、React Hookを使用する方法や、refを割り当てることができない関数(Textコンポーネントとか)をwrap()でアプローチすることもできます。

cavy.app

3. テストコード

最後にテストコードです。

export default function(spec) {

  spec.describe('Logging in', function() {

    spec.it('works', async function() {
      await spec.exists("Scene.TextInput");
    });
  });
}

これはシンプルですね。 CavyがTest Helpersを用意しているので、わかりやすくなっています。

テスト実行

テスト実行はExpo Clientで行えます。

expo start --clearでSimulatorを立ち上げます。

その後、cavy run-ios --skipbuild --file App.jsを実行します。 ポイントとしては、--skipbuildオプションと--file App.jsでエントリーポイントを指定することです。 --skipbuildがなければ、ReactNativeのbuild用の処理が走ってしまうため、スキップをします。 --fileオプションは、Expoを使用している場合は、エントリーポイントがApp.jsとなっているため、Cavyのデフォルトであるindex.jsから変更するために指定します。

テスト結果はこの様に出力されます。 f:id:watasihasitujidesu:20200914211933p:plain

まとめ

AppiumとDetoxはReactNativeアプリのE2Eテストフレームワークの候補として挙がりましたが、Managed workflowのExpoアプリに導入するE2EテストフレームワークはCavy一択となりました。 Cavyはテスト実行がやや恐ろしいですが、簡単導入することができました。 ただ、用意されているTest Helpersがやや貧弱な印象*2を受けたので、少しお試しで使ってみようと思います。

*1:あと可愛いです

*2:例えば、スクリーンショットが使えないとか

ExpoのMediaLibraryからiOSのHEICファイルをアップロードした場合にリサイズできない問題の解決方法

概要

会社で作っているSnapmartアプリは写真のアップロードがメインのアプリです。
Snapmartにおける写真アップロード処理は、ざっくり以下の手順で処理されます。

  1. スマホからS3にアップロード
  2. S3にアップロードされた画像ベースにLambdaでサムネイル作成

上記で問題としては上がったのは、HEICファイルがアップロードされると、サムネイル作成処理が失敗することでした。*1

今回は、iOSのHEICファイルをアップロードした場合にリサイズできない問題の解決方法について紹介します。

原因

この不具合のトリガーとなったのは、Expo SDK V35からV37へのアップグレード時、写真選択処理のAPIをCameraRollからMediaLibraryに変更したことでした。
CameraRollは、getPhotos()のreturnでedges.node.image.uriで画像のパスが取得できます。この時、画像がHEICの場合はCameraRollがJPEG形式に変換したあとの画像のパスを返していました。
一方、MediaLibraryはgetAssetsAsync()のreturnで取得するassets.uriおよび、getAssetInfoAsync()のreturnで取得するlocalUriは、HEICファイルのパスを示すため、「1. スマホからS3にアップロード」時にHEICでアップロードされるか、JPEGでアップロードされるかの違いがありました。

また、「2. S3にアップロードされた画像ベースにLambdaでサムネイル作成」ではこれまでJPEGファイルが元画像となる前提の処理であったため、HEICが渡されるとエラーにより、サムネイルが作成されませんでした。

対策

対策は2つあります。

  1. サムネイル作成処理において、元画像がHEICでもサムネイル作成できるようにする
  2. 手元の端末でHEICからJPEGに変換して、変換後のJPEGファイルをアップロードする。

今回はReact Native + Expoアプリで対応することに焦点を当てるので、「2. 手元の端末でHEICからJPEGに変換して、変換後のJPEGファイルをアップロードする。」ことについて説明します。

ImageManipulatorでHEICからJPEGに変換する

ImageManipulatorとは、Expoが提供するローカルファイルシステムに保存されている画像を変更するためのAPIです。 このImageManipulatorのmanipulateAsync(uri, actions, saveOptions)メソッドを使用すると、画像をAPIを通して編集することができます。
また、manipulateAsync()メソッドでは、保存時に引数で指定されたsaveOptionsでフォーマットを指定できます。指定できるフォーマットはJPEGまたはPNGです。(JPEGがデフォルト)

import * as ImageManipulator from "expo-image-manipulator";
...
const assetInfo = await MediaLibrary.getAssetInfoAsync(asset)
if (Platform.OS === "ios" && (assetInfo.localUri.endsWith(".heic") || assetInfo.localUri.endsWith(".HEIC"))) {
  const manipResult = await ImageManipulator.manipulateAsync(assetInfo.localUri)
  console.log(manipResult.uri)
}

今回は、何の加工もせず、ただ単純にHEICからJPEGに変換するだけであるため、manipulateAsyncメソッドの第二引数と第三引数は指定しません。
ドキュメントはこちらです。

docs.expo.io

まとめ

HEICファイルをアップロードした時に、リサイズができなかった原因は、写真選択処理のAPIをCameraRollからMediaLibraryに変更したことでした。
これは、CameraRollはJPEGに変換したファイルを提供することに対して、MediaLibraryはローカルに保存されているファイルのURIを返すことにありました。
アプリ内でうまいことやるには、ImageManipulatorを使用します。
ImageManipulatorは画像を加工するAPIなのですが、保存時のフォーマットはJPEGもしくはPNGです。
そのため、MediaLibraryで取得したHEICを元データとして、ImageManipulator.manipulateAsync()を呼び出すことで、HEICをJPEGに変換させることができました。

*1:HEIF(ヒーフ)とはiOS 11から登場した「High Efficiency Image File Format」の略で「高効率画像ファイル」に使われる拡張子です。高画質のままで画像の容量が軽いのが特徴で、JPEGの半分くらいの容量に抑えられます。

SnapmartアプリにFirebaseAnalyticsを導入しました

概要

SnapmartアプリはReact Native + Expoで動いています。
2019年5月にAndroid版をリリースし、2019年12月にiOS版をリリースしました。
リリース当初は不具合や非機能要件を満たせないことが多かったため、その対応に追われることが多かったのは記憶に新しいです。

現在は、致命的な不具合がなくなったので、ユーザーの使い勝手を向上させる施策が徐々に増えてきました。
しかし、アプリの施策を出しをする際に、定量的なデータがないため、定性的な情報から仮説を立て、実行を決断するしかありませんでした。

極端な例を出すとこのようなイメージです。

  • 私はこの画面が使いにくいと思うから修正した方が良いのでは(他の人は使いにくいと感じるのかな...?)
  • 最近お問い合わせが多い画面なので、修正した方が良いのでは(声が上がっていない画面は...?)

そのため、明確に機能向上すると判断できない施策をなんとなく実行するような状態です。

そんな中、2020年4月にExpo SDK V37がリリースされました。

https://blog.expo.io/expo-sdk-37-is-now-available-dd5770f066a6

このリリースで、FirebaseAnalyticsがExpoを使っているアプリでも使用できるようになりました!

FirebaseAnalyticsとは

FirebaseAnalyticsを使用するとアプリのイベントをGoogleアナリティクスに記録することができます。
そのため、どのユーザーが、どこの画面で、どのように行動したのかをトラッキングすることができます。
このログを蓄積することで、どの画面に問題があるのかを定量的に把握することが可能になります。

この記事では、React Native + ExpoアプリにFirebaseAnalyticsを導入する方法について書きます。

設定

expo-firebase-analyticsをインストール

まずは、expo-firebase-analyticsを以下のコマンドでインストールします。

expo install expo-firebase-analytics

Firebase projectを作成

次に、Firebase projectを作成します。

ログイン - Google アカウント

上記のリンクにアクセスし、以下の通り進めていきます。

  1. プロジェクトを作成
  2. プロジェクト名を入力
  3. Google Analyticsと連携
  4. Google Analyticsにプロパパティ追加

f:id:watasihasitujidesu:20200710144856p:plain
f:id:watasihasitujidesu:20200710144816p:plain
f:id:watasihasitujidesu:20200710144821p:plain
f:id:watasihasitujidesu:20200710144825p:plain

モバイルアプリの設定

app.jsonの修正

FirebaseAnalyticsとExpoアプリを連携させるために、app.jsonのios/androidブロックにある、ストア内の一意識別子を追加します。

{
  "expo": {
    "android": {
+      "package": "com.mypackage.coolapp",
    },
    "ios": {
+      "bundleIdentifier": "com.mypackage.coolapp",
    }
  }
}

Firebase projectにアプリを追加

プロジェクトを作成後にOS毎にアプリの設定をする必要があります。
設定はダッシュボード => 「プロジェクトの概要」横の設定アイコンをクリック => プロジェクトを設定 から、各種アプリを追加していきます。

f:id:watasihasitujidesu:20200710144830p:plain

Androidアプリの場合は、ドロイド君をクリックします。 アプリを追加するのに必要な情報は、さきほどapp.jsonで追加したpackageです。

f:id:watasihasitujidesu:20200710144838p:plain

「アプリの登録」ボタンを押した後は、設定ファイルをダウンロードします。

アプリの追加は以上です。 iOSアプリも必要な場合は、上記の手順と同様にAppleのロゴを押下してアプリを追加 => bundleIdentifierを入力 => 設定ファイルをダウンロード と進めます。

設定ファイルをプロジェクトに配置しapp.jsonに追加

さきほどの手順でAndroidであればgoogle-services.json、iOSであればGoogleService-Info.plistがダウンロードされるはずです。
ダウンロードした2つのファイルは、Expoアプリのルートディレクトリに配置します。
また、app.jsonにそれぞれのファイルのpathを設定します。

{
  "expo": {
    "android": {
      "package": "com.mypackage.coolapp",
+      "googleServicesFile": "./google-services.json"
    },
    "ios": {
      "bundleIdentifier": "com.mypackage.coolapp",
+      "googleServicesFile": "./GoogleService-Info.plist"
    }
  }
}

アプリの設定は以上です。

ローカル環境でデバッグする方法

通常、FirebaseAnalyticsにイベントを送信する実装をする際、ローカルで意図した通りにイベントが発火されるかを確認することが多いです。
また、ローカルで動かす場合に最も多く使うであろうExpo Clientは、google-services.jsonGoogleService-Info.plistを読み込むことができず、JavaScriptベースのFirebase Analyticsの実装に依存してイベントをログに記録します。 そのため、Expo Clientでイベントを発火させるには、Firebase projectにウェブアプリを追加し、構成をapp.jsonに設定する必要があります。

Firebase projectにWebアプリを追加

モバイルアプリと同様に、ダッシュボード => 「プロジェクトの概要」横の設定アイコンをクリック => プロジェクトを設定 から、Webアプリアイコンを押下します。
その後、任意のアプリ名を入力し「アプリを登録」ボタンを押下します。

f:id:watasihasitujidesu:20200710144844p:plain
f:id:watasihasitujidesu:20200710144850p:plain

次の画面で、Firebase Analyticsと連携するためのJavaScriptのコードが表示されます。
ここで表示される、以下の値をapp.jsonに追記します。

  • appId
  • apiKey
  • measurementId

app.jsonの修正

さきほど取得したappIdapiKeymeasurementIdをapp.jsonに追記します。
この項目はwebブロックに追記します。

{
  "expo": {
    "android": {
      "package": "com.mypackage.coolapp",
      "googleServicesFile": "./google-services.json"
    },
    "ios": {
      "bundleIdentifier": "com.mypackage.coolapp",
      "googleServicesFile": "./GoogleService-Info.plist"
-    }
+    },
+    "web": {
+      "config": {
+        "firebase": {
+          "appId": "xxxxxxxxxxxxx:web:xxxxxxxxxxxxxxxx",
+          "apiKey": "AIzaXXXXXXXX-xxxxxxxxxxxxxxxxxxx",
+          "measurementId": "G-XXXXXXXXXXXX"
+        }
+      }
+    }
  }
}

設定は以上です。

コードの修正

最後にアプリからFirebase Analyticsにイベントを送信するための、コードを記述します。 expo-firebase-analyticsをimportし任意のイベントにイベント発火用のコードを仕込んでいきます。

import * as Analytics from 'expo-firebase-analytics';
...
async _onPress(){
  // デバッグ用の設定
  if(Constants.manifest.releaseChannel != "production"){
    Analytics.setDebugModeEnabled(true);
  }

  await Analytics.logEvent('ButtonTapped', {
    name: 'settings',
    screen: 'profile',
    purpose: 'Opens the internal settings',
  });
}

上記のコードの中でAnalytics.setDebugModeEnabled(true);がデバッグ用のコードです。
このコードが処理されると、Firebase AnalyticsのDebugViewというページにリアルタイムで発火されたイベントが通知されます。
また、Constants.manifest.releaseChannel != "production"としていますが、これは、Android/iOSのスタンドアロンアプリをビルドする際にチャンネルを"production"に指定することを想定しています。
したがって、Expo Clientで動作させる場合は、チャンネルが指定されないため、ローカル環境だとみなしています。

まとめ

Expo SDK V37からExpo ManagedアプリでもFirebase Analyticsが使用できるようになりました。
Firebase Analyticsの使用開始は、大きく以下の手順を踏む必要があります。

  1. Firebase projectを作成
  2. アプリを追加
  3. 構成ファイルをExpoアプリのルートディレクトリに配置
  4. app.jsonの修正
  5. イベント発火コードの追加

また、デバッグには、Firebase プロジェクトにWebアプリを追記する必要があります。
これは、Expo ClientではJavaScriptベースのFirebase Analyticsの実装に依存してイベントをログに記録するためです。

Expo SDK v36.0.0で追加されたMediaLibraryのお気に入り写真の読み込みが遅い問題と回避策

概要

React Native 0.61からCameraRollが削除されました。
そのため、Expo SDK v36.0.0以降でExpoマネージドアプリを作成している場合は、MediaLibraryを使用して写真を読み込まなければならなくなりました。
CameraRolleでは、「お気に入り」がアルバムの一つとして識別されていたため、.getPhotosのオプションとして"Favorites"を渡せば一度で取得できました。
しかし、MediaLibraryは「お気に入り」はassetsの属性としてしか取得できないため、全ての写真を読み込まなければ「お気に入り」写真を全て表示することができません。
この処理が非常に遅いので、この記事ではExpo SDK v36.0.0で追加されたMediaLibraryのお気に入り写真の読み込みが遅い問題と回避策について、書こうと思います。

MediaLibraryの仕様

MediaLibrary - Expo Documentation

MediaLibraryの全件取得はMediaLibrary. getAssetsAsync()で行えます。
上記の返り値のassets(assetの集合)をループで回し、MediaLibrary.getAssetInfoAsync(asset)を実行します。
このMediaLibrary.getAssetInfoAsync(asset)の返り値にisFavoriteが含まれています。

問題点

await MediaLibrary.getAssetInfoAsync(asset)が異様に遅いです。

上記をコードに表すとざっくり以下のようになります。

const assets = await MediaLibrary.getAssetsAsync(options).assets
for (const asset of mediaLibrary.assets) {
  const assetInfo = await MediaLibrary.getAssetInfoAsync(asset) // <= ここの応答速度が問題
  if(assetInfo.isFavorite){
    // お気に入りの場合の処理
  }
}

具体的には、await MediaLibrary.getAssetInfoAsync(asset)の呼び出しに750ms ~ 1,000ms程かかります

回避策

Promise.allを使用する

const assets = mediaLibrary.assets.map((asset) => {
  return MediaLibrary.getAssetInfoAsync(asset)
  });
});
await Promise.all(assets).then(function(assetInfos) { 
  assetInfos.forEach((assetInfo, key) => {
    // お気に入りの場合の処理
  });
});

MediaLibrary.getAssetInfoAsync(asset)を並列で実行しようと試みるも、iPhone XSでは全てのUIスレッドをPromiseの処理に使用されてしまいます。端的に言うと固まります
そのため、戻るボタンの操作、画面遷移も全くできない状態が続く上、端末がめちゃくちゃ熱くなります*1 スマホではこの処理は向かないため断念せざるを得ません...。

hasNextPageとendCursor-afterを使用する

MediaLibrary.getAssetsAsync()の返り値にhasNextPageとendCursorが含まれます。
これは以下の役割があります。

  • hasNextPage: 取得するアセットが他にあるかどうか。(totalCount >= assets.lengthであるか)
  • endCursor: 最後に取得したアセットのID。afterオプションにこの値を渡すと、直後のアセットが取得できる。

要は、hasNextPageがtrueの間、endCursorを使用したページングをhasNextPageがfalseになるまで使用することができます。
これはwhile文とforEach文で実装が行えるため、forEach文の終わりにユーザーに何らか変化が行ったことを知らせることができます。

例えば以下のような処理のイメージです。

let mediaLibrary = await MediaLibrary.getAssetsAsync(options)
while (mediaLibrary.hasNextPage) {
for (const asset of mediaLibrary.assets) {
  let assetInfo = await MediaLibrary.getAssetInfoAsync(asset)
  if(assetInfo.isFavorite){
    // お気に入りの場合の処理
  }
}
Alert.alert("ユーザーに対しての何かしらの処理")
mediaLibrary = await MediaLibrary.getAssetsAsync({...options, after: mediaLibrary.endCursor})
}

処理開始から終了までの時間は、当初と変わりありません。 しかし、画像表示処理を行う場合は、1スクロールで表示される画像には限りがあるため、ある小さな塊内で表示できるものがあれば、すぐに返し、それを繰り返すことで、ユーザーが「遅延している」と感じさせないようにすることも可能であるはずです。

キャッシュを使用する

assetInfo.isFavoriteがTrueとなったアセットのIDをAsyncStorageで格納する方法です。 2回目に表示する場合に、Keyが存在すれば、それを使用することで、表示速度を速めることができます。 しかし、懸念点は以下の通りです。

  1. お気に入りから削除された場合に対応できない
  2. 古い写真をお気に入りに追加された場合に対応できない

新しく追加された写真かつ、お気に入りに追加された写真しか対応できません。
そのため、速度面以外でのお問い合わせが増えてしまう可能性があります。

お気に入りから写真を選択することをやめる

CameraRolleからMediaLibraryの切り替えによって、お気に入りからの写真選択以外は全て高速化されます。*2 また、写真アプリでお気に入りに追加する手間と、アルバムに追加する手間はさほど変わりません。
ただ、ユーザーが「アルバムを作成し、そこ写真を入れることができる」というiOSの機能を認知していない可能性があります。(社内比) その点は、お問い合わせが増加する可能性もあります。

ただ、

  • お気に入りから選択ができなくなったことを明示
  • 代替案として、アルバムを作成し、そこに追加することができる操作方法を示したページへのリンクを設置し、ユーザーに認知させる
  • 上記を事前告知

といった、導線を確保することで、お問い合わせは増加を未然に防げることは可能なのではないかと思います。(私は一人では決めきれませんでした)

結論

まずは、現時点で最も良いUXを提供できるであろう、「hasNextPageとendCursor-afterを使用」し、実装を行うのが、現時点で最良の選択だと思います。
また、「キャッシュ使用する」実装をした場合のお問い合わせ(=懸念点を実行するユーザー数)がどれだけ増加するかは未知数であるため、段階的リリースなどで、様子をみつつ、リリースチャレンジをするのも検討できるではないかと思います。

*1:熱くなってきたぜ🔥💪🔥💪🔥

*2:Expoバージョンアップ時の記事を参照

Snapmartアプリで使用しているReact Navigationをv4からv5にアップグレードした際の対応

概要

2020年2月にReact Navigationのv5がリリースされました。
React Navigation 5.0 - A new way to navigate | React Navigation

Snapmartアプリでは、React Navigation v4を使用しています。
React Navigation v4では、Stack NavigatorにJavaScriptを使用していましたが、ネイティブのような感覚とパフォーマンスが低下する場合がありました。
React Navigation v5では、Native navigation primitivesを使用するStack Navigatorに変更することで改善がされました。

Stack Navigatorは多くの画面遷移で使用するため、このタイミングでアップグレードすることにしました。

今回は、Snapmartアプリで使用しているReact Navigationをv4からv5にアップグレードした際の対応についてまとめます。

手順

React Navigationはアップグレードガイドが非常に丁寧に書かれています。

Upgrading from 4.x | React Navigation

このリストの中で、実際に使用している処理については対応をしていく必要があります。 対応したことは以下の通りです。

  1. パッケージ名の変更
  2. createAppContainerからNavigationContainerに変更
  3. コンポーネント化されたN Navigatorに変更
  4. navigation propから分割されたroute propを使用
  5. navigationOptions を各Screenで設定するように修正
  6. Navigation eventsの変更
  7. ディープリンクの記述修正
  8. CommonActionsの変更

1. パッケージ名の変更

各パッケージ名が変更されたので、記載されている内容にしたがって変更をしていきます。

Upgrading from 4.x | React Navigation

若干ハマった点としては、パッケージ名を変更して起動をするとこのようなエラーが発生しました。

I'm getting "SyntaxError in @react-navigation/xxx/xxx.tsx" or "SyntaxError: /xxx/@react-navigation/xxx/xxx.tsx: Unexpected token"

というエラーが表示されたので、以下のコマンドで対応します。

$ rm -rf node_modules
$ rm yarn.lock
$ yarn

2. createAppContainerからNavigationContainerに変更

こちらは単純にcreateAppContainerからNavigationContainerに変更します。
Upgrading from 4.x | React Navigation

3. コンポーネント化されたN Navigatorに変更

React Navigation v5の大きな変更として、コンポーネント化があります。

React Navigation v4では以下のような記述でした。

const RootStack = createStackNavigator(
  {
    Home: {
      screen: HomeScreen,
      navigationOptions: { title: 'My app' },
    },
    Profile: {
      screen: ProfileScreen,
      params: { user: 'me' },
    },
  },
  {
    initialRouteName: 'Home',
    defaultNavigationOptions: {
      gestureEnabled: false,
    },
  }
);

React Navigtaion v5からは以下のような書き方になります。

const Stack = createStackNavigator();

function RootStack() {
  return (
    <Stack.Navigator
      initialRouteName="Home"
      screenOptions={{ gestureEnabled: false }}
    >
      <Stack.Screen
        name="Home"
        component={HomeScreen}
        options={{ title: 'My app' }}
      />
      <Stack.Screen
        name="Profile"
        component={ProfileScreen}
        initialParams={{ user: 'me' }}
      />
    </Stack.Navigator>
  );
}

変更を自動化できなさそうなので、粛々と修正していきました。

4. navigation propから分割されたroute propを使用

React Navigation v5からは、navigation propからroute propが切り出されました。 影響を受けた点としては、画面遷移時に渡すparameterがroute propから取得しなければならなくなったことです。

- this.props.navigation.state.params.id
+ this.props.route.params.id

こちらの対応は、sedで一括置換で対応しました。

find src/ -type f -print0 | xargs -0 sed -i -e "s/navigation\.state/route/g"

5. navigationOptions を各Screenで設定するように修正

navigationOptionsもv4では各画面で設定していましたが、v5からはScreenコンポーネントで設定します。
Upgrading from 4.x | React Navigation

こんな感じです。

v4

class ProfileScreen extends React.Component {
  static navigationOptions = {
    headerShown: false,
  };

  render() {
    // ...
  }
}

v5

<Stack.Screen
  name="Profile"
  component={ProfileScreen}
  options={{ headerShown: false }}
/>

これも自動で変換はできなさそうなので、手で粛々と直します。

6. Navigation eventsの変更

Upgrading from 4.x | React Navigation

v4では、Navigation evenetが4種類ありました。

  • willFocus
  • didFocus
  • willBlur
  • didBlur

v5ではシンプルにfocusblurだけになります。これは、v4におけるwillFocuswillBlurです。

また、書き方も変わります。 Class Componentにおいてはこのようになります。

+ componentDidMount() { 
+   const unsubscribe = this.props.navigation.addListener("focus", e => {
+     // 処理
+   });
+ }
render() {
    return (
      <View style={styles.container}>
-        <NavigationEvents onWillFocus={payload => { console.log("onWillFocus") }} />

7. ディープリンクの記述修正

Deep linking | React Navigation
ディープリンキングは、pathのハンドリングが変わります。

import { Linking } from 'expo';
import { NavigationContainer, useLinking } from "@react-navigation/native"; 

const prefix = Linking.makeUrl('/');

function App() {
  const ref = React.useRef();

  const { getInitialState } = useLinking(ref, {
    prefixes: [prefix],
    config: {
      HomeStack: {
        path: 'stack',
        screens: {
          Home: 'home',
          Profile: 'user',
        },
      },
      Settings: 'settings',
    }
  });

  const [isReady, setIsReady] = React.useState(false);
  const [initialState, setInitialState] = React.useState();

  React.useEffect(() => {
    getInitialState()
      .catch(() => {})
      .then(state => {
        if (state !== undefined) {
          setInitialState(state);
        }

        setIsReady(true);
      });
  }, [getInitialState]);

  if (!isReady) {
    return null;
  }

  return (
    <NavigationContainer initialState={initialState} ref={ref}>
      {/* content */}
    </NavigationContainer>
  );
}

refを定義し、NavigationContaineruseLinkingに渡します。 また、pathの定義は、useLinkingconfigで画面を紐づけを行います。

上記の例は、HomeStackとSettingsの2つのタブがあり、 HomeStackタブには、HomeとProfileという2つのスクリーンが存在するイメージです。

この定義だとmyapp://homeでHomeスクリーンにリンクすることができます。

8. CommonActionsの変更

CommonActions reference | React Navigation
最後に、CommonActionにも変更がありました。
resetを使っていた箇所があったので、修正をします。

+ import { CommonActions } from '@react-navigation/native';
...
-    let resetAction = StackActions.reset({
-      index: 0,
-      actions: [NavigationActions.navigate({ routeName: "Account" })],
-    });
-    this.props.navigation.dispatch(resetAction);
+    this.props.navigation.dispatch(
+      CommonActions.reset({
+        index: 0,
+        routes: [{ name: "Account" }],
+      })
+    )

まとめ

対応は以上です。

  1. パッケージ名の変更
  2. createAppContainerからNavigationContainerに変更
  3. コンポーネント化されたN Navigatorに変更
  4. navigation propから分割されたroute propを使用
  5. navigationOptions を各Screenで設定するように修正
  6. Navigation eventsの変更
  7. ディープリンクの記述修正
  8. CommonActionsの変更

Snapmartは78画面で構成されるアプリですが、手作業の部分が多く、骨が折れました...。
特に、コンポーネント化されたN Navigatorに変更とnavigationOptions を各Screenで設定するように修正する作業は、全ての画面を開いて修正しなけばならなかったのが大変でした。

SnapmartアプリのExpo SDKのバージョンをv35.0.0からv37.0.0にアップグレードで対応したこと

概要

4月1日にExpo SDK v37.0.0がリリースされました。
Expo SDK 37 is now available - Exposition

自分が開発に携わっているSnapmartアプリではExpo SDK v35.0.0を使用していました。
Expo SDK v36.0.0がリリースされた際は、Snapmartアプリにとって、アップグレードを行う強いモチベーションがなかったのですが、Expo SDK v37.0.0では、以下の機能が追加されたため、いち早く導入するためにアップグレードを行いました。

  1. Firebase Analyticsが使用可能になった
  2. SMS認証が使用可能になった

これまで、Expoマネージドのアプリ(ejectをしていないアプリ)では、アプリの解析に使用できるサービスはAmplitudeに限定されていました。
新興サービスであるため、情報が非常に少ないことや、安定性(=サービスの継続性)も非常に不安でした。
しかし、Expoマネージドアプリを運用する限りは、Amplitudeを使うしか道がない。という状態です。
今回のExpo SDKのアップデートでFirebase Analyticsを使用可能になったため、筆者はあえてAmplitudeを選択する理由はないと思い、既存のバージョン v35.0.0からv37.0.0のアップグレードを行うことにしました。

この記事ではSnapmartアプリのExpo SDKのバージョンをv35.0.からv37.0.0にアップグレードで対応したことを書き記したいと思います。

大まかな対応手順

対応は大まかに以下の手順にしたがって行います。

  1. Expoから提供されているexpo upgradeコマンドで、機械的にアップグレードを任せる
  2. 修正箇所を把握するために、changelogを確認し、Breaking changesを確認 & 修正を行う
  3. Expo Clientをアップグレードして動作確認
  4. 動作確認中に発生したエラーと警告を修正

ざっくりこんな感じです。作業の多くは、2, 4です。以下に説明を書いていきます。

Breaking changesを確認 & 修正

今回はExpo SDK v35.0.0 => v37.0.0のアップグレードとメジャーバージョン2つ分確認する必要があります。
影響の範囲を狭くしたほうが、問題の切り分けをスムーズに行えるため、まずはv35.0.0からv36.0.0と一つずつバージョンアップすることを強くオススメします。

Breaking changesを確認すると、Snapmartアプリで影響を受けそうな変更は以下でした。 *1

  • CameraRoll: Removed CameraRoll from react-native core, developers are encouraged to use expo-media-library instead

これは「react-native coreからCameraRollから削除されたため、expo-media-libraryせよ」という内容です。
Snapmartアプリではカメラから写真を選択し、アップロードする機能があり、これにCameraRoll.getPhotosを使用していました。

CameraRollからMediaLibraryへ変更

CameraRollからMediaLibraryへ変更する際、よかったことは、MediaLibraryはCameraRollの不具合が修正されていたことと、機能的に良くなっている点です。
具体的には、以下の通りです。

  • ファイルの種類の絞り混みで不具合が発生しない
  • アルバム名で絞り込みが正常に動作するようになった(不具合の修正)
  • 写真のソートができるようになった
  • アルバムの一覧を取得できるようになった

特にアルバムの一覧を取得できるようになったのは、かつて全ての素材をforEachでブン回してアルバム名を取得していたため、大幅な改善だと思います。

一方、MediaLibraryの唯一最大の難点は、写真の詳細情報をMediaLibrary.getAssetInfoAsync(asset)を使用しなければならないことです。
もちろんhasNextPageとendCursorを使用すれば、少しずつ取得できるため、オーバーヘッドは少なくなるはずなので、問題ありませんが、
全件取得し、全件詳細が必要な場合は、処理速度が問題になります。

動作確認中に発生したエラーと警告を修正

Breaking changesを使用したあとは実際に動作確認をしてみます。ここで発生したエラーと警告を修正していきます。*2

具体的に遭遇したエラーと警告で大きなものは「React 17で廃止されるunsafe lifecycle methodsの移行」です。 これは、React 16.9から警告が出るようになったため、このタイミングで対応することにしました。
もちろん、まずはバージョンアップしたアプリをリリースし、この警告は別途対応するという方針も有りだと思います。

手順は以下の通りです。

  1. 自前で書いているunsafe lifecycleを修正する
  2. インストールしているパッケージのライブラリのバージョンからlatestのバージョンまでのリリースノートに問題が修正されている確認
  3. 2で修正されている場合は、対応されているバージョン以上にupgradeする

今回は、警告が出た全てのパッケージがunsafe lifecycleの対応がされていたので、非常に助かりました。改めて、コントリビューターが多いOSSを選択することの重要さを感じました。

まとめ

今回は、SnapmartアプリのExpo SDKのバージョンをv35.0.0からv37.0.0にアップグレードで対応したことをまとめました。 大きくは、以下の対応を行いました。

  1. Breaking changesの修正
  2. React 17で廃止されるunsafe lifecycle methodsの修正

修正しなければならない箇所が比較的少なかったので対応期間も短くで済んだのは良かったです。 反省点としては、メジャーバージョンを一つ飛ばしてアップグレード対応をしたことです。
今回はそれによる影響が少なかったから良かったものの、問題の切り分けなどが難しくなりそうであるため、バージョンアップ対応は一段階ずつ行うのが良いと思います。

*1:以外と少なくてよかったです

*2:厳密には警告であるため修正する必要は必ずしもありませんが、多くは「次のメジャーバージョンで削除予定だよ」という類が多いので、今のうちに修正しておくことをオススメします

React Nativeにおいて、WebViewでhtml内のリンクをブラウザアプリで処理をする方法

概要

先日、Expoアプリで使うWebViewはreact-native-communityを使わなければ表示させることができないという記事を書きました、今回もWebViewネタ。
WebViewを使用するときは、大きく2つに分かれます。

  1. URLを指定する場合
  2. HTMLを記述する場合

WebViewはWebView内でタグを押下すると、そのWebView内で画面遷移をしてしまう仕様です。
そのため、
タグを押下時にブラウザで開いて欲しい場合はうまく意図した通りに動作しません。

今回はWebViewでhtml内のリンクをブラウザアプリで処理をする方法について書きます。

解決方法

onShouldStartLoadWithRequest propsを使用することです。
具体的には、この関数内でLinking.openURL("https://~~")とすると意図した挙動となります。

onShouldStartLoadWithRequest={(event)=>{
  if(event.url == "about:blank"){
    return true
  } else {
    Linking.openURL(event.url);
  }
}}

onShouldStartLoadWithRequestは初期ロード時にも呼ばれます。
そのため、2行目の「if(event.url == "about:blank"){」を記述することで、

  • 初期ロード時は何もしない
  • 何かしらのリンクが押された場合は、ブラウザアプリで開く

としています。

その他試したこと

当初はonNavigationStateChangeの使用を検討していました。
これは、WebViewのロードが開始または終了したときに呼び出される関数です。 この関数を使用した場合、AndroidとiOSでWebViewの挙動が若干異なるため、使用をやめました。
具体的には、リンク先が、.htmlの場合は意図した挙動になるのですが、.jpgなど、画像の場合は、WebView内で遷移もしてしまうし、Linkingも実行してしまうという挙動となりました。
これは、onShouldStartLoadWithRequestと処理がクロスオーバーしてしまうためだと思われます。
*1 this.webview.reload()this.webview.stopLoading()を駆使すればうまいこと制御できそうですが、処理が複雑になってしまうことや、onShouldStartLoadWithRequestを使用すれば問題ないため、こちらで実装することはやめました。

*1:未検証です