在离线 / Air-Gapped 环境中构建带 Web UI 的 Zot

在完全离线(Air-Gapped)的机器上从源码构建 Zot 时,一个常见问题是:Zot 的构建过程会尝试从 GitHub 下载或构建 ZUI(Zot Web UI)。如果机器无法访问 GitHub,make 就会失败。

本文记录一种简单、可重复的做法:提前准备 ZUI 和 Go 依赖,在离线环境中构建 Zot 二进制文件,再直接把已经构建好的二进制打包成 Docker 镜像。

1. 问题现象

直接执行:

make

可能看到类似错误:

curl: Failed to connect to github.com
Cloning into 'zui'...
fatal: unable to access 'https://github.com/project-zot/zui.git/'

原因是 Zot 的 Makefile 在启用 ui extension 时,需要准备 ZUI。如果预编译的 UI 不存在,它会尝试:

  1. 从 GitHub Release 下载 zui.tgz
  2. 下载失败后,git clone ZUI;
  3. 执行 npm installnpm run build

这些操作都需要网络。

2. 找到 Zot 对应的 ZUI 版本

不要自己猜 ZUI 版本。Zot 当前版本对应的 ZUI ref 已经写在 Makefile 中。

相关变量集中在 Makefile 顶部。在 Zot 源码目录执行:

grep -n '^ZUI' Makefile

输出类似:

29:ZUI_BUILD_PATH := ""
30:ZUI_VERSION := commit-a7feb46
31:ZUI_REPO_OWNER := project-zot
32:ZUI_REPO_NAME := zui

其中 ZUI_VERSION 就是要找的 ref。只取它的值:

sed -n 's/^ZUI_VERSION[[:space:]]*:=[[:space:]]*//p' Makefile

更方便的做法是直接存进 shell 变量,后面的下载和 clone 都复用它:

ZUI_VERSION=$(sed -n 's/^ZUI_VERSION[[:space:]]*:=[[:space:]]*//p' Makefile)
echo "$ZUI_VERSION"

也可以直接观察 make 输出。

例如,本文遇到的 Zot 版本使用:

commit-a7feb46

构建日志中可以看到:

https://github.com/project-zot/zui/releases/download/commit-a7feb46/zui.tgz

以及:

git clone --depth=1 --branch "commit-a7feb46" \
  https://github.com/project-zot/zui.git

因此,这个 Zot checkout 对应的 ZUI ref 就是:

commit-a7feb46

每次切换 Zot tag/commit 后,都应该重新检查 Makefile,而不是一直使用这个示例值。

3. 在联网机器上准备 ZUI

有两种方法。

方法 A:直接使用 Zot 指定的预编译 ZUI

这是更推荐的方法,因为它与 Zot Makefile 默认使用的 artifact 完全一致。

例如:

curl -fL \
  "https://github.com/project-zot/zui/releases/download/$ZUI_VERSION/zui.tgz" \
  -o zui.tgz

解压:

mkdir -p zui-build
tar xzf zui.tgz -C zui-build

检查内容:

find zui-build -maxdepth 2 -type f | head

然后把这个目录复制到离线机器。

方法 B:自己构建 ZUI

如果没有对应的预编译 artifact,可以从源码构建:

git clone \
  --depth=1 \
  --branch "$ZUI_VERSION" \
  https://github.com/project-zot/zui.git

cd zui
npm install
npm run build

构建结果通常位于:

zui/build/

把这个 build/ 目录复制到离线机器即可。

4. 准备 Zot 的 Go 依赖

如果离线机器也不能访问 Go module proxy,需要提前 vendor Go dependencies。

在联网机器上进入对应版本的 Zot:

git clone https://github.com/project-zot/zot.git
cd zot

获取 tags:

git fetch --tags

切换到需要构建的版本,例如:

git checkout <zot-tag>

然后:

go mod download
go mod vendor

此时源码目录中应该出现:

vendor/

将完整 Zot 源码目录(包括 vendor/)复制到离线机器。

5. 在离线机器上构建 Zot + UI

假设准备好的 ZUI 位于:

/opt/zui/build

进入 Zot 源码:

cd zot

强制 Go 不访问网络:

export GOPROXY=off
export GOSUMDB=off
export GOFLAGS=-mod=vendor

