【命令】Python執行命令超時控制【原創】

目錄

參考

概要

方案

方案一:os.system

方案二:os.popen

方案三:subprocess.check_output

方案四:subprocess.Popen

方案五:subprocess.Popen

方案六:subprocess.Popen


參考

官方手冊

python3 subprocess.check_output的使用

subprocess之preexec_fn

安全開發 | Python Subprocess庫在使用中可能存在的安全風險總結 

 

概要

這幾天在做一個發佈系統,裏面進行前端Node發佈的時候,需要執行npm install以及npm run build命令,這兩個命令執行的時間很長,尤其是npm run build,經常需要超過4、5分鐘,一開始我是使用最簡單的os.system命令的話,前期沒什麼大問題,後期隨着前端加載的包越來越多,發佈系統經常性的會造成莫名的堵塞,卡死在npm run build之間,npm會一直處於epoll_pwait狀態,原因是因爲超時導致的,所以用了好幾種方案來進行超時的控制

 

建議先了解一下Python執行命令的相關:【命令】Python中的執行命令【原創】

 

方案

方案一:os.system

os.system用來執行cmd指令,在cmd輸出的內容會直接在控制檯輸出,返回結果爲0表示執行成功

system()函數在執行過程中進行了以下三步操作:

  • fork一個子進程
  • 在子進程中調用exec函數去執行命令
  • 在父進程調用wait(阻塞)去等待子進程結束

對於fork失敗,system函數會返回-1

 

注意: 由於使用該函數經常會莫名其妙地出現錯誤,但是直接執行命令並沒有問題,所以一般建議不要使用

注意:os.system是簡單粗暴的執行cmd指令,如果想獲取在cmd輸出的內容,是沒辦法獲到的

注意:在Unix,Windows都有效

 

優點:簡單易理解,能夠返回執行命令的成功失敗,返回0是成功,非0是失敗

缺點:會造成堵塞,無法設置超時,不能輸出控制檯的結果

 

代碼如下:

import os


def run_cmd(cmd_string):
    print("命令爲:" + cmd_string)
    return os.system(cmd_string)

 

方案二:os.popen

同樣是用來執行cmd指令,如果想獲取控制檯輸出的內容,那就用os.popen的方法了,popen返回的是一個file對象,跟open打開文件一樣操作了,r是以讀的方式打開

 

popen() 創建一個管道,通過fork一個子進程,然後該子進程執行命令。返回值在標準IO流中,該管道用於父子進程間通信。父進程要麼從管道讀信息,要麼向管道寫信息,至於是讀還是寫取決於父進程調用popen時傳遞的參數(w或r)

 

注意:能獲取到命令的執行內容,可以打印出來,但是獲取不到命令是否執行成功,只是單純輸出了命令的執行結果而已

注意:os.popen() 方法用於從一個命令打開一個管道。在Unix,Windows中有效

 

優點:簡單易理解,能夠輸出控制檯的結果

缺點:無法獲取命令是否執行成功,不清楚是否能夠設置超時

 

代碼如下:

import os

def run_cmd(cmd_string):
    print("命令爲:" + cmd_string)
    p = os.popen(cmd_string)
    x = p.read()
    p.close()
    return x

 

 

方案三:subprocess.check_output

subprocess模塊是在2.4版本中新增的,官方文檔中描述爲可以用來替換以下函數:os.system、os.spawn、os.popen、popen2

 

參數既可以是string字符串,也可以是list列表

比如:

subprocess.Popen([“cat”,”test.txt”])
subprocess.Popen(“cat test.txt”, shell=True) 

對於參數是字符串,需要指定shell=True,官方建議使用list列表

 

參數有:

 

比如:subprocess.call代替os.system

執行命令,返回命令的結果和執行狀態,0或者非0

import subprocess

retcode = subprocess.call('ls -l', shell=True)
print(retcode)

 

比如:subprocess.getstatusoutput()

接受字符串形式的命令,返回 一個元組形式的結果,第一個元素是命令執行狀態,第二個爲執行結果

比如:

#執行正確
>>> subprocess.getstatusoutput('pwd')
(0, '/root')
#執行錯誤
>>> subprocess.getstatusoutput('pd')
(127, '/bin/sh: pd: command not found')

 

比如:subprocess.getoutput()

接受字符串形式的命令,返回執行結果

