browser-use 容器化拆解:两个 Dockerfile 差在哪,基础镜像为何预装那么多
本文基于 browser-use 仓库 commit f0aa3a8(2026-07-27)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/browser-use/browser-use 最新代码与文档为准。
这两个 Dockerfile 的区别不是”慢版和快版”,而是它们装进容器的 Chromium 根本不是同一个来源、也不在同一个路径上——容器里跑浏览器踩的坑,一多半都能追到这一条上。 你如果只看 docker/README.md 里那张构建耗时表就选了 Dockerfile.fast,很可能在启动阶段卡在”找不到浏览器”或者”浏览器起来就崩”,而这两件事都不是 bug,是你选的构建路径决定的。
容器化这个话题站内已经有几篇了:容器隔离的取向差异讲的是另一个项目为什么把隔离当默认前提,基础设施选型是站在选方案的位置往下看,Agent 日常运维管的是上线之后。本篇只钉在 browser-use 这个仓库自己的两份构建文件上,读完你应该能判断该用哪条路径、镜像里那一堆预装物是给谁准备的、以及运行时哪几个开关会因为”在不在容器里”而改变行为。
一、两条构建路径,各自解决什么问题
Dockerfile 是自包含的一条路:从 python:3.12-slim 起步,把系统依赖、Chromium、uv、Python 依赖、项目本身全部在这一个文件里装完。它的注释头部就写了预期用法,git clone 之后 docker build . -t browseruse --no-cache,然后 docker run -v "$PWD/data":/data browseruse。好处是任何人 clone 下来就能构建,不依赖任何外部预制品;代价是每次改一行代码,后面那些重活都可能重新走一遍。
Dockerfile.fast 走的是另一条:它整个文件只有三十来行,开头一句注释之后紧跟的三行就把这条路交代完了
ARG REGISTRY=browseruse
ARG BASE_TAG=latest
FROM ${REGISTRY}/base-python-deps:${BASE_TAG}
也就是说,它假定”系统依赖 + 浏览器 + Python 依赖”这三层已经被提前烤进了一个基础镜像。剩下要做的只有建用户、COPY . /app、一次 uv sync、切用户、声明卷和端口。docker/README.md 里给出的耗时对比是标准构建约 2 分钟、快速构建约 30 秒、改完代码重建约 16 秒——这组数字是仓库自己的自述,落到你的机器上取决于 CPU、网络和缓存命中情况,不必当成承诺。
真正值得记住的是那三层基础镜像的分层依据,它就在 docker/base-images/ 下面,三个目录三个 Dockerfile,一层套一层:system 层从 python:3.12-slim 起,只装 ca-certificates curl wget 加一个从 ghcr.io/astral-sh/uv:latest 复制进来的 uv;chromium 层在它之上装浏览器;python-deps 层再在它之上把项目依赖装好。分层顺序是按”变更频率从低到高”排的,系统包最少变,浏览器次之,Python 依赖最常变,业务代码每天都变——所以代码留在最外层的 Dockerfile.fast 里。
二、基础镜像里为什么塞了那么多东西
先看标准 Dockerfile 的装法。它用系统包管理器直接装 Chromium,并且顺手装了一批字体,然后做了两个软链:
apt-get install -y --no-install-recommends \
chromium \
fonts-unifont \
fonts-liberation \
fonts-dejavu-core \
fonts-freefont-ttf \
fonts-noto-core \
&& ln -s /usr/bin/chromium /usr/bin/chromium-browser \
&& ln -s /usr/bin/chromium /app/chromium-browser
字体这件事对这类项目是硬需求,不是锦上添花。Agent 看页面靠的是截图,缺字体的容器里,中日韩文字会渲染成方块,模型读到的就是一张错的图,后面所有决策都建立在错的观察上。所以这几个字体包不能当”体积优化”的目标去砍。
再看 chromium 基础镜像那层,它拿浏览器的方式完全不同:
pip install --no-cache-dir playwright && \
PLAYWRIGHT_BROWSERS_PATH=/opt/playwright playwright install chromium --with-deps --no-shell && \
ln -s /opt/playwright/chromium-*/chrome-linux/chrome /usr/bin/chromium-browser && \
chmod -R 755 /opt/playwright && \
pip uninstall playwright -y
这是把 playwright 当成一次性的”浏览器下载器”用:临时装上,用 --with-deps 让它把浏览器需要的一大串系统库一并带进来,把浏览器落到 /opt/playwright,做好软链、放开权限,然后把 playwright 本身卸掉。最终镜像里留下的是浏览器和它的系统依赖,不留 playwright 这个 Python 包。标准 Dockerfile 里那一大段被注释掉的 libnss3 libxss1 ... 清单旁边也写着同样的判断——那些库由 playwright install --with-deps chromium 自动带入,所以不必手写。
python-deps 那层的关键是三个环境变量和一次预热:
ENV PYTHONUNBUFFERED=1 PATH="/app/.venv/bin:$PATH" PLAYWRIGHT_BROWSERS_PATH=/opt/playwright
WORKDIR /app
COPY pyproject.toml uv.lock* ./
RUN uv venv && uv sync --all-extras --no-dev --no-install-project --compile-bytecode
--no-install-project 是重点:只装依赖、不装项目本身。这样依赖层可以长期复用,代码变了不影响它。标准 Dockerfile 里也用了同一手法,先 uv sync --all-extras --no-dev --no-install-project 拿依赖,再 COPY . /app,最后 uv sync --all-extras --locked --no-dev 把项目装上并跟一句 python -c "import browser_use; print('browser-use installed successfully')" 做导入自检。
依赖为什么这么重?--all-extras 意味着把可选依赖全装。这个仓库的 browser_use/llm/ 下有 15 个 provider 目录,各家 SDK 都是独立的可选依赖;browser_use/browser/watchdogs/ 下有 14 个 watchdog,分别管崩溃、下载、弹窗、权限、录制、截图、存储态这些副作用。镜像里预装得多,是因为它不想在运行时因为”你恰好换了个模型供应商”而现装一个包。
标准 Dockerfile 里另外几处值得留意的工程细节:它把 apt 配置改成保留已下载的包并关掉推荐/建议安装,配合 --mount=type=cache,target=/var/cache/apt 让重复构建吃缓存;它把 SHELL 设成带 pipefail errexit errtrace nounset 的 bash,构建脚本一旦有一步失败就不会被管道吞掉;它把大量构建信息用 tee -a /VERSION.txt 累积到镜像里的一个文件,源码注释明确说这是”for human eyes only, not used by anything else”——排查线上镜像来历时这个文件很好用。它还带了一整块 LABEL,包括 com.docker.desktop.extension.* 那一组,是给 Docker Desktop 扩展用的元数据,Dockerfile.fast 里只留了两个 LABEL。
| 组成部分 | 它负责什么 | 仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
| 自包含构建 | 从 slim 基础镜像一路装到项目本身,不依赖预制品 | Dockerfile | 第一次构建、CI 里从零构建、需要可复现来源时 |
| 快速构建 | 只做建用户、拷代码、装项目三件事 | Dockerfile.fast | 本地反复改代码重建 |
| 系统层基础镜像 | Python + 最小系统包 + uv | docker/base-images/system/Dockerfile | 想换 Python 版本或加系统工具时 |
| 浏览器层基础镜像 | 用一次性 playwright 把 Chromium 装到 /opt/playwright | docker/base-images/chromium/Dockerfile | 排查”浏览器在哪""为什么没有 playwright 包” |
| 依赖层基础镜像 | 预装全部可选依赖,不装项目 | docker/base-images/python-deps/Dockerfile | 改了 pyproject.toml 需要重烤基础镜像时 |
| 基础镜像构建脚本 | 按 system → chromium → python-deps 顺序构建并打标签 | docker/build-base-images.sh | 用快速构建之前必须先跑一次 |
| 容器环境判定 | 决定运行时是否按容器模式配置浏览器 | browser_use/config.py | 沙箱开关行为跟你预期不一致时 |
| 容器专用启动参数 | 容器里追加的那组 Chromium 开关 | browser_use/browser/profile.py | 浏览器起不来、内存异常、被站点识别时 |
| 浏览器可执行文件查找 | 按平台和 channel 按序探测浏览器路径 | browser_use/browser/watchdogs/local_browser_watchdog.py | 镜像里明明有浏览器却报找不到 |
三、运行时怎么知道自己在容器里,以及它因此改了什么
标准 Dockerfile 的环境变量块里直接写了 IN_DOCKER=True。这个变量不是给人看的注释,代码里真的在读它。browser_use/config.py 里 IN_DOCKER 是个属性:先看环境变量,取不到就调 is_running_in_docker() 自己探。探测的第一招是:
if Path('/.dockerenv').exists() or 'docker' in Path('/proc/1/cgroup').read_text().lower():
return True
接着还有两招兜底:看 PID 1 的命令行里有没有 py/uv/app 这类字样,以及”总进程数少于 10 就几乎肯定在容器里”。三招都是启发式,不是保证。
判定结果影响的是浏览器怎么起。browser_use/browser/profile.py 里 chromium_sandbox 这个字段的默认值直接写成 default=not CONFIG.IN_DOCKER,描述是”recommended unless inside Docker”;组装启动参数时又有这么一行:
*(CHROME_DOCKER_ARGS if (CONFIG.IN_DOCKER or not self.chromium_sandbox) else []),
CHROME_DOCKER_ARGS 这一组是容器场景的专用开关,包含 --no-sandbox、--disable-gpu-sandbox、--disable-setuid-sandbox、--disable-dev-shm-usage、--no-xshm、--no-zygote、--disable-site-isolation-trials。其中 --disable-dev-shm-usage 在默认参数里也出现了一次,旁边的注释写着”crucial for docker support, harmless in non-docker environments”;--disable-site-isolation-trials 的注释更坦白,说它在容器里能降低内存占用,但页面若能探测到这一点,被判定为自动化的风险会变高。参数最终会走一遍字典化再列表化的去重合并,--disable-features= 这类可累加的开关是把值并起来而不是互相覆盖,仓库测试里也断言了 --no-sandbox 在最终参数里只出现一次。
有无显示器同样是自动判定的。detect_display_configuration() 里,若你没显式指定 headless,就按”有没有探到显示尺寸”来定:self.headless = not has_screen_available。而探显示尺寸的实现先试 macOS 的 AppKit、再试 screeninfo,在没有 X11 的容器里两者都失败,于是自动进无头模式。标准 Dockerfile 里 x11/xvfb 那一整段依赖也是注释掉的,说明这个镜像本来就没打算给你一个图形显示环境。
浏览器路径的查找逻辑值得单独看一眼。local_browser_watchdog.py 里按平台列出一串候选路径,Linux 分支上 chromium 这一组的顺序是:先 {PLAYWRIGHT_BROWSERS_PATH}/chromium-*/chrome-linux*/chrome 这个通配,再 /usr/bin/chromium、/usr/bin/chromium-browser、/usr/local/bin/chromium、/snap/bin/chromium。PLAYWRIGHT_BROWSERS_PATH 取不到时默认按 ~/.cache/ms-playwright 展开。把两条构建路径对上就清楚了:快速构建的镜像里这个变量被基础镜像设成了 /opt/playwright,第一条通配就命中;标准构建的镜像没设这个变量,默认目录不存在,于是落到 /usr/bin/chromium。默认 channel 是 chromium(BROWSERUSE_DEFAULT_CHANNEL 就取这个值),所以 chromium 这一组会被排到最前面优先探测。
数据落盘的路径也在构建期就铺好了。两份 Dockerfile 都做了 ln -s $DATA_DIR /home/$BROWSERUSE_USER/.config/browseruse,而配置目录的默认值是 XDG_CONFIG_HOME 下的 browseruse,profiles 目录是它的子目录、默认 profile 又是 profiles/default——这就是为什么两份文件都提前 mkdir -p "$DATA_DIR/profiles/default" 并把属主改成容器内那个非特权用户。你挂 -v "$PWD/data":/data,挂的就是浏览器 profile 和登录态。
四、边界与代价:它不打算管的事
这套镜像的 ENTRYPOINT 是 browser-use 这个命令行,不是一个 HTTP 服务。标准 Dockerfile 里那条 HEALTHCHECK 是注释掉的,编排层想做存活探测得自己定义。它也不给你图形环境、不带 Xvfb,headful 模式在这个镜像里不是开箱可用的能力。
安全边界上要算清一笔账:容器模式下 Chromium 自己的沙箱是关掉的,站点隔离试验也被禁用。也就是说浏览器进程级的那层防护被让出去了,换回来的是能在容器里跑起来和更低的内存占用,而唯一剩下的隔离层就是容器本身。这决定了一条使用纪律:不要在这种容器里塞进权限过大的登录态去访问不可信页面。权限该怎么切,可以配合最小权限的设计思路一起想。项目侧能用的手段是 profile 上的域名白名单字段,把可访问范围提前限死比事后补救现实得多。
EXPOSE 9242 和 EXPOSE 9222 只是声明,不等于端口自动可达。但你一旦真的把它们映射出去就得清楚:这是浏览器的调试端口,profile.py 里那个常量的注释说选 9242 是为了避开其他工具占用的 9222。调试端口没有鉴权概念,能连上的人就能操控这个浏览器和它带着的会话。
还有一类事它明确不管:目标站点的使用条款、验证码、以及各家的反自动化与风控机制。仓库里确实有一个跟验证码相关的 watchdog,但读一眼它的文件头说明就知道职责边界在哪:它做的是监听求解开始/结束这类事件、让主循环先阻塞等一等,自身不做任何破解动作。“感知到有挑战”和”绕过挑战”是两件事,容器化更不会改变这些机制的存在。工程上正确的做法是遇到验证码或异常挑战就停下来转人工,并且事先确认你的自动化行为在目标站点的条款范围内;账号被判异常、数据被带出边界的风险,都得由你自己承担。
跨镜像复用上也有代价。Dockerfile.fast 脱离预构建的基础镜像就无法构建;而三层基础镜像里,chromium 和 python-deps 两层的 FROM 写的是硬编码的 browseruse/base-system:${BASE_TAG} 和 browseruse/base-chromium:${BASE_TAG},只有仓库名的 tag 前缀在构建脚本里是可配的。
五、上手与避坑清单
1. 直接 docker build -f Dockerfile.fast 而没先烤基础镜像。 会踩是因为 README 的快速开始里两条命令挨在一起,很容易只看后一条。避法:先跑 ./docker/build-base-images.sh,它会按 system → chromium → python-deps 的顺序构建;第一次上手或者只想验证能不能跑通,就用标准 Dockerfile。
2. 用脚本的 --registry 换私有仓库,结果发现拉的还是别人的镜像。 会踩是因为脚本确实支持这个参数,但它只作用于 -t 打的标签,而 chromium 与 python-deps 两层的 FROM 是硬编码的 browseruse/ 前缀。避法:要么把这两个文件的 FROM 也参数化,要么保持本地标签仍叫 browseruse/base-*,只在最外层用 --build-arg REGISTRY= 切。
3. 用快速构建的镜像跑,浏览器起来就崩。 会踩是因为 Dockerfile.fast 的 ENV 里没有 IN_DOCKER=True——这是它和标准 Dockerfile 一个容易被忽略的差异。此时是否按容器模式配置浏览器,全靠运行时那三招启发式探测,而它们在某些沙箱、CI runner 或 PID 1 不典型的环境里可能判成”不在容器里”,容器专用参数就不会被追加。避法:运行时显式传 -e IN_DOCKER=True,别赌探测结果。
4. 在代码里硬编码浏览器可执行文件路径。 会踩是因为两条构建路径的浏览器位置不同:标准镜像在 /usr/bin/chromium,快速构建的镜像在 /opt/playwright 下面带版本号的目录里。避法:优先什么都不填,让查找逻辑自己按顺序探;确实要指定就按你实际用的那个镜像去核对,先进容器 ls 一眼再写。
5. 忘了挂 /data,或者挂了但写不进去。 前者会踩是因为 profile 目录在镜像里,容器一销毁登录态就没了;后者会踩是因为镜像里最后 USER 切到了非特权用户,而 uid/gid 在构建时被固定成了 911。避法:挂卷跑,并且让宿主目录对这个 uid 可写;改用别的 uid 时记得两份 Dockerfile 里的用户创建逻辑都要跟着改。
6. 用 ignore_default_args 把默认参数集合整体忽略掉。 会踩是因为这个字段允许直接传 True,一传默认集合就整体清空。这里要分清两种情况:--disable-dev-shm-usage 恰好在容器参数组里也有一份,容器模式下还会被补回来;而只出现在默认集合里的开关就没人补了,比如 --disable-hang-monitor 和 --disable-ipc-flooding-protection——后者的源码注释写明它的用途是让程序能在紧凑循环里发大量 CDP 调用,丢了它调用密集的任务就容易出怪问题。避法:这个字段也接受列表,只按名字剔掉你确实不想要的那几个,别整体传 True;改完打一次日志把最终参数列表看一遍。
7. 把 9222/9242 映射到公网做远程调试。 会踩通常是为了本地方便调试顺手加了 -p,然后忘了撤。避法:只绑 127.0.0.1,或者放在内网里再加一层带鉴权的入口,绝不要让调试端口裸奔。
8. 改了 pyproject.toml 但没更新锁文件。 会踩是因为标准 Dockerfile 的第二次同步带了 --locked,这一步以锁文件为准。避法:本地先把锁文件同步好、连同代码一起提交,再构建;依赖有变动时基础镜像那层也要重烤,否则快速构建只是在最外层补差量。
9. 为了瘦身把字体包砍掉。 会踩是因为字体在依赖清单里看着像可选项。避法:把”跑一次中文页面截图并肉眼看一眼”放进构建后的验收里。两条构建路径的字体来源本就不同——标准镜像是显式装的那几个包,快速构建靠的是 --with-deps 带进来的那批——所以换路径之后这一项要重新验一次,不能想当然。
收束
把这套东西看成三个问题就不容易乱:浏览器从哪来(apt 还是一次性 playwright)、代码怎么知道自己在容器里(环境变量还是启发式探测)、状态存在哪(/data 那个软链)。三个问题的答案不同,你排障的方向就完全不同。
构建完先跑这几项自检:进容器确认浏览器可执行文件真实存在且能 --version;确认 IN_DOCKER 的取值符合预期;打出最终的 Chromium 参数列表,核对沙箱相关开关和 --disable-dev-shm-usage 都在;跑一次带中文的页面截图看字体;确认 /data/profiles/default 在挂载卷上且可写;最后确认调试端口没有暴露到不该暴露的网络。
想继续往里读,顺序建议是:browser_use/browser/watchdogs/local_browser_watchdog.py 看浏览器路径的探测顺序,browser_use/browser/profile.py 的参数组装看每一组开关在什么条件下被追加,browser_use/config.py 看容器判定和各路径默认值。仓库 examples/ 下有 124 个文件,browser_use/agent/system_prompts/ 下有 8 份系统提示词,想理解它到底怎么驱动模型就从这两处入手。这个项目采用 MIT 许可证,读、改、自己重打镜像都没有障碍。想顺手把镜像构建文件写得更规范一点,可以参考让 AI 帮你写 Dockerfile 的正确姿势。
本篇属于一个把开源浏览器操作 Agent 项目 browser-use逐层拆开讲的系列,整体地图见 browser-use 是什么;沿着这条线往下,还可以看 browser-use 本地跑不住时 和 读懂 browser-use 用量统计层。