home of val packett val's home blogcodephotos

Podfox:世界上首个容器感知浏览器

一个端口冲突促使我彻底废除了容器端口转发,让我的 Firefox 直接与 Podman 的整个网络通信。此外:为命令行爱好者容器化开发环境。

2025年5月5日 ✦ 阅读需 11 分钟

容器。容器容器容器。即使你以前不情愿,但现在你很可能至少在处理涉及这类事情的项目时,使用它们来运行各种支持基础设施。在各种不同项目之间共享的笔记本电脑上安装 Postgres 完整系统从来都不是正确的选择。运行 podman run --rm -it -p 5432:5432 postgres:17 以弹出一个没有持久状态的临时实例才是。

现在,任何 Web 后端或其他类型的联网服务项目通常都包含类似 docker-compose.yml 的文件,这使得只需一个命令即可运行所有服务依赖项。因此,我正在为一个项目(一个严肃的商业客户咨询工作)运行 Compose 设置,我让它在后台运行,然后我去启动另一个项目,结果——

哦不,失败了,出现了 EADDRINUSE。一个 端口冲突

这两个项目都涉及 RabbitMQ,并且都将管理 Web UI 端口转发到主机:

 queue:
  image: rabbitmq:3-management
  ports: 
   - "15672:15672"

因此,如果不修改端口号或其他设置,我就无法同时启动这两个项目。所以,就像任何理智、心态平和的人一样,那时我决定端口转发必须被废除!!

严肃地说,我们有 Ptyxis 作为一个容器感知的终端模拟器……为什么没有一个容器感知的 Web 浏览器可以直接与容器通信呢?(有人还记得 Rails 黄金时代的 Pow 吗?它提出了“只需打开 yourapp.dev”的体验?)

正如标题所示,现在有点像有一个容器感知的浏览器了:

Screenshot with a Firefox window on top showing a RabbitMQ management UI on http://myrabbit.randomtests.podman, and a terminal on the bottom shown launching podman run --rm -it --network randomtests --name myrabbit rabbitmq:3-management

好吧,标题稍微夸张了一点。并不是浏览器本身被修改了。而且,它不仅仅是在 Podman 容器中运行。有一个进程在一切旁边运行,使其成为可能,而且它非常简单和微小。

你已经猜到它是如何工作的了吗?首先,让我们看看我们是如何走到这一步的。

容器是如何联网的??

过去,容器化需要 root 权限,并且有一个作为 root 运行的守护程序,这总是让我感到反感。这从来都不是固有的必要条件,但 Linux 的命名空间功能还不够好,无法在没有权限的情况下运行。这些天——谢天谢地——它们可以了,Podman 鼓励运行“无根” (rootless)(且无守护程序)。

在老式的 rootful 设置中,联网非常简单:守护程序可以在主机上创建桥接接口,容器在桥接子网上分配 IP 地址,并且可以像实际机器一样访问它们。并且可以在主机上 设置 DNS 以解析为容器名称,但是……哎,修改 DNS

但作为一个非特权进程,我们不能只是在主机上创建桥接(而且我不想在网络接口列表中看到任何东西,看到这些东西让我感到困扰!),那么 Podman 做了什么?

你可能听说过使用 slirp4netns 进行“用户空间中的所有伪造”联网,这是对 90 年代早期调制解调器时代黑客技术的惊人复活,它刚刚 被替换为 一个名为 pasta 的现代替代品(嗯,美味 – 等等,什么是 bypass4netns 东西?)。 事实上,Podman 的 实际“无根教程” 将这些称为 the “无根联网工具”。但是……实际上……这只是让非特权容器能够 连接到外部世界 的一小部分。

实际的联网堆栈是真正的 Linux 内核堆栈,已命名空间化。

这是它实际的样子。 Rootless Podman 首先创建一个名为 rootless-netns 的单个网络命名空间,在其中创建桥接网络,真正的 Linux 内核桥接;对于每个加入网络的容器,它都会生成一个 veth 对,其中一端连接到上述公共命名空间中网络的桥接接口,另一端传递到容器自己的命名空间。

我们可以通过运行 podman unshare --rootless-netns 手动进入那个“父”命名空间——而无需进入“容器”, 进入网络命名空间。

等等,这是如何工作的?告诉我,万能的 strace

open("/run/user/1000/libpod/tmp/pause.pid", O_RDONLY|O_LARGEFILE) = 4
read(4, "1809", 11)           = 4
open("/proc/1809/ns/user", O_RDONLY|O_LARGEFILE|O_CLOEXEC) = 5
fcntl(5, F_SETFD, FD_CLOEXEC)      = 0
open("/proc/1809/ns/mnt", O_RDONLY|O_LARGEFILE|O_CLOEXEC) = 6
fcntl(6, F_SETFD, FD_CLOEXEC)      = 0
setns(5, 0)               = 0
setns(6, 0)               = 0

哦,嗯,就是这样吗??等等,网络在哪里?

openat(AT_FDCWD, "/run/user/1000/containers/networks/rootless-netns/rootless-netns", O_RDONLY|O_CLOEXEC) = 8
[…]
setns(8, CLONE_NEWNET)         = 0

好的,这解释了上面的 mnt 命名空间。

所以关键是 setns(2) 系统调用,它“允许调用线程移动到不同的命名空间”。 请注意,当它是进程中唯一的线程时,所以我们不能只在不同的命名空间中有不同的线程。 但是没有人说我们不能在切换之前保留来自先前命名空间的开放套接字! 仅仅意识到这一点就开启了一个充满可能性的世界。 现在我们可以构建一个非常优雅和简单的……

代理!叮叮叮叮叮

如果你在阅读上述所有内容之前猜到了“代理”,那么恭喜你! 你赢了! :)

这就是 Podfox 实际上是。 这是一个 SOCKS 代理,它开始监听主机系统,然后进入 Podman 的无根联网命名空间以与你的容器通信。 它是进入容器世界的门户,作为一个简单的进程运行,用 Rust 编写。

无需在主机上进行任何 DNS 操作,因为代理可以以任何方式处理主机名,因此 Podfox 会解析 <container>.<network>.podman 主机名,使用 network 名称查询 podman network inspect 以查找网络的网关地址,最后向该网络的网关发送 <container>.dns.podman 的 DNS 查询,连接到返回的地址并开始代理。

完美。

它就是可以工作。

你可以立即从 crates.io 安装它:

cargo install --locked podfox

并将其作为 podfox 运行,默认情况下它将侦听端口 :42666。(它也支持 systemd 套接字激活!)

现在,嗯,我们实际上还没有触及“fox”部分。 浏览器。 当我第一次测试时,我使用了 Multi-Account Containers 的(呵呵)功能来为每个 Firefox 容器分配代理,但这只是为了测试。

后来我制作了一个 WebExtension 来创建一个全局代理规则,该规则将 Podfox 代理分配给伪造的 .podman TLD。 这可能是最简单的 Firefox 插件,实际上非常有用,因为它只包含一个单行(!)后台脚本:

browser.proxy.onRequest.addListener(() => ({ type: "socks", host: "localhost", port: 42666, proxyDNS: true }), { urls: ["*://*.podman/*"] });

但我不想将一个字面上的单行代码发布到 addons.mozilla.org。 我正在考虑制作一个小插件,用于通过设置 UI 创建此类规则,但目前我发现了一个更简单的解决方案,根本不需要任何插件发布。

是的,它是 PAC (Proxy Auto-Configuration)。 另一个 90 年代的复兴,就像 SLiRP 一样!

将以下内容放在 Firefox 可以直接访问的文件中(我说的是注意 Flatpak – 如果运行 Flatpak Firefox,通过 Flatseal 公开一个新目录,仅包含该文件):

function FindProxyForURL(url, host) {
 if (host.endsWith('.podman'))
  return 'SOCKS5 localhost:42666';
 return 'DIRECT';
}

