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

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

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を使っているとか