Cybozu Inside Out | サイボウズエンジニアのブログ

 

Kubernetesへの機能追加にかかわった話と、そこから得た知見

はじめに

こんにちは、Necoプロジェクトsatと申します。

みなさんはKubernetesに機能が追加されるまでの流れをご存知でしょうか。githubに存在するプロジェクトであれば典型的にはfeature request用のissueが立てられて、それをもとにPRが作られてレビューを経たのちにマージという流れです。しかしKubernetesはたくさんのプロジェクトから構成される非常に複雑なシステムなので、このような単純なやりかたが難しいのです。KubernetesではそのかわりにKubernetes Enhancement Proposal(以下KEPと表記)というしくみを使って新機能を開発するというスタイルをとっています。

本記事はKEPについて簡単に説明した後に、Kubernetesの機能開発が進んでいく具体的な流れについて、NecoプロジェクトがレビューにかかわったKEPを通して紹介します。

KEPとは

Kubernetesに新機能を追加しようとした場合、まずは所定の形式に沿ってkubernetes/enhancementsというプロジェクトへのPRという形でKEPを発行します。まずは機能追加の要否やデザインの良し悪しについて関係者のレビューを受けて、レビューが通れば晴れて実装可能な状態となります。

KEPの役割はこれで終わりません。みなさんはKubernetesがリリースされるたびに「〇〇機能がbetaからGAになった」などの文言を見ることがあると思いますが、各機能のalpha,beta,GAという成熟度もKEPを介して更新されます。

kubernetes/enhancementsを見ると現在Kubernetesにどんな機能が提案されているのか、それぞれの開発状況はどうなっているのか、などの情報が得られるので、興味のあるかたはフォローしてみるとよいでしょう。

本記事ではKEPそのものについてのしくみについてはこれ以上述べませんが、以下の記事、およびそこから参照されるドキュメントが参考になるので、ご興味のある方はごらんください。

qiita.com

KEPにかかわるようになったきっかけ

NecoプロジェクトはTopoLVMというローカルストレージ用のCSIドライバを作っています。TopoLVMはNeco専用の特殊なものではなく、Kubernetesでローカルストレージを扱いたい人であればだれでも恩恵を受けられるものだったので、あるときKubernetesのslackの中でストレージについての話題を扱うsig-storageというチャネルで紹介をしました。これに対してpmem-csiという不揮発性メモリ用のCSIドライバの開発者であるPatrick Ohlyさんというかたが興味を持ってくれたのがすべての始まりです。

彼が注目したのはTopoLVMが持つ、ノードのストレージ空き容量を考慮してPodをスケジューリングするという機能です。この機能については以下記事の"TopoLVMの特徴"という節をごらんください。

blog.cybozu.io

我々がsig-storageでTopoLVMを紹介したとき、偶然にもpmem-csiではちょうど同様の機能を開発中だったのです。

github.com

さらに彼はこの機能をドライバ独自のものではなくKubernetesの標準的な機能として実装したいと考えていたため、以下のKEPを発行していました。

github.com

このような事情から、上記KEPに対して、この機能についての知見を持っている我々のコメントが求められたというわけです。

どのようなKEPか

本KEPの目的は、次のようなことをKubernetesの標準機能として提供することです。

  • 各ストレージシステムが空き容量についての情報をAPIサーバに見える形で提供する
  • スケジューラは上記の情報を参考にして、可能な限りボリュームの割り当てに成功するよう考慮してPodをスケジュールする(さもなくばPodはスケジュールできない状態になる)

本KEPはTopoLVMやpmem-csiなどのノードローカルストレージだけではなく、クラウドサービスにおけるゾーンローカルストレージなど、より多くの種類のストレージシステムに対応できるようになっています。

我々にコメントが依頼された時点でのKEPは次のようなものでした。

  • 以下2種類のボリュームについて考慮する
  • 各ストレージシステムの空き容量を管理するためのCSIStoragePoolというKubernetesのリソースを追加する
  • ephemeral volumeのサイズ情報を標準化する
  • 上記の情報を使ってPodをスケジュールするようスケジューラを拡張する

Ephemeral volumeはなじみのない機能かもしれないので簡単に説明しておきます。KubernetesにおいてCSIドライバが提供するボリュームは通常永続化されており、Persistent VolumeとPersistent Volume Claimという2つのリソースによって管理されています。そしてボリュームのライフライクルはPodとは独立しています。しかし、Podと同じライフライクルを持つボリュームが欲しいというユースケースがあります。Ephemeral Volumeはこのようなときのために使うものであり、Podの生成と同時に作成され、かつ、Podの終了と共に削除されます。

次節から本KEPに対して具体的にどのようなコメントがあって、かつ、それがどのようにKEPに反映されたかについて記載します。

我々によるコメント

