pyppeteer使用及docker中產生大量殭屍進程的解決方法

pyppeteer簡介

Puppeteer(中文翻譯」操縱木偶的人」) 是 Google Chrome 團隊官方的無界面(Headless)Chrome 工具,它是一個 Node 庫,提供了一個高級的 API 來控制 DevTools協議上的無頭版 Chrome 。也能夠配置爲使用完整(非無頭)的 Chrome。Chrome 素來在瀏覽器界穩執牛耳,所以,Chrome Headless 必將成爲 web 應用自動化測試的行業標杆。使用 Puppeteer,至關於同時具備 Linux 和 Chrome 雙端的操做能力,應用場景可謂很是之多。此倉庫的創建,便是嘗試各類折騰使用 GoogleChrome Puppeteer;以期在好玩的同時,學到更多有意思的操做。
而pyppeteer 是對無頭瀏覽器 puppeteer的 Python 封裝,可讓你使用python來操做Chrome。css

Pyppeteer的GIT
Pyppeteer官方文檔html

使用過程當中的問題

  • pyppeteer api提供的close()命令沒法真正的關閉瀏覽器,會形成不少的殭屍進程
  • websockets 版本過高致使報錯pyppeteer.errors.NetworkError: Protocol error Network.getCookies: Target close
  • chromium瀏覽器多開頁面卡死問題
  • 瀏覽器窗口很大,內容顯示很小的問題

pyppeteer使用

pyppeteer安裝

python3 -m pip install pyppeteer
複製代碼

在初次使用pyppeteer的時候他會自動下載chromium(看心情,大部分狀況下能夠用龜速形容),或者直接去官網下載最新版的瀏覽器而後在代碼中指定瀏覽器的路徑。 chromium下載地址python

簡單入門

import asynciofrom pyppeteer import launch

async def main():
    # 建立一個瀏覽器
    browser = await launch({
        'executablePath': '你下載的Chromium.app/Contents/MacOS/Chromium',
    })
    # 打開一個頁面,同一個browser能夠打開多個頁面
    page = await browser.newPage()
    await page.goto('https://baidu.com') # 訪問指定頁面
    await page.screenshot(path='example.png')  # 截圖
    await page.close() # 關閉頁面
    await browser.close() # 關閉瀏覽器(實測中發現打開多個頁面會產生大量殭屍進程)

asyncio.get_event_loop().run_until_complete(main())
複製代碼

運行上面這一段代碼會產生一張頁面截圖,若是在運行中報錯pyppeteer.errors.NetworkError: Protocol error Network.getCookies: Target close能夠經過下降websockets 版原本解決mysql

pip uninstall websockets #卸載websockets
pip install websockets==6.0
或者
pip install websockets==6.0 --force-reinstall #指定安裝6.0版本
複製代碼

重要參數設置及方法

import asynciofrom pyppeteer import launch


async def intercept_request(req):
    # 不加載css和img等資源
    if req.resourceType in ["image", "media", "eventsource", "websocket", "stylesheet", "font"]:
        await req.abort() #鏈接請求
    else:
        res = {
            "method": req.method,
            "url": req.url,
            "data": "" if req.postData == None else req.postData,
            "res": "" if req.response == None else req.response
        }
        print(res) # 打印請求的內容
        await  req.continue_() #繼續請求,能夠添加參數將請求地址重定向、改變請求的headers

async def intercept_response(res):
    resourceType = res.request.resourceType
    # 攔截ajax請求獲取數據
    if resourceType in ['xhr']:
        resp = await res.json()
        print(resp)# 這裏能夠操做mysql、redis或者設計一個class來保存數據
        
async def main():
    # 建立一個瀏覽器
    browser = await launch({
        'executablePath': '你下載的Chromium.app/Contents/MacOS/Chromium',
        'headless': False, # 關閉無頭模式。主要在測試環境調試使用
        'devtools': True, # 打開 chromium 的 devtools與headless配個使用
        'args': [ 
             '--disable-extensions',
             '--hide-scrollbars',
             '--disable-bundled-ppapi-flash',
             '--mute-audio',
             '--no-sandbox',# --no-sandbox 在 docker 裏使用時須要加入的參數,否則會報錯
             '--disable-setuid-sandbox',
             '--disable-gpu',
          ],
         'dumpio': True, #把無頭瀏覽器進程的 stderr 核 stdout pip 到主程序,也就是設置爲 True 的話,chromium console 的輸出就會在主程序中被打印出來
    })
    # 打開一個頁面,同一個browser能夠打開多個頁面
    page = await browser.newPage()
    # 是否啓用JS,enabled設爲False,則無渲染效果,若是頁面有ajax請求須要開啓此項
    await page.setJavaScriptEnabled(enabled=True)
    # 是否容許攔截請求,若是開啓能夠註冊的兩個回調函數,在瀏覽器發出請求和獲取到請求以前指向這兩個函數。
    await page.setRequestInterception(value=True)
    page.on('request', intercept_request) # 請求的內容
    page.on('response', intercept_response) # 響應的內容
    await page.goto('https://baidu.com') # 訪問指定頁面
    await page.screenshot(path='example.png')  # 截圖
    await page.close() # 關閉頁面
    await browser.close() # 關閉瀏覽器(實測中發現打開多個頁面會產生大量殭屍進程)

asyncio.get_event_loop().run_until_complete(main())
複製代碼

殭屍進程

緣由分析

當一個父進程以fork()系統調用創建一個新的子進程後,核心進程就會在進程表中給這個子進程分配一個進入點,而後將相關信息存儲在該進入點所對應的進程表內。這些信息中有一項是其父進程的識別碼。 而當這個子進程結束的時候(好比調用exit命令結束),其實他並無真正的被銷燬,而是留下一個稱爲殭屍進程(Zombie)的數據結構(系統調用exit的做用是使進程退出,可是也僅僅限於一個正常的進程變成了一個殭屍進程,並不能徹底將其銷燬)。此時原來進程表中的數據會被該進程的退出碼(exit code)、執行時所用的CPU時間等數據所取代,這些數據會一直保留到系統將它傳遞給它的父進程爲止。因而可知,defunct進程的出現時間是在子進程終止後,可是父進程還沒有讀取這些數據以前。
此時,該殭屍子進程已經放棄了幾乎全部的內存空間,沒有任何可執行代碼,也不能被調度,僅僅在進程列表中保留一個位置,記載該進程的退出狀態信息供其餘進程收集,除此以外,殭屍進程再也不佔有任何存儲空間。他須要他的父進程來爲他收屍,若是他的父進程沒有安裝SIGCHLD信號處理函數調用wait 或 waitpid() 等待子進程結束,也沒有顯式忽略該信號,那麼它就一直保持殭屍狀態,若是這時候父進程結束了,那麼init進程會自動接手這個子進程,爲他收屍,他仍是能被清除掉的。 拿Nginx做爲例子,默認是做爲後臺守護進程。它是這麼工做的。第一,Nginx建立一個子進程。第二,原始的Nginx進程退出了。第三,Nginx子進程被init進程給接收了。 linux

可是若是父進程是一個循環,不會結束,那麼子進程就會一直保持殭屍狀態,這就是系統中爲何有時候會有不少的殭屍進程。
一個子進程終止了,但一直被等待就變成了」殭屍「。
defunct狀態下的殭屍進程是不能直接使用kill -9命令殺掉的,不然就不叫殭屍進程了。
Unix的進程是一個有序的樹。每一個進程能夠派生子進程,每一個進程具備一個除了最頂層之外的父進程,這個最頂層的進程是init進程。它是當你啓動系統時由內核啓動。這個init進程負責啓動系統的其他部分,如啓動SSH服務,從啓動Docker守護進程,啓動Apache / Nginx的,啓動你的GUI桌面環境,等等。他們每一個進程均可能會反過來派生出更多的子進程。

若是一個進程終止會發生什麼?bash(PID 5)進程終止,它變成了一個所謂的「中止活動的進程」,也稱爲「殭屍進程」。
這時PID5要等待sshd2調用wait 或 waitpid() 而後完全結束,假設sshd2沒有調用相應的方法,那麼PID5就會一直等待下去,當sshd2結束的時候PID5會被init進程接手而後處理掉。
可是在docker中init 1每每是你的任務進程,須要不間斷的運行不能退出,這就致使了殭屍進程無人清理愈來愈多,所以不建議在docker中直接運行腳本,而是先啓動/bin/bash而後啓動腳本

CMD ["/bin/bash", "-c", "set -e && 你的任務腳本"]
複製代碼

可是這種方法也有問題,不能優雅的結束進程。假設你用kill發送SIGTERM信號給bash.Bash終止了,可是沒有發送SIGTERM給它的子進程! 當bash結束了,內核結束整個容器中的全部進程。包擴經過SIGKILL信號沒有被幹淨的終結的進程。SIGKILL不能被捕獲,因此進程是沒有辦法乾淨的終結。假設你運行的應用程序正忙於寫文件;在寫的過程當中,應用被不乾淨的終止了這個文件可能會崩潰。不乾淨的終止是很壞的事情。很像把服務器的電源給拔掉。 可是爲何要關心init進程是否被SIGTERM給終結了呢?那是由於docker stop 發送 SIGTERM信號給init進程了。「docker stop」 應該乾淨的中止容器,以致於稍後你可以用「docker start」啓動它。git

解決辦法

  • 在linux下找到該defunct殭屍進程的父進程,將該進程的父進程殺掉,而後init進程會自動接手其子進程併爲子進程收屍。ps -ef | grep defunct_process_pid
  • docker中在啓動真正的工做腳本以前先啓動/bin/bash用來給殭屍進程收屍

docker環境搭建

鏡像搭建

dockerfile文件github

FROM centos:7
RUN set -ex \ # 預安裝所需組件     && yum install -y wget tar libffi-devel zlib-devel bzip2-devel openssl-devel ncurses-devel sqlite-devel readline-devel tk-devel gcc make initscripts \
    && wget https://www.python.org/ftp/python/3.6.0/Python-3.6.0.tgz \
    && tar -zxvf Python-3.6.0.tgz \
    && cd Python-3.6.0 \
    && ./configure prefix=/usr/local/python3 \
    && make \
    && make install \
    && make clean \
    && rm -rf /Python-3.6.0* \
    && yum install -y epel-release \
    && yum install -y python-pip
# 設置默認爲python3
RUN set -ex \ # 備份舊版本python     && mv /usr/bin/python /usr/bin/python27 \
    && mv /usr/bin/pip /usr/bin/pip-python2.7 \
    # 配置默認爲python3
    && ln -s /usr/local/python3/bin/python3.6 /usr/bin/python \
    && ln -s /usr/local/python3/bin/pip3 /usr/bin/pip
# 修復因修改python版本致使yum失效問題
RUN set -ex \ && sed -i "s#/usr/bin/python#/usr/bin/python2.7#" /usr/bin/yum \ && sed -i "s#/usr/bin/python#/usr/bin/python2.7#" /usr/libexec/urlgrabber-ext-down \ && yum install -y deltarpm # 基礎環境配置
RUN set -ex \ # 修改系統時區爲東八區     && rm -rf /etc/localtime \
    && ln -s /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
    && yum install -y vim \
    # 安裝定時任務組件
    && yum -y install cronie
# 支持中文
RUN localedef -c -f UTF-8 -i zh_CN zh_CN.utf8 # chrome瀏覽器依賴
RUN yum install kde-l10n-Chinese -y RUN yum install pango.x86_64 libXcomposite.x86_64 libXcursor.x86_64 libXdamage.x86_64 libXext.x86_64 libXi.x86_64 libXtst.x86_64 cups-libs.x86_64 libXScrnSaver.x86_64 libXrandr.x86_64 GConf2.x86_64 alsa-lib.x86_64 atk.x86_64 gtk3.x86_64 -y RUN yum install ipa-gothic-fonts xorg-x11-fonts-100dpi xorg-x11-fonts-75dpi xorg-x11-utils xorg-x11-fonts-cyrillic xorg-x11-fonts-Type1 xorg-x11-fonts-misc -y # 更新pip版本
RUN pip install --upgrade pip ENV LC_ALL zh_CN.UTF-8
RUN mkdir -p /usr/src/scrapy COPY requirements.txt /usr/src/scrapy RUN pip install -i https://pypi.douban.com/simple/ -r /usr/src/scrapy/requirements.txt 複製代碼

docker-compose文件web

version: '3.3'
services:
  scrapy:
    privileged: true
    build: scrapy
    tty: true
    volumes:
      - type: bind
        source: /爬蟲文件路徑
        target: /usr/src/scrapy
    ports:
      - "9999:9999"
    networks:
      scrapynet:
        ipv4_address: 172.19.0.8
    command: [/bin/bash, -c, set -e && python /usr/src/scrapy/job.py]
  
networks:
  scrapynet:
    driver: bridge
    ipam:
      driver: default
      config:
        - subnet: 172.19.0.0/24
複製代碼

command: [/bin/bash, -c, set -e && python /usr/src/scrapy/job.py]命令解釋ajax

  • /bin/bash 防止產生殭屍進程,-e 指令阻止bash把這個腳本當作簡單的命令直接執行exec()
  • python /usr/src/scrapy/job.py 真正的工做腳本

基於pyppeteer的爬蟲腳本

import asyncio,random,psutil,os,signal,time
from pyppeteer import launcher
# hook 禁用 防止監測webdriver
launcher.AUTOMATION_ARGS.remove("--enable-automation")
from pyppeteer import launch
async def intercept_request(req):
    if req.resourceType in ["image"]:
        await req.abort()
    else:
        res = {
            "method": req.method,
            "url": req.url,
            "data": "" if req.postData == None else req.postData,
            "res": "" if req.response == None else req.response
        }
        print(res)
        await req.continue_()


