Posted at

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

More than 1 year has passed since last update.


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

この記事は クローラー/Webスクレイピング Advent Calendar 2016 11日目の記事です。

2016年3月に PythonによるWebスクレイピング という書籍が出版されました。

微力ではありますが、私もお手伝いさせていただきました。

PythonによるWebスクレイピングの中でも紹介しているスクレイピングツールである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 から落として入れるとかすれば良いです。

なんだかとっても遠回りしてしまった気分です。

同じ遠回りを誰かがしないことを祈ります。