
如果你在一个Python项目里同时看到OperationError: code3的红色输出和requirements.txt里躺着websockets这一行那大概率已经踩到了Google Cloud Functions和长连接之间最深的那个坑。我最初是在一个实时聊天服务里遇到这个问题的本地用Python websockets库跑得行云流水一执行gcloud functions deploy就开始亮红灯。这篇文章不是从官方文档里转述概念而是把我在生产环境里排查这类部署失败的完整过程、日志解读和最终的方案取舍整理出来。遇上同样问题的朋友按这篇文章的排查链路走一遍大概率能在一小时左右定位到自己的具体错误。1. 先把部署失败分个类红字背后是完全不同的锅很多人看到“deployment failure”就把整段日志丢进搜索引擎这样做效率极低。因为Cloud Functions部署失败在日志上都是红色状态但实际失败点分三类每一类的处理方向完全不同。1.1 我见过的高频失败现场第一类是构建失败。state: FAILED错误信息里通常有Build failed紧接着能看到的是一段pip安装输出。这类问题本质上是依赖没有装成功要么是websockets包找不到对应的Python版本要么是某个依赖包在云端的构建环境里编译失败。第二类是健康检查失败。函数代码都装好了容器也起来了但Cloud Functions的探活请求打不进来入口没在预期端口上响应最终返回Container health check failed。这类问题在加了WebSocket逻辑之后特别容易出现因为你会忍不住把服务启动在自定义端口或者写了常驻事件循环。第三类最坑部署状态是绿的但外部请求一到就超时。日志显示socket hang up、Function execution took too long或者客户端WebSocket握手根本没有返回101响应码。这种“部署成功但跑不通”的情况往往不是配置问题而是架构本身不适配。1.2 一张表快速区分失败类型失败类型日志里大概率出现的内容最可能的原因构建失败Build failed,ERROR: Could not find a version,Failed building wheelrequirements.txt声明问题、Python版本不兼容、依赖包需要编译健康检查失败Container health check failed,Received unexpected status入口函数返回异常、监听端口不是8080、启动时抛错部署成功但连接失败无部署错误但请求超时、101握手失败、连接立刻被断开Cloud Functions请求模型不适合WebSocket长连接或并发参数没调好1.3 为什么分类是排查的第一步因为三类问题的“下一步动作”截然不同。构建失败你需要回到requirements.txt和构建日志健康检查失败你需要检查入口代码和端口连接失败你需要跳出“改配置”的思路重新评估部署目标是否选错了。我自己第一次踩坑就是没分类看到Build failed就去改入口代码改了一下午方向完全反了。后来发现构建日志里写的其实是websockets需要编译本地又能跑原因在于本地Python版本和云端运行时版本不一致。这个小教训值得先记着排查永远从错误发生的阶段开始而不是从你怀疑的组件开始。2. 从根上讲明白Cloud Functions的请求模型为什么和WebSocket冲突很多人不理解一个关键事实部署失败只是表象更深层的问题是Cloud Functions压根不是为长连接设计的。如果你不理解这个模型就算这次侥幸部署成功之后也会在运行时被它坑得毫无头绪。2.1 函数被设计成“活一瞬”实例冻结与回收Cloud Functions第一代的模型是纯粹的事件/请求驱动。一个HTTP请求进来平台分配一个函数实例执行完你的代码返回响应然后这个实例进入空闲状态。空闲实例可能在一段时间后冻结冻结比冷启动还要彻底内存里的全局连接、后台进程全都没了。WebSocket是什么它是一个需要长期保持的TCP连接客户端和服务端之间要持续收发帧。这和“请求进来-响应出去-实例回收”的模型天然冲突。如果你试图在一个函数内部起一个WebSocket服务最直接的问题是函数返回了服务还在跑但平台对外的代理已经切断了调用关系外部流量根本进不到你那个内部服务上。2.2 第一代Cloud Functions的HTTP触发器不想做长连接第一代Cloud Functions的HTTP触发器由前端的标准HTTP代理处理它按照普通HTTP请求的语义来工作。客户端发一个HTTP Upgrade请求过来按道理应该返回101 Upgrade协议切换从而建立WebSocket连接。但第一代函数的运行时只是把事件交给你的WSGI/ASGI应用处理整个请求处理完之后代理对连接的预期是“结束”。所以哪怕你的Python代码里用了websockets.serve准备了一个完整的WebSocket服务器这个服务器监听的可能只是函数实例内部的一个端口而不是Cloud Functions对外暴露的地址。外部客户端访问的是https://region-project.cloudfunctions.net/这个地址背后的代理不会把流量转到你那个内部socket端口上。2.3 看似能跑但实际有害的写法在入口里启动事件循环有一种写法我见到过很多次也是导致健康检查失败的高频原因在入口函数里这样写import asyncio import websockets def main(request): async def echo(websocket, path): async for message in websocket: await websocket.send(message) start_server websockets.serve(echo, 0.0.0.0, 8080) asyncio.get_event_loop().run_until_complete(start_server) asyncio.get_event_loop().run_forever()这段代码在任何普通环境里都能跑起来但在Cloud Functions里几乎必挂。原因很直接run_forever()会阻断函数入口函数永远不会返回HTTP响应平台健康检查得到的是一个无响应的进程最终判定部署失败。即使某些情况下探活请求被你的函数“暂时忽略”了、部署勉强通过后续每一个真实请求也会一直挂在run_forever上直到超时被杀掉。2.4 第二代Cloud Functions出来了但并发模型仍然会咬你一口Google后来推出的第二代Cloud Functions是基于Cloud Run实现的底层运行环境从“纯事件函数”变成了“容器实例”。Cloud Run本身是支持WebSocket的这一点和第二代Functions一样可以接收HTTP Upgrade请求并完成协议切换。但有个细节Cloud Run实例默认并发是80及以上。WebSocket连接是长期存活的长连接会一直占着一个并发槽。如果实例并发是80一个实例上挂80个WebSocket可能还好但更麻烦的是Cloud Run在处理WebSocket升级请求时可能会把多个长连接分配给同一个进程实例而你的代码如果用了不支持并发WebSocket的依赖就会出现连接异常、消息串线之类的问题。正确做法是把并发参数调成1让每个实例专门处理少数几个WebSocket连接。如果不调这个参数你会遇到的现象就是“部署成功连接也能建但时不时断、日志里全是超时”。很多人绕了一圈发现根本不是代码问题只是没理解并发模型。3. 完整排查链路从依赖声明到部署参数逐项排雷这一节我说的是自己每次遇到这类问题时的固定操作步骤顺序很重要。我建议你别跳步尤其不要一上来就自己改代码那是最后一步才做的事。3.1 第一步永远是看完整的错误输出而不是最长的那段日志运行部署后第一件事是看gcloud functions deploy打印的完整状态。注意不是只看error字段要看整个输出里有没有Build ID或operation ID。如果存在说明构建阶段已经起了一个Cloud Build任务下一步就要去翻构建日志。常用的命令是gcloud functions deploy your-function-name \ --runtime python311 \ --trigger-http \ --allow-unauthenticated \ --entry-point main \ --source .一旦出现state: FAILED先去查构建日志gcloud builds log BUILD_ID如果根本没有Build ID那就不是构建失败可能是部署前的校验失败比如函数名格式不对、区域问题、存储桶权限等。这类问题通常不需要看代码。3.2 第二步用functions-framework本地复现还原云端的真环境很多本地报错和云端不一致是因为你本地直接用python main.py启动Flask或FastAPI在自己监听的端口上工作而云端是把你的入口函数交给functions-framework来驱动。两者对请求的处理方式不完全一致。正确的本地调试方式是安装Google官方的函数框架pip install functions-framework functions-framework --targetmain --debug然后用一个真实的HTTP请求去访问它curl -i http://localhost:8080/这个步骤能帮你立刻确认两件事一是入口函数是否能被框架正常导入二是端口是否正确地暴露在8080上。如果--debug模式下连启动都直接抛异常那就说明你在函数顶层写的某些初始化逻辑比如websockets服务器在导入时就把进程卡住了。3.3 第三步检查requirements.txt里的版本锁定我在排查时几乎总是从requirements.txt开始因为大部分构建失败都发生在这里。最典型的问题有两个。第一个是直接裸写websockets没有版本号。这样会拉取当前最新版本而最新版本可能要求较高的Python版本或新的依赖如果你的函数运行时是python311或python310有时会看到构建日志里出现ERROR: Could not find a version that satisfies the requirement。第二个是写了一个过老的版本。websockets这个库在10.x之后API变化较大如果你本地用的是新版本写的代码但requirements里锁的是老版本部署时装的是老版本本地和云端行为分裂。我建议在requirements.txt里至少写大版本和小版本例如websockets12.0然后本地用同一个Python版本环境中安装一次。更保险的做法是只允许安装预编译的wheel包以此验证是否有可用的二进制版本pip install websockets12.0 --only-binary:all:如果这个命令报错说明当前Python版本没有对应的预编译wheel云端构建环境大概率也会尝试源码编译而源码编译是Cloud Functions构建失败的高发区。3.4 第四步入口函数必须返回一个合格的WSGI/ASGI应用Cloud Functions第一代要求入口函数接收request对象并返回响应值最常见的使用方式是把入口转发给Flask/FastAPI应用。比如from flask import Flask from flask_sock import Sock app Flask(__name__) sock Sock(app) sock.route(/ws) def ws_socket(ws): while True: data ws.receive() ws.send(data) def main(request): return app(request)这个模式是可以工作的但注意这里用的是flask-sock这类支持WebSocket的WSGI扩展而不是在函数内部额外构建一个独立WebSocket服务器。如果用websockets库的serve方法是典型的错误姿势两者要严格区分。3.5 第五步检查端口和环境变量确认8080是唯一正确的监听位置Cloud Functions第一代和第二代对外暴露的唯一入口是8080端口。函数框架默认监听8080但由于你可能在代码里调用app.run()就可能遇到端口冲突或监听位置错乱。常见的问题代码是if __name__ __main__: app.run(host0.0.0.0, portint(os.environ.get(PORT, 5000)))在本地这能跑在Cloud Functions里由于平台调用的是main(request)而不是执行python main.py这段代码根本不会走到但你如果把它放在模块顶层就可能意外占用端口。更稳妥的写法是import os if __name__ __main__: from werkzeug.serving import run_simple run_simple(0.0.0.0, int(os.environ.get(PORT, 8080)), app)实际上在Cloud Functions里你不需要自己启动任何HTTP服务器只有本地调试时才需要。记住这个原则部署目标里写入口函数不要写启动服务器的代码。3.6 第六步部署参数决定连接能活多久如果构建和健康检查都通过了但WebSocket连接不稳定需要回头检查部署参数。gcloud functions deploy your-function-name \ --runtime python311 \ --trigger-http \ --allow-unauthenticated \ --entry-point main \ --timeout3600 \ --memory512MB \ --max-instances10 \ --source .--timeout决定单个请求最多跑多久。默认是60秒WebSocket连接一握手成功就会一直持有如果超时被触发整个请求会被平台强制掐断客户端看到的直接是连接断开。长连接的--timeout需要设置到分钟级甚至小时级。内存方面websockets这类库在建立连接时会创建一些缓冲区加上一个或多个并发连接256MB默认值偏小。我通常给WebSocket服务用512MB起调如果同时连接数多再往上加。4. 依赖层和构建层的隐蔽问题本地能跑不代表云端能装这一节单独拿出来讲是因为我发现这类项目里最耗时的其实不是WebSocket功能本身而是“本地正常、云端构建失败”这种黑盒问题。4.1 构建日志里最常见的三个“鬼话”第一句是ERROR: Could not find a version that satisfies the requirement websockets。很多人以为这是网络问题或源问题但实际往往是Python版本和websockets版本不匹配。Cloud Functions的构建环境对每个Python运行时都预置了特定版本如果你构架时选了python39但requirements里锁定的websockets11.0.1可能只包含了更高的Python版本的wheel于是它要去源码编译而编译环境缺工具链构建失败。第二句是Failed building wheel for websockets。这说明确实走了源码编译路径。办法很简单把版本号调整到当前Python运行时适用或者干脆换Python运行时版本让构建环境有预编译wheel可用。第三句是构建成功但运行时ModuleNotFoundError。这类最隐蔽因为Cloud Build会把源码目录传上去构建但如果你多模块项目里没有把自定义模块放在根目录、或者import方式用了本地绝对路径构建时能通过因为模块可能在同一层目录里但运行时工作目录变了就会找不到模块。4.2 版本锁定为什么裸写websockets是大忌我还是想强调一下版本锁定的问题。websockets库迭代很快每次大版本更新都可能改API。Cloud Build每次部署都会执行一次干净的pip install -r requirements.txt你本地环境里已经装好的版本不会影响它。如果requirements里裸写websockets云端会装当时最新的版本和你的本地开发版本完全可能不同于是运行代码里用到的某个方法在云端装的新版本里已经被废弃或改名自然部署失败或运行报错。所以不光是写版本号我还会顺带在本地用一个全新的虚拟环境验证一遍python -m venv .cf-env source .cf-env/bin/activate pip install -r requirements.txt functions-framework --targetmain --debug这一步能最大程度还原云端构建的结果。4.3 自定义依赖pip能装的和必须编译的是两回事纯Python包基本没有构建风险但有C扩展的包websockets在一定程度上也是要看当前Python版本是否有对应的wheel。一个有用的技巧是直接去PyPI的下载页查一下你锁定的版本支持到哪个Python版本再返回来看你的--runtime参数通常问题就能解决。如果你的项目里还有自定义pb2文件、本地工具包注意放在源码目录中不要在构建时才用pip install -e .安装因为Cloud Build的工作目录和你的本地目录命名不一定一致很容易出现找不到setup.py的情况。5. 如果你必须要上WebSocket正确的方案是迁移而不是硬凹走到这一步我通常会对提问的人说一句不太好听但很真实的话Cloud Functions尤其是第一代就不应该承载WebSocket服务。你需要的不是修复部署而是换一个合适的部署目标。5.1 Cloud Run才是Google Cloud无服务器里能讲WebSocket的地方第二代Cloud Functions底层其实已经是Cloud Run所以最省事的路径是直接部署到Cloud Run。它支持HTTP/1.1的Upgrade机制也就是说WebSocket握手可以完整走通。只要你把服务容器化或在源码目录里准备好依赖声明Cloud Run可以从源码直接构建整个过程和部署Cloud Functions很像。如果项目简单用下面的命令就可以从源码部署gcloud run deploy ws-service \ --source . \ --region asia-southeast1 \ --allow-unauthenticated \ --concurrency1 \ --timeout900 \ --memory512Mi5.2 一个可部署的FastAPI WebSocket示例为了把这部分讲得更实用我给一个完整可跑的示例。基于FastAPI和uvicorn这是目前Python WebSocket服务里比较省心的组合import os from fastapi import FastAPI, WebSocket app FastAPI() app.websocket(/ws) async def websocket_endpoint(websocket: WebSocket): await websocket.accept() while True: data await websocket.receive_text() await websocket.send_text(fecho: {data}) if __name__ __main__: import uvicorn port int(os.environ.get(PORT, 8080)) uvicorn.run(app, host0.0.0.0, portport)requirements.txt写入fastapi0.110.0 uvicorn[standard]0.29.0 websockets12.0部署时容器会自行监听$PORT环境变量指定的端口。注意端口必须动态读取Cloud Run会分配一个随机的$PORT写死8080在有些情况下也能跑但不符合平台规范不推荐。5.3 并发和超时是WebSocket在Cloud Run上的生死线上文提到的--concurrency1对WebSocket有多重要我实际测试过一个例子默认并发80挂上5个WebSocket客户端客户端之间会互相干扰有的消息被另一个客户端收到有的连接直接被重置。调成--concurrency1之后每个实例只处理一个连接问题立刻消失。代价是成本。一个实例只服务一个连接如果同时在线100人就需要100个实例。很多团队接受不了这个成本就会考虑其他方案。5.4 什么时候可以不用迁移HTTP替代方案如果只是客户端需要实时性但服务端不需要主动推送其实用普通的HTTP轮询或SSEServer-Sent Events就足够了。SSE也是长连接但它走HTTP不需要WebSocket升级Cloud Functions第一代也能勉强支撑短时间的流式响应不过超时限制依然在。对真正的复杂实时应用聊天室、协同编辑、在线游戏建议直接用托管的实时后端或自己管好Cloud Run实例生命周期。6. 我留在笔记里的一张部署对照表和几条硬经验这一节是我每次做技术选型时都会翻出来的笔记也算是对这次排查的一个沉淀。6.1 三种部署方案的WebSocket支持对照产品WebSocket支持并发要求超时上限适用场景Cloud Functions 第一代基本不支持不适用540秒普通HTTP API、事件处理Cloud Functions 第二代支持但需调参数建议1最长3600秒轻量实时功能、少量连接Cloud Run支持必须调低最长3600秒正经WebSocket服务、可接受实例扩容6.2 三条从真实失败里学到的东西第一看到Build failed先确认requirements.txt和环境的关系不要改代码。我第二次踩这个坑时整个下午都在调试Flask路由结果只是websockets版本在python39上没wheel。第二如果你确定要部署WebSocket先想清楚用的是什么运行时模型。第二代Functions和Cloud Run走的是同一条路径但千万不要用默认并发。部署成功后第一件事就是压测多客户端场景而不是只测一个客户端。第三本地调试用functions-framework不要用自己启动的Flask服务器。它完全模拟云端对入口函数的调用方式可以提前暴露端口、导入路径、事件循环阻塞的问题。我后来所有相关部署都会先在本地用functions-framework --debug跑一遍把这个作为一道强制测试关卡比任何代码审查都管用。6.3 如果只剩一个Trace ID怎么继续查有时候CLI输出的错误不够完整只给了一个操作ID。这时候可以这样继续深挖gcloud functions operations describe OPERATION_ID输出里会有更多详情如果还是指向构建失败就去Cloud Console的Cloud Build历史里找到对应构建ID查看整个构建日志。很多时候这个日志里最后20行才藏着真正原因前面的pip安装记录都是无关噪音别被刷屏的安装输出带偏。我个人现在的习惯是项目一开始就决定好实时连接是否真的必要如果能用普通HTTP请求解决绝不上WebSocket如果必须要上直接选Cloud Run绕开Cloud Functions的各种限制。最后提一个小技巧——在部署脚本里加上--concurrency1和--timeout900这两个参数作为默认值能省掉之后至少两轮故障排查。部署日志从红色变成绿色的那一刻你会感谢这个顺手写进去的默认值。