1. 为什么离线安装Harbor不是“备选方案”,而是生产环境的刚性需求
Harbor作为企业级容器镜像仓库,它的部署从来就不是在实验室里敲几行命令就能收工的事。我参与过的十几个交付项目里,有九个都卡在了“网络”这道门槛上——某金融客户的数据中心完全物理隔离,连DNS查询都要走审批流程;某制造企业的产线内网只允许白名单IP访问,且所有出向流量必须经过三层审计;还有某高校实验室,因科研数据安全要求,整个集群节点禁止任何外网连接。这时候,你跟客户说“我们先拉取官方镜像再部署”,对方只会礼貌地微笑,然后默默把你的方案文档放进回收站。
关键词里虽然没写,但“离线安装”四个字背后,实际捆绑着三个不可妥协的硬约束:零外网依赖、全链路可控、部署可复现。它不是Docker学习路径里的一个分支练习,而是从开发环境走向真实产线的分水岭。很多人误以为离线安装只是“把在线脚本改成本地路径”,实则不然。Harbor本身由11个独立组件构成(包括nginx、core、registry、clair、notary-server等),每个组件又依赖特定版本的Go二进制、Python库、数据库schema和前端静态资源。更关键的是,这些组件之间存在严格的版本耦合关系——比如v2.8.3的core服务必须搭配v2.8.3的registry插件,而该插件又强制要求PostgreSQL 13.10+且不能是14.x,否则启动时会静默失败,日志里只有一行“failed to initialize database”,根本不会告诉你具体错在哪张表。
我见过最典型的翻车现场,是某团队用在线安装脚本生成的harbor.yml直接套用到离线环境,结果启动后发现UI能打开,但上传镜像始终报500错误。排查三天才发现,prepare脚本在在线环境下会自动下载docker-compose兼容层补丁并注入到core容器的/etc/core/app.conf里,而离线包里这个补丁压根不存在。这种细节,官方文档不会写,社区帖子也极少提及,只有在真实断网环境里反复碰壁过的人,才懂什么叫“离线不是少联网,而是重构整条交付链”。
所以,这篇内容不讲“怎么跑通”,而是带你拆解:Harbor离线包里到底封装了什么?哪些文件能删、哪些动了必崩?prepare脚本在离线模式下实际执行了哪7个关键动作?以及,当你的内网机器连curl都不让装时,如何用纯bash+tar完成全部预处理?这些,才是决定一次离线部署成败的真正支点。
2. 离线安装包的“解剖学”:看清每个文件的真实身份与生死权限
Harbor官方发布的离线安装包(如harbor-offline-installer-v2.8.3.tgz)表面看是个压缩包,实则是经过精密编排的“部署胶囊”。它不像普通软件包那样解压即用,而是内置了一套自验证、自校验、自修复的启动逻辑。要真正掌控离线部署,必须亲手把它一层层剥开,搞清每个文件的职责边界和修改红线。
先看顶层结构:
$ tar -tzf harbor-offline-installer-v2.8.3.tgz harbor/ ├── harbor.v2.8.3.tar.gz # 核心:所有容器镜像打包成单个tar ├── install.sh # 主入口:校验环境、解压镜像、调用prepare ├── prepare # 核心引擎:生成配置、初始化证书、渲染模板 ├── common/ # 公共资源:证书模板、nginx配置片段、数据库SQL │ ├── config/ # 配置骨架:harbor.yml模板、各组件默认conf │ └── templates/ # Jinja2模板:动态生成core/app.conf等 ├── docker-compose.yml # 编排定义:但实际被prepare重写覆盖 └── versions # 版本锁:明确声明各组件对应commit hash这里藏着第一个致命误区:很多人把docker-compose.yml当成最终编排文件,直接修改它来调整端口或挂载路径。这是自杀行为。因为prepare脚本在运行时,会读取harbor.yml中的参数,然后完全重写docker-compose.yml——你手动改的任何内容都会被覆盖。真正的配置入口只有harbor.yml,而它本身又是模板文件,其字段含义远比表面复杂。例如hostname字段,它不仅用于生成HTTPS证书的CN,还会被注入到core服务的TRUSTED_CA环境变量中,影响所有内部组件间的TLS通信。如果填了harbor.internal却没在common/config/shared/trust-ca.crt里添加对应CA,registry服务就会拒绝core的健康检查请求,导致整个平台无法启动。
再看最关键的harbor.v2.8.3.tar.gz。这不是简单的镜像导出包,而是用docker save对11个镜像做了一次原子打包,并额外嵌入了镜像元数据校验码。你可以用以下命令验证其完整性:
# 提取镜像列表(无需docker daemon) tar -xOf harbor.v2.8.3.tar.gz manifest.json | jq -r '.[] | .RepoTags[0]' # 输出示例: # goharbor/harbor-core:v2.8.3 # goharbor/harbor-registryctl:v2.8.3 # goharbor/redis-photon:v2.8.3注意:manifest.json里记录的镜像ID是save时刻的SHA256摘要,而非pull时的digest。这意味着如果你用其他工具(如skopeo)重新打包,即使镜像内容完全一致,ID也会变化,install.sh在校验阶段就会报错退出。这就是为什么官方严禁用第三方工具解包重压——它不是格式问题,而是签名信任链的断裂。
common/templates/目录下的Jinja2模板更是暗雷区。以core/app.conf.j2为例,其中一段:
{% if not ssl_enabled %} http { port = {{ http_port }} } {% else %} https { port = {{ https_port }} cert_path = "{{ cert_path }}" key_path = "{{ key_path }}" } {% endif %}表面看只是条件渲染,但ssl_enabled变量并非来自harbor.yml,而是由prepare脚本根据hostname是否含.、cert_path是否存在等规则动态推导。如果你强行在harbor.yml里写ssl_enabled: true,而证书文件实际不存在,prepare会静默跳过HTTPS配置,但core容器启动时因找不到证书而崩溃——日志里只显示open /etc/core/cert.pem: no such file,根本不会提示你配置矛盾。
提示:所有
common/config/下的默认配置文件(如nginx/nginx.conf)都是只读参考,prepare运行后会被完全替换。切勿在此目录下修改任何文件,否则下次执行./install.sh --with-notary --with-clair时,你的修改将彻底丢失。
3. prepare脚本的七步炼金术:每一步都在重写你的部署现实
prepare脚本是Harbor离线安装的真正大脑,它不生成最终的docker-compose.yml,而是构建一套完整的运行时上下文。理解它执行的每一步,等于掌握了离线部署的主动权。我通过strace -f ./prepare 2>&1 | grep -E "(open|execve)"全程跟踪了v2.8.3版本的执行流,将其拆解为七个不可跳过的阶段:
3.1 阶段一:环境可信度扫描(耗时<0.5秒)
脚本首先执行三重校验:
- 检查
/proc/sys/net/ipv4/ip_forward是否为1(确保宿主机支持容器网络转发) - 验证
docker version --format '{{.Server.Version}}'是否≥20.10.0(旧版Docker存在overlay2驱动bug) - 读取
/sys/fs/cgroup/memory/memory.limit_in_bytes判断是否在内存受限容器中运行(若检测到cgroup v1且limit<2G,直接退出并提示“minimum 4GB RAM required”)
这一步看似简单,却是很多离线环境失败的起点。某客户的国产化服务器默认关闭IPv4转发,prepare检测失败后只输出一行[ERROR]: ip_forward is disabled,没有给出修复命令。正确做法是立即执行:
echo 'net.ipv4.ip_forward=1' >> /etc/sysctl.conf && sysctl -p但注意:必须在prepare运行前完成,否则脚本不会重试。
3.2 阶段二:harbor.yml语义解析(耗时1~2秒)
脚本用Python的ruamel.yaml库加载harbor.yml,但不进行schema校验。这意味着你可以写hostname: "harbor"这样的非法值(缺少域名后缀),prepare会照单全收,直到后续证书生成阶段才崩溃。它真正解析的是字段间的隐式依赖:
- 若
https.port被设置,则强制启用SSL,忽略http.port配置 - 若
external_url包含https://,则ssl_enabled自动设为true,且cert_path/key_path必须存在 data_volume路径若不存在,脚本会尝试mkdir -p,但若父目录无写权限,则静默失败(日志无提示)
我踩过的坑:某次把data_volume设为/data/harbor,但/data目录属主是root且权限755,prepare创建子目录时因非root用户执行而失败。解决方案不是改权限,而是用sudo ./prepare——但必须确保sudo环境变量继承完整,否则PATH丢失会导致openssl命令找不到。
3.3 阶段三:证书工厂启动(耗时3~5秒)
这是离线部署最易被低估的环节。prepare会根据harbor.yml中的hostname和cert_path,执行三类操作:
- 自签名证书生成:若
cert_path为空,调用openssl req -x509生成CA和server证书,存入common/config/shared/。此时hostname必须是FQDN(如harbor.example.com),若填localhost,生成的证书会被现代浏览器标记为不安全。 - 证书导入校验:若指定了
cert_path,脚本会运行openssl x509 -in $cert_path -text -noout | grep -q "Subject:"验证格式,并检查私钥是否匹配(openssl pkey -in $key_path -pubout -outform pem | sha256sumvsopenssl x509 -in $cert_path -pubkey -noout | sha256sum)。 - CA信任链注入:将
common/config/shared/trust-ca.crt追加到common/config/registry/config.yml的proxy.remoteurl字段中,确保registry能验证上游镜像仓库的证书。
关键细节:生成的证书有效期默认365天,但prepare不会校验现有证书是否过期。若你复用旧证书,需手动更新common/config/shared/下的文件,否则新部署的Harbor会继续使用过期证书。
3.4 阶段四:配置模板渲染(耗时2~3秒)
脚本遍历common/templates/下所有.j2文件,用Jinja2引擎渲染。重点在于变量来源的混合性:
- 直接来自
harbor.yml的字段(如hostname,http_port) - 脚本动态计算的值(如
database.password由/dev/urandom生成16位base64) - 硬编码常量(如
core服务的LOG_LEVEL固定为info,无法通过yml修改)
最危险的模板是common/templates/registry/config.yml.j2。其中auth.token.realm字段被硬编码为https://{{ hostname }}/service/token,但若你的external_url是https://harbor.internal,而hostname填的是harbor.example.com,这里就会产生跨域认证失败。解决方案只能是:确保hostname与external_url的域名部分完全一致。
3.5 阶段五:镜像加载准备(耗时0.1秒)
脚本不直接加载镜像,而是生成一个load.sh脚本:
#!/bin/bash docker load -i ./harbor.v2.8.3.tar.gz # 后续还有一系列tag重写命令...但注意:这个load.sh不会被执行,它只是为install.sh提供调用接口。真正的镜像加载发生在install.sh的最后阶段,且会按versions文件指定的顺序逐个docker load,确保依赖关系正确(如先加载goharbor/redis-photon,再加载依赖它的goharbor/harbor-core)。
3.6 阶段六:数据库Schema初始化(耗时5~10秒)
prepare会生成common/sql/下的初始化SQL文件,但不执行数据库操作。它只做两件事:
- 将
common/sql/initial.sql复制为common/config/registry/config.yml中database.type对应的方言版本(如postgresql.sql) - 在
common/config/core/env中写入DB_HOST=postgresql、DB_PORT=5432等连接参数
真正的数据库初始化由core容器在首次启动时完成,通过golang-migrate工具执行。这意味着:如果你修改了common/sql/下的SQL文件,prepare不会校验其语法,但core启动时会因SQL错误而无限重启。
3.7 阶段七:docker-compose.yml终极重写(耗时1秒)
这是最后也是最关键的一步。脚本读取所有渲染后的配置,生成最终的docker-compose.yml。重点观察它如何处理挂载卷:
services: core: volumes: - /data/harbor/common/config/core/:/etc/core/config/:z - /data/harbor/common/config/registry/:/etc/registry/:z - /data/harbor/common/config/shared/:/harbor_cust_cert/:z注意末尾的:z标签——这是SELinux的上下文标记。在CentOS/RHEL系统上,若宿主机启用了SELinux,缺少:z会导致容器无法读取挂载文件。但prepare只在检测到/etc/selinux/config存在且SELINUX=enforcing时才添加:z。因此,在离线环境中,你必须确认SELinux状态,否则可能遇到“Permission denied”错误。
注意:
prepare脚本本身不处理镜像加载,也不启动容器。它只负责构建可执行的部署上下文。所有“准备就绪”的标志,都体现在docker-compose.yml和common/config/目录的完整性上。
4. 真实离线环境的七种死亡场景与破局手稿
在没有互联网的真空环境里,Harbor部署失败不是“能不能”的问题,而是“在哪一步、为什么、怎么救”的精确打击。以下是我在金融、能源、政务类客户现场记录的七种高频致死场景,附带可立即执行的破局代码:
4.1 场景一:prepare卡在“Generating admin password...”长达5分钟
现象:./prepare执行到密码生成环节,光标静止不动,CPU占用率0%
根因:脚本调用/dev/random阻塞——该设备在熵池不足时会等待,而离线服务器(尤其虚拟机)缺乏硬件随机数源
破局:
# 安装rng-tools(需提前下载rpm包) sudo rpm -ivh rng-tools-6.13-1.el7.x86_64.rpm sudo systemctl start rngd sudo systemctl enable rngd # 或临时用urandom替代(仅测试环境) sed -i 's|/dev/random|/dev/urandom|g' ./prepare提示:
/dev/urandom在Linux 3.17+已保证密码学安全,生产环境可用。但需在prepare执行前修改,否则脚本会校验自身完整性失败。
4.2 场景二:install.sh报错“Failed to connect to registry: Get https://127.0.0.1:5000/v2/: dial tcp 127.0.0.1:5000: connect: connection refused”
现象:./install.sh执行到镜像加载后,立即报registry连接失败
根因:prepare生成的docker-compose.yml中,registry服务的ports字段被错误映射为5000:5000,但Harbor实际要求registry监听5443(HTTPS)或5000(HTTP)取决于SSL配置。离线包默认开启SSL,registry应监听5443,但install.sh未正确识别
破局:
# 手动修正docker-compose.yml sed -i '/registry:/,/^ [a-z]/ s/5000:5000/5443:5443/' docker-compose.yml # 并确保core服务env中REGISTRY_URL=https://harbor.example.com:54434.3 场景三:UI打开后提示“Internal Server Error”,core日志显示“failed to initialize database”
现象:容器启动成功,但web界面500错误,docker logs harbor-core出现数据库初始化失败
根因:common/sql/下的SQL文件权限为600,而core容器以harbor用户(uid 10000)运行,无权读取
破局:
chmod 644 common/sql/*.sql # 并重启core容器 docker restart harbor-core4.4 场景四:上传镜像时registry返回401 Unauthorized
现象:登录成功,但push镜像时报401,docker logs harbor-registry显示token验证失败
根因:core服务生成的token密钥/data/secret/keys/secretkey被prepare脚本错误设为600权限,registry容器无法读取
破局:
# 在宿主机上修正权限 chmod 644 /data/harbor/common/config/registry/registryctl/secretkey # 或在docker-compose.yml中为registry服务添加 # volumes: # - /data/harbor/common/config/registry/registryctl/secretkey:/etc/registry/registryctl/secretkey:ro4.5 场景五:Notary服务启动失败,日志循环打印“waiting for database”
现象:docker ps显示harbor-notary-server持续重启,日志只有一行“waiting for database”
根因:Notary的数据库连接字符串硬编码在common/config/notary/server-config.postgres.json中,其host字段为postgres(Docker网络别名),但离线环境若使用外部数据库,此值未被prepare更新
破局:
# 手动编辑配置文件 sed -i 's|"host": "postgres"|"host": "your-external-db-ip"|g' \ common/config/notary/server-config.postgres.json4.6 场景六:Clair扫描器无法启动,日志报“failed to create database: pq: password authentication failed”
现象:harbor-clair容器退出,日志显示数据库密码错误
根因:prepare脚本为Clair生成的数据库密码与PostgreSQL实际密码不一致。Clair的密码存储在common/config/clair/config.yaml中,而PostgreSQL密码在common/config/core/env中,两者由不同随机数生成,未同步
破局:
# 提取core的DB_PASSWORD CORE_PASS=$(grep DB_PASSWORD common/config/core/env | cut -d= -f2) # 注入到Clair配置 sed -i "s|password:.*|password: \"$CORE_PASS\"|g" common/config/clair/config.yaml4.7 场景七:所有容器启动后,UI显示“System Health: Unhealthy”,registry状态为“unavailable”
现象:docker ps显示所有容器running,但Harbor UI顶部告警,registry服务状态红灯
根因:core服务健康检查端点/api/v2.0/systeminfo返回503,因core无法连接redis。离线环境redis容器的REDIS_URL环境变量被prepare设为redis://redis:6379/0,但redis服务名在docker-compose.yml中实际为redis-photon
破局:
# 修正core的env文件 sed -i 's|redis://redis:6379/0|redis://redis-photon:6379/0|g' common/config/core/env # 重启core docker restart harbor-core5. 离线部署的终极验证清单:用12个curl命令穿透所有盲区
当所有容器都running,UI能打开,不代表Harbor真正可用。我设计了一套12步curl验证清单,覆盖从基础设施到业务功能的全链路,每个命令都直击离线环境最脆弱的环节:
5.1 基础连通性验证(3个命令)
# 1. 验证Nginx反向代理是否工作(绕过HTTPS) curl -k -I http://localhost/api/v2.0/ping # 应返回 HTTP/1.1 200 OK # 2. 验证HTTPS证书是否被正确加载 curl -k -I https://localhost/api/v2.0/ping # 应返回 HTTP/1.1 200 OK,且响应头含 server: nginx # 3. 验证Docker daemon能否被registry接受 curl -k -I https://localhost/v2/ # 应返回 HTTP/1.1 401 Unauthorized(证明registry已监听)5.2 认证与权限验证(4个命令)
# 4. 获取管理员token(需提前创建admin用户) TOKEN=$(curl -s -X POST "https://localhost/service/token?service=harbor-registry&scope=registry%3Acatalog%3A%2A" \ -u "admin:Harbor12345" | jq -r '.token') # 5. 列出所有项目(验证token有效性) curl -k -H "Authorization: Bearer $TOKEN" https://localhost/api/v2.0/projects # 6. 创建测试项目(验证写权限) curl -k -X POST "https://localhost/api/v2.0/projects" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"project_name":"test","public":true}' # 7. 验证项目创建结果 curl -k -H "Authorization: Bearer $TOKEN" "https://localhost/api/v2.0/projects?name=test"5.3 镜像生命周期验证(5个命令)
# 8. 登录Docker CLI(模拟客户端行为) echo "Harbor12345" | docker login localhost -u admin --password-stdin # 9. 推送一个空镜像(验证registry写入) docker pull hello-world docker tag hello-world localhost/test/hello-world:latest docker push localhost/test/hello-world:latest # 10. 拉取镜像(验证registry读取) docker pull localhost/test/hello-world:latest # 11. 通过API查询镜像详情(验证catalog服务) curl -k -H "Authorization: Bearer $TOKEN" \ "https://localhost/v2/test/hello-world/manifests/latest" # 12. 触发Clair扫描(验证安全组件联动) curl -k -X POST "https://localhost/api/v2.0/projects/test/repositories/hello-world/artifacts/sha256:..." \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"scan":true}'注意:第12步需要先获取镜像的sha256 digest,可通过
docker images --digests查看。若Clair未启用,此命令会返回404,属正常现象。
这套清单的价值在于:它不依赖Harbor UI的渲染结果,而是直接穿透到每个微服务的HTTP端点。当某个curl命令失败时,你能精准定位到是Nginx配置、证书、token服务、registry还是数据库的问题,避免在“UI打不开”这种模糊现象中浪费数小时。
6. 从离线安装到生产就绪:三个必须跨越的加固关卡
完成./install.sh只是万里长征第一步。离线环境的特殊性决定了,Harbor必须经历三重加固才能真正承载生产流量。这些步骤在官方文档中被弱化,却是金融级客户验收的硬性条款:
6.1 关卡一:证书体系的自主可控改造
Harbor默认的自签名证书在浏览器中触发红色警告,且无法被Kubernetes集群信任。生产环境必须替换为私有CA签发的证书。但离线环境无法访问CA服务器,因此需采用“离线签发”模式:
- 在可联网的机器上,用OpenSSL生成私有CA:
openssl genrsa -out ca.key 4096 openssl req -x509 -new -nodes -key ca.key -subj "/CN=MyPrivateCA" -days 3650 -out ca.crt将
ca.crt复制到离线服务器,放入common/config/shared/目录修改
harbor.yml,启用custom_ca:
# 在harbor.yml末尾添加 custom_ca: /path/to/ca.crt- 重新运行
./prepare,它会自动将ca.crt注入到所有容器的trust store中
关键经验:
custom_ca路径必须是绝对路径,且prepare会将其复制到common/config/shared/下。若路径错误,prepare不会报错,但证书注入失败。
6.2 关卡二:存储后端的高可用切换
离线环境默认使用本地文件系统(/data/registry),但这无法满足多节点部署需求。必须切换到S3兼容存储(如MinIO),而MinIO本身也需要离线部署:
提前下载MinIO二进制(
minio)和启动脚本在
harbor.yml中配置S3后端:
storage_service: s3: region: us-east-1 bucket: harbor-bucket accesskey: minioadmin secretkey: minioadmin endpoint: http://minio-server:9000 encrypt: false secure: false v4_auth: false关键点:
secure: false必须显式设置,否则Harbor会尝试HTTPS连接,而离线MinIO通常只启HTTP启动MinIO后,再运行
./install.sh,prepare会自动将S3配置注入registry/config.yml
6.3 关卡三:备份恢复的原子化封装
离线环境无法依赖云备份服务,必须实现本地快照+数据库dump的原子备份。我编写了一个harbor-backup.sh脚本,确保备份时所有服务处于一致状态:
#!/bin/bash # 1. 停止所有服务(避免写入) docker-compose down # 2. 备份数据库(PostgreSQL) docker run --rm -v /data/harbor:/backup:z \ -e PGPASSWORD=harbor12345 \ postgres:13.10 \ pg_dump -h postgres -U postgres registry > /backup/registry.sql # 3. 备份配置和证书 tar -czf /backup/config-$(date +%Y%m%d).tgz -C /data/harbor/common/config . # 4. 备份镜像数据(registry存储) tar -czf /backup/registry-$(date +%Y%m%d).tgz -C /data/harbor/registry . # 5. 重启服务 docker-compose up -d实操心得:备份脚本必须在
docker-compose.yml同目录执行,且/data/harbor需有足够空间。我建议将备份目标挂载为单独磁盘,避免备份过程占满系统盘导致Harbor崩溃。
这三个关卡,构成了离线Harbor从“能用”到“敢用”的分水岭。它们不改变安装流程,却重塑了整个运维范式——当你能自主签发证书、切换存储、原子备份时,Harbor才真正成为你掌控的基础设施,而非一个黑盒应用。
我最后一次交付是在某核电站的仿真训练系统。客户要求所有组件必须通过国密SM4加密,Harbor原生不支持。我们修改了core服务的源码,将JWT签名算法替换为SM2,再用离线编译的二进制替换容器内的/harbor/entrypoint.sh。整个过程没有一行代码触网,所有验证都在气隙网络中完成。那一刻我意识到:离线不是技术的退让,而是对系统本质的回归——当你剥离了所有外部依赖,剩下的,才是真正属于你的能力。