一、先聊聊容量规划到底是什么
很多刚接触云原生的朋友,一听到"容量规划"四个字就头大,觉得这是个特别高深的事。其实说白了,容量规划就是回答一个问题:我的系统现在这个规模,下一步需要加多少机器、加多少内存,才能让用户访问起来不卡、不报错,同时又不至于买一堆用不上的服务器浪费钱。
以前我们做单体应用的时候,一台机器扛所有,容量规划相对简单:看看CPU、内存、磁盘的使用率,差不多接近瓶颈了就开始扩容。但是到了云原生时代,服务被拆成几十个甚至上百个小模块,每个模块又有多个副本,从前端入口到数据库之间可能经过几十次调用。这个时候光看服务器的CPU和内存,根本说不清瓶颈到底在哪里。比如用户反馈接口变慢了,你去看所有服务器的负载都是正常的,那问题出在哪儿呢?这时候就需要一个能穿透整个调用链的工具,告诉你请求到底在哪个环节慢了下来,这就是Jaeger要干的事。
云原生系统的容量规划,本质是沿着"用户的请求路径"去找瓶颈,而不是孤立地看每一台机器的指标。如果把容量规划比作做饭,以前的做法是死盯着锅里的菜熟没熟,而用了Jaeger之后,你就能看到从洗菜、切菜、下锅到装盘的每一个环节各自花了多少时间,哪些环节挤在一起堵住了,一目了然。
二、为什么容量规划非要看链路数据
2.1 容量规划的出发点不是"机器",而是"请求"
云原生架构下,每一个用户请求都会在系统内部创建一条完整的调用链。假设一个"查询订单"的接口,前端打到网关,网关调订单服务,订单服务要同时查缓存、查数据库、调物流服务、调用户服务,最后把结果组装起来返回给前端。整个过程可能涉及到十几个服务实例。
如果用户量翻倍,哪些服务先扛不住?绝对不是所有服务一起挂,一定是最先到达瓶颈的那一个。可能是数据库的连接池满了,也可能是某个下游服务响应变慢导致上游线程被占满。只要是链路中的某个节点出问题,整个请求就会跟着变慢。这个时候,如果没有链路数据,你就只能看着整条链路的表现去瞎猜,而有了Jaeger,你可以准确地看到每次请求在每一个服务、每一个操作上分别花了多少毫秒,从而精准定位谁是"堵点"。
2.2 容量规划需要动态视角,不是静态快照
传统容量规划往往依赖监控报表,看的是某个时间段内的平均负载。但是云原生流量的特点是突发性强,比如促销活动、热点事件,流量可能在几分钟内翻十几倍。这时候靠平均值来规划容量完全不靠谱,因为平均值会掩盖峰值瞬间的服务阻塞。
Jaeger记录的是每一次请求的详细数据,包括时间戳、持续时长、调用关系。把一段时间内的所有调用数据汇总起来,你可以清晰地看到流量的潮汐规律——哪些时刻是高峰、高峰持续多久、高峰期间的延迟和错误率变化趋势如何。有了这些数据,容量规划就不只是"按峰值预留资源"这种粗放的做法,而是能做到"按业务节奏动态调整资源",流量高峰前提前扩容,低谷期自动缩容。
三、Jaeger能帮你看到哪些容量信号
在具体讲怎么操作之前,先弄清楚Jaeger能给我们提供哪些有用的信息。Jaeger的核心概念是链路(Trace)和跨度(Span)。一次完整的用户请求对应一条链路,链路中的每一个服务调用、每一个数据库查询、每一次消息发送,都是一个跨度。
第一,跨度的延迟分布。 每个跨度都记录了开始时间和结束时间,所以你能知道某一个服务处理请求的真实耗时。把同一种操作的大量跨度数据做统计分析,比如"订单服务查数据库"这个操作,过去一小时内所有请求的耗时分布,你可以得到P50、P90、P99这样的分位数。P99的意思是一百个请求里,九十九个请求的耗时都小于这个值。容量规划最关注的就是P99,因为尾部延迟决定了用户体验的底线。
第二,链路中的依赖关系。 Jaeger的依赖分析功能,可以自动统计服务之间的调用关系和调用频次。比如A服务调B服务每天多少次、每秒多少次,平均耗时多少。这能帮你算出当上游流量增长时,下游服务会额外承受多大的压力,从而为每一个下游服务预留合适的资源。
第三,错误率和超时信息。 每个跨度可以记录错误状态,通过按服务、按接口聚合错误率,你可以找出那些对资源消耗特别大、又容易失败的调用。这些调用往往会在系统压力升高时率先雪崩,容量规划时必须重点考虑它们的冗余度。
四、实施前的准备工作:选好技术栈和数据采集方案
这一节开始进入实际操作。我们全程使用Python作为技术栈做示例演示,因为Python写起来直白,看例子的时候不需要再花精力去理解语法。实际生产环境你完全可以用Java、Go、Node.js等任何主流语言,道理是一模一样的。
首先,你需要一个已经部署好的Jaeger后端。最简单的方式是用Docker在本机先跑起来:
# 拉取镜像并启动Jaeger AllInOne
docker run -d --name jaeger \
-e COLLECTOR_ZIPKIN_HOST_PORT=:9411 \
-p 6831:6831/udp \
-p 16686:16686 \
jaegertracing/all-in-one:latest
启动之后,在浏览器打开 http://localhost:16686 就能看到Jaeger自带的UI界面。生产环境中,你通常会把Jaeger部署在Kubernetes集群内部,接收所有服务上报的链路数据,但本机跑一个AllInOne版本足够我们学习和验证概念。
然后,在Python服务里要安装Jaeger的客户端库。我们使用最主流的 jaeger-client:
# 在虚拟环境中安装jaeger客户端
pip install jaeger-client
# 同时安装opentracing的python封装,它提供了标准化的埋点接口
pip install opentracing
这里说明一下:Opentracing是一套链路追踪的标准接口,Jaeger是这个标准的一种实现。我们用标准的接口写埋点代码,以后就算公司把链路追踪系统换成别的,代码改动也会很小。
五、动手埋点:接入Jaeger跟踪每一次请求
5.1 一个最基础的接入示例
下面这个例子演示了如何在Python的Web服务中,使用Jaeger追踪每个HTTP请求从进入到返回的完整过程。技术栈:Python 3.9 + Flask + jaeger-client。
# flask_jaeger_demo.py
# 技术栈:Python 3.9 + Flask + jaeger-client
from flask import Flask
import opentracing
import time
from jaeger_client import Config
app = Flask(__name__)
# 创建一个全局的Tracer实例,用它的start_active_span方法创建链路
# service_name表示这个服务在Jaeger UI上显示的名称
tracer = None
def init_tracer():
# 配置Jaeger连接信息
config = Config(
config={
'sampler': {
# const表示全部采样,type为0表示全部记录
# 生产环境通常会设成probabilistic概率采样,后面会细说
'type': 'const',
'param': 1,
},
# 本地没有部署agent时,直接指向collector的HTTP端口
'local_agent': {
'reporting_host': 'localhost',
'reporting_port': 5775,
},
# 日志打印,方便调试
'logging': True,
},
# 每个服务要有一个独一无二的服务名
service_name='order-api'
)
return config.initialize_tracer()
@app.route('/api/order/<order_id>')
def get_order(order_id):
# 开启一个span,span名称用接口路径,方便在UI上按接口筛选
with tracer.start_active_span('GET /api/order/{id}') as scope:
# 给当前span打上业务标签,后续能按标签做统计
scope.span.set_tag('http.method', 'GET')
scope.span.set_tag('order.id', order_id)
# 模拟查数据库的耗时操作
time.sleep(0.05)
# 再模拟一次调用本地缓存的耗时操作
time.sleep(0.02)
return {'status': 'ok', 'order_id': order_id}
if __name__ == '__main__':
tracer = init_tracer()
app.run(port=5001)
运行这个服务之后,每访问一次接口,Jaeger UI的"Search"页面就能看到一条名为"GET /api/order/{id}"的链路记录,展开可以看到这个请求内部两个模拟操作分别耗时多少。
5.2 多服务透传:带上完整链路信息进行跨服务调用
上面只是单服务内部的链路追踪,但云原生系统的核心特征是服务间互相调用。链路头信息要在服务间传递,才能把一次完整的用户请求串成一条线。下面演示服务A调用服务B时如何传递上下文。技术栈:Python 3.9 + Flask + requests + jaeger-client。
# service_a.py
# 技术栈:Python 3.9 + Flask + requests + jaeger-client
# 服务A:接收前端请求,然后调用服务B获取用户信息
from flask import Flask, request
import opentracing
import requests
import time
from jaeger_client import Config
app = Flask(__name__)
tracer = None
def init_tracer():
config = Config(
config={
'sampler': {'type': 'const', 'param': 1},
'local_agent': {'reporting_host': 'localhost', 'reporting_port': 5775},
'logging': True,
},
service_name='frontend-api'
)
return config.initialize_tracer()
@app.route('/get_user/<user_id>')
def get_user(user_id):
# 当前HTTP请求如果是由上游服务转发过来的,Jaeger会自动从请求头中提取链路上下文
# 但为了演示跨服务传递的完整过程,我们手动获取从header中解析出的span上下文
with tracer.start_active_span('GET /get_user') as scope:
# 模拟一些本地耗时,比如拼接数据
time.sleep(0.01)
# 注入当前span的上下文到HTTP请求头中
# 这样服务B的Jaeger客户端就能解析出这是同一个链路的延续
headers = {}
# 核心代码:把当前span context注入到headers对象里
tracer.inject(
span_context=scope.span.context,
format=opentracing.Format.HTTP_HEADERS,
carrier=headers
)
# 调用下游服务,带上注入好的headers
resp = requests.get('http://localhost:5002/api/user/' + user_id, headers=headers)
return resp.json()
if __name__ == '__main__':
tracer = init_tracer()
app.run(port=5001)
# service_b.py
# 技术栈:Python 3.9 + Flask + requests + jaeger-client
# 服务B:用户信息服务,接收服务A的调用,并作为下游服务返回数据
from flask import Flask, request
import opentracing
import time
from jaeger_client import Config
app = Flask(__name__)
tracer = None
def init_tracer():
config = Config(
config={
'sampler': {'type': 'const', 'param': 1},
'local_agent': {'reporting_host': 'localhost', 'reporting_port': 5775},
'logging': True,
},
service_name='user-service'
)
return config.initialize_tracer()
@app.route('/api/user/<user_id>')
def get_user_info(user_id):
# 从HTTP头中提取由上游服务注入的链路上下文
# 提取出来的上下文会被Jaeger自动关联到当前的span
with tracer.start_active_span('GET /api/user') as scope:
# 关键操作:从HTTP请求头中解析链路上下文
# 这一步让当前的span自动成为上游span的子span,串成完整链路
span_ctx = tracer.extract(
format=opentracing.Format.HTTP_HEADERS,
carrier=request.headers
)
# 如果上游成功传递了上下文,这里就可以设置父span
# 注意:start_active_span默认会从当前scope中找父span,这里手动传入
# 为了让例子更简洁,这里不深剖自动化逻辑,实际场景直接从request取headers交给tracer即可
time.sleep(0.03)
return {'user_id': user_id, 'name': '张三'}
if __name__ == '__main__':
tracer = init_tracer()
app.run(port=5002)
跑起来之后,在Jaeger UI里搜索服务名"frontend-api",点开任意一条链路,你会看到两个服务各自的span上下层级关系清晰地连在一起,时间轴上半部分显示frontend-api的耗时,下半部分显示user-service的耗时。整条链路一目了然。
六、从链路数据到容量规划的实战方法
光有数据还不行,关键是怎么从庞杂的链路数据中提取容量决策的依据。
6.1 用延迟分位数识别哪个环节先饱和
在Jaeger的UI里面,选择一个服务,再选择一种操作名(Operation),设置时间范围,系统会自动生成延迟分布图。你要重点看的是P50、P90、P99三条线的变化趋势。
举个具体例子:假设"user-service"的P99延迟从平时的20毫秒涨到了80毫秒,而同时这个服务所在Pod的CPU使用率只有30%。这就说明瓶颈不在CPU,而在其他东西——可能是线程池不够、可能是数据库连接池排队、可能是依赖了某个外部API变慢了。这时候你要做的容量规划动作,不是盲目加CPU,而是先优化那个具体的慢操作。如果慢操作是查数据库,那就得给数据库加连接数或者优化SQL。
实际操作中,你可以用Jaeger的API直接拉取时序数据,然后导入到Prometheus里画趋势图。不过这一步不是必须的,日常观察UI界面也足够用。
6.2 用链路依赖图做流量推演
打开Jaeger的"System Architecture"页面,可以看到服务间的调用依赖图,每个服务旁边会标注调用量。这个图对容量规划非常有价值:当你计划做一次大型推广活动、预判流量要涨三倍时,你可以沿着依赖图从入口服务开始,一层一层推算每个下游服务会承担的流量增量。
比如图上显示"frontend-api"每秒调用"user-service"200次,而"user-service"每秒调用"member-service"150次。入口流量涨三倍,大概估算"frontend-api"到600次,"user-service"到450次,"member-service"到大约450次。再对照每个服务当前的容量上限,就能算出哪些服务需要扩容、各扩多少副本。整个过程不需要写一张复杂的容量公式表,跟着链路一张图一张图往下推就行。
6.3 结合压测做容量标定
链路数据结合压测,是容量规划最扎实的做法。用压测工具模拟日常流量、两倍流量、四倍流量,每一档压测的过程中,通过Jaeger观察每个环节的延迟分位数变化。你会看到某些服务在流量到某个临界值后,延迟突然陡增——那个临界值就是这个服务的容量上限。
比如我们用locust向"frontend-api"发压测请求,同时盯着Jaeger UI。当压到每秒500个请求时,"user-service"的P99延迟还是20毫秒,再往上加到每秒700个时,P99突然跳到200毫秒,而CPU还没满。这就说明有一个隐藏的线程池或者连接池到顶了。定位到具体是哪个span变慢,然后去检查那个组件的配置参数,调大之后重新压测,再验证容量是否翻倍。这种"压测-观察-调整-再压测"的循环,就是云原生时代容量规划的日常节奏。
七、掌握采样策略,别让数据淹没存储
7.1 为什么不能100%采样
Jaeger默认配置下,如果设置100%全量采样,在高流量系统里会产生海量链路数据。假设每秒有一万个请求,每条链路有几十个span,一天的存储量会是TB级别,成本极高。容量规划不需要每条请求都记录,统计学上只要样本量足够,分位数就是稳定的。
7.2 适合容量规划的采样策略
推荐使用概率采样,把每个请求被记录的概率设为10%或更高,看你的流量总量有多大。配置方式如下:
{
"sampler": {
"type": "probabilistic",
"param": 0.1
},
"local_agent": {
"reporting_host": "localhost",
"reporting_port": 5775
},
"logging": false
}
param为0.1表示10%的请求会被采样记录。如果流量每秒少于100个请求,建议直接用const全量采样,因为数据量不大,全量更精准。
7.3 根据业务重要级别差异化采样
另一个思路是把请求按重要性分流:核心交易链路的请求100%采样,普通查询接口10%采样。这需要在代码里根据请求路径来判断,动态设置采样率。比如下面这个伪代码思路:
# 技术栈:Python 3.9 + jaeger-client
# 动态采样率,按路径区分链路重要性
import opentracing
from jaeger_client import Config
config = Config(
config={
'sampler': {'type': 'probabilistic', 'param': 0.01},
'local_agent': {'reporting_host': 'localhost', 'reporting_port': 5775},
},
service_name='order-api'
)
tracer = config.initialize_tracer()
def create_span_with_dynamic_sampling(path):
# 重要的交易接口走全量采样
if path.startswith('/pay') or path.startswith('/checkout'):
# 效果等同于把param设为1
sampler_override = opentracing.SpanContext()
span = tracer.start_span(path, child_of=sampler_override)
else:
# 普通接口走全局10%概率采样
span = tracer.start_span(path)
return span
八、除了Jaeger,搭配哪些关联技术让容量规划更完整
Jaeger提供的是链路级的延迟与依赖信息,但容量规划还需要两类数据:资源消耗数据和业务流量数据。
资源消耗数据可以用Prometheus配合Grafana来做。在Kubernetes集群里,Prometheus通过node-exporter采集节点CPU和内存使用率,通过kube-state-metrics采集Pod的副本数和资源配额。把Jaeger导出的延迟分位数和Prometheus采集的资源利用率放到同一个DashBoard上,你就能看到"延迟上涨时资源用到了多少"这种关联图像。
业务流量数据可以通过网关日志或者Kafka的topic积压情况来估计。在上游入口处做好流量统计,把实时QPS与Jaeger链路延迟叠加分析,能推算出延迟指数增长的点位,那个点就是容量规划必须覆盖的临界容量。
下面给一个简单的Python脚本思路,演示如何从Jaeger的API读取延迟数据,结合Prometheus的接口做对比分析。技术栈:Python 3.9 + requests。
# capacity_analysis.py
# 技术栈:Python 3.9 + requests
# 作用:从Jaeger的API拉取P99延迟,从Prometheus拉取CPU使用率,输出到控制台
import requests
import json
# 从Jaeger查询服务在指定时间窗口内的追踪延迟
def query_jaeger_p99(service_name, lookback_minutes=30):
# Jaeger的API接口,按服务名和标签过滤追踪记录
url = "http://localhost:16686/api/traces"
params = {
"service": service_name,
"lookback": lookback_minutes + "m",
"limit": 5000 # 取最近5000条,作为估算样本
}
resp = requests.get(url, params=params)
traces = resp.json().get("data", [])
# 提取所有trace的总耗时,按从大到小排序
durations = []
for trace in traces:
# trace的duration单位是微秒,转换为毫秒
durations.append(trace["duration"] / 1000)
durations.sort()
# 计算P99:取第99百分位
if not durations:
return None
p99_idx = int(len(durations) * 0.99) - 1
return durations[p99_idx]
# 从Prometheus拉取某服务的CPU使用率均值
def query_prometheus_cpu(pod_name):
# Prometheus的HTTP API,执行PromQL查询语句
prom_url = "http://localhost:9090/api/v1/query"
promql = f'avg(rate(container_cpu_usage_seconds_total{{pod="{pod_name}"}}[5m])) * 100'
resp = requests.get(prom_url, params={"query": promql})
result = resp.json()["data"]["result"]
if not result:
return None
# 返回百分比值
return float(result[0]["value"][1])
if __name__ == "__main__":
# 服务名需要和Jaeger中注册的service_name保持一致
p99_ms = query_jaeger_p99("user-service", "30m")
cpu_usage = query_prometheus_cpu("user-service-xxxx")
print(f"user-service P99延迟: {p99_ms} ms")
print(f"user-service CPU使用率: {cpu_usage}%")
九、容量规划中的三个常见坑与注意事项
9.1 别忽略缺少链路追踪的历史业务
你的系统里很可能还有一部分老服务,没有接任何链路追踪。这些服务在容量规划的依赖图中是黑洞,你无法判断它们会不会成为瓶颈。解决办法是优先给入口服务和核心数据库操作埋点,其他边缘服务可以逐步接入。在评估容量结果时,要记得给这部分未知区域留出30%的冗余空间。
9.2 注意采样率对P99准确度的影响
当你把采样率从100%降到1%时,P99的置信区间会明显变宽。也就是说,你计算出来的P99可能不太准确。容量规划依赖的延迟指标,建议保持至少10%的采样率,这样才能得到稳定的P99估算值。另外要看大流量时段的数据,流量小的时候样本量不足,P99会偏低,容易低估容量需求。
9.3 链路数据只负责"找问题",不负责"预测未来"
气Native系统的容量规划还需要结合业务增长预期、活动日历、历史同期的数据来设定未来一段时间的流量假设。链路数据告诉你的是今天的容量基线,不能直接推算出三个月后的容量需求。正确的姿势是:用链路数据定基线,用容量推算公式(流量增长率×当前资源上限)来预算未来资源数量,两者一结合才能落地。
十、一个完整的容量规划落地案例
把一个完整的流程串起来。假设我们有一个电商系统,入口是"frontend-api"网关,下游有三个核心服务:"user-service"(用户)、"order-service"(订单)、"inventory-service"(库存)。
某天我们计划做一次大型促销,预估下单流量会变成平时的5倍。按照下面的步骤做容量规划:
先在Jaeger后台查看最近一周的链路数据,拿到每个服务每天的调用量和P99延迟。假设统计结果是:"frontend-api"日均请求20万次,高峰秒级300QPS;"order-service"高峰150QPS,P99响应时间35毫秒;"inventory-service"高峰130QPS,P99响应时间50毫秒。
接着查看每条链路中哪些环节占的时长比例最大。发现"order-service"内部一个查数据库的操作占了总时长的70%,这个操作单独P99是25毫秒。
用压测工具分别对三个服务加压,同时观察Jaeger中各自P99的变化。当请求量到1200QPS时,"inventory-service"的P99延迟从50毫秒跳到400毫秒;当请求量到2000QPS时,"order-service"的连接池开始报错。这样我们就知道了:库存服务大概在1000QPS左右就是危险线,而订单服务的上限大概是1800QPS。
促销预估5倍流量,"inventory-service"会到650QPS,还没过危险线,但已经很接近了,为了安全起见扩容2个Pod;"order-service"会到750QPS,离1800还很远,暂不扩容。同时对"inventory-service"内部的慢SQL做一次索引优化,把P99降下来扩大容量余量。
扩容之后重新压测,验证P99恢复到了50毫秒以内,容量规划落地完成。
整个过程中,Jaeger的洞察就是第2和第3步里的延迟分位数和瓶颈定位。没有它,你可能会给所有服务都盲目扩容,花冤枉钱。
十一、总结一下
云原生系统的容量规划,不再是一张静态的服务器配置表,而是一个持续不断、跟随业务动态调优的过程。Jaeger作为链路追踪工具,最大的价值不是告诉你哪台机器负载高,而是告诉你一次用户的请求在系统内部到底经历了什么。通过分析链路延迟的分布数据、服务间的依赖关系、以及不同压力下的性能拐点,你能准确知道容量瓶颈在哪里、哪个服务该扩容、哪个服务需要优化代码而不是加机器。
在实际落地的过程中,建议研发团队主动养成"每次上线新功能都看一眼链路数据"的习惯,把容量隐患消灭在萌芽状态。链路数据既是排障工具,更是容量规划的第一手素材。
评论
围绕“如何通过Jaeger进行云原生系统的容量规划?”参与讨论