最后,配置 Firefox 以使用它(并勾选复选框以代理 DNS 名称):

Screenshot of Firefox proxy settings, set to 'Automatic proxy configuration URL' with a path of 'file:///home/val/.config/podfox/podfox.pac', and at the bottom the 'Proxy DNS when using SOCKSv[45]' checkboxes are checked

现在你可以在浏览器中始终访问这个虚拟的 .podman TLD,并且只要 Podfox 代理正在运行,你就可以打开 rootless Podman 运行时中任何网络中任何容器上的任何端口!

奖励:容器化我的 CLI 开发环境

因此,通过这种浏览在容器中运行的服务的能力,并且不使用端口转发,当然,开发中的服务也必须在容器中运行。

近年来,容器开发工具呈爆炸式增长。 我听说过 Tilt,它提供了一个工作流程,其中服务容器默认情况下会不断重建(最重要的是,可以选择通过将更改复制到容器中来支持实时重新加载),并且有一个很好的 UI 页面可以观看所有日志,并且“最强烈建议”的运行时是一个本地 Kubernetes 集群(即使只是在 Podman 中运行也是可能的)。 并且来自 VSCode 和 GitHub Codespaces 世界的 MicrosoftⓇ 的 Development Container Specification™ 提供了一种清单格式,允许(通常)IDE 知道如何设置一个容器,其中(通常)“运行”按钮将运行该应用程序(以及语言服务器和其他相关工具)。

但我喜欢我的命令行环境,我不想让它被打断! 我在 Helix 中编辑代码——它运行的语言服务器可能会与实际构建共享它们的缓存——并运行各种随机工具,所有这些都来自我的 fish shell,并配置了我喜欢的提示符等等。

尝试仅在容器内运行项目部分(如编译器、服务本身、语言服务器和特定于语言的工具(如 linters 和 formatters))听起来有点痛苦,例如,Helix 没有“将所有工具包装在某物中”的选项,并且配置 PATH 以包含容器化工具或手动包装调用听起来非常烦人。 我宁愿……将我的整个环境带入每个容器!

运行一个包含我的点文件的容器非常容易:-v $HOME/.config:$HOME/.config:ro——谢天谢地,我使用的几乎所有东西都支持 XDG 目录,并且不会用随机路径污染 ~。 但是 shell、编辑器、随机工具呢? 构建我自己的每个项目容器,在项目的图像之上添加我的工具听起来很烦人、缓慢且繁重。 将它们安装在一个甚至不是容器映像的覆盖卷中(这似乎是 VSCode 开发容器的工作方式)感觉像是同一个的“更脏”版本。 我不能简单地 -v 安装它们吗?

好吧,按照我当前的设置方式,我的大多数工具都是通过 updatetools 脚本安装到 ~/.local 目录中的。 然而,当然,它们是为宿主机系统构建的——动态链接,目前,到 Chimera 的 musl libc、LLVM libc++,并且偶尔会链接到各种其他系统范围的库。 除非与 sharun 或我很久以前看到的任何工具打包在一起,否则它们不会在随机的基于 Debian 的容器中工作,但重点是 避免 像这样的“奇怪的东西”。 我也不想构建静态二进制文件。 甚至不是所有东西 可以 作为静态二进制文件工作。

就在那时,我明白了。

答案是获取 Homebrew

是的,就是那个来自 macOS 世界的包管理器,它总是必须将主机操作系统视为某种不可变的,这在过去被视为专有操作系统的解决方法,但现在是一项巨大的资产。 Homebrew 管理一个单独的非特权前缀,并拥有带有固定前缀的预构建包,目前在 Linux 上为 /home/linuxbrew/.linuxbrew。(它还执行 GoboLinux 风格的多版本控制,我开玩笑地称之为“对于那些接触草的人来说的 Nix”,但这与现在不太相关。)

