Docker構成のMastodonに後からpgBouncerを組み込む

背景

mastodon.juggler.jp はここ数日はサーバの人数1100〜1050、1日間のアクティブユーザ270人くらいで人数はあまり変わってません。しかしユーザ間のフォローが増えるにつれて、発言、ブースト、お気に入りで発生するFan-outが大量にsidekiqのキューに積まれるのが目立ってきました。特にdefaultキューに処理が溜まるとユーザへの応答性が低下します。

Push通知は計算よりも通信待機が多いのでキューを処理するスレッドを増やすのが効果的です。
それに必要なのがCPU、メモリ、そしてDBサーバへの接続数です。

今回はDBサーバへの接続数を稼ぐために、DBサーバとアプリケーションの間にpgBouncerを設置してみます。

期待される効果

Docker構成のMastodon で使われているDBサーバの初期設定では max_connectionが100 になっていますが、この接続一つごとにプロセスを一つ使ってしまいます。

pgBouncerを使ってDB接続の中継を行うと、アプリ側に必要な接続数を増やしつつもDBサーバ側のプロセス数を節約することができます。

pgbouncerイメージの取得

pgbouncerイメージの取得と設定ファイルの雛型の抽出を行います。

docker run --rm --name test-pgbouncer -it gavinmroy/alpine-pgbouncer sh
でコンテナ中にログインできたら、以下の作業を行います。

Mastodonの停止とコンテナの削除

あらかじめdbサーバはデータをホスト側のフォルダに出して永続化しておいてください。
このあたりは前回の記事で説明しています。 http://d.hatena.ne.jp/tateisu/20170416/1492341633
また、データベースにはMastodonに必要なデータが既に一通り定義されているものとします。

docker-compose down

Mastodonの停止と関連コンテナの削除が行われます。

データベースのサービス定義の名前変更

docker-compose.ymlを編集します。サービス定義の内、名前が「db:」となっているものを「db_backend:」という名前に変更します。

pgbouncer設定ファイルの用意

.env.production にある DB_で始まる設定値をテキストエディタにコピーしておきます。

docker-compose.ymlがあるフォルダの下に pgbouncerフォルダを作成します。
その中に pgbouncer.ini と userlist.txt を作成します。
パーミッションと内容は先ほどメモしたものに合わせてください。

userlist.txt には「"bouncer" "(新パスワード)" 」という行を記述します。パスワードは適当に生成してください。

pgbouncer.iniには以下の編集を行います。

[database]セクションにバックエンドへの接続を書きます。
「postgres = host=db_backend port=5432 dbname=postgres user=postgres password=(DBパスワード)」

  • hostに指定するのは、データベースサーバのサービス定義の名前です。先ほどdb_backendに変更しましたね?
  • port,dbname,user,passwordに指定するのは、これまでDB接続に使っていたのと同じ情報です。先ほど .env.production からメモしましたね?

[pgbouncer]セクションの以下の情報を編集します。

listen_addr = 0.0.0.0
listen_port = 5432
auth_type = plain
auth_file = /etc/pgbouncer/userlist.txt
pool_mode = transaction
max_client_conn = 900
default_pool_size = 20

pgBouncerのサービス定義を追加

docker-compose.ymlを編集します。以下のようなサービス定義を追加します。

  db:
    restart: always
    image: gavinmroy/alpine-pgbouncer
    volumes:
      - ./pgbouncer:/etc/pgbouncer
    expose:
      - "5432"
    depends_on:
      - db_backend

pgbouncer.ini のlisten_post とdocker-compose.ymlの exposeの指定が同じポート番号か確認しましょう。

.env.production の編集

.env.production の設定値を、bouncerへの接続に適したものに変更します。

  • DB_HOSTに指定する名前は、docker-compose.ymlのpgBouncer用サービス定義の名前と一致させます。
  • DB_NAMEに指定する名前はpgbouncer.ini の[database]セクションに指定した接続情報の左端の名前と一致させます。
  • DB_USERに指定する名前はuserlist.txt に指定したユーザと一致させます。
  • DB_PASSに指定するパスワードはuserlist.txt に指定した新パスワードと一致させます。
  • 末尾にある PREPARED_STATEMENTS=false をアンコメントします。

データベースを利用するアプリはこれらの環境変数を使って接続するようになります。

動作確認

docker-compose up -d して問題がなければ成功です。

うまくいかない場合は以下をチェックしてください

  • docker ps -a で出るポート指定は期待通りになっているか。expose漏れはないか。
  • docker-compose logs -f db でログイン周りのエラーが出ていないか
  • docker-compose logs -f db_backend でログイン周りのエラーが出ていないか
  • docker-compose logs -f で prepared statement に関するエラーが出ていないか。PREPARED_STATEMENTS=false を設定し忘れてないか

関連記事

Mastodonを設置したときのメモ

Mastodonは公式ドキュメントが比較的充実しているので設置そのものは簡単な部類です。

mastodon.juggler.jpは4/12に稼働開始しました。特に人を増やす予定もなかったのですが、当時の日本サーバ群は色々と不安定だったので避難所的な使われ方をして人数が増えてしまいました。その頃に発生した問題についてメモしておきます。

作成時に行っておくと良い作業

masterブランチを使わない

Mastodonのmasterブランチは開発ブランチです。使ってはいけません。
最新のタグをチェックアウトしましょう。

git clone https://github.com/tootsuite/mastodon.git
cd mastodon
git checkout $(git describe --tags `git rev-list --tags --max-count=1`)
ボリューム永続化のすすめ

git update-index --skip-worktree docker-compose.yml してから docker-compose.yml を編集します。

