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.js
がpuppeteer.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以降のバージョンをお使いであれば *2,docker 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-initやkrallin/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の偉大さを再認識させられますね!!
参考資料
追記
背景としてはこれがgrafanaのスクリーンショットを取るためpuppeteerを使っててchromeを立ち上げちゃうのよね〜,コンテナ環境で素直に使うとゾンビが増える https://t.co/FvqizKQh3T
— moznion (@moznion) 2019年3月2日
本来は1コンテナ1プロセスみたいな感じで扱うべきだと思っているし,コンテナ内でプロセスをガンガンspawnするのは良くないと思っている,少なくとも自分が設計するアプリケーションでは1コンテナ1プロセスという感じ (あるいはそれに近しい感じ) にすると思うのですが,生きていると色々ありますね.
更に追記
puppeteerの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を使っているとか