使用容器,我们可以简单地安装 Homebrew 的前缀:-v /home/linuxbrew:/home/linuxbrew:ro。 并使用 Podman 的良好 env-merge 功能将其附加到 PATH:--env-merge PATH=\${PATH}:/home/linuxbrew/.linuxbrew/sbin:/home/linuxbrew/.linuxbrew/bin。 现在我们可以将 --entrypoint 更改为 homebrewed fish…然后我们就可以开始了!

Screenshot of a terminal, first running fastfetch that shows Chimera Linux, then running 'podman run --rm -it --tz local -v /home/linuxbrew:/home/linuxbrew:ro --env-merge PATH=${PATH}:/home/linuxbrew/.linuxbrew/sbin:/home/linuxbrew/.linuxbrew/bin -e XDG_CONFIG_HOME=$HOME/.config -v $HOME/.config:$HOME/.config:ro -v $PWD:$PWD -w $PWD --entrypoint (which fish) haskell:9.10.1', the same shell prompt appears, fastfetch is run and shows Debian

现在,这就是我所说的“发行版跳跃”!

尽管与 Distrobox 在一瞥之下具有表面上的相似性,但这是一个不同的想法。 请记住,我们仍然在谈论特定于项目的开发容器。 这 不是 一个持久的“宠物”容器,你可以在其中持久地安装来自另一个发行版的软件包,

这是一个临时/“牛”特定于项目的开发容器的运行时组合,该容器仅具有项目的工具链和一个“宠物”用户环境,以只读方式挂载。

并且由于它是挂载的,因此没有可能过时的冗余副本,也没有复制/安装东西的延迟! 这真的很酷。 我可以带着我所有舒适的东西立即进入任何容器镜像。

我全力投入 Homebrew,主要用 Brewfile 替换了我的自定义工具安装/更新脚本,并且我对它感到非常满意。 作为奖励,它也使原始的完整系统类型的发行版跳跃更容易! 如果我将 Homebrew 用于我所有的 CLI 内容,并将 Flatpak 用于我所有的 GUI 内容,我可以使用像 GNOME OS 这样固定且精简的东西作为主机! 虽然我目前对 AerynOS 最感兴趣,它反映了我很多的工程价值和选择。

无论如何,所以,我开始编写这些长的 podman run 调用,并很快感到需要自动化它们。 结果被称为 Podchamp (当前版本永久链接) 作为对互联网的愚蠢引用。 它只是一个存在于我的点文件中的 fish 脚本,我没有将其作为 Real Project™ 发布,因为它很小并且相对特定于我个人做事的方式,但它所做的事情很简单:它查找工作目录上方的第一个 .podchamp[.local] 配置文件(在“项目根目录”中),读取容器运行指令并 run 一个 podman 容器或 exec 到现有的一个中。 指令如下所示:

image mycoolproject-dev:latest
pod pod_mycoolproject
network mycoolproject_app-network
name mycoolproject-dev
persist /var/cache/rust
env CARGO_TARGET_DIR=/var/cache/rust
requires mycoolproject_postgres mycoolproject_rabbitmq

因此,它基本上是一堆方便性,用于将每个项目的开发容器配置存储在实际文件中,而不是存储在 shell 历史记录中。 它对我很有效!

它还会发出 OSC 777 序列来指示 Ptyxis 哪个容器刚刚被进入,Podman 本身目前没有这样做。 终端模拟器中的容器感知对于能够在与当前选项卡相同的容器中生成新选项卡非常好——是的,不是那么多,但这对 UX 来说是 巨大的。 如果能在其他终端中获得相同的体验就好了——已经有 Ghostty 的原型,并且我正在研究使其在 WezTerm 中工作。 阅读更多文章 订阅新文章 只需将你的 feed 阅读器指向整个网站,它应该会发现一切! 但如果这没有发生:这里有 AtomJSON Feedh-feed 的链接(等等,这只是博客页面本身)。 此外,Patreon 会员还可以收到博客文章的通知以及我在那里发布的工作更新 ;) 在 Patreon 上支持我! © 2022-2025 val packett ✦ ↑↑↑homeblogcolophon GetFirefox :)Not by AI