最近要部署数据采集平台到生成环境上去。需要两个docker容器,一个运行采集脚本,一个是运行vnc+chrome的远程x11 server,也是跑在容器中。这个X11 server可以完成模拟登录,自动上传,自动发布的功能,包括自动idle以保持会话。之前在单机上正确运行无误,所以迁移到两个容器中也无碍。
基本需求:
selenium remote sever 其实就是通过webdriver Json与浏览器交互,这也就介绍了为什么selenium能够实现支持各种语言,
不管是java python 等,都是通过selenium API翻译成Json 与浏览器进行交互。掌握了webdriver protocol 可以通过自己直接写request来实现与浏览器交互。
Chrome在网民手中不过是一个浏览器而已,但在开发者手中就是神兵利器。自动化测试、爬虫、抓包、调试诊断、逆向、以及自动化运维都少不了它。搭配一些可编程的代理工具,如mitmproxy,更是如有神助。
这种场景最好采用别人制作好的镜像,否则各种安装依赖会让你深陷泥潭。服务器端,root用户是不能直接运行chrome的。不然无法启动chrome。可行的方法是切换用户,或者在启动chrome时添加‘–no-sandbox’参数。
目前普遍采用的镜像是:
docker pull selenium/standalone-chrome
运行方式:
docker run -d --cpus 6 -m 12GB --name my-chrome -p 4444:4444 -p 9898:80 -e SE_NODE_MAX_SESSIONS=8 --shm-size="2g" selenium/standalone-chrome
这个standalone-chrome镜像其实是把selenium grid的基本组件都集成在一起了。
注意:SE_NODE_MAX_SESSIONS如果不设置的话,只支持一个会话,就无法多个爬虫并发执行了。
连接selenium的代码示例如下:
def connect_remote_chrome(url_str):
print(f'Conencting to {url_str} ...')
time.sleep(5)
options = webdriver.ChromeOptions()
driver = webdriver.Remote(
command_executor="http://10.10.10.10:4444/wd/hub",
options=options
)
driver.get(url_str)
content = driver.title.split("_")[0]
print(content)
driver.close()
hub是selenium的一个组件,起到转发请求的作用。
当然,如果你有更多的定制化需求,也完全可以打造一个自己的chrome容器。下面是一个简单的例子。
FROM node:10-slim
LABEL maintainer="Eric Bidelman "
# Install utilities
RUN apt-get update --fix-missing && apt-get -y upgrade
# Install latest chrome dev package.
RUN wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add - \
&& sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list' \
&& apt-get update \
&& apt-get install -y google-chrome-unstable --no-install-recommends \
&& rm -rf /var/lib/apt/lists/* \
&& rm -rf /src/*.deb
ADD https://github.com/Yelp/dumb-init/releases/download/v1.2.0/dumb-init_1.2.0_amd64 /usr/local/bin/dumb-init
RUN chmod +x /usr/local/bin/dumb-init
# Download latest Lighthouse from npm.
# cache bust so we always get the latest version of LH when building the image.
ARG CACHEBUST=1
RUN npm i lighthouse -g
# Install express.
COPY package.json .
RUN npm i --production
# Add the simple server.
COPY server.js /
RUN chmod +x /server.js
COPY entrypoint.sh /
RUN chmod +x /entrypoint.sh
# Add a chrome user and setup home dir.
RUN groupadd --system chrome && \
useradd --system --create-home --gid chrome --groups audio,video chrome && \
mkdir --parents /home/chrome/reports && \
chown --recursive chrome:chrome /home/chrome
USER chrome
#VOLUME /home/chrome/reports
#WORKDIR /home/chrome/reports
# Disable Lighthouse error reporting to prevent prompt.
ENV CI=true
EXPOSE 8080
ENTRYPOINT ["dumb-init", "--", "/entrypoint.sh"]
#CMD ["lighthouse", "--help"]
远程启动的参数说明:
chrome.exe --remote-debugging-port=9222 --user-data-dir=remote-profile
可以采用现有的镜像,如https://github.com/MeiK2333/headful-chrome-launch.git。
首先,在远程容器中启动chrome:
chrome --remote-debugging-port=9222 --user-data-dir=/root/chrome-data --no-sandbox
注意:笔者是以root身份启动的。
然后,在python中采用selenium连接远程chrome:
def get_remote_chrome():
options = webdriver.ChromeOptions()
options.add_argument(
"user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.127 Safari/537.36")
## 此处的100.100.100.100请换成真实的主机地址
options.add_experimental_option("debuggerAddress", f"100.100.100.100:9222")
options.add_argument('disable-infobars')
# options.add_argument("--headless")
options.add_argument("--disable-gpu")
# 针对反爬虫的设置
options.add_argument("--disable-blink-features")
options.add_argument("--disable-blink-features=AutomationControlled")
driver = webdriver.Chrome(chrome_options=options)
driver.maximize_window()
return driver
def test_remote():
driver = get_remote_chrome()
driver.get("https://www.csdn.net")
title = driver.title.split("_")[0]
print(title)
driver.close()
当我们运行上述python代码时,会报错。原因是以调试方式运行chrome时,由于chrome的安全限制,不允许我们通过chrome浏览器以外的机器去连接它的调试端口。通常遇到下面报错信息:
[0526/132024.480654:ERROR:socket_posix.cc(137)] bind() returned an error, errno=49: Can't assign requested address
[0526/132024.480766:ERROR:devtools_http_handler.cc(226)] Cannot start http server for devtools. Stop devtools.
即使你通过–remote-debugging-address 参数指定0.0.0.0也不行。不过这点好办,写个代理,跑在和chrome同一个台机器上,做转发不就完事了?没错,这几乎是对付这种场景的万能方案。
下面是一个Python写的端口转发程序:
import time
import socket
import threading
def log(strLog):
strs = time.strftime("%Y-%m-%d %H:%M:%S")
print(strs +" -> "+strLog)
class pipethread(threading.Thread):
'''
classdocs
'''
def __init__(self,source,sink):
'''
Constructor
'''
threading.Thread.__init__(self)
self.source = source
self.sink = sink
log("New Pipe create:%s->%s" % (self.source.getpeername(),self.sink.getpeername()))
def run(self):
while True:
try:
data = self.source.recv(1024)
if not data: break
self.sink.send(data)
except Exception as ex:
log("redirect error:"+str(ex))
break
self.source.close()
self.sink.close()
class portmap(threading.Thread):
def __init__(self, port, newhost, newport, local_ip = ''):
threading.Thread.__init__(self)
self.newhost = newhost
self.newport = newport
self.port = port
self.local_ip = local_ip
self.protocol = 'tcp'
self.sock = socket.socket(socket.AF_INET,socket.SOCK_STREAM)
self.sock.bind((self.local_ip, port))
self.sock.listen(5)
log("start listen protocol:%s,port:%d " % (self.protocol, port))
def run(self):
while True:
newsock, address = self.sock.accept()
log("new connection->protocol:%s,local port:%d,remote address:%s" % (self.protocol, self.port,address[0]))
fwd = socket.socket(socket.AF_INET,socket.SOCK_STREAM)
try:
fwd.connect((self.newhost,self.newport))
except Exception as ex:
log("connet newhost error:"+str(ex))
break
p1 = pipethread(newsock, fwd)
p1.start()
p2 = pipethread(fwd, newsock)
p2.start()
class pipethreadUDP(threading.Thread):
def __init__(self, connection, connectionTable, table_lock):
threading.Thread.__init__(self)
self.connection = connection
self.connectionTable = connectionTable
self.table_lock = table_lock
log('new thread for new connction')
def run(self):
while True:
try:
data,addr = self.connection['socket'].recvfrom(4096)
#log('recv from addr"%s' % str(addr))
except Exception as ex:
log("recvfrom error:"+str(ex))
break
try:
self.connection['lock'].acquire()
self.connection['Serversocket'].sendto(data,self.connection['address'])
#log('sendto address:%s' % str(self.connection['address']))
except Exception as ex:
log("sendto error:"+str(ex))
break
finally:self.connection['lock'].release()
self.connection['time'] = time.time()
self.connection['socket'].close()
log("thread exit for: %s" % str(self.connection['address']))
self.table_lock.acquire()
self.connectionTable.pop(self.connection['address'])
self.table_lock.release()
log('Release udp connection for timeout:%s' % str(self.connection['address']))
class portmapUDP(threading.Thread):
def __init__(self, port, newhost, newport, local_ip = ''):
threading.Thread.__init__(self)
self.newhost = newhost
self.newport = newport
self.port = port
self.local_ip = local_ip
self.sock = socket.socket(socket.AF_INET,socket.SOCK_DGRAM)
self.sock.bind((self.local_ip,port))
self.connetcTable = {}
self.port_lock = threading.Lock()
self.table_lock = threading.Lock()
self.timeout = 300
#ScanUDP(self.connetcTable,self.table_lock).start()
log('udp port redirect run->local_ip:%s,local_port:%d,remote_ip:%s,remote_port:%d' % (local_ip,port,newhost,newport))
def run(self):
while True:
data,addr = self.sock.recvfrom(4096)
connection = None
newsock = None
self.table_lock.acquire()
connection = self.connetcTable.get(addr)
newconn = False
if connection is None:
connection = {}
connection['address'] = addr
newsock = socket.socket(socket.AF_INET,socket.SOCK_DGRAM)
newsock.settimeout(self.timeout)
connection['socket'] = newsock
connection['lock'] = self.port_lock
connection['Serversocket'] = self.sock
connection['time'] = time.time()
newconn = True
log('new connection:%s' % str(addr))
self.table_lock.release()
try:
connection['socket'].sendto(data,(self.newhost,self.newport))
except Exception as ex:
log("sendto error:"+str(ex))
#break
if newconn:
self.connetcTable[addr] = connection
t1=pipethreadUDP(connection,self.connetcTable,self.table_lock)
t1.start()
log('main thread exit')
for key in self.connetcTable.keys():
self.connetcTable[key]['socket'].close()
if __name__=='__main__':
myp = portmap(9223, '127.0.0.1', 9222)
myp.start()
在chrome所在的docker容器运行它:
python ./portmap.py
即可将进入容器的9223端口上的请求转发到chrome上去。再次执行selenium访问代码即可正常操作浏览器了。
FROM mcr.microsoft.com/playwright:focal
ENV TZ=Asia/Shanghai
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get install -y locales
RUN sed -i -e 's/# zh_CN.UTF-8 UTF-8/zh_CN.UTF-8 UTF-8/' /etc/locale.gen && \
dpkg-reconfigure --frontend=noninteractive locales && \
update-locale LANG=zh_CN.UTF-8
ENV LANG=zh_CN.UTF-8
ENV LC_ALL=zh_CN.UTF-8
ENV LANGUAGE=zh_CN:zh:en
# Install front and others
RUN apt-get install -yq \
ca-certificates \
curl \
dumb-init \
ffmpeg \
fontconfig \
fonts-indic \
fonts-liberation \
fonts-noto-cjk \
fonts-noto-color-emoji \
fonts-thai-tlwg \
gconf-service \
libappindicator1 \
libappindicator3-1 \
libatk-bridge2.0-0 \
libatk1.0-0 \
libc6 \
libcairo2 \
libcups2 \
libdbus-1-3 \
libexpat1 \
libfontconfig1 \
libgbm1 \
libgcc1 \
libgconf-2-4 \
libgl1 \
libglib2.0-0 \
libgtk-3-0 \
libnspr4 \
libpango-1.0-0 \
libpangocairo-1.0-0 \
libstdc++6 \
libx11-6 \
libx11-xcb1 \
libxcb1 \
libxcomposite1 \
libxcursor1 \
libxdamage1 \
libxext6 \
libxfixes3 \
libxi6 \
libxrandr2 \
libxrender1 \
libxt6 \
libxtst6 \
locales \
lsb-release \
unzip \
wget \
x11-apps \
x11-xkb-utils \
x11vnc \
xdg-utils \
xfonts-100dpi \
xfonts-75dpi \
xfonts-cyrillic \
xfonts-scalable \
xvfb \
fvwm \
xterm
# Download Chrome
RUN cd /tmp && \
wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add - && \
sh -c 'echo "deb [arch=amd64] https://dl-ssl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google-chrome.list' && \
apt-get update && \
apt-get install -yq google-chrome-stable
RUN apt-get -qq clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
RUN mkdir /app
WORKDIR /app
COPY . /app
RUN npm install && \
npm install -g ts-node typescript
# RUN chown -R pwuser:pwuser /app
# Run everything after as non-privileged user.
# USER pwuser
CMD ["./start.sh"]
Grid的适用场景是多个node,每个node上运行一个浏览器,而且可以是不同类型的浏览器。这对提高并发,和做兼容性测试是非常有好处的。
直接在容器中运行headless chrome,会由于安全的限制,提示报错:
Failed to move to new namespace: PID namespaces supported, Network namespace supported, but failed: errno = Operation not permitted
采用open命令:
open -a Google\ Chrome –args -disable-web-security
或者直接运行(zsh下):
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --remote-debugging-port=9222 --user-data-dir=/opt/tmp/chrome
chrome \
--headless \ # Runs Chrome in headless mode.
--disable-gpu \ # Temporarily needed if running on Windows.
--remote-debugging-port=9222 \
https://www.baidu.com # URL to open. Defaults to about:blank.
If you’re on the stable channel of Chrome and cannot get the Beta, I recommend using chrome-canary:
Download Chrome Canary here. https://www.google.com/chrome/canary/,每日构建版本
alias chrome="/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome"
alias chrome-canary="/Applications/Google\ Chrome\ Canary.app/Contents/MacOS/Google\ Chrome\ Canary"
alias chromium="/Applications/Chromium.app/Contents/MacOS/Chromium"
Print the DOM:
chrome --headless --disable-gpu --dump-dom https://www.chromestatus.com/
The --print-to-pdf flag creates a PDF of the page:
chrome --headless --disable-gpu --print-to-pdf https://www.chromestatus.com/
To capture a screenshot of a page, use the --screenshot flag:
chrome --headless --disable-gpu --screenshot https://www.chromestatus.com/
# Size of a standard letterhead.
chrome --headless --disable-gpu --screenshot --window-size=1280,1696 https://www.chromestatus.com/
# Nexus 5x
chrome --headless --disable-gpu --screenshot --window-size=412,732 https://www.chromestatus.com/
The --repl flag runs Headless in a mode where you can evaluate JS expressions in the browser, right from the command line:
$ chrome --headless --disable-gpu --repl --crash-dumps-dir=./tmp https://www.chromestatus.com/
[0608/112805.245285:INFO:headless_shell.cc(278)] Type a Javascript expression to evaluate or "quit" to exit.
>>> location.href
{"result":{"type":"string","value":"https://www.chromestatus.com/features"}}
>>> quit
$
只采用一个chrome实例往往不能满足需求,这时就需要用到实例池了。下面的脚本创建了一个chrome实例池。
let pool = genericPool.createPool({
create() {
return puppeteer.launch()
},
validate(browser) {
return Promise.race([
new Promise(res => setTimeout(() => res(false), 1500)),
browser.version().then(_ => true).catch(_ => false)
])
},
destroy(browser) {
return browser.close();
}
}, {
min: 2,
max: 10,
testOnBorrow: true,
acquireTimeoutMillis: 15000
})
本文简要介绍了在容器中运行chrome,以及在容器外采用selenium库远程操纵chrome的两种模式: