Fizzy SaaS 模式全解析fizzy-saas 引擎、Hotcell 附件隔离处理与 Kamal 部署指南【免费下载链接】fizzyKanban as it should be. Not as it has been.项目地址: https://gitcode.com/GitHub_Trending/fizzy2/fizzy本指南以 saas/README.md 为骨架结合仓库内引擎源码、构建脚本、部署配置与测试用例系统讲解如何在开源 Fizzy看板应用上开启 SaaS 模式、接入 Stripe 计费与原生推送通知以及核心亮点——通过 Hotcell 在无网络、无特权的旁路容器cell中隔离处理图片变体、文件分析与 PDF/视频预览的完整架构与部署链路。读完你将掌握 SaaS 模式的开关机制、本地开发与部署流程以及一套可复制的内容哈希镜像 自动发布/重启钩子的旁路组件治理方案。一、fizzy-saas 是什么fizzy-saas 是一个 Rails Engine由 37signals 随 Fizzy 一起打包发布用于承载托管版本https://fizzy.do。它不是一个独立的应用程序而是叠加在开源 Fizzy 之上的 SaaS 增强层。从 fizzy-saas.gemspec 可以看到它的定位名称与版本fizzy-saas版本号由 lib/fizzy/saas/version.rb 提供授权以 OSaasy License 发布见 saas/LICENSE.md与开源 Fizzy 的许可相互独立职责范围gem 打包的内容集中在app、config、db、lib、exe目录可执行文件为push-dev与stripe-dev两个开发辅助脚本依赖除 Rails 外还依赖queenbee订阅/账户体系、console1984/audits1984受保护的数据库控制台与审计、yabeda全家桶Prometheus 指标、sentry-ruby错误上报等。引擎的装载入口是 lib/fizzy/saas/engine.rb它通过一系列initializer完成如下挂载将自身路由挂载到/mount Fizzy::Saas::Engine /路由定义见 saas/config/routes.rb补充my/devices、admin/stats、admin/console、hotcellz与hotcellz/test等端点让原生推送设备ActionPushNative与 SaaS 相关模型走独立的saas数据库SaasRecord通过connects_to database: { writing: :saas, reading: :saas }连接见 saas/app/models/saas_record.rb插入TrackTrueClientIp中间件在 Rails 的RemoteIp之前把 Cloudflare 的True-Client-IP拷贝进X-Forwarded-For见 saas/lib/fizzy/saas/true_client_ip.rb在active_storage.configs之前注册 Hotcell cellCell.register!并合并 Active Storage 的远端处理配置在to_prepare阶段把计费、存储限额、原生推送目标等模块混入主应用的Account、Identity、Signup、CardsController等类。从源码结构看SaaS 化还体现在计费与限额上订阅模型 saas/app/models/subscription.rb 定义了FreeV1免费档位存储限额模型 saas/app/models/account/storage_limited.rb 设置了默认 1GB 上限与 500MB 预警阈值并提供exceeding_storage_limit?/nearing_storage_limit?/add_storage_exception等判定与豁免接口。二、在开源 Fizzy 上切换 SaaS 模式SaaS 模式与开源模式的切换由两个 Rails 任务完成bin/rails saas:enable # 开启 SaaS 模式 bin/rails saas:disable # 回到开源模式结合 saas/AGENTS.md 的说明可以理解其底层机制saas:enable创建tmp/saas.txt标记文件saas:disable删除它Fizzy.saas?同时读取环境变量SAAS——任何非false的值都会启用而SAASfalse即使文件存在也会强制关闭该环境变量路径用于生产镜像与bin/ci不适合用来手动切换本地检出启用后会同时完成两件事把 bundle 切换到Gemfile.saas引擎依赖、推送、队列等完整依赖并把默认数据库适配器切换为 MySQL对应 config/database.mysql.yml。也就是说saas:enable会改变后续所有bin/rails与bin/kamal命令的默认行为部署前置条件只有处于 SaaS 标志之下bin/kamal才会传入-c指向本引擎的 saas/config/deploy.yml否则 Kamal 会读取根目录的 config/deploy.yml——那是自托管示例配置对托管环境的部署目标一无所知。引擎自带测试任务运行全部 SaaS 测试使用bin/rails test:saas该任务定义在 saas/lib/tasks/fizzy/saas_tasks.rake收集引擎test/**/*_test.rb下所有用例。三、如何把 gem 改动同步回 Fizzyfizzy-saas 以 gem 形式存在修改完引擎代码后需要让主应用依赖更新才能生效BUNDLE_GEMFILEGemfile.saas bundle update --conservative fizzy-saas这里显式指定Gemfile.saas作为 bundle 入口--conservative只升级fizzy-saas本身而不顺带升级其他依赖避免 SaaS 环境发生意外的间接版本漂移。这也是引擎开发的标准迭代节奏改 gem → 更新主应用锁文件 → 运行测试。四、本地对接 Stripe 计费首次使用 Stripe 集成需要两步准备安装 Stripe CLI执行stripe login并授权37signals Development环境。此后在本地开发 Stripe 集成时需要通过脚本建立隧道并注入环境变量eval $(BUNDLE_GEMFILEGemfile.saas bundle exec stripe-dev) bin/dev # 必须在同一个终端会话里启动开发服务器eval是关键——stripe-dev声明于 fizzy-saas.gemspec 的 executables向标准输出打印环境变量赋值语句只有被当前 shelleval后bin/dev启动的进程才能继承这些变量。脚本会请求 1Password 授权以读取并设置 Stripe 所需的凭据。Stripe 按环境划分为三套独立账户Development沙箱测试环境配合 Stripe CLI 的本地隧道做开发验证Staging基础设施验证环境使用独立的测试密钥Production真实计费环境仅通过受控部署接触。底层数据模型上订阅表account_subscriptions保存stripe_customer_id唯一索引与stripe_subscription_id见 saas/db/migrate/20251203144630_create_account_subscriptions.rb引擎在to_prepare中通过Queenbee::Subscription.short_names Subscription::SHORT_NAMES把计费档位动态注册为顶层常量如FreeV1Subscription。五、本地测试原生推送通知APNs / FCM要在本地验证原生推送APNs 与 FCM用--push标志启动开发服务器bin/dev --push这会请求 1Password 授权拉取推送凭据并注入环境。需要注意本地加载的是生产环境的 APNs 与 FCM 凭据。其实现位于 saas/exe/push-dev脚本通过op read从 1Password 的Deploy/Fizzy/Production条目读取APNS_KEY_ID、APNS_ENCRYPTION_KEY_B64、FCM_ENCRYPTION_KEY_B64并输出export ...语句与ENABLE_NATIVE_PUSHtrue因此同样需要eval $(bundle exec push-dev)式用法才能生效。引擎侧原生推送设备模型ApplicationPushDevice由 saas/app/models/application_push_device.rb 提供并注册了名为:native的推送目标Notification.register_push_target(:native)对应设备管理界面与接口位于my/devices控制器见 saas/app/controllers/my/devices_controller.rb。六、Hotcell把附件处理隔离到无特权旁路容器这是本仓库 SaaS 层最值得深入的部分。图片变体variant、blob 分析以及 PDF / 视频预览可以运行在一个无网络、无特权、与数据库凭据隔离的兄弟容器中而不是在持有数据库凭据的应用进程里执行。这个容器被称作cell全部相关代码位于 saas/hotcell/其 Dockerfile、独立的 Gemfile 与 Gemfile.lock、资源上限配置 config.rb以及它对外提供的操作operations。6.1 为什么要一个cell思路来自最小化爆炸半径blast radius处理用户上传的任意二进制内容图片、PDF、视频意味着要运行解析器和转换器这些工具链一旦有漏洞若跑在应用进程内就直接暴露数据库凭据。把这类工作放进一个只有两个 Unix socket、没有网络的兄弟容器即使解析器被攻破攻击者也拿不到任何网络能力更拿不到数据库凭据。Dockerfile 中这段注释点明了设计哲学——cell 的镜像内所有东西都在爆炸半径内请把这个文件当作预算而不是清单。6.2 本地如何运行 cell在 SaaS 模式下bin/dev会通过 saas/Procfile.dev 与 foreman 在服务器旁启动一个 cellbin/dev # cell 随开发服务器一起启动承担附件处理与生产行为一致本地 cell不容器化运行原因在 README 中写得很清楚macOS 上 Docker 运行在一个 Linux VM 里容器无法接收文件描述符——SCM_RIGHTS无法跨两个内核传递所以开发环境让 cell 直接以宿主机进程方式跑。Procfile 中cell:一行的要点是cell: env -u RUBYOPT -u RUBYLIB ... BUNDLE_GEMFILE$PWD/saas/hotcell/Gemfile \ HOTCELL_DIR$PWD/tmp/hotcell/active_storage \ HOTCELL_CONFIG$PWD/saas/hotcell/config.rb \ HOTCELL_OPERATIONS$PWD/saas/hotcell/operations \ bundle exec hotcell --development它显式清掉主应用注入的 bundler 相关环境变量-u改用 cell 自己的Gemfile与自己的配置并把操作目录指向 saas/hotcell/operations/。由于开发环境的 cell 直接在你的笔记本上执行命令Dockerfile 里的每个工具libvips42、mupdf-tools、ffmpeg都需要宿主机等价物——bin/setup会安装它们如果你往镜像里加了新工具必须同步加进.mise.toml和Brewfile否则该操作只会在开发环境失败。6.3 /hotcellzcell 的可达性检查/hotcellz回答cell 是否可达这一简单问题路由与控制器定义见 saas/config/routes.rb 与 saas/app/controllers/hotcellz_controller.rb。它有两个端点成本完全不同端点鉴权检查内容成本/hotcellz匿名仅控制 socketdescribe与metrics极低supervisor 内联回答不 fork/hotcellz/test仅 staff完整诊断含两趟工作 socket 往返每趟都 fork 一个 worker/hotcellz返回OK200或FAIL503。它是匿名端点便于监控系统轮询除此之外什么也不透露——陌生人不该知道 cell 的库存或负载/hotcellz/test以 JSON 返回完整诊断at、host、root以及各检查项的ok/error逻辑在 saas/lib/fizzy/saas/cell.rb 的Cell.diagnostics(work: true)中实现。staff 门槛由Current.identity.staff?强制见ensure_staff_access未登录直接403不跳登录页——探针要的是答案。为什么需要两趟工作 socket往返并且为什么它们要藏在 staff 之后因为两个示例操作证明的是同一个机制的两半example.echosaas/hotcell/operations/echo.rb直接读取调用者传来的文件描述符证明SCM_RIGHTS端到端传递成功example.reopensaas/hotcell/operations/reopen.rb按名字重新打开输入输出Linux 上是/dev/fd/NmacOS 上是文件自身路径这是一次全新的 open会按 cell 的 uid 重新做权限检查——所有把文件名交给外部工具的操作走的都是这条路。因此一个 group 配错的 cellecho完美通过而reopen失败——单看echo会把一个坏掉的 cell 误判为健康。加上每趟往返都要 fork worker所以这两趟检查被放在鉴权之后。结论是设计性的监控看不到坏掉的工作 socket只有这两趟往返能发现而它们仅限 staff——因为那属于配置错误而不是会自我恶化的故障所以每当配置变更时应该手动执行/hotcellz/test或Cell.diagnostics(work: true)验证。6.4 两个开关HOTCELL_ROOT 与 HOTCELL_GROUPcell 的启停完全由两个环境变量控制README 中的开关表如下变量作用所在位置HOTCELL_ROOT注册 cell注册后所有转换都由 cell 承担不设置则一切在应用内完成saas/config/deploy.ymlHOTCELL_GROUP应用与 cell 共享的 gid使 cell 能按名字打开应用交给它的文件必须与应用的group-add及 cell 自身的 gid 一致开发环境不设置两侧以同一用户运行saas/config/deploy.yml源码层面saas/lib/fizzy/saas/cell.rbCell.root读取HOTCELL_ROOT若在非 local 环境且未设置且没有SECRET_KEY_BASE_DUMMY——那是 Dockerfile 里资产预编译时启动生产环境的场景会直接抛HotCell::ConfigurationError强制要求生产必须显式配置Cell.group读取HOTCELL_GROUPgem 的 setter 会做数值校验Cell.enabled?即root.present?register!用timeout: 135注册名为active_storage的 cell——135 秒必须覆盖 cell 的answer_within即queue_wait deadline reply否则一个饱和的 cell 会表现为传输层失败而非它自己的判决同时把UnprocessableAttachment标记为永久性错误、ProcessingUnavailable标记为瞬态错误继承关系就是分类Cell.active_storage_configuration在启用时把 Vips 变体处理器、Image/Video/Audio 分析器、PDF/视频预览器全部换成 HotCell 客户端实现并通过引擎 initializerbefore: active_storage.configs合并进app.config.active_storage。对应地saas/config/deploy.yml 中env.clear设置HOTCELL_ROOT: /run/hotcell HOTCELL_GROUP: 10001为什么 gid 必须三方一致因为按文件名重开描述符reopen路径是一次按 cell 的 uid 重新校验的全新 open应用拥有的 0600 临时文件对 cell 是EACCES。解决方案是group-add: 10001加到 web 与 jobs 两个角色上见 saas/config/deploy.yml 的servers.web.options与servers.jobs.options让应用把文件放进共享组缺了它错误只是从 cell 里的EACCES挪到应用里的EPERM并没有消失。三处数字cell 的--user 10001:10001、角色的group-add 10001、HOTCELL_GROUP10001必须一致而 saas/test/lib/hotcell_accessory_test.rb 中的测试 the app shares the cells group 正是跨所有部署目标校验这一点。6.5 cell 的资源上限与内部结构saas/hotcell/config.rb 定义的是天花板而非默认值——单个操作自身的上限会被钳制到这些值以内因此它们要按最苛刻的操作来定视频预览器的 120 秒 deadline、图片转换器的 256MB 文件大小HotCell.limits concurrency: 4, queue_size: 8, queue_wait: 10, deadline: 120, memory: 1536 * 1024**2, file_size: 256 * 1024**2单位说明deadline与queue_wait是秒memory与file_size是字节这里均为 1536MB / 256MB。Dockerfile 的工程细节同样值得留意分阶段构建bundle 在build阶段编译需要编译器而真正运行的镜像不携带编译器——运行不可信字节的镜像不能带编译器安全补丁自持apt-get upgrade主动应用 Debian 的待发布安全补丁避免等上游ruby:3.4-slim重建工具集最小化只装libvips42、mupdf-tools、ffmpeg。没有 LibreOffice——Fizzy 接受 office 文档但从不预览装它的解析器等于白花钱买爆炸半径最小权限用户hotcell用户 uid/gid 10001无 home、无 shellHOME/tmp移除镜像内所有 setuid/setgid 位chmod a-s与应用侧no-new-privileges双重独立防护线程池对齐OMP_NUM_THREADS2匹配 cell 的 CPU 配额cpus: 2是 CFS 配额而非亲和掩码防止 libgomp 在超过 90 核的生产宿主机上为每个核开线程、把 worker 的RLIMIT_DATA打穿导致pthread_create返回EAGAIN、进而exit(1)静默崩溃Dockerfile 注释记录了 bc3 某次安装中 285 个 worker 因此被杀的真实事故健康检查hotcell-health从容器内探测控制 socket——因为容器内network: none不适用外部探针。cell 的 Gemfile 刻意保持极短每个 gem 都在爆炸半径内、每次请求都要付钱只有hotcell-server与activestorage-hotcell-server均锁定0.4.1并明确hotcell-client和rails不属于这里。而 operations/active_storage.rb 逐个 require 需要的操作不加载 ImageMagick / Poppler 操作因为镜像里没有这些工具并把图片分析的文件上限从 gem 默认的 48MB 抬到 256MB——48MB 装不下一张 4800 万像素的手机照片同时屏蔽openslidefork 的 worker 里会 segfault sqlite与tiffFizzy 从不读取加载器。6.6 部署Kamal 两个钩子让 cell 自动跟上生产部署使用 Kamal两个容器一起部署bin/kamal deploy -d destinationKamal 把 cell 当作一个accessory自己的镜像、自己的生命周期。两个钩子让 cell 与应用永远保持同步部署者无需操心pre-buildsaas/.kamal/hooks/pre-build执行saas/hotcell/bin/check --publish如果 registry 里没有本次提交 pin 的 cell 镜像就按宿主平台构建并推送。发生在部署锁之前、不做任何 sshpre-deploysaas/.kamal/hooks/pre-deploy执行saas/hotcell/bin/check --reboot如果宿主机上跑的不是 pin 的镜像就地重启该 accessory。发生在锁内、应用启动之前。两者都从被部署的提交KAMAL_VERSION取 pin所以bin/kamal rollback会把 cell 一起回退两者都尊重--hosts与--roles只影响指定的机器。如果故意要在坏掉的 cell 之上部署用SKIP_HOTCELL_CHECKS1跳过两个检查。手动检查则用saas/hotcell/bin/check destination # 报告 cell 状态与修复方式 saas/hotcell/bin/check destination --apply # 直接修复退出码直接命名修复动作2 需要构建并推送3 只需重启 accessory1 检查自身无法判断。脚本saas/hotcell/bin/check的实现要点检查分两步先本地问 registry零 ssh 成本docker manifest inspect判断 pin 的 tag 是否存在再随机抽查一个宿主机上 accessory 实际跑的镜像pin 按目标漂移而不按宿主机漂移全量 ssh 生产要一分钟--versionSHA从指定提交取 pin而非工作树这正是 rollback 能落回对应 cell 的原因--hostsa,b收窄时只问/只重启这些机器宁可失败也不猜docker 未登录、ssh 不通都会判为无法判断退出码 1并明确告知修复方式。README 点出理由因为docker manifest inspect认证失败就让人重建一个已发布镜像是最昂贵的失败模式。6.7 修改 cell 的标准工作流任何被镜像拷入的saas/hotcell/内容变化或Gemfile.saas.lock中 hotcell gem 的版本移动都意味着一个新镜像。CI 会在你欠镜像时给出提醒saas/test/lib/hotcell_accessory_test.rb 的 the pinned image is the one this tree builds 用例会失败——如果deploy.yml的 pin 与当前树构建出的 tag 不再一致。注意该测试是对 Kamal 生成的docker run命令断言而非对 YAML 断言Kamal 自己会加 flag文件里正确的东西可能到 daemon 变成重复参数并且遍历全部目标beta除外——它是需要BETA_NUMBER的模板。完整改动流程README 的 5 步构建它会顺带 pin saas/config/deploy.ymlsaas/hotcell/bin/build --platformlinux/amd64跑测试bin/rails test saas/test/lib/hotcell_accessory_test.rb一起提交两份 lockfile、pin、以及saas/hotcell/下所有改动部署每个目标。钩子看到新 pin 不在 registry → 推送 → 重启 cell → 部署应用bin/kamal deploy -d destination验证目标/hotcellz返回OK/hotcellz/teststaff 专属四项检查全过Prometheus 中每台宿主机hotcell_up 1Loki 中{service_namehotcell}无WARN/ERROR行。build是唯一必须手动执行的步骤因为它写下的 pin 必须属于你的提交tag 是内容哈希改变内容的那个提交必须携带它check --publish拒绝推送未提交的 pin。相关脚本职责分明全部位于 saas/hotcell/bin/image被 build 与 deploy 共同 source保证两者对镜像名认知一致build把 cell 的 lockfile 锁到应用锁文件的 hotcell 版本、构建镜像、把 saas/config/deploy.yml 的 pin 更新为内容哈希 tag。不碰 registrypush把已构建镜像推送到 registry并校验架构必须是 amd64否则宿主机无法运行accessory 会 crash-loop 且应用侧毫无报错。不可逆的一半所以从 build 中独立出来重启仍然显式、不在其中check前述的检查/修复脚本。6.8 为什么这样设计三个核心决策tag 是内容哈希而不是 git revision。tag 取Dockerfile、Gemfile、Gemfile.lock、config.rb、operations/*.rb的 SHA-256 前 12 位见 saas/hotcell/bin/image。原因若用 commit 命名那个升级 gem 并 pin 结果的提交永远无法命名自己——amend 它会改变 pin 本该指向的 SHA。相同字节得到相同 tag改到镜像不包含的东西则完全不影响它。build 把 cell 的 lockfile 锁到应用的 hotcell 版本。因为客户端与服务器差一个版本就是每次请求的protocol失败。应用的 lockfile 是唯一事实来源先移动应用里的 gem再构建。build 通过awk分别从Gemfile.saas.lock与 cell 的Gemfile.lock读hotcell-core版本两侧都以精确约束依赖它不一致时用sed改写并刻意删除 CHECKSUMS 行避免旧版本的 sha256 挂到新版本上导致 bundler 校验拒绝再重新bundle install并重读验证——不能信任一次 sed。tag 不可变没有latest。部署不会更新 accessorykamal accessory reboot拉取的是该 tag 当前指向的内容一个漂移的 tag 会让宿主机跑什么取决于它上次重启的时间。这也是为什么只升级 gem 的部署也必须重启 cell——pre-deploy 钩子替你做了这件事。七、部署环境总览Fizzy 托管版用 Kamal 部署部署前需要配置好 1Password CLI 以读取机密之后部署就是一句bin/kamal deploy -d destination各环境定位详见 saas/README.md 与各deploy.destination.ymlProductionhttps://app.fizzy.do/正式环境blob 存储使用 FlashBlade 桶Betahttps://beta1.fizzy-beta.com主要用于产品功能测试与生产共用同一套数据库与 Active Storage 配置目前有 1 个 beta 环境部署命令bin/kamal deploy -d beta1。注意 saas/config/deploy.beta.yml 是模板必须提供BETA_NUMBER环境变量beta1是唯一真实存在的编号目标saas/.kamal/secrets.beta2至secrets.beta4符号链接是历史遗留不构成真实目标Staginghttps://app.fizzy-staging.com/主要用于基础设施变更测试使用与生产类似但完全独立的数据库与 Active Storage 配置。各目标的环境差异通过 saas/config/deploy.beta1.yml、saas/config/deploy.staging.yml、saas/config/deploy.production.yml 对基础 saas/config/deploy.yml 做深合并deep-merge而来——这正是 accessory 测试要遍历所有目标的原因目标文件深合并时数组是替换而非合并。生产监控与可观测性由yabeda体系支撑引擎在 saas/lib/fizzy/saas/engine.rb 的fizzy_saas.yabedainitializer 中安装了 SolidQueue、ActionCable、GVL、ActiveSupportCache、SolidCache、HotCell 等指标插件并定义fizzy_replica_stale计数与fizzy_replica_wait直方图saas/lib/fizzy/saas/metrics.rb配合事务固定中间件TransactionPinning度量读副本追主库的等待时间。八、维护模式把生产离线要因维护把生产下线在负载均衡器上通过knife ssh执行kamal-proxy stopknife ssh hostname:fizzy-lb-* sudo docker exec fizzy-load-balancer kamal-proxy stop fizzy --messageSorry! Fizzy is undergoing some maintenance and will be back shortly.访问 https://app.fizzy.do/ 验证维护页已生效恢复时执行knife ssh hostname:fizzy-lb-* sudo docker exec fizzy-load-balancer kamal-proxy resume fizzy九、Licensefizzy-saas 以 OSaasy License 发布saas/LICENSE.md与开源 Fizzy 的许可相互独立——采用本引擎部署托管服务前请先确认该许可对自身业务的适用性。小结围绕 saas/README.md 这条主线本指南把 SaaS 模式的四件事讲透了一是saas:enable/saas:disable切换机制与它对 bundle、数据库、Kamal 配置的连锁影响二是 Stripe 与原生推送在本地开发时的凭据注入模式eval $(...) 1Password三是 Hotcell 把附件处理隔离到无网络旁路容器的完整架构——从两个环境变量开关、/hotcellz两级健康检查到内容哈希 tag、双钩子自动发布/重启的部署链路四是各环境与维护模式的运维要点。对任何需要让第三方二进制处理不可信文件的 Rails 应用来说saas/hotcell/ 这套最小镜像 内容哈希 pin 自动对账的 cell 方案都是一份值得直接借鉴的参考实现。【免费下载链接】fizzyKanban as it should be. Not as it has been.项目地址: https://gitcode.com/GitHub_Trending/fizzy2/fizzy创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