async def intercept_response(res):
    resourceType = res.request.resourceType
    if resourceType in ['xhr']:
        resp = await res.json()
        print(resp)

class newpage(object):
    width, height = 1920, 1080
    def __init__(self, page_url,chrome_browser):
        self.url = page_url
        self.browser = chrome_browser

    async def run(self):
        t = random.randint(1, 4)
        tt = random.randint(t, 10)
        await asyncio.sleep(tt)
        try:
            page = await self.browser.newPage()
            await page.setUserAgent(
                userAgent='Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/70.0.3521.2 Safari/537.36')
            await page.setViewport(viewport={'width': self.width, 'height': self.height})
            # 是否啓用JS,enabled設爲False,則無渲染效果
            await page.setJavaScriptEnabled(enabled=True)
            await page.setRequestInterception(value=True)
            page.on('request', intercept_request)
            page.on('response', intercept_response)
            await page.goto(self.url, options={'timeout': 30000})
            await page.waitFor(selectorOrFunctionOrTimeout=1000)
            try:
                await page.close()
                return self.url
            except BaseException as err:
                return "close_newpage: {0}".format(err)
        except BaseException as err:
            return "newpage: {0}".format(err)

class Browser(object):
    width, height = 1920, 1080
    browser = None
    is_headless = True
    url_list = []

    def __init__(self,urls):
        self.url_list = urls

    # 封裝了kill()方法殺死chrome主進程,讓init 1進程接管其殭屍子進程處理殭屍進程
    def kill(self,name):
        # win平臺
        # subprocess.Popen("taskkill /F /IM chrome.EXE ", shell=True)
        
        # linux平臺
        try:
            pid = self.browser.process.pid
            pgid = os.getpgid(pid)
            # 強制結束
            os.kill(pid, signal.SIGKILL)
            print("結束進程:%d" % pid)
            print("父進程是:%d" % pgid)
            print("等待結果:%d" % self.browser.process.wait())
        except BaseException as err:
            print("close: {0}".format(err))
        time.sleep(3)
        # 查看是否還有其餘進程
        for proc in psutil.process_iter():
            if name in proc.name():
                try:
                    os.kill(proc.pid, signal.SIGTERM)
                    print('已殺死[pid:%s]的進程[pgid:%s][名稱:%s]' % (proc.pid,pgid,proc.name()))
                except BaseException as err:
                    print("kill: {0}".format(err))

    # 打開瀏覽器
    async def newbrowser(self):
        try:
            self.browser = await launch({
                'headless': self.is_headless,
                'devtools': not self.is_headless,
                'dumpio': True,
                'autoClose': True,
                # 'userDataDir': './userdata',
                'handleSIGTERM': True,
                'handleSIGHUP': True,
                # 'executablePath':'C:/Users/zhang/Desktop/chrome-win/chrome.exe',
                'args': [
                    '--no-sandbox',  # --no-sandbox 在 docker 裏使用時須要加入的參數,否則會報錯
                    '--disable-gpu',
                    '--disable-extensions',
                    '--hide-scrollbars',
                    '--disable-bundled-ppapi-flash',
                    '--mute-audio',
                    '--disable-setuid-sandbox',
                    '--disable-xss-auditor',
                    '--window-size=%d,%d' % (self.width, self.height)
                ]
            })
        except BaseException as err:
            print("launch: {0}".format(err))

        print('----打開瀏覽器----')

    async def open(self):
        await self.newbrowser()
        try:
            tasks = [asyncio.ensure_future(newpage(url,self.browser).run()) for url in self.url_list]
            for task in asyncio.as_completed(tasks):
                result = await task
                print('Task ret: {}'.format(result))
        except BaseException as err:
            print("open: {0}".format(err))
        # browser.close()方法沒法完全退出chrome進程,這裏咱們本身封裝了kill()方法殺死chrome主進程,讓init 1進程接管其殭屍子進程
        # await self.browser.close()

    def main(self):
        loop = asyncio.get_event_loop()
        loop.run_until_complete(self.open())
        print('----關閉瀏覽器----')
        self.kill('chrom')

if __name__ == '__main__':
    url_list=[
        'https://www.baidu.com/',
        'https://www.baidu.com/',
        'https://www.baidu.com/',
        'https://www.baidu.com/',
    ]
    while True:
        # 不停的添加任務
        o = Browser(url_list)
        print(o.main())
複製代碼
相關文章
相關標籤/搜索
本站公眾號
   歡迎關注本站公眾號,獲取更多信息