その手の平は尻もつかめるさ

ギジュツ的な事をメーンで書く予定です

Dockerコンテナ内でpuppeteerを使うとChromeゾンビプロセスがたまる問題

表題のような問題があり,その調査したという記録です.なお,結論を一言で言うと--initを使え,ということになります.


そもそもDockerコンテナを起動すると,CMDあるいはENTRYPOINTに指定されたコマンドがコンテナ内でPID 1として起動します.これが何を意味するかと言うと,「CMDあるいはENTRYPOINTに指定されたコマンド」はそのコマンド自体の責務をまっとうするのと同時に,initプロセスとしての振る舞いも行わなければならないということになります (id:hayajo_77さんにこの辺を詳しく教えてもらいました,ありがとうございます).
つまりPID 1で動いているプロセスは「SIGCHLDをトラップすることで孤児プロセスを適切に回収し,waitpidをかける」という処理も適切に行う必要があります.


さて,puppeteerを使ってChromeブラウザを起動するとどうなるでしょうか *1.以下に検証で利用したコード片を記します.

なお検証環境は

$ uname -a
Linux ip-198-18-0-91.ap-northeast-1.compute.internal 4.14.88-88.76.amzn2.x86_64 #1 SMP Mon Jan 7 18:43:26 UTC 2019 x86_64 x86_64 x86_64 GNU/Linux
$ cat /etc/system-release
Amazon Linux release 2 (Karoo)
$ docker --version
Docker version 18.06.1-ce, build e68fc7a215d7133c34aa18e3b72b4a21fd0c6136

となります.

index.js:

const puppeteer = require('puppeteer');

new Promise((resolve) => {
  resolve(puppeteer.launch({
    args: [
      '--no-sandbox',
      '--disable-setuid-sandbox'
    ]
  }));
}).then((browser) => {
  console.log('launched');
  setTimeout(() => {
    browser.close();
    console.log('closed');
    setTimeout(() => {
      console.log('finished');
    }, 10000);
  }, 10000);
});

Dockerfile:

FROM node:10-jessie

WORKDIR /app
ADD . /app/

RUN apt-get update && apt-get install -y libx11-dev libx11-xcb-dev libxcomposite-dev libxcursor-dev libxdamage-dev libxi-dev libxtst-dev libnss3-dev libcups2-dev libxss-dev libxrandr-dev libasound2-dev libatk1.0-dev libatk-bridge2.0-dev libgtk-3-dev
RUN npm install

ENTRYPOINT ["node", "index.js"]

そしてこのDockerコンテナを起動すると以下のようなプロセスツリーとなります (一部の長大なコマンドについては省略しています).

root     12029  0.1  7.1 792184 72480 ?        Ssl  Feb28   3:50 /usr/bin/dockerd --default-ulimit nofile=1024:4096
root     12040  0.1  1.8 644872 18304 ?        Ssl  Feb28   3:54  \_ docker-containerd --config /var/run/docker/containerd/containerd.toml
root     29692  0.0  0.3   8792  3888 ?        Sl   06:15   0:00      \_ docker-containerd-shim -namespace moby 
root     29728  4.0  3.7 595548 37676 pts/0    Ssl+ 06:15   0:00          \_ node index.js
root     29789  1.2  6.4 535180 65444 ?        Ssl  06:15   0:00              \_ /app/node_modules/puppeteer/.local-chromium/linux-624492/chrome-linux/chrome 
root     29791  0.4  4.2 374876 42716 ?        S    06:15   0:00                  \_ /app/node_modules/puppeteer/.local-chromium/linux-624492/chrome-linux/chrome --type=zygote --no-sandbox --headless --headless
root     29813  0.4  6.2 607496 63388 ?        Sl   06:15   0:00                  |   \_ /app/node_modules/puppeteer/.local-chromium/linux-624492/chrome-linux/chrome --type=renderer --no-sandbox 
root     29810  0.6  5.4 432496 55320 ?        Sl   06:15   0:00                  \_ /app/node_modules/puppeteer/.local-chromium/linux-624492/chrome-linux/chrome --type=gpu-process 

index.jspuppeteer.launch()を実行することによってChromeのプロセスがspawnされ,さらにそのChromeプロセスがChromeのプロセスをspawnしていることが分かります.つまり「子プロセスであるChrome」と「孫プロセスであるChrome」が存在しているという状況になります.

そしてその10秒後にbrowser.close()によってブラウザをクローズすると……

root     12029  0.1  7.1 792184 72480 ?        Ssl  Feb28   3:50 /usr/bin/dockerd --default-ulimit nofile=1024:4096
root     12040  0.1  1.8 644872 18304 ?        Ssl  Feb28   3:54  \_ docker-containerd --config /var/run/docker/containerd/containerd.toml
root     29692  0.0  0.3   8792  3888 ?        Sl   06:15   0:00      \_ docker-containerd-shim -namespace moby
root     29728  4.0  3.7 595548 37676 pts/0    Ssl+ 06:15   0:00          \_ node index.js
root     29791  0.0  0.0      0     0 ?        Z    06:15   0:00              \_ [chrome] <defunct>
root     29813  0.2  0.0      0     0 ?        Z    06:15   0:00              \_ [chrome] <defunct>
root     29810  0.1  0.0      0     0 ?        Z    06:15   0:00              \_ [chrome] <defunct>

孫プロセスであるChromeがゾンビプロセスになってしまっていますね.
これはbrowser.close()でkillしたプロセスは「子プロセスであるChrome」のプロセスで,その配下にいた「孫のChromeプロセス群」はPID 1のプロセス (つまりこの場合のnode index.js) に回収されてしまうものの,node index.js はその孫プロセスたちについてwaitpid(2)を発行して看取るということをしないため,このようにゾンビプロセスとしてコンテナ内に溜まっていくということになります.
puppeteerとしては,孫プロセスはinitに回収されることを期待しているという感じですね.非コンテナ環境であれば期待通りに動く仕組みと言えます.


で,どうすべきかと言うと向かうべき道は2つくらいあると思っていまして,

  • PID 1のプロセス上で適切にSIGCHLDをトラップしてwaitpid(2)を発行して子孫プロセスを看取る
  • コンテナ内にinitを導入する

というものが考えられると思います.

前者はまさに文面通りPID 1となるプロセスの中にSIGCHLDをトラップしてwaitpid(2)を発行するようなロジックを入れるというものです.良識的なプログラミング言語をご利用であればわりかしシンプルに実現可能でしょう.これによってChromeのゾンビプロセスが爆発するというのを避けられると思います.

後者はコンテナ内にinitを導入する,つまりPID 1のプロセスをinitにして,その配下に任意のコマンドをぶら下げるという方法です.
Docker 1.13以降のバージョンをお使いであれば *2docker runに対して--initオプションを付与することでPID 1にinitを指定することができるので,これによりinitのメカニズムをコンテナ内に持ち込むことが可能となります: https://docs.docker.com/engine/reference/run/#specify-an-init-process

root     12029  0.1  5.0 792184 51268 ?        Ssl  Feb28   4:03 /usr/bin/dockerd --default-ulimit nofile=1024:4096
root     12040  0.1  1.7 644872 17516 ?        Ssl  Feb28   3:58  \_ docker-containerd --config /var/run/docker/containerd/containerd.toml
root     27900  0.0  0.3   8792  3888 ?        Sl   07:08   0:00      \_ docker-containerd-shim -namespace moby
root     27945  0.5  0.0    956     4 pts/0    Ss   07:08   0:00          \_ /dev/init -- node index.js
root     27982  8.5  3.7 595548 37708 pts/0    Sl+  07:08   0:00              \_ node index.js
root     27999  3.0  6.5 535212 65616 ?        Ssl  07:08   0:00                  \_ /app/node_modules/puppeteer/.local-chromium/linux-624492/chrome-linux/chrome
root     28001  0.5  4.1 374876 42320 ?        S    07:08   0:00                      \_ /app/node_modules/puppeteer/.local-chromium/linux-624492/chrome-linux/chrome --type=zygote --no-sandbox --headless --headless
root     28022  1.5  6.1 607496 62492 ?        Sl   07:08   0:00                      |   \_ /app/node_modules/puppeteer/.local-chromium/linux-624492/chrome-linux/chrome --type=renderer --no-sandbox
root     28020  1.5  5.3 432496 54448 ?        Sl   07:08   0:00                      \_ /app/node_modules/puppeteer/.local-chromium/linux-624492/chrome-linux/chrome --type=gpu-process

このようにinitが差し込まれるので,孫プロセスが孤児になった際はこのinitが回収して看取ってくれることになります.

あるいは様々な理由により *3 --initオプションを使えない場合はYelp/dumb-initkrallin/tiniを手で差し込むという方法も可能です.例えば今回の例のDockerfileが以下のようになります:

FROM node:10-jessie

WORKDIR /app
ADD . /app/

RUN apt-get update && apt-get install -y libx11-dev libx11-xcb-dev libxcomposite-dev libxcursor-dev libxdamage-dev libxi-dev libxtst-dev libnss3-dev libcups2-dev libxss-dev libxrandr-dev libasound2-dev libatk1.0-dev libatk-bridge2.0-dev libgtk-3-dev
RUN wget https://github.com/Yelp/dumb-init/releases/download/v1.2.2/dumb-init_1.2.2_amd64.deb
RUN dpkg -i dumb-init_*.deb
RUN npm install

ENTRYPOINT ["/usr/bin/dumb-init", "--"]
CMD ["node", "index.js"]

これを起動すると以下のようなプロセスツリーになります:

root     12029  0.1  7.1 792184 72232 ?        Ssl  Feb28   4:09 /usr/bin/dockerd --default-ulimit nofile=1024:4096
root     12040  0.1  1.9 644872 20024 ?        Ssl  Feb28   3:59  \_ docker-containerd --config /var/run/docker/containerd/containerd.toml
root     11466  0.0  0.3   7384  3892 ?        Sl   07:19   0:00      \_ docker-containerd-shim -namespace moby
root     11502  0.2  0.0    212     4 ?        Ss   07:19   0:00          \_ /usr/bin/dumb-init -- node index.js
root     11547  2.2  3.7 595548 37840 pts/0    Ssl+ 07:19   0:00              \_ node index.js
root     11564  1.0  6.4 535180 65576 ?        Ssl  07:19   0:00                  \_ /app/node_modules/puppeteer/.local-chromium/linux-624492/chrome-linux/chrome
root     11566  0.1  4.2 374876 42672 ?        S    07:19   0:00                      \_ /app/node_modules/puppeteer/.local-chromium/linux-624492/chrome-linux/chrome --type=zygote --no-sandbox --headless --headless
root     11587  0.2  6.1 607496 62228 ?        Sl   07:19   0:00                      |   \_ /app/node_modules/puppeteer/.local-chromium/linux-624492/chrome-linux/chrome --type=renderer --no-sandbox
root     11585  0.3  5.3 432528 54468 ?        Sl   07:19   0:00                      \_ /app/node_modules/puppeteer/.local-chromium/linux-624492/chrome-linux/chrome --type=gpu-process

dumb-initがPID 1として差し込まれており,--initによってinitを差し込んだときと同じような効果が得られます.


というわけで色々と調査した結果でした.今回はたまたまPuppeteerで発生した事例でしたが,他のケースでも有効かと思います.initの偉大さを再認識させられますね!!

追記

本来は1コンテナ1プロセスみたいな感じで扱うべきだと思っているし,コンテナ内でプロセスをガンガンspawnするのは良くないと思っている,少なくとも自分が設計するアプリケーションでは1コンテナ1プロセスという感じ (あるいはそれに近しい感じ) にすると思うのですが,生きていると色々ありますね.

更に追記

puppeteerのtroubleshooting.mdに書いてたことに気づいた.

https://github.com/GoogleChrome/puppeteer/blob/82bef7021263c17716bfcda5ff02a3c2f097cac0/docs/troubleshooting.md

*1:ちなみにpuppeteerは内部でchild_process.spawnを利用してプロセスをspawnしている

*2:Add init process for zombie fighting and signal handling by crosbymichael · Pull Request #26061 · moby/moby · GitHub

*3:たとえばElastic BeanstalkのDocker Single Container runtime environmentを使っているとか

「公式ガイドブック SORACOMプラットフォーム」が出ます

公式ガイドブック SORACOMプラットフォーム

公式ガイドブック SORACOMプラットフォーム

出ます,出るのです.
ソラコムプラットフォームの概観やその設計思想,実際のユースケースに応じた参考アーキテクチャなどが満載されている楽しい本です!!
ソラコムをすでにご利用の方にもそうでない方にもお役立ちな情報が記されていると確信しております.

いやー久々に商業誌に文章を書きました (WEB+DB PRESS Vol.81以来なので5年ぶりくらいでしょうか?).とは言っても本当にほんの一部なのですが……

手に取ってもらえると嬉しいです,よろしくお願いします!!

YAPC::Tokyo 2019に参加してきた & LTしてきた #yapcjapan

遅くなりましたが,表題のとおりです.YAPCが東京に凱旋してきたので参加してきました.

yapcjapan.org

じつはこのような形で事前に関わっていたりもします:

blog.yapcjapan.org

んで,LTをやってきました.資料は以下です.

AWS LambdaでPerlを動かすというテーマのLTで,これはかつてブログにも書いた内容だったのですが,ここらで一丁LTにでもしてみるか〜となり,なったという感じです.

ありがたいことにベストLTをいただきました.大感謝です.


さて今回のYAPCはなんやかやPerlの話題が多く,まだまだホットではあるな〜とは思うものの,charsbarさんの発表にあったように「年々CPAN Authorの数が減少している」など少々寂しい話題もあり,いろいろ考えさせられるものがある回でした.とはいえtokuhiromさんのキーノートにもあったように,なんやかやperlは便利なので今後も使っていく気はしており,まあここらへんはやっていくことになるのであろう……

次回の開催地・会場はまだ未定とのことでしたが,楽しみに待ちたいと思います.今回も運営の皆さんお疲れ様でした,ありがとうございました.

Jacksonでtop levelのpropertyを省略してMap<K, V>をserializeしたいんですけどってとき

public static class Something {
    private Map<String, String> prop;
}

をJacksonでserializeすると

{
  "prop": {
    "foo": "bar"
  }
}

と,トップレベルにpropのようなpropertyが出てくるので微妙……となるシチュエーションがまれによくあります.

で,どうすると良いかというと @JsonUnwrappedを使うという方法がまず考えられると思うんですが,これは問題があって期待通りに動かない.かれこれ6年くらいチケットがオープンになっています: @JsonUnwrapped not supported for Map-valued properties · Issue #171 · FasterXML/jackson-databind · GitHub

public static class Something {
    @JsonUnwrapped
    private Map<String, String> prop;
}

つまりこれは動かない.というわけでどうするかと言うと,チケット中にも示されているように@JsonAnyGetterを使うという方法があります.

public static class Something {
    private Map<String, String> prop;

    // workaround: https://github.com/FasterXML/jackson-databind/issues/171#issuecomment-117794241
    @JsonAnyGetter
    public Map<String, String> getProp() {
        return prop;
    }
}

このようにすると,

{
  "foo": "bar"
}

というふうにトップレベルのpropertyが省略された,MapのKey-Valueがそのままserializeされることとなります.よかったよかった.

gowrtr - goコード生成支援ライブラリ

gowrtr (go writerと発音します) というgoのコード生成支援ライブラリ (ジェネレータ群) を書きました.

github.com

Synopsisに書いたように,

package main

import (
	"fmt"

	"github.com/moznion/gowrtr/generator"
)

func main() {
	generator := generator.NewRoot(
		generator.NewComment(" THIS CODE WAS AUTO GENERATED"),
		generator.NewPackage("main"),
		generator.NewNewline(),
	).AddStatements(
		generator.NewFunc(
			nil,
			generator.NewFuncSignature("main"),
		).AddStatements(
			generator.NewRawStatement(`fmt.Println("hello, world!")`),
		),
	).
		EnableGofmt("-s").
		EnableGoimports()

	generated, err := generator.Generate(0)
	if err != nil {
		panic(err)
	}
	fmt.Println(generated)
}

のようなコード (ジェネレータ) を書いてやると

// THIS CODE WAS AUTO GENERATED
package main

import "fmt"

func main() {
        fmt.Println("hello, world!")
}

というようなコードが生成されるというようなライブラリです.
この例だと生成結果があまりにシンプルなので,逆に記述量が増えてアレな感じになっていますが,ある程度生成結果が大きくなるようなものだと便利に使えるはず……です (後述のImmutabilityの話題もご覧ください).詳細につきましてはGoDocをご覧ください (Exampleも示してあります).


特徴としては

  • 生成したコードにコードフォーマッタ (gofmt, goimports) を適用することができる
  • ライブラリの各メソッドはimmutableに振る舞う

というものが挙げられます.

前者は生成結果にgofmtをかけることによって「生成コードのフォーマットが統一される」というメリットと「Syntaxチェックができる」というメリットを享受できることに加え,goimportsを適用することによって「コード生成のためのジェネレータを書いているときに『何をimportするか』を考えなくても (記述しなくても) 良くなる」というメリットを得ることができます.

後者については各ジェネレータの各メソッドが暗黙的に内部状態を変更しないので,利用者はジェネレータのスナップショットを任意のタイミングで取得することができます.これによりジェネレータの再利用が可能になると同時に,そこからジェネレータを派生させていくようなコードが書きやすくなるというメリットがあります.




さて,Javaにはsquare/javapoetというライブラリがあって,これはJavaコードの生成支援を行うライブラリなのですが,gowrtrはこのライブラリに着想を得て作られました.
まだできたてのライブラリなのですが,一部の自前環境に適用したところうまくワークしているのである程度動くのではないでしょうかというステータスです.が,もしかしたら足りない機能や記法のサポートがあるかもしれません.ご意見・ご要望をお待ちしております.

ぜひご利用くださいませ.

go-errgen書いた

goのstructにエラー定義を書いておけば良い感じで「エラーを返却する関数」をコード生成するツールであるgo-errgenを書きました.

github.com

Synopsisに書いてあるとおり,

package mypkg

//go:generate errgen -type=myErrors
type myErrors struct {
	FooErr error `errmsg:"this is FOO error"`
	BarErr error `errmsg:"this is BAR error [%d, %s]" vars:"hoge int, fuga string"`
}

みたいな感じでstructにエラー定義を書いて,go:generate を設定してから go generate を実行すると

package mypkg

import "errors"
import "fmt"

func FooErr() error {
	return errors.New("[ERR-1] this is FOO error")
}

func BarErr(hoge int, fuga string) error {
	return fmt.Errorf("[ERR-2] this is BAR error [%d, %s]", hoge, fuga)
}

func MyErrorsList() []string {
	return []string{
		`[ERR-1] this is FOO error`,
		`[ERR-2] this is BAR error [%d, %s]`,
	}
}

という感じのコードが my_errors_errmsg_gen.go として生成されるというツールです.

これはエラーメッセージに「通し番号が付いたprefix」を付与したerrorを返却する関数をコード生成します.もし errmsg に加えて vars パラメータが定義されている場合はその値が関数の引数パラメータとして利用され,かつ fmt.Errorf() によってsprintf互換のプレースホルダにbindされます.


主なモチベーションとしては,

  • 集権的にエラーを定義して管理したい (エラー定義が散在するとつらい)
  • エラーの特定を行う際にエラーコードを利用したい

というのがあり,特に後者はいろいろなチームから多く利用されるコンポーネントであれば必須に近い機能でしょう.で,集権的にエラーを管理するにしても,手でエラーコードを記述するようにしたところでうっかりミスってしまう可能性もありますし (例えばエラーコードを重複させてしまうとか),そこんところは機械的にやりたいな〜という気持ちからerrgenを作ったという感じです.

errgen -type=myErrors -prefix=My-Prefix のように -prefix を付与すると [My-Prefix-1] のようにprefixを自由に設定することもできます.


この手の仕組みはプロジェクトごとに自作しがちだったんですが,毎度毎度書くというのも面倒だったのでこの度汎用的に使えるようツール化したという次第です.

ご利用くださいませ.

golangのstruct custom tagをうまいことparseして値を引っこ抜きたいってとき

type Foo struct {
	Bar string `buz:"qux" iyan:"bakan"`
}

における `buz:"qux" iyan:"bakan"` を良い感じで buz => "qux", iyan => "bakan" のように取得したいというケースでは以下のようにするとよい:

tagKeyValue := reflect.StructTag(`buz:"qux" iyan:"bakan"`)
buz := tagKeyValue.Get("buz") // => "qux"
iyan := tagKeyValue.Get("iyan") // => "bakan"

という具合でreflect.StructTagを使うと良い感じでできます.よかったよかった.

……クソ,なんのためにこんなものを書いてしまったんだ!!!! 良いお年を!!!!

github.com