以下の内容はhttps://takuya-1st.hatenablog.jp/entry/2016/04/11/044313より取得しました。


python でコマンド実行。サブプロセスの終了待ち・強制終了・親プロセスと一緒に殺す。getoutputとcheckoutputについて

python でコマンドを実行するには

subprocess モジュールを使う

以前にも書いたんだけど、気になったので、再度調べ直した。

os.popen があるじゃないと思われるかもしれないが、os.popen は内部的に subprocess (shell=True)を使ってるので同じことである。

後述するが、シェルインジェクションも考慮すると、shell=Falseにするべきなので、考えなしのos.popen利用は避けよう。

suprocessでコマンドを実行する

単純にコマンドを実行するには、subprocess.call を使うのが楽ですね バッククォート ` やos.system のかわりに subprocess .call() を使うようです。

import subprocess
cmd = "sleep 30"
proc = subprocess.call( cmd , shell=True)

実行した結果はこちら。(シェル経由)

USER       PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
takuya   16804  0.0  0.0  20960  4076 pts/4    Ss   03:49   0:00 /bin/bash
takuya   16864  1.0  0.0  23572  4864 pts/4    S+   03:49   0:00  \_ python proc.py
takuya   16865  0.0  0.0   4180   580 pts/4    S+   03:49   0:00      \_ /bin/sh -c sleep 30
takuya   16866  0.0  0.0   5152   584 pts/4    S+   03:49   0:00          \_ sleep 30

shell=True を渡したので、シェルsh -c 'sleep 30' が実行されている。

この場合は、終了待ちをしています。call の部分でブロックされます。

PythonをCtrl+Cで止めたら、sh も一緒に止まりました。

シェル経由をしない場合

今度は、シェル経由をせずに、直接プロセスを起動する場合。

import subprocess
cmd = "sleep 30"
proc = subprocess.call( cmd .strip().split(" ") )

実行中のプロセスのツリーはこちら。

USER       PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
takuya   16804  0.0  0.0  21112  4164 pts/4    Ss   03:49   0:00 /bin/bash
takuya   16963  0.0  0.0  23572  4864 pts/4    S+   03:53   0:00  \_ python proc.py
takuya   16964  0.0  0.0   5152   584 pts/4    S+   03:53   0:00      \_ sleep 30

シェル経由ではないので、コマンドとコマンド引数を配列で渡しています。

終了待ちをしています。callでブロックされます。Ctrl+Cで終了したら、sleep も殺してくれます。

Popen を用いる場合。

os.spawn の代わりに最近では Popenを用いるべきらしいです。

popen には使い方がいくつかあります。

単純に呼び出した場合。

import subprocess
from subprocess import Popen
from time import  sleep
cmd = "sleep 30"
proc = Popen( cmd .strip().split(" ") )

単純に呼び出した場合のプロセスツリーです。

USER       PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
takuya   16804  0.0  0.0  21112  4172 pts/4    Ss   03:49   0:00 /bin/bash
takuya   17038  0.0  0.0   5152   584 pts/4    S    03:58   0:00 sleep 30

単純なPopenは終了待ちをせずにサブプロセスを起動するだけです。

親プロセスのPythonはsleep の終了待ちをせずに、sleepより先に終了してしまい、孤児プロセスとなったsleep はroot プロセス(init)に引き取られています。

import subprocess
from subprocess import Popen
from time import  sleep
cmd = "sleep 30"
proc = Popen( cmd .strip().split(" ") )
sleep(5)

time.sleep を使って、起動直後の状態を確認してみます。

USER       PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
takuya   16804  0.0  0.0  21112  4172 pts/4    Ss   03:49   0:00 /bin/bash
takuya   17044  0.1  0.0  23572  4864 pts/4    S+   03:58   0:00  \_ python proc.py
takuya   17045  0.0  0.0   5152   584 pts/4    S+   03:58   0:00      \_ sleep 30

起動直後では、親プロセスがPythonで子プロセスにsleep がちゃんと入っています。

起動後、親プロセスのPythonが先に終了するので 、子プロセスのsleep が孤児になるわけですね。

Popen+シェル経由で、サブプロセスを起動する。

今度は、シェル経由でサブプロセスを起動して、Pythonが終了待ちをしないPopenを見てみます。

import subprocess
from subprocess import Popen
from time import  sleep

cmd = "sleep 30"
proc = Popen( cmd,shell=True )

Pythonが先に終了するので、sh -c が孤児プロセスとして引き取られていました。

takuya@atom:~$ ps uxf
USER       PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
takuya   16718  0.0  0.0  94388  1876 ?        S    03:49   0:00 sshd: takuya@pts/0
takuya   16719  0.0  0.0  22816  6012 pts/0    Ss   03:49   0:00  \_ -bash
takuya   16802  0.0  0.0  22656  1176 pts/0    S+   03:49   0:00      \_ screen
takuya   17138  0.0  0.0   4180   580 pts/4    S    04:04   0:00 /bin/sh -c sleep 30
takuya   17139  0.0  0.0   5152   584 pts/4    S    04:04   0:00  \_ sleep 30

シェル経由なので sh -c が出てきてます。

こちらも、起動後、親プロセスのPythonが先に終了するので 、子プロセスのsleep が孤児になるわけですね。

そのままでは終了を待ち合わせしません。

終了待ちをする Popen#wait を使う。

popen = Popen( cmd,shell=True )
popen.wait()

これで、Waitできる。でもPopenってなに?

Popen オブジェクトについて

Popenによってコマンドを実行し、サブプロセスを起動した場合はPopenオブジェクトが返却される。

Popenオブジェクトのインスタンスは、process ID や STDIN/STDOUT などを変数に持つプロセスを抽象化したオブジェクトのようです。

import subprocess
from subprocess import Popen
from time import  sleep

cmd = "sleep 30"
proc = Popen( cmd,shell=True )
print( "process id = %s" % proc.pid )

実行結果は次のようになっていて

takuya@atom:~$ python proc.py
process id = 17216

実行後のプロセスは次のようになっていました。

takuya@atom:~$ ps uxf
USER       PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
takuya   16718  0.0  0.0  94388  1876 ?        S    03:49   0:00 sshd: takuya@pts/0
takuya   16719  0.0  0.0  22816  6012 pts/0    Ss   03:49   0:00  \_ -bash
takuya   16802  0.0  0.0  22656  1176 pts/0    S+   03:49   0:00      \_ screen
takuya   17216  0.0  0.0   4180   580 pts/4    S    04:09   0:00 /bin/sh -c sleep 30
takuya   17217  0.0  0.0   5152   580 pts/4    S    04:09   0:00  \_ sleep 30

これから、間違いなくサブプロセスのプロセスIDを取得できていることが分かりました。

Popenオブジェクト便利ですね。

Popen#waitをしながら、Ctrl+Cを押した。

subprocess.call と同じように終了待ちをPopenで実現するには、 Popen#waitを使う。

Ctrl+Cでpythonを止めると、起動した子プロセスも一緒に死んでくれて便利。

孤児プロセスが出ないのは嬉しい。

import subprocess
from subprocess import Popen
from time import  sleep

cmd = "sleep 30"
proc = Popen( cmd,shell=True )
print( "process id = %s" % proc.pid )
proc.wait()

ただし、Pythonが面倒見てくれるのはCtrl+Cを押した時だけ。

別のターミナルから、SIGINTを送信したら。

takuya@atom:~$ kill -INT 17343
takuya@atom:~$ ps uxf
USER       PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
takuya   17354  0.0  0.0  16388  1252 pts/5    R+   04:17   0:00  \_ ps uxf
takuya   16804  0.0  0.0  21268  4252 pts/4    Ss+  03:49   0:00 /bin/bash
takuya   16718  0.0  0.0  94388  1876 ?        S    03:49   0:00 sshd: takuya@pts/0
takuya   16719  0.0  0.0  22816  6012 pts/0    Ss   03:49   0:00  \_ -bash
takuya   16802  0.0  0.0  22656  1176 pts/0    S+   03:49   0:00      \_ screen
takuya   17344  0.0  0.0   4180   580 pts/4    S    04:16   0:00 /bin/sh -c sleep 30 ## initに引き取られた孤児プロセス
takuya   17345  0.0  0.0   5152   584 pts/4    S    04:16   0:00  \_ sleep 30

TERM も INT も HUP も孤児が出来た。キーボードを押したら孫まで消してくれるのに。。。Ctrl+C押した場合とシグナル送信では若干動きが違う?

この辺は理由がわからないけど、ruby もそうだったので、Linuxのシグナルとプロセス管理をやっぱり勉強しなおしだね。

サププロセスを終了する Popen#terminate

teminate() を使うとSIGTERMシグナルが送信進されて、プロセスが終了される。

これもきちんと終了してくれる。

一定時間経過したらプロセスを終了するには

sleep と組み合わせて戦う。

import subprocess
from subprocess import Popen
from time import  sleep

cmd = "sleep 30"
proc = Popen( cmd,shell=True )
print( "process id = %s" % proc.pid )
sleep(5)
proc.terminate()

stack overflowには、Decoratorをと例外を使ってタイムアウトを実現する方法が紹介されていた。

Popenと wait と terminate を使う時の注意点

プロセスを扱うときに、PIPE.stdin/stdoutがデッドロックになる可能性があるので、注意しろとドキュメントに書いてあった。

terminate は SIGTERMを送信するが、WindowsはWin32APIの ProcessTerminate()を送信する。実行環境で違いがある。

実装系に依って動作が異なるようだ。

また、OSXで動かした時は shell=Trueをつけても、シェル経由にならなかった。

shell=True (シェル実行)とは

シェル実行とは、/usr/bin/bash のような、シェルプロンプトで実行するということである。

シェルで実行すると、python -> bash -> スクリプト の呼び出し関係になる。

例えば次のプログラム

## shell=Trueの例
os.popen('echo 1 ; echo 2 ; echo $SHELL')

上記は、次のシェルスクリプトと同じである。

#!/usr/bin/env bash
echo 1 ; 
echo 2 ;
echo $SHELL

pythonbash などのシェルコマンドに文字列を渡して実行することになる。そのため、複数行コマンドや複数コマンドの実行が可能になる。 とても便利で手軽であるが、文字列のエスケープ処理では対応しきれない「限界」があるためユーザーが入力する文字列を実行コマンドに含めないように配慮が必要である。注意してもユーザーが入力する文字列をエスケープで排除するのは技術的に不可能である。そのためShell=Falseを使う。

インジェクションを考慮すると、shell=Falseが一番安全です。暗黙的にshell=Trueになってる関数で変数を埋め込むのは注意が必要なので、そもそも使わないという選択肢が良いと思います。

OSX で shell=True 付きの動作

takuya@~/Desktop$ pstree -s python
-+= 00001 root /sbin/launchd
 \-+= 02974 takuya /Applications/iTerm.app/Contents/MacOS/iTerm2
   \-+= 42127 takuya /Applications/iTerm.app/Contents/MacOS/iTerm2 --server login -fp takuya
     \-+- 42128 root login -fp takuya
       \-+= 42129 takuya -bash
         \-+= 42234 takuya /usr/local/Cellar/python/2.7.11/Frameworks/Python.framework/Versions/2.7/Resources/Python.ap
           \--- 42235 takuya sleep 30

理由は良く分からないけど、shell=Trueをつけて試したがシェル経由にはらなかった。疑問が残った。

Pythonのsubprocess は実行環境次第なところがあるらしく、OSを変えたらチェックが必要かもしれない。

複数プロセスをパイプするとき

subprocess.PIPを使って、プロセスをパイプする。

cmd1 = "ls -lt "
cmd2 = "head -n 5 "
p1 = subprocess.Popen(cmd1.strip().split(" "), stdout=subprocess.PIPE)
p2 = subprocess.Popen(cmd2.strip().split(" "), stdin=p1.stdout)
p1.stdout.close()
output = p2.communicate()[0]

comunicate を呼び出すと、waitになるし、stdout.close()して次に渡されるようですね。

p2が起動後に、p1のSTDOUTをclose()したらp1 がp2からのSIGPIPEを受け取れるのでパイプを使えるとのこと。

check_output でも出来るようです

output=check_output("ls -alt / | head -n 2", shell=True)

戻り値が byte 何だけど、str でほしいよ。

byte で出てくるので、decode が必要になったりして、めんどくさい。

ret = subprocess.check_output("ls -l ", shell=True,universal_newlines=True)

universal_newlines=True をつけると unicode な str でもらえるよ。

プログラムにSTDINを渡したい。

rubyphpbashなどSTDINでソース・コードを渡せるスクリプトpythonから実行するには

p1 = Popen(['php'],shell=False,stdin=subprocess.PIPE,stdout=subprocess.PIPE)
p1.stdin.write(b'<?php echo "hello";')
p1.stdin.close()
p1.wait()
ret = p1.stdout.read()
print(str(ret, 'UTF-8'))

このように、手順を踏めば、pythonから外部スクリプトを実行して、その結果を変数に受け取ることができる。jsonなどで外部コードとやり取りすれば、プロセス間通信のようなことも可能になる。

getoutput と check_output

python の subprocess (プロセス実行) には getoutputcheck_outputがある。

外部コマンドの実行をするときに、getoutput 利用が増えているが、これを使うときは注意が必要

Popenのような、配列引数を渡すことができない。

import subprocess
import shlex

cmds = shlex.split('which ls')
ret = subprocess.getoutput(cmds) # =>''

次のように、getoutputを実行しシェル経由を確認する。

ret = [
    subprocess.getoutput('which ls; which bash'),
    subprocess.getoutput('echo $SHELL'),
]

結果は次のようになる。

[
    '/usr/bin/ls\n/usr/bin/bash',
    '/bin/bash'
]

結果から、getoutputはシェルスクリプトとして実行されているとわかります。

実はgetoutputは、内部でcheck_output(cmd, shell=True) を呼び出している。だから、プロセスを実行しているわけではなく、シェルが実行されている。

実際にソース・コードを見てみましょう。

getoutput

def getoutput(cmd):
    return getstatusoutput(cmd)[1]

getstatusoutput

def getstatusoutput(cmd):
    try:
        data = check_output(cmd, shell=True, text=True, stderr=STDOUT)
        # ...

subprocess.getoutput のソース・コードを見れば、実体はsubprocess.check_outputです。getoutput とは、check_output(cmd, shell=True..)だとわかります。つまり、シェル・スクリプトが実行できることになる。意図しない実行ができてしまうことになる。

これらのことから、subprocess.getoutput はバグ誘発関数と言える。

特に、文字列をシェルで実行するため、変数埋込を使うと思わぬ挙動に遭遇するだろう。

次のようにしているとき、変数で意図しない動作を引き起こすことになる。

dir='name'
ret = subprocess.getoutput(f'rm -rf ~/.config/{dir}) # =>''

もし、dir='../../../' だったらどうでしょう。 また、dir=';;;; rm /etc/passwd だったらどうでしょう

シェルコマンドインジェクションが成立してしまう。また、シェルの脆弱性に影響を受ける。過去にbash脆弱性がありました。shell=Trueのコードは影響を被ります。

コマンドを実行するときは、シェル文字列を渡さずに、popenやcheck_outputが無難である。popenはデフォルト値がshell=Falseです。

getoutput は名前がわかりやすく、イメージしやすいのでよく紹介される。getoutput記事も増えている。しかし、シェル経由を避けてプロセス起動が無難だろう。個人的には、check_output に配列を渡せば良いと思う。

# getoutput より、check_output
cmds = shlex.split('which ls;')
ret= subprocess.check_output(cmds)

シェル・スクリプトなら、次のようにエラーになるので安全

subprocess.CalledProcessError: Command '['which', 'ls;']' returned non-zero exit status 1.

変に「エスケープ処理」をするよりマシです。そもそも実行できないので安全です。

世間のセキュリティ対策に、コマンドインジェクション対策は「エスケープしろ」とか言われますが。そもそも間違い。ソース・コードのセキュリティ監査項目やプログラム・ガイドラインに「エスケープしてないと認めない」とかテンプレ項目があるらしい。個人的にかなり違和感を覚える。

SQLインジェクション対策なら、「プリペアしろ、エスケープすんな。」である。なぜならエスケープには限界があるためである。シェル・インジェクションの対策も同じで、「スクリプトを実行できないようにしろ、プロセスをちゃんと起動しろ。」と言える。これに尽きると思うんですよね。エスケープ済みか監査するという対策は、セキュリティ監査の項目自体が誤りで技術的に間違ってると思うんですよね。

## 安全なコマンド実行結果の取得
cmd = 'ls'
cmds = shlex.split(f'which {cmd}')
ret= subprocess.check_output(cmds)
ret = ret.decode().strip()

以上のことから、変数が含まれる場合は、getoutput より、check_outputが望ましいと考えます。

まとめ

コマンドの実行

  • 単純な呼び出し(終了待ちする系)
    • call ( "ls -l " , shell=True)
    • check_call ( "ls -l " , shell=True) 終了STATUSが0以外なら例外
    • check_output ( "ls -l ", shell=True ) stdoutを取得
  • Popenを使ったプロセス起動 (終了待ちなし)
    • popen = Popen( "ls -l" ,shell=True)
    • popen . wait ()
    • popen.terminate()
    • popen.communicate()[0] stdout取得
    • popen.reterncode exit status 終了STATSUを取得
    • popen .pid プロセスIDを取得
    • popen.poll() 終了しているか取得
  • シェル経由かそうでないか
    • シェル経由 shell=True をつけて文字列で渡す
    • 非シェル経由 コマンドを配列で渡す。
    • os.popengetoutputはシェル経由でインジェクションが起きる。
関数 popenを使って同等のこと
call Popen( cmd_list ).wait()
check_output Popen( cmd_list ).communicate[0]

追記

Popenの読み方がよくわからん。 Popen = p-open なのか、 Pop-en なのか、どっちなんだろう。

2016-04-25追記

split だと正規表現が使えないので

cmd .strip().split(" ")

正規表現を使う場合は

import re
cmd = re.split('\s+', cmd.strip() )

のほうが良さそうです。

2024-08-08

getoutput について「大幅に追記」

2024-08-08

os.popen について言及。Shell=Falseについて言及。シェルコマンド・インジェクションについて言及

2019-09-03

str / byte の取り方について補足

参考資料

https://docs.python.org/2/library/subprocess.html#replacing-functions-from-the-popen2-module

http://stackoverflow.com/questions/2281850/timeout-function-if-it-takes-too-long-to-finish




以上の内容はhttps://takuya-1st.hatenablog.jp/entry/2016/04/11/044313より取得しました。
このページはhttp://font.textar.tv/のウェブフォントを使用してます

不具合報告/要望等はこちらへお願いします。
モバイルやる夫Viewer Ver0.14