Toastの再利用

Androidで、サービス等のUIを持たないコンポーネントから画面にテキストを表示するToastというAPIがある。

割と昔からあるものなので挙動も同じだろうと思っていたら、某社の端末(未発売)でトラブルがあった。
その端末のバグっぽい印象だったので割と邪道な対応で済ませたが、それについてはあまり詳しく書けない。

ついでに手持ちの他の端末でも簡単なテストを行ってみた。

  • トースト を表示して4秒経過したらトーストをキャンセルして、トーストへの参照をnullで上書きする。
  • トースト を前回表示してから4秒未満で再度トーストを表示した時に、以下のいずれかの処理を行う。
    • キャンセルした後に再利用(setTextして再度show)
    • キャンセルせずに再利用(setTextして再度show)
    • キャンセルした後に新しくmakeTextしてshow
    • キャンセルせずに新しくmakeTextしてshow

f:id:tateisu:20181109234422j:plain

結果

キャンセルして再利用
    • 4.4.2~9.0 でうまくいかない。文章は更新されるが、最初のトーストが表示されてから4秒で消えてしまう。
キャンセルせずに再利用
    • 4.4.2の端末ではOK。最後に文章を更新してshowしてから4秒でトーストが消える。
    • 7.1.1の端末ではNG。文章は更新されるが、最初のトーストが表示されてから4秒で消えてしまう。
    • 8.0.0の端末ではNG。文章は更新される(+トーストの表示がちらつく)が、最初のトーストが表示されてから4秒で消えてしまう。
    • 9.0.0の端末ではNG。文章は更新される(+トーストの表示がちらつく)が、連打するとすぐにトーストが消えてしまう。
キャンセルした後に新しくmakeTextしてshow
    • 特に問題は見られなかった
キャンセルせずに新しくmakeTextしてshow
    • 端末によってはトーストがキューに溜まり遅延が発生する

コード例

package jp.juggler.toastsample

import android.os.Bundle
import android.os.Handler
import android.support.v7.app.AppCompatActivity
import android.util.Log
import android.view.Gravity
import android.view.View
import android.widget.Toast

class MainActivity : AppCompatActivity() {
	
	companion object {
		private const val LOGTAG = "ToastSample"
	}
	
	lateinit var handler : Handler
	
	lateinit var btnCancel : View
	
	private var nCount = 0
	
	private var lastToast : Toast? = null
	
	override fun onCreate(savedInstanceState : Bundle?) {
		super.onCreate(savedInstanceState)
		setContentView(R.layout.activity_main)
		handler = Handler()
		
		btnCancel = findViewById<View>(R.id.btnCancel)
		btnCancel.setOnClickListener { toastRemover.run() }
		btnCancel.isEnabled = false
		
		
		findViewById<View>(R.id.btnShow1).setOnClickListener { showToast(1) }
		findViewById<View>(R.id.btnShow2).setOnClickListener { showToast(2) }
		findViewById<View>(R.id.btnShow3).setOnClickListener { showToast(3) }
		findViewById<View>(R.id.btnShow4).setOnClickListener { showToast(4) }
	}
	
	private val toastRemover : Runnable = object : Runnable {
		override fun run() {
			handler.removeCallbacks(this)
			lastToast?.cancel()
			lastToast = null
			btnCancel.isEnabled = false
		}
	}
	
	private fun showToast(type : Int) {
		try {
			val gravity = Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL
			val text = "Toast ${++ nCount}"
			val duration = Toast.LENGTH_LONG
			var toast = lastToast
			if(toast != null) {
				
				when(type) {
					1 -> {
						// キャンセルして再利用
						toast.cancel()
						toast.setText(text)
						toast.duration = duration
					}
					
					2 -> {
						// キャンセルせずに再利用
						toast.setText(text)
						toast.duration = duration
					}
					
					3 -> {
						// キャンセルして新しく作成
						toast.cancel()
						toast = Toast.makeText(this, text, duration)
						lastToast = toast
					}
					
					4 -> {
						// キャンセルせず新しく作成
						toast = Toast.makeText(this, text, duration)
						lastToast = toast
					}
				}
				toast?.setGravity(gravity, 0, 0)
				toast?.show()
			} else {
				toast = Toast.makeText(this, text, duration)
				lastToast = toast
				toast.setGravity(gravity, 0, 0)
				toast.show()
			}
			handler.removeCallbacks(toastRemover)
			handler.postDelayed(toastRemover, 4000L)
			btnCancel.isEnabled = true
		} catch(ex : Throwable) {
			Log.e(LOGTAG, "showToast failed.", ex)
		}
	}
}

