ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Openship 监控体系深度解析:边缘计数、控制面收集的零成本可观测性架构

Openship 监控体系深度解析:边缘计数、控制面收集的零成本可观测性架构 Openship 监控体系深度解析边缘计数、控制面收集的零成本可观测性架构【免费下载链接】openshipSelf-hosted deployment platform项目地址: https://gitcode.com/GitHub_Trending/ope/openship本文以 Openship自托管部署平台的 Monitoring 模块为对象系统讲解其流量统计近乎免费、资源采样按预算执行的设计哲学如何让每次访问的开销只有微秒级、如何把逐请求计数与 Postgres 落库彻底解耦、Top Paths 为何必须显式开启以及真实访客 IP 在 Cloudflare 与免费域名两种前置场景下的恢复机制。读完你将掌握这套边缘OpenResty计数 → 控制面API定时归集架构的完整链路并能直接对照仓库源码验证每一个性能数字与配置项。一图看懂三个问题与一个设计原则Openship 的每个项目都带有一个 Monitoring 标签页它回答三个问题我的应用现在在用什么资源、流量从哪里来、返回了什么结果。而它最核心的设计目标是——回答这些问题时访问者不需要付出任何代价。项目在 docs/monitoring.md 中明确写道我们收集分析数据通常意味着每个请求都多干了活因此这份文档把架构与实测成本摆在了台面上。先看它的短版本结论表维度数值加到访客请求上的工作量内存计数器更新约~1.4 µs开启 Top Paths 后约 3.1 µs外加一次国家查询缓存命中 0.01 µs / 未命中 2.5 µs——且全部发生在响应已发送之后请求路径上的 I/O零——没有 socket、没有 HTTP 调用、没有文件写入、没有数据库每请求写入 Postgres 的行数零每请求写入 Postgres 的行数按天每个域名 ≤1440 行外加每域名 1 行、每服务 288 行控制面是否在请求路径上否。访客请求永远不会到达 API 或数据库贯穿全文的设计原则一句话概括边缘负责计数控制面负责归集the edge counts, the control plane collects。请求只是在共享内存里累加计数器一个定时任务每 30 分钟把这些已经聚合好的数字搬进 Postgres。任何逐请求的数据都绝不会跨越进程边界。请求路径log_by_lua阶段的纯共享字典计数流量测量发生在 OpenResty 内部——也就是那个已经在终止 TLS 并向你的容器反向代理的同一个进程。它只运行一个 Lua 处理器 site_logger.lua挂在 nginx 的log_by_lua阶段。这个阶段的选择至关重要它运行在响应已经写回客户端之后。因此测量一个请求不可能延迟它——当测量发生时访客手里已经拿到了自己的字节。源码开头的注释也印证了这一点OpenResty log_by_lua - runs AFTER the response is sent. Pure shared-dict analytics. No Redis, no timers for counters, no batching. Every incr() is atomic across workers.见 site_logger.lua。处理器对每个请求做的工作约十几次incr/safe_add调用目标是ngx.shared.DICT共享字典区——这是 nginx 自身的共享内存进程内、跨 worker 原子。其中几次是有条件的响应时间只在非零时累加页面请求计数器只统计非静态 URL独立访客计数器只在该访客当天第一次请求时递增一次共享字典get用于检查该主机是否开启了按路径收集即下文要讲的 Top Paths 开关一次set写入一个固定 1000 槽位的环形缓冲区保存最近的原始请求供 Logs 标签页和实时地图使用。整个处理器里没有 socket、没有 HTTP 客户端、没有文件句柄、没有数据库驱动。由此推出两个结论它不可能阻塞在网络往返上也不可能以影响响应的方式失败。聚合而非插入成本随流量增长保持平坦请求不会创建一条记录它只是给已经存在的计数器加一。源码中的键结构如下见 site_logger.luas:{domain}:{minute}:r request count for this minute s:{domain}:{minute}:i / :o bytes in / out g:{domain}:{day}:{CC} hits from this country today g:{domain}:{day}:s:{code} responses with this exact status today某一分钟内的第 1000 个请求和第 1 个请求触碰的键完全相同。每秒 10 个请求和每秒 10000 个请求写入的键数量一样——只是键里的数值不同。此外Lua 端还通过incr无init时对缺失键返回 nil正好充当存在性探测实现了常见路径只做一次哈希操作的bump_indexed写法见 site_logger.lua让热点路径上的开销进一步收敛。一切无界的东西都有界计数器键按 (domain, minute) 和 (domain, day) 固定但有三样东西会随恶意输入膨胀因此每一项都被封顶路径Paths——默认关闭见下文开启时URL 模糊扫描器会为每个探测的 URL 铸造一个永久键所以查询字符串被剥离、数字与 UUID 段折叠为:id且每个域名每天超过 2000 个不同路径后尾部折叠进单一的other桶。源码中对应PATH_CARDINALITY_CAP 2000见 site_logger.lua与normalize_path的折叠逻辑见 site_logger.lua原始请求日志——固定 1000 槽位的环形缓冲区1 小时 TTL。槽位 1001 覆盖槽位 1该共享区永不增长。源码中RING 1000写入采用seq % RING取模见 site_logger.lua 与 site_logger.lua独立访客——每个访客按天加盐哈希存放在独立的 64 MB 共享区约每天 100 万独立访客。超过后该区 LRU 逐出、计数会偏低UI 会被告知这一点GET /status上报每个区的剩余空间仪表盘把该数值标记为近似值而不是把一个逐出伪影当成测量结果。每个共享区都单独、刻意地设定大小因此某个区的高基数洪泛不会把另一个区的计数器逐出。这些尺寸在 openresty-lua.ts 的EDGE_SHARED_DICTS中集中声明analytics256m分钟桶计数、每日地理、总量、request_data128m原始日志环形缓冲兼作实时 SSE 源、rules32m路由规则缓存、rl_counters32m限流计数与规则区分开以免高基数洪泛逐出规则集、visitors64m独立访客标记。配套的 edge-shared-dicts.test.ts 测试还专门钉死了声明尺寸与OpenResty 模块迁移脚本里就地改尺寸两个写入方必须收敛到同一目标值防止全新安装拿到迁移本要修正的旧尺寸。实测成本微秒级的账官方在真实的openship-edge镜像里跑处理器所写的那些共享字典写操作做基准测试aarch64、2 vCPU 容器、30 万次迭代、5 组样本场景每请求仅计数器2.86 – 3.18 µs计数器 原始日志环形记录4.11 – 4.40 µs约 1.3 µs 的差额几乎全部来自原始日志记录的cjson.encode。按维度拆解这解释了为什么其中一项是可选开启的维度每请求paths整个代码块1.72 µs—— 其中字符串处理normalize_path、静态资源检查1.38 µs分钟桶5 ×incr0.61 µs国家2 ×incr0.24 µs状态码1 ×incr0.14 µs可选开关本身的检查1 ×get0.07 µs而国家查询不是计数器值得单独一行——它是路径上缓存未命中时最贵的东西场景每请求国家LRU 命中回头客0.010 µs国家mmdb 查询LRU 未命中2.3 – 2.8 µs这条路径上没有磁盘而且缓存的存在不是为了省一次磁盘读。数据库MMDB是内存映射的首次触碰后常驻页缓存、跨 worker 共享每次查询都没有 read 系统调用——数据库在内存里正是 mmap 的意义。它每个 worker 只打开一次。一次查询的几微秒是CPU 而非 I/O。实测把整个 8.7 MB 文件读入页缓存后对相同地址重复三次raw mmdb walk, pass 1 (cold pages) 5.550 µs raw mmdb walk, pass 2 (warm) 5.450 µs raw mmdb walk, pass 3 (warm) 5.450 µs完全平坦——没有任何东西在等存储。那个时间就是 MMDB 格式本身一棵按地址位逐节点下探的二分搜索树IPv6 最多 128 跳加一次数据段解码和一次 FFI 穿越。把同样的字节复制进 Lua 堆会跑同样的遍历、花更多钱——因为把 140 万个节点做成 Lua 表是每个 worker 数百 MB 的 GC 托管对象而不是共享的 8.7 MB。所以 LRU 不是磁盘缓存而是对这棵树的遍历做记忆化memoization——这正是它值约 250 倍的原因也是移除它会变成每个请求都付全量遍历而非消除一次未命中的原因。现实成本因此随 IP 多样性变化有常客的站点每请求约 0.01 µs被全新地址逐个扫描的站点每请求付个位数微秒。给个量级参照在耗时 5 ms 的请求上4 µs 约占0.08%在一台 1000 req/s 的机器上约占一颗 CPU 核的 0.4%。三个诚实的注意事项第一这里测的是分析工作本身且在紧密循环中驱动真实请求还要付 nginx 自己日志阶段的固定开销——无论你是否收集任何东西它都存在——而且不会有同样热的缓存。第二数字来自一台机器把它当作数量级而非你硬件的保证。第三它不是零它很小、被测量过、且发生在响应发送之后。Top Paths 是显式开关为什么按路径计数要付费上面每个维度几乎免费的原因是边缘本来就在处理请求——只有一个例外。按路径计数占了约 3.0 µs 计数器路径中的1.72 µs57%其中 1.38 µs 是字符串工作剥离查询、把/orders/48219折叠成/orders/:id、检查 URL 是否为静态资源。它也是基数最高的维度每域名每天最多 2000 个键对比约 200 个国家和几十个状态码还是每日汇总里最大的列。因此它是逐项目开关默认关闭包括在开关出现之前就存在的项目——没有人主动选择承担这个成本所以没有人该被迫继续支付它。从 Monitoring 标签页的 Top Paths 卡片上打开它同一张卡片的 ⋯ 菜单可以再关掉。关闭时计数器路径降到约1.4 µs每请求。机制上开关是 Postgres 里的project.collect_paths字段被推送到项目每个主机名的边缘rules共享字典键为cfg:{host}:paths。日志处理器用一次get0.07 µs读取它键缺失时跳过整个代码块。刻意放在rules区而不是analytics区——后者是 256 MB 计数器在 LRU 压力下的高频换出区若开关键在那里被逐出就会悄无声息地改变收集内容。这套推送逻辑实现在 analytics-config.service.tspushProjectAnalyticsConfig每次调用都会把所有主机含已删除域名对应的旧主机名用于清理残留键打包成一次/analytics/config往返推给边缘——通过 SSH 隧道时一次往返远比逐主机往返便宜。让边缘与数据库保持同步开关缓存在 RAM 里因此恰好有两种漂移方式且两种都是已知的事件开关存活吗openresty -s reload一次路由变更存活——共享内存区在 reload 后保留完全重启重启、docker restart、镜像升级不存活——共享区被清空缺失意味着关闭所以重启后的机器会停止收集路径而数据库仍认为它应该收集。有三件事防止这变成一次静默、永久的失配每次 route apply 都会重新推送——任何部署或域名变更都会立即修正它30 分钟一次的分析归集会重新断言每个项目在每台边缘上的设置每台服务器一次往返。这把重启后的漂移限制在一次归集周期内而不是等到下一次部署——对一个稳定的项目那可能要等几周。对应 analytics-scraper.ts 中reconcileAnalyticsConfig的调用既然归集已经访问每台服务器就顺手把逐主机开关重新推一遍写操作是幂等的无条件推送比先读再条件写便宜关闭只有一个状态。边缘删除键而不是存0所以从未设置显式禁用重启丢失是同一个值。两种关闭的表示正是读者会把其中一个当成开启的根源。诚实的总结这是最多 30 分钟窗口的最终一致而不是事务性。而这个窗口正是这套分析数据本身一直生活在其中的那个窗口——计数器本来就在 RAM 里完全重启会丢失尚未被抓取的部分所以这个开关不比它所管辖的数据更弱。进入 Postgres30 分钟归集与行数预算边缘把计数器放在 RAM 里并带 TTL分钟桶 24h每日汇总 48h。一个定时任务在它们过期前把它们搬出去。analytics:scrape每 30 分钟一次cron 为13,43 * * * *定义见 job.registry.ts每台服务器一个连接——不是每项目、不是每域名一次POST /analytics/collect覆盖该机器上的所有域名由单次共享字典扫描服务。以前是 N 个域名的 2N1 次无界扫描在一台 50 域名的机器上那就是每轮归集对 256 MB 共享区做 101 次全量扫描。get_keys会遍历整个共享区并在此期间持有字典锁压制所有 nginx worker——analytics-scraper.ts 注释记录了这段历史分钟桶被原子地读出并删除然后批量 upsert每日汇总不删除边缘保留当天累计值所以每次归集都重读全天、upsert 覆盖。重复抓取是幂等的。服务器串行处理。每一台都意味着一次 SSH 连接或docker exec同时轰炸五十台机器正是指标归集变成一次事故的方式。没有任何东西在等这个 tickanalytics-scraper.ts 明确说明串行而非Promise.all一次不可达的失败被scrapeServerIfStale吞掉不会让任务失败或卡住后续服务器。打开标签页也会触发一次归集为了在定时计划之外保持新鲜度。这条路径有陈旧度门控跨进程所以第二个仪表盘标签页不会重复触发和 in-flight 去重窗口内的重复读取不会再次命中服务器。实现上scrapeServerIfStale用 cacheStore 记录lastAt:{serverId}键、以 60 秒 TTL 节流并用进程内inflightScrapeMap 合并并发调用analytics-scraper.ts。归集器的窗口逻辑也很关键toMinute now - 1因为当前分钟仍在累加立即刷新会截断它起始分钟从getLastScrapedMinute继续默认回退最近一小时。会产生多少行稳态、保留期稳定之后表每天行数保留期稳态server_analytics分钟桶每域名 ≤144090 天每域名 ≤129,600server_analytics_geo每日汇总每域名 1400 天每域名 400—— 其paths列仅当 Top Paths 开启时resource_usage5 分钟采样每服务 28830 天每服务 8,640≤1440是因为桶行只存在于实际有流量的那一分钟——收集器只在某分钟的计数器存在时才输出该桶。每小时只有 cron 流量的站点每天写约 24 行而不是 1440。1440 是该域名每分钟至少一个请求的天花板。因此一个繁忙域名上的五服务项目最终稳定在约173,000 行——几十 MB写入以每小时两小批到达而不是一条流。剪枝在夜间运行analytics:retention-prune04:23和resources:retention-prune04:29两者的 cron 与云模式下的可用性门控都定义在 job.registry.ts。唯一不免费的部分资源采样为什么另起炉灶资源采样与流量在本质上不同值得理解为什么。流量计数是免费的边缘本来就在处理请求递增计数器只是顺带。资源用量则需要一次主动探测——对每个容器执行docker stats——而 daemon 必须采集两个 CPU 样本才能算出一个百分比所以每次调用会占用它大约一秒——比健康检查用的廉价docker inspect约 2 ms高出几个数量级。这一个事实驱动了整个设计实现在 usage-sampler.ts节奏就是分辨率。resources:sample每 5 分钟运行一次1-59/5 * * * *每服务每天 288 个点。更快地采样是在以真实且不断增长的价格换取细节有界并发——每台服务器最多 8 个在途调用SAMPLE_CONCURRENCY 8所以 20 服务的栈不会同时发出 20 个一秒级调用每台服务器的预算——每次归集 120 个样本MAX_SAMPLES_PER_SERVER 120。溢出会被计数并在任务摘要中报告绝不静默丢弃——否则会读成那些容器是空闲的跳过已停止的容器——免费且避免为一个无话可说的容器付出一秒。采样任务刻意独立于健康检查任务services:health-watch因为健康检查承诺一切正常的 tick 不写数据库而采样器按定义每次都会写健康检查还会被容器事件在秒级突发地重复触发那正是时间序列最忌讳的且健康检查被selfhosted门控但云运行时实现了同样的 usage 接口骑上那个门控会让云项目完全没有历史数据usage-sampler.ts。另外采样按bucketMinuteFor把 epoch 分钟向下取整到RESOURCE_BUCKET_MINUTES的倍数再配合onConflictDoNothing使同窗口内的重跑成为幂等空操作usage-sampler.ts。标签页里的实时每秒视图是一条独立的 SSE 流只在标签页打开时存在它从数据库读取服务列表是每条流一次而非每个 tick 一次。收集什么、不收集什么计数而非身份访客计数是一个计数从来不是一种身份。地址用一个盐做哈希这个盐在你机器上生成、每天轮换、除那个共享字典区外从不写入任何地方——所以标记键不可逆推为 IP第二天就一文不值。只有最终得到的计数器到达 Postgres。任何一层都不存在逐访客的行。源码在 site_logger.lua 中实现了这一点vsalt:{day}键由第一个写入者生成safe_add保证竞态下只有一个赢家vd:{domain}:{day}:{hash}标记键以safe_add去重——它只在访客当天第一次请求时成功因此计数器恰好每独立访客递增一次。原始请求日志IP、路径、User-Agent存在于边缘的 1000 槽环形缓冲区中一小时控制面从不持久化。而且原始日志在写入前就做了凭据清洗查询字符串常携带 OAuth 码、签名 URL、token被完全剔除两个已知的携带凭据路由/accept-invite/:token与/api/auth/invitation-preview/:token在进入聚合或环形缓冲之前就被折叠site_logger.lua并有专门的隐私测试 site-logger-privacy.test.ts 钉死这两个返回值。此外日志处理器里每个子块GeoIP、访客、路径、状态码、环形缓冲都分别用pcall隔离——因为log_by_lua里抛错会中止处理器剩余部分一次坏的共享字典调用会静默清零其后所有计数器这正是当初paths 和 statuses 读起来像没有流量而 requests 和带宽一切正常这类 bug 的来源site_logger.lua。位于 Cloudflare 或其他代理之后如果边缘位于某个代理之后连接的对端是代理而不处理这一点的话每个访客的国家都会解析到 PoP、独立访客计数会塌缩到 PoP 的数量级、按 IP 的限流会把某个 PoP 后面的所有人装进同一个桶。Openship 用 nginx 的realip模块配合 Cloudflare 发布的地址段恢复真实客户端地址尊重CF-Connecting-IP。信任锚定在谁打开了这个 socket只有当对端是可信地址时头才被采信所以你自己在源站发送它毫无作用。为什么用CF-Connecting-IP而不是X-Forwarded-For实现在 edge-real-ip.ts 中有完整论证核心两条Cloudflare覆写CF-Connecting-IP——客户端发送的任何值都被丢弃到达我们手里的值完全是 Cloudflare 自己对客户端地址的陈述不含任何客户端提供的内容Cloudflare 对X-Forwarded-For是追加——到达的值形如客户端塞的, 真实客户端是一个部分受客户端控制的列表。正确读取需要real_ip_recursive on并从右往左走取最左项朴素读法也是大量代码的做法等于让客户端自己选地址——也就自己选限流桶和封禁规则。set_real_ip_from不是信任这个头而是仅当 TCP 对端是这些地址之一时信任这个头。直接 curl 源站并携带伪造CF-Connecting-IP: 1.2.3.4的访客不是从 Cloudflare 地址连接的nginx 会忽略该头并保留其真实对端地址——信任锚定在客户端无法伪造的谁开了 socket上。对于非 Cloudflare 代理设置OPENSHIP_EDGE_TRUSTED_PROXIES逗号分隔的 CIDR以及可选的OPENSHIP_EDGE_REAL_IP_HEADER。值得注意的实现细节edgeTrustedProxies会丢弃格式错误的条目而非透传——一个坏 token 会让nginx -t失败失败的测试意味着 reload 被拒绝、该机器上任何路由变更都无法落地所以一个环境变量的拼写错误就会卡死整台机器的所有路由对只会扩大信任范围的值被静默忽略是更好的失败方式edge-real-ip.ts。生成的openship-real-ip.conf以 include 文件形式落盘而非拼进http {}因为裸机收敛是 append-only 的grep || sed无法更新已写内容而会随 Cloudflare 新增地址段增长的信任列表必须可整文件覆写edge-real-ip.ts。免费的*.opsh.io域名免费域名由 Openship Cloud 自己的边缘提供服务它通过纯 :80 代理到你的机器并把访客写在X-Real-IP里——这与 Cloudflare 路径是不同的头而 nginx 每个配置作用域只允许一个real_ip_header。所以这些 vhost且只有这些 vhost携带它们自己的realip块由registerRoute发射到server { }内部。这个块信任任何对端的X-Real-IP与 Cloudflare 路径不同。它必须如此Cloud 边缘从自己的地址到达你的机器opsh.io被 Cloudflare 代理只覆盖访客→边缘这一段而一个不包含真实出口地址的对端列表会静默忽略该头——让每个免费域名访客都被记为边缘。边界是server_name它只作用于slug.opsh.io这个主机名它的唯一合法路径就是那个边缘。自定义域名继续使用对端锚定的 http 作用域块且绝不能把这个块扩到自定义域名上去edge-real-ip.tscloudEdgeRealIpConf发射real_ip_header X-Real-IP;、real_ip_recursive off;、set_real_ip_from 0.0.0.0/0;与::/0;并以内联注释说明了该信任为何只能锚定到 vhost。判断逻辑isCloudFrontedHost刻意使用字面量云域名SYSTEM.DOMAINS.CLOUD_DOMAIN而不是 API 的getRoutingBaseDomain()当设置了HOST_DOMAIN时托管主机名住在操作者自己的通配符 zone 上、直指本机、前面没有任何东西——在那里放一个信任头块的块等于相信一个没有任何代理会设置的头即把任意选择源地址的权利白送给每个访客edge-real-ip.ts。已有 vhost 会在下一次 route apply 时捡起这个块——部署、Ensure edge、域名保存或 Retry routing 都会触发而 edge-ensure 路径还会重放任何盖戳低于当前VHOST_GENERATION的 vhost所以仅一次升级不再会让一个已生成配置的修复落空。在 Openship CloudSaaS上在 SaaS 上流量分析数据位于 Oblien 的边缘按需读取——从不被抓取进本地数据库。因此analytics:scrape和analytics:retention-prune两个任务在那里都不可用因为没有本地数据可收集或剪枝job.registry.ts 明确注释Cloud 没有受管服务器也没有 OpenResty。资源用量确实会在云上采样云运行时实现了同一个 usage 接口所以采样和它的保留剪枝在该模式下照常运行。归集器内部也有一道硬停止runAnalyticsScrapeSweep在env.CLOUD_MODE下直接返回空结果scrapeServerIfStale同样——共享的分析读取绝不能从 SaaS 拨号到租户的服务器analytics-scraper.ts。无论哪种形态仪表盘只读取一种形状一个解析器报告项目的流量来源是自托管还是云标签页本身并不知道差异。延伸阅读边缘日志处理器完整实现packages/adapters/src/infra/lua/site_logger.lua共享字典尺寸声明与 OpenResty 配置生成packages/adapters/src/infra/openresty-lua.ts真实客户端 IP 恢复packages/adapters/src/infra/edge-real-ip.tsTop Paths 开关推送与重断言apps/api/src/modules/analytics/analytics-config.service.ts30 分钟归集任务apps/api/src/modules/system/analytics-scraper.ts资源采样任务apps/api/src/modules/monitoring/usage-sampler.ts任务注册与 cron 定义apps/api/src/modules/jobs/job.registry.ts遥测隐私测试packages/adapters/src/infra/site-logger-privacy.test.ts【免费下载链接】openshipSelf-hosted deployment platform项目地址: https://gitcode.com/GitHub_Trending/ope/openship创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表