likes
comments
collection
share

pyppeteer使用及docker中产生大量僵尸进程的解决方法

作者站长头像
站长
· 阅读数 5

pyppeteer简介

Puppeteer(中文翻译”操纵木偶的人”) 是 Google Chrome 团队官方的无界面(Headless)Chrome 工具,它是一个 Node 库,提供了一个高级的 API 来控制 DevTools协议上的无头版 Chrome 。也可以配置为使用完整(非无头)的 Chrome。Chrome 素来在浏览器界稳执牛耳,因此,Chrome Headless 必将成为 web 应用自动化测试的行业标杆。使用 Puppeteer,相当于同时具有 Linux 和 Chrome 双端的操作能力,应用场景可谓非常之多。 而pyppeteer 是对无头浏览器 puppeteer的 Python 封装,可以让你使用python来操作Chrome。

Pyppeteer的GIT Pyppeteer官方文档

使用过程中的问题

  • pyppeteer api提供的close()命令无法真正的关闭浏览器,会造成很多的僵尸进程
  • websockets 版本太高导致报错pyppeteer.errors.NetworkError: Protocol error Network.getCookies: Target close
  • chromium浏览器多开页面卡死问题
  • 浏览器窗口很大,内容显示很小的问题
  • 使用while True循环不停的执行任务导致python脚本占用内存越来越大导致程序假死

下面会针对使用过程中的各种问题给出解决方案

pyppeteer使用

pyppeteer安装

python3 -m pip install pyppeteer

在初次使用pyppeteer的时候他会自动下载chromium(看心情,大部分情况下可以用龟速形容),或者直接去官网下载最新版的浏览器然后在代码中指定浏览器的路径。 chromium下载地址

简单入门


from pyppeteer import launch
import asyncio

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 版本来解决

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进程给接收了。

pyppeteer使用及docker中产生大量僵尸进程的解决方法 但是如果父进程是一个循环,不会结束,那么子进程就会一直保持僵尸状态,这就是系统中为什么有时候会有很多的僵尸进程。 一个子进程终止了,但一直被等待就变成了”僵尸“。 defunct状态下的僵尸进程是不能直接使用kill -9命令杀掉的,否则就不叫僵尸进程了。 Unix的进程是一个有序的树。每个进程可以派生子进程,每个进程具有一个除了最顶层以外的父进程,这个最顶层的进程是init进程。它是当你启动系统时由内核启动。这个init进程负责启动系统的其余部分,如启动SSH服务,从启动Docker守护进程,启动Apache / Nginx的,启动你的GUI桌面环境,等等。他们每个进程都可能会反过来派生出更多的子进程。

pyppeteer使用及docker中产生大量僵尸进程的解决方法 如果一个进程终止会发生什么?bash(PID 5)进程终止,它变成了一个所谓的“停止活动的进程”,也称为“僵尸进程”。 pyppeteer使用及docker中产生大量僵尸进程的解决方法 这时PID5要等待sshd2调用wait 或 waitpid() 然后彻底结束,假设sshd2没有调用相应的方法,那么PID5就会一直等待下去,当sshd2结束的时候PID5会被init进程接手然后处理掉。 pyppeteer使用及docker中产生大量僵尸进程的解决方法 但是在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”启动它。

while True 循环导致程序卡死

通过while True不停的抓取数据,python进程占用内存会慢慢变大速度也会变慢,我尝试过在每一个变量使用完成后显示的调用del 变量然后在循环的结尾调用gc.collect()进行垃圾回收,但是程序在运行一段时间后依然卡死。看了几天的循环引用和垃圾回收的相关文章还是一头的雾水。

终极大招

经过几天的测试我打算使用子进程的方式来管理chrome浏览器的调用及相关数据的处理,因为进程是系统进行资源分配和调度的基本单位,进程有自己独立的内存空间和cpu资源,当进程结束的时候会系统清理归还PCB。 具体的实施是通过继承Process实现自定义进程类,然后在每次while循环尾部结束掉子进程。

进程间的通讯

使用子进程管理爬虫的方式虽然可以有效控制内存占用,但是爬虫的数据如何返回是个问题。当然可以在子进程中直接操作mysql或者reids保存数据,这种方式适合单节点爬虫,不适合分布式多节点爬虫。通常分布式爬虫是多个节点抓数据一个节点存数据。 通过Manager进行数据共享,将子进程的数据保存到主进程中。但当使用Manager处理list、dict等可变数据类型时,是有陷阱的这里不讲解可自行科普。

最终代码及环境

镜像搭建

dockerfile文件

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文件

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]命令解释

  • /bin/bash 防止产生僵尸进程,-e 指令阻止bash把这个脚本当做简单的命令直接执行exec()
  • python /usr/src/scrapy/job.py 真正的工作脚本

基于pyppeteer的爬虫脚本

import asyncio,random,psutil,os,signal,time,subprocess,gc
from pyppeteer import launcher
from multiprocessing import Process
from multiprocessing import Manager
# hook  禁用 防止监测webdriver
launcher.AUTOMATION_ARGS.remove("--enable-automation")
from pyppeteer import launch

class AjaxData():
    response_data=[]

    @classmethod
    def init(cls):
        cls.response_data.clear()

    @classmethod
    def save(cls,data):
        cls.response_data.append(data)

    @classmethod
    def get_data(cls):
        return cls.response_data

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()
        # 测试数据
        resp={'num':random.randint(1, 200)}
        AjaxData.save(resp)
        del 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(Process):
    width, height = 1920, 1080
    browser = None
    system = ''
    pid = 0
    is_headless = True
    url_list = []
    returnlist=[]

    def __init__(self,urls,return_list):
        Process.__init__(self)
        self.url_list = urls
        self.returnlist = return_list

    # 封装了kill()方法杀死chrome主进程,让init 1进程接管其僵尸子进程处理僵尸进程
    def kill(self, name: str = ''):
        if self.system == 'Windows':
            # win平台
            subprocess.Popen("taskkill /F /IM chrome.EXE ", shell=True)
        else:
            # linux平台
            # 查看进程是否存在
            if self.pid > 0 and psutil.pid_exists(self.pid):
                # 查看进程状态是否是运行
                p = psutil.Process(self.pid)
                print('浏览器状态:%s' % p.status())
                if p.status() != psutil.STATUS_ZOMBIE:
                    try:
                        pgid = os.getpgid(self.pid)
                        # 强制结束
                        os.kill(self.pid, signal.SIGKILL)
                        # os.kill(pgid, signal.SIGKILL)
                        print("结束进程:%d" % self.pid)
                        print("父进程是:%d" % pgid)
                        print("浏览器状态:%d" % self.browser.process.wait())
                    except BaseException as err:
                        print("close: {0}".format(err))
                del p
            # 查看是否还有其他进程
            for proc in psutil.process_iter():
                if name in proc.name():
                    try:
                        pgid = os.getpgid(proc.pid)
                        os.kill(proc.pid, signal.SIGKILL)
                        print('已杀死pid:%d的进程pgid:%d名称:%s' % (proc.pid, pgid, proc.name()))
                        del pgid
                    except BaseException as err:
                        print("kill: {0}".format(err))
        time.sleep(3)

    # 打开浏览器
    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()
        self.pid = self.browser.process.pid
        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))
            del tasks[:]
        except BaseException as err:
            print("open: {0}".format(err))
        # browser.close()方法无法彻底退出chrome进程,这里我们自己封装了kill()方法杀死chrome主进程,让init 1进程接管其僵尸子进程
        await self.browser.close()

    def run(self):
        AjaxData.init()
        loop = asyncio.get_event_loop()
        loop.run_until_complete(self.open())
        self.returnlist.extend(AjaxData.get_data())
        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:
        manager = Manager()
        return_list = manager.list()
        # 不停的执行任务
        p = Browser(url_list, return_list)
        p.start()
        p.join(30)
        if p.is_alive() == True:
            p.terminate()
            print('强制关闭子进程....')
        else:
            print('子进程已关闭...')
        # 打印子进程返回的数据
        print(return_list)
        # 清理
        del p
        del return_list[:]
        gc.collect()