ToastCompat

API level 25 以上で BadTokenException が出る不具合は ToastCompat https://github.com/drakeet/ToastCompat を使うと改善する。

KotlinのCoroutineScopeのメモ

やっとexperimental が外れたのでボチボチ使っていきたい。

https://github.com/Kotlin/kotlinx.coroutines/blob/master/coroutines-guide.mdhttp://kotlinlang.org/docs/reference/coroutines/exception-handling.html を読みながら理解しようとしたが、サンプルを見ても「なぜこういう挙動になるのか」が分からないのでソースコードを読んでみた。

コルーチンとは何か

コルーチンは停止(yield)と再開(resume)が可能な実行単位である。スレッドと異なり停止中はリソースを占有しないように考慮されているし、Dispatcherが抽象化されているので、必要ならAndroidのメインスレッド上でコルーチンを動かすこともできる。

意外なことに、Coroutine そのものが名づけられた クラスやインタフェースは 存在しない。存在するのは Continuation インタフェースと AbstractCoroutine抽象クラス(後述)である。

コルーチンの開始。CoroutineStart.invoke() の内部から startCoroutine が呼ばれ、組み込み関数の createCoroutineなんたら() により 「サスペンド可能なラムダ式」が「Continuationインスタンス」に変わり、 拡張関数 resumeなんたら(Unit) が呼ばれてコルーチンの処理が開始する。Continuation がもし DispatchedContinuation なら resume 時にDispatcherを利用する。

コルーチンがどのスレッドで実行されるかはDispatcherによって異なる。 runBlocking{} は呼び出し元スレッドでイベントループを回す。 launch{},async{}はcontextに指定されたDispatcherがなければ Dispatchers.Default を使う。Dispatchersには数種類のDispatcherが定義されている。

Continuation の context プロパティについて。 JVM実装では createCoroutineFromSuspendFunction()
https://github.com/JetBrains/kotlin/blob/master/libraries/stdlib/coroutines/jvm/src/kotlin/coroutines/intrinsics/IntrinsicsJvm.kt#L155 によると completion 引数のcontext が使われるらしい。

CoroutineContext

https://kotlinlang.org/api/latest/jvm/stdlib/kotlin.coroutines/-coroutine-context/index.html

コルーチン(Continuation)は実行に必要な情報をCoroutineContextに保持する。CoroutineContextは複数の属性を持つ。Job や Dispatcher などが設定される。

  • 属性のキーは CoroutineContext.Key である。参照アドレスでの比較が行われる。
  • 属性の値は CoroutineContext.Element である。ElementそのものもCoroutineContextである。
  • キーの定数はJobやContinuationInterceptorのコンパニオンオブジェクトが使われる
  • EventLoopBase は CoroutineDispatcher である。 CoroutineDispatcher は ContinuationInterceptor である。
  • CoroutineContext は合成できる。左辺の属性の一部が右辺の属性で上書きされる。
  • 派生クラス CombinedContextなど

kotlin.coroutines パッケージに coroutineContext という suspend property が定義されている。
coroutine か suspend function からのみ suspend property にアクセスできる。
suspend fun の内部から現在のCoroutineContextを取得することができる。

Job

https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/-job/

Jobはコルーチンの実行状態を示す。キャンセルや完了待機などの状態を持つ。

JobはCoroutineContextの一部として扱われるため、Job のベースクラスは CoroutineContext.Element と CoroutineContext である。Job どうしを合成することは可能だが、意味がないので合成結果は右辺そのものとなる。

Jobオブジェクトを扱う機会

  • キャンセルのための親ジョブを作る時
  • launch{} や async{} の戻り値