然后执行:

make binary \
  ZUI_BUILD_PATH=/opt/zui/build

关键参数是:

ZUI_BUILD_PATH=/opt/zui/build

设置以后,Zot Makefile 会直接使用本地已经准备好的 ZUI,而不是尝试访问 GitHub。

如果目标是 Linux AMD64,也可以显式指定:

make binary \
  OS=linux \
  ARCH=amd64 \
  ZUI_BUILD_PATH=/opt/zui/build

构建完成后,二进制文件通常在:

bin/

例如:

bin/zot-linux-amd64

6. 验证二进制

先确认 Zot 可以运行:

./bin/zot-linux-amd64 --help

然后检查是否为静态链接:

file bin/zot-linux-amd64

以及:

ldd bin/zot-linux-amd64

如果是完全静态链接,ldd 通常会显示:

not a dynamic executable

7. 用已经构建好的 Zot 二进制制作 Docker 镜像

既然 Zot 已经构建完成,就没有必要在 Dockerfile 中再次安装 Go、Node.js、npm,然后重新编译 Zot。

如果 Zot binary 是静态链接,可以使用非常简单的 Dockerfile:

FROM scratch

COPY bin/zot-linux-amd64 /usr/bin/zot

EXPOSE 5000

ENTRYPOINT ["/usr/bin/zot"]
CMD ["serve", "/etc/zot/config.json"]

保存为:

Dockerfile.runtime

然后:

docker build \
  -f Dockerfile.runtime \
  -t zot:local .

因为使用 FROM scratch,Docker 构建过程甚至不需要下载 Alpine、Ubuntu 等基础镜像。

注意:如果 Zot binary 不是静态链接,就不能直接使用 scratch,需要选择包含相应运行库的基础镜像。

8. ZUI 需要复制进 Docker 镜像吗?

通常不需要。

在执行:

make binary ZUI_BUILD_PATH=/opt/zui/build

时,ZUI 已经作为 Zot UI extension 构建进最终程序所需的资源中。

因此 runtime image 不需要再次执行:

COPY zui/build ...

只需要复制最终 Zot binary。

9. 导出 Docker 镜像

如果 Docker image 是在另外一台构建机器上生成的,可以导出:

docker save zot:local | gzip > zot-local.tar.gz

zot-local.tar.gz 复制到最终离线服务器,然后执行:

gunzip -c zot-local.tar.gz | docker load

检查:

docker images zot

10. 推荐的完整流程

整个过程可以简化为:

flowchart TD
    subgraph ONLINE["联网机器"]
        ZS["Zot source"]
        V["vendor/<br/>go mod vendor"]
        REF["从 Makefile 找到<br/>对应的 ZUI ref"]
        TGZ["下载 zui.tgz"]
        NPM["npm run build"]
        ZB["zui/build/"]
        ZS --> V
        REF --> TGZ --> ZB
        REF --> NPM --> ZB
    end

    subgraph OFFLINE["离线构建机器"]
        MK["make binary ZUI_BUILD_PATH=..."]
        BIN["zot-linux-amd64"]
        DB["docker build"]
        IMG["zot:local"]
        MK --> BIN --> DB --> IMG
    end

    V -->|复制| MK
    ZB -->|复制| MK

11. 最核心的命令

如果所有依赖已经准备好,真正的离线构建其实只有:

GOPROXY=off \
GOSUMDB=off \
GOFLAGS=-mod=vendor \
make binary \
  ZUI_BUILD_PATH=/opt/zui/build

然后用已经生成的 binary 构建镜像:

docker build \
  -f Dockerfile.runtime \
  -t zot:local .

总结

Air-Gapped 环境中构建 Zot 的关键不是修改 Zot 源码,而是提前把所有需要网络获取的依赖变成本地输入

Go modules  → vendor/
Zot Web UI  → zui/build/

其中 ZUI 版本不要手工猜测,而应该从当前 Zot checkout 的 Makefile 中获取。

最后,Zot 编译完成后,直接使用已经生成的 binary 构建 runtime Docker image。这样可以避免在 Docker build 阶段再次引入 Go、Node.js、npm、GitHub 等网络和构建依赖,使整个离线构建过程更简单、更稳定,也更容易重复。