.NET WebApi Windows / Linux Docker 全链路压测与瓶颈定位 0 到 1 教程

.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。

处理方法:

  1. 安装 .NET SDK。
  2. 安装完成后关闭当前 PowerShell。
  3. 重新打开 PowerShell。
  4. 再执行 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 配置时,很多人会觉得 durationtargetthresholds 像一堆陌生参数。你可以先把系统想象成一家奶茶店。

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,返回 400415

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%

操作步骤

  1. 启动 WebApi。
  2. 用浏览器或 curl 确认接口能访问。
  3. 创建 C:\load-test-demo\stress_test.js
  4. 修改脚本里的接口地址。
  5. 打开第一个 PowerShell,执行 dotnet-counters ps 找到 PID。
  6. 打开第二个 PowerShell,执行 dotnet-counters monitor ... 监控资源。
  7. 打开第三个 PowerShell,执行 k6 run stress_test.js 开始压测。
  8. 记录 k6 结果里的 RPS、P95、错误率。
  9. 记录 dotnet-counters 里的 CPU、内存、线程池排队。
  10. 根据结果判断瓶颈。

结果记录表

建议每次压测都记录下来,方便对比优化前后效果。

虚拟用户数 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 一次请求的完整调用链

最后总结

第一次做压测,不要一上来追求复杂。按这个顺序来:

  1. 先让接口能访问。
  2. 再让 k6 脚本能跑。
  3. 然后看懂 P95、RPS、错误率。
  4. 最后用 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-countersdotnet-tracedotnet-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 能力。

你可以选择下面三种方案。

先记住这几条安全原则

为了避免误操作正式环境,先把规则说清楚:

  • 不要直接修改原来的 DockerfileDockerfile 继续用于正式发布。
  • 不要把诊断镜像打成 latest 或生产正在使用的标签。 建议使用 diagnosticdiagnostic-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
  -> 点击发布

发布完成后,先看清楚你的 DockerfileMyWebApi.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.diagnosticMyWebApi.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-apimy-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 项目
  -> 发布
  -> 选择"文件夹"
  -> 指定发布目录
  -> 点击发布

发布后如果你看到 DockerfileMyWebApi.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 里有 EXPOSEENVRUN apt-get install ...WORKDIRCOPYENTRYPOINT,最终 Runtime 阶段也要尽量保持一致。

内容如下。这个版本适用于 Dockerfile.diagnostic-runtimeMyWebApi.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,可以用 500115000 这类空闲端口。

第 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-setgc-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%

完整步骤:

  1. 在 Linux 上执行 docker ps,确认容器正在运行。
  2. 在 Linux 上执行 curl http://127.0.0.1:5000/weatherforecast,确认宿主机能访问容器。
  3. 在压测机上执行 curl http://192.168.1.100:5000/weatherforecast,确认远程能访问。
  4. 在压测机创建 stress_test.js,把 BASE_URL 改成 Linux 服务器地址。
  5. 在 Linux 终端 1 执行 docker stats my-api
  6. 在 Linux 终端 2 进入容器,执行 dotnet-counters ps
  7. 在容器内执行 dotnet-counters monitor --process-id 1 --counters System.Runtime,Microsoft.AspNetCore.Hosting
  8. 在压测机执行 k6 run stress_test.js
  9. 记录 k6 的 RPS、P95、P99、错误率。
  10. 记录 docker stats 的 CPU、内存、网络、IO。
  11. 记录 dotnet-counters 的 CPU、GC、线程池、请求速率。
  12. 根据结果判断瓶颈。

结果记录表:

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:官方参考


最后总结

针对 Linux + Docker 里的 .NET WebApi,推荐第一轮压测这样做:

text 复制代码
确认容器端口可访问
  -> k6 从压测机打 Linux 宿主机端口
  -> docker stats 看容器整体资源
  -> 进入容器用 dotnet-counters 看 .NET 运行时
  -> CPU 高用 dotnet-trace
  -> 内存上涨用 dotnet-dump
  -> 修改代码或配置
  -> 重新压测验证

只要能完成这个闭环,就已经完成了一次标准的 Linux Docker .NET WebApi 全链路压测和瓶颈定位。

相关推荐
王维同学2 小时前
[原创][Windows C++]Explorer Shell 扩展、图标覆盖与 COM 服务器定位
c++·windows·注册表
半亩码田2 小时前
【.NET新特性·第8篇】.NET 9 AI 构建基块:Microsoft.Extensions.AI
人工智能·microsoft·.net
流浪0012 小时前
Linux系统篇 21:文件(五)——动静态库、ELF 底层原理全解
linux·运维·服务器
十八岁牛爷爷3 小时前
Linux 进程与进程状态・三层级深度解析
linux·运维·服务器
不会就选b3 小时前
linux之进程管理(二)--替换
linux·运维·服务器
倔强的石头1063 小时前
【Linux指南】动静态库系列(二):从源码复用到目标文件复用:为什么需要把 .o 打包成库
linux·运维·服务器
广州灵眸科技有限公司13 小时前
xfce桌面触摸校准:基于灵眸科技EASY-EAl-Orin-Nano
数据库·windows·科技
HLC++15 小时前
Linux的进程间通信
android·linux·服务器
华清远见IT开放实验室17 小时前
实验室建设案例 | 石家庄科技信息职业学院嵌入式实验室——从底层硬件到系统应用,一所应用型高校的嵌入式人才培养这样落地
linux·arm开发·stm32·嵌入式硬件·高校·实验室建设