OpenGrok:自托管源码同步、索引切换与权限治理
搜索命中了已经删除的调用,真正陈旧的是索引
凌晨变更评审中,团队在 OpenGrok 搜到旧支付接口的 27 处调用,逐仓修完后又在 Git 里发现其中两处早已删除,另有一个新仓库根本没出现在结果里。搜索框没有报错,页面也能打开;错误发生在更早的地方:源码镜像上一次同步失败,索引器仍基于旧工作树成功退出,Web 层随后继续提供旧索引。对使用者来说,这是比服务宕机更危险的“绿色失败”。
OpenGrok 要成为可信的工程设施,必须把代码宿主、源码快照、索引代次、Web 配置和访问身份分别管理。搜索结果只能证明某个身份查询了某一代索引;只有同步提交、索引完成标记和 Web 当前代次能够对齐,结果才足以进入迁移决策。
先认清运行中的五份状态
OpenGrok 的 Web 页面不是直接搜索 Git 远端。同步任务先把仓库落到 source root;索引器读取这棵目录,调用语言分析器与 Universal Ctags,生成全文索引、符号与交叉引用数据,并写出 Web 使用的 configuration.xml。Web 应用读取配置和 data root 后,才提供全文搜索、定义跳转、引用、历史与 blame。
这五份状态有不同的成功语义。git fetch 成功不表示工作树已切到目标提交;索引器退出 0 不表示 Web 已加载新配置;Web 返回 200 不表示当前项目列表正确;用户能登录也不表示他只能看到获批项目。值班判断必须回答四个问题:源码快照对应哪个提交,索引属于哪次构建,Web 正在服务哪一代,以及当前身份为什么有权看到它。
用固定镜像跑起一个隔离试点
当前稳定发布基线是 OpenGrok 1.14.13。官方把版本解释为 major.minor.micro:主版本升级需要全量重建并调整配置,次版本升级需要干净的全量重建,微版本通常只需重新部署 Web 应用,而且通常只能在同一微版本线内可靠回退。版本号因此不是页面角落里的信息,而是索引能否复用、回退能否成立的运行契约。
OpenGrok 主体按 CDDL 1.0 only 发布,仓库也明确存在随文件声明的许可例外及第三方 NOTICE。内部原样运行通常不会产生对外分发动作;一旦修改并分发镜像、WAR 或覆盖文件,就要由组织的开源合规流程核对对应文件的源码提供、许可证与通知义务,不能把“免费自托管”误写成“没有许可治理”。
官方容器把 Tomcat 10、JRE 21、索引器、同步脚本和 Web 应用封装在一起,适合验证产品能力。它的说明也明确提示:大型源码、稳定服务、特殊 SCM 或认证授权需求应使用独立部署或基于官方镜像定制。试点机器需要 Docker、足够容纳源码与索引的本地磁盘,以及一个不含生产密钥的测试仓库。镜像固定到 1.14.13,生产发布再记录镜像 digest,不追随 latest 或 master,这样索引格式、JRE 和回退镜像才有共同基线。
先准备持久目录和一个测试仓库:
install -d -m 0750 /srv/opengrok/{src,etc,data}
git clone --no-local /srv/git/payment-lab.git /srv/opengrok/src/payment-lab
cd /srv/opengrok/src/payment-lab
git rev-parse HEAD把输出的完整提交号保存为探针基线。再创建 compose.yaml:
services:
opengrok:
image: opengrok/docker:1.14.13
container_name: opengrok-lab
restart: unless-stopped
ports:
- "127.0.0.1:8080:8080"
- "127.0.0.1:5000:5000"
environment:
SYNC_PERIOD_MINUTES: "0"
NOMIRROR: "1"
REST_TOKEN: "${OPENGROK_REINDEX_TOKEN:?set in secret-backed environment}"
INDEXER_JAVA_OPTS: "-Xms1g -Xmx1g"
INDEXER_OPT: "-i d:node_modules -i d:dist -i d:vendor"
volumes:
- /srv/opengrok/src:/opengrok/src:ro
- /srv/opengrok/etc:/opengrok/etc
- /srv/opengrok/data:/opengrok/dataSYNC_PERIOD_MINUTES: "0" 关闭周期同步,但容器启动时仍会执行一次索引;NOMIRROR 禁止容器自行拉取仓库,源码更新由外部受审计任务负责。REST_TOKEN 只保护容器提供的 /reindex 触发端点,不是用户登录凭证,也不是 OpenGrok Web REST API 的内部 bearer token。INDEXER_JAVA_OPTS 控制索引器 JVM;堆过小通常表现为 OutOfMemoryError,堆过大则可能把宿主机页缓存和 Web 进程挤出内存。INDEXER_OPT 影响进入索引的目录,排除依赖与生成物能降容量,却会让这些路径真正不可搜索。
将 5000 与 8080 都绑定在回环地址很重要。5000 是管理触发面,不应直接暴露到办公网;8080 后面还要接企业反向代理。启动时从秘密存储注入 token:
export OPENGROK_REINDEX_TOKEN="$(openssl rand -hex 32)"
docker compose up -d
docker compose ps
docker compose logs -f --tail=200 opengrok预期日志依次出现源码扫描、项目建立和索引完成,http://127.0.0.1:8080/ 随后能看到 payment-lab。首次构建时间取决于代码量、文件数、语言分析和存储性能;在索引完成前页面可访问但结果不完整,因此不能用 HTTP 200 作为就绪证据。官方容器目录约定与环境变量可在 1.14.13 Docker README 核对。
容器试点之外,独立部署需要 Java 21 或更高版本、Tomcat 10.x、Universal Ctags,以及提供历史能力时所需的 Git、Subversion 等 SCM 客户端;使用官方 Python 同步工具时还需要 Python 3.9+。发布包中的 source.war 部署到 Tomcat,索引器以 source root 为输入、data root 为输出,并用 -W 生成 configuration.xml。下面这条命令展示三份状态怎样接上,而不是让 Web 猜索引位置:
opengrok-indexer \
-J=-Djava.util.logging.config.file=/opengrok/etc/logging.properties \
-a /opengrok/dist/lib/opengrok.jar -- \
-c /usr/local/bin/ctags \
-s /opengrok/src -d /opengrok/data -H -P -S -G \
-U http://127.0.0.1:8080/source \
-R /opengrok/etc/read-only.xml \
-W /opengrok/etc/configuration.xml-s 与 -d 确定输入和索引位置,-W 把新配置持久化,-U 在索引末尾通知正在运行的 Web 应用加载新状态,-R 先读包含 API token 等受管项的只读配置。token 应通过权限受限文件和 --token @file 一类入口传递,不能进入命令历史或进程参数。官方 安装说明 同时给出了直接执行 JAR 与包装器两种方式;大型生产环境选择独立部署,主要是为了拆开同步、索引、Web 堆、权限和发布节奏,而不是为了换一条启动命令。
第一次正向实验:搜索、xref 与历史必须指向同一提交
在测试仓库加入三个文件:
// src/main/java/lab/LegacyGateway.java
package lab;
public final class LegacyGateway {
public static String charge(String orderId) {
return "charged:" + orderId;
}
}// src/main/java/lab/CheckoutService.java
package lab;
public final class CheckoutService {
public String checkout(String orderId) {
return LegacyGateway.charge(orderId); // OPENGROK_PROBE_7F3A
}
}# README.md
OpenGrok history probe: OPENGROK_PROBE_7F3A提交后,外部同步任务把工作树快进到该提交,再触发索引:
cd /srv/opengrok/src/payment-lab
git fetch --prune origin
git merge --ff-only origin/main
git rev-parse HEAD
curl --fail-with-body \
-H "Authorization: Bearer ${OPENGROK_REINDEX_TOKEN}" \
http://127.0.0.1:5000/reindex
docker compose logs --since 5m opengrokfetch --prune 更新远端引用,merge --ff-only 保证同步任务不会在镜像里制造合并提交;出现非快进时任务应失败并保留旧快照,而不是 reset 掩盖代码宿主异常。curl --fail-with-body 让 HTTP 错误变成非零退出码。触发成功只表示任务被接受,最终证据仍是日志中的索引成功与页面结果。
在 Web 中先搜唯一标记,预期命中 Java 调用和 README;点击 LegacyGateway.charge,预期能到定义并看到调用处的 xref;再打开 History 与 Annotate,预期提交身份等于同步任务记录的 HEAD。文本、符号和历史三条路径同时成立,才证明 source root、索引与 SCM 元数据对齐。若全文命中而 xref 缺失,优先检查语言识别、Universal Ctags 和分析器日志;若代码正确但 History 为空,检查 .git 是否随工作树挂载,以及容器内是否有相应 SCM 客户端。
反向实验:制造一份稳定的陈旧索引
现在在代码宿主删除 OPENGROK_PROBE_7F3A,但故意让同步任务使用无效远端:
cd /srv/opengrok/src/payment-lab
original_origin="$(git remote get-url origin)"
git remote set-url origin https://invalid.example.invalid/payment-lab.git
git fetch --prune origin
echo "sync_exit=$?"
git rev-parse HEAD预期 git fetch 非零退出,HEAD 仍停留在旧提交。此时不要触发索引;Web 继续命中旧标记,搜索服务本身也不会报错。这就是事故现场的失败证据:宿主最新提交已经变化,同步任务失败,服务代次没有推进。
更隐蔽的错误是同步失败后仍无条件执行索引器。它会再次基于旧工作树成功,日志末尾出现“索引完成”,却没有任何新代码。修复方式不是调整查询,而是把流水线写成有状态门:只有同步全部成功、每个仓库提交号已记录且源码快照冻结后,索引阶段才允许开始。
set -euo pipefail
git -C /srv/opengrok/src/payment-lab fetch --prune origin
git -C /srv/opengrok/src/payment-lab merge --ff-only origin/main
git -C /srv/opengrok/src/payment-lab rev-parse HEAD > /srv/opengrok/build/source-revision
curl --fail-with-body \
-H "Authorization: Bearer ${OPENGROK_REINDEX_TOKEN}" \
http://127.0.0.1:5000/reindex实验结束后不要把损坏的远端留在同步工作树中。恢复 URL,确认目标提交确实包含代码宿主上的删除,再重新索引:
git remote set-url origin "$original_origin"
git fetch --prune origin
git merge --ff-only origin/main
git rev-parse HEAD
curl --fail-with-body \
-H "Authorization: Bearer ${OPENGROK_REINDEX_TOKEN}" \
http://127.0.0.1:5000/reindex索引成功并由 Web 加载后,唯一标记应变成零命中。零命中仍只针对当前源码快照和当前索引;长期分支、未镜像仓库、排除目录、二进制文件和不受支持语言不会被这次结果证明不存在。
把源码同步从“定时 git pull”升级为可审计输入
官方容器会遍历项目并尝试同步仓库,Git 通常执行 git pull --ff-only,也可通过 /opengrok/etc/mirror.yml 调整镜像行为。小团队可以使用这条内置链路,但生产平台更适合把同步从索引容器拆开:专用服务身份读取代码宿主,逐仓 fetch 到临时目录,校验目标分支与提交,再把完整快照发布给索引器。这样网络失败、凭证过期和非快进不会与索引日志混在一起。
同步清单至少保存规范项目名、远端 URL、默认分支、允许的附加分支、SCM 类型、凭证引用、数据分级、同步 owner 和退役状态。凭证引用指向秘密存储,不保存 token 值。GitHub App、GitLab Project Access Token 或只读部署密钥应限制到批准仓库;不要让一个组织管理员 PAT 成为所有镜像的共同单点。
历史与 blame 需要 SCM 元数据,这会显著增加磁盘、网络和数据驻留。只复制导出源码能减少 Git 对象,却会失去历史能力。对于依法不得保留历史的仓库,建立独立项目或实例并关闭相应采集,而不是清理 .git 后仍向用户承诺 blame 完整。submodule 也不会因为父仓出现就自动成为可信语料:要么显式同步并作为项目建索引,要么在项目登记中声明未纳入。
全量重建不要在正在服务的 data root 上赌博
日常源码变化可增量索引,产品升级、索引格式不兼容、分析器配置改变、排除规则大改或数据损坏则需要全量重建。官方容器的 CHECK_INDEX 能在启动时检查格式,不兼容时会清空 data root 并从头索引。这个行为适合可容忍长时间空窗的试点,不适合把唯一生产索引原地擦除。
稳定做法是蓝绿两套完整的 source、data、etc 与 Web 实例。当前 A 栈继续服务;B 栈读取冻结的同一源码清单,在新目录生成索引和 configuration.xml,通过探针后才由负载均衡器一次切换流量:
/srv/opengrok/releases/
A/{src,data,etc,manifest.json}
B/{src,data,etc,manifest.json}manifest.json 记录镜像 digest、OpenGrok 发布标签、索引参数、项目到提交号映射、构建开始与完成的相对阶段、文件数、索引字节数和探针结果。它不进入 Web 搜索语料,却是判断两代差异的依据。
这里的“原子”是用户流量从完整 A 栈切到完整 B 栈,不是在线替换 data 目录的符号链接。Lucene 索引文件可能正被 Web 进程持有,直接改链接会让配置、文件句柄和缓存跨代混合。切流后立刻重跑同一组探针;失败就把负载均衡指回 A,而不是在 B 上现场修索引。上一代只保留经过风险评审的短回退窗口,避免源码副本和索引长期翻倍。
若只能单实例运行,至少先在独立 data root 完成构建,安排只读维护窗口,停止 Web,替换完整配置与数据引用,再启动并验证。它不是零中断方案,但比运行中覆盖索引可预测。
Web 登录与仓库授权是两层问题
OpenGrok Web 应只监听内网或回环地址,TLS、OIDC/SAML 登录、MFA 与会话策略放在受管反向代理或统一入口。代理校验身份后再转发到 OpenGrok,同时移除客户端伪造的身份头。示意 Nginx 配置如下,其中 auth_request 端点由组织的身份代理提供:
server {
listen 443 ssl;
server_name code-search.example.com;
location / {
auth_request /_auth;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-User $upstream_http_x_auth_request_user;
proxy_pass http://127.0.0.1:8080;
}
location = /_auth {
internal;
proxy_pass http://identity-proxy/verify;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
}
}这只解决“谁能进入站点”。反向代理不会自动理解每个 OpenGrok project 对应哪个 Git 仓库,也不会自行把代码宿主 ACL 转换为逐项目过滤;若同一实例混放不同密级仓库,只做全站 SSO 就会把所有项目暴露给每个已登录用户。
OpenGrok 在启用 projects 时也提供原生 authorization framework:认证仍由应用服务器或反向代理完成,Web 再用内置或自定义 authorization plugin 按请求和项目判定,且该框架覆盖页面与 REST API。这里有一个危险默认值:插件目录不存在或没有可加载插件时,框架允许请求通过,不能把“配置了插件目录”当成授权已经生效。采用这条路线时,要把代理传入的身份、插件栈、项目到 ACL 的映射、插件加载失败行为和普通用户探针一起验收,具体机制以官方 Authorization 文档为准。
如果团队不准备开发和长期维护这套授权映射,按同一授权集合拆实例或 URL 路由更容易审计:每组使用独立 source/data 目录、采集身份和入口策略;授权变化时从实例清单移除项目、重建索引并完成数据清除。两种方案都不能只验证登录成功,必须验证无权身份看不到项目、搜索结果、历史和 API 响应。
上线权限实验需要两个普通身份和两个测试项目。reader-a 被允许访问公开与受限实例,reader-b 只能访问公开实例;两者请求相同唯一标记,预期前者两处命中,后者对受限路由得到 403 或 404,而不是先加载页面再隐藏项目名。再撤销 reader-a 的组成员资格,等待身份代理缓存过期并复测。管理员浏览器会话不能代替普通身份,因为它会掩盖入口策略错误。
查询词、项目名、路径和命中片段也可能含客户名、漏洞代号或密钥。代理访问日志、OpenGrok 日志和 APM 标签按源码同级保护,避免记录完整查询参数与响应正文;确需审计时保存用户、项目、结果数、请求 ID 和策略判定,敏感查询内容采用限制访问与保留周期。
项目接入必须先证明“可搜”和“不可见”
应用团队提交项目登记后,平台任务用只读身份拉取仓库,在隔离构建栈完成索引。接入探针不应只搜索 README:至少选择一个唯一文本、一个定义与引用、一个最近提交的历史记录,以及一个被排除路径。前三项应命中,排除路径应稳定零命中;再用无权身份验证整个项目不可达。
INDEXER_OPT、只读配置文件和项目选择规则都属于平台契约。READONLY_CONFIG_FILE 指向的配置会在容器启动时与生成配置合并,适合把受审查的 Web/索引设置固化;它必须与 /opengrok/etc/configuration.xml 使用不同文件。不要向 INDEXER_OPT 追加内部已经使用的 -R。独立安装时,索引器用 -W 写持久配置,Web 从该文件加载;官方 安装说明 给出了 Java 与 opengrok-indexer 两种等价入口。
项目 owner 能修改业务代码,但不能直接写 source root、data root 或运行索引器。平台 owner 管同步清单、镜像与重建;身份团队管入口策略;安全团队审批数据域;应用 owner 提供探针和退役申请。这个职责分离能阻止“为了快点搜到”把生产 PAT、私有依赖缓存或未批准仓库直接挂进容器。
容量瓶颈从文件数、语言与更新节奏一起长出来
源码字节数只是起点。索引容量还受文件数、符号密度、历史元数据、语言分析器、项目数、排除规则和索引格式影响;构建耗时受 CPU、JVM 堆、ctags 并行度、磁盘随机 I/O 与 source/data 是否争用同一设备影响。大量小文件可能比同体积的大文件更慢,生成代码与 vendored 依赖又会放大符号和 xref。
试点要保存每代 source_bytes、source_files、index_bytes、构建耗时、峰值 RSS、失败分析器数、同步滞后、Web 查询 P95/P99 和磁盘剩余量。阈值来自代表性仓库基线和恢复目标,不复制通用倍数。真正有用的趋势是:相同发布线下 index_bytes/source_bytes 是否突然跃迁,构建耗时是否随变化量失去稳定关系,同步队列是否持续不回落,旧代保留是否让磁盘长期接近满载。
容量保护优先做语料治理:排除确定不需要的 node_modules、构建产物、二进制与镜像生成目录;把权限集合不同的大仓拆实例;限制附加分支和历史复制。随后才是增加索引器堆、CPU、SSD 和并行 worker。盲目增大 WORKERS 会同时放大代码宿主请求、Git I/O、ctags 进程和 data root 写入,可能让吞吐下降。
成本包括计算、SSD、蓝绿双份索引、源码镜像、备份、网络、代码宿主 API、身份代理、日志与平台值班。OpenGrok 没有托管许可证账单,不等于没有平台成本。团队应按活跃项目、索引字节、同步频率和查询使用率做归属,连续多个评审周期无人查询且 owner 已确认的项目进入降频或退役,而不是无限累积。
备份要区分“必须保存”和“可以重建”
source root 和 data root 理论上都能从代码宿主重建,但恢复时间可能超过业务容忍窗口。必须保存的是同步清单、只读配置、代理策略、镜像 digest、构建脚本、项目提交 manifest 与密钥引用;是否备份 Git 镜像和索引则由恢复时间、代码宿主可用性、存储成本与数据清除承诺决定。
如果备份 data root,必须先阻止索引任务写入;可以停止索引器与 Web 后复制,或使用能保证 data root、同代 configuration.xml 和 manifest 位于同一存储一致性点的快照。不能一边增量索引一边分别复制这些目录,也不能拿新配置打开未知代次的索引。恢复演练在隔离地址启动相同 OpenGrok 发布线的完整栈,运行文本、xref、history 与权限探针,成功后才能计入灾备能力。只检查备份文件存在,无法证明 Lucene 索引可读或授权入口仍正确。
升级时先阅读对应发布说明和索引兼容要求,在 B 栈使用固定镜像重建。官方容器的 x.y 标签可追随微版本并尽量避免全量重建,但生产仍应固定不可变 digest;标签变化不会留下足够的供应链证据。升级失败回退的是上一套完整 Web 与索引,不是只换回旧镜像去读取新格式 data root。
退役时按副本传播路径反向清除
一个项目退出 OpenGrok,先从同步清单移除并停止采集凭证,再在新索引代次确认项目、路径、符号和历史都不可见。切流后清理旧代 source、data、配置引用、容器缓存和临时构建目录;随后按保留策略处理代理日志、构建日志与备份。仓库在代码宿主删除,不会自动擦除已经镜像的 Git 对象与 Lucene 索引。
整个平台退役时,先冻结新项目与同步任务,导出项目到授权集合映射和必要审计记录,关闭入口流量,撤销 Git/SCM 只读身份与 reindex token,再删除 source、data、etc、镜像缓存和备份副本。销毁前保留一份不含源码的 manifest,证明最后服务代次、清理对象和责任人;不要为了“以后可能排障”永久留下可恢复源码的磁盘快照。
OpenGrok 的选型优势是自托管、语言广、全文与 xref 紧密结合,并能利用本地 SCM 提供历史。它的代价是平台必须自行承担同步、索引、授权隔离、容量和升级。只需单仓即时搜索时,rg 或 IDE 更轻;需要代码宿主原生权限且不想复制源码时,应优先评估宿主搜索;需要跨宿主精确代码导航与细粒度权限同步时,则要比较具备相应授权模型的平台。OpenGrok 最合适的位置,是组织愿意经营一套只读源码索引服务,并能把每一代搜索结果追溯到明确快照的场景。