ファイル中に指定されているボリュームは次の5か所です。

  • ./postgres:/var/lib/postgresql/data
  • ./redis:/data
  • ./public/assets:/mastodon/public/assets
  • ./public/system:/mastodon/public/system
  • ./public/system:/mastodon/public/system

ただし最初の2つはコメントアウトしないとホスト側フォルダに保存されません。コンテナ自体にデータが保存されるため、コンテナが削除された時点でpostgresやredisのデータが消えてしまいます。

「### Uncomment to enable DB persistance」の下の2行 x 2か所のコメントを外します。こうしておくとコンテナをdown やrm してもホスト上にフォルダがそのまま残っているので、コンテナをいつ作り直しても問題ありません。まとめてtarできるので管理も楽になります。

もし postgresやredis のボリュームを指定せずに始めてしまった場合は、コンテナが消える前に(stopしてから) docker cp でコンテナが保持しているデータを抽出して、docker-compose.yml を編集しなおすのが良いと思います。

Minio

私は行っていないけど、参考までに。

nullkalさんのトゥート https://mstdn.jp/@nullkal/125745 によると「あ,一つ忠告なんですけど,どんなに小さい公開インスタンス作るとしてもMinioとか駆使して画像アセットだけはドメイン分けといたほうが後で人が多くなったときに楽です (二回目のリセットの理由はここです)」

Mastodonはユーザが投稿した画像などのデータをS3などのストレージサービスに保存する設定がある。ただし素直にS3を使ってしまうと費用的にバカ高いのでMinioなどのS3互換のストレージサーバを自分で建てることを推奨しているようだ。

最初にこれを行うことを勧めているのは、後から移行するのが困難だからだろう。

発生した問題

300人超で自宅鯖だと帯域の限界に

上り帯域が一日15GBを超えて、般家庭用のプロバイダ契約だと帯域規制の対象になりそうでした。一週間くらいでプロバイダから警告のメールやお手紙が届くので、そうなる前にVPSに移行することにしました。

  • 永続化が必要なボリュームには最初からホスト側のパスを指定していたのでそれ全部とmastodonのビルドツリーをtar cpjf で固めておく
  • 移動先のVPSで 同じフォルダ構成で展開する
  • docker-compose build && docker-compose up -d

で移行終了。

ここで下手にgit pull とか docker-compose run --rm web rails assets:precompile とかしちゃうとassets がうまくなくてWebアプリのJavaScriptがうまく動かなくなる。assetsこわい

Webアプリ用にプリコンパイルされるassetsを削除する方法は rails --tasks に2つ説明されています。

rails assets:clean[keep]                          # Remove old compiled assets
rails assets:clobber                              # Remove compiled assets

assets:clobberの方は「Errno::EBUSY: Resource busy @ dir_s_rmdir - /mastodon/public/assets」と怒られますが、削除するファイルはassets:cleanより多いです。

docker-compose run --rm web rails assets:clean
docker-compose run --rm web rails assets:clobber

のように両方を呼び出すと、public/assets/ には何もファイルがない状態になります。

800人超 で 帯域4Mbpsの限界に

だいたい4Mbpsあたりでクリップして帯域不足が発生しました。
VPSのお試し期間を切り上げて本登録したら 8Mbps前後でも安定するようになりました。

1100人超

今度はCPU負荷やディスク負荷が頭打ちになりました。
どのプロセスが重いのかdstat -tdng --file --lock --top-io --top-bio で調べてみたら sidekiq でした。
管理者メニューでsidekiqを見てみると待機中のpushキューが8kありました。

対策は https://medium.com/@Gargron/scaling-mastodon-1becde463090 に書いてあったものです。

  • 「ネイティブスレッドじゃないRubyだから、1プロセス複数スレッド => 複数プロセス複数スレッド に分けた方がより速い」
  • 「4種類のキューを並列にうごかすと良い。defaultキューが捌けるのが早いとユーザレスポンスが向上する」

sidekiqのpushは先方のサーバの処理速度に影響されるものだし、やや重いのは仕方ないですね

作業としては docker-compose.yml を編集してsidekiqのコンテナを増やしました。

記述例 https://gist.github.com/tateisu/646969c6281c9ef99c24a765cbd701ef

sidekiq -c に指定する並列数ですが、この時点ではメールキューとpullは5、defaultとpush は20程度で不足ありませんでした。

そしてこの並列数の最大値にあわせて、.env.production に DB_POOL=20 を指定します。
DB_POOL環境変数mastodon/config/database.yml で参照されています。
railsプロセス内のDBコネクションプールの数を指定するものです。
プロセスごとに最大でこの数までのDB接続を保持します。

設定ファイルを書き換えたら docker-compose down --remove-orphan とか docker compose up -d を行うと反映されます。
dockerコンテナのイメージを作成するのに10分前後かかりました。

溜まったキューはこれで消化されて、CPU使用率もディスク使用率も大幅に下がりました。

キューが溢れる原因は?

Mastodonの場合はどんなインスタンスと連動するか事前には分かりません。中には「とりあえず動かしてみたけどすぐ止めた」ような感じで稼働が安定しておらず、キューのジョブが接続タイムアウトで終わるようなインスタンスもあります。
基本的には処理プロセス数やスレッド数を増やして、いくつかブロッキングしても残りで十分に回せるようにするしかなさそうです。
手動で何かする余裕があるなら、管理者メニューのsidekiqで再試行や死亡を見てダメそうなのは削除するのも良いと思います。

アクティブユーザ500人超 ?

