.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 配置时,很多人会觉得 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%

操作步骤

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

完整步骤:

  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 全链路压测和瓶颈定位。

相关推荐
快乐的韭菜根根4 小时前
ubuntu 安装slurm并配置
linux·运维·ubuntu
倒头就睡的小比特5 小时前
Linux基础指令3
linux·服务器
梦帮科技6 小时前
Kali Linux Scapy 畸形报文构造学——深度解剖非法 TCP Flags 与反常序列号
linux·爬虫·神经网络·算法·数学建模·系统架构·模拟退火算法
半杯咖啡半行码7 小时前
Linux 驱动开发入门:内核模块编程基础
linux·服务器·网络
别动我齐刘海7 小时前
Linux 系统调试学习2
linux·运维·c++·人工智能·学习·机器学习·机器人
龙虾电话7 小时前
语音机器人打断不进去:VAD 双阈值迟滞里一个共用的 bool,和它引发的连锁误判
linux
honsor7 小时前
机房IP化改造首选:以太网温湿度传感器,PoE供电+Modbus TCP一网搞定
linux·运维·服务器
默|笙8 小时前
【Linux网络】_udp_socket(认识)
linux·网络·udp
程序员梅雨9 小时前
ZooKeeper面经总结
java·大数据·linux·分布式·后端·zookeeper·中间件
lingran__9 小时前
Linux进程万字详解(二)|进程状态深度剖析:调度队列、内核链表、D状态设计哲学、僵尸孤儿进程与Slab机制
linux·进程管理·内核链表·pcb·僵尸进程·孤儿进程·进程状态