1. 从 Heroku 铁粉到账单焦虑,我经历了什么
如果不是那次季度对账,我大概到现在还在为 Heroku 付费。推动我换平台的不是技术瓶颈,而是一封财务提醒邮件。当时我们的 SaaS 后台工具刚跑了一年多,用户量谈不上爆炸,但已经在稳定增长。我顺手把 Heroku 的月度账单导出来,看到总额的那一刻,说实话有点愣住了:一个日活并不算高的业务系统,每个月居然要付 200 多美元给“部署平台”。
先交代一下背景:我从个人项目阶段就开始用 Heroku,前后在上面跑过 API 服务、定时爬虫、公司内部工具,一度对它非常依赖。git push 就完成部署的模式,让人再也不想回到配 Nginx、写 systemd 服务、手动续 HTTPS 证书的那种生活。就算是今天回头看,我仍然认为 Heroku 是“开发者体验”做得最早也做得最极致的一批平台之一。问题在于,当你的项目从“能跑”变成“要稳定地跑”,账单会突然变得不可爱。
这篇文章是我完整迁移过程的复盘:我会先讲清楚 Heroku 为什么值得喜欢,再拆解它的成本到底高在哪儿;然后说明为什么我选了一个开源云原生开发平台作为替代,以及迁移过程中做对了什么、踩了什么坑。如果你也是中小型团队的技术负责人,或者正犹豫自己的项目要不要从 Heroku 搬走,这篇文章可以直接当参考。
最终效果是:同一个系统,月成本从原来的约 220 美元降到了约 43 美元,算下来省了 80% 左右。而且部署速度、回滚能力、日志可观测性都没有缩水,某些地方甚至比以前更好用。
1.1 git push 即部署的开发体验,确实是当年的天花板
Heroku 最大的贡献,是把“部署”这件事简化到了几乎不需要思考。本地写好代码,提交后执行 git push heroku main,剩下的构建、启动、健康检查、日志收集全部由平台接管。你不需要知道后端跑在哪个进程里,不需要关心负载均衡怎么配,也不需要在半夜爬起来处理证书过期。
这种体验依赖的是它那套 Buildpack 机制。Heroku 会识别项目语言,自动拉取对应运行时,安装依赖,然后启动你在 Procfile 里声明的进程。比如一个 Node.js 项目,你只需要在 Procfile 里写:
code复制web: node src/index.js
worker: node src/worker.js
平台会自动为 web 进程分配公网入口,为 worker 进程提供后台任务执行环境。数据库、Redis、定时任务这类需求,也不用自己搭建,直接在 Add-ons 市场里点几下就能创建。一个研发团队不需要专职运维,也能把产品稳定对外提供服务。
这套体验在当年是革命性的。很多人说用过 Heroku 就回不去了,不是情怀,而是它真的把“上线”的门槛降到了极致。在我们团队只有两三个人的阶段,这套模式帮我们省掉了大量基础设施时间,让我们能把精力全部放在业务代码上。这是它值得被认可的地方,也是为什么后来做迁移决策时,我一直在想:能不能找到一套方案,既保留接近 Heroku 的开发体验,又不需要持续支付高昂的托管费用。
1.2 转折点:应用规模一上来,账单开始不受控制
转折发生在我们把第二个环境(预发布环境)加入 Heroku 之后。第一个环境只需要一个 web 实例加一个数据库,成本还在可接受范围;但为了测试和生产隔离,你需要两套 Postgres、两套 Redis,可能还要给预发布环境单独开一个 web dyno,否则测试流量会干扰生产实例。
也就是从这个时候开始,我注意到 Heroku 的计费逻辑有一个非常“吃”规模的特点:它不是按项目整体收费,而是按“应用实例 + 附加服务”逐项收费。Web 进程按数量、类型单独计费,Worker 单独计费,数据库按容量和连接数分档,Redis 又分档,日志、监控、证书、备份也各有各的费用。你以为自己在用“一个平台”,其实是在为十几个独立组件分别付费。
另一个容易被忽略的问题是 dyno 的规格和实例数量。Heroku 的单实例资源并不宽裕,一旦应用出现性能瓶颈,你通常不是去优化代码,而是直接加一个 dyno。加一个容易,加两个也容易,账单翻倍同样容易。到迁移前,我们一个并不复杂的 Rails 风格 Web 应用加后台任务系统,月账单已经超过了团队里任何一个人的云主机预算。这时候我终于开始认真思考:我们到底是离不开 Heroku,还是离不开它背后的部署能力?
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Heroku 账单里的三个“黑洞”,不是单看 Dyno 价格就能发现的
很多人对 Heroku 成本的理解,停留在“一个 web dyno 大概多少钱”的层面。实际上,等你真刀真枪跑起一个业务系统,会发现 Dyno 本身只是账单的一部分。下面我按当时的账单记录,拆解一个典型应用在 Heroku 上的月成本构成。
2.1 算一笔账:一个典型 Web 应用每月在 Heroku 花多少钱
以我们当时运行的 Web 工具为例:一个 Web 应用对外服务,一个后台 Worker 处理异步任务,数据库使用 PostgreSQL,缓存和队列使用 Redis,另外附加了日志、监控和备份服务。账单大致长这样:
| 项目 | 规格说明 | 月成本估算 |
|---|---|---|
| Web dyno × 2 | 标准 1X,保证并发处理能力 | 约 50 美元 |
| Worker dyno × 1 | 标准 1X,跑 Sidekiq 类任务 | 约 25 美元 |
| Heroku Postgres | Standard 0 档,带连接数保障 | 约 50 美元 |
| Heroku Redis | Premium 0 档 | 约 15 美元 |
| 日志与监控插件 | 长期指标保留 | 约 15 美元 |
| 数据流量超额 | 按实际用量浮动 | 约 15 美元 |
| SSL、备份等杂项 | 不同套餐内含 | 约 10 美元 |
这还只是一个环境。如果你有生产环境和预发布环境,数据库和附加服务基本要乘以 2,因为不同环境之间需要数据隔离。当时我们为了控制成本,预发布环境只保留了数据库,没有开额外的 web dyno,但已经让整体账单站上了每月 200 美元左右。
请注意,这个账单对应的业务规模并不大:日活用户几百人,数据库体积不到 10GB,异步任务每天也就几千条。说白了,大部分钱不是花在“资源消耗”上,而是花在“Heroku 为每个组件提供的托管便利”上。
2.2 比 Dyno 费用更隐蔽的成本项
除了看得见的组件费用,还有几个隐性成本值得拿出来单独说,因为它们往往要踩过坑才能真正意识到。
第一是连接数和并发限制。Heroku 的数据库分档,核心差异往往不是存储空间,而是连接数上限。应用一跑起来,Web 进程、Worker 进程、定时任务都要占用数据库连接。连接数不够时,你不得不升级数据库套餐,而升级的代价是月费直接跳档。这个问题和实际数据量无关,纯粹是连接管理上的资源占用,但账单不会因为你解释“我只是连接多,数据量不大”就网开一面。
第二是 dyno 的闲置模型。Heroku 的免费档会让应用在无流量时休眠,很多个人项目为了保持“常驻”,不得不依赖外部监控服务定时 ping 应用,防止休眠。这类“叫醒服务”本身通常不贵,但它是账单之外的一笔持续开销,而且本质上是在给平台的限制交学费。对于需要持续处理后台任务的应用,这种限制非常难受:你无法预知任务队列什么时候有消息进来,只能让进程一直醒着。
第三是附加服务的“叠加税”。日志系统、监控面板、错误追踪,在 Heroku 生态里都有很成熟的插件,但几乎每个插件都有独立的订阅费用。单个看起来不贵,四五个加起来就是一笔可观的固定成本。最让我不舒服的是,这些能力在开源生态里很多都是免费的,只是需要自己搭建和维护。当账单里出现越来越多的“管理费”时,你被迫重新思考:我到底在为什么付费?
3. 为什么我没选裸 K8s,而是选了轻量级开源云原生平台
决定离开 Heroku 之后,我并没有马上跳到容器化自建的路线。老实说,当时摆在面前的选择有好几个:裸机加 Docker Compose、自建 Kubernetes 集群,还有各类开源 PaaS 类平台。我花了差不多两周时间对比和试验,最后才确定了方向。
3.1 裸容器、K8s、开源 PaaS:三者的边界在哪
很多从 Heroku 出走的人,第一反应是“那我买一台云主机,用 Docker Compose 把服务跑起来不就行了”。这个思路对小项目完全成立,但一旦你要管理五六个关联服务,包括 Web、Worker、Postgres、Redis、定时任务、反向代理,问题就会暴露出来:Docker Compose 只解决了“定义和启动容器”的问题,并没有解决“容器挂了怎么办”“日志怎么看”“证书谁来更新”“多应用怎么隔离”这些运维问题。
Kubernetes 能解决上述所有问题,但代价是引入一套极其复杂的系统。为了托管一个日活几百人的应用去维护 K8s 集群,就像为了在小区门口取快递,专门买一辆集装箱卡车。控制面组件、工作节点、网络插件、存储类、Ingress Controller,任何一环出问题,排查成本都远超 Heroku 的月费。没有专职运维团队的中小公司,不建议走这条路。
轻量级开源 PaaS 类平台正好卡在中间。它们的核心思路是:用 Docker 作为运行底座,用 Nginx 或 Traefik 处理反向代理和自动 HTTPS,用 Web 面板统一管理多应用部署、环境变量、容器重启和日志。平台本身可以安装在一台普通云主机上,界面和操作逻辑都接近 Heroku 的体验,但不需要按实例数量付费。
这里要说明的是,不同开源项目的侧重点会有差异。例如 CapRover、Dokploy、Coolify 都属于这个方向,有的基于 Docker Swarm,有的基于 Docker Compose,有的原生集成 Nixpacks。我不建议无脑选择某个项目,而是要先列出自己的核心需求:
| 需求 | 说明 |
|---|---|
| 社区活跃度 | 不能选无人维护的项目,否则等于给自己埋雷 |
| 安装复杂度 | 最好能在 30 分钟内装完 |
| 自动 HTTPS | 必须支持 Let's Encrypt 自动签发续期 |
| 应用构建方式 | 同时支持 Dockerfile、Nixpacks 等 |
| 健康检查与自动重启 | 容器挂了需要能自动拉起 |
| 日志与多应用隔离 | 多个环境跑在同一台机器上不出问题 |
3.2 我的选型清单与最终落点
按上面的清单筛选后,我把目标锁定在“我能在自己云主机上安装的开源云原生开发平台”这一类方案上。选择这类平台而不是直接写一堆 Docker Compose 文件,原因很简单:我需要的是“平台能力”,而不只是“容器能力”。自动 HTTPS、一键回滚、应用级别的日志查看、健康检查、环境变量管理,这些能力如果全部自己拼装,至少要搭一个周末;而开源 PaaS 已经把它们做成了开箱即用的功能。
我当时最终落地的是一款基于 Docker 的轻量 PaaS 类开源平台。它给我的直接感受是:安装后得到一个类似 Heroku Dashboard 的 Web 界面,可以创建应用、配置端口、绑定域名、查看实时日志;同时它也提供命令行工具,适合写进 CI/CD 脚本。对我们这种已经有一个 Dockerfile 的项目来说,迁移成本极低。
选型过程中我还有一个很实际的判断标准:出了问题,社区能不能给我答案。开源项目最怕的不是功能少,而是文档缺失、社区冷淡、作者长期不更新。我会在 GitHub 上看 issues 的响应速度、最近 commit 时间、release 频率,这些比宣传文案可靠得多。最后选定的项目至今仍保持较高更新频率,这也是我敢把它用于生产环境的重要原因。
4. 迁移 14 天的完整行动路径:从 Heroku 到开源平台
很多人一听到“迁移”就头大,觉得要重写部署架构、要改代码、要处理各种兼容性问题。从我实际经验看,如果项目本身已经是一个标准的 Web 应用加后台任务结构,迁移过程远没有想象中可怕。关键是别一股脑全切,要分阶段做。下面是我们当时的操作路径,花了两周时间完成,其中大部分时间用于数据验证和灰度观察。
4.1 第一步:把 Heroku 上的资源全部列成迁移清单
动手前我先做了一次资源盘点,把所有依赖 Heroku 的东西列成一张表,包括应用类型、进程数量、附加服务、环境变量、定时任务、DNS 记录、对象存储桶等。
| 资源类型 | 具体内容 |
|---|---|
| Web 应用 | 生产环境、预发布环境各一个 |
| Worker 进程 | Sidekiq 异步任务 |
| 数据库 | PostgreSQL,含表结构和数据 |
| Redis | 缓存队列 |
| 定时任务 | Heroku Scheduler 跑的每日报表任务 |
| 环境变量 | 几十个,包括 API Key、数据库连接串 |
| DNS | 主域名和 API 子域名 |
| 外部依赖 | 对象存储、邮件服务、第三方 Webhook |
这份清单的用处是防止迁移过程中漏掉某个“看起来不起眼但其实在跑的东西”。比如我们最初差点漏掉一个每天早上生成报表的定时任务,如果没有提前记账,切换之后报表就会悄无声息地停掉,直到客户提醒才发现。建议你在做同样的事情时,第一步一定是在 Heroku Dashboard 里把所有应用、附加服务、Config Vars 截图留存,再逐项对照迁移。
4.2 第二步:应用容器化,从 Buildpack 思维切到 Dockerfile
Heroku 用 Buildpack 自动识别语言并构建,而开源 PaaS 平台通常更青睐 Dockerfile。如果你的项目还没有容器化,这一步需要补上。我们用的是一个 Node.js 服务,Dockerfile 非常简单:
dockerfile复制FROM node:20-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
EXPOSE 3000
CMD ["node", "src/index.js"]
这里有一个关键区别需要适应:Heroku 会通过 $PORT 动态告诉应用监听哪个端口,平台再做路由。自建容器时,端口则是你自己指定的。比如你在平台里把应用的“容器 HTTP 端口”配置为 3000,应用内部就要监听 3000,后续配置反向代理时会用到这个数值。EXPOSE 3000 只是声明,真正的端口映射要在平台的应用设置里确认。
另外,Heroku Procfile 里声明的 worker 进程,在开源平台上通常需要拆成一个独立的应用容器来跑。例如原来的 Procfile 包含 worker: node src/worker.js,迁移后在平台上创建一个名为 worker 的应用,启动命令设置为 node src/worker.js,不暴露公网端口,只作为后台任务容器运行。这种方式比在同一个容器里用进程管理器同时跑 web 和 worker 要干净得多,重启和扩容都互不影响。
4.3 第三步:数据库和 Redis 数据迁移
数据迁移是整个过程中我最谨慎的一步。Heroku Postgres 提供了备份导出功能,但对自建平台来说,最关键的是保证目标数据库版本和大版本一致。当时我用 pg_dump 导出为自定义格式,再恢复到新平台的 PostgreSQL 容器里,命令大致如下:
bash复制# 从 Heroku 拿到数据库连接串后导出(不取 owner 和权限信息)
pg_dump --no-owner --no-privileges "$HEROKU_DATABASE_URL" -Fc -f backup.dump
# 恢复到目标数据库,先确保目标库是空的
pg_restore --no-owner --role=your_user -d your_database backup.dump
用自定义格式而不用纯 SQL 脚本,是因为 pg_restore 会按照依赖顺序恢复表结构、数据、索引、约束,避免外键顺序导致的插入失败。我见过不少人在数据迁移时直接导出 .sql 文件再 psql < backup.sql,一旦表之间有外键关系,很容易在中途报错。
Redis 迁移简单很多。我们只把 Redis 当作缓存和队列使用,队列里的任务可以在业务低峰期清空重建,缓存本身也能容忍丢失。所以当时直接在新环境启动一个新的 Redis,让应用重新预热缓存,而不是追求把旧 Redis 的 key 全部复制过去。如果你的 Redis 中存有不可丢失的业务数据,那需要额外考虑持久化方案,不能直接照搬这个做法。
4.4 第四步:Worker、定时任务、DNS 和证书切换
Worker 的切换方式在 4.2 里已经提过,单独创建一个后台任务容器即可。定时任务则需要一个额外的容器来运行 cron。Heroku Scheduler 是一个托管 cron,在自建环境中,最省事的做法是在平台上开一个始终运行的“调度器”容器,然后在容器里装 cron,或者直接用平台的定时任务接口。
我们的定时任务比较简单:每天早上 8 点生成报表并发送邮件。我在后台任务容器里挂了一个简单的 cron 配置:
cron复制0 8 * * * node /app/scripts/daily-report.js >> /var/log/cron.log 2>&1
DNS 切换是迁移中最“敏感”的一步,因为它直接关系到用户流量的切换时机。流程是:先在开源平台上配置好应用的自定义域名,并申请 Let's Encrypt HTTPS 证书,确认证书签发成功后,再修改 DNS A 记录指向新云主机 IP。这里建议先不要删除 Heroku 的 DNS 记录,而是把 TTL 调低,这样万一需要回退,可以在几分钟内完成切换。
HTTPS 证书有一个容易踩的坑:在 DNS 切换之前,平台无法为你的域名自动签发证书,因为域名验证请求还到不了新服务器。所以顺序必须是先临时添加一条指向新服务器的 DNS 记录,或者先改 A 记录并等待生效,再让平台完成证书申请。如果顺序反了,你会看到证书一直处于 pending 状态。
4.5 第五步:灰度运行两周,保留回退能力
域名切换后,我没有立刻关停 Heroku 资源,而是让新旧环境并行跑了两周。Heroku 那边只保留最低配置的 web 实例和数据库,用于快速回退。
具体的验证策略是:先观察新环境日志,确认请求量、错误率、响应时间正常;再对比数据库中的业务数据,确认没有丢失;最后逐一验证关键链路,比如用户登录、文件上传、后台任务执行、每日报表发送。两周内如果发现问题,同时旧环境还活着,随时可以切回 DNS。
两周后一切稳定,我才把 Heroku 上的 dyno 缩到最小,再过一周确认无回退需求,才彻底删掉附加服务和数据库实例。这个节奏看起来保守,但对业务系统来说,“平稳迁移”比“快速迁移”重要得多。唯一需要付出的代价是那两周多付了 Heroku 的一部分费用,但比起数据丢失或者长时间不可用,这是非常划算的保险。
5. 钱到底省在哪了:成本对比与适用边界
迁完后,最直观的成果当然是那 80% 的成本削减。但我要先说清楚:这个比例不是平台变魔术变出来的,而是运行方式改变后的必然结果。开源平台本身通常不收费,你省下的主要是托管 PaaS 的“平台附加费”;与此同时,新增的成本是云主机租金、备份策略和一部分自己的运维时间。
5.1 迁移前后的逐项对照账本
为了让你看清钱到底花在哪,我列了一张迁移前后的对照表,数字按我们当时的实际用量估算:
| 项目 | Heroku 方案 | 开源云原生平台方案 |
|---|---|---|
| Web 运行 | 2 × Standard 1X dyno,约 50 美元 | 云主机承载,合并在主机费中 |
| Worker 运行 | 1 × Standard 1X dyno,约 25 美元 | 同上 |
| PostgreSQL | Standard 0,约 50 美元 | 云主机自带容器运行 |
| Redis | Premium 0,约 15 美元 | 云主机自带容器运行 |
| 日志监控 | 插件订阅,约 15 美元 | 平台自带日志,零附加费 |
| 流量超额 | 约 15 美元 | 包含在云主机带宽内 |
| 云主机费用 | 无 | 约 30 美元 |
| 对象存储备份 | 需单独附加 | 约 8 美元 |
| 证书和 DNS | 包含在应用套餐 | Let's Encrypt 免费 |
算下来,旧方案月成本约 220 美元,新方案约 43 美元。这个减少不是靠砍功能,而是砍掉了被平台层层叠起来的“管理溢价”。同一个应用,自己跑和托管跑,计算资源本身的差价不会这么悬殊,悬殊的是“平台替你运维”这件事的定价。
5.2 哪些人适合照做,哪些场景不建议为了省钱硬迁
我不建议所有人看到 80% 就立刻放弃 Heroku。这个方案成立的前提是:团队里有人愿意承担基础的容器运维工作,应用的流量规模处于一台高性价比云主机能扛住的范围内,对高可用和容灾的要求不是“银行级”。
适合迁移的情况通常具备以下特征:项目由两三个服务组成,无状态应用可以容器化,数据库体积在几十 GB 以内,团队已经具备 Docker 基础,愿意每个月花几个小时处理平台升级和备份检查。
不适合迁移的信号也很明显:你们没有运维能力,或者业务要求极高可用,数据库必须跨可用区自动故障转移,应用实例必须分布在不同区域。如果是这种场景,Heroku 这类托管 PaaS 的价值依然存在,省下的 80% 成本可能不够弥补一次自建环境故障带来的损失。开源云原生平台能做集群化部署,但那是另一个量级的运维复杂度,不适合为了省钱硬上。
另外要提醒的是,自托管方案中数据库最容易成为短板。Heroku Postgres 自带备份、高可用和故障切换;而在云主机上跑数据库,你需要自己设置定时备份、监控磁盘空间、关注 PostgreSQL 版本升级。这些工作不算困难,但必须形成固定习惯,否则某一天磁盘满了或者误删数据,你才会意识到“托管服务”里其实包含了许多隐形的保险。
6. 迁移后真实的坑:三次事故级排查过程复盘
迁移不是说切完 DNS 就万事大吉。后面一段时间里我陆续踩了几个坑,每一个都差点让我怀疑当初的决定。把它们完整记录下来,希望你能绕开。
6.1 启动日志正常,健康检查却集体失败
第一个坑出现在部署后的第一次滚动重启。平台提示应用健康检查失败,容器不断被停止再启动,但我去看应用日志,发现 Node 服务明明已经成功启动,接口也能正常返回数据。这个矛盾让我排查了很久。
百思不得其解时,我注意到平台健康检查是直接从容器外部发 HTTP 请求,而我本地测试用的是 curl localhost:3000。问题出在 Node 服务监听的地址上:如果代码里写的是 app.listen(3000, '127.0.0.1'),那容器外部永远无法访问到服务。Heroku 时代因为平台层有特殊处理,这个问题很少暴露;但在自建容器环境里,应用必须监听 0.0.0.0,否则就相当于把自己锁在容器内。
修复方式很简单,把监听地址改成 0.0.0.0,或者在 Dockerfile 的启动命令里显式传入宿主地址。这个坑之所以难排查,是因为它表面上是“健康检查不过”,实际上是“网络监听范围错误”。我建议你在容器化部署完成后,第一件事就是用一条命令从容器外部确认端口可达,而不是只看应用日志。
6.2 数据库迁移后外键约束报错,问题不在备份文件
数据库恢复流程原本很顺利,数据也导进去了,但业务跑起来后开始频繁出现外键约束错误。我去查源数据库,数据明明没问题,于是第一反应是 pg_dump/pg_restore 产生了损坏,反复导了几遍都一样。
后来才意识到,问题出在目标数据库不是“空库”。我当时先在开源平台上手动创建了 PostgreSQL 容器,然后顺手跑了一次应用初始化脚本,脚本会在空库里自动建表。等到我再执行 pg_restore 时,目标库里已经存在同名表,恢复过程虽然跳过了很多对象,但外键关系、序列、索引和既有表产生了冲突。
正确处理方式是把目标库彻底清空后再恢复,恢复完成后要先跑一段只读查询验证核心表行数,再启动应用初始化逻辑。如果应用和数据库在同一台主机上,还要注意不要把应用自带的建表脚本和数据库恢复流程搞乱顺序。数据恢复和业务初始化,二者只能选一个作为权威来源。
6.3 “上传文件消失”事故:容器可写层不是持久化磁盘
迁移后大约第三天,有用户反馈前一天上传的图片第二天就打不开了。我去服务器上看,文件确实存在,但只有一部分还在——准确地说,是平台在凌晨执行过一次自动更新后,某些文件就消失了。
这个现象的根因,是容器文件系统的生命周期和容器本身绑在一起。容器更新、重建、迁移时,写入容器可写层的数据会随旧容器一起被清理。Heroku 的文件系统本来就是临时性的,但它通常配合 S3 等外部存储使用,所以这个限制不太容易被感知;到了自建平台,如果沿用“把文件直接保存在本地目录”的思路,隐患就会立刻暴露。
最终解决方案是把用户上传文件全部迁移到对象存储,并在应用代码里改造成“直传或服务端中转”模式。如果你不想引入外部对象存储,至少要在平台里给应用配置持久化存储卷,并把文件写入路径指向持久化目录。注意,如果是多个应用实例同时运行,本地卷方案还需要考虑文件一致性问题,外部对象存储依然是最省心的选择。
6.4 内存超卖导致容器被杀,监控指标不会说谎
最后一个坑出在性能调优阶段。某天早上应用突然无响应,平台显示容器状态为“已退出”,退出原因疑似内存超限。我一开始怀疑是 Node.js 应用出现了内存泄漏,但反复压测后本地并没有复现。
后来我去看宿主机层面的内核日志,才发现平台给容器设置的默认内存限额,比 Node 进程实际需要的内存峰值要低。当宿主机整体内存资源紧张时,内核会优先杀掉超出限额的容器。这个现象在 Heroku 上很少遇到,因为平台会为 dyno 提供比较明确的资源边界,而自建容器里,你可以把限额调大,但代价是同一台机器上其他容器可能受影响。
我当时的处理方式是给关键容器设置明确的内存上限,并给 Node 进程配置 NODE_OPTIONS=--max-old-space-size=512 之类的参数,限制 V8 堆内存增长。同时,在平台里配置内存告警,当容器内存使用率超过 80% 时立即通知。经过一周观察,容器运行稳定多了,没有再出现神秘消失的情况。这个过程中最有价值的经验是:排查容器被杀,不能只盯着应用日志,主机内核日志和资源监控曲线往往才是真正的线索。
如果你也准备走类似的自托管路线,我的最后一条建议是:不要把成本压力当成迁移的唯一理由。真正支撑你走完迁移过程的,是对自己应用的了解——每一个进程、每一条任务队列、每一处文件存储,都值得在切换前想清楚。迁移的痛是一次性的,账单的痛是持续的,算清楚这笔账后,做决定就不会犹豫了。
