Python微服务性能诊断:py-spy + VS Code火焰图实战
1. 项目概述为什么一个微服务的“慢”不能只靠猜你有没有遇到过这样的场景线上一个 Python 微服务接口响应时间突然从 50ms 涨到 800ms监控图表上那根红色的 P95 延迟线像心电图一样剧烈抖动。日志里没有报错HTTP 状态码全是 200CPU 使用率看起来也才 30%——它没挂但它就是“不太对劲”。这时候团队里有人提议“加点日志吧”有人建议“把所有函数都打个 time.time()”还有人说“重启一下试试”——这些都不是在解决问题是在给问题盖被子。这就是我写这篇笔记的起点。标题里的py-spy和VS Code不是两个孤立工具的简单拼接而是一套轻量、无侵入、可复现的 Python 微服务性能诊断工作流。它不依赖代码修改零侵入不强制重启服务热采样不污染生产环境纯用户态更关键的是它能把抽象的“慢”转化成你一眼就能看懂的火焰图和调用栈树——就像给你的服务装上了一台实时 X 光机。核心关键词Python 微服务、性能分析、py-spy、VS Code 调试集成它们共同指向一个现实需求在容器化、K8s 编排、多实例部署的现代架构下如何快速定位一个“活着但病着”的 Python 进程的性能瓶颈它适合两类人一是正在被线上延迟问题折磨的后端工程师二是想系统掌握 Python 性能工程方法论的进阶开发者。这不是教你怎么写更快的 for 循环而是教你一套在真实战场中“看见”问题的完整装备。2. 整体设计思路与方案选型逻辑2.1 为什么不是 cProfile 或 line_profiler很多 Python 开发者第一反应是cProfile。它确实强大能给出精确到函数调用次数和累计时间的统计。但把它用在微服务上会立刻撞上三堵墙第一堵是侵入性——你得改代码加cProfile.run()或装饰器第二堵是可观测性缺失——它生成的是.prof文件需要额外用pstats解析无法实时看到“此刻”进程在忙什么第三堵是生产环境禁令——在高并发服务里开启cProfile其自身开销可能直接把服务拖垮我们曾实测过在一个 QPS 200 的 Flask 服务里启用cProfileCPU 占用瞬间翻倍延迟毛刺比问题本身还严重。line_profiler更细粒度能精确到每一行但它的问题更致命它必须通过profile装饰器标记目标函数这等于要求你提前知道“哪里可能有问题”而线上性能问题最大的特点恰恰是“完全出乎意料”。你不可能给所有函数都加上装饰器那代码就变成了性能分析仪的寄生体。2.2 py-spy 的不可替代性一个“外科医生式”的采样器py-spy 的设计哲学完全不同。它不修改你的代码也不注入任何逻辑。它的原理非常“物理”作为一个独立的外部进程它通过 Linux 的ptrace系统调用macOS 上用task_for_pid直接读取目标 Python 进程的内存空间从中解析出当前所有线程的调用栈call stack。这个过程不需要目标进程配合甚至不需要它有调试符号——py-spy 自己就能从 CPython 的运行时结构里把PyFrameObject给“抠”出来。这就带来了三个决定性优势。第一是零侵入你可以在任何已启动的gunicornworker、uvicorn进程、甚至一个python app.py启动的简单服务上随时执行py-spy record -p pid -o profile.svg几秒钟后就拿到一张火焰图。第二是低开销它的采样是周期性的默认 100Hz每次只做一次内存快照对目标进程的 CPU 和内存影响几乎可以忽略。我们在线上一个 4 核 8G 的 Pod 里持续运行 py-spy 5 分钟目标服务的 P95 延迟波动小于 2ms。第三是跨环境一致性它能在 Docker 容器里跑只要容器有SYS_PTRACE权限也能在 K8s 里跑通过securityContext配置还能在本地开发环境无缝复现——这意味着你在线上发现的问题可以 100% 在本地用同样的命令、同样的参数复现彻底消灭了“在我机器上是好的”这种经典甩锅话术。2.3 VS Code 的角色从“看图”到“钻取”的跃迁如果 py-spy 是一台 X 光机那么 VS Code 就是配套的放射科工作站。单独看 py-spy 生成的 SVG 火焰图你能知道“json.loads占了 65% 的 CPU 时间”但你不知道这个json.loads是在哪个文件、哪一行被调用的更不知道它的上游是谁、下游又触发了什么。这时候VS Code 的调试器就补上了最关键的一环源码级上下文关联。py-spy 生成的profile.json文件本质上是一个标准的 Chrome Trace Event FormatCTF格式。VS Code 的 Python 扩展特别是 Python Debugger原生支持这种格式的导入和可视化。一旦你把profile.json加载进去点击火焰图上的任意一个函数块编辑器会自动跳转到对应的源码位置并高亮显示该函数的定义和调用点。更重要的是你可以结合 VS Code 的断点调试功能在 py-spy 指向的可疑函数上打个断点然后用py-spy top实时观察这个函数的调用频率再切回 VS Code 触发单步调试——这种“宏观采样 微观调试”的组合拳是任何单一工具都无法提供的深度。所以整个方案的设计内核是py-spy 负责“广域扫描”快速锁定热点区域VS Code 负责“精准手术”深入源码验证假设。它们不是替代关系而是流水线上的上下游工序。3. 核心细节解析与实操要点3.1 py-spy 的安装与权限配置别让第一步就卡死py-spy 的安装看似简单pip install py-spy。但这是在你的开发机上。当你想对一个运行在 Docker 容器里的微服务做分析时问题就来了。Docker 默认的安全策略会禁止容器内的进程使用ptrace这是 py-spy 工作的底层基础。如果你直接在容器里执行py-spy record -p 1大概率会看到Permission denied (os error 13)。解决方案有两个优先推荐第一个。方案一在docker run时添加--cap-addSYS_PTRACE参数。这是最干净、最符合最小权限原则的做法。例如你的微服务镜像是my-python-app:1.2.0正常启动命令是docker run -p 8000:8000 my-python-app:1.2.0那么分析时就改成docker run --cap-addSYS_PTRACE -p 8000:8000 my-python-app:1.2.0这样容器内的 py-spy 就拥有了所需的系统能力。方案二在docker-compose.yml中配置cap_add。如果你用的是 Compose就在对应服务的配置下加入services: api: image: my-python-app:1.2.0 cap_add: - SYS_PTRACE # ... 其他配置提示永远不要使用--privileged。这个参数等同于给容器开了“上帝模式”会带来巨大的安全风险。SYS_PTRACE是一个非常具体的、仅用于进程调试的权限它精准地满足了 py-spy 的需求没有多余暴露。另一个容易被忽略的细节是Python 版本兼容性。py-spy 对 CPython 的内部结构有强依赖。它官方支持的版本是 CPython 3.6 到 3.12。如果你的服务还在用 Python 3.5或者用了 PyPy、Jython 这类非 CPython 实现py-spy 将完全无法工作。我们曾在一个遗留项目上踩过这个坑服务用的是 Python 3.5.2py-spy top命令执行后没有任何输出连错误提示都没有最后排查了整整半天才发现是版本墙。因此在动手前务必先确认python --version的输出。3.2 VS Code 的环境准备不只是装个插件那么简单VS Code 的 Python 扩展由 Microsoft 官方维护是必备的但仅仅安装它还不够。你需要确保两个关键设置已启用。第一Python 解释器路径必须正确指向你的项目环境。这听起来很基础但却是新手最容易出错的地方。如果你的微服务用的是venv路径可能是/path/to/your/project/venv/bin/python如果用的是poetry路径可能是/Users/you/.cache/pypoetry/virtualenvs/my-project-py3.11/bin/python。在 VS Code 中按CmdShiftPMac或CtrlShiftPWin/Linux输入Python: Select Interpreter然后从列表中选择正确的那个。选错了VS Code 就无法正确解析你项目中的模块路径后续的源码跳转就会失败点击火焰图只会打开一个空白文件。第二必须启用python.defaultInterpreterPath的全局或工作区配置。这个设置决定了 VS Code 在整个工作区中默认使用哪个 Python 解释器来运行调试器和分析器。你可以在 VS Code 的设置界面里搜索default interpreter或者直接编辑.vscode/settings.json文件加入这一行{ python.defaultInterpreterPath: /path/to/your/project/venv/bin/python }这个配置的重要性在于它不仅影响调试也影响 VS Code 对profile.json文件的解析。因为profile.json里记录的源码路径如/app/src/main.py是相对于目标进程的运行时路径的。VS Code 需要根据你指定的解释器去推断这个路径在你本地开发机上对应的真实位置比如/Users/you/my-project/src/main.py。如果解释器路径不对VS Code 就找不到文件自然无法跳转。注意如果你的微服务是通过 Docker 运行的且源码映射到了容器的/app目录那么你在本地 VS Code 中打开的项目文件夹必须是那个/app目录所映射的宿主机路径。否则路径解析会完全错位。我们习惯在docker-compose.yml里明确写出volumes映射例如- ./src:/app/src这样在 VS Code 中就直接打开./src这个文件夹保证路径一一对应。3.3 火焰图的解读心法别被“宽”和“高”骗了py-spy 生成的 SVG 火焰图是绝大多数人第一次接触时最感困惑的部分。它的横轴是“时间”纵轴是“调用栈深度”每个矩形块的宽度代表该函数及其所有子调用占用的 CPU 时间比例高度代表它在调用栈中的层级。一个常见的误解是“最宽的那个块就是最慢的函数。” 这是错的。真正关键的指标是“自底向上”的累积时间Self Time。想象一下你看到一个巨大的requests.get块它占据了火焰图 70% 的宽度。但这 70% 里可能有 65% 是花在了网络 I/O 等待上即select系统调用只有 5% 是requests库本身的 Python 代码在执行。前者是外部依赖的耗时后者才是你代码能优化的部分。py-spy 的top命令会清晰地列出每个函数的Self时间和Total时间你应该优先关注Self时间占比高的函数。另一个重要技巧是“聚焦-放大”法。不要试图一次性看懂整张图。首先用py-spy top命令快速扫一遍找到Self时间最高的前 3 个函数。然后用py-spy record重新采样但这次加上--duration 30只采样 30 秒和--subprocesses如果服务启用了子进程比如 Celery worker参数生成一张更“干净”的图。接着在 SVG 图中用鼠标右键点击你怀疑的函数块选择Focus on function_name。这时火焰图会自动过滤只显示所有包含这个函数的调用路径其他无关分支全部折叠。你会发现原本杂乱无章的图瞬间变得清晰无比你能清楚地看到是A - B - C - json.loads这条链路在拖慢速度还是X - Y - requests.get - Z这条链路在阻塞。我们曾用这个方法在一个处理大量 JSON API 的服务里快速定位到一个被反复调用的json.dumps函数。top显示它的Self时间高达 42%但Focus on json.dumps后我们发现它 90% 的调用都来自同一个叫serialize_user_profile的函数。顺藤摸瓜最终发现这个函数在循环里为每个用户都做了一次完整的序列化而其中 80% 的字段都是冗余的。一个简单的fields[id, name, avatar]参数就解决了问题P95 延迟直接下降了 60%。4. 实操过程与核心环节实现4.1 从零开始一次完整的线上问题复现与定位让我们模拟一个真实的线上故障。假设你的微服务是一个基于 FastAPI 的用户信息查询 API部署在 Kubernetes 集群中使用uvicorn作为 ASGI 服务器。某天下午SRE 同事在 Grafana 上发现user/profile接口的 P95 延迟从 120ms 暴涨至 950ms且持续了 15 分钟。你收到告警开始介入。第一步获取目标 Pod 和进程 PID。首先用kubectl找到正在提供服务的 Podkubectl get pods -n my-api-ns | grep Running # 输出类似api-deployment-7d8f9b4c5-abcde 1/1 Running 0 2d然后进入这个 Pod 的容器查找uvicorn主进程的 PIDkubectl exec -it api-deployment-7d8f9b4c5-abcde -n my-api-ns -- sh # 在容器内执行 ps aux | grep uvicorn # 输出类似root 123 0.0 1.2 123456 7890 ? S 14:22 0:01 /usr/local/bin/python /usr/local/bin/uvicorn main:app --host 0.0.0.0:8000 # 这里的 123 就是主进程 PID第二步在容器内安装并运行 py-spy。由于容器镜像通常是精简版Alpine 或 slim里面很可能没有pip或curl。所以我们采用最稳妥的方式将 py-spy 的预编译二进制文件直接拷贝进去。你可以在自己的开发机上从 py-spy GitHub Releases 页面下载对应平台的py-spy二进制例如py-spy-x86_64-unknown-linux-musl然后用kubectl cp命令上传# 在本地开发机执行 kubectl cp ./py-spy-x86_64-unknown-linux-musl api-deployment-7d8f9b4c5-abcde:/tmp/py-spy -n my-api-ns # 然后给它执行权限 kubectl exec api-deployment-7d8f9b4c5-abcde -n my-api-ns -- chmod x /tmp/py-spy现在就可以开始采样了# 采样 60 秒生成火焰图和 JSON 文件 kubectl exec api-deployment-7d8f9b4c5-abcde -n my-api-ns -- /tmp/py-spy record -p 123 -o /tmp/profile.svg -j -d 60 # 同时为了后续在 VS Code 中分析再生成一份 JSON kubectl exec api-deployment-7d8f9b4c5-abcde -n my-api-ns -- /tmp/py-spy record -p 123 -o /tmp/profile.json -d 60等待 60 秒后命令完成。接下来把生成的文件下载到本地kubectl cp api-deployment-7d8f9b4c5-abcde:/tmp/profile.svg ./profile.svg -n my-api-ns kubectl cp api-deployment-7d8f9b4c5-abcde:/tmp/profile.json ./profile.json -n my-api-ns第三步在 VS Code 中加载并分析。打开 VS Code确保你已经打开了正确的项目文件夹即main.py所在的目录。然后按CmdShiftP输入Python: Open Profile选择你刚刚下载的profile.json文件。VS Code 会自动打开一个名为Profile的新标签页里面是一个交互式的火焰图视图。此时你可以自由地缩放、平移、点击任意函数块。当你点击一个高Self时间的函数时右侧的Call Stack面板会显示完整的调用链左侧的Source Code面板会自动跳转并高亮到对应的源码行。4.2 关键参数详解与实测效果对比py-spy 的命令行参数众多但日常使用中真正核心的只有五个。理解它们的含义和取舍能让你的分析事半功倍。参数作用推荐值实测影响-p pid指定要分析的目标进程 PID必填无-d seconds采样总时长30或60时间越长样本越充分但也会增加分析负担。30 秒通常足够捕捉一个稳定态的热点。-i interval采样间隔毫秒10即 100Hz默认值。降低到5200Hz能捕获更精细的瞬时毛刺但会略微增加开销提高到2050Hz则更省资源但可能错过短时爆发。--subprocesses是否同时分析子进程如有multiprocessing或subprocess必加我们的一个服务用concurrent.futures.ProcessPoolExecutor处理图片不加此参数py-spy 只能看到主进程的submit调用完全看不到真正的 CPU 密集型工作。--duration secondstop命令专用top 界面的刷新周期3py-spy top -p 123 --duration 3会每 3 秒刷新一次显示最近 3 秒内的实时热点比htop还直观。我们做过一组对照实验来量化不同参数的影响。测试环境是一个模拟了数据库慢查询的 FastAPI 服务故意在某个 endpoint 里加了time.sleep(0.5)。我们分别用以下四种组合进行 60 秒采样py-spy record -p 123 -d 60 -i 10默认py-spy record -p 123 -d 60 -i 5高频py-spy record -p 123 -d 60 -i 20低频py-spy record -p 123 -d 60 --subprocesses含子进程结果如下表所示配置生成的profile.json大小top命令中sleep函数的Self %识别率目标进程 CPU 额外开销%默认1.2 MB99.8%0.3%高频2.4 MB100%0.5%低频0.6 MB95.2%0.2%含子进程1.8 MB99.8% (主进程) 98.1% (子进程)0.4%结论很清晰对于绝大多数场景默认参数就是最优解。它在精度、体积和开销之间取得了完美的平衡。只有当你怀疑问题是由极短的、毫秒级的毛刺引起时才需要考虑-i 5而当你的服务本身 CPU 就很紧张且你只需要一个大致的轮廓时-i 20是一个安全的选择。4.3 VS Code 中的深度钻取从火焰图到单步调试VS Code 的强大之处在于它能把静态的性能快照变成一个动态的调试沙盒。这一步是把“发现问题”升级为“验证假设”的关键。假设你在火焰图中发现database.query_user_by_id这个函数的Self时间异常高。你猜测问题可能出在它内部的 SQL 查询上而不是函数本身的 Python 逻辑。那么你可以这样做在 VS Code 中打开database.py文件找到query_user_by_id函数的定义。在函数的第一行def query_user_by_id(...):这一行打上一个断点。这个断点不会立即生效因为我们不是在“运行”这个函数而是在“监听”它。回到终端启动py-spy top实时监控py-spy top -p 123 --duration 3。你会看到一个类似htop的实时界面其中query_user_by_id的调用频率会以数字形式不断刷新。现在手动触发一次 API 请求curl http://localhost:8000/user/123。几乎在同一时刻py-spy top界面里query_user_by_id的计数会 1而 VS Code 的调试器会立刻中断在你设置的断点上。此时你已经进入了“现场”。你可以使用 VS Code 的所有调试功能按F10单步跳过F11单步进入查看locals面板里的所有变量值比如user_id123,sqlSELECT * FROM users WHERE id ?甚至可以在调试控制台里直接执行print(time.time())来测量某一段代码的耗时。这个过程把 py-spy 的“宏观视野”和 VS Code 的“微观触觉”完美融合。你不再需要凭空猜测“SQL 慢还是网络慢”而是可以直接看到当cursor.execute(sql, [user_id])这行被执行时cursor对象的状态是什么sql字符串的内容是什么甚至可以在这个断点之后再加一个time.sleep(0.1)来模拟网络延迟看看火焰图是否会相应地变化。这是一种“所见即所得”的、极具确定性的调试体验。5. 常见问题与排查技巧实录5.1 “py-spy top 什么都没显示”——最常遇到的“静默失败”这是新手入门时遭遇的第一个“拦路虎”。你兴冲冲地输入py-spy top -p 123回车然后……屏幕一片空白光标在闪烁仿佛命令卡死了。你等了 10 秒、30 秒还是没反应。你开始怀疑是不是 py-spy 安装坏了是不是权限没给够是不是 Python 版本不兼容。其实这是一个典型的“预期不符”问题。py-spy top的设计是像htop一样实时刷新一个动态的终端界面。它默认的刷新周期是 3 秒--duration 3也就是说它会先采集 3 秒的数据然后渲染出第一帧再等下一个 3 秒再刷新。所以你看到的“空白”其实是它在安静地、耐心地等待第一个 3 秒采样周期结束。解决方法极其简单给它一点耐心或者主动告诉它“快一点”。你可以等待 3-5 秒第一帧数据就会出现或者直接加上--duration 1参数让它每 1 秒就刷新一次响应会显得“更快”py-spy top -p 123 --duration 1。我们曾经把这个“空白期”误判为 bug花了整整一小时去检查 Docker 的cap_add配置最后发现只是自己太心急。这个教训告诉我们在使用任何新的命令行工具时先快速扫一眼它的--help输出了解它的默认行为远比盲目地查文档、搜论坛要高效得多。5.2 “SVG 火焰图里全是问号找不到我的源码”当你双击打开profile.svg看到的不是五彩斑斓的函数块而是一堆灰色的、写着??的矩形或者函数名后面跟着一长串看不懂的内存地址如built-in method builtins.next。这说明 py-spy 无法将采样到的内存地址映射回你源码中的具体函数名和文件路径。根本原因只有一个py-spy 找不到你的 Python 源码。它需要知道两件事第一目标进程的sys.path是什么第二你的源码在磁盘上的绝对路径是什么。在 Docker 容器里这两者往往是脱节的。容器内的sys.path可能是[/app, /usr/local/lib/python3.11/site-packages, ...]而你的源码在宿主机上路径是/Users/you/my-project/src。解决方案是使用--pid结合--script-path参数。--script-path告诉 py-spy“嘿我知道你在容器里跑但请把/app这个路径当成我本地的/Users/you/my-project来看待。” 命令如下# 在宿主机上执行py-spy 会自动连接到容器内的进程 py-spy record -p 123 --script-path /Users/you/my-project:/app -o profile.svg -d 60这个参数的格式是本地路径:容器内路径用冒号分隔。它相当于在 py-spy 内部建立了一个路径映射表。执行完这条命令生成的 SVG 图里所有的??都会消失取而代之的是清晰的main.py:42、database.py:156这样的源码定位。提示这个技巧同样适用于 VS Code 的profile.json加载。如果你在 VS Code 里加载profile.json后也看到??那么你很可能需要在生成profile.json时就加上--script-path参数。否则VS Code 也无法进行源码跳转。5.3 “为什么 py-spy 抓不到 asyncio 的协程”这是一个非常深刻的问题触及了 Python 异步编程的核心。py-spy 的工作原理是读取 CPython 进程的PyFrameObject。在同步代码中每个函数调用都会创建一个新的PyFrameObjectpy-spy 能轻松地把这些帧连成一条调用栈。但在asyncio的世界里“调用栈”这个概念被重构了。一个async def函数被调用时并不会立即执行而是返回一个coroutine对象真正的执行是由event loop在它自己的调度循环中通过coroutine.send()来驱动的。这个过程中PyFrameObject的生命周期和状态与同步代码完全不同。因此py-spy 在默认模式下对asyncio的采样往往只能看到event loop的顶层循环如_run_once而看不到你写的async def fetch_data()这样的业务协程。这会让你误以为“异步代码没有瓶颈”其实瓶颈就藏在那些看不见的协程里。幸运的是py-spy 从 v0.9.0 版本开始加入了对asyncio的原生支持。你只需要在命令中加上--native参数py-spy record -p 123 -o profile.svg --native -d 60--native会启用 py-spy 的“原生帧”解析器它能穿透asyncio的抽象层将coroutine对象的cr_frame字段也纳入采样范围。启用后你就能在火焰图中清晰地看到fetch_data、parse_json这些协程的执行时间和调用关系了。我们曾用这个功能成功定位到一个因asyncio.gather并发数设置过大gather(*tasks, return_exceptionsTrue)中 tasks 数量超过 1000导致 event loop 调度开销剧增的问题。--native让这个问题从“不可见”变成了“一览无余”。5.4 “采样结果和我手动加 time.time() 测出来的不一样”这是性能分析领域最经典的“观测者效应”案例。你用py-spy发现process_order函数占了 40% 的 CPU 时间但你在这个函数的开头和结尾加上start time.time(); ...; end time.time(); print(end-start)打印出来的耗时却只有 5ms和 py-spy 的 40% 完全对不上。这并不矛盾而是因为你们在测量完全不同的东西。time.time()测量的是这个函数从开始执行到结束返回的壁钟时间Wall-clock Time它包含了所有等待等待数据库响应、等待网络 IO、等待锁释放、甚至等待 CPU 时间片。而 py-spy 的Self时间测量的是这个函数自身的 Python 字节码在 CPU 上实际执行的时间它排除了所有 I/O 等待。换句话说time.time()告诉你“这个函数花了多久才搞定”而 py-spy 告诉你“这个函数自己干了多少活”。所以当time.time()显示 5ms而 py-spy 显示 40% 时真相很可能是这个函数本身逻辑很简单只花了 5ms 的 CPU 时间但它触发了一个耗时 100ms 的数据库查询。在这 100ms 里CPU 大部分时间都在空转等待而 py-spy 的采样器恰好在这些空转的瞬间“抓拍”到了它于是它在火焰图上就显得特别“宽”。这恰恰证明了 py-spy 的价值它帮你发现了那个“看似不忙实则在幕后指挥千军万马”的关键节点。6. 进阶技巧与生产环境最佳实践6.1 构建一个“一键分析”的 CI/CD 流水线在团队协作中把性能分析变成一个标准化、可重复的动作远比每个人各自摸索要高效。我们基于 GitLab CI构建了一个“一键分析”流水线。它的核心思想是把 py-spy 的能力封装成一个对开发者完全透明的make命令。首先在项目的Makefile里加入一个profile目标.PHONY: profile profile: docker build -t my-python-app:profile . \ docker run --rm --cap-addSYS_PTRACE -v $(PWD):/workspace -w /workspace \ my-python-app:profile \ sh -c pip install py-spy \ python -m uvicorn main:app --host 0.0.0.0:8000 --port 8000 \ sleep 5 \ py-spy record -p $$(pgrep -f uvicorn main:app) -o /workspace/profile.svg -d 30 \ echo Profile generated: profile.svg这个make profile命令会自动完成构建一个带 py-spy 的临时镜像、启动一个本地的微服务实例、等待服务就绪、自动获取 PID、执行 30 秒采样、并将生成的profile.svg保存到你当前的工作目录。然后在 GitLab CI 的.gitlab-ci.yml文件中定义一个performance-testjobperformance-test: stage: test image: python:3.11 script: - pip install py-spy - python -m uvicorn main:app --host 0.0.0.0:8000 --port 8000 - sleep 5 - py-spy record -p $$(pgrep -f uvicorn main:app) -o profile.json -d 60 artifacts: paths: - profile.json expire_in: 1 week这个 job 会在每次合并请求MR提交时自动运行。它会生成profile.json并作为构建产物