接着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(或者推送到自己公司内的镜像仓库)。
部署镜像构建
有了基础镜像,下一步便是改造代码并构建部署镜像。
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,完成域名的配置。
- 测试导出HTML能力
功能看起来正常
- 测试导出PDF能力
怎么回事儿,汉字都没有显示~
不用猜了,肯定是缺少中文字体包啦,接下来就是给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;
}
}
最终启动效果如下:
开启chromium沙箱
还记得在实现篇中提到的启动Chromium需要关闭浏览器的沙箱模式吗?
然后就收到了公司安全团队发来的警告:
唉,事情还没完,还得继续搞啊...。
经过多次尝试,最终采用的方案是使用非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用户权限是受控的,要启动像nginx、node等应用,需要给与其相应的权限)。这里的测试过程省略,直接给出最终的部署镜像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并授予其运行nginx和chromium的权限,因为容器里暴露的服务端口切换为了8080,因此之前的nginx配置也得修改一下,需要修改的是这一行
...
server {
listen 8080 ;
...
}
至此,完整的puppeteer服务总算是部署完毕。
一点小插曲
部署完毕后服务运行不到24小时就出现了所有node进程雪崩的情况,开始以为是并发请求过多,后来通过查看服务监控状态,发现是服务本身存在内存泄露:
通过分析代码,发现是没有执行
page.close(),随着页面的不断开启,内存使用逐渐上升,直到pod崩溃。
总结
- puppeteer的运行依赖于Chrome,因此需要在docker镜像中安装Chrome或Chromium;
- 如果打开的网页有中文字符,则需要给docker里的Chrome额外安装中文字体包;
- docker镜像的启动默认是root用户,无法开启浏览器沙箱,需要新建一个非root,并使用该用户启动node进程;
- 提高puppeteer服务并发能力的一个方案是,在一个pod内启动多个node进程,搭配nginx的负载均衡策略,以轮询地方式调用node进程;
- pm2可以用来作为node进程的托管进程,简单、高效。