Pythonのseleniumライブラリからphantomjsを使ったらzombieになった

# Pythonのseleniumライブラリからphantomjsを使ったらzombieになった

この記事は [クローラー／Webスクレイピング Advent Calendar 2016](http://qiita.com/advent-calendar/2016/crawler) 11日目の記事です。

2016年3月に [PythonによるWebスクレイピング](https://www.oreilly.co.jp/books/9784873117614/) という書籍が出版されました。
微力ではありますが、私もお手伝いさせていただきました。

[PythonによるWebスクレイピング](https://www.oreilly.co.jp/books/9784873117614/)の中でも紹介しているスクレイピングツールであるseleniumとphantomjsですが、
スクレイパーの作成時に環境によってはphantomjsを終了できないという問題が発生します。
今回はその事例というか問題を紹介したいと思います。

## phatomjsのプロセスがなぜか残ってしまう

次のコードを実行します。
すでにPython, Node, selenium, phantomjsがインストールされている環境を前提とします。

run.py

```
from selenium.webdriver.phantomjs.webdriver import WebDriver

browser = WebDriver()
browser.close()
browser.quit()
print('Finished')
```

このrun.pyを実行します。

```
$ python run.py
Finished
```

実行できる環境があれば標準出力にFinishedと出力して終了します。

ここでphantomjsプロセスが存在するかどうか確認して見ます。
問題が発生している場合はphantomjsプロセスが残ってしまっているはずです。

```
$ ps aux | grep phantomjs
sximada          74272 100.0  0.0  2432804   2004 s006  S+    4:41PM   0:00.01 grep --color phantomjs
sximada          74267   0.0  0.7  3647068  59976 s006  S     4:41PM   0:02.01 /usr/local/bin/phantomjs --cookies-file=/var/folders/hx/xp4thw0x7rj15r_2w57_wvfh0000gn/T/tmp2vmrand3 --webdriver=50599
```

悲しいですね。
※環境によっては残っていない場合もあります。良かったですね。問題を踏んでいないです。

この問題はstackoverflowにも同様の投稿がいくつかあって、回答の中には `pkill phantomjsしろ` みたいな回答もあります。
マジかよ、、って感じです。

## 問題が発生する環境を作成してみる

上記の動作環境は次を使いました。

- OS: macOS 10.12.1（16B2657）
- Python: 3.5.2
- selenium: 2.53.6
- Node.js: v6.7.0
- phantomjs: 2.1.7

OS, Python, Nodeのインストールは本質から離れるので解説は飛ばします。
seleniumは普通に`pip install selenium` してください。

問題はphantomjsです。nodeだから `npm install phantomjs` したいなあと思います。

phantomjsの本家は https://github.com/ariya/phantomjs です。しかしこれはnpmで入れられるものではありません。
`npm install phantomjs` でインストールされるのは https://github.com/Medium/phantomjs です。
これはリポジトリのdescriptionにもある通り `NPM wrapper for installing phantomjs` で、
phantomjsをnpmでインストール/実行できるようにしたラッパーです。

実際次のように実行すると問題が発生すると思われます(もしかしたら発生しないケースもあるかもしれませんが)。
package.jsonを作成します。

```
$ npm init .
```

phantomjs(github.com/Medium/phantomjs)をインストールします。

```
$ npm install phantomjs
```

zombie1.py:

```
from selenium.webdriver.phantomjs.webdriver import WebDriver

browser = WebDriver(executable_path='./node_modules/.bin/phantomjs')  # MODIFIED
browser.close()
browser.quit()

print('Finished')
```

zombie1.pyを実行します。

```
$ python zombie1.py
Finished
```

プロセスが残っているか確認します。

```
(py3.5.2) $ ps aux | grep phantomjs
sximada           2426   0.0  0.0  2423392    408 s002  R+    5:51PM   0:00.00 grep --color phantomjs
sximada           2421   0.0  0.6  3645988  46780 s002  S     5:51PM   0:01.56 /usr/local/bin/phantomjs --cookies-file=/var/folders/hx/xp4thw0x7rj15r_2w57_wvfh0000gn/T/tmp8nobgxn7 --webdriver=50641
```

残っていますねえ。

## homebrewでインストールしたphatomjsだと発生しない

homebrewでインストールしたphantomjsだとどうでしょうか。

```
$ brew install phantomjs
```
※versionを合わせるのが面倒だったので今入っているものを使いました。(phantomjs-2.1.1)

zombie2.py:

```
from selenium.webdriver.phantomjs.webdriver import WebDriver

browser = WebDriver(executable_path='/usr/local/bin/phantomjs')
browser.close()
browser.quit()

print('Finished')
```

zombie2.pyを実行します。

```
$ python zombie2.py
Finished
```

プロセスが残っているか確認します。

```
$ ps aux | grep phantomjs
sximada           3530   0.0  0.0  2432804    796 s002  R+    6:11PM   0:00.00 grep --color phantomjs
$
```

残っていないですねえ。何が違うんでしょう。

## homebrewで入れたphantomjsは実行可能なバイナリファイル

fileコマンドで確認するとhomebrewで入れたphantomjs `/usr/local/bin/phantomjs` は実行可能なバイナリファイルです。
便宜上、こちらの直接利用する方法をバイナリ版とします。

```
$ file /usr/local/bin/phantomjs
/usr/local/bin/phantomjs: Mach-O 64-bit executable x86_64
```

## npmで入れたphantomjsはnodejs script

一方npmで入れたphantomjsをfileコマンドで確認するとテキストファイルです。
便宜上、こちらの直接利用する方法をnpm版とします。

```
$ file node_modules/.bin/phantomjs
node_modules/.bin/phantomjs: a /usr/bin/env node script text executable, ASCII text
```

中身はnodejsのscriptで以下のように記述されています。

```
#!/usr/bin/env node

/**
 * Script that will execute the downloaded phantomjs binary.  stdio are
 * forwarded to and from the child process.
 *
 * The following is for an ugly hack to avoid a problem where the installer
 * finds the bin script npm creates during global installation.
 *
 * {NPM_INSTALL_MARKER}
 */

var path = require('path')
var spawn = require('child_process').spawn

var binPath = require(path.join(__dirname, '..', 'lib', 'phantomjs')).path

var args = process.argv.slice(2)

// For Node 0.6 compatibility, pipe the streams manually, instead of using
// `{ stdio: 'inherit' }`.
var cp = spawn(binPath, args)
cp.stdout.pipe(process.stdout)
cp.stderr.pipe(process.stderr)
process.stdin.pipe(cp.stdin)

cp.on('error', function (err) {
  console.error('Error executing phantom at', binPath)
  console.error(err.stack)
})

cp.on('exit', function(code){
  // Wait few ms for error to be printed.
  setTimeout(function(){
    process.exit(code)
  }, 20)
});

process.on('SIGTERM', function() {
  cp.kill('SIGTERM')
  process.exit(1)
})

```

`var cp = spawn(binPath, args)` でバイナリを子プロセスとして実行しているようです。
最後の方で `SIGTERM` に対するハンドラが記述されていて、SIGTERMが来ると子プロセスに `SIGTERM` を送信して終了するようです。

## バイナリ版は2段、npmインストール版は3段のプロセスの構造になっている

バイナリ版とnpm版を使ってseleniumを起動した場合プロセスは次のような構造になります。

バイナリ版:

```
$ pstree 3812
-+= 03812 sximada python zombie2.py
 \--- 03815 sximada /usr/local/bin/phantomjs --cookies-file=/var/folders/hx/xp4thw0x7rj15r_2w57_wvfh0000gn/T/tmpu8trzjh0 --webdriver=50761
```

npm版:

```
$ pstree 3701
-+= 03701 sximada python zombie1.py
 \-+- 03704 sximada node ./node_modules/.bin/phantomjs --cookies-file=/var/folders/hx/xp4thw0x7rj15r_2w57_wvfh0000gn/T/tmp9c0y1sj7 --webdriver=50747
   \--- 03705 sximada /usr/local/bin/phantomjs --cookies-file=/var/folders/hx/xp4thw0x7rj15r_2w57_wvfh0000gn/T/tmp9c0y1sj7 --webdriver=50747
```

プロセスを確認すると、この一番下にぶら下がっている孫プロセスが残っているようです。

```
$ pstree 4537
-+= 04537 sximada python zombie1.py
 \-+- 04540 sximada node ./node_modules/.bin/phantomjs --cookies-file=/var/folders/hx/xp4thw0x7rj15r_2w57_wvfh0000gn/T/tmpg1eq1xst --webdriver=51406
   \--- 04541 sximada /usr/local/bin/phantomjs --cookies-file=/var/folders/hx/xp4thw0x7rj15r_2w57_wvfh0000gn/T/tmpg1eq1xst --webdriver=51406
$ ps aux | grep phantomjs
sximada           4554   0.0  0.0  2432804    632 s003  R+    6:50PM   0:00.00 grep --color phantomjs
sximada           4541   0.0  0.6  3646488  47532 s002  S     6:49PM   0:05.84 /usr/local/bin/phantomjs --cookies-file=/var/folders/hx/xp4thw0x7rj15r_2w57_wvfh0000gn/T/tmpg1eq1xst --webdriver=51406
```

## 手動で現象を再現させる

`node ./node_modules/.bin/phantomjs --cookies-file=/var/folders/hx/xp4thw0x7rj15r_2w57_wvfh0000gn/T/tmpg1eq1xst --webdriver=51406` を実行した後で
そのプロセスを`kill -KILL` してみます。

```
$ node ./node_modules/.bin/phantomjs --cookies-file=/var/folders/hx/xp4thw0x7rj15r_2w57_wvfh0000gn/T/tmpoazqtmx7 --webdriver=51448
[INFO  - 2016-12-10T09:57:42.829Z] GhostDriver - Main - running on port 51448
```

プロセスが起動したらSIGKILLでkillします。

```
$ ps -ef | grep phantom
  501  4662   763   0  6:57PM ttys002    0:00.12 node ./node_modules/.bin/phantomjs --cookies-file=/var/folders/hx/xp4thw0x7rj15r_2w57_wvfh0000gn/T/tmpoazqtmx7 --webdriver=51448
  501  4663  4662   0  6:57PM ttys002    0:01.73 /usr/local/bin/phantomjs --cookies-file=/var/folders/hx/xp4thw0x7rj15r_2w57_wvfh0000gn/T/tmpoazqtmx7 --webdriver=51448
  501  4666   764   0  6:57PM ttys003    0:00.00 grep --color phantom
$ kill -KILL 4662
$ ps -ef | grep phantom
  501  4663     1   0  6:57PM ttys002    0:03.63 /usr/local/bin/phantomjs --cookies-file=/var/folders/hx/xp4thw0x7rj15r_2w57_wvfh0000gn/T/tmpoazqtmx7 --webdriver=51448
  501  4670   764   0  6:58PM ttys003    0:00.00 grep --color phantom
```

再現しました。これのせいですね。seleniumはshutdown時にSIGKILLを送信しているのでしょうか。

## selenium.webdriver.phantomjs.webdriver.Webdriver.close()の挙動

ここからはpdb.set_trace()でデバッガを使ってPythonが何をやっているのか調べていきます。
zombie1.pyにpdbを仕込んで動きを見ましょう。

```
from selenium.webdriver.phantomjs.webdriver import WebDriver

browser = WebDriver(executable_path='./node_modules/.bin/phantomjs')
import pdb; pdb.set_trace()
browser.close()
browser.quit()

print('Finished')
```

動作を見てみると selenium/webdriver/remote/remote_connection.py(470)_request() 箇所でHTTPリクエストを送信しているようです。

```
464                 if password_manager:
465                     opener = url_request.build_opener(url_request.HTTPRedirectHandler(),
466                                                       HttpErrorHandler(),
467                                                       url_request.HTTPBasicAuthHandler(password_manager))
468                 else:
469                     opener = url_request.build_opener(url_request.HTTPRedirectHandler(),
470                                                       HttpErrorHandler())
471  ->             resp = opener.open(request, timeout=self._timeout)
472                 statuscode = resp.code
```

送っているリクエストは次のようなrequestです。

```
-> request = Request(url, data=body.encode('utf-8'), method=method)
(Pdb) p url
'http://127.0.0.1:51524/wd/hub/session/57277cb0-bec1-11e6-a0b1-31edd9b29650/window'
(Pdb) p body
'{"sessionId": "57277cb0-bec1-11e6-a0b1-31edd9b29650"}'
(Pdb) p method
'DELETE'
(Pdb)
```

それ以外は特に何もしていないようです。

## selenium.webdriver.phantomjs.webdriver.Webdriver.quit()の挙動

quit()の方はどうでしょうか。

selenium/webdriver/phantomjs/webdriver.py(76)quit()の `self.service.stop()` の処理の中でSIGTERMやSIGKILLを送信しているところがありました。

selenium/webdriver/common/service.py(154)stop():

```
(Pdb) list
149                             stream.close()
150                         except AttributeError:
151                             pass
152                     self.process.terminate()
153                     self.process.kill()
154  ->                 self.process.wait()
155                     self.process = None
156             except OSError:
157                 # kill may not be available under windows environment
158                 pass
159
```

コードを読む限りSIGTERMを送信した後でSIGKILLを送信しています。

しかしnpm版にはSIGTERMのシグナルハンドラがありました。そもそもこのSIGKILLは必要なのでしょうか。
試しに `self.process.kill()` をコメントアウトして実行して見ます。


selenium/webdriver/common/service.py:

```
                self.process.terminate()
                # self.process.kill()   ## コメントアウト
                self.process.wait()
```

実行します。

```
$ python  zombie1.py
Finished
$ ps aux | grep phantomjs
sximada           5270   0.0  0.0  2424612    500 s002  R+    7:34PM   0:00.00 grep --color phantomjs
$
```

プロセスは残っていません。`self.process.kill()` のせいで子プロセスがkillされて孫プロセスが残っているようです。

## 子プロセスのSIGTERMハンドラ処理完了前にSIGKILLを受け取っているのが原因

どうやら`self.process.terminate()`で送られたSIGTERMをnpm版のphantomjsが、
ハンドラで孫プロセスにSIGTERMを送信する前にSIGKILLでkillされているようです。
なんか境界のはざまにはまりこんだ気分です。

## npmを使わずphantomjsをインストールすれば回避できる

npmを介さなければ良さそうなので、`npm install phantomjs` せず、
homebrewでインストールするとか、http://phantomjs.org/download.html から落として入れるとかすれば良いです。

なんだかとっても遠回りしてしまった気分です。
同じ遠回りを誰かがしないことを祈ります。