Jobオブジェクトへの操作

  • join(), joinAll(), cancel(), cancelAndJoin()

Jobには親子関係がある。
コード中のコメントから引用

coroutineContext内のJobインスタンスは、コルーチン自体を表します。
ジョブは親ジョブを持つことができます。
親を持つジョブは、その親が取り消されるとキャンセルされます。
親ジョブは すべての子プロセスが完了するまで_completing_ または _cancelling_ 状態で待機します。
_completing_状態は、ジョブの純粋な内部状態であることに注意してください。
_completing_ ジョブは外部から観察したらまだ _active_ なように見えますが、内部的には子たちのために待機しています。

ジョブの失敗と通常のキャンセルはキャンセル例外のcauseの種別により区別されます。
キャンセル例外のcauseが[CancellationException]の場合、ジョブ通常のキャンセルされたと解釈されます。
追加パラメータなしでキャンセルが呼び出された時にこれは発生します。
もしキャンセル例外のcauseがその他の例外だった場合、ジョブは失敗したと解釈されます。
ジョブのコードで問題が起きて例外が投げられた時にこれは発生します。

Jobインタフェースと派生した全てのインタフェースの関数はスレッドセーフであり
外部の同期なしで並行するコルーチンから安全に呼び出すことができます。

キャンセル時の例外ハンドリングではcauseも表示しておくべきか。

Job() ヘルパ関数は JobImpl(parent=null) を作成する。JobImpl はJobSupportである。
JobSupport クラスには cancelsParent プロパティがあり、キャンセル発生時に親をキャンセルするかどうかを決定する。
JobImpl cancelsParent=true, onCancelComplete=true, handlesException=false のプロパティがJobSupportと異なる。

コード中のコメントから引用

private fun cancelParent(cause: Throwable): Boolean {
// (causeが)CancellationExceptionなのは "通常"と解釈され、子供がそれを生成した場合は親はキャンセルされない。
// これにより、子がクラッシュしてその完了時に他の例外が生成されない限り、親は自身をキャンセルせずに子を(通常の)キャンセルすることができます。
...
}

Structured concurrency https://medium.com/@elizarov/structured-concurrency-722d765aa952 の正体は、Jobの親子関係と JobSupport の cancelsParent プロパティだった。

CoroutineScope

https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/-coroutine-scope/

CoroutineScope は通常、ラムダ式へのレシーバ変数として提供される。

  • coroutineContextプロパティへのアクセス
  • 拡張関数 actor, async, broadcast, launch, newCoroutineContext, plus, produce, promise
  • 派生クラス GlobalScope, ActorScope, ProducerScope
  • CoroutineScope + CoroutineContext でスコープを合成できる

スコープの絶縁

runBlocking,coroutineScope,launch,async等のスコープ作成関数で作成したスコープ(ジョブ)は、その内部で作成した子スコープ(ジョブ)が全て終了するまでは終了しない。これはこれで便利なのだが、ライフサイクル的には親子のスコープ(ジョブ)を絶縁したい場合がある。

間違った例 coroutineScope{}

coroutineScope{} はコード外側のコンテキストからJob以外を継承してJobを新しくしたスコープを作成してblockを実行する。内部のJobのキャンセルがcoroutineScopeよりも外側に影響しないので絶縁できたかのように思えるが、他のスコープ作成関数と同様、coroutineScope{}自体は内部で作成した子ジョブが全て終了するまでブロックしてしまう。

val producer = coroutineScope{ produce<Int>{  (1..3).forEach { channel.send(it) } } }

この例は外側のスコープとproducerを分離しようとしているが、coroutineScope{} は produceのコードブロックが完了するまでブロックする。この時点ではproducer変数からデータを読む処理は開始しないので、ここで永遠にブロックしてしまう。

正しい例1 GlobalScope

GlobalScopeはEmptyCoroutineContext を持つグローバル定数。JobもDispatcherも割り当てられていない。
CoroutineScope(context) と同様、親スコープと子ジョブを分離する際に使われる。

val producer = GlobalScope.produce<Int>{ (1..3).forEach { channel.send(it) } } 