docker 公式の nginx イメージは worker_processes 1 と worker_connections 1024 が指定されています。
ストリーミングAPIを多数抱えるとこれでは足りなくなるので、適当に増やしましょう。
worker_processes 3 、worker_connections 20000 くらいにしました。

もし nginx環境でulimit -n して 低い数字が出るなら、worker_rlimit_nofile も指定する必要があるかもしれません。

DHCPを使わずに無線LANに接続する

普通はDHCPクライアントが自動的にIPアドレスを取得してくれるが、今回はDHCPサーバにしたかったので dhcpcd5 と isc-dhcp-client をpurge してから以下の方法でネットワーク情報を設定した。

resolvconf のインストール

「dpkg -l |grep resolvconf」で確認して、もし入っていないなら「sudo apt-get install resolvconf 」でインストールする。
Raspbian Lite 等ではデフォルトではインストールされていない。
これで /etc/network/interfaces 中に dns-nameservers を記述できるようになる

/etc/network/interfaces の編集
source-directory /etc/network/interfaces.d

auto lo
iface lo inet loopback

auto eth0
iface eth0 inet manual

auto wlan0
allow-hotplug wlan0
iface wlan0 inet manual
    wpa-roam /etc/wpa_supplicant/wpa_supplicant.conf
    wireless-power off

allow-hotplug wlan1
iface wlan1 inet manual
    wpa-roam /etc/wpa_supplicant/wpa_supplicant.conf
    wireless-power off

iface default inet dhcp

iface home inet static
    address xxx.xxx.xxx.xxx
    network xxx.xxx.xxx.xxx
    netmask xxx.xxx.xxx.xxx
    broadcast xxx.xxx.xxx.xxx
    gateway xxx.xxx.xxx.xxx
    dns-nameservers xxx.xxx.xxx.xxx

ポイントはwpa-conf ではなく wpa-roamを使うことと、 ネットワーク情報を記述するインタフェースを別途追加しておくこと。
ついでに wireless-power off で無線LANの省電力機能を無効化している。実際に無効化されたかどうかは「iwconfig」コマンドで確認できる。

アクセスポイントのパスワードの暗号化

「wpa_passphrase (your_ssid_name)」を実行して標準入力にパスワードを打ち込むと次のような出力が得られる

network={
        ssid="(your_ssid_name)"
        #psk="**********"
        psk=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
}

"**********" の部分には生のパスワードが、 XXX... の部分には暗号化されたパスワードが入る。ダブルクオートの有無に注意。
暗号化されたパスワードだけコピペして、すぐにCTRL+Lで画面を消してしまおう。

/etc/wpa_supplicant/wpa_supplicant.conf の編集
country=JP
ctrl_interface=DIR=/var/run/wpa_supplicant GROUP=netdev
update_config=1

network={
        ssid="(your_ssid_name)"
        psk=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
        priority=0
        id_str="home"
}

ssidとpskの部分は先ほど得られた内容に置き換える。ダブルクオートの有無に注意。暗号化されたパスワードの前後にダブルクオートをつけてはいけない。
id_strには、 /etc/network/interfaces 中の、ネットワーク情報を記述したインタフェースの名前を指定する

java.net.PlainSocketImpl.finalize() timed out after 10 seconds

java.util.concurrent.TimeoutException: java.net.PlainSocketImpl.finalize() timed out after 10 seconds
at libcore.io.Posix.close(Native Method)
at libcore.io.BlockGuardOs.close( BlockGuardOs.java:101)
at libcore.io.IoBridge.closeAndSignalBlockedThreads( IoBridge.java:207)
at java.net.PlainSocketImpl.close( PlainSocketImpl.java:148)
at java.net.PlainSocketImpl.finalize( PlainSocketImpl.java:206)
at java.lang.Daemons$FinalizerDaemon.doFinalize( Daemons.java:190)
at java.lang.Daemons$FinalizerDaemon.run(Daemons.java:173)
at java.lang.Thread.run(Thread.java:818)

この例外、コールスタックからだとFinalizerDaemon.run() を動かしているスレッドから投げられたように見える。しかし https://android.googlesource.com/platform/libcore/+/master/libart/src/main/java/java/lang/Daemons.java を確認すると、実際にはこの例外はFinalizerDaemonからではなく java.lang.Daemons$FinalizerWatchdogDaemon#finalizerTimedOut()で生成されてThread.getDefaultUncaughtExceptionHandler().uncaughtException(...) に渡される。一見だとそう見えないのは、この例外のコールスタック情報はFinalizerDaemonのthread.getStackTrace()のもので上書きされているからだ。

FinalizerWatchdogDaemonはこの例外を uncaughtException()に渡した後は終了してしまう。

FinalizerDaemonはおそらくPosix.close(Native Method) でブロックしているが、これが一定時間内に復帰する保証はない。

アプリ側であらかじめソケットをクローズしておけばいいのかもしれないが、そのソケットというのはHttpUrlConnectionが内部に持つコネクションプールのものだ。このコネクションプールを明示的に制御する方法はよくわからない。

そもそも、10秒タイムアウトでアプリを落とすというのは、日本のモバイルネットワークだとやや短すぎるのではないかと思う。かといってFinalizerWatchdogDaemonのタイムアウト定数をアプリ側から変更できる仕組みは用意されていない。

対策としては、Thread.setDefaultUncaughtExceptionHandler() で設定したハンドラでこの種のエラーを検出したら何もせずにreturnすれば良いかもしれない。FinalizerDaemonがブロックし続けた場合はまた別のエラーがどこかで発生するだろう。

back stack を保持したまま現在のfragmentを入れ替える

素直に入れ替えると「back stackに記録された最後のフラグメントとcontainerに現在保持されているフラグメントがマッチしない」という問題が発生します。

