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 から落として入れるとかすれば良いです。
なんだかとっても遠回りしてしまった気分です。
同じ遠回りを誰かがしないことを祈ります。