この例ではGlobalScopeを親にすることで、コードの外側のスコープとproducerのスコープを完全に分離する。
実際には必要に応じてproduceの引数にDispatcherやJob等のコンテキストを追加する。

正しい例2 CoroutineScope(context) ヘルパ関数 と SupervisorJob()

https://github.com/Kotlin/kotlinx.coroutines/blob/master/docs/exception-handling.md#supervision

val supervisor = SupervisorJob()
with(CoroutineScope(coroutineContext + supervisor)) {
	val producer = produce<Int>{ (1..3).forEach { channel.send(it) } }
}

CoroutineScope() はコードの外側のスコープの影響を受けない。
また、もし引数のコンテキストにJobが含まれなければ空のジョブを追加する。

スコープを絶縁した後の注意

スコープの絶縁はコルーチンをリークさせる可能性がある。上の例ではproducer 変数のチャネルはcapacity=0 で作成されるため、チャネルからデータが読み取られるまでsend() の部分でブロックする。作成したコルーチンのライフサイクルをプログラマが管理する必要がある。

AbstractCoroutine

https://github.com/Kotlin/kotlinx.coroutines/blob/master/common/kotlinx-coroutines-core-common/src/AbstractCoroutine.kt

@InternalCoroutinesApi なのでユーザがこのクラスを直接利用することはないが書いておく。

runBlocking などのレシーバ変数(is CoroutineScope) は実際には BlockingCoroutine 等の、AbstractCoroutine の派生クラスである。

AbstractCoroutine はJobSupportであり、Jobであり、Continuationであり、CoroutineScope である。
1つのクラスに複数の役割が持たせられている理由はおそらくオブジェクト確保を減らすためだろう。
Continuation 以外にコルーチン個別の実行制御に必要な情報は全て AbstractCoroutine に入っている訳だ。

AbstractCoroutine作成時の引数 parentContext の指定は runBlocking() 等ではデフォルト値 EmptyCoroutineContext となっている。AbstractCoroutineのJobSupportには cancelsParent=true が設定されているので、もし parentContextが存在すればブロック内部で発生したキャンセルは親に伝達される。

asyncとlaunch

https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/async.html
https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/launch.html

launchはコルーチンを開始してJobを返す。
asyncはコルーチンを開始してDeferredを返す。DeferredはJobであるが戻り値の扱いが多少異なる。

asyncとlaunchはどちらも newCoroutineContext() を使って外側のコンテキストを引き継ぎ、指定がなければディスパッチャをDispatchers.Defaultにする。親Jobがあればキャンセル発生時にキャンセルの伝達が起きる。
newCoroutineContext() は外側のコンテキストと引数(省略時はEmptyCoroutineContext)のコンテキストを合成し、ディスパッチャの指定がなければDispatchers.Defaultを補う。

asyncとlaunch はそっくりだが少しだけ異なる。

  • launch は StandaloneCoroutine か LazyStandaloneCoroutine を生成、startしてそれを返す
  • async は DeferredCoroutine かLazyDeferredCoroutine を生成、startしてそれを返す

そして StandaloneCoroutine と DeferredCoroutine もそっくりだ。

  • どちらもAbstractCoroutineである。cancelsParent=trueに設定される。
  • DeferredCoroutine はDeferred, SelectClause1 である。await() と onAwait と registerSelectClause1() が用意される。

CoroutineExceptionHandler

launch で非同期処理を行う際に CoroutineExceptionHandler というCoroutineContextを追加することで例外を補足できる。
しかし実際に試してみると Structured Concurrency と相性が悪く、親スコープが例外を処理してしまうとそこで例外が再送出されてしまう。
直接の親のジョブをSupervisorJobにするだけでは足りない。 GlobalScope、親ジョブのないCoroutineScope(SupervisorJob)、SupervidorScopeなどを使う。

val handler = CoroutineExceptionHandler { _, exception ->
    println("handler caught $exception")
    println("thread ${Thread.currentThread().name}")
}

val supervisor = SupervisorJob()

// NG
// coroutineScope {
//    launch(handler) {
//        error("inside launch1")
//    }
//    delay(1000)
// }