#執行正確
>>> res = subprocess.getoutput('pwd')
>>> res
'/d/software/laragon/www/test/AutoRelease'
#執行錯誤
>>> res = subprocess.getoutput('ip addr')
>>> res
"'ip' 不是內部或外部命令,也不是可運行的程序\n或批處理文件。"

 

以及還有check_output()、check_call()、run()等

 

以上subprocess使用的方法,都是對subprocess.Popen的封裝

 

注意:官方建議使用run函數,run函數是在Python 3.5增加的(https://docs.python.org/zh-cn/3.7/library/subprocess.html#module-subprocess)

>>> import subprocess
# python 解析則傳入命令的每個參數的列表
>>> subprocess.run(["df","-h"])
Filesystem      Size Used Avail Use% Mounted on
/dev/mapper/VolGroup-LogVol00
           289G  70G 204G 26% /
tmpfs         64G   0  64G  0% /dev/shm
/dev/sda1       283M  27M 241M 11% /boot
CompletedProcess(args=['df', '-h'], returncode=0)
# 需要交給Linux shell自己解析,則:傳入命令字符串,shell=True
>>> subprocess.run("df -h|grep /dev/sda1",shell=True)
/dev/sda1       283M  27M 241M 11% /boot
CompletedProcess(args='df -h|grep /dev/sda1', returncode=0)


查詢了subprocess的官方文檔,發現是有超時的異常處理(https://docs.python.org/zh-cn/3/library/subprocess.html

 

代碼如下:

import subprocess

def run_cmd_old(cmd_string, timeout=20):
    print("命令爲:" + cmd_string)
    try:
        out_bytes = subprocess.check_output(cmd_string, stderr=subprocess.STDOUT, timeout=timeout, shell=True)
        res_code = 0
        msg = out_bytes.decode('utf-8')
    except subprocess.CalledProcessError as e:
        out_bytes = e.output
        msg = "[ERROR]CallError :" + out_bytes.decode('utf-8')
        res_code = e.returncode
    except subprocess.TimeoutExpired as e:
        res_code = 100
        msg = "[ERROR]Timeout : " + str(e)
    except Exception as e:
        res_code = 200
        msg = "[ERROR]Unknown Error : " + str(e)

    return res_code, msg

check_output返回的是子程序的執行結果,是unicode編碼,如果程序執行報錯的話,會直接拋出異常CalledProcessError,並且異常當中會有output屬性,該屬性爲unicode編碼的,要當字符串使用的時候需要轉碼,如e.output.decode(encoding="utf-8")

 

但實際發現,是有問題的,比如超時時間設置爲20秒,那麼該命令會等到命令執行完畢纔會拋出TimeoutExpired異常,並不是說一旦命令執行了20秒,立馬就停止命令拋出異常的(參考:python3 subprocess.check_output的使用,timeout參數不能和shell=True一起使用,不然就算是時間到了,還是會繼續執行,等執行結束以後纔會拋出subprocess.TimeoutExpired異常)

官方文檔有這麼一段話:

timeout 參數將被傳遞給 Popen.communicate()。如果發生超時,子進程將被殺死並等待。 TimeoutExpired 異常將在子進程中斷後被拋出。

 

優點:簡單易理解,能夠輸出控制檯的結果,能夠知道是否超時

缺點:需要在命令執行完成纔會拋出超時異常

 

方案四:subprocess.Popen

該方案是基於上面的方案進行改造的,上面的方案既然無法進行超時的控制,那麼就手動來進行超時的判斷和控制,即在循環中判斷是否達到超時時間,如果已經達到的話,那麼手動殺掉進程來停止命令的繼續執行

代碼如下:

import subprocess
import time

def run_cmd(cmd_string, timeout=20):
    print("命令爲:" + cmd_string)
    try:
        p = subprocess.Popen(cmd_string, stderr=subprocess.STDOUT, stdout=subprocess.PIPE, shell=True)
        t_beginning = time.time()
        res_code = 0
        while True:
            if p.poll() is not None:
                break
            seconds_passed = time.time() - t_beginning
            if timeout and seconds_passed > timeout:
                p.terminate()  # 等同於p.kill()
                msg = "Timeout :Command '" + cmd_string + "' timed out after " + str(timeout) + " seconds"
                raise Exception(msg)
            time.sleep(0.1)

        msg = str(p.stdout.read().decode('utf-8'))
    except Exception as e:
        res_code = 200
        msg = "[ERROR]Unknown Error : " + str(e)

    return res_code, msg
  • poll:判斷是否執行完成,如果完畢返回returncode,未完成返回None
  • terminate:終止進程發送SIGTERM信號

 

這個方案雖然能夠在達到設置的超時時間後輸出超時錯誤,但實際上還是會存在一些問題

在生產環境下(LInux)中,我們發現雖然主進程不在了,但子進程npm還在執行,但由於父進程被殺死了導致這個npm子進程處於殭屍進程,殺不掉

 

優點:能夠輸出控制檯的結果,能夠進行超時的控制

缺點:無法殺乾淨所有的子進程,可能會導致子進程變成殭屍進程,超時之後無法輸出命令的執行過程信息

 

方案五:subprocess.Popen

那既然使用subprocess的kill方法或者是terminate方法,無法殺乾淨所有的子進程,那麼可以使用os.killpg方法來進行殺掉子進程,只需要知道子進程的進程號即可

 

另外,查閱了subprocess的官方文檔(https://docs.python.org/zh-cn/3.7/library/subprocess.html#popen-objects),裏面有提到一個communicate方法,可以實現超時控制,另外爲什麼要用communicate方法,可以參考:安全開發 | Python Subprocess庫在使用中可能存在的安全風險總結 

 

注意:如果要使用os.killpg方法的話,需要通過preexec_fn 參數讓popen成立自己的進程組,然後向進程組發送SIGTERM 或 SIGKILL,中止 subprocess.Popen 所啓動進程的子子孫孫,可參考:subprocess之preexec_fn

Popen在Linux/Unix平臺下的實現方式是,先fork一個子進程,然後讓這個子進程去exec載入外部可執行程序。被執行程序可能會再fork一些子進程來進行工作,從被執行程序被fork之後,產生的子進程都有獨立的進程空間和pid,超出了Popen可控制的範圍(如果不設置preexec_fn 和 start_new_session的話)。而Popen的preexec_fn 參數,接受一個回調函數,在fork子進程之後並且exec之前會執行這個回調函數,可以利用這個特性對被運行的子進程做出一些修改,比如執行setsid函數來成立一個獨立的進程組

Linux 的進程組是一個進程的集合,任何進程用系統調用 setsid 可以創建一個新的進程組,並讓自己成爲首領進程。首領進程的子子孫孫只要沒有再調用 setsid 成立自己的獨立進程組,那麼它都將成爲這個進程組的成員。 之後進程組內只要還有一個存活的進程,那麼這個進程組就還是存在的,即使首領進程已經死亡也不例外。 而這個存在的意義在於,我們只要知道了首領進程的 pid (同時也是進程組的 pgid), 那麼可以給整個進程組發送 signal,組內的所有進程都會收到。

因此利用這個特性,就可以通過 preexec_fn 參數讓 Popen 成立自己的進程組, 然後再向進程組發送 SIGTERM 或 SIGKILL,中止 subprocess.Popen 所啓動進程的子子孫孫。當然,前提是這些子子孫孫中沒有進程再調用 setsid 分裂自立門戶。

 

所以,改進的方案如下:

import subprocess
import os
import signal

def run_cmd(cmd_string, timeout=20):
    print("命令爲:" + cmd_string)
    p = subprocess.Popen(cmd_string, stderr=subprocess.STDOUT, stdout=subprocess.PIPE, shell=True, close_fds=True,
                         preexec_fn=os.setsid)
    try:
        (msg, errs) = p.communicate(timeout=timeout)
        ret_code = p.poll()
        if ret_code:
            code = 1
            msg = "[Error]Called Error : " + str(msg.decode('utf-8'))
        else:
            code = 0
            msg = str(msg.decode('utf-8'))
    except subprocess.TimeoutExpired:
        # 注意:不能只使用p.kill和p.terminate,無法殺乾淨所有的子進程,需要使用os.killpg
        p.kill()
        p.terminate()
        os.killpg(p.pid, signal.SIGTERM)

        # 注意:如果開啓下面這兩行的話,會等到執行完成才報超時錯誤,但是可以輸出執行結果
        # (outs, errs) = p.communicate()
        # print(outs.decode('utf-8'))

        code = 1
        msg = "[ERROR]Timeout Error : Command '" + cmd_string + "' timed out after " + str(timeout) + " seconds"
    except Exception as e:
        code = 1
        msg = "[ERROR]Unknown Error : " + str(e)

    return code, msg
  • close_fds=True,此時除了文件描述符爲0 , 1 and 2,其他子進程都要被殺掉。(Linux中所有的進程都是進程0的子進程)
  • pid=1的是init,內核完成之後啓動的第一個進程,然後init根據/etc/inittab的內容再去啓動其它進程。)
  • os.setsid(): 使獨立於終端的進程(不響應sigint,sighup等),使脫離終端。
  • SIGTERM: 終止信號
  • os.killpg( p.pid,signal.SIGTERM): 發送終止信號到組進程p.pid

 

我們以爲這個方案是最後的方案了,但其實還是有問題的,首先是在超時時候,無法獲取執行的結果,如果想要獲取命令執行的結果,可以註釋掉中間的這兩行,但是呢,超時的控制就會失效,一定會等到命令執行完成纔會輸出超時異常同時輸出命令的執行結果,無法實現到達超時時間之後,立即殺掉進程同時輸出命令執行的結果

# 注意:如果開啓下面這兩行的話,會等到執行完成才報超時錯誤,但是可以輸出執行結果
(outs, errs) = p.communicate()
print(outs.decode('utf-8'))

另外呢,該方案在Windows不能運行,因爲Windows下的os是沒有setsid的

 

優點:能夠進行超時的控制,能夠殺掉所有的子進程

缺點:超時之後無法輸出命令執行結果,無法在Windows下運行

 

另外由於一些未知的原因,導致超時的概率比之前的更高,手動執行倒是沒有任何的問題,至今找不到任何的原因

所以只能換其他的超時控制方案了

 

方案六:subprocess.Popen

爲了兼容Windows系統以及爲了更安全,官方建議使用start_new_session=True來代替了上面的方案裏面的preexec_fn=os.setsid

官方文檔裏面描述:

警告
preexec_fn 形參在應用程序中存在多線程時是不安全的。子進程在調用前可能死鎖。如果你必須使用它,保持警惕!最小化你調用的庫的數量。 

註解
如果你需要修改子進程環境,使用 env 形參而非在 preexec_fn 中進行。 start_new_session 形參可以代替之前常用的 preexec_fn 來在子進程中調用 os.setsid()。 

如果start_new_session設置爲True,那麼將在子進程執行之前進行setid(僅Posix系統)

 

注意:不管是設置preexec_fn=os.setsid 或者是 設置 start_new_session=True,都不能保證百分百清理掉子進程,一旦被執行的子進程的子進程調用使用了setsid的話,那麼也同樣是無法清理掉的

 

代碼如下:

import os
import signal
import subprocess
import platform

def run_cmd(cmd_string, timeout=20):
    print("命令爲:" + cmd_string)
    p = subprocess.Popen(cmd_string, stderr=subprocess.STDOUT, stdout=subprocess.PIPE, shell=True, close_fds=True,
                         start_new_session=True)

    format = 'utf-8'
    if platform.system() == "Windows":
        format = 'gbk'

    try:
        (msg, errs) = p.communicate(timeout=timeout)
        ret_code = p.poll()
        if ret_code:
            code = 1
            msg = "[Error]Called Error : " + str(msg.decode(format))
        else:
            code = 0
            msg = str(msg.decode(format))
    except subprocess.TimeoutExpired:
        # 注意:不能只使用p.kill和p.terminate,無法殺乾淨所有的子進程,需要使用os.killpg
        p.kill()
        p.terminate()
        os.killpg(p.pid, signal.SIGTERM)

        # 注意:如果開啓下面這兩行的話,會等到執行完成才報超時錯誤,但是可以輸出執行結果
        # (outs, errs) = p.communicate()
        # print(outs.decode('utf-8'))

        code = 1
        msg = "[ERROR]Timeout Error : Command '" + cmd_string + "' timed out after " + str(timeout) + " seconds"
    except Exception as e:
        code = 1
        msg = "[ERROR]Unknown Error : " + str(e)

    return code, msg

 

優點:能夠進行超時的控制,能夠殺掉所有的子進程,Windows下也可以運行

缺點:超時之後無法輸出命令執行結果

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章