“pm2启动hono服务后,访问api没有响应”这句话我在Node.js部署相关的社群里已经看到过太多次了。Hono这个框架这两年确实火,轻量、快、TS友好,一套代码能跑Node也能跑边缘运行时,但正因为跨运行时,很多人第一次从本地开发切到pm2生产部署时就懵了:pm2 list里明明显示online,curl却一直卡住直到超时,浏览器里接口转圈,后台日志又什么都没有,最后只能重启服务器或者干脆放弃pm2转手用screen。
这篇文章不扯概念,直接按我线下排障的顺序走一遍,从进程状态确认到监听地址检查,从pm2配置细节到Hono代码里的隐蔽坑,最后给一张速查表。正在用Hono写API、准备用pm2部署到服务器的朋友,建议收藏着看,遇到同款问题能少走好几小时弯路。
1. 先判断“没响应”到底发生在哪一层
1.1 三种典型的“没响应”现象
我处理过大量的同类问题,发现“访问API没有响应”这个描述其实掩盖了三种完全不同的情况,排障方向截然不同,一开始分错类会浪费大量时间。
第一种是pm2列表里显示online,进程活着,但用curl访问本机端口直接卡住不返回,直到超时。这种情况最迷惑人,因为进程状态看起来一切正常,实际上服务可能根本没在监听,或者事件循环被某段代码堵死了。
第二种是pm2显示errored或者restarts次数不停增长,进程反复崩溃重启。这种情况相对好排查,因为至少有错误日志可看,大概率是启动入口有问题、模块加载失败、或者端口被占用。
第三种是本地命令行curl能通,但从外部浏览器或客户端访问不通。这种情况pm2和Hono基本没责任,问题多半出在防火墙、云安全组、监听地址绑定在回环上,或者反向代理没配好。
你拿到问题先问自己一句:到底是哪一种?别上来就改代码。我见过有人在Hono业务代码里翻了一晚上,最后发现是云服务器安全组没放行端口,属于典型的定位方向错误。
1.2 第一步:pm2进程状态和重启次数
排障第一步,我习惯先跑三组命令,把pm2的真实情况摸清楚:
pm2 list pm2 logs hono-api --lines 50 pm2 describe hono-api很多人只看pm2 list的第一列状态,看到online就放心了,但online只能说明进程被fork出来并且没有退出,绝不能说明服务已经正常进入监听状态。真正有价值的是describe输出里的restarts字段,这个数字会在进程每次意外退出时加一。如果restarts一直在涨,说明进程反复崩溃,这根本不是“没响应”,是“起不来”,日志里一定有原因。
还有一个容易忽略的点:pm2 list会显示uptime,如果这个时间很短,就说明进程刚重启过。配合logs看具体报错,绝大多数启动失败都能当场定位。
再补充一个细节:pm2启动后端口有没有起来,从pm2本身是看不出来的。pm2只负责进程生命周期管理,它不检测你的应用是否监听了端口,也不检测HTTP服务是否健康。这一点很多人没想明白,总以为pm2显示online服务就一定在正常工作,这是最典型的认知误区。
1.3 第二步:从外部链路逐层探测
当pm2进程状态看起来正常时,我一般会从链路最远端往回逐层探测,而不是一上来就翻代码。先在外面(或换个终端)执行:
curl -v http://127.0.0.1:3000/ping curl -v http://<服务器公网IP>:3000/ping如果本机通、公网IP不通,那问题就在网络层,跟pm2、Hono都没有关系。这时候要查的是云控制台的安全组入站规则、服务器防火墙放行状态。如果本机都不通,那问题才回到进程和服务本身,再继续往下查监听端口和日志。
这层判断是整个排障过程里成本最低、收益最高的一步。我自己踩过的最大一个坑就在这里:某次部署后本地怎么测都通,但外部客户端始终连不上,排查了pm2配置、Hono中间件、Nginx转发,最后才发现是安全组只放行了80端口,而我把服务跑在了3000端口上。
2. 问题十有八九出在pm2配置上
2.1 ecosystem.config.js的正确打开方式
很多人喜欢pm2 start src/index.js --name hono-api这种一行命令启动方式,但生产环境真的别这么干。我强烈建议从第一天就用ecosystem.config.js,把配置固化下来,可维护性天差地别。
下面是经历过线上验证的模板,逐项解释:
module.exports = { apps: [ { name: 'hono-api', script: './dist/index.js', cwd: '/var/www/hono-api', instances: 1, exec_mode: 'fork', autorestart: true, max_memory_restart: '512M', env: { NODE_ENV: 'production', PORT: 3000, HOST: '0.0.0.0' }, out_file: '/var/log/pm2/hono-out.log', error_file: '/var/log/pm2/hono-error.log', merge_logs: true, kill_timeout: 5000 } ] }有几个点必须单独拎出来说。
script指向的是./dist/index.js而不是./src/index.ts,这是刻意为之。用pm2直接跑TS源文件需要额外配置interpreter(比如tsx),但这会引入一系列兼容性问题:日志堆栈错位、进程异常退出时错误信息不完整、pm2对进程状态的判断也可能失真。所以正确做法是先把TS编译成JS,pm2只负责跑构建产物,职责单一不容易出事。
cwd字段特别容易被忽略。它的含义是进程的工作目录,默认是执行pm2命令时所在的目录。如果你的代码用process.cwd()去拼接路径读取.env文件,而cwd没配置对,就会出现一种非常隐蔽的问题:进程起得来,但环境变量加载失败,数据库连不上,接口一直在报错或者pending。
exec_mode设为fork、instances设为1,这是我处理此类问题时的默认起点。原因往下看,cluster模式在部署Hono时经常是祸根。
2.2 别急着用cluster模式
pm2的cluster模式看起来很美:一行配置就能多进程负载均衡。但在Hono场景下,它是我最先怀疑的变量之一。
cluster模式的核心是多个worker进程共享同一个端口,靠SO_REUSEPORT机制实现。这在多数情况没问题,但一旦你的Hono实例内部存在单例资源,比如内存缓存、WebSocket连接池、定时器、或者某个不想被多进程共享的第三方客户端,每个worker各持一份,行为就开始变得诡异。更麻烦的是某些服务器环境下SO_REUSEPORT的兼容性问题,会让新启动的worker明明没报错却bind不上端口,表现出来就是“服务在跑但请求全挂”。
我的建议非常明确:起步阶段一律fork模式、单实例。先确保服务稳定跑起来,然后再根据真实压力去评估要不要上cluster。多实例解决的是并发能力和CPU多核利用,不是部署完备性。你连单实例的稳定性都没验证就上多实例,出问题时连日志都要从三四个进程里翻,排查难度直接翻倍。
如果前期非要试cluster,至少把这几项配置检查一遍:instance数量建议和CPU核数一致、代码里不要有依赖进程内状态的逻辑、启动后必须逐个worker测试请求是否正常。
2.3 环境变量和.env的隐性坑
pm2启动环境和本地开发环境在环境变量上可能完全是两个世界。本地跑的是shell里export的变量,或者启动时.env被工具链加载;但pm2启动的进程默认不会加载你项目根目录下的.env文件,除非你用了pm2 start时带--env production并从ecosystem配置里读env,或者代码里显式调用dotenv去加载。
如果你写的是:
const port = Number(process.env.PORT) || 3000本地跑没问题,因为.env里有PORT=3000。但pm2启动的进程如果没加载.env,又没有在ecosystem.config.js的env字段里设置PORT,就会退回默认值。端口实际监听3000,你做其他配置时以为是3001,那自然怎么访问都不对。
解决办法是在ecosystem.config.js的env字段里显式声明关键变量,或者保证代码入口第一行加载dotenv:
import 'dotenv/config'但这里注意:dotenv默认加载的是process.cwd()下的.env,如果你pm2的cwd配置错误,即便代码里写了dotenv.config()也可能读到错文件。所以cwd和env要一起看,不能只修一个。
2.4 启动之后千万别忘了save
有一个很经验主义的细节:pm2不是配置一次永远生效的。你在服务器上执行pm2 start之后,如果后来重启过机器,pm2的进程列表并不会自动恢复。
生产环境标准的部署流程应该是:
pm2 start ecosystem.config.js pm2 save pm2 startuppm2 startup会在系统里注册一个开机启动脚本,把pm2本身拉起;pm2 save则把当前进程列表固化下来,开机后自动恢复。少了任何一步,服务器一重启服务就丢,这时候你ssh进去看到pm2 list是空的,接口自然全挂,但这个锅不应该算在Hono头上。
3. hono服务本身容易踩的监听与代码细节
3.1 监听地址决定别人能不能连上
这一节可能是整篇文章里含金量最高的一节。Hono本身是一个框架,真正让它跑在Node环境里的通常是用@hono/node-server这个包。很多人在开发环境写完代码直接npm run dev一切正常,因为开发脚本里监听地址可能根本没有显式设置,或者设置了localhost。
问题就在这:localhost在大多数Node环境下会解析成127.0.0.1,也就是只看回环接口。服务起来后只有本机能访问,外部请求进不来。我见过有人把Hono服务部署到服务器,pm2显示online,curl本机也通,但外部就是访问不了,最后发现监听地址写的127.0.0.1。
正确的做法是显式绑定所有可用接口:
import { serve } from '@hono/node-server' import { Hono } from 'hono' const app = new Hono() app.get('/ping', (c) => c.json({ status: 'alive' })) serve({ fetch: app.fetch, port: Number(process.env.PORT || 3000), hostname: process.env.HOST || '0.0.0.0' })用process.env.HOST兜底到0.0.0.0是个好习惯,这样在pm2的ecosystem配置里可以通过env字段控制监听地址,本地开发和线上部署用同一份代码,不需要改代码切分支。
还有一个验证技巧:服务启动后执行ss -tlnp | grep node,看监听地址到底是0.0.0.0:3000还是127.0.0.1:3000,一眼就能判断外部能不能访问。
3.2 事件循环被阻塞导致的假死
有一种非常隐蔽的无响应:进程完全正常,端口有监听,请求能到,但就是不返回。这时候你要往代码执行层面想——事件循环被阻塞了。
Node.js是单线程事件循环模型。如果你的某个Hono路由处理函数里有一段耗时的同步操作,比如大文件读取、复杂的正则匹配、或者某个循环里跑了同步加密,这个请求执行期间,整个进程的CPU会被占满,后续所有请求全部排队等待,表现为“接口没响应”。
这种情况pm2帮不上任何忙,它不是监控工具,也不会自动重启卡死的进程。你只会看到pm2 list一切正常,但请求全挂。
排查方法:执行pm2 monit看CPU占用,或者直接用top -p <pid>看进程CPU。如果CPU长期逼近100%而请求无响应,基本就是事件循环被同步任务堵死。
解决思路很简单但需要重构:把重计算交给异步方式,比如改用流式处理、worker_threads、或者拆成异步任务队列。在生产环境,同步阻塞事件循环是不可接受的。
3.3 中间件、CORS和路由注册导致的假无响应
第三类隐蔽问题出在Hono应用本身,典型症状是curl直连通,但浏览器里API调用失败或无响应。
浏览器跨域请求和普通curl最大的区别是:跨域时会先发一个OPTIONS预检请求。如果你的Hono应用没有配置CORS中间件,OPTIONS请求会被框架拦截或者直接挂起,浏览器拿不到预期响应就会判定请求失败。
Hono官方提供了cors中间件,几行代码就能解决:
import { cors } from 'hono/cors' app.use('/api/*', cors({ origin: '*', allowHeaders: ['Content-Type', 'Authorization'], allowMethods: ['POST', 'GET', 'OPTIONS', 'PUT', 'DELETE'] }))另外还要注意中间件的注册顺序。Hono里中间件按注册顺序执行,如果app.use()写在了某条具体路由的app.get()之后,这个中间件对该路由是不生效的。这不会导致请求无响应,但会导致你的认证中间件、日志中间件逻辑不执行,后续排查时很容易误判。
我建议在Hono应用里至少加一个全局错误处理中间件,避免未捕获异常导致请求挂起:
app.onError((err, c) => { console.error(`${new Date().toISOString()} ${err.message}`) return c.json({ error: 'internal_error' }, 500) })这也是很多人忽略的一点:Hono默认的错误处理虽然能返回500,但如果你在serve环节自己封装了fetch函数并且吞掉了异常,请求就会pending住。错误处理中间件是确保“必有响应”的最后一道保险。
4. 日志、命令与三板斧排障法
4.1 日志你其实没看全
pm2默认会捕获进程的stdout和stderr,并分别写到日志里。很多人出了问题时只会pm2 logs看几行,然后没看到东西就说“没日志”,其实是用错了命令。
建议这样操作:
pm2 flush # 先清空旧日志 pm2 restart hono-api # 干净地重启一次 pm2 logs hono-api --lines 200 --raw先清空日志再复现问题,这样日志文件里记录的就是你这次操作产生的内容,不会被几百行旧日志淹没。--raw参数会输出原始日志格式,不再带pm2的时间戳和进程前缀,方便直接复制去搜索报错。
如果你在ecosystem.config.js里配置了out_file和error_file,也可以直接:
tail -f /var/log/pm2/hono-error.log标准输出和错误输出分流后,查找异常堆栈的效率会高很多。
4.2 一条命令确认监听地址
定位“没响应”问题时,ss命令比pm2自带的任何输出都有用:
ss -tlnp | grep node输出里如果看到127.0.0.1:3000,外部访问绝对不通;看到0.0.0.0:3000或者[::]:3000才是绑定所有接口。
顺带说一句:0.0.0.0和*在输出里有时候显示为:::3000,这是IPv6的通配形式,也代表所有接口,是正常的,别误判。
如果ss输出里完全没有node相关监听,说明进程根本没有成功监听端口,这时候就该去看启动日志,问题通常在入口脚本、模块加载或者端口占用。
4.3 排障三板斧:curl、ss、logs
我把这套流程总结成三板斧,每个新项目部署完都按这个顺序过一遍:
第一板斧,curl本机健康检查接口:
curl -v http://127.0.0.1:3000/ping确认服务本身存活,这一步超时就先看日志看监听,别往后走。
第二板斧,ss确认监听地址是否对外:
ss -tlnp | grep node只看0.0.0.0:端口或者:::端口,看到127.0.0.1直接去改hostname。
第三板斧,从外部链路自测:
curl -v http://<公网IP>:3000/ping如果本机通、公网不通,去看防火墙和安全组。
这套三板斧最多十分钟能跑完,能过滤掉80%的“没响应”问题。剩下那20%,再去结合日志、CORS、中间件这些细节排查。
5. 常见问题速查表与避坑清单
5.1 五分钟定位的问题速查表
| 现象 | 大概率原因 | 快速验证方法 | 解决方案 |
|---|---|---|---|
| pm2 online但本机curl超时 | 服务没监听成功/事件循环阻塞 | ss -tlnp查看端口 | 配合日志定位启动异常 |
| 本机通但外部不通 | 监听127.0.0.1或安全组未放行 | ss -tlnp看监听地址 | hostname改0.0.0.0 |
| pm2状态errored或restarts增长 | 入口报错/依赖缺失 | pm2 logs --lines 200 | 本地先跑通再部署 |
| curl通但浏览器不通 | CORS未配置 | 浏览器网络面板看OPTIONS | 加cors中间件 |
| 报错ERR_CONNECTION_REFUSED | 端口未监听或防火墙拦截 | ss -tlnp + 防火墙状态 | 放行端口/修正监听 |
| SyntaxError/ERR_MODULE_NOT_FOUND | ESM模块配置不一致 | 看日志堆栈 | 检查package.json的type字段 |
| 请求长时间pending不返回 | 代码里有同步阻塞/异常被吞 | pm2 monit看CPU | 重构为异步+加onError |
| 重启服务器后服务消失 | pm2未save和startup | pm2 list为空 | pm2 save + pm2 startup |
5.2 避坑清单
- 生产环境别用
pm2 start裸启动,用ecosystem.config.js把配置固化。 - script指向编译后的dist文件,别依赖pm2去解析TS源码。
- 监听地址必须显式设置为
0.0.0.0,不要写localhost或127.0.0.1。 - fork模式单实例起步,cluster模式是优化步骤不是部署必需品。
- 部署完必须pm2 save,否则重启服务器等于服务全没。
- .env文件不会自动加载,用ecosystem的env字段或代码里显式dotenv。
- 日志文件路径的目录必须提前创建好,否则pm2可能报错。
- 遇到诡异问题先检查防火墙和安全组,技术栈经常是背锅的。
这套排障流程我用了很久,从简单的“端口没监听”到极其隐蔽的“事件循环阻塞”,基本都能覆盖。写下来才发现,绝大多数“pm2启动hono后没响应”的问题,根因都不在pm2或者Hono本身,而是部署细节没有对齐。把监听地址、环境变量、安全组这几件基础功课做扎实,这类问题几乎可以绝迹。