// NG
// runBlocking(supervisor) {
//    launch(handler) {
//        error("inside launch2")
//    }
//    delay(1000)
// }

// OK
GlobalScope.launch(handler) {
    error("inside launch3")
}
delay(1000)

// OK
with(CoroutineScope(supervisor)){
    launch(handler) { error("inside launch4") }
    delay(1000)
}

supervisorScope{
    launch(handler) { error("inside launch5") }
    delay(1000)
}

Docker構成のMastodonに組み込んだpgbouncerの監視をmuninに追加する

「Docker構成のMastodonに後からpgBouncerを組み込む http://d.hatena.ne.jp/tateisu/20170418/1492457904 」の続きです。

https://github.com/munin-monitoring/contrib/blob/master/plugins/postgresql/pgbouncer_ を使ってみたというただそれだけの記事です。

pgbouncer への接続に必要な情報を確認する

まず mastodonの.env.productionに指定ているDB接続情報を確認します

DB_HOST=db
DB_USER=bouncer
DB_NAME=postgres
DB_PASS=XXXXXXXXXXXXXXXXX
DB_PORT=5432
DB_POOL=20

この例では DB_HOSTが'db' になっています。
これはdockerが内部で管理するホスト名なのでdockerの外からはそのままではアクセスできません。
pgbounderを動かしてるコンテナのポートマッピングを確認します。

$ docker ps
(略)
XXXXXXXX gavinmroy/alpine-pgbouncer "/bin/sh -c /start.sh" (略) 172.17.0.1:5432->5432/tcp, 6432/tcp mastodon1_db_1
(略)

この例では 172.17.0.1:5432 に接続できそうです

pgbouncer に管理ユーザを追加する

pgboucer には管理用の疑似データベース pgbouncer があります。
そのデータベースに接続すると管理用コマンドを使えます。

pgbouncer の userlist.txt に下記の行を追加
"bouncer_admin" "XXXXXXXXXXXXXXXXXX"

パスワード部分は適当に生成するなりダイジェスト化するなりしてください

pgbouncerのpgbouncer.ini を編集
; comma-separated list of users, who are allowed to change settings
;admin_users = user2, someadmin, otheradmin
admin_users = bouncer_admin
pgbouncer にHUPシグナルを送って設定をリロード
pkill -HUP pgbouncer

接続プールの名前を確認する

psql -h 172.17.0.1 -p 5432 -U bouncer_admin pgbouncer -c 'show pools;'
(略)
 database  |   user    | cl_active | cl_waiting | sv_active | sv_idle | sv_used | sv_tested | sv_login | maxwait |  pool_mode  
-----------+-----------+-----------+------------+-----------+---------+---------+-----------+----------+---------+-------------
 pgbouncer | pgbouncer |         1 |          0 |         0 |       0 |       0 |         0 |        0 |       0 | statement
 postgres  | postgres  |        65 |          0 |         0 |       3 |       4 |         0 |        0 |       0 | transaction
(2 rows)

この例だと postgres が接続プールの名前になるようです

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

https://github.com/munin-monitoring/contrib/blob/master/plugins/postgresql/pgbouncer_
をRAW表示して/etc/munin/plugins にコピーしてchmod 755 します。
また、ファイル名は pgbounder_(プール名) にしておきます

pgbouncer プラグインの設定

/etc/munin/plugin-conf.d/munin-node に以下のセクションを追加します

[pgbouncer_postgres]
env.pgbouncer_host 172.17.0.1
env.pgbouncer_port 5432
# env.pgbouncer_pool postgres
env.pgbouncer_user bouncer_admin
env.pgbouncer_pass XXXXXXXXXXXXXXXXXX

セクション名の部分は pgbouncer プラグイン のファイル名と揃えます。
接続情報の部分は 接続プールの名前を確認した時の情報と揃えます。
env.pgbouncer_pool はスクリプト名末尾と実際のプール名が異なる場合だけ指定するのが良いようです。

動作確認

以下のコマンドを実行してプラグインの動作を確認します。

munin-run pgbouncer_postgres config
munin-run pgbouncer_postgres