前節において述べたKEPに対して我々からいくつかのコメントをしました。そのうちの主だった3つについてここでは紹介します。

User Storiesが弱い

KEPには、新機能によって誰がどのようなことをできるようになるかを書くUser Storiesという項目があります。我々がレビューした当時、ここにはpmem-csiについてしか書かれていませんでした。これでは本KEPによって利益を受けるユーザが相当限られてしまうと思った我々は、TopoLVMのようなローカルストレージやネットワーク経由で接続されたストレージなどをここに追加してはどうかと提案しました。

幸いにもこの提案は受け入れられ、User Storiesは充実しました。

空き容量の大きさによる優先順位付け

本KEPでやろうとしていることは十分なストレージ空き容量が存在するノードにPodをスケジュールするという単純な機能だけでした。その一方で、TopoLVMではこれに加えて空き容量が多いノードにPodを優先的にスケジュールするという機能(以下、優先順位付け機能と記載)も備えていました。この機能には、各ノードのストレージが均等に消費されていくため、Podの分散配置やボリューム作成後のノードのリサイズがしやすいといった利点があります。これを踏まえて我々は優先順位付け機能をKEPに盛り込んではどうだろうかと提案しました。

この提案については、彼から「KEPをできるだけ単純なものに保って、その後に次第によくしていきたい」という回答がありました。この意見には我々も異論がなかったので、提案を取り下げました。

ephemeral volumeのサイズに関するもの

現在のephemeral volumeにはサイズを指定する標準的な方法がなく、サイズ指定をする方法はドライバに任されています。しかし本KEPはephemeral volumeも考慮に入れているため、ephemeral volumeのサイズ標準化(以下簡単のため"サイズ標準化"と記載)が前提条件となります。そこでサイズ標準化も同じKEP内でやってしまおう、というのが当時の状況だったのですが、このやりかたには次のような問題がありました。

  • サイズ標準化は、これ自身でKEP発行に値する独立した機能と考えられる
  • ephemeral volumeの仕様が複雑怪奇なため、仮にこのKEPでサイズ標準化しようとすると影響範囲が巨大化する

我々はこれらの問題について指摘した上で、ephemeral volumeについては本KEPではそもそも対象外としてはどうかという提案をしました。

この提案についてはsig-storageのメンテナでもあるMichelle Auさんの意向もあって、サイズ標準化は別KEPとして分離されました。

github.com

ただしephemeral volumeの考慮は削除されずに残りました。つまり本KEPはサイズ標準化用の新たなKEPに依存するようになったということです。

それ以外のコメント

我々のコメントの前後に別の開発者からも次のような様々なコメントがあり、それらに従ってKEPは洗練されていきました。

  • 他のKEPとの重複排除
  • User Storiesの追加
  • CSIStoragePoolのデータ構造の変更
  • その他矛盾の解消

上記のような過程を経て、PR発行から二か月程度後についに本KEPは実装可能段階になりました。これに伴ってテスト計画成熟度を上げる基準ための定義*2がKEPに追加されました。

その後の流れ

この後は実装を粛々と進めるだけ…と思いきや、そうは問屋が卸しません。ソフトウェア開発の経験が豊富なかたほど想像しやすいとおもいますが、設計を終えた後に実装段階で初めて明らかになる問題というものが多々あります。本KEPもその例外ではなく、実装開始時点からさまざまな問題が噴出し、現在は実装を一時中断している状態になっています。

検出された問題をすべてを詳しく説明していてはきりがないので、ここではそのうち重要なものについて軽く触れておきます。

  • ストレージの都合でスケジューラのコア部分に手を入れるのに強い反対が起きたため、TopoLVMも使っているscheduler extenderなどの他の方法による実現方法を探さざるをえなくなった
  • ephemeral volumeの現状の設計がまずいために、これ以上機能追加するよりも設計段階から作り直すほうがよいのではないかということになった(関連する議論)
  • そもそもPodスケジュール時にボリュームの割り当てに失敗するとにスケジューリング不可能になるバグを修正すべきだということになった(関連issue)

興味のあるかたは関連情報を追ってみてください。

おわりに

我々は本件を通して初めてKEPのプロセスにかかわったことによって、次のような知見を得ました。

  • KEPプロセスそのもの。今後我々自身がKEPを発行する際はそれほど手間取らないはず
  • ephemeral volumeの仕様などの本件に関連する技術
  • Kubernetesの機能追加に必要な能力。関係者を説得するための広範な知識、ステークホルダーを巻き込む能力、およびそれを継続させる精神的なタフさなど
  • KEPによるKubernetesの変更は少なくとも数か月、場合によっては一年以上を要するため、なんらかの機能がすぐに必要なのであればKEP以外の方法で実現するのが得策であること

本記事によって我々の知見がみなさまに一部なりとも共有できたのであれば幸いです。

