Help us understand the problem. What is going on with this article?

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

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

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

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした