.NET WebApi Windows / Linux Docker 全链路压测与瓶颈定位 0 到 1 教程
面向对压力测试、全链路压测零基础的 .NET 开发工程师。本文从概念讲起,带你一步一步完成:装工具、写 k6 脚本、运行压测、看懂结果、定位瓶颈。
适合谁看
如果你遇到下面任意一种情况,这篇文档就是给你的:
- 你知道接口能跑,但不知道能扛多少并发。
- 你听过压测、TPS、P95,但不知道怎么实际操作。
- 你会写 .NET WebApi,但不熟悉 k6、dotnet-counters、dotnet-trace。
- 你想要一份可以照着敲命令的详细教程。
本文支持两种常见环境:
路线 A:Windows 本机 / Windows 压测机
- Windows
- .NET WebApi
- PowerShell
- k6
- .NET 8 或更高版本
路线 B:Linux + Docker 容器部署
- Linux 服务器
- Docker
- Docker 容器中运行 .NET 8 或更高版本 WebApi
- k6 从 Windows、Linux 或独立压测机发压
- dotnet-counters、dotnet-trace、dotnet-dump 在容器内或诊断镜像中监听 .NET 进程
如果你的项目部署在 Linux 的 Docker 容器里,核心思路是:k6 请求 http://Linux服务器IP:宿主机映射端口/接口路径,同时用 docker stats 看容器资源,用 dotnet-counters 看容器内 .NET 运行时指标。
第一篇:先搞懂几个最基础的概念
动手之前,先建立几个心智模型。理解这些名词后,后面看报告就不会懵。
1. 压测到底在测什么
压测就是:用机器模拟很多用户同时访问你的接口,然后观察系统表现。
重点看这几个指标:
| 指标 | 含义 | 怎么理解 |
|---|---|---|
| TPS / RPS | 每秒处理多少个请求 | 越高说明吞吐越好 |
| RT | 响应时间 | 单个请求花了多久 |
| P95 | 95% 的请求都小于这个耗时 | 比平均值更有参考意义 |
| P99 | 99% 的请求都小于这个耗时 | 看极端慢请求 |
| 错误率 | 请求失败比例 | 越低越好,通常要求小于 1% |
| CPU / 内存 / GC | 服务器资源使用情况 | 用来判断瓶颈在哪里 |
一句话总结:不断增加虚拟用户数,看系统从什么时候开始扛不住。
常见的"扛不住"表现:
- 响应时间突然变高。
- 500 错误变多。
- 请求超时。
- CPU 接近 100%。
- 内存持续上涨。
- 数据库连接数耗尽。
2. 什么是全链路压测
假设你有一个天气查询接口,真实请求链路可能是:
text
用户请求 -> Nginx/网关 -> WebApi -> Redis 缓存
-> SQL Server 数据库
-> 第三方天气 HTTP 接口
只压 WebApi 本身,不一定能发现真实问题。因为瓶颈经常在下游,例如:
- SQL 查询慢。
- Redis 连接不够。
- 第三方接口限流。
- 网关超时。
- 数据库连接池耗尽。
所以全链路压测的意思是:尽量模拟真实业务请求,让整个调用链都参与进来。
3. 两个必须提前知道的安全常识
请认真看这两条,尤其是第一次做压测时:
- 不要直接压生产环境。 除非你有专门的影子流量、限流、隔离和回滚方案,否则会影响真实用户。
- 准备独立压测环境。 最好使用测试服务器,或者本机 Docker,把 WebApi、数据库、Redis 等组件都准备好。
第二篇:环境准备
这一篇只做一件事:把工具装好,并确认它们真的可用。
建议打开一个新的 PowerShell,后面的命令都在 PowerShell 里执行。
步骤 1:确认是否安装 .NET SDK
执行:
powershell
dotnet --version
正常输出类似:
text
8.0.301
只要能看到版本号,就说明 .NET SDK 已经可用。
如果提示:
text
dotnet 不是内部或外部命令,也不是可运行的程序
说明你还没安装 .NET SDK,或者安装后没有重新打开 PowerShell。
处理方法:
- 安装 .NET SDK。
- 安装完成后关闭当前 PowerShell。
- 重新打开 PowerShell。
- 再执行
dotnet --version。
步骤 2:安装压测工具 k6
k6 是本文使用的压测工具。它的脚本使用 JavaScript 编写,适合做接口压测。
方式 A:用 Chocolatey 安装
如果你已经安装 Chocolatey,直接执行:
powershell
choco install k6 -y
安装完成后验证:
powershell
k6 version
正常输出类似:
text
k6 v0.50.0 ...
如果你还没有 Chocolatey,可以用管理员身份打开 PowerShell,然后执行:
powershell
Set-ExecutionPolicy Bypass -Scope Process -Force
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072
iex ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1'))
装完 Chocolatey 后,重新打开 PowerShell,再执行:
powershell
choco install k6 -y
k6 version
方式 B:下载安装包
如果你不想安装 Chocolatey,可以打开 k6 官方安装页面下载安装包:
text
https://k6.io/docs/get-started/installation/
安装完成后,重新打开 PowerShell,执行:
powershell
k6 version
步骤 3:安装 .NET 诊断工具
后面定位瓶颈会用到这几个工具:
| 工具 | 用途 |
|---|---|
| dotnet-counters | 实时看 CPU、内存、GC、线程池 |
| dotnet-trace | 抓 CPU 火焰图 |
| dotnet-dump | 抓内存快照,分析内存泄漏 |
执行:
powershell
dotnet tool install --global dotnet-counters
dotnet tool install --global dotnet-trace
dotnet tool install --global dotnet-dump
验证是否安装成功:
powershell
dotnet-counters --version
dotnet-trace --version
dotnet-dump --version
如果提示找不到命令,可以临时把 .NET 全局工具目录加到当前 PowerShell 的 PATH:
powershell
$env:Path += ";$env:USERPROFILE\.dotnet\tools"
然后再次验证:
powershell
dotnet-counters --version
步骤 4:准备一个可以访问的 WebApi
假设你的 WebApi 项目目录叫 MyWebApi,可以用 Release 模式启动。
进入项目目录:
powershell
cd C:\你的项目路径\MyWebApi
发布:
powershell
dotnet publish -c Release -o .\publish
进入发布目录:
powershell
cd .\publish
启动项目:
powershell
dotnet MyWebApi.dll
启动成功后,控制台通常会看到类似:
text
Now listening on: http://localhost:5000
Application started. Press Ctrl+C to shut down.
记住这个地址,后面 k6 脚本要用。本文示例使用:
text
http://localhost:5000
启动后,先用浏览器访问一个接口,例如:
text
http://localhost:5000/weatherforecast
如果浏览器能看到 JSON 返回,说明接口已经能访问。
第三篇:写第一个 GET 压测脚本
这一篇从零开始创建一个 k6 脚本。
步骤 1:创建脚本目录
建议先建一个专门放压测脚本的目录,例如:
powershell
mkdir C:\load-test-demo
cd C:\load-test-demo
确认当前目录:
powershell
pwd
正常应该看到:
text
Path
----
C:\load-test-demo
步骤 2:创建 stress_test.js
你可以用 VS Code、记事本或其他编辑器创建文件:
text
C:\load-test-demo\stress_test.js
把下面内容复制进去:
javascript
// 导入 k6 的 HTTP 请求模块
import http from 'k6/http';
// check 用来做断言,sleep 用来模拟用户思考或停顿时间
import { check, sleep } from 'k6';
// options 是 k6 的压测配置
export const options = {
// stages 表示"分阶段加压"。
// 每一行都是一个阶段:duration 表示这个阶段持续多久,target 表示这个阶段最终达到多少个虚拟用户。
// 虚拟用户可以简单理解为"同时在线并不断请求接口的用户数"。
stages: [
{ duration: '30s', target: 20 }, // 30 秒内从 0 个用户逐步增加到 20 个用户,用来预热系统
{ duration: '1m', target: 50 }, // 继续加压到 50 个用户,并持续观察系统是否稳定
{ duration: '30s', target: 100 }, // 30 秒内从 50 个用户增加到 100 个用户,观察系统是否开始变慢
{ duration: '1m', target: 100 }, // 保持 100 个用户 1 分钟,用来看系统在高压下是否稳定
{ duration: '30s', target: 0 }, // 逐步降到 0 个用户,表示压测结束,避免突然停止造成数据不好观察
],
thresholds: {
// http_req_duration 表示请求响应时间。
// p(95)<500 表示 95% 的请求必须在 500ms 内完成。
// 如果接口要求更严格,可以改成 p(95)<300;如果接口本身比较重,可以先放宽到 p(95)<1000。
http_req_duration: ['p(95)<500'],
// http_req_failed 表示请求失败率。
// rate<0.01 表示失败率必须小于 1%。
// 如果希望完全不能失败,可以改成 rate<0.001,约等于失败率小于 0.1%。
http_req_failed: ['rate<0.01'],
},
};
// 每个虚拟用户都会反复执行这个函数
export default function () {
// 向目标接口发起一次 GET 请求
const res = http.get('http://localhost:5000/weatherforecast');
// 校验接口是否返回 200
check(res, {
'status is 200': (r) => r.status === 200,
});
// 每次请求后停顿 1 秒,模拟真实用户不会一直疯狂刷新
sleep(1);
}
用"奶茶店排队"的故事理解压测配置
刚开始看 options 配置时,很多人会觉得 duration、target、thresholds 像一堆陌生参数。你可以先把系统想象成一家奶茶店。
WebApi 就是这家奶茶店,接口就是点单窗口,数据库、Redis、第三方接口就是后厨、仓库和外卖供应商。压测工具 k6 做的事情,就是安排一批"模拟顾客"按计划进店点单。
在这个故事里:
| 配置 | 故事里的意思 | 实际含义 |
|---|---|---|
target |
店里同时有多少顾客 | 虚拟用户数,模拟多少用户同时访问接口 |
duration |
这一批顾客进店或停留多久 | 当前阶段持续时间 |
stages |
顾客分几批进店、停留、离开 | 分阶段加压计划 |
thresholds |
老板定的合格标准 | 压测是否通过的判断规则 |
比如这段:
javascript
stages: [
{ duration: '30s', target: 20 },
{ duration: '1m', target: 50 },
{ duration: '30s', target: 100 },
{ duration: '1m', target: 100 },
{ duration: '30s', target: 0 },
]
可以理解成这家奶茶店的一天小高峰演练:
第一阶段:
javascript
{ duration: '30s', target: 20 }
30 秒内,让顾客从 0 个慢慢增加到 20 个。这个阶段不是为了压垮系统,而是为了预热。你要先确认接口地址、Token、数据库连接、基础流程都没问题。
第二阶段:
javascript
{ duration: '1m', target: 50 }
接下来提高到 50 个顾客,并观察 1 分钟左右。这个阶段适合看系统在中等压力下是否稳定。
第三阶段:
javascript
{ duration: '30s', target: 100 }
再把顾客增加到 100 个。这里开始进入真正的压力区间,要重点观察 P95、错误率、CPU、数据库响应时间。
第四阶段:
javascript
{ duration: '1m', target: 100 }
保持 100 个顾客一段时间。这个阶段非常重要,因为系统有时候刚冲上去没问题,但持续一会儿后才会暴露问题,比如连接池耗尽、内存上涨、GC 变频繁。
第五阶段:
javascript
{ duration: '30s', target: 0 }
让顾客慢慢离开,压测结束。建议保留这个阶段,因为它可以让曲线更平滑,也方便观察系统压力下降后能不能恢复正常。
后期怎么调整 stages
如果你只是第一次试跑,不确定系统能不能扛住,建议保守一点:
javascript
stages: [
{ duration: '30s', target: 10 },
{ duration: '1m', target: 30 },
{ duration: '30s', target: 0 },
]
如果 30 个用户时系统很稳,再逐步提高:
javascript
stages: [
{ duration: '30s', target: 20 },
{ duration: '1m', target: 50 },
{ duration: '2m', target: 100 },
{ duration: '30s', target: 0 },
]
如果你想找系统极限,可以继续往上加,但要一档一档加,不要第一次就从 20 直接跳到 1000:
javascript
stages: [
{ duration: '1m', target: 50 },
{ duration: '2m', target: 100 },
{ duration: '2m', target: 200 },
{ duration: '2m', target: 500 },
{ duration: '1m', target: 0 },
]
如果你要模拟真实业务高峰,比如活动开始后大量用户同时进来,可以让用户更快涨上去,并保持更久:
javascript
stages: [
{ duration: '30s', target: 100 },
{ duration: '5m', target: 100 },
{ duration: '1m', target: 0 },
]
这表示 30 秒内快速冲到 100 个用户,然后持续压 5 分钟。
后期怎么调整 thresholds
thresholds 可以理解成老板定的"及格线"。
javascript
thresholds: {
http_req_duration: ['p(95)<500'],
http_req_failed: ['rate<0.01'],
}
这段意思是:
p(95)<500:95% 的请求必须在 500ms 内完成。rate<0.01:失败率必须小于 1%。
如果是普通查询接口,希望响应很快,可以严格一点:
javascript
http_req_duration: ['p(95)<300']
如果是复杂接口,比如创建订单、写数据库、调用第三方服务,可以先放宽一点:
javascript
http_req_duration: ['p(95)<1000']
如果系统要求很高,几乎不能失败,可以把失败率调得更严格:
javascript
http_req_failed: ['rate<0.001']
简单记住这三句话:
text
target 调的是"同时有多少用户"。
duration 调的是"这一阶段持续多久"。
thresholds 调的是"什么结果算合格"。
需要改的地方只有这一行:
javascript
const res = http.get('http://localhost:5000/weatherforecast');
把它改成你自己的接口地址。
补充:从 CSV / JSON 文件读取数据,动态传参给接口
真实压测时,很多接口不能每次都用同一组参数。例如:
- 查询天气时,每次传不同城市。
- 查询订单时,每次传不同订单号。
- 创建订单时,每次传不同商品、数量、用户。
- 登录接口时,每个虚拟用户使用不同账号。
这时可以把测试数据放在文件里,让 k6 脚本读取文件,再把数据传给接口。
注意:这里说的是 CSV 文件,不是 CVS。CSV 是一种常见表格文本格式,Excel 也可以另存为 CSV。
1. 文件应该放在哪里
建议把脚本和数据文件放在同一个目录:
text
C:\load-test-demo
├─ stress_test.js
├─ users.csv
└─ orders.json
运行时先进入这个目录:
powershell
cd C:\load-test-demo
k6 run stress_test.js
k6 脚本里的:
javascript
open('./users.csv')
就是从当前脚本目录读取 users.csv。
2. 准备一个 CSV 文件
创建文件:
text
C:\load-test-demo\users.csv
内容示例:
csv
city,days,userId
Beijing,3,1001
Shanghai,5,1002
Hangzhou,7,1003
Shenzhen,2,1004
第一行是表头,后面每一行是一组测试数据。
3. GET 接口从 CSV 读取参数
创建或修改 stress_test.js:
javascript
// 导入 k6 的 HTTP 请求模块
import http from 'k6/http';
// check 用来断言响应结果,sleep 用来模拟用户停顿
import { check, sleep } from 'k6';
// SharedArray 用来让所有虚拟用户共享同一份测试数据,避免重复读取文件
import { SharedArray } from 'k6/data';
// 压测配置:分阶段加压,并设置通过标准
export const options = {
stages: [
// 30 秒内逐步增加到 20 个虚拟用户
{ duration: '30s', target: 20 },
// 保持或增加到 50 个虚拟用户,观察系统是否稳定
{ duration: '1m', target: 50 },
// 压测结束前逐步降到 0
{ duration: '30s', target: 0 },
],
thresholds: {
// 95% 的请求必须在 500ms 内完成
http_req_duration: ['p(95)<500'],
// 请求失败率必须小于 1%
http_req_failed: ['rate<0.01'],
},
};
// 被压测服务的基础地址,实际使用时改成你的接口地址
const BASE_URL = 'http://localhost:5000';
// 读取 CSV 测试数据。open() 必须放在初始化阶段,不能放到 default 函数里反复读
const users = new SharedArray('users from csv', function () {
// 读取 users.csv 文件内容
const text = open('./users.csv');
// 按行拆分,兼容 Windows / Linux 换行
const lines = text.trim().split(/\r?\n/);
// 跳过第一行表头,把每一行转换成对象
return lines.slice(1).map((line) => {
// 简单 CSV 可以直接按英文逗号拆分
const columns = line.split(',');
return {
city: columns[0],
days: Number(columns[1]),
userId: columns[2],
};
});
});
// 每个虚拟用户都会反复执行这个函数
export default function () {
// 按虚拟用户编号和循环次数取一行数据,取完后从头循环
const row = users[(__VU + __ITER) % users.length];
// URL 参数里如果包含中文或特殊字符,需要编码
const city = encodeURIComponent(row.city);
// 把 CSV 中读取到的数据拼到 Query 参数里
const res = http.get(
`${BASE_URL}/api/weather?city=${city}&days=${row.days}&userId=${row.userId}`
);
// 校验接口是否返回 200
check(res, {
'status is 200': (r) => r.status === 200,
});
// 每次请求后停顿 1 秒,模拟真实用户操作间隔
sleep(1);
}
这段里最重要的是:
javascript
const row = users[(__VU + __ITER) % users.length];
它的意思是:每个虚拟用户、每次循环都从 CSV 里取一行数据。取到最后一行后,再从第一行继续取。
4. POST 接口从 CSV 读取 Body 参数
如果你的接口是创建订单,可以准备:
text
C:\load-test-demo\orders.csv
内容示例:
csv
productName,price,quantity,userId
测试商品A,99.9,1,1001
测试商品B,199.9,2,1002
测试商品C,59.9,3,1003
脚本示例:
javascript
// 导入 k6 的 HTTP 请求模块
import http from 'k6/http';
// check 用来断言响应结果,sleep 用来模拟用户停顿
import { check, sleep } from 'k6';
// SharedArray 用来共享 CSV 数据,避免每个虚拟用户都重复读取文件
import { SharedArray } from 'k6/data';
// 压测配置:先升到 20 个虚拟用户,再升到 50,最后降到 0
export const options = {
stages: [
{ duration: '30s', target: 20 },
{ duration: '1m', target: 50 },
{ duration: '30s', target: 0 },
],
};
// 被压测服务的基础地址,实际使用时改成你的接口地址
const BASE_URL = 'http://localhost:5000';
// 读取订单 CSV 数据
const orders = new SharedArray('orders from csv', function () {
// 读取 orders.csv 文件内容
const text = open('./orders.csv');
// 按行拆分
const lines = text.trim().split(/\r?\n/);
// 跳过表头,把 CSV 每一行转换成订单对象
return lines.slice(1).map((line) => {
const columns = line.split(',');
return {
productName: columns[0],
price: Number(columns[1]),
quantity: Number(columns[2]),
userId: columns[3],
};
});
});
// 每个虚拟用户都会反复执行这个函数
export default function () {
// 从 CSV 中取一条订单数据,取完后循环使用
const order = orders[(__VU + __ITER) % orders.length];
// 组装 POST 请求的 JSON Body
const payload = JSON.stringify({
productName: order.productName,
price: order.price,
quantity: order.quantity,
userId: order.userId,
});
// 设置请求头,告诉 WebApi 当前请求体是 JSON
const params = {
headers: {
'Content-Type': 'application/json',
},
};
// 向创建订单接口发起 POST 请求
const res = http.post(`${BASE_URL}/api/orders`, payload, params);
// 校验创建是否成功
check(res, {
'create success': (r) => r.status === 200 || r.status === 201,
});
// 每次请求后停顿 1 秒
sleep(1);
}
5. 从 JSON 文件读取数据
如果数据层级比较复杂,JSON 比 CSV 更适合。
创建文件:
text
C:\load-test-demo\orders.json
内容示例:
json
[
{
"productName": "测试商品A",
"price": 99.9,
"quantity": 1,
"userId": "1001"
},
{
"productName": "测试商品B",
"price": 199.9,
"quantity": 2,
"userId": "1002"
}
]
k6 读取 JSON:
javascript
// 导入 k6 的 HTTP 请求模块
import http from 'k6/http';
// check 用来断言响应结果,sleep 用来模拟用户停顿
import { check, sleep } from 'k6';
// SharedArray 用来共享 JSON 数据
import { SharedArray } from 'k6/data';
// 被压测服务的基础地址,实际使用时改成你的接口地址
const BASE_URL = 'http://localhost:5000';
// 读取 orders.json,并把 JSON 字符串转换成 JavaScript 数组
const orders = new SharedArray('orders from json', function () {
return JSON.parse(open('./orders.json'));
});
// 每个虚拟用户都会反复执行这个函数
export default function () {
// 从 JSON 数组中取一条订单数据,取完后循环使用
const order = orders[(__VU + __ITER) % orders.length];
// 直接把 JSON 对象作为 POST Body 发送
const res = http.post(
`${BASE_URL}/api/orders`,
JSON.stringify(order),
{
// 设置请求头,告诉 WebApi 当前请求体是 JSON
headers: {
'Content-Type': 'application/json',
},
}
);
// 校验创建是否成功
check(res, {
'create success': (r) => r.status === 200 || r.status === 201,
});
// 每次请求后停顿 1 秒
sleep(1);
}
6. 几个必须注意的点
open()只能放在脚本初始化阶段,不能放进export default function () { ... }里面反复读取。SharedArray可以让多个虚拟用户共享同一份数据,避免每个虚拟用户都重复加载文件。- 简单 CSV 可以用
line.split(',')解析。 - 如果 CSV 里包含英文逗号、双引号、换行等复杂内容,建议改用 JSON,或者使用专门的 CSV 解析库。
- 数据文件不要放真实生产账号、真实密码、真实手机号、真实身份证号。
- 压测数据最好使用测试环境专用账号和测试环境专用业务数据。
步骤 3:运行前先确认脚本存在
在 PowerShell 里执行:
powershell
cd C:\load-test-demo
ls stress_test.js
如果能看到 stress_test.js,说明路径正确。
如果提示找不到文件,说明你当前目录不对,或者文件没有保存到这个目录。
步骤 4:运行压测
执行:
powershell
k6 run stress_test.js
如果脚本路径包含空格或中文,建议用引号:
powershell
k6 run "C:\Users\Gabirel\My Documents\stress_test.js"
也可以直接写完整路径:
powershell
k6 run C:\load-test-demo\stress_test.js
只跑一次迭代(1个用户,1次请求)适合调试打断点:
powershell
k6 run -u 1 -i 1 stress_test.js
常见错误 1:Could not open file
原因:k6 找不到脚本文件。
检查方法:
powershell
pwd
ls
解决方法:
- 先
cd到脚本所在目录,再执行k6 run stress_test.js。 - 或者直接执行
k6 run 脚本完整路径。
常见错误 2:请求全部失败
如果 k6 能跑,但 checks 全部失败,先确认接口能不能访问:
powershell
curl http://localhost:5000/weatherforecast
如果 curl 也访问不了,说明不是 k6 的问题,而是 WebApi 没启动、端口不对、路径不对或接口报错。
第四篇:看懂 k6 压测结果
k6 跑完后,你会看到类似结果:
text
checks.........................: 100.00% 1200 out of 1200
http_req_duration..............: avg=80ms min=12ms med=60ms p(90)=140ms p(95)=220ms
http_req_failed................: 0.00% 0 out of 1200
http_reqs......................: 1200 20/s
vus............................: 100
重点看这几项:
| 字段 | 含义 | 重点 |
|---|---|---|
| checks | 断言是否通过 | 最好 100% |
| http_req_duration | 响应时间 | 重点看 p(95)、p(99) |
| http_req_failed | 请求失败率 | 通常希望小于 1% |
| http_reqs rate | 每秒请求数 | 这就是实际吞吐 |
| vus | 虚拟用户数 | 当前模拟多少用户 |
新手判断表
| 现象 | 说明 | 下一步 |
|---|---|---|
| checks 不是 100% | 有请求没有返回预期结果 | 看状态码和接口日志 |
| http_req_failed 很高 | 错误率高 | 先排查 401、404、500、超时 |
| p95 越来越高 | 大部分用户开始变慢 | 看 CPU、数据库、线程池 |
| vus 增加但 http_reqs rate 不增加 | 吞吐上不去了 | 基本到瓶颈点了 |
| avg 不高但 p99 很高 | 少量请求特别慢 | 看慢 SQL、GC、第三方接口 |
什么叫"压到瓶颈"
假设你的目标是:
text
P95 < 500ms
错误率 < 1%
当你压到 100 个虚拟用户时,结果变成:
text
p(95)=1200ms
http_req_failed=3%
这说明 100 个虚拟用户已经超过系统当前承载能力。这个点就可以记录为:
text
系统在 100 VU 左右开始不达标。
第五篇:真实接口压测:Token、POST、Body、业务链路
真实项目很少只有一个 GET 接口,通常会有登录、Token、POST Body、创建订单、查询订单等流程。
1. GET 请求带 Query 参数
javascript
import http from 'k6/http';
import { check } from 'k6';
export default function () {
const city = encodeURIComponent('Beijing');
const res = http.get(`http://localhost:5000/api/weather?city=${city}&days=3`);
check(res, {
'status is 200': (r) => r.status === 200,
});
}
2. 固定 Bearer Token 请求
如果你已经有测试 Token,可以直接写进请求头:
javascript
import http from 'k6/http';
import { check } from 'k6';
const params = {
headers: {
Authorization: 'Bearer 你的Token',
'Content-Type': 'application/json',
},
};
export default function () {
const res = http.get('http://localhost:5000/api/orders', params);
check(res, {
'status is 200': (r) => r.status === 200,
});
}
3. 先登录拿 Token,再请求业务接口
大多数系统的 Token 来自登录接口。k6 可以用 setup() 在压测开始前登录一次。
javascript
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
// stages 表示压测时的用户数变化过程。
// duration 是持续时间,target 是目标虚拟用户数。
// 这段配置适合验证"登录拿 Token 后访问接口"这种中等压力场景。
stages: [
{ duration: '30s', target: 20 }, // 30 秒内逐步加到 20 个用户,先确认接口和 Token 流程正常
{ duration: '1m', target: 50 }, // 保持或加到 50 个用户,观察接口响应时间和错误率
{ duration: '30s', target: 0 }, // 30 秒内降到 0,结束压测
],
};
export function setup() {
const loginRes = http.post(
'http://localhost:5000/api/auth/login',
JSON.stringify({
username: 'testuser',
password: 'P@ssw0rd',
}),
{
headers: {
'Content-Type': 'application/json',
},
}
);
check(loginRes, {
'login success': (r) => r.status === 200,
});
const token = loginRes.json('data.token');
return {
token,
};
}
export default function (data) {
const params = {
headers: {
Authorization: `Bearer ${data.token}`,
'Content-Type': 'application/json',
},
};
const res = http.get('http://localhost:5000/api/orders', params);
check(res, {
'get orders 200': (r) => r.status === 200,
});
sleep(1);
}
注意这一行:
javascript
const token = loginRes.json('data.token');
它必须按你的登录接口返回结构来改。
如果接口返回:
json
{
"access_token": "xxx"
}
就改成:
javascript
const token = loginRes.json('access_token');
4. POST JSON Body
javascript
import http from 'k6/http';
import { check } from 'k6';
export default function (data) {
const payload = JSON.stringify({
productName: '压测商品',
price: 99.9,
quantity: 2,
});
const params = {
headers: {
Authorization: `Bearer ${data.token}`,
'Content-Type': 'application/json',
},
};
const res = http.post('http://localhost:5000/api/orders', payload, params);
check(res, {
'create success': (r) => r.status === 200 || r.status === 201,
});
}
重点:
- Body 要用
JSON.stringify(...)。 - Header 要带
'Content-Type': 'application/json'。 - 否则 .NET WebApi 可能收到
null,返回400或415。
5. 完整业务链路脚本
这个例子模拟一个用户完整流程:
text
登录 -> 创建订单 -> 查询订单 -> 删除订单
javascript
import http from 'k6/http';
import { check, sleep, group } from 'k6';
export const options = {
// stages 表示完整业务链路的加压节奏。
// 业务链路通常比单接口更重,所以不要一上来就设置太大的 target。
// 可以先从 20、50、100 开始,确认稳定后再提高到 200、500。
stages: [
{ duration: '30s', target: 20 }, // 30 秒内加到 20 个虚拟用户,先让系统预热
{ duration: '2m', target: 100 }, // 2 分钟内逐步加到并保持 100 个用户,用来观察完整业务链路的稳定性
{ duration: '30s', target: 0 }, // 压测结束,逐步把用户数降到 0
],
thresholds: {
// p(95)<500 表示 95% 的完整业务请求要在 500ms 内完成。
// 如果链路包含创建订单、查数据库、调第三方接口,500ms 可能比较严格,可以按业务要求调整。
http_req_duration: ['p(95)<500'],
// rate<0.01 表示整体失败率小于 1%。
// 失败包括接口返回 4xx/5xx、超时、网络错误等。
http_req_failed: ['rate<0.01'],
},
};
const BASE = 'http://localhost:5000';
export function setup() {
const res = http.post(
`${BASE}/api/auth/login`,
JSON.stringify({
username: 'testuser',
password: 'P@ssw0rd',
}),
{
headers: {
'Content-Type': 'application/json',
},
}
);
check(res, {
'login 200': (r) => r.status === 200,
});
return {
token: res.json('data.token'),
};
}
export default function (data) {
const params = {
headers: {
Authorization: `Bearer ${data.token}`,
'Content-Type': 'application/json',
},
};
let orderId;
group('创建订单', function () {
const payload = JSON.stringify({
productName: '压测商品',
price: 99.9,
quantity: 1,
});
const res = http.post(`${BASE}/api/orders`, payload, params);
check(res, {
'create 200 or 201': (r) => r.status === 200 || r.status === 201,
});
orderId = res.json('data.id');
});
group('查询订单', function () {
const res = http.get(`${BASE}/api/orders/${orderId}`, params);
check(res, {
'get 200': (r) => r.status === 200,
});
});
group('删除订单', function () {
const res = http.del(`${BASE}/api/orders/${orderId}`, null, params);
check(res, {
'delete ok': (r) => r.status === 200 || r.status === 204,
});
});
sleep(1);
}
第六篇:找到瓶颈
k6 告诉你"慢了"或"失败了",但不会直接告诉你"为什么慢"。这时要配合 .NET 诊断工具。
先看这个定位流程
text
压测结果不达标
|
+-- 错误率高?
| |
| +-- 是:先看状态码、接口日志、数据库错误、认证失败
|
+-- 响应时间高?
|
+-- 看 dotnet-counters
|
+-- CPU 接近 100%:用 dotnet-trace 抓火焰图
|
+-- CPU 不高但延迟高:看线程池、数据库、Redis、第三方接口
|
+-- 内存持续上涨:用 dotnet-dump 抓内存快照
场景 A:CPU 高
先找到 WebApi 进程 ID:
powershell
dotnet-counters ps
正常会看到类似:
text
12345 MyWebApi C:\...\MyWebApi.dll
假设 PID 是 12345。
打开另一个 PowerShell,边压测边监控:
powershell
dotnet-counters monitor --process-id 12345 --counters System.Runtime,Microsoft.AspNetCore.Hosting
重点看:
text
cpu-usage (%)
working-set (MB)
gc-heap-size (MB)
threadpool-queue-length
requests-per-second
判断:
| 现象 | 说明 |
|---|---|
| cpu-usage 接近 100 | CPU 很可能是瓶颈 |
| threadpool-queue-length 持续大于 0 | 线程池可能排队 |
| gc-heap-size 持续上涨 | 可能有内存问题 |
| requests-per-second 不再上涨 | 吞吐到顶了 |
当 CPU 很高时,抓火焰图:
powershell
dotnet-trace collect --process-id 12345 --providers Microsoft-DotNETCore-SampleProfiler
压测 30 秒左右后,按 Enter 停止。会生成一个 .nettrace 文件。
转换为 speedscope 格式:
powershell
dotnet-trace convert trace.nettrace --format speedscope
然后打开:
text
https://www.speedscope.app/
把 .speedscope.json 文件拖进去。
怎么看火焰图:
- 横向越宽,表示耗时越多。
- 最宽的函数通常就是 CPU 热点。
- 如果看到 JSON 序列化、复杂 LINQ、加密计算等特别宽,就优先排查这些代码。
场景 B:CPU 不高,但接口很慢
这种通常是"等待型瓶颈",常见原因:
- 等数据库。
- 等 Redis。
- 等第三方 HTTP 接口。
- 线程池被同步阻塞拖住。
- 数据库连接池不够。
先看 dotnet-counters 里的:
text
threadpool-queue-length
如果这个值持续大于 0,说明线程池有排队。
常见代码问题:
csharp
var result = SomeAsyncMethod().Result;
或者:
csharp
SomeAsyncMethod().Wait();
建议改成:
csharp
var result = await SomeAsyncMethod();
场景 C:要看清楚慢在 DB、Redis 还是第三方接口
这时推荐接入 OpenTelemetry。
先安装 NuGet 包:
powershell
dotnet add package OpenTelemetry.Extensions.Hosting
dotnet add package OpenTelemetry.Instrumentation.AspNetCore
dotnet add package OpenTelemetry.Instrumentation.Http
dotnet add package OpenTelemetry.Instrumentation.EntityFrameworkCore
dotnet add package OpenTelemetry.Exporter.Console
然后在 Program.cs 里加入:
csharp
using OpenTelemetry.Trace;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenTelemetry()
.WithTracing(tracing => tracing
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddEntityFrameworkCoreInstrumentation()
.AddConsoleExporter());
var app = builder.Build();
app.MapControllers();
app.Run();
如果你的 Program.cs 已经有很多代码,不要照抄整个文件,只需要加两部分:
第一部分是 using:
csharp
using OpenTelemetry.Trace;
第二部分是 builder.Services... 这一段,放在 var builder = WebApplication.CreateBuilder(args); 后面。
启动 WebApi 后,控制台会输出每个请求的 span 信息。压测时你可以看到:
text
HTTP GET /api/orders 80ms
SQL SELECT Orders 60ms
HTTP GET third-party 10ms
这就能判断慢在哪里。
进阶可以接 Jaeger 图形化查看链路:
powershell
docker run -p 16686:16686 -p 4317:4317 jaegertracing/all-in-one
然后把 Console Exporter 换成 OTLP Exporter,再打开:
text
http://localhost:16686
场景 D:内存一直涨
如果压测时发现内存一直上涨,先抓 dump:
powershell
dotnet-dump collect --process-id 12345
分析 dump:
powershell
dotnet-dump analyze 生成的dump文件名
进入分析界面后输入:
text
dumpheap -stat
重点看:
- 哪个类型对象数量最多。
- 哪个类型占用内存最大。
- 是否有缓存对象一直不释放。
例如看到:
text
MyWebApi.CacheItem 500000
就要检查缓存是否没有设置过期时间。
第七篇:第一次完整实战流程
这一篇把前面的内容串成一个完整流程。第一次压测可以照着走。
目标
压测接口:
text
http://localhost:5000/weatherforecast
验收目标:
text
P95 < 500ms
错误率 < 1%
操作步骤
- 启动 WebApi。
- 用浏览器或
curl确认接口能访问。 - 创建
C:\load-test-demo\stress_test.js。 - 修改脚本里的接口地址。
- 打开第一个 PowerShell,执行
dotnet-counters ps找到 PID。 - 打开第二个 PowerShell,执行
dotnet-counters monitor ...监控资源。 - 打开第三个 PowerShell,执行
k6 run stress_test.js开始压测。 - 记录 k6 结果里的 RPS、P95、错误率。
- 记录 dotnet-counters 里的 CPU、内存、线程池排队。
- 根据结果判断瓶颈。
结果记录表
建议每次压测都记录下来,方便对比优化前后效果。
| 虚拟用户数 | RPS | P95 | 错误率 | CPU | 结论 |
|---|---|---|---|---|---|
| 20 | 18/s | 80ms | 0% | 20% | 正常 |
| 50 | 45/s | 180ms | 0% | 55% | 正常 |
| 100 | 70/s | 900ms | 2% | 95% | CPU 瓶颈 |
怎么写结论
可以这样写:
text
本次压测目标是 P95 < 500ms,错误率 < 1%。
20 VU 和 50 VU 时系统正常。
100 VU 时 P95 达到 900ms,错误率达到 2%,CPU 接近 95%。
结论:当前系统在 100 VU 左右开始不达标,主要瓶颈疑似 CPU。
下一步:使用 dotnet-trace 抓火焰图,定位 CPU 热点函数。
第八篇:.NET WebApi 常见瓶颈自查
压测前可以先排查这些经典问题,否则测出来的瓶颈可能是低级错误。
| 问题 | 现象 | 建议修法 |
|---|---|---|
| 每次请求都 new HttpClient | socket 耗尽、连接数暴涨 | 使用 IHttpClientFactory |
.Result / .Wait() |
CPU 不高但吞吐上不去 | 全链路 async/await |
| EF Core N+1 查询 | SQL 数量很多,DB 慢 | 批量查询、Include、投影查询 |
| EF Core 查询未使用 AsNoTracking | 查询对象多,内存压力大 | 只读查询加 AsNoTracking |
| 热路径里使用大锁 | 并发上不去 | 减小锁粒度或使用并发集合 |
| 大对象频繁分配 | GC 频繁,P99 高 | 减少大对象分配,必要时对象池 |
| 日志过多 | 压测时磁盘 IO 高 | 降低日志级别,避免热路径大量写日志 |
| 数据库连接池太小 | 请求排队或超时 | 检查连接池配置和慢 SQL |
附录 A:k6 请求写法速查
| 场景 | 写法 |
|---|---|
| GET | http.get(url, params) |
| POST JSON | http.post(url, JSON.stringify(body), params) |
| PUT JSON | http.put(url, JSON.stringify(body), params) |
| DELETE | http.del(url, null, params) |
| 带授权头 | headers: { Authorization: 'Bearer ' + token } |
| 取返回字段 | res.json('data.token') |
| 校验状态码 | check(res, { 'ok': (r) => r.status === 200 }) |
| 分组统计 | group('名称', function () { ... }) |
| 模拟用户思考时间 | sleep(1) |
附录 B:工具速查表
| 工具 | 用途 | 关键命令 |
|---|---|---|
| k6 | 发压、出报告 | k6 run stress_test.js |
| dotnet-counters ps | 查 .NET 进程 PID | dotnet-counters ps |
| dotnet-counters | 实时看 CPU、内存、GC、线程池 | dotnet-counters monitor --process-id <pid> --counters System.Runtime,Microsoft.AspNetCore.Hosting |
| dotnet-trace | 抓 CPU 火焰图 | dotnet-trace collect --process-id <pid> --providers Microsoft-DotNETCore-SampleProfiler |
| dotnet-dump | 抓内存快照 | dotnet-dump collect --process-id <pid> |
附录 C:核心名词速查
| 名词 | 解释 |
|---|---|
| TPS / RPS | 每秒处理请求数 |
| RT | 响应时间 |
| P95 | 95% 的请求都快于这个耗时 |
| P99 | 99% 的请求都快于这个耗时 |
| VU | Virtual User,虚拟用户 |
| GC | .NET 垃圾回收 |
| STW | Stop The World,GC 等场景造成的暂停 |
| 线程池饥饿 | 请求排队等线程,吞吐上不去 |
| N+1 查询 | 循环里逐条查库,导致 SQL 数量暴增 |
| Span | 链路追踪里的一段调用 |
| Trace | 一次请求的完整调用链 |
最后总结
第一次做压测,不要一上来追求复杂。按这个顺序来:
- 先让接口能访问。
- 再让 k6 脚本能跑。
- 然后看懂 P95、RPS、错误率。
- 最后用 dotnet-counters、dotnet-trace、dotnet-dump 定位问题。
完整流程是:
text
能访问 -> 能压测 -> 会读结果 -> 找到瓶颈 -> 修改代码 -> 重新压测验证
只要能跑完这个闭环,就已经完成了一次标准的 .NET WebApi 全链路压测。
Linux + Docker 容器专项篇:压测容器里的 .NET WebApi
面向第一次做压力测试、第一次在 Linux + Docker 环境里定位 .NET WebApi 性能瓶颈的开发工程师。本文从环境确认开始,带你完成:确认容器服务可访问、安装 k6、安装 .NET 诊断工具、编写压测脚本、运行压测、监听容器内 .NET 性能指标、判断瓶颈。
适合谁看
如果你遇到下面任意一种情况,这篇文档就是给你的:
- 你的 .NET Core / ASP.NET Core 项目已经发布到 Linux 服务器。
- 项目是通过 Docker 容器方式运行的。
- 你想知道接口能扛多少并发、P95 多高、错误率是否达标。
- 你想在压测时监听容器里的 .NET 运行时指标,比如 CPU、内存、GC、线程池、请求速率。
- 你希望有一份可以照着敲命令的完整教程。
本文默认环境:
- Linux 服务器
- Docker
- Docker 容器中运行 .NET 8 或更高版本 WebApi
- k6 作为压力测试工具
- dotnet-counters、dotnet-trace、dotnet-dump 作为 .NET 诊断工具
推荐架构:
text
压测机运行 k6
|
v
Linux 服务器 IP:宿主机端口
|
v
Docker 端口映射
|
v
容器内 ASP.NET Core WebApi
推荐不要在被压测的容器里运行 k6。小规模验证可以同机执行,大规模压测建议使用独立压测机,避免 k6 自身消耗 CPU、内存和网络,影响结果判断。
第一篇:先搞懂压测对象
1. 这次到底在压什么
本文压测的不是"Linux 操作系统本身",而是:
text
Linux 服务器上的 Docker 容器里运行的 .NET WebApi 接口
例如容器内服务监听:
text
http://0.0.0.0:8080
宿主机端口映射:
text
5000 -> 8080
那么外部压测地址就是:
text
http://Linux服务器IP:5000/你的接口
2. 什么是全链路压测
真实业务请求通常不是只访问 WebApi 本身,而是会经过网关、缓存、数据库、第三方接口等组件。
text
k6 请求
-> Linux 宿主机端口
-> Docker 容器
-> WebApi
-> Redis
-> 数据库
-> 第三方 HTTP 服务
全链路压测的意思是:尽量模拟真实业务请求,让整个调用链都参与进来。这样才能发现真实瓶颈。
常见瓶颈包括:
- WebApi CPU 打满。
- .NET GC 频繁。
- 线程池排队。
- 数据库慢查询。
- Redis 连接不够。
- 第三方接口限流或超时。
- Docker 容器内存限制太小。
- Linux 宿主机 CPU、网络或磁盘 IO 成为瓶颈。
3. 压测时重点看哪些指标
| 指标 | 含义 | 重点 |
|---|---|---|
| RPS / TPS | 每秒处理多少请求 | 越高说明吞吐越好 |
| RT | 响应时间 | 单个请求耗时 |
| P95 | 95% 请求小于这个耗时 | 比平均值更有参考意义 |
| P99 | 99% 请求小于这个耗时 | 观察极端慢请求 |
| 错误率 | 请求失败比例 | 通常希望小于 1% |
| CPU | 容器或进程 CPU 使用率 | 判断是否 CPU 瓶颈 |
| 内存 | 容器或进程内存占用 | 判断是否泄漏或内存限制过小 |
| GC | .NET 垃圾回收 | 判断内存分配压力 |
| 线程池队列 | ThreadPool 排队情况 | 判断是否阻塞或线程池饥饿 |
一句话总结:
text
不断增加虚拟用户数,观察系统从什么时候开始 P95 变高、错误率升高、吞吐不再增长。
第二篇:确认 Docker 中的 .NET 服务可以访问
在压测前,先确认容器服务本身正常。
步骤 1:查看容器
在 Linux 服务器上执行:
bash
docker ps
你应该能看到类似结果:
text
CONTAINER ID IMAGE PORTS NAMES
abc123 my-api:latest 0.0.0.0:5000->8080/tcp my-api
这里说明:
text
宿主机 5000 端口 -> 容器 8080 端口
步骤 2:确认 ASP.NET Core 监听地址
容器里的 ASP.NET Core 建议监听:
text
http://0.0.0.0:8080
不要只监听:
text
http://localhost:8080
因为容器内的 localhost 只代表容器自己,外部可能访问不到。
推荐启动方式:
bash
docker run -d \
--name my-api \
-p 5000:8080 \
-e ASPNETCORE_URLS=http://0.0.0.0:8080 \
your-api-image
如果使用 Docker Compose:
yaml
services:
my-api:
image: your-api-image
container_name: my-api
ports:
- "5000:8080"
environment:
ASPNETCORE_URLS: "http://0.0.0.0:8080"
步骤 3:在 Linux 宿主机上访问接口
bash
curl http://127.0.0.1:5000/weatherforecast
如果能看到 JSON 返回,说明宿主机访问容器成功。
步骤 4:在压测机上访问接口
在你的 Windows 电脑或另一台压测机上访问:
bash
curl http://Linux服务器IP:5000/weatherforecast
如果这里访问失败,先检查:
- Linux 防火墙是否放行 5000 端口。
- 云服务器安全组是否放行 5000 端口。
- Docker 端口映射是否正确。
- ASP.NET Core 是否监听
0.0.0.0。 - 接口路径是否正确。
第三篇:安装 k6 压测工具
k6 可以安装在 Windows、Linux 或用 Docker 运行。推荐安装在独立压测机。
方式 A:Linux 压测机安装 k6
Debian / Ubuntu:
bash
sudo gpg -k
curl -fsSL https://dl.k6.io/key.gpg | sudo gpg --dearmor -o /usr/share/keyrings/k6-archive-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/k6-archive-keyring.gpg] https://dl.k6.io/deb stable main" | sudo tee /etc/apt/sources.list.d/k6.list
sudo apt-get update
sudo apt-get install k6
验证:
bash
k6 version
方式 B:Windows 压测机安装 k6
如果你已经安装 Chocolatey:
powershell
choco install k6 -y
或者使用 winget:
powershell
winget install k6 --source winget
验证:
powershell
k6 version
方式 C:用 Docker 运行 k6
Linux 上可以直接用 k6 Docker 镜像:
bash
docker pull grafana/k6
运行脚本时:
bash
docker run --rm -i grafana/k6 run - < stress_test.js
如果 k6 容器需要访问宿主机或内网服务,要特别注意网络连通性。
第四篇:在容器内安装 .NET 诊断工具
这是本文最关键的部分。
你的目标是:
text
进入运行 WebApi 的 Docker 容器
确认容器是否具备安装 dotnet-counters / dotnet-trace / dotnet-dump 的条件
监听容器内 .NET 进程
步骤 1:进入容器
bash
docker exec -it my-api sh
如果镜像里有 bash,也可以:
bash
docker exec -it my-api bash
步骤 2:确认 dotnet 可用
bash
dotnet --info
如果能看到 .NET Runtime 或 SDK 信息,说明容器里可以执行 dotnet。
重点看这一段:
text
.NET SDKs installed:
如果显示类似:
text
.NET SDKs installed:
8.0.xxx
说明当前容器里有 SDK,可以继续安装 dotnet-counters、dotnet-trace、dotnet-dump。
如果显示:
text
.NET SDKs installed:
No SDKs were found.
说明当前容器是生产 Runtime 镜像,只能运行 .NET 程序,通常不能直接安装 .NET 全局诊断工具。
这时你继续执行:
bash
dotnet tool install --global dotnet-counters
大概率会看到:
text
No .NET SDKs were found.
如果执行:
bash
dotnet-counters ps
看到:
text
dotnet-counters: not found
也说明当前容器里还没有这个工具。这不是命令写错,而是镜像里没有 SDK,也没有预装诊断工具。
遇到这种情况,直接跳到后面的"方案 A:测试环境使用 SDK 镜像"或"方案 B:多阶段构建,把工具复制进 Runtime 镜像"。
步骤 3:安装诊断工具
只有当 dotnet --info 能看到 SDK 时,才建议在当前容器内执行:
bash
dotnet tool install --global dotnet-counters
dotnet tool install --global dotnet-trace
dotnet tool install --global dotnet-dump
加入 PATH:
bash
export PATH="$PATH:$HOME/.dotnet/tools"
验证:
bash
dotnet-counters --version
dotnet-trace --version
dotnet-dump --version
重要说明:生产 Runtime 镜像可能无法安装
很多生产镜像使用的是:
text
mcr.microsoft.com/dotnet/aspnet
这类镜像通常只有 ASP.NET Core Runtime,没有完整 SDK。此时 dotnet tool install 可能失败。
如果失败,不代表工具不能监听 .NET,只代表当前容器里没有安装全局工具需要的 SDK 能力。
你可以选择下面三种方案。
先记住这几条安全原则
为了避免误操作正式环境,先把规则说清楚:
- 不要直接修改原来的
Dockerfile。 原Dockerfile继续用于正式发布。 - 不要把诊断镜像打成
latest或生产正在使用的标签。 建议使用diagnostic、diagnostic-runtime这类明确标签。 - 不要随手
docker stop正在对外服务的生产容器。 如果不确定容器是不是生产环境,先问清楚或只读查看。 - 第一次练习建议在测试服务器、测试容器或空闲端口上做。
- 诊断容器建议使用新的容器名和新的宿主机端口。 例如原容器叫
my-api、端口是5000,诊断容器可以叫my-api-diagnostic、端口用5001。
下面的示例会默认使用:
text
正式容器名示例:my-api
诊断容器名示例:my-api-diagnostic
诊断镜像标签示例:my-api:diagnostic
诊断访问端口示例:5001
容器内部端口示例:8080
也就是说,压测诊断时访问:
text
http://Linux服务器IP:5001/你的接口
这样做的好处是:不影响原来正在运行的正式容器。
方案 A:测试环境使用 SDK 镜像,最适合第一次上手
如果你是第一次做压测和 .NET 性能监听,优先选这个方案。
这个方案的意思是:
text
原来生产容器:只有 Runtime,只能运行程序,不能安装 dotnet-counters
诊断版容器:使用 SDK 镜像,既能运行程序,也能安装诊断工具
它的优点是简单、直观、排错成本低。缺点是 SDK 镜像比 Runtime 镜像大,不要把它当作正式生产镜像长期使用后期打包运行贼慢。
第 1 步:先准备发布目录,Visual Studio 用户优先用右键发布
假设你的项目发布后文件在 publish 目录里,里面应该能看到类似文件:
text
MyWebApi.dll
appsettings.json
其他依赖 dll
如果你平时用 Visual Studio,推荐直接用 Visual Studio 发布,不需要手动敲 dotnet publish。
Visual Studio 操作方式:
text
右键 WebApi 项目
-> 发布
-> 选择"文件夹"
-> 指定一个发布目录,例如 D:\publish\MyWebApi
-> 点击发布
发布完成后,先看清楚你的 Dockerfile 和 MyWebApi.dll 到底在什么位置。这里最容易搞混。
常见有两种目录结构。
结构 1:Dockerfile 和 dll 在同一个目录,Visual Studio 发布后很常见
如果你看到的是这种结构:
text
当前目录
├─ Dockerfile
├─ MyWebApi.dll
├─ appsettings.json
└─ 其他 dll
那么后面新建的 Dockerfile.diagnostic 也应该放在这个目录里,和 MyWebApi.dll 同级。
这种结构下,Dockerfile 里复制程序文件时应该写:
dockerfile
COPY . .
结构 2:Dockerfile 在外层,发布文件在 publish 子目录
如果你自己整理成这种结构:
text
当前目录
├─ Dockerfile
└─ publish
├─ MyWebApi.dll
├─ appsettings.json
└─ 其他 dll
那么后面新建的 Dockerfile.diagnostic 放在外层,和 publish 文件夹同级。
这种结构下,Dockerfile 里复制程序文件时应该写:
dockerfile
COPY ./publish .
下面默认先按结构 1:Dockerfile 和 dll 同级来写,因为这是你用 Visual Studio 发布后更容易遇到的情况。
如果你已经在 Visual Studio 里右键项目添加过 Docker 支持,它通常会自动生成一个 Dockerfile。这个 Dockerfile 可以继续作为正式镜像的构建文件,不需要删除,也不需要为了诊断去改它。
如果你不用 Visual Studio,也可以用命令行发布。
Windows 上执行:
powershell
dotnet publish -c Release -o .\publish
Linux 上执行:
bash
dotnet publish -c Release -o ./publish
如果你按上面的命令行方式发布到 publish 子目录,那么你属于"结构 2",后面的 Dockerfile 示例里要使用:
dockerfile
COPY ./publish .
这里的重点是:先确认你属于上面哪种目录结构。Dockerfile.diagnostic 要放在能复制到 MyWebApi.dll 的那个构建目录里。
第 2 步:创建诊断版 Dockerfile
注意:这里是新建一个文件 ,不是在你原来的 Dockerfile 里追加内容。
如果你的 Dockerfile 是 Visual Studio 右键"添加 Docker 支持"生成的,也不要直接改它。那个文件通常用于正常运行和正式发布;诊断镜像需要安装 dotnet-counters 等工具,所以单独新建一个诊断专用 Dockerfile 更安全。
建议保留原来的正式 Dockerfile,再额外新增一个诊断专用文件:
text
当前目录
├─ Dockerfile # 原来的正式/生产镜像构建文件,不动它
├─ Dockerfile.diagnostic # 新建,专门用于压测和性能诊断
├─ MyWebApi.dll
├─ appsettings.json
└─ 其他 dll
也就是说,如果你的 Dockerfile 已经和 MyWebApi.dll 同级,就把 Dockerfile.diagnostic 也建在这个同级目录里:
text
Dockerfile.diagnostic
这个文件可以用任何方式创建:
text
方式 A:在 Visual Studio 里右键项目或解决方案 -> 添加 -> 新建项 -> 文本文件,然后命名为 Dockerfile.diagnostic
方式 B:在文件资源管理器里新建文本文件,再改名为 Dockerfile.diagnostic
方式 C:在 Linux 服务器上用 vi / vim / nano 创建 Dockerfile.diagnostic
以后正式发布仍然用原来的 Dockerfile;只有压测诊断时,才用 Dockerfile.diagnostic。
可以这样理解:
text
Dockerfile -> 正式镜像,给正常部署用
Dockerfile.diagnostic -> 诊断镜像,给压测监听用
第 2.1 步:先从正式 Dockerfile 里复制必要运行配置
Dockerfile.diagnostic 不是随便写一个能启动 dll 的文件。它应该尽量模拟正式容器的运行环境,只是在这个基础上额外加诊断工具。
可以按下面这张表判断哪些内容要从正式 Dockerfile 复制过来。
| 正式 Dockerfile 内容 | 诊断版是否要复制 | 原因 |
|---|---|---|
WORKDIR /app |
要 | 保持应用运行目录一致 |
EXPOSE 8080 |
建议复制 | 让容器内部端口说明一致 |
ENV ASPNETCORE_URLS=... |
要 | 保持监听地址和端口一致 |
ENV ASPNETCORE_ENVIRONMENT=... |
要 | 保持运行环境一致 |
ENV TZ=...、LANG=... |
要 | 保持时区、语言环境一致 |
RUN apt-get install ... |
要 | 如果正式环境安装了字体、图形库、证书、时区等依赖,诊断版也要有 |
COPY . . 或 COPY ./publish . |
要 | 保持发布文件复制方式一致 |
ENTRYPOINT ["dotnet", "...dll"] |
要 | 启动同一个 WebApi |
USER app |
看情况 | 如果正式环境用非 root 用户,诊断版也应尽量一致;但诊断工具可能需要权限,第一次排查可先不用 |
| 多阶段构建里的源码编译阶段 | 通常不用 | 这里针对的是已经发布好的目录,不需要重新编译源码 |
比如正式 Dockerfile 用的是:
dockerfile
ENV ASPNETCORE_URLS=http://+:8080
诊断版就继续用这行,不要一会儿写 http://+:8080,一会儿写 http://0.0.0.0:8080。两者通常都能表示监听容器内所有网卡,但压测诊断时最好和正式文件保持一致。
你的正式 Dockerfile 如果有类似 SkiaSharp、字体、中文渲染依赖:
dockerfile
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
fontconfig \
libfontconfig1 \
libfreetype6 \
fonts-wqy-zenhei \
&& rm -rf /var/lib/apt/lists/*
这段就必须复制到 Dockerfile.diagnostic 里。否则诊断容器和正式容器的运行环境不一致,压测结果可能不准,生成图片、海报、中文字体渲染也可能出问题。
第 2.2 步:诊断版必须额外添加的代码
诊断版和正式 Dockerfile 相比,必须额外添加的是这几类内容。
第一,把基础镜像从 Runtime 换成 SDK。比如正式环境是:
dockerfile
FROM mcr.microsoft.com/dotnet/aspnet:8.0-bookworm-slim AS final
诊断版方案 A 改成:
dockerfile
FROM mcr.microsoft.com/dotnet/sdk:8.0-bookworm-slim AS final
第二,安装 .NET 诊断工具:
dockerfile
RUN dotnet tool install --global dotnet-counters \
&& dotnet tool install --global dotnet-trace \
&& dotnet tool install --global dotnet-dump
第三,把 .NET 全局工具目录加入 PATH:
dockerfile
ENV PATH="$PATH:/root/.dotnet/tools"
简单说:
text
Dockerfile.diagnostic = 正式 Dockerfile 的运行配置 + SDK 基础镜像 + dotnet 诊断工具安装 + PATH
第 2.3 步:最简单的诊断版模板
下面这个版本适用于 Dockerfile.diagnostic 和 MyWebApi.dll 同级,并且正式 Dockerfile 没有特殊系统依赖的情况。你需要把 MyWebApi.dll 改成自己的 dll 名称。
dockerfile
FROM mcr.microsoft.com/dotnet/sdk:8.0
RUN dotnet tool install --global dotnet-counters \
&& dotnet tool install --global dotnet-trace \
&& dotnet tool install --global dotnet-dump
ENV PATH="$PATH:/root/.dotnet/tools"
WORKDIR /app
COPY . .
ENV ASPNETCORE_URLS=http://0.0.0.0:8080
ENTRYPOINT ["dotnet", "MyWebApi.dll"]
第 2.4 步:带系统依赖的诊断版示例
如果你的正式 Dockerfile 像这样安装了字体或图形依赖,诊断版也要保留这些依赖。下面是一个更贴近正式环境的示例:
dockerfile
# 诊断版 Dockerfile:
# 用于压测和性能诊断,不用于正式生产发布。
FROM mcr.microsoft.com/dotnet/sdk:8.0-bookworm-slim AS final
WORKDIR /app
EXPOSE 8080
# 这里保留正式 Dockerfile 里的系统依赖。
# 例如 SkiaSharp、图片生成、中文字体渲染需要这些包。
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
fontconfig \
libfontconfig1 \
libfreetype6 \
fonts-wqy-zenhei \
&& rm -rf /var/lib/apt/lists/*
# 诊断版额外安装 .NET 诊断工具。
RUN dotnet tool install --global dotnet-counters \
&& dotnet tool install --global dotnet-trace \
&& dotnet tool install --global dotnet-dump
# 让 dotnet-counters / dotnet-trace / dotnet-dump 可以直接执行。
ENV PATH="$PATH:/root/.dotnet/tools"
# 保持和正式容器一致的监听地址。
ENV ASPNETCORE_URLS=http://+:8080
# 你的发布文件和 Dockerfile.diagnostic 同级时使用 COPY . .
COPY . .
# 启动你的 WebApi,dll 名称要改成自己的项目 dll。
ENTRYPOINT ["dotnet", "Net8WebApiFramework.dll"]
如果你的目录是"外层 Dockerfile + publish 子目录",才把这一行:
dockerfile
COPY . .
改成:
dockerfile
COPY ./publish .
例如你的程序叫:
text
Yun.Api.dll
最后一行就改成:
dockerfile
ENTRYPOINT ["dotnet", "Yun.Api.dll"]
第 3 步:构建诊断版镜像
在 Dockerfile.diagnostic 所在目录执行:
bash
docker build -f Dockerfile.diagnostic -t my-api:diagnostic .
这条命令的意思是:
text
使用 Dockerfile.diagnostic 构建一个诊断版镜像
镜像名称叫 my-api
镜像标签叫 diagnostic
其中 -f Dockerfile.diagnostic 的意思是:
text
不要使用默认的 Dockerfile
本次构建明确使用 Dockerfile.diagnostic
第 4 步:运行诊断版容器
先确认当前有哪些容器:
bash
docker ps
如果看到原来的正式容器正在运行,例如:
text
my-api 0.0.0.0:5000->8080/tcp
不要为了测试诊断镜像就把它停掉。
建议启动一个新的诊断容器,使用新的容器名和新的宿主机端口:
bash
docker run -d \
--name my-api-diagnostic \
-p 5001:8080 \
my-api:diagnostic
这里的含义是:
text
my-api-diagnostic 是新诊断容器,不是原正式容器
宿主机 5001 端口 -> 诊断容器内 8080 端口
也就是说,压测诊断容器时访问:
text
http://Linux服务器IP:5001
如果你确认是在测试环境,并且原来的 my-api 只是测试容器,可以先删除旧测试容器再复用名字:
bash
docker stop my-api
docker rm my-api
docker run -d \
--name my-api \
-p 5000:8080 \
my-api:diagnostic
这个复用名字的做法只建议在测试环境使用。
这里的 my-api、my-api-diagnostic 都只是示例容器名,可以换成你自己的名字。5001:8080 是端口映射,也可以按你的项目实际端口调整。
端口映射格式固定是:
text
宿主机端口:容器内部端口
例如:
text
5001:8080
表示外部访问宿主机 5001,实际进入容器内 8080。
第 5 步:验证接口能访问
在 Linux 宿主机执行:
bash
curl http://127.0.0.1:5001/weatherforecast
如果你的接口不是 /weatherforecast,就换成自己的接口路径。
如果你使用的是 5000:8080,这里就访问 5000;如果你使用的是 5001:8080,这里就访问 5001。
第 6 步:进入容器验证诊断工具
bash
docker exec -it my-api-diagnostic sh
进入后执行:
bash
dotnet-counters --version
dotnet-trace --version
dotnet-dump --version
能看到版本号,就说明工具已经装好了。
再查看 .NET 进程:
bash
dotnet-counters ps
如果你的 WebApi 是容器主进程,通常可以直接监听 PID 1:
bash
dotnet-counters monitor --process-id 1 --counters System.Runtime,Microsoft.AspNetCore.Hosting
方案 A 常见问题
如果 docker build 时下载工具失败,多半是服务器不能访问 NuGet,需要检查网络、代理或源配置。
如果容器启动后访问不通,先看日志:
bash
docker logs my-api-diagnostic
再确认 ASPNETCORE_URLS 是:
text
http://0.0.0.0:8080
如果你只是第一次学习和压测,方案 A 已经足够好用。记住:它是诊断用,不是正式发布用。
方案 B:多阶段构建,把工具复制进 Runtime 镜像,更接近生产
如果你已经理解方案 A,并且希望最终容器仍然基于 Runtime 镜像,可以选方案 B。
这个方案的意思是:
text
第一阶段:临时使用 SDK 镜像,只负责下载 dotnet-counters / dotnet-trace / dotnet-dump
第二阶段:使用 Runtime 镜像运行 WebApi,并把第一阶段下载好的工具复制进来
它的优点是最终镜像比 SDK 方案更轻,更接近真实生产环境。缺点是 Dockerfile 稍微复杂一点。
第 1 步:准备发布目录,可以继续用 Visual Studio 发布
和方案 A 一样,先确认你的发布文件和 Dockerfile 是哪种目录结构。
如果你使用 Visual Studio:
text
右键 WebApi 项目
-> 发布
-> 选择"文件夹"
-> 指定发布目录
-> 点击发布
发布后如果你看到 Dockerfile 和 MyWebApi.dll 已经在同一个目录,就不需要再额外创建 publish 文件夹。
这种情况下目录应该像这样:
text
当前目录
├─ Dockerfile # 原来的正式/生产镜像构建文件,不动它
├─ Dockerfile.diagnostic-runtime # 新建,Runtime 诊断版镜像构建文件
├─ MyWebApi.dll
├─ appsettings.json
└─ 其他 dll
如果你喜欢把发布文件单独放在 publish 子目录,也可以整理成这样:
text
当前目录
├─ Dockerfile # 原来的正式/生产镜像构建文件,不动它
├─ Dockerfile.diagnostic-runtime # 新建,Runtime 诊断版镜像构建文件
└─ publish
├─ MyWebApi.dll
├─ appsettings.json
└─ 其他 dll
两种方式都可以。关键是 Dockerfile 里的 COPY 写法要和目录结构对应。
如果你使用命令行:
bash
dotnet publish -c Release -o ./publish
如果你按这条命令发布到 publish 子目录,那么后面的 Dockerfile 示例里要把:
dockerfile
COPY . .
改成:
dockerfile
COPY ./publish .
第 2 步:创建多阶段 Dockerfile
注意:这里同样是新建一个文件 ,不是在原来的 Dockerfile 里追加内容。
如果原来的 Dockerfile 是 Visual Studio 自动生成的,继续保留给正式发布使用。Dockerfile.diagnostic-runtime 只是为了压测诊断临时使用。
创建文件:
text
Dockerfile.diagnostic-runtime
方案 B 也要遵守同一个原则:
text
最终 Runtime 阶段 = 正式 Dockerfile 的运行配置 + 从 tools 阶段复制进来的诊断工具
也就是说,如果正式 Dockerfile 里有 EXPOSE、ENV、RUN apt-get install ...、WORKDIR、COPY、ENTRYPOINT,最终 Runtime 阶段也要尽量保持一致。
内容如下。这个版本适用于 Dockerfile.diagnostic-runtime 和 MyWebApi.dll 同级的情况。同样只需要把最后一行 MyWebApi.dll 改成你自己的 dll 名称。
dockerfile
# 第一阶段:只负责下载 .NET 诊断工具。
FROM mcr.microsoft.com/dotnet/sdk:8.0-bookworm-slim AS tools
RUN dotnet tool install --tool-path /tools dotnet-counters \
&& dotnet tool install --tool-path /tools dotnet-trace \
&& dotnet tool install --tool-path /tools dotnet-dump
# 第二阶段:仍然使用 Runtime 镜像运行 WebApi,更接近正式环境。
FROM mcr.microsoft.com/dotnet/aspnet:8.0-bookworm-slim
WORKDIR /app
# 如果正式 Dockerfile 有 EXPOSE,也复制过来。
EXPOSE 8080
# 如果正式 Dockerfile 有系统依赖,也复制过来。
# 例如 SkiaSharp、图片生成、中文字体渲染需要这些包。
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
fontconfig \
libfontconfig1 \
libfreetype6 \
fonts-wqy-zenhei \
&& rm -rf /var/lib/apt/lists/*
# 把第一阶段下载好的诊断工具复制到最终 Runtime 镜像里。
COPY --from=tools /tools /tools
# 你的发布文件和 Dockerfile.diagnostic-runtime 同级时使用 COPY . .
COPY . .
# 让 dotnet-counters / dotnet-trace / dotnet-dump 可以直接执行。
ENV PATH="$PATH:/tools"
# 保持和正式容器一致的监听地址。
ENV ASPNETCORE_URLS=http://+:8080
# 启动你的 WebApi,dll 名称要改成自己的项目 dll。
ENTRYPOINT ["dotnet", "Net8WebApiFramework.dll"]
如果你的目录是"外层 Dockerfile + publish 子目录",才把这一行:
dockerfile
COPY . .
改成:
dockerfile
COPY ./publish .
这里最关键的是两行:
dockerfile
COPY --from=tools /tools /tools
ENV PATH="$PATH:/tools"
第一行把 SDK 阶段下载好的诊断工具复制到最终 Runtime 镜像里。
第二行把 /tools 加入 PATH,这样进入容器后才能直接执行:
bash
dotnet-counters
dotnet-trace
dotnet-dump
第 3 步:构建多阶段诊断镜像
bash
docker build -f Dockerfile.diagnostic-runtime -t my-api:diagnostic-runtime .
第 4 步:运行容器
和方案 A 一样,推荐不要覆盖或停止原正式容器。启动一个新的 Runtime 诊断容器:
bash
docker run -d \
--name my-api-diagnostic-runtime \
-p 5001:8080 \
my-api:diagnostic-runtime
这里同样可以把 my-api-diagnostic-runtime 换成你的诊断容器名,把 5001:8080 换成你的宿主机端口和容器端口。
如果你的正式容器已经占用了宿主机 5000 端口,诊断容器就不要再用 5000,可以用 5001、15000 这类空闲端口。
第 5 步:验证工具是否存在
进入容器:
bash
docker exec -it my-api-diagnostic-runtime sh
执行:
bash
dotnet-counters --version
dotnet-trace --version
dotnet-dump --version
再监听 .NET 进程:
bash
dotnet-counters ps
dotnet-counters monitor --process-id 1 --counters System.Runtime,Microsoft.AspNetCore.Hosting
方案 B 常见问题
如果进入容器后仍然提示:
text
dotnet-counters: not found
优先检查 Dockerfile 里有没有这两行:
dockerfile
COPY --from=tools /tools /tools
ENV PATH="$PATH:/tools"
如果 dotnet-counters --version 可以执行,但 dotnet-counters ps 看不到进程,继续检查:
- 是否在运行 WebApi 的同一个容器里执行。
- 当前用户是否有权限访问 .NET 进程。
- 应用是否设置了
DOTNET_EnableDiagnostics=0。
第一次上手时建议先跑通方案 A;等流程熟悉后,再切到方案 B。
方案 C:使用 dotnet-monitor sidecar
dotnet-monitor 更适合长期或生产级诊断,可以作为 sidecar 容器采集 metrics、dump、trace、logs。
这个方案更专业,但配置比进入容器执行工具复杂。第一次压测时可以先用方案 A 或 B,确认流程跑通后再考虑。
第五篇:监听 Docker 容器和 .NET 进程
压测时建议同时开三个终端。
终端 1:看容器整体资源
在 Linux 宿主机上执行:
bash
docker stats my-api
重点看:
| 字段 | 含义 |
|---|---|
| CPU % | 容器 CPU 使用率 |
| MEM USAGE / LIMIT | 容器内存使用量和限制 |
| MEM % | 内存使用百分比 |
| NET I/O | 网络收发 |
| BLOCK I/O | 磁盘读写 |
如果容器 CPU 接近上限,说明可能是 CPU 瓶颈。
如果内存持续上涨,说明可能有内存泄漏、缓存过大或 GC 压力。
如果容器有内存限制,压测时要特别注意是否 OOM。
终端 2:进入容器看 .NET 指标
进入容器:
bash
docker exec -it my-api sh
查看 .NET 进程:
bash
dotnet-counters ps
如果你的 WebApi 是容器主进程,PID 很可能是:
text
1
开始监听:
bash
dotnet-counters monitor --process-id 1 --counters System.Runtime,Microsoft.AspNetCore.Hosting
如果 dotnet-counters ps 显示其他 PID,就把 1 换成实际 PID:
bash
dotnet-counters monitor --process-id <pid> --counters System.Runtime,Microsoft.AspNetCore.Hosting
重点观察:
| 指标 | 含义 | 判断 |
|---|---|---|
| cpu-usage | .NET 进程 CPU 使用率 | 接近上限时可能是 CPU 瓶颈 |
| working-set | 进程工作集内存 | 持续上涨要警惕 |
| gc-heap-size | GC 堆大小 | 持续上涨可能有内存压力 |
| gen-0-gc-count / gen-1-gc-count / gen-2-gc-count | GC 次数 | 频繁 GC 可能影响延迟 |
| threadpool-thread-count | 线程池线程数 | 持续升高要关注 |
| threadpool-queue-length | 线程池排队长度 | 大于 0 且持续存在要重点排查 |
| requests-per-second | ASP.NET Core 请求速率 | 看吞吐是否还能上升 |
终端 3:运行 k6 压测
在压测机上执行:
bash
k6 run stress_test.js
第六篇:编写第一个 GET 压测脚本
创建文件:
bash
mkdir -p ~/load-test-demo
cd ~/load-test-demo
touch stress_test.js
写入下面内容:
javascript
// 导入 k6 的 HTTP 请求模块
import http from 'k6/http';
// check 用来做断言,sleep 用来模拟用户停顿
import { check, sleep } from 'k6';
// k6 压测配置
export const options = {
// stages 表示分阶段加压
stages: [
// 30 秒内逐步增加到 20 个虚拟用户
{ duration: '30s', target: 20 },
// 继续增加到 50 个虚拟用户
{ duration: '1m', target: 50 },
// 再增加到 100 个虚拟用户,观察系统是否变慢
{ duration: '30s', target: 100 },
// 保持 100 个虚拟用户 1 分钟,观察高压下是否稳定
{ duration: '1m', target: 100 },
// 最后逐步降到 0,结束压测
{ duration: '30s', target: 0 },
],
thresholds: {
// 95% 的请求必须在 500ms 内完成
http_req_duration: ['p(95)<500'],
// 请求失败率必须小于 1%
http_req_failed: ['rate<0.01'],
},
};
// Linux Docker 场景下,这里写宿主机 IP 和宿主机映射端口
// 例如容器是 -p 5000:8080,这里就访问 Linux服务器IP:5000
const BASE_URL = 'http://Linux服务器IP:5000';
// 每个虚拟用户都会反复执行这个函数
export default function () {
// 向 WebApi 接口发起 GET 请求
const res = http.get(`${BASE_URL}/weatherforecast`);
// 校验接口是否返回 200
check(res, {
'status is 200': (r) => r.status === 200,
});
// 每次请求后停顿 1 秒,模拟真实用户操作间隔
sleep(1);
}
你主要需要改这一行:
javascript
const BASE_URL = 'http://Linux服务器IP:5000';
例如:
javascript
const BASE_URL = 'http://192.168.1.100:5000';
运行:
bash
k6 run stress_test.js
第七篇:真实接口压测写法
1. GET 带 Query 参数
javascript
import http from 'k6/http';
import { check, sleep } from 'k6';
const BASE_URL = 'http://192.168.1.100:5000';
export default function () {
const city = encodeURIComponent('Beijing');
const res = http.get(`${BASE_URL}/api/weather?city=${city}&days=3`);
check(res, {
'status is 200': (r) => r.status === 200,
});
sleep(1);
}
2. 固定 Bearer Token
javascript
import http from 'k6/http';
import { check, sleep } from 'k6';
const BASE_URL = 'http://192.168.1.100:5000';
const params = {
headers: {
Authorization: 'Bearer 你的Token',
'Content-Type': 'application/json',
},
};
export default function () {
const res = http.get(`${BASE_URL}/api/orders`, params);
check(res, {
'status is 200': (r) => r.status === 200,
});
sleep(1);
}
3. 登录获取 Token 后访问业务接口
javascript
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
stages: [
{ duration: '30s', target: 20 },
{ duration: '1m', target: 50 },
{ duration: '30s', target: 0 },
],
thresholds: {
http_req_duration: ['p(95)<500'],
http_req_failed: ['rate<0.01'],
},
};
const BASE_URL = 'http://192.168.1.100:5000';
export function setup() {
const loginRes = http.post(
`${BASE_URL}/api/auth/login`,
JSON.stringify({
username: 'testuser',
password: 'P@ssw0rd',
}),
{
headers: {
'Content-Type': 'application/json',
},
}
);
check(loginRes, {
'login success': (r) => r.status === 200,
});
return {
token: loginRes.json('data.token'),
};
}
export default function (data) {
const params = {
headers: {
Authorization: `Bearer ${data.token}`,
'Content-Type': 'application/json',
},
};
const res = http.get(`${BASE_URL}/api/orders`, params);
check(res, {
'get orders 200': (r) => r.status === 200,
});
sleep(1);
}
如果你的登录接口返回结构是:
json
{
"access_token": "xxx"
}
就把:
javascript
token: loginRes.json('data.token'),
改成:
javascript
token: loginRes.json('access_token'),
4. POST JSON Body
javascript
import http from 'k6/http';
import { check, sleep } from 'k6';
const BASE_URL = 'http://192.168.1.100:5000';
export default function () {
const payload = JSON.stringify({
productName: '压测商品',
price: 99.9,
quantity: 2,
});
const params = {
headers: {
'Content-Type': 'application/json',
},
};
const res = http.post(`${BASE_URL}/api/orders`, payload, params);
check(res, {
'create success': (r) => r.status === 200 || r.status === 201,
});
sleep(1);
}
第八篇:看懂 k6 压测结果
k6 跑完后会看到类似结果:
text
checks.........................: 100.00% 1200 out of 1200
http_req_duration..............: avg=80ms min=12ms med=60ms p(90)=140ms p(95)=220ms
http_req_failed................: 0.00% 0 out of 1200
http_reqs......................: 1200 20/s
vus............................: 100
重点看:
| 字段 | 含义 | 怎么判断 |
|---|---|---|
| checks | 断言是否通过 | 最好 100% |
| http_req_duration | 响应时间 | 重点看 p95 和 p99 |
| http_req_failed | 请求失败率 | 通常希望小于 1% |
| http_reqs rate | 每秒请求数 | 实际吞吐 |
| vus | 虚拟用户数 | 当前模拟多少用户 |
常见判断:
| 现象 | 说明 | 下一步 |
|---|---|---|
| checks 不是 100% | 有请求没返回预期结果 | 看状态码、接口日志 |
| http_req_failed 高 | 错误率高 | 先排查 401、404、500、超时 |
| p95 越来越高 | 大部分用户开始变慢 | 看容器 CPU、GC、线程池、数据库 |
| VU 增加但 RPS 不增加 | 吞吐上不去了 | 基本压到瓶颈 |
| avg 不高但 p99 很高 | 少量请求特别慢 | 看慢 SQL、GC、第三方接口 |
第九篇:瓶颈定位流程
总流程
text
k6 结果不达标
|
+-- 错误率高?
| |
| +-- 看状态码、接口日志、认证、数据库错误、超时
|
+-- P95 / P99 高?
|
+-- 看 docker stats
|
+-- 看 dotnet-counters
|
+-- CPU 高:用 dotnet-trace 抓 CPU trace
|
+-- CPU 不高但线程池排队:排查同步阻塞、数据库等待、第三方接口
|
+-- 内存持续上涨:用 dotnet-dump 抓 dump
场景 A:容器 CPU 很高
先看容器:
bash
docker stats my-api
再看 .NET 进程:
bash
dotnet-counters monitor --process-id 1 --counters System.Runtime,Microsoft.AspNetCore.Hosting
如果 CPU 接近上限,抓 trace:
bash
dotnet-trace collect --process-id 1 --providers Microsoft-DotNETCore-SampleProfiler
压测运行 30 秒到 1 分钟后,按 Enter 停止。工具会生成 .nettrace 文件。
转换成 speedscope 格式:
bash
dotnet-trace convert trace.nettrace --format speedscope
然后打开:
text
https://www.speedscope.app/
把 .speedscope.json 文件拖进去看火焰图。
判断原则:
- 横向越宽,说明耗时越多。
- 最宽的函数通常就是 CPU 热点。
- 常见热点包括 JSON 序列化、复杂 LINQ、加密计算、循环处理、正则匹配。
场景 B:CPU 不高,但接口很慢
这通常是等待型瓶颈。
重点看:
text
threadpool-queue-length
如果这个值持续大于 0,说明线程池有排队。
常见问题代码:
csharp
var result = SomeAsyncMethod().Result;
或者:
csharp
SomeAsyncMethod().Wait();
建议改成:
csharp
var result = await SomeAsyncMethod();
还要排查:
- 数据库慢查询。
- Redis 超时。
- 第三方 HTTP 接口慢。
- 数据库连接池不够。
- HttpClient 使用不当。
- 同步锁阻塞。
场景 C:内存持续上涨
先观察:
bash
docker stats my-api
再观察:
bash
dotnet-counters monitor --process-id 1 --counters System.Runtime
如果 working-set 和 gc-heap-size 持续上涨,可以抓 dump:
bash
dotnet-dump collect --process-id 1
分析 dump:
bash
dotnet-dump analyze core_生成的文件名
进入分析界面后输入:
text
dumpheap -stat
重点看:
- 哪个类型对象数量最多。
- 哪个类型占用内存最大。
- 是否有缓存对象一直不释放。
- 是否有大对象频繁分配。
注意:在容器内抓 dump 可能需要额外权限,并且 dump 文件可能很大。压测环境建议预留足够内存和磁盘空间。
如果 dotnet-dump collect 失败,可以用带权限的方式启动容器:
bash
docker run -d \
--name my-api \
--cap-add=SYS_PTRACE \
-p 5000:8080 \
-e ASPNETCORE_URLS=http://0.0.0.0:8080 \
your-api-image
第十篇:第一次完整实战流程
假设你的目标接口是:
text
http://192.168.1.100:5000/weatherforecast
验收目标:
text
P95 < 500ms
错误率 < 1%
完整步骤:
- 在 Linux 上执行
docker ps,确认容器正在运行。 - 在 Linux 上执行
curl http://127.0.0.1:5000/weatherforecast,确认宿主机能访问容器。 - 在压测机上执行
curl http://192.168.1.100:5000/weatherforecast,确认远程能访问。 - 在压测机创建
stress_test.js,把BASE_URL改成 Linux 服务器地址。 - 在 Linux 终端 1 执行
docker stats my-api。 - 在 Linux 终端 2 进入容器,执行
dotnet-counters ps。 - 在容器内执行
dotnet-counters monitor --process-id 1 --counters System.Runtime,Microsoft.AspNetCore.Hosting。 - 在压测机执行
k6 run stress_test.js。 - 记录 k6 的 RPS、P95、P99、错误率。
- 记录 docker stats 的 CPU、内存、网络、IO。
- 记录 dotnet-counters 的 CPU、GC、线程池、请求速率。
- 根据结果判断瓶颈。
结果记录表:
| VU | RPS | P95 | P99 | 错误率 | 容器 CPU | 容器内存 | 线程池队列 | 结论 |
|---|---|---|---|---|---|---|---|---|
| 20 | 18/s | 80ms | 120ms | 0% | 20% | 300MB | 0 | 正常 |
| 50 | 45/s | 180ms | 260ms | 0% | 55% | 380MB | 0 | 正常 |
| 100 | 70/s | 900ms | 1500ms | 2% | 95% | 480MB | 3 | 疑似 CPU 或线程池瓶颈 |
结论示例:
text
本次压测目标是 P95 < 500ms,错误率 < 1%。
20 VU 和 50 VU 时系统正常。
100 VU 时 P95 达到 900ms,错误率达到 2%,容器 CPU 接近 95%,threadpool-queue-length 持续大于 0。
结论:当前系统在 100 VU 左右开始不达标,主要瓶颈疑似为 CPU 和线程池排队。
下一步:使用 dotnet-trace 抓 CPU trace,并排查接口中是否存在 .Result / .Wait() / 同步阻塞 / 慢 SQL。
第十一篇:常见问题
1. k6 访问不通接口
先确认:
bash
curl http://Linux服务器IP:5000/weatherforecast
如果失败,检查:
- 服务器 IP 是否正确。
- 宿主机端口是否映射。
- 防火墙是否放行。
- 云安全组是否放行。
- ASP.NET Core 是否监听
0.0.0.0。 - 接口路径是否正确。
2. 容器里没有 bash
使用:
bash
docker exec -it my-api sh
很多精简镜像没有 bash,但一般有 sh。
3. dotnet tool install 失败
常见原因是容器里只有 Runtime,没有 SDK。
解决方式:
- 压测环境改用 SDK 镜像。
- 多阶段构建把工具复制进 Runtime 镜像。
- 使用 dotnet-monitor sidecar。
4. dotnet-counters ps 看不到进程
检查:
- 是否在同一个容器内执行。
- 当前用户是否和 .NET 进程用户一致,或者是否有 root 权限。
/tmp是否正常,因为 Linux 上 .NET 诊断端口使用 Unix Domain Socket。- 应用是否禁用了诊断能力,比如设置了
DOTNET_EnableDiagnostics=0。
5. dotnet-dump collect 失败
可能是容器缺少 ptrace 权限。
可以在压测环境启动容器时加:
bash
--cap-add=SYS_PTRACE
如果仍然失败,还需要检查 seccomp、安全策略和容器平台限制。
6. 压测时容器被杀掉
可能是 OOM。
检查:
bash
docker inspect my-api
docker logs my-api
dmesg
也要确认 dump、trace 文件不会占满磁盘或触发内存限制。
第十二篇:.NET WebApi 常见瓶颈自查
| 问题 | 现象 | 建议 |
|---|---|---|
| 每次请求 new HttpClient | 连接数暴涨、Socket 耗尽 | 使用 IHttpClientFactory |
.Result / .Wait() |
CPU 不高但吞吐上不去 | 全链路 async/await |
| EF Core N+1 查询 | SQL 数量很多、DB 慢 | 批量查询、Include、投影查询 |
| 查询未使用 AsNoTracking | 内存压力偏高 | 只读查询加 AsNoTracking |
| 热路径大锁 | 并发上不去 | 减少锁粒度或使用并发集合 |
| 大对象频繁分配 | GC 频繁、P99 高 | 减少大对象分配,必要时对象池 |
| 日志过多 | 磁盘 IO 高、延迟升高 | 降低日志级别,避免热路径大量日志 |
| 数据库连接池太小 | 请求排队或超时 | 检查连接池配置和慢 SQL |
| Docker 内存限制过小 | OOM 或频繁 GC | 调整内存限制,优化分配 |
| 容器 CPU 限制过小 | CPU 快速打满 | 调整 CPU limit 或扩容 |
附录 A:关键命令速查
Docker
| 场景 | 命令 |
|---|---|
| 查看容器 | docker ps |
| 查看容器资源 | docker stats my-api |
| 进入容器 | docker exec -it my-api sh |
| 查看容器日志 | docker logs my-api |
| 查看容器进程 | docker top my-api |
k6
| 场景 | 命令 |
|---|---|
| 查看版本 | k6 version |
| 运行脚本 | k6 run stress_test.js |
| Docker 运行 k6 | docker run --rm -i grafana/k6 run - < stress_test.js |
.NET 诊断工具
| 工具 | 用途 | 命令 |
|---|---|---|
| dotnet-counters | 实时看 CPU、内存、GC、线程池、请求速率 | dotnet-counters monitor --process-id 1 --counters System.Runtime,Microsoft.AspNetCore.Hosting |
| dotnet-trace | 抓 CPU trace | dotnet-trace collect --process-id 1 --providers Microsoft-DotNETCore-SampleProfiler |
| dotnet-dump | 抓内存 dump | dotnet-dump collect --process-id 1 |
附录 B:核心名词速查
| 名词 | 解释 |
|---|---|
| VU | Virtual User,虚拟用户 |
| RPS | Requests Per Second,每秒请求数 |
| TPS | Transactions Per Second,每秒事务数 |
| RT | Response Time,响应时间 |
| P95 | 95% 的请求都快于这个耗时 |
| P99 | 99% 的请求都快于这个耗时 |
| GC | .NET 垃圾回收 |
| ThreadPool Queue | 线程池排队 |
| Docker stats | Docker 容器资源统计 |
| Trace | 一段时间内的执行采样数据 |
| Dump | 进程内存快照 |
附录 C:官方参考
- k6 安装文档:https://grafana.com/docs/k6/latest/set-up/install-k6/
- dotnet-counters 文档:https://learn.microsoft.com/en-us/dotnet/core/diagnostics/dotnet-counters
- .NET Linux 容器诊断文档:https://learn.microsoft.com/en-us/dotnet/core/diagnostics/diagnostics-in-containers
- dotnet-dump 文档:https://learn.microsoft.com/en-us/dotnet/core/diagnostics/dotnet-dump
- dotnet-monitor 文档:https://learn.microsoft.com/en-us/dotnet/core/diagnostics/dotnet-monitor
最后总结
针对 Linux + Docker 里的 .NET WebApi,推荐第一轮压测这样做:
text
确认容器端口可访问
-> k6 从压测机打 Linux 宿主机端口
-> docker stats 看容器整体资源
-> 进入容器用 dotnet-counters 看 .NET 运行时
-> CPU 高用 dotnet-trace
-> 内存上涨用 dotnet-dump
-> 修改代码或配置
-> 重新压测验证
只要能完成这个闭环,就已经完成了一次标准的 Linux Docker .NET WebApi 全链路压测和瓶颈定位。