back stack の内容を書き換えるのは難易度が高いので、別の対応を考えてみましょう。

  • back stack をpopする時に、現在のフラグメントがback stackに記録されていないならremoveする

コードは後述。これだけでもとりあえず動きますね。ライフサイクルイベントがこんな順序で発生します。

#----------------------------------------
# アプリを起動してフラグメントを1つ追加
(ActMain onApplyThemeResource)ActMain onApplyThemeResource
(ActMain onCreate(1arg)
	(ActMain onWindowAttributesChanged)
	(ActMain onWindowAttributesChanged)
	(ActMain onWindowAttributesChanged)
	(ActMain onWindowAttributesChanged)
	(ActMain onWindowAttributesChanged)
	(ActMain onContentChanged)
)ActMain onCreate(1arg)
(ActMain onStart
	(FragmentTest#1 onAttach)
	(ActMain onAttachFragment(support.Fragment))
	(FragmentTest#1 onCreate state=null,is_view_destroyed=false)
	(FragmentTest#1 onCreateView state=null,is_view_destroyed=false)
	(FragmentTest#1 onCreateAnimation)
	(FragmentTest#1 onViewCreated state=null,is_view_destroyed=false)
	(FragmentTest#1 onActivityCreated state=null)
	(FragmentTest#1 onViewStateRestored state=null)
	(FragmentTest#1 onStart)
)ActMain onStart
(ActMain onPostCreate(1arg)
	(ActMain onTitleChanged)
)ActMain onPostCreate(1arg)
(ActMain onResume)
(ActMain onPostResume
	(ActMain onResumeFragments
		(FragmentTest#1 onResume)
	)ActMain onResumeFragments
)ActMain onPostResume
(ActMain onAttachedToWindow)
(ActMain onWindowFocusChanged)
(ActMain onEnterAnimationComplete)

#----------------------------------------
# 状態の確認
(ActMain onUserInteraction)
backstack: count=0
getFragments: FragmentTest#1
findFragmentById: FragmentTest#1

#----------------------------------------
# 現在のフラグメントを差し替えて、バックスタックに追加する
(ActMain onUserInteraction)
(FragmentTest#1 onPause)
(FragmentTest#1 onStop)
(FragmentTest#1 onDestroyView)
(FragmentTest#1 onCreateAnimation)
(FragmentTest#2 onAttach)
(ActMain onAttachFragment(support.Fragment))
(FragmentTest#2 onCreate state=null,is_view_destroyed=false)
(FragmentTest#2 onCreateView state=null,is_view_destroyed=false)
(FragmentTest#2 onCreateAnimation)
(FragmentTest#2 onViewCreated state=null,is_view_destroyed=false)
(FragmentTest#2 onActivityCreated state=null)
(FragmentTest#2 onViewStateRestored state=null)
(FragmentTest#2 onStart)
(FragmentTest#2 onResume)

#----------------------------------------
# 状態の確認
(ActMain onUserInteraction)
backstack: count=1
backstack[0]: FragmentTest#2
getFragments: FragmentTest#1
getFragments: FragmentTest#2
findFragmentById: FragmentTest#2

#------------------------------------
# 現在のフラグメントを差し替えるが、バックスタックは変更しない
(ActMain onUserInteraction)
replace FragmentTest#2 => FragmentTest#3
(FragmentTest#2 onPause)
(FragmentTest#2 onStop)
(FragmentTest#2 onDestroyView)
(FragmentTest#2 onCreateAnimation)
(FragmentTest#3 onAttach)
(ActMain onAttachFragment(support.Fragment))
(FragmentTest#3 onCreate state=null,is_view_destroyed=false)
(FragmentTest#3 onCreateView state=null,is_view_destroyed=false)
(FragmentTest#3 onCreateAnimation)
(FragmentTest#3 onViewCreated state=null,is_view_destroyed=false)
(FragmentTest#3 onActivityCreated state=null)
(FragmentTest#3 onViewStateRestored state=null)
(FragmentTest#3 onStart)
(FragmentTest#3 onResume)

#----------------------------------------
# 状態の確認
(ActMain onUserInteraction)
backstack: count=1
backstack[0]: FragmentTest#2
getFragments: FragmentTest#1
getFragments: FragmentTest#2
getFragments: FragmentTest#3
findFragmentById: FragmentTest#3

#-------------------------------------------
# ホームキーを押した(1回目)
(ActMain onUserInteraction)
(ActMain onUserLeaveHint)
(ActMain onPause
	(FragmentTest#3 onPause)
)ActMain onPause
(ActMain onWindowFocusChanged)
(ActMain onCreateDescription)
(ActMain onSaveInstanceState
	(FragmentTest#1 onSaveInstanceState is_created=true,is_view_created=false,is_view_destroyed=true,is_resumed=false)
	(FragmentTest#2 onSaveInstanceState is_created=true,is_view_created=false,is_view_destroyed=true,is_resumed=false)
	(FragmentTest#3 onSaveInstanceState is_created=true,is_view_created=true,is_view_destroyed=false,is_resumed=false)
)ActMain onSaveInstanceState
(ActMain onStop
	(FragmentTest#3 onStop)
)ActMain onStop
(ActMain onTrimMemory)
(ActMain onDestroy
	(FragmentTest#1 onDestroy is_view_destroyed=true)
	(FragmentTest#1 onDetach)
	(FragmentTest#2 onDestroy is_view_destroyed=true)
	(FragmentTest#2 onDetach)
	(FragmentTest#3 onDestroyView)
	(FragmentTest#3 onDestroy is_view_destroyed=true)
	(FragmentTest#3 onDetach)
)ActMain onDestroy
(ActMain onDetachedFromWindow)

#----------------------------------------
# 履歴からアプリを復元
(ActMain onApplyThemeResource)
(ActMain onCreate(1arg)
	(FragmentTest#1 onAttach)
	(ActMain onAttachFragment(support.Fragment))
	(FragmentTest#1 onCreate state=(Bundle),is_view_destroyed=false)
	(FragmentTest#2 onAttach)
	(ActMain onAttachFragment(support.Fragment))
	(FragmentTest#2 onCreate state=(Bundle),is_view_destroyed=false)
	(FragmentTest#3 onAttach)
	(ActMain onAttachFragment(support.Fragment))
	(FragmentTest#3 onCreate state=(Bundle),is_view_destroyed=false)
	(ActMain onWindowAttributesChanged)
	(ActMain onWindowAttributesChanged)
	(ActMain onWindowAttributesChanged)
	(ActMain onWindowAttributesChanged)
	(ActMain onWindowAttributesChanged)
	(ActMain onContentChanged)
)ActMain onCreate(1arg)
(ActMain onStart
	(FragmentTest#3 onCreateView state=(Bundle),is_view_destroyed=false)
	(FragmentTest#3 onCreateAnimation)
	(FragmentTest#3 onViewCreated state=(Bundle),is_view_destroyed=false)
	(FragmentTest#3 onActivityCreated state=(Bundle))
	(FragmentTest#3 onViewStateRestored state=(Bundle))
	(FragmentTest#3 onStart)
)ActMain onStart
(ActMain onRestoreInstanceState(1arg))
(ActMain onPostCreate(1arg)
	(ActMain onTitleChanged)
)ActMain onPostCreate(1arg)
(ActMain onResume)
(ActMain onPostResume
	(ActMain onResumeFragments
		(FragmentTest#3 onResume)
	)ActMain onResumeFragments
)ActMain onPostResume
(ActMain onAttachedToWindow)
(ActMain onWindowFocusChanged)
(ActMain onEnterAnimationComplete)

#----------------------------------------
# 状態の確認
(ActMain onUserInteraction)
backstack: count=1
backstack[0]: FragmentTest#2
getFragments: FragmentTest#1
getFragments: FragmentTest#2
getFragments: FragmentTest#3
findFragmentById: FragmentTest#3

#----------------------------------------
# ホームキーを押した(2回目)
(ActMain onUserInteraction)
(ActMain onUserLeaveHint)
(ActMain onPause
	(FragmentTest#3 onPause)
)ActMain onPause
(ActMain onWindowFocusChanged)
(ActMain onCreateDescription)
(ActMain onSaveInstanceState
	(FragmentTest#1 onSaveInstanceState is_created=true,is_view_created=false,is_view_destroyed=false,is_resumed=false)
	(FragmentTest#2 onSaveInstanceState is_created=true,is_view_created=false,is_view_destroyed=false,is_resumed=false)
	(FragmentTest#3 onSaveInstanceState is_created=true,is_view_created=true,is_view_destroyed=false,is_resumed=false)
)ActMain onSaveInstanceState
(ActMain onStop
	(FragmentTest#3 onStop)
)ActMain onStop
(ActMain onTrimMemory)
(ActMain onDestroy
	(FragmentTest#1 onDestroy is_view_destroyed=false)
	(FragmentTest#1 onDetach)

	(FragmentTest#2 onDestroy is_view_destroyed=false)
	(FragmentTest#2 onDetach)

	(FragmentTest#3 onDestroyView)
	(FragmentTest#3 onDestroy is_view_destroyed=true)
	(FragmentTest#3 onDetach)
)ActMain onDestroy
(ActMain onDetachedFromWindow)

#----------------------------------------
# 履歴からアプリを復元
(ActMain onApplyThemeResource)
(ActMain onCreate(1arg)

	(FragmentTest#1 onAttach)
	(ActMain onAttachFragment(support.Fragment))
	(FragmentTest#1 onCreate state=(Bundle),is_view_destroyed=false)

	(FragmentTest#2 onAttach)
	(ActMain onAttachFragment(support.Fragment))
	(FragmentTest#2 onCreate state=(Bundle),is_view_destroyed=false)

	(FragmentTest#3 onAttach)FragmentTest#3 onAttach
	(ActMain onAttachFragment(support.Fragment))
	(FragmentTest#3 onCreate state=(Bundle),is_view_destroyed=false)

	(ActMain onWindowAttributesChanged)
	(ActMain onWindowAttributesChanged)
	(ActMain onWindowAttributesChanged)
	(ActMain onWindowAttributesChanged)
	(ActMain onWindowAttributesChanged)
	(ActMain onContentChanged)
)ActMain onCreate(1arg)
(ActMain onStart
	(FragmentTest#3 onCreateView state=(Bundle),is_view_destroyed=false )
	(FragmentTest#3 onCreateAnimation)
	(FragmentTest#3 onViewCreated state=(Bundle),is_view_destroyed=false )
	(FragmentTest#3 onActivityCreated state=(Bundle) )
	(FragmentTest#3 onViewStateRestored state=(Bundle) )
	(FragmentTest#3 onStart)
)ActMain onStart
(ActMain onRestoreInstanceState(1arg))
(ActMain onPostCreate(1arg)
	(ActMain onTitleChanged)
)ActMain onPostCreate(1arg)
(ActMain onResume)
(ActMain onPostResume
	(ActMain onResumeFragments
		(FragmentTest#3 onResume)
	)ActMain onResumeFragments
)ActMain onPostResume
(ActMain onAttachedToWindow)ActMain onAttachedToWindow
(ActMain onWindowFocusChanged)ActMain onWindowFocusChanged
(ActMain onEnterAnimationComplete)ActMain onEnterAnimationComplete

#------------------------------------------
# 状態の確認
(ActMain onUserInteraction)
backstack: count=1
backstack[0]: FragmentTest#2
getFragments: FragmentTest#1
getFragments: FragmentTest#2
getFragments: FragmentTest#3
findFragmentById: FragmentTest#3

#------------------------------------------
# 戻るキーを押した

(ActMain onUserInteraction )
(ActMain onKeyDown )
(ActMain onUserInteraction)
(ActMain onKeyUp
	(ActMain onBackPressed
page_pop: back stack name and current fragment are not match. remove current fragment.
	)ActMain onBackPressed
)ActMain onKeyUp

(FragmentTest#3 onPause )
(FragmentTest#3 onStop)
(FragmentTest#3 onDestroyView )
(FragmentTest#3 onCreateAnimation )

(FragmentTest#2 onDestroy is_view_destroyed=false )
(FragmentTest#2 onDetach )

(FragmentTest#1 onCreateView state=(Bundle),is_view_destroyed=false )
(FragmentTest#1 onCreateAnimation )
(FragmentTest#1 onViewCreated state=(Bundle),is_view_destroyed=false )
(FragmentTest#1 onActivityCreated state=(Bundle) )
(FragmentTest#1 onViewStateRestored state=(Bundle) )
(FragmentTest#1 onStart)
(FragmentTest#1 onResume)

(FragmentTest#3 onDestroy is_view_destroyed=true)
(FragmentTest#3 onDetach)

#------------------------------------------
# 状態の確認
(ActMain onUserInteraction)
backstack: count=0
getFragments: FragmentTest#1
getFragments: null
getFragments: null
findFragmentById: FragmentTest#1

#------------------------------------------
# 戻るキーを押した

(ActMain onUserInteraction)
(ActMain onKeyDown)

(ActMain onUserInteraction)
(ActMain onKeyUp
	(ActMain onBackPressed)
)ActMain onKeyUp

(ActMain onWindowFocusChanged)

(ActMain onPause
	(FragmentTest#1 onPause )
)ActMain onPause

(ActMain onStop
	(FragmentTest#1 onStop )
)ActMain onStop

(ActMain onDestroy
	(FragmentTest#1 onDestroyView )
	(FragmentTest#1 onDestroy is_view_destroyed=true )
	(FragmentTest#1 onDetach )
)ActMain onDestroy

(ActMain onDetachedFromWindow )

このアプローチは入れ替えの時点でback stackを操作しようとするよりもラクですが、問題もあります。

問題1: 入れ替えによって表示されなくなったフラグメントが back stack に保持され続ける

onDestroyView などのタイミングでビューへの参照を外すなどしてFragmentの消費メモリを軽減すると良いです。ただし onDestroyView が呼ばれた時点ではビューはまだ画面に表示されています。Bitmapの後処理を行うタイミングはFragment#onCreateAnimation で作成するアニメーションにセットするリスナーの onAnimationEnd イベントなどが適しているのかもしれません。

問題2: ホーム画面が再生成される際に、FragmentTransaction#setCustomAnimation() に指定した情報の一部が失われる

他アプリに移動せずに操作していると正常に動くのですが、他アプリに移動してまたテストアプリに戻ってきてから操作すると、popBackStack() した際のアニメーションが一部行われない場合がありました。

onCreateAnimation に渡される引数を追いかけてみるとこんな感じです。
バックスタックの頭のF#6と、バックスタックを更新しない入れ替えを行った F#3,F#5でnextAnimの情報が失われています。

# 初期配置。アニメーションは指定していない
(FragmentTest#1 onCreateAnimation transit=0,enter=true,nextAnim=0

# f#1 がexit, f#2がenter (通常の入れ替え)
(FragmentTest#1 onCreateAnimation transit=0,enter=false,nextAnim=2131034118
(FragmentTest#2 onCreateAnimation transit=0,enter=true,nextAnim=2131034121

# f#2 がexit, f#3がenter (back stackを更新しない入れ替え)
(FragmentTest#2 onCreateAnimation transit=0,enter=false,nextAnim=2131034118
(FragmentTest#3 onCreateAnimation transit=0,enter=true,nextAnim=2131034121

# f#3 がexit, f#4がenter (通常の入れ替え)
(FragmentTest#3 onCreateAnimation transit=0,enter=false,nextAnim=2131034118
(FragmentTest#4 onCreateAnimation transit=0,enter=true,nextAnim=2131034121

# f#4 がexit, f#5がenter (back stackを更新しない入れ替え)
(FragmentTest#4 onCreateAnimation transit=0,enter=false,nextAnim=2131034118
(FragmentTest#5 onCreateAnimation transit=0,enter=true,nextAnim=2131034121

# f#5 がexit, f#6がenter (通常の入れ替え)
(FragmentTest#5 onCreateAnimation transit=0,enter=false,nextAnim=2131034118
(FragmentTest#6 onCreateAnimation transit=0,enter=true,nextAnim=2131034121

# 状態の確認
backstack: count=3
backstack[0]: FragmentTest#2
backstack[1]: FragmentTest#4
backstack[2]: FragmentTest#6
getFragments: FragmentTest#1
getFragments: FragmentTest#2
getFragments: FragmentTest#3
getFragments: FragmentTest#4
getFragments: FragmentTest#5
getFragments: FragmentTest#6
findFragmentById: FragmentTest#6

# 他アプリから戻ってきた
(FragmentTest#6 onCreateAnimation transit=0,enter=true,nextAnim=0

# 他アプリから戻ってきた
(FragmentTest#6 onCreateAnimation transit=0,enter=true,nextAnim=0

# f#6 がexit, f#5がenter
(FragmentTest#6 onCreateAnimation transit=0,enter=false,nextAnim=0
(FragmentTest#5 onCreateAnimation transit=0,enter=true,nextAnim=0

# f#5 がexit, f#3がenter
(FragmentTest#5 onCreateAnimation transit=0,enter=false,nextAnim=2131034119
(FragmentTest#3 onCreateAnimation transit=0,enter=true,nextAnim=0

# f#3 がexit, f#1がenter
(FragmentTest#3 onCreateAnimation transit=0,enter=false,nextAnim=2131034119
(FragmentTest#1 onCreateAnimation transit=0,enter=true,nextAnim=0

対策ですが、fragment を pop した直後だけ onCreateAnimation でnextAnimを適切に補完できるようにします。今回は以下のようなことを行いました。

  • Fragment#toString() をオーバライドして、フラグメントのインスタンスを特定できるような名前を返す
  • addToBackStack(name) に指定する名前を、除去したフラグメントと追加したフラグメントの名前を含めたパターンにする
  • popBackStackする直前にBackStackEntry#getName()でそのパターンを取り出し、補完するアニメーションのリソースIDをフラグメントに渡しておく
  • フラグメントのonCreateAnimation では、もしリソースIDが渡されていたらそれを使ってアニメーションを生成する。渡されたリソースIDはリセットして、別のタイミングで呼びだされた時に使わないようにする

この問題はこれで解決したようです。

コード例

// Fragment 側


static final String ARG_NAME = "name";
static final FragmentTest create(int idx){
	Bundle b = new Bundle();
	b.putString( ARG_NAME, String.format( "%s", idx ) );
	FragmentTest fragment = new FragmentTest();
	fragment.setArguments( b );
	return fragment;
}

@Override public String toString(){
	return this.getClass().getSimpleName() + "#" + getArguments().getString( ARG_NAME );
}

int nextAnim = 0;
public void setNextAnimation( int nextAnim ){
	this.nextAnim =nextAnim;
}

// Called when a fragment loads an animation.
public Animation onCreateAnimation( int transit, boolean enter, int nextAnim ){
	if( DEBUG_LIFECYCLE ) log.d( "(%s onCreateAnimation transit=%s,enter=%s,nextAnim=%s", toString() ,transit,enter,nextAnim);
	Animation rv;
	if( nextAnim == 0 && this.nextAnim != 0){
		log.d("supply next Anim: %s",this.nextAnim);
		rv = AnimationUtils.loadAnimation( getActivity(), this.nextAnim );
		this.nextAnim = 0;
	}else{
		rv = super.onCreateAnimation( transit, enter, nextAnim );
	}
	return rv;
}

// Activity側
int index_seed = 0;
static final String STATE_INDEX_SEED="index_seed";

@Override protected void onCreate( Bundle state ){
	is_created = true;
	super.onCreate( state );
	setContentView( R.layout.act_main );
	if( state == null ){
		getSupportFragmentManager().beginTransaction()
			.add( R.id.container, FragmentTest.create(++index_seed) )
			.commit();
	}else{
		index_seed = state.getInt(STATE_INDEX_SEED);
	}
}

@Override protected void onSaveInstanceState( Bundle state ){
	super.onSaveInstanceState( state );
	state.putInt(STATE_INDEX_SEED,index_seed);
}

@Override public void onBackPressed(){
	// XXX: may you want to dispatch this event to fragment ?

	if( getSupportFragmentManager().getBackStackEntryCount() > 0){
		page_pop();
	}else{
		super.onBackPressed();
	}
}

void page_push(){
	FragmentManager fm = getSupportFragmentManager();

	FragmentTest f_new = FragmentTest.create(++index_seed);
	Fragment f_old = fm.findFragmentById( R.id.container );
	String stack_name = (f_old==null?"null": f_old.toString() +"=>" + f_new.toString() );

	getSupportFragmentManager().beginTransaction()
		.setCustomAnimations( // enter,exit,popEnter,popExit 後ろ2つは popBackStack の際に使われる
			R.anim.right_to_center,
			R.anim.center_to_left,
			R.anim.left_to_center,
			R.anim.center_to_right
		)
		.replace( R.id.container, f_new )
		.addToBackStack( stack_name )
		.commit();
}

void page_replace(boolean back){
	FragmentManager fm = getSupportFragmentManager();

	Fragment f_old = fm.findFragmentById( R.id.container );
	Fragment f_new = FragmentTest.create( ++ index_seed );
	log.d( "replace %s => %s", f_old, f_new );

	FragmentTransaction ft = fm.beginTransaction();
	if( back ){
		ft.setCustomAnimations( // enter,exit,popEnter,popExit 後ろ2つは popBackStack の際に使われる
			R.anim.left_to_center,
			R.anim.center_to_right,
			R.anim.right_to_center,
			R.anim.center_to_left
		);
	}else{
		ft.setCustomAnimations( // enter,exit,popEnter,popExit 後ろ2つは popBackStack の際に使われる
			R.anim.right_to_center,
			R.anim.center_to_left,
			R.anim.left_to_center,
			R.anim.center_to_right
		);
	}
	ft.replace( R.id.container, f_new )
		.commit();
	// 結果として、最後のbackstackで追加されたfragmentと現在のフラグメントが食い違うことになる。
	// これをこの時点で解決することはできないので、pop_pageで処理する
}

public boolean page_pop(){
	FragmentManager fm = getSupportFragmentManager();
	//
	FragmentManager.BackStackEntry entry = fm.getBackStackEntryCount() == 0 ? null : fm.getBackStackEntryAt( fm.getBackStackEntryCount() - 1 );
	if( entry == null ){
		log.d( "page_pop: back stack is empty." );
		return false;
	}

	// BackStackに記録されているFragment
	Fragment f_backstack_old =null;
	Fragment f_backstack_current =null;
	Matcher m = Pattern.compile( "(.+?)=>(.+?)" ).matcher( entry.getName() );
	if( m.find() ){
		f_backstack_old = getFragmentByName( m.group( 1 ) );
		f_backstack_current = getFragmentByName( m.group( 2 ) );
	}

	//Activityの破棄と復元によりアニメーションが失われた場合に補う
	if( f_backstack_old != null ) ((FragmentTest)f_backstack_old).setNextAnimation( R.anim.left_to_center );
	if( f_backstack_current != null ) ((FragmentTest)f_backstack_current).setNextAnimation( R.anim.center_to_right );

	// 現在container に格納されているFragment
	Fragment f_current = fm.findFragmentById( R.id.container );
	// 現在container に格納されているFragmentがバックスタックに記録されている追加したFragmentと異なる場合は、アニメーションつきで除去する
	if( f_current != null && f_current != f_backstack_current ){
		log.d( "page_pop: current fragment mismatch between back stack and holded in container. remove fragment in container." );
		fm.beginTransaction()
			.setCustomAnimations( // enter,exit,popEnter,popExit 後ろ2つは popBackStack の際に使われる
				R.anim.left_to_center,
				R.anim.center_to_right,
				R.anim.right_to_center,
				R.anim.center_to_left
			)
			.remove( f_current )
			.commit();
	}

	// バックスタックをpopする
	fm.popBackStack();

	return true;
}


private FragmentTest getFragmentByName( String name ){
	for( Fragment f : getSupportFragmentManager().getFragments()){
		if(f ==null ) continue;
		if( f instanceof FragmentTest ){
			if( f.toString().equals( name ) ) return (FragmentTest) f;
		}
	}
	return null;
}

その他

今回の話題とは直接関係ありませんが、上のログを記録した時は開発者オプションで「アクティビティを保持しない」を指定していました。ホームボタンを押すとアクティビティが破棄されて、履歴から戻った際に作成と復元が行われます。再度ホームボタンを押すとまたアクティビティが破棄されるのですが、この間、back stack にだけ存在する fragment に対して呼び出されるライフサイクルイベントは onAttach, onCreate, onSaveInstanceState, onDestroy, onDetach だけです。つまりonCreateで全ての状態をsavedInstanceStateから取得してonSaveInstanceStateでそれを保存しないと、状態が失われます。なんと恐ろしい。

Nexus 7 (2013)からの動画キャプチャ

ゲームの録画をしたくなったので動画キャプチャ環境を整えたなど。最初はChromecastの出力をHDMIスプリッターに繋いだけど、やはり帯域の問題で、Chromecast に出力した時点で画質が結構劣化してた。有線のHDMI出力アダプターの方が明らかに鮮明だった。

録画サンプルと機器の詳細をメモしておくよ

録画サンプル

(A) N7 2013 → Chromecast → HDMI スプリッタ → HDMI キャプチャ

http://juggler.jp/game-prru/video/capture-via-chromecast.mp4

(B) N7 2013 → HDMI 出力アダプタ → HDMI スプリッタ → HDMI キャプチャ

http://juggler.jp/game-prru/video/capture-via-hdmi-adapter.mp4

Windows ムービーメーカーで切り出して再エンコードしたもの
Bの方が明らかに良いね

機器の詳細

SlimPort 用 HDMI 出力アダプタ

microUSBから映像を出力する規格はMHLとSlimPortがあり、GalaxyやXperia系はMHL、Nexus系はSlimPort らしい。それぞれ異なる出力アダプタが必要になる。

http://www.amazon.co.jp/gp/product/B00I1C6RZ6 Nexus対応 SlimPort to HDMI 変換アダプタ 【Nexus7(2013動作確認済】 〔添付品 : HDMI ケーブル・充電用ケーブル〕

メーカーのVodaviewがAmazon経由で販売しているもの。充電用USBケーブルとHDMIケーブル付きで2789円だった。画面の縦横は自動的に処理される。Full HD を縦画面でそのまま…というモードはないみたい

HDMI スプリッター

HDCP 周りの問題を解決するためにHDMIスプリッターを挟む。

http://www.amazon.co.jp/gp/product/B003U0M3BQ LEICKE KanaaN 1入力2出力 対応HDMI 分配器 スプリッター Y-アダプタ 1080p FullHD1.3b 2-port/ 1 to 2

SPD Japan出品の純正品。HDMIケーブル1本+ACアダプタ付きで2599円だった。バスパワー動作は出来なかった。

HDMI キャプチャデバイス

HDMI 信号の画像と音声をPCにキャプチャする機器。まあ何でも良いと思うんだけど、今回使ったのはコレ

http://www.amazon.co.jp/gp/product/B00MIQ40JQ Elgato(エルガト)Elgato Game Capture HD60 (日本語ソフトウェア/正規輸入品)

Elgato Game Capture HD60

Elgato Game Capture HD60

メーカーのElgatoがAmazon経由で販売しているもの。18590円だった。1080p/60fps 対応、HDMI パススルー出力あり。キャプチャ時のエンコード品質は標準(14Mbps,6.4GB/時)から最高(40Mbps,18.1GB/時)まで調節可能。