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())
复制代码
相关文章
相关标签/搜索
本站公众号
   欢迎关注本站公众号,获取更多信息