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

  • 7
    いいね
  • 0
    コメント

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 から落として入れるとかすれば良いです。

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

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