私の環境ではパッケージの追加インストールが必要でした。

apt install libdbd-pg-perl 

munin-nodeの再起動

sudo /etc/init.d/munin-node try-restart

今すぐグラフを生成する

su - munin --shell=/usr/bin/munin-cron

作成直後は数値がNaNになるみたいですが、5-10分ほど待ってリロードすると解決します

Docker構成のMastodonでImageMagickのポリシーを変えてGhostscriptの脆弱性に対応する

Ghostscript に緊急レベルの脆弱性、悪用攻撃も発生
https://japan.zdnet.com/article/35100535/

MastodonではImageMagick経由でGhostscriptが呼び出される場合があります。

今回はImageMagickの設定ファイルを変更することでGhostscriptの呼び出しを回避します。

また、今回は行いませんが ImageMagick の policy.xml ではメモリリソースの使用量の調整も可能です。

コンテナからImageMagickの設定フォルダを取り出す

cd (docker-compose.yml のあるフォルダ)
docker cp mastodon1_web_run_1:/etc/ImageMagick-7 .
mv ImageMagick-7 etc-ImageMagick-7

policy.xml を編集

emacs ./etc-ImageMagick-7/policy.xml

policy.xml のコメント部分におすすめの設定が書いてあります

  Rules are processed in order.  Here we want to restrict ImageMagick to only
  read or write a small subset of proven web-safe image types:
    <policy domain="delegate" rights="none" pattern="*" />
    <policy domain="coder" rights="none" pattern="*" />
    <policy domain="coder" rights="read|write" pattern="{GIF,JPEG,PNG,WEBP}" />

この3行を の手前にコピペします

変更した設定フォルダをボリュームに指定する

docker-compose.yml を編集します

web とsidekiqのコンテナのvolumes の指定に追加します

      - ./etc-ImageMagick-7:/etc/ImageMagick-7

再起動

一度コンテナを削除してから作り直すため、まだDBの永続化を行っていない方は行っておいてください。少し前の記事で説明しています。 http://d.hatena.ne.jp/tateisu/20170416/1492341633

dockerのコンテナを再起動します。

# stop じゃなくてdown。コンテナはrmされる
docker-compose down

# 起動
docker-compose up -d

sidekiq コンテナを増やしている方は docker-compose scale もお忘れなく

Subway Tooter

Subway Tooter
https://play.google.com/store/apps/details?id=jp.juggler.subwaytooter

Android用のMastodonクライアントアプリです。
2017年4月23日に Playストアで公開しました。
(某所の「アプリを最速でリリースした話」よりさらに早い&速い)
当時の痕跡 https://mastodon.juggler.jp/@tateisu/450607

マルチカラム

公開当時から複数アカウントに対応してます。アカウントの切り替え操作も不要。画面を左右にフリックして複数のカラムを閲覧できます。カラムは好きなように追加/削除できます。並び順も変更可能です。これはTweetDeckやJanetterやtwitcle plusに似た操作性ですね。

絵文字対応

絵文字対応も頑張りました。私はMastodonの自分のアカウントの表示名に :juggling: などの絵文字を設定しているのですが、これを絵文字に変換して表示してくれるスマホアプリはまだSubway Tooterだけだと思います。

cross-account operation

複数アカウント対応の応用として「別のアカウントでフォロー」「(同タンスの)別のアカウントでお気に入り/ブースト」ができるようにしました。

メディアつき発言のみ表示

v0.1.5から、メディアつき発言のみ表示を任意のタイムラインで行うことができるようになりました。某アプリと違ってアプリ側で無理矢理実装してるので負荷はやや高いですが、どのインスタンスでもローカルTLでも連合TLでもハッシュタグでも使えるのが良いところです。

ソース公開

https://github.com/tateisu/SubwayTooterソースコードを公開しています

今後について

現状では開発に回せるリソースがさほどある訳でもないので、時間のある間に自分の欲しい機能を一通り実装してしまいたいです。アップデートは不定期になると思います。

追記

Subway Tooter の 説明/宣伝用のblog を用意しました http://subwaytooter.hatenadiary.jp/

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 も指定する必要があるかもしれません。