*1:機能名はドキュメントごとに表記の揺れが激しいのですが、ここでは"ephemeral volume"と記載します

*2:これこれの基準を満たせばalphaからbetaになれる、など

 

AWS版kintone.comリリースの裏側

こんにちは!Yakumoチームの@ueokandeです。

昨年9月、US向けにAWS 基盤のkintoneがリリースしました。 以前まではUS向けkintone (kintone.com) は日本のオンプレデータセンターから提供してましたが、このリリースによりUS内のAWSリージョンから提供が始まりました。 本日はAWS版kintone.comリリースの裏側を紹介します。

AWS版kintone.comについて

まず初めにAWS版kintone.comについて軽く説明します。

これまでのkintone.comは、日本向けkintone (cybozu.com) と同じ日本のデータセンターで運用していました。 US国内のAWSに移行することで、USのお客様のセキュリティニーズを満たしつつより高いパフォーマンスを実現できるようになります。 またcybozu.comとは独立して運用できるため、リリースによる停止時間の削減やスピーディなサービスの改善を実現できるようになります。

すでにAWS版kintone.comの提供は開始していますが、ユーザー視点で見るとリリース前後で大きな変化はありません。 運用視点で見ると、従来日本のデータセンターで運用してきた、DNSサーバーやHTTPロードバランサー (L7LB) なども、AWS上のサービスに移行します。 このリリース後も、これまでにkintone.comを利用していた客様は、引き続き日本の基盤で運用されるkintoneを利用します。 既存のお客様は今年2020年のQ2に、AWSへの移行が計画されています。 この作業が完了すると、kintone.comは日本のcybozu.comから完全に独立して運用ができるようになります。

並行運用とリリース計画

先ほども書きましたが、2019年9月から2020年Q2までの期間は、kintone.comは日本とAWS版の両方で運用されます。 そのため今年の2020年Q2までの期間は、既存のお客様のリクエストは日本で運用されているkintoneが処理します。 この仕組みを実現するために、日本・AWS両方のkintoneのリクエストを一度AWS側で受け取り、既存のお客様のリクエストを日本に転送します。

AWS版kintone.comと日本版kintone.comの並行運用の図
AWS版kintone.comと日本版kintone.comの並行運用

開発初期は日本の基盤と全く切り離して開発がスタートしました。 DNSサーバーやL7LBなどの運用準備が整うと、kintone.comへのリクエストをAWS側で受け取るように切り替え作業をします。 もちろんこのリリースでは、既存のお客様への影響は最小限にする必要があります。 これらのリリースの手順を説明します。

ビッグバンリリースを避けろ!

リリースの影響や切り戻しの規模を最小限にするため、ビッグバンリリースは極力避けるべきだと言われています。 AWS版kintone.comのリリースには、日本側の切り替え作業も発生します。 リリースに失敗すると、新規のお客様だけではなく既存のお客様も利用できなくなるなどの影響が出る可能性もあります。 そのためAWS版kintone.comのリリースは慎重に進められました。

AWS版kintone.comのリリースは、7月、8月、9月の3回に分けてリリースをしました。 9月にAWS版kintone.comのサービス提供が開始したのは事実ですが、実は7月からすでにAWSの運用は開始していました。 それぞれのリリースでは以下の切り替え作業をしました。

  • 7月: DNSサーバーの移行
  • 8月: L7LBの切り替え
  • 9月: AWS上で新規お申し込みの受付

各リリースでYakumoチームはどういう対応をしたかを順に説明します。

7月: DNSサーバーの移行

まずはDNSサーバーの切り替えです。 このリリースでは、これまで日本のオンプレデータセンターで運用していたDNSサーバーの一部を、AWSのRoute 53に移行します。

この時点ではまだ、kintone.comは日本のデータセンターで運用されています。 Route 53は***.kintone.comというドメイン名に対して、日本のIPアドレスを返します。

DNSサーバーの移行の図
DNSサーバーの移行

DNSサーバーの移行は以下の手順で行いました。

  1. 即座に反映・切り戻しを行えるように、日本のDNSサーバーのTTLを短くする。
  2. Route 53であらかじめ***.kintone.comに該当するDNSレコードを登録する。名前解決されたIPアドレスは日本のデータセンターを指す。これも短いTTLを設定する。
  3. kintone.comのNSレコードを、日本のネームサーバーからRoute 53のネームサーバーに変更する。
  4. しばらく待ってRoute 53で設定したTTLを長くする。

これらの作業を順に適用しました。 DNSは効率化のためにキャッシュされるので、設定の変更が確実に反映されるために、TTL以上の期間待つ必要があります。 開発環境も含めて実際の切り替え作業は数日ほど要しましたが、お客様への影響はありませんでした。

8月: L7LBの切り替え

続いてはL7LBの切り替えです。 日本に到達していたkintone.comへのリクエストを、AWSのALB (Application Load Balancer) が受け取るようにします。 AWS版kintone.comは、ALB内部に更にNGINXをつかって、既存のお客様のリクエストを日本に転送します。 NGINXはリクエストのFQDNから、既存のお客様かAWS版kintone.comのお客様かを判断します。

L7LBの切り替えの図
L7LBの切り替え

この作業では、Route 53の***.kintone.comに対するアドレスを、日本のデータセンターからAWSのALBに変更します。 すでにDNSサーバーはRoute 53に移行済みなので、Yakumoチーム内だけで切り替えと切り戻しができるようになりました。

AWS版kintone.comの提供は開始してませんが、すでにNGINXやFQDNの管理に必要なミドルウェアが、Amazon EKS (Elastic Kubernetes Service) 上で運用されています。 このリリースから、Yakumoチームのオンコール体制も始まりました。 ミドルウェアやEKSに障害が発生すると、既存のお客様にも影響がでるので、Yakumoチームが速やかに障害対応する必要があるためです。

9月: 新規お申し込みの受付

さていよいよAWS版kintone.comのリリースです。 実はこのリリースはこれまでのリリースと比較すると、Yakumoチームの作業はほぼ発生しませんでした。 このリリースでは、kintoneのお申し込み画面 (https://www.kintone.com/trial/) からのリクエストを、国内からAWS上のシステムに変更します。 これで晴れて、新規のお客様は無事、AWS版kintoneを利用できるようになりました。

お申し込みの切り替えの図
お申し込みの切り替え

まとめ

AWS版kintone.comのリリースの裏側と、日本のkintoneの並行運用について紹介しました。 お客様への影響を最小限にするため、お客さんへの提供を始める前にから、裏ではこまめなリリースを実施してきました。

現在Yakumoチームは、Q2の既存のお客様をAWSに移行するための準備をしています。 この移行作業が完了すると、US向けのkintoneは完全に日本のデータセンターと独立して運用できます。 しかしミドルウェアの設計が異なるので、データコピーだけでなくそのマイグレーション作業も発生します。 移行作業やマイグレーションについてもお話できることはいくつもあるので、kintone移行後にまた改めて記事を書きたいと思います。

 

PHPerKaigi 2020にサイボウズも協賛しています!

こんにちは。コネクト支援チーム 兼 Garoonチームの @sakay_y です。
サイボウズもPHPerKaigi 2020にシルバースポンサーにて協賛していますので、そのお知らせをいたします。
あと、参加者の皆様が気になるPHPerトークンを掲載しています。

PHPerKaigi 2020 に協賛に至った理由

サイボウズではGaroonという製品でPHPを利用しています。 Garoonは2002年にリリースして以来、長くお客様より支持していただいてきた歴史ある製品です。 一方で、私達Garoonエンジニアとしては積み重ねた歴史に苦労する時もあり、製品の機能開発だけでなく、日々改善に励んでいます。 PHP界隈では、同様に歴史ある製品への改善に取り組んでいるエンジニアが多くいらっしゃり、私達もそんなエンジニアの皆様と共に情報発信をしていければ良いなと考えました。

そんな弊社の姿勢を知っていただく、PHP界隈を盛り上げていく、という思いで協賛することを決めました。

弊社からは杉山が登壇します

Garoonチーム所属の杉山(@oogFranz)が「静的解析の育て方」というネタで登壇します!
よろしくお願いします! https://fortee.jp/phperkaigi-2020/proposal/8f41e23f-69ef-4f93-8625-db64be39248f

ステッカーお配りしてます

Garoonのイメージキャラクター「カンガルーン」のステッカーをお配りします。
弊社デザイナーの@kochitakuがデザインしました。かわいがっていただければ幸いです。

丸型のカンガルーンステッカー
カンガルーンステッカー。よく見ると3種類あります。

PHPerチャレンジ用のトークン

PHPerKaigiでは、PHPerチャレンジという宝探し的なイベントが開催されます。
PHPerチャレンジを実施します: PHPerKaigi スタッフブログ

そして、サイボウズの提供するPHPerトークンはこちら

#PHPerKaigi2020_Garoon

です! よろしくお願いします!

 

テナントがArgoCD Applicationを任意に作れるようにする

こんにちは😸 Necoの@dulltzです。

皆さんはマルチテナントでGitOpsするためにどのような構成をとっていますか? 我々はArgoCDを利用しています。

以前、@zoetroからArgoCDについての紹介がありました。

blog.cybozu.io

上の記事でもテナント*1に対しArgoCDを提供する方法に触れているのですが、 最近そこからもう一歩踏み込んで、テナントがApplicationを任意のタイミングで安全に作れるようにしました。これについて説明します。 なおNecoでは実装をOSSにしているので、記事内にソースコードへのリンクを適宜貼っておきます。気になる方はそちらも御覧ください。

前提知識

  • ArgoCD
    • KubernetesでGitOpsを行うためのミドルウェアです。*2
    • この記事ではArgoCD v1.3.6を対象とします。
  • Application
    • ArgoCDのカスタムリソースです。GitOpsするアプリケーションを宣言します。指定したGitリポジトリパスに置いてあるマニフェストを、ArgoCDはKubernetesに適用してくれます。
  • AppProject
    • ArgoCDのカスタムリソースです。Applicationの論理グループを表現します。AppProjectを使うとApplicationの適用範囲や、Applicationにアクセス可能なユーザを制御できます。
  • App of Apps
    • Application群を管理するためのApplicationを用意することで、Application自体もGitOpsするというパターンです。*3

現在の構成

先に現在の構成を書いておきます。

Adminチームのマニフェストリポジトリneco-appsにテナントのリポジトリを参照するApp of Apps用Applicationを配置することで、 テナントのApplicationをテナント管理のマニフェストリポジトリへ配置できるようにしました。

こうすると何が良いのかというと、テナントチームがApplicationを作るときにKubernetes adminチームへレビューを依頼する手間がなくなります。 詳しい話は後述します。

現在のArgoCD構成
現在のArgoCD構成

以前のやり方

以前はテナントがArgoCDを扱えるように、次のような2つのAppProjectを用意していました。

  • default: admin向けのprojectです。ArgoCDで可能なすべての権限が許されています。
  • tenant: テナント向けのprojectです。テナント用のNamespace内にのみアプリケーションをデプロイできます。

これらのうち、テナント用のApplication.spec.projectにはtenantを指定することで、テナントの権限を制御していました。

テナントのApplicationはすべてNecoチーム(=admin)のマニフェスト用リポジトリneco-appsで管理し、 テナントチームが新たにApplicationを追加したい際には、neco-appsにPRを出してもらうことで対応していました。 この仕組みでは、テナントがApplicationを追加するたびにNecoチームはレビューする必要があり、 テナントはそのレビューを通過するのを待つ必要があります。

どうしてこうなった

テナントがadminチームに毎回PRを投げなければならない仕組みはなんかちょっと面倒そうです。 ここでより良さそうなやり方として思いつくのは、 「AppProjectによる権限分離を利用して、 neco-appsにはテナントのApplication群を管理するApp of Apps用Applicationだけを配置しておけば、 テナント用Applicationをテナントのリポジトリの中に配置できるのでは?」という方式です。 もしそれができれば、テナントがApplicationを追加しようとするたびNecoチームへPRを出す手間がなくなります。

ですが最初にNecoチームがマルチテナントArgoCDを設計したときは、その方式は採用しませんでした。 なぜかというとApplicationspec.project、つまりそのApplicationが所属するAppProjectを指定するフィールドに、任意の値をセットできてしまうからです。 言い換えると、Applicationを作る権限を渡すことが、ArgoCDでできるすべてのデプロイを許可することになってしまうということです。

これを防ぐために、neco-appsの中でテナントのApplicationも保持しておき、テナントにはApplicationの作成権限を渡さなかったのでした。

今のやり方

前述の通り、最近やり方を見直してテナントがadminに毎回PRを投げなくても良くなるようにしました。 なぜそうしたのかというと、インフラ管理のための手作業コストの低減がNecoチームの目的の1つだからです。

新しいやり方ではさきほど触れた「テナント用Applicationをテナントのリポジトリの中に配置しておいて、neco-appsにはテナントのApplication群を管理するApp of Apps用Applicationだけを配置する」という仕組みになっています。

次のような3つのAppProjectを用意しました。

テナントのリポジトリを参照するApp of Apps用Applicationtenant-appsに所属させています。 前述の図を再掲します。

現在のArgoCD構成
現在のArgoCD構成

Validating Admission Webhookによる解決

さきほど触れた「テナントがApplication.spec.projectdefaultを指定できてしまう」問題を解決するために、Validating Admission Webhookを実装しました。

Applicationの参照しているリポジトリのURLをもとにApplication.spec.projectとして指定可能なプロジェクト名を判定し、テナントの権限を超えたApplicationの作成を禁止しています。

閑話休題: Admission Webhookの実装

巷ではGatekeeperを使いRegoでAdmission Webhookのルールを書く方式が流行っていますが、 NecoではAdmission Webhookをcontroller-runtimeで実装しています。 つまりGoで書いています。

なぜGoで実装しているのかというと、Admission Webhookはカスタムコントローラとほぼ同じやり方で実装できるので、 チームメンバーのスキルセットと相性が良かったからです。

また、現在はneco-containers/admissionにすべてのwebhookを実装しワンプロセスで動かしていますが、 これはそのうち分割するかもしれません。

まとめ

今回紹介した新しいやり方によって、テナントチームがApplicationを作るたびにadminチームへレビューを依頼する手間がなくなりました。

ただしテナントチームが新しいマニフェスト用リポジトリを追加したいときには、adminチームがApp of Apps用Applicationを新規追加したり、AppProjectを更新したりする手間が残っています。 このような定形作業は、テナント管理のためのカスタムコントローラを作ることで自動化していく予定です。

今回の内容についてもっと良いやり方があったらぜひ教えてください。

サイボウズではKubernetesが好きなインフラ〜ミドルウェア領域のエンジニアを募集中です。

 

Navigation Component のニッチな落とし穴から得た Android アプリ開発の学び

あけましておめでとうございます! (遅すぎ)

モバイルチームの向井田 (@mk_mkee) です。
モバイル系の 2020 年最初のブログは Android について書いていこうと思います!

皆さん、Android Jetpack の Navigation Component は使っていますか? Navigation Component は Android アプリの画面遷移やバックスタック管理を楽にしてくれるライブラリです。 弊社では kintone モバイルの Android アプリで導入しています。

今回の記事では、Navigation Component を利用していて気になった画面遷移の挙動を調査し、その過程で得た学びをお伝えしたいと思います。 Navigation Component の学びというよりは、Android アプリ開発全般における学びとなるので、よかったら読んでいってください。

経緯

先日、kintone モバイルに SAML 認証によるシングルサインオン機能を実装していたときの話です。 Chrome Custom Tabs で認証をし、認証完了後にログイン画面からログイン後のポータル画面に遷移する機能を作っていました。

実装が終わって動作確認をしていたときです。 Chrome Custom Tabs が閉じられ、kintone モバイルのログイン画面に戻ってきたとき、タイミング悪く Recents ボタン (□のやつ) を押すと、 画面遷移が完了しない現象に出くわしました。 もうログインは完了しているので、フォアグラウンドに戻ってきたときにポータル画面に遷移する、というのが我々が期待していた動作でした。

シングルサインオンで画面遷移が成功するときシングルサインオンで画面遷移が失敗するとき
(左)シングルサインオンで画面遷移が成功するとき, (右)シングルサインオンで画面遷移が失敗するとき

原因

ログイン画面 -> ポータル画面 の画面遷移には Navigation Component の NavController#navigate() を使っていました。 結論から言うと、以下の2つが原因でした。

  1. Activity が onSaveInstanceState() 以降のライフサイクルのときに NavController#navigate() を呼び出しても画面遷移しない
  2. NavController#navigate() を非同期に呼び出すと、onSaveInstanceState() 以降に呼び出してしまう可能性がある

お察しの通り、起きている現象自体はめちゃくちゃコーナーケースです。 しかし、原因を調べてみると Android の基本的な仕組みをより深く知る良い機会となったので、皆さんにも共有しようと思い筆を執りました。

それでは、順を追って説明していきたいと思います。

Navigation Component を解剖していこう

1つ目の原因である Activity が onSaveInstanceState() 以降のライフサイクルのときに NavController#navigate() を呼び出しても画面遷移しない から解説していきます。

Navigation Component の基礎

まず Navigation Component の基礎から復習していきます。

Navigation Component は1つの Activity が複数の Fragment を入れ替えて画面遷移を実現しています。 Activity は NavHostFragment という Fragment の入れ物を持っており、NavHostFragment が各画面に相当する Fragment の入れ替えを処理しています。 そして、NavController#navigate() を呼び出すと画面遷移が実行されて、Fragment が入れ替わります。

developer.android.com

NavController#navigate() の中身

では、NavController#navigate()onSaveInstanceState() の関係を知るために、NavController#navigate() のコードを読んでいきましょう。

以下のコードは NavController#navigate() の実装です。

// NavController#navigate() は 内部で FragmentNavigator#navigate() を呼び出す
public NavDestination navigate(
    @NonNull Destination destination,
    @Nullable Bundle args,
    @Nullable NavOptions navOptions,
    @Nullable Navigator.Extras navigatorExtras) {
    if (mFragmentManager.isStateSaved()) {
        Log.i(TAG, "Ignoring navigate() call: FragmentManager has already"
                + " saved its state");
        return null;
    }
    // 省略...

FragmentNavigator.java - Source

NavController#navigate() の実装を見ると、mFragmentManager.isStateSaved() の値が true だったとき、画面遷移の処理は ignore されます。 mFragmentManager.isStateSaved() の値は mFragmentManager を所持する NavHostFragment や、NavHostFragment をホストしている Activity のライフサイクルによって変わります。 そして、Activity の onSaveInstanceState() が呼ばれた後だと mFragmentManager.isStateSaved() は true になります。

public boolean isStateSaved() {
    // See saveAllState() for the explanation of this.  We do this for
    // all platform versions, to keep our behavior more consistent between
    // them.
    return mStateSaved || mStopped;
}

FragmentManager.java - Source

余談ですが、 isStateSaved() の実装で mStoppedonStop() の状態もチェックしている理由は、Android のバージョンによって onSaveInstanceState() の呼ばれるタイミングが異なるからだと思います。 (下図)

Activity のライフサイクル (一部)
Activity のライフサイクル (一部)
参考: Understand the Activity Lifecycle  |  Android Developers

では、なぜ Navigation Component は Activity の onSaveInstanceState() が呼ばれた後だと画面遷移しないのでしょうか? その答えは、Navigation Component が Fragment を入れ替えるために使用している FragmentTransaction という仕組みにあります。

FragmentTransaction の復習

FragmentTransaction とは、ある Fragment を Activity や 別の Fragment に追加、置換したり、削除したりする仕組みです。 Fragment が Android に導入されたときからある基本的な操作です。

追加や置換など、Fragment に関する変更処理のまとまりを FragmentTransaction という単位で管理します。 FragmentTransactionFragmentManager が内部で管理しているバックスタックに追加できます。 そのため、Fragment の変更は Navigation Bar の戻るボタンで元に戻すことが出来ます。

FragmentTransaction を利用して、ある Fragment を別の Fragment に置換する場合は以下のようなコードになります。 addToBackStack() を実行すると、beginTransaction() から commit() までの変更が1つのスタックとしてバックスタックに追加されます。

val newFragment = ExampleFragment()
val transaction = supportFragmentManager.beginTransaction()
// R.id.fragment_container の Fragment を newFragment に入れ替える
transaction.replace(R.id.fragment_container, newFragment)
transaction.addToBackStack(null)
transaction.commit()

developer.android.com

FragmentTransaction と Activity のライフサイクル

FragmentTransaction は Fragment をホストしている Activity のライフサイクルと深く関係しています。

beginTransaction() のドキュメントには、保存される FragmentTransactiononSaveInstanceState() より前に実行されたものだけであり、onSaveInstanceState() より後に FragmentTransaction を実行するとエラーになると書かれています。

A fragment transaction can only be created/committed prior to an activity saving its state. If you try to commit a transaction after FragmentActivity.onSaveInstanceState() (and prior to a following FragmentActivity.onStart or FragmentActivity.onResume(), you will get an error. This is because the framework takes care of saving your current fragments in the state, and if changes are made after the state is saved then they will be lost.

FragmentManager  |  Android Developers

なぜこういう仕様なのでしょう?

onSaveInstanceState() とは何だったか

画面を構成する Activity のインスタンスは、画面回転時やバックグラウンドでのメモリ不足時に Android OS によって破棄、再生成されます。 何もしなければ Activity のインスタンスが破棄されたタイミングで Activity が持つデータも失われてしまいます。

そこで、ある Activity のインスタンスが持つデータを再生成後の Activity インスタンスに引き継ぐために、onSaveInstanceState() が呼ばれます。 引き継ぎたいデータは onSaveInstanceState() の引数で受け取った outState に格納しておく必要があります。

FragmentTransaction と onSaveInstanceState()

Activity が再生成されるため、Activity が管理する Fragment も再生成されます。 Fragment が持つデータは FragmentActivity#onSaveInstanceState() ですべて outState に格納されます。 よって、FragmentManagerが管理するバックスタックに追加した FragmentTransaction も 最終的には FragmentActivity#onSaveInstanceState()outState に格納され、再生成された Activity に引き継がれます。

public class FragmentActivity extends ComponentActivity implements
        ActivityCompat.OnRequestPermissionsResultCallback,
        ActivityCompat.RequestPermissionsRequestCodeValidator {
    // 省略...

    @Override
    protected void onSaveInstanceState(@NonNull Bundle outState) {
        super.onSaveInstanceState(outState);
        markFragmentsCreated();
        mFragmentLifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_STOP);

        // バックスタックを含む Fragment の全データを outState に格納
        Parcelable p = mFragments.saveAllState();
        if (p != null) {
            outState.putParcelable(FRAGMENTS_TAG, p);
        }
        // 省略...

FragmentActivity.java - Source

逆にいうと、FragmentActivity#onSaveInstanceState() 以降に発生したデータは outState に格納されず、インスタンス破棄とともに失われます。 つまり、FragmentActivity#onSaveInstanceState() より後に実行した FragmentTransaction も、Activity の再生成によって失われてしまう可能性があり、Fragment の変更を元に戻せることを保証できません。 だから、FragmentActivity#onSaveInstanceState() 後の FragmentTransaction 実行はエラーになる仕様になっています。

Navigation Component が Activity のライフサイクルを確認する理由

Navigation Component も FragmentTransaction を使って Fragment を入れ替える都合上、 FragmentActivity#onSaveInstanceState() 後に Fragment の入れ替えを実行するとエラーが起きてしまいます。 このエラーを避けるために、Navigation Component は Activity の onSaveInstanceState() が呼ばれたかどうかを mFragmentManager.isSavedState() で確認し、エラーなく画面遷移を完了できるようにしていたというわけです。

NavController#navigate() と非同期実行

次に、2つ目の原因であった NavController#navigate() を非同期に呼び出すと、onSaveInstanceState() 以降に呼び出してしまう可能性がある について解説していきます。

一般的に、ボタンを押すと画面遷移するようなコードは、Button の listener 内で NavController#navigate() を呼ぶと思います。

// A Fragment
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    super.onViewCreated(view, savedInstanceState)

    // Kotlin Android Extensions を利用
    go_to_b_button_success.setOnClickListener {
        // A Fragment -> B Fragment の画面遷移をする
        Navigation.findNavController(requireActivity(), R.id.nav_host_fragment)
            .navigate(R.id.BFragment)
    }
}

このように、listener 内で同期的に NavController#navigate() を呼び出した場合は、Activity ライフサイクル図の Activity running の状態での呼び出しになります。 すなわち、onSaveInstanceState() 前の呼び出しなので、正常に画面遷移が成功します。

しかし、listener 内で非同期に NavController#navigate() を呼び出した場合は違います。 我々は、実装していたシングルサインオン機能の都合上、NavController#navigate() が非同期で実行されていました。

以下は検証用のコードです。

// A Fragment
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    super.onViewCreated(view, savedInstanceState)
    go_to_b_button_success.setOnClickListener {
        // UIスレッドのループを回さずに遅延させる (同期実行)
        try {
            Thread.sleep(5000)
        } catch (e: Exception) {}

        Navigation.findNavController(requireActivity(), R.id.nav_host_fragment)
            .navigate(R.id.BFragment) // 成功 😘
    }

    go_to_b_button_failure.setOnClickListener {
        // UIスレッドのループを回して遅延させる (非同期実行)
        Handler().postDelayed({
            val option = NavOptions.Builder()
                .setEnterAnim(R.anim.nav_default_enter_anim)
                .setExitAnim(R.anim.nav_default_exit_anim)
                .setPopEnterAnim(R.anim.nav_default_pop_enter_anim)
                .setPopExitAnim(R.anim.nav_default_pop_exit_anim)
                .build()

            Navigation.findNavController(requireActivity(), R.id.nav_host_fragment)
                .navigate(R.id.BFragment) // 失敗 🤮
        }, 5000)
    }
}

ライフサイクルの更新は UIスレッド (メインスレッド) のループ処理の中で更新されます。 ユーザがボタンを押したとき、listener 自体は Activity running の状態で呼び出されます。 listener 内で同期的に処理が行われる限り、UIスレッドのループが進むことは無いため、listener 内の処理は Activity running 状態で実行されます。

しかし、listener 内に非同期処理がある場合、listener が呼び出されたときのループでは非同期処理が実行されず、次以降のループで実行されます。 そのため、ライフサイクルが onSaveInstanceState() 以降に更新される可能性が出てくるのです。

kintone モバイルの例では、NavController#navigate() を非同期で実行しており、かつ Resents ボタンを押してライフサイクルが更新されたため、画面遷移が ignore されてしまったというわけです。

上記の非同期処理は昔ながらの Handler を用いたコードですが、RxJava や Kotlin Coroutines でも同様の現象が発生すると思います。

学びまとめ

今回は、Navigation Component の NavController#navigate() を呼んでも画面遷移しない場合がある理由について、詳しめに解説しました。

現象としてはとてもニッチな内容ですが、今回の件で学んだことが2つあります。

  1. Android アプリ開発においてライフサイクルの考慮は忘れてはならない
  2. 非同期処理は UIスレッドの処理が進むため、ライフサイクルが更新されて意図しない挙動になる場合がある

これらの学びは Android アプリエンジニアなら知ってて当たり前な内容かもしれません。 しかし同時にハマりやすいポイントでもあります。 特にライフサイクルは、Jetpack が提供する ViewModelLifecycle を使えば、随分と楽にはなってきていますが、 完全に忘れて良いものでは無いなと再認識しました。

この記事を読んだ方の何かの助けになれば幸いです!😘

おまけ

ちなみに、今回のようにアプリがバックグラウンドになってしまって ignore された画面遷移を、 アプリがフォアグラウンドに復帰した際に再実行したい場合は、NavController#navigate() を呼び直すしかありません。 kintone モバイルでは ignore された画面遷移の処理をキューとして保持しておき、フォアグラウンド復帰時にキューを処理するという方法で対処しました。


サイボウズでは、今回のように iOS/Android の開発技術について探求したい人を募集中です💪
We are hiring!!! 🔜 cybozu.co.jp