HTML页面导出为PDF完整指南(部署篇)

642 阅读9分钟

接着HTML页面导出为PDF完整指南(实现篇)的内容,本篇文章给大家补上部署部分。

部署准备

基础镜像构建

如何优雅地部署一个 Next.js 应用一文中,笔者为了部署Next.js应用,构建了一个包含Nginx+Node环境的基础镜像nginx-node

接下来,我将以这款基础镜像为原型,构建可以部署puppeteer应用的新基础镜像。

Q:为什么不用puppeteer提供的基础镜像 ghcr.io/puppeteer/p…?

A:因为后续还要用nginx做负载均衡,用自己的镜像更可控些

puppeteer作为Google开发的一个Node.js库,依赖于Headless Chrome或Chromium浏览器的API。因此,要想使用puppeteer正常运作,需要在容器中添加Chrome或者Chromium。
笔者基础镜像的操作系统是 linux:alpine(体积小、部署轻),其只支持Chromium。因此,要做的就是在之前的基础镜像上安装Chromium依赖(这里需要梯子or切换镜像源,否则在国内是安装不上的)。基础镜像的Dockerfile文件如下:

    FROM nginx:alpine AS deps

    # set the time zone to Zone 8
    ENV TZ Asia/Shanghai

    # 设置为淘宝镜像源
    RUN echo "https://mirrors.aliyun.com/alpine/edge/testing/" >> /etc/apk/repositories && \
        apk --no-cache update

    # 下载并安装 node.js chromium
    RUN apk add --no-cache nodejs npm chromium

    # 设置时区
    RUN ln -fs /usr/share/zoneinfo/${TZ} /etc/localtime \
        && echo ${TZ} > /etc/timezone

得到基础镜像后将其推送到dockerhub(或者推送到自己公司内的镜像仓库)。

image.png

部署镜像构建

有了基础镜像,下一步便是改造代码并构建部署镜像。

Q:改造哪块的代码呢?

A:当然是启动puppeteer的代码,在本地开发中,因为本机安装有Chrome,puppeteer会自动找到Chrome的安装路径并启动,但是在Docker环境,需要开发者手动指定Chrome|Chromium的安装路径。

首先是指定Chromium的安装路径:

    const getPuppeteerLaunchOptions = () => {
      const launchOptions = {
        headless: 'new',
        args: ['--no-sandbox', '--disable-setuid-sandbox'],
      };
      if (process.env.RUNTIME_ENV !== 'local') {
        launchOptions.executablePath = '/usr/bin/chromium-browser';
      }
      return launchOptions;
    };

上面的代码主要作用是,当运行环境不为本地时,配置Chromium的启动路径。

完成了代码的改造后,下一步就是编写部署镜像的Dockerfile:

    # 使用自定义镜像, 内置了nginx+node.js 18+chromium 112, 时区设置为: Asia/Shanghai (北京时间)
    FROM xxx:chromium AS deps

    WORKDIR /app

    COPY ./docker/test/nginx.conf /etc/nginx/conf.d/default.conf
    COPY ./docker/test/nginx_default.conf /etc/nginx/nginx.conf
    RUN rm -rf ./docker/test/nginx.conf ./docker/test/nginx_default.conf
    COPY . .

    EXPOSE 80

    # start docker
    CMD ["sh", "-c", "npm run start & nginx -g 'daemon off;'"]

部署实施

通过上面的步骤,基本完成了部署的前期准备工作,下一步便是部署并测试。

笔者搭建好智研流水线,将应用部署到tkex上,然后创建一个clb,在udns上将域名指向clb,完成域名的配置。

  1. 测试导出HTML能力 pdf-导出html.png 功能看起来正常
  2. 测试导出PDF能力 pdf-汉字乱码.png 怎么回事儿,汉字都没有显示~

不用猜了,肯定是缺少中文字体包啦,接下来就是给chromium添加中文字体包。

安装中文字体包

linux:alpine 中管理字体包的应用是fontconfig,所以只需要安装好该应用并添加相应字体包就行,因为中文字体包是一个基础能力,所以可以把字体包安装到基础镜像里:

    FROM nginx:alpine AS deps

    # set the time zone to Zone 8
    ENV TZ Asia/Shanghai

    # 设置为淘宝镜像
    RUN echo "https://mirrors.aliyun.com/alpine/edge/testing/" >> /etc/apk/repositories && \
        apk --no-cache update

    # 下载并安装 node.js chromium
    RUN apk add --no-cache nodejs npm chromium

    # 安装 文泉驿正黑字体
    RUN apk add --no-cache fontconfig && \
        apk add --no-cache wqy-zenhei ttf-dejavu && \
        rm -rf /var/cache/apk/* && \
        mkfontscale && mkfontdir && fc-cache

    # 设置时区
    RUN ln -fs /usr/share/zoneinfo/${TZ} /etc/localtime && \ 
    	echo ${TZ} > /etc/timezone

上面的命令安装了 文泉驿正黑字体 包,如果产品同学想要其他字体,可以手动下载到应用中,然后通过COPY命令复制到/usr/share/fonts/目录。比如笔者就使用了微软雅黑字体:

    # 配置微软雅黑字体包
    COPY ./fonts/microsoft-black/ /usr/share/fonts/

pm2托管多node进程

在使用导出能力时,因为要保证报告完全打开,所以有大量的等待任务,而一些报告的指标查询得比较慢,造成导出可能耗时30s-60s。当并发量大的时候,很容易出现node进程阻塞的情况。通过跟有多年运维开发经验的同学讨论,决定在一个pod里启动多个node进程,搭配nginx的负载均衡能力,采用轮询的方式处理API请求,保证请求均匀分布到每一个进程中,提高服务的并发能力。

为了管理多个node进程,笔者决定使用pm2来托管。于是,部署镜像的启动命令就变成了:

    # 此处省略其他配置...

    # start docker
    CMD ["sh", "-c", "./node_modules/.bin/pm2 start ecosystem.config.js --env test & nginx -g 'daemon off;'"]

对于pm2,笔者没有采取全局安装的方式,而是在项目内安装,其使用效果跟全局安装是完全一致的。ecosystem.config.js的配置内容如下:

    module.exports = {
      apps: [
        {
          name: 'app1',
          script: './app.js',
          max_memory_restart: '1G',// 内存超过1G时自动重启进程
          log_date_format: 'YYYY-MM-DD HH:mm:ss',
          out_file: './logs-pm2/out.log',
          error_file: './logs-pm2/error.log',
          env: {
            PORT: 3000,
            NODE_ENV: 'production',
          },
          env_test: {
            ENV: 'test',
            RUNTIME_ENV: 'test',
          },
          env_prod: {
            ENV: 'prod',
            RUNTIME_ENV: 'prod',
          },
        },
        {
          name: 'app2',
          script: './app.js',
          max_memory_restart: '1G',
          log_date_format: 'YYYY-MM-DD HH:mm:ss',
          out_file: './logs-pm2/out.log',
          error_file: './logs-pm2/error.log',
          env: {
            PORT: 3001,
            NODE_ENV: 'production',
          },
          env_test: {
            ENV: 'test',
            RUNTIME_ENV: 'test',
          },
          env_prod: {
            ENV: 'prod',
            RUNTIME_ENV: 'prod',
          },
        },
        {
          name: 'app3',
          script: './app.js',
          max_memory_restart: '1G',
          log_date_format: 'YYYY-MM-DD HH:mm:ss',
          out_file: './logs-pm2/out.log',
          error_file: './logs-pm2/error.log',
          env: {
            PORT: 3002,
            NODE_ENV: 'production',
          },
          env_test: {
            ENV: 'test',
            RUNTIME_ENV: 'test',
          },
          env_prod: {
            ENV: 'prod',
            RUNTIME_ENV: 'prod',
          },
        },
      ],
    };

接下来是nginx的配置:

    upstream node_backend {
        server 127.0.0.1:3000 max_fails=0 fail_timeout=60s weight=1;
        server 127.0.0.1:3001 max_fails=0 fail_timeout=60s weight=1;
        server 127.0.0.1:3002 max_fails=0 fail_timeout=60s weight=1;
    }

    server {
        listen 80;
        server_name puppeteer-server;
        root /app;
        autoindex off;

        location / {
            proxy_pass http://node_backend;
            proxy_http_version 1.1;
            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection 'upgrade';
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $remote_addr;
            proxy_set_header X-Forwarded-Proto $scheme;
            proxy_cache_bypass $http_upgrade;
            proxy_next_upstream error timeout invalid_header http_500 http_502 http_503 http_504 http_404 http_429 non_idempotent;
        }
    }

最终启动效果如下:

pdf-pm2启动效果.png

开启chromium沙箱

还记得在实现篇中提到的启动Chromium需要关闭浏览器的沙箱模式吗?

然后就收到了公司安全团队发来的警告:

pdf-沙箱问题.png

唉,事情还没完,还得继续搞啊...。

经过多次尝试,最终采用的方案是使用非root用户启动容器,以此确保开启浏览器的沙箱模式。关于为什么在Docker中无法以root用户身份启动Chrome的沙箱模式,ChatGPT给出的答案是:

Docker中以root用户启动Chrome时,通常是因为Docker容器本身以root用户身份运行。在这种情况下,Chrome无法以沙箱模式运行,因为沙箱模式需要在操作系统层面上设置特定的权限和限制,而这些权限和限制需要在容器启动时就设置好。

Chrome的沙箱模式依赖于Linux内核的安全模块,例如seccomp、AppArmor、SELinux等,以及特定的访问控制规则,这些规则是在Chrome启动时通过命令行参数传递给Chrome的。但是,如果容器以root用户身份运行,Chrome就无法获得足够的权限来设置这些安全模块和访问控制规则,从而无法启动沙箱。

为了解决这个问题,可以考虑在Docker容器中使用非root用户来运行Chrome,并设置相应的安全模块和访问控制规则。或者,也可以使用一些特殊的Docker镜像,例如Selenoid、Zalenium等,这些镜像已经针对性地解决了在Docker容器中运行Chrome的沙箱问题。

所以开发浏览器沙箱的关键点落到了创建一个新的用户上,这个没得巧的,只能一步一步试(倒不是创建用户麻烦,而是非root用户权限是受控的,要启动像nginxnode等应用,需要给与其相应的权限)。这里的测试过程省略,直接给出最终的部署镜像Dockerfile:

    # 使用自定义镜像, 内置了nginx+node.js 18+chromium 112, 时区设置为: Asia/Shanghai (北京时间)
    FROM xxx:chromium AS deps

    WORKDIR /app

    COPY ./docker/test/nginx.conf /etc/nginx/conf.d/default.conf
    COPY ./docker/test/nginx_default.conf /etc/nginx/nginx.conf
    RUN rm -rf ./docker/test/nginx.conf ./docker/test/nginx_default.conf
    COPY . .

    # 配置微软雅黑字体包
    COPY ./fonts/microsoft-black/ /usr/share/fonts/

    # 配置chrome-sandbox
    RUN apk add sudo && \
        cd /usr/lib/chromium && \
        sudo chown root:root chrome-sandbox && \
        sudo chmod 4755 chrome-sandbox && \
        export CHROME_DEVEL_SANDBOX=/usr/lib/chromium/chrome-devel-sandbox

    # 添加 puppeteer 用户,并给其添加工作目录访问权限
    RUN adduser -D -u 1000 -G root puppeteer && \
        adduser puppeteer nginx && \
        adduser puppeteer video && \
        chown -R puppeteer:root /var/cache/nginx && \
        touch /var/run/nginx.pid && \
        chown puppeteer:root /var/run/nginx.pid && \
        chmod -R 770 /app && \
        chown -R puppeteer:root /app

    # 切换用户为 puppeteer
    USER puppeteer

    # 暴露端口改为8080,因为1024及以下端口只能为root用户使用
    EXPOSE 8080

    # start docker
    CMD ["sh", "-c", "./node_modules/.bin/pm2 start ecosystem.config.js --env test & nginx -g 'daemon off;'"]

上面的docker命令创建了一个新的用户puppeteer并授予其运行nginxchromium的权限,因为容器里暴露的服务端口切换为了8080,因此之前的nginx配置也得修改一下,需要修改的是这一行

    ...
    server {
        listen  8080 ;
    	...
    }

至此,完整的puppeteer服务总算是部署完毕。

一点小插曲

部署完毕后服务运行不到24小时就出现了所有node进程雪崩的情况,开始以为是并发请求过多,后来通过查看服务监控状态,发现是服务本身存在内存泄露:

pdf-内存泄露.png 通过分析代码,发现是没有执行page.close(),随着页面的不断开启,内存使用逐渐上升,直到pod崩溃。

总结

  1. puppeteer的运行依赖于Chrome,因此需要在docker镜像中安装Chrome或Chromium;
  2. 如果打开的网页有中文字符,则需要给docker里的Chrome额外安装中文字体包;
  3. docker镜像的启动默认是root用户,无法开启浏览器沙箱,需要新建一个非root,并使用该用户启动node进程;
  4. 提高puppeteer服务并发能力的一个方案是,在一个pod内启动多个node进程,搭配nginx的负载均衡策略,以轮询地方式调用node进程;
  5. pm2可以用来作为node进程的托管进程,简单、高效。

参考

附录

字体包下载