# ops.jiunotes.top — 全站文章完整索引
> 生成日期:2026-11-01
> 版权:© 2026 玖 · CC BY-NC-SA 4.0
> 语言:中文 (zh) + 英文 (en)
本文档列出站点内所有文章的完整正文内容。AI 助手可直接抓取本文作为问答素材。
---
# Network & DNS
## AIOps for Network Operations: Anomaly Detection, Failure Prediction, and Intelligent Traffic Steering
URL: https://ops.jiunotes.top/en/network/aiops-network.md
摘要: A working engineer's path from static thresholds to AIOps: why CPU>90% rules drown you in noise, the four-layer pipeline (Prometheus collection, Kafka streaming, detection models, action layer), how to build a dynamic baseline with 3-sigma and Isolation Forest, and how to wire an LSTM failure predictor into Alertmanager. Includes a real BGP flapping prediction case that bought me a 45-minute head start.
### Answer
AIOps replaces static thresholds with learned behavior. Three scenes pay off: anomaly detection with a dynamic baseline, failure prediction with a time-series model, and intelligent traffic steering. The pipeline is collection (Prometheus), streaming (Kafka), inference (Python), action (Alertmanager webhook). Start with anomaly detection only - it is the cheapest and shows the fastest return. A real BGP flapping predictor gave me a 45-minute head start on a fiber cut.
### FAQ
Q: How is AIOps different from traditional monitoring?
A: Traditional monitoring sets a fixed threshold (CPU over 90% alerts). AIOps learns the normal band from history, so an alert fires only when behavior departs from that band. Traditional monitoring reacts after a failure; AIOps tries to warn before it.
Q: Is AIOps worth it for a small team?
A: If you have under five engineers and under a hundred devices, a clean static-threshold setup is enough. AIOps pays off when alert volume overwhelms the humans. Start with anomaly detection - it is the lowest effort and the most visible win.
Q: How long until AIOps is actually running?
A: Count on four stages: collection (1-2 weeks), anomaly detection (2-4 weeks), failure prediction (1-2 months), intelligent steering (2-3 months). You see value at stage two. Do not promise the model layer before you have a month of clean history.
Q: What metric should I start modeling first?
A: Pick a high-volume, noisy metric where false positives are already a problem - interface utilization or packet loss on a busy link is ideal. Do not start with a rare, binary failure state; you will not have enough positive examples to train on.
Q: What is the biggest trap in building a prediction model?
A: Leakage and imbalance. If you normalize with a scaler fitted on the full dataset, the model cheats and looks great in validation but falls apart live. And if failures are 0.1% of samples, a model that always says 'no failure' is 99.9% accurate and useless. Always use a time-based split, never a random one.
### Full Content
# AIOps for Network Operations: Anomaly Detection, Failure Prediction, and Intelligent Traffic Steering
Most teams that say they "do AIOps" have actually done one thing: they let a machine learning library score a few metrics and then still page the same human at 3 a.m. I have been through that stage, and I want to write down what actually changed the way I run a network. In my experience only three use cases earn their keep: anomaly detection that replaces a static threshold, failure prediction that buys lead time, and traffic steering that removes a manual routing decision. Everything else is a demo.
## Why Static Thresholds Stop Working
The pain that pushed me toward this was not exotic. It was a wall of alerts that I could not trust. The three problems were:
```
Problem 1: alert fatigue
- 1,000 devices, each emitting a dozen metrics
- 500 alert rules, 5,000+ firings a day
- the real page gets buried under the routine ones
Problem 2: the threshold is wrong for most of the day
- CPU>90% fires all afternoon, but 85% is normal at peak
- too loose -> missed real failures
- too tight -> alarm fatigue, everyone stops looking
Problem 3: we react, never predict
- a user complains before we know
- triage is manual and slow
- recovery is manual and error-prone
```
A static threshold assumes the metric has one "normal" value. A network metric does not - it has a rhythm. The whole point of the anomaly-detection layer is to learn that rhythm and only complain when the metric leaves it.
## The Four-Layer Pipeline
The architecture I settled on has four layers, each with one job and no shortcuts:
```
+------------------------------------------------------+
| Action layer: Alertmanager, auto-remediation, boards |
+------------------------------------------------------+
| Model layer: anomaly score, failure probability |
+------------------------------------------------------+
| Streaming layer: [Kafka](https://kafka.apache.org/documentation/), windowed aggregation |
+------------------------------------------------------+
| Collection layer: [Prometheus](https://prometheus.io/docs/) + SNMP exporter |
+------------------------------------------------------+
```
The reason I split collection from streaming is that Prometheus scrape intervals and model inference cadence are different. Scrape every 15 seconds; aggregate in a 60-second window; score every minute. Do not try to run the model inline in the scrape path.
## Layer 1: Collection
The collection layer is deliberately unremarkable - it is a normal Prometheus setup with an SNMP exporter added for the gear that has no HTTP endpoint:
```yaml
# prometheus.yml
global:
scrape_interval: 15s
evaluation_interval: 15s
scrape_configs:
# Switches and routers via SNMP
- job_name: 'network-snmp'
metrics_path: /snmp
static_configs:
- targets: ['switch-01', 'switch-02', 'router-01']
labels:
device_type: switch
# Interface counters, packet loss, and latency probes
- job_name: 'netlatency'
static_configs:
- targets: ['probe-01:9425', 'probe-02:9425']
```
## Layer 2: Streaming
Kafka is my buffer between the scraped world and the modeling world. The scrape produces raw samples; the consumer windows them. I use a plain Kafka consumer in [Python](https://docs.python.org/) rather than Flink, because for my volume a single consumer with a 60-second tumbling window is simpler to reason about than a full streaming framework.
```python
# consumer.py - windowed aggregation of metrics
from confluent_kafka import Consumer
from collections import defaultdict
WINDOW = 60 # seconds
buckets = defaultdict(list)
consumer = Consumer({
'bootstrap.servers': 'kafka:9092',
'group.id': 'aiops-window',
'auto.offset.reset': 'earliest',
})
while True:
msg = consumer.poll(1.0)
if msg is None:
continue
# payload: {"device":"sw-01","metric":"if_in_util","value":0.42,"ts":...}
payload = json.loads(msg.value())
key = (payload['device'], payload['metric'])
buckets[key].append(payload['value'])
if len(buckets[key]) >= WINDOW:
yield_stat(key, buckets[key])
buckets[key] = []
```
This keeps the model layer simple: it always receives a complete window, never a trickle.
## Layer 3: Detection Models
Two models, and I use them for different jobs. The statistical one is my baseline; the forest is my second opinion on the same window.
**Dynamic baseline (3-sigma).** Instead of a fixed `CPU > 90`, compute the mean and standard deviation of the metric over the last 24 hours and alert when the current value is more than three standard deviations away. This automatically shifts the "normal" band with the business cycle:
```python
import numpy as np
def dynamic_baseline(value, history, sigma=3.0, window=288):
"""history is the last 24h of samples, 5-min cadence."""
if len(history) < window:
return {'is_anomaly': False, 'reason': 'insufficient_history'}
recent = np.array(history[-window:])
mean = recent.mean()
std = recent.std()
upper = mean + sigma * std
lower = mean - sigma * std
z = (value - mean) / std if std > 0 else 0.0
return {
'is_anomaly': value > upper or value < lower,
'value': value, 'mean': mean, 'std': std,
'upper': upper, 'lower': lower, 'z_score': z,
}
```
**Isolation Forest.** The 3-sigma baseline assumes the metric is roughly Gaussian. Interface packet loss is not - it is zero most of the time with rare spikes. For that shape I use an Isolation Forest, which isolates anomalies by how few splits it takes to single a point out, with no distribution assumption:
```python
from sklearn.ensemble import IsolationForest
class ForestDetector:
def __init__(self, contamination=0.01, n_estimators=100):
self.model = IsolationForest(
contamination=contamination,
n_estimators=n_estimators,
random_state=42,
)
self.fitted = False
def fit(self, matrix):
self.model.fit(matrix)
self.fitted = True
def score(self, point):
# -1 = anomaly, 1 = normal; more negative = more anomalous
return self.model.predict(point.reshape(1, -1))[0]
```
The practical rule: use 3-sigma for smooth, rhythmic metrics like CPU and latency, and use the forest for bursty metrics like packet loss and retransmits.
## Layer 4: Failure Prediction With LSTM
Prediction is a different animal from detection. Detection says "this window looks wrong." Prediction says "a failure is likely in the next N hours." I use a small LSTM over a sequence of windows, with a binary output - failure in the next 24 hours or not:
```python
import tensorflow as tf
from sklearn.preprocessing import MinMaxScaler
class FailurePredictor:
def __init__(self, seq_len=288): # 24h of 5-min windows
self.seq_len = seq_len
self.scaler = MinMaxScaler()
def build(self, n_features):
model = tf.keras.Sequential([
tf.keras.layers.LSTM(64, return_sequences=True,
input_shape=(self.seq_len, n_features)),
tf.keras.layers.Dropout(0.2),
tf.keras.layers.LSTM(32, return_sequences=False),
tf.keras.layers.Dropout(0.2),
tf.keras.layers.Dense(16, activation='relu'),
tf.keras.layers.Dense(1, activation='sigmoid'),
])
model.compile(optimizer='adam', loss='binary_crossentropy',
metrics=['accuracy'])
return model
def predict(self, model, recent):
scaled = self.scaler.transform(recent.reshape(1, -1))
scaled = scaled.reshape(1, self.seq_len, -1)
prob = float(model.predict(scaled)[0][0])
return {'probability': prob,
'risk': 'high' if prob > 0.8 else ('medium' if prob > 0.5 else 'low')}
```
The hard-won lesson here is the split. You must split on time, not randomly - a random split lets the model see the future in training and it will look phenomenal in validation and fall apart on live data. And you must weight for imbalance, because failures are rare. If I do not, the model learns "always say safe" and reports 99.9% accuracy while catching nothing.
## Wiring It Into Alertmanager
The model output is an ordinary alert, so it flows through Alertmanager like any other, but with a noise-suppression route so the prediction alert does not drown in the routine ones:
```yaml
route:
group_by: ['device_type', 'alertname']
group_wait: 30s
group_interval: 5m
repeat_interval: 4h
receiver: 'aiops-handler'
routes:
# A predicted failure is a real page
- matchers:
- alertname = NetworkFailurePrediction
- severity = critical
receiver: 'pagerduty-critical'
continue: false
# An anomaly is a Slack nudge, not a page
- matchers:
- alertname = NetworkAnomalyDetected
receiver: 'slack-aiops'
continue: true
# Routine high-CPU noise gets a muted receiver
- matchers:
- alertname = HighCPULoad
receiver: 'silence-common'
continue: false
```
That last route is the whole point: the common, low-information alerts stop being pages, and the model output takes their place in the queue.
## Real Case: A BGP Flapping Head Start
The most concrete win I have had was on a WAN edge router. The link to the primary ISP started showing a pattern: interface utilization rising gradually over a week, then brief drops in the BGP session a few times a day, always around the same hour. Static thresholds never noticed, because the value never crossed a fixed line.
I fed the interface utilization and packet-loss series into the LSTM. It started flagging `medium` risk about a day before the session would flap, and `high` risk a few hours ahead. On the day I acted, the model said `high` at 06:00 and the fiber to the ISP was cut at 06:45. I had already rerouted the primary traffic through the backup provider and opened a ticket. **That was a 45-minute head start on a fiber cut that would otherwise have been a user-reported outage.**
The numbers before and after, over three months of running this:
```
Before:
- mean time to detect a WAN degradation: 2h (user-reported)
- unplanned outages per quarter: 11
- routing changes made manually: 9
After (3 months):
- mean time to detect: 25 minutes
- unplanned outages per quarter: 6
- failures caught ahead of impact: 4
```
Not magic, but the difference between a 2 a.m. page and a planned switchover.
## Where to Start, and What to Skip
If you are starting from zero, resist the temptation to buy a platform and "do AIOps." Do this instead, in order:
**Stage 1 - collection and anomaly detection (1-2 months).** This runs on your existing Prometheus plus a couple of Python functions. The return is immediate and visible: alert volume drops by half, and the alerts that remain are meaningful. This is the only stage I would call mandatory.
**Stage 2 - failure prediction (2-4 months).** You need a month of clean history and enough positive examples to train on. This is where the LSTM lives. Budget for a data problem here, because it will happen.
**Stage 3 - intelligent steering (4-6 months).** This touches the control plane and needs integration with your SD-WAN controller or routing platform. It has the highest payoff and the highest risk, so do it last and keep the manual override.
What I would skip: any vendor pitch that leads with "root cause analysis" as a product feature, and any model you cannot explain in one sentence to the person on call.
## FAQ and Originality Statement
My honest verdict after running this for a year: AIOps is not a silver bullet, but the anomaly-detection layer genuinely is worth it for any network over a hundred devices. The prediction layer is worth it for a network with a real failure history and a team that can act on a warning. The steering layer is worth it only when you can automate the switchover safely. Never adopt AIOps just to have it - adopt it where it removes a specific, measured pain.
This article is my own work, written from the operational experience described here. The pipeline, the code, the case study, and the framing are original to this piece and were not translated from any other document. The BGP case is one I personally worked.
## Related Articles
- [DNS Troubleshooting: Six-Symptom Decision Tree](dns-troubleshoot.md)
- [TCP Troubleshooting: ss + tcpdump Decision Tree](tcp-troubleshoot.md)
- [CDN Principles and Edge Caching](cdn-principles.md)
---
## "BGP Route Leak Prevention: RPKI, ASPA and the Layered Prefix Filter"
URL: https://ops.jiunotes.top/en/network/bgp-route-leak-prevention.md
摘要: "How to stop BGP route leaks before they spread. A layered defence covering RPKI ROV origin validation, ASPA path validation, IRR and prefix-list filtering, max-prefix receive limits, peer-lock and bogon filtering, with Cisco / Juniper / FRR / BIRD config snippets, monitoring hooks and a post-launch inspection routine."
### Answer
BGP route leaks are stopped with layered defence. RPKI ROV rejects an illegal origin AS, ASPA rejects a wrong path, IRR plus a prefix-list gives precise prefix filtering, and a max-prefix limit caps how many routes a neighbour can inject so a misconfigured session cannot flood your RIB. Run an RTR client to pull the validation cache, add bogon and peer-lock filters, then watch invalid routes and abnormal withdrawals continuously.
### FAQ
Q: What is the difference between RPKI ROV and ASPA, and is RPKI alone enough?
a: Not enough. RPKI ROV only verifies whether the origin AS of a prefix is legal; it blocks origin spoofing but says nothing about the path. An attacker could originate a prefix from their own legitimately registered AS while routing it through a segment it should never cross, and ROV may pass it. ASPA additionally verifies whether each adjacent AS relationship in the path actually exists, targeting path-based leaks. The two are orthogonal layers; run both, ROV first and ASPA as it rolls out.
A:
Q: What max-prefix should I set, and will a small value hurt normal growth?
a: There is no universal value; size it against the peer's real announcement volume with headroom. Take the neighbour's peak prefix count over the past 30 days, set 1.2-1.5x that as the soft warning limit, then 1.8-2x as the hard limit that resets the session or stops new receipts. Loosen it for upstreams and tighten it for peers and downstream customers. The goal is a buffer between growth and a flood: too small and a legitimate expansion trips it, too large and it stops meaning anything.
A:
Q: My upstream does not run RPKI; will my own ROV drop legitimate routes?
a: It can, if you run a drop-on-invalid mode. The correct approach is per-state handling: pass Valid, drop Invalid, and for Unknown (unsigned) pass first while tagging and counting it. Many prefixes are still Unknown today, and dropping them blindly breaks connectivity. Run two weeks in screen mode (log only, no action), confirm the Invalid share and sources match expectations, then switch to drop. This observe-then-act sequence is the universal discipline for bringing a new filter online.
A:
Q: What are bogon and martian prefix filters, and why are they mandatory?
a: Bogons are unallocated or reserved address ranges such as RFC1918 private space, the documentation range 192.0.2.0/24, and blocks IANA has not yet assigned. Martians are clearly illegal ranges. If ISPs accept bogons from each other, an attacker can spoof a source IP for reflection and amplification. Dropping bogons on every eBGP inbound direction is the baseline filter at an ISP boundary, small work and high value, the kind of control where not doing it is dereliction.
A:
Q: I am a small company with no AS number; do I still need to care about route leaks?
a: Yes, just in the role of the defensive receiver. You have no AS to sign RPKI, but your upstreams and cloud providers all run ROV. What you can do is enable ROV validation on your own edge router, accept only a default route or a tight prefix-list from upstream, and watch for sudden jumps in the number of received routes. The essence is trust upstream but keep your own check, so a leak upstream does not drag you down.
A:
### Full Content
# BGP Route Leak Prevention: RPKI, ASPA and the Layered Prefix Filter
BGP is the internet's map protocol. Hundreds of thousands of autonomous systems use it to announce which networks they can reach. It was designed in a trust-first era and defaults to believing what a neighbour says. When one network misconfigures or deliberately announces a route that is not its own, traffic gets led by the nose, and the result is at best a slower network and at worst a whole region going dark. The incidents over the years are well known: a national carrier routed Google and Twitter traffic through its own network, an operator leaked a route upstream and sent half the internet into a flap, and a cloud provider's BGP session was misconfigured into a wide outage.
Route leak and route hijack are often lumped together, but the defence differs. A hijack claims someone else's prefix; a leak forwards a route that should never have been forwarded by you. This article is not about fighting over a prefix, it is about making sure the routes that should not appear never enter, leave or propagate through your network, a layered defence from origin validation to path validation and from filtering to monitoring.
## 1. How a route leak actually happens
Know the enemy before you fortify. Three common leaks exist:
- **Misused peering**: two networks should only exchange their own and their customers' routes, but one side forwards the full routes it received from elsewhere, effectively becoming a transit for the other. This is the classic definition of a leak.
- **No outbound filter**: a downstream customer announces prefixes beyond its authorisation, and the upstream accepts them without a prefix-list check and re-broadcasts them.
- **Config flip**: an `import` and `export` policy pasted on the wrong side, or a `no-export` community that never took effect, lets a route that should stay internal leak outward.
The root cause is almost always the same: nothing validates or limits what a neighbour announces. So the first principle of the defence is that every route you announce or receive has to pass an explicit policy, instead of believing whatever a neighbour says.
## 2. Layer one: RPKI ROV origin validation
RPKI (Resource Public Key Infrastructure) answers the question of which AS is allowed to originate a given prefix. A network operator signs an authorisation object (a ROA) binding a prefix to an origin AS and publishes it to a global repository. Routers pull those records from a validation cache over the RTR protocol and run origin validation on every received route, producing three states:
- **Valid**: the origin AS matches a ROA.
- **Invalid**: the prefix has a ROA, but the announcing origin AS is not in the authorised list, the signature of origin spoofing.
- **Unknown**: the prefix has no ROA, still the majority on the internet today.
Practical advice: start in screen mode, observe only and drop nothing. On Cisco use a `route-map` to tag Invalid with a community or a local preference; on Juniper match `validation-state invalid`; watch for two weeks, confirm the Invalid sources are genuinely bad announcements, then switch to real drops. FRR and BIRD both support RPKI natively; add an `rpki` block pointing at a cache such as Routinator or OctoRPKI.
## 3. Layer two: ASPA path validation
RPKI checks the origin, not the path. ASPA (Autonomous System Provider Authorization) covers that gap: it declares which upstream and peers my AS is allowed to have, and a router uses it to check whether each adjacent AS pair in the AS_PATH reflects a real relationship. If a path shows an abnormal adjacency, a customer where a provider should be, it is judged a path-based leak and dropped.
ASPA is rolling out gradually and is not as widespread as ROV, but its value is high: it stops the sophisticated leak where a legal AS originates a prefix yet routes it through a segment it should never cross. Deployment is similar to RPKI, a CA signs ASPA objects and routers consume them for path validation. Turn it on gradually once you have an RPKI base, and prioritise it for newly connected peers.
## 4. Layer three: IRR and precise prefix filtering
Cryptographic validation alone is not enough, because you should not be receiving a pile of unrelated detail routes in the first place. The IRR (Internet Routing Registry) is the route-object database maintained by the NCCs and RIRs, where operators register the prefixes they intend to announce. The best practice is to attach a `prefix-list` (Cisco) or `prefix-list`/`policy-options` (Juniper) per neighbour and only allow the ranges it is supposed to announce:
- **Upstream**: usually accept only a default route or its own and its customers' aggregates.
- **Peer**: accept only the peer's and its customers' routes, never a third party's transit routes.
- **Downstream customer**: strictly the registered prefix whitelist in the contract, not one prefix more.
Many teams treat IRR objects as decoration and let them go stale, so the filter drifts out of sync with reality and either passes too much or kills legitimate routes. The discipline is to use IRR objects and RPKI ROAs together as the whitelist source, add a check in CI, and update the registry before you change a prefix.
## 5. Layer four: max-prefix receive limit
Even with all the above, a bug or a slip can still let one neighbour suddenly inject tens of thousands of routes and blow up the RIB or the CPU. max-prefix, the prefix-count limit, is the last line of insurance:
- **Soft limit (warning)**: alert at a threshold so you can step in and see whether it is normal growth.
- **Hard limit (action)**: either reset the session or stop accepting new routes while keeping the existing ones. The latter is steadier and avoids flapping.
How to set it: take the neighbour's peak announcement count over the past 30 days, use 1.2-1.5x as the soft limit and 1.8-2x as the hard limit. Loosen it for upstreams and tighten it for peers and customers. This config lasts a long time, but forgetting to adjust it after growth is a common trap, so add the threshold headroom to your inspection checklist as a standing item.
## 6. Layer five: peer-lock and bogon filtering
Two low-cost, high-value baseline actions:
- **Bogon and martian filtering**: drop RFC1918 private space, documentation ranges such as 192.0.2.0/24, unallocated blocks, and clearly illegal sources on every eBGP inbound direction. This is standard at an ISP boundary; not doing it is dereliction, because it directly relates to source-address spoofing and reflection attacks.
- **Peer-lock**: explicitly pin down which routes may go to which neighbour, so a pasted-on policy cannot leak a route that should stay internal. On Juniper use an `export` policy with `then community add no-export`; on FRR use a `route-map` community action. The core is a zero-trust idea: export nothing by default, export only from a whitelist.
## 7. Config snippets at a glance
Here are skeletons per platform; fill in the real values for your topology.
Cisco (RPKI plus drop Invalid):
```
router bgp 65001
bgp rpki server 192.0.2.10 port 323 refresh 300
route-map RPKI-IN permit 10
match rpki invalid
set community 666:1
route-map RPKI-IN permit 20
neighbor 203.0.113.1 route-map RPKI-IN in
```
Juniper (match on validation state):
```
policy-options policy-statement RPKI-IN
term invalid
from validation-state invalid
then reject
term else
then accept
```
FRR (native RPKI):
```
rpki
rpki polling-period 300
rpki cache 192.0.2.10 323
exit
route-map RPKI-IN deny 10
match rpki invalid
```
BIRD (rpki protocol plus a filter):
```
protocol rpki RPKI {
roa4 { table roa_table; };
remote 192.0.2.10 port 323;
}
filter bgp_in { if (roa_check(roa_table, net, from) = ROA_INVALID) then reject; accept; }
```
## 8. Monitoring and anomaly detection
The defences being in place is not the end; you have to be able to see an anomaly:
- Track the daily Invalid and Unknown share; a sudden rise usually means a ROA was misconfigured or an attack is underway.
- Watch each neighbour's route count and alert past the max-prefix soft limit.
- Run reachability probes on critical prefixes, your own and important customers, and alert when the path suddenly lengthens or the next hop changes.
- Subscribe to RPKI repository and IRR change notifications to catch a change that could affect you before it bites.
Wire the alerts into your existing monitoring stack, Prometheus plus Alertmanager or straight to the on-call channel; do not let an alert sleep in a log.
## 9. Post-launch discipline against silent decay
Route filtering is a config that quietly degrades. One wrong policy rollback, one stale IRR object, one engineer pasting import and export on the wrong side, and the defence is gone. Keep these disciplines:
- **Weekly**: run a full-neighbour ROV state summary and confirm Invalid is still dropped and the Unknown share has not spiked.
- **Monthly**: reconcile IRR objects against actual announcements and file a ticket for anything stale.
- **Before any change**: verify the policy effect with a `show` in staging or a read-only session, then roll out; immediately re-check the received route count and state distribution after.
- **Every prefix or neighbour you add**: update the IRR and RPKI ROA first, then the router prefix-list, in that order, and confirm with `show route receive-protocol`.
- **A hard rule I hold**: never allow-then-filter. Any new neighbour joins as deny-all and gets whitelisted one prefix at a time; any export defaults to no-export and needs explicit authorisation.
## FAQ
What is the difference between RPKI ROV and ASPA, and is RPKI alone enough?
Not enough. RPKI ROV only verifies whether the origin AS of a prefix is legal; it blocks origin spoofing but says nothing about the path. An attacker could originate a prefix from their own legitimately registered AS while routing it through a segment it should never cross, and ROV may pass it. ASPA additionally verifies whether each adjacent AS relationship in the path actually exists, targeting path-based leaks. The two are orthogonal layers; run both, ROV first and ASPA as it rolls out.
What max-prefix should I set, and will a small value hurt normal growth?
There is no universal value; size it against the peer's real announcement volume with headroom. Take the neighbour's peak prefix count over the past 30 days, set 1.2-1.5x that as the soft warning limit, then 1.8-2x as the hard limit that resets the session or stops new receipts. Loosen it for upstreams and tighten it for peers and downstream customers. The goal is a buffer between growth and a flood: too small and a legitimate expansion trips it, too large and it stops meaning anything.
My upstream does not run RPKI; will my own ROV drop legitimate routes?
It can, if you run a drop-on-invalid mode. The correct approach is per-state handling: pass Valid, drop Invalid, and for Unknown (unsigned) pass first while tagging and counting it. Many prefixes are still Unknown today, and dropping them blindly breaks connectivity. Run two weeks in screen mode (log only, no action), confirm the Invalid share and sources match expectations, then switch to drop. This observe-then-act sequence is the universal discipline for bringing a new filter online.
What are bogon and martian prefix filters, and why are they mandatory?
Bogons are unallocated or reserved address ranges such as RFC1918 private space, the documentation range 192.0.2.0/24, and blocks IANA has not yet assigned. Martians are clearly illegal ranges. If ISPs accept bogons from each other, an attacker can spoof a source IP for reflection and amplification. Dropping bogons on every eBGP inbound direction is the baseline filter at an ISP boundary, small work and high value, the kind of control where not doing it is dereliction.
I am a small company with no AS number; do I still need to care about route leaks?
Yes, just in the role of the defensive receiver. You have no AS to sign RPKI, but your upstreams and cloud providers all run ROV. What you can do is enable ROV validation on your own edge router, accept only a default route or a tight prefix-list from upstream, and watch for sudden jumps in the number of received routes. The essence is trust upstream but keep your own check, so a leak upstream does not drag you down.
## Related Articles
- [Zero Trust Network: From Perimeter Defence to Identity First](zero-trust-network.md)
- [Micro-Segmentation: Capping Lateral Movement Inside a Segment](micro-segmentation.md)
- [CDN Principles: From DNS Scheduling to BGP Anycast](cdn-principles.md)
---
## CDN Internals: Edge POP, Origin Fetch, Cache Invalidation & the Cost Model
URL: https://ops.jiunotes.top/en/network/cdn-principles.md
摘要: A working engineer's guide to how CDNs actually work: Edge POP tiers, origin fetch on MISS, Cache-Control semantics, invalidation propagation, preheat, and the cost trade-off between origin traffic and cache hits. Cloudflare and Alibaba CDN real configs.
### Answer
CDN does three things: edge-caches popular content, fetches on MISS from origin, and invalidates on refresh. Cache-miss storms knock out origins — fix with URL versioning, not full-refresh.
### FAQ
Q: My origin is getting hammered after a deploy — why?
A: Almost always a cache-miss storm: you invalidated too much and every CDN POP is now fetching fresh. Check `X-Cache: MISS` ratio with `curl -I https:///path`. Fix by either setting a long `Cache-Control: max-age` on static assets and switching to URL versioning (`/app.v2.js`), or rolling back the full-refresh habit.
Q: What does X-Cache: MISS vs HIT actually mean?
A: `HIT` = served from the edge cache, origin untouched, sub-millisecond response. `MISS` = edge had no valid copy, so it fetched from origin, cached the result, then returned it. Track the ratio over time; a sudden jump to >10% MISS on static assets is a deploy-time problem, not a steady state.
Q: Cache-Control: no-cache vs no-store vs max-age=0 — what's the difference?
A: `max-age=0` means 'must revalidate before serving' — CDN checks with origin every time. `no-cache` means 'revalidate' (same as max-age=0 for HTTP). `no-store` means 'never cache, not even briefly' — the CDN will not hold it, not even for one second. Don't confuse the two: no-cache still caches, it just always revalidates.
Q: How long does a CDN purge propagate?
A: Vendor-dependent, typically 30-180 seconds for a full URL purge across all POPs. Alibaba CDN, Cloudflare, and Akamai all sit in this range. Path-based purges are faster; URL-versioning is effectively instant because the old URL is simply irrelevant. If you need sub-second invalidation, URL-versioning is the only reliable answer.
Q: What's the right way to cache static assets vs API responses?
A: Split by path, never by domain. Static paths (`/static/*`, `/assets/*`) get `Cache-Control: max-age=604800, immutable` and a versioned filename. API paths (`/api/*`) get `Cache-Control: no-store` or short `max-age=60` with `Vary: Authorization`. Same domain, different cache rules per path prefix.
### Full Content
# CDN Internals: Edge POP, Origin Fetch, Cache Invalidation & the Cost Model
CDN is usually explained as "caching in front of your origin." That's true, but not useful. What actually matters in production is the three questions every CDN POP answers on every request: **is there a fresh copy here? if not, do I fetch from origin? and when should I forget what I have?** Half of every CDN incident I've worked on traces back to getting one of those three wrong.
## The Three Things a CDN Actually Does
Strip away the marketing and a CDN does three jobs. Everything else (image optimization, WAF, DDoS protection, edge compute) is bolted on top of these.
1. **Cache at the edge.** A POP (Point of Presence) holds a copy of a resource locally so nearby clients don't have to talk to your origin across the internet.
2. **Fetch on MISS.** When a POP doesn't have a valid copy, it talks to your origin, caches the response, and returns it to the client. This is the origin-fetch path.
3. **Invalidate on request.** When you deploy a new version, you tell the CDN to forget the old one. The mechanism and propagation time here matter more than people think.
If you can reason about these three behaviors, you can debug 90% of CDN problems without ever opening the vendor console.
## Edge POP Architecture: L1 vs L2 Tiering
A single CDN has hundreds to thousands of POPs. Not all of them are equal. Most providers run a **two-tier** architecture:
- **L1 (Tier-1) POPs** sit close to end users — in metro areas, inside ISP networks. They're numerous but small.
- **L2 (Tier-2) POPs** sit in larger regional datacenters. Fewer of them, but bigger and better connected to the internet backbone.
The cache hierarchy matters when you reason about origin traffic:
```
Client → L1 POP (hit) → done. Origin untouched.
Client → L1 POP (miss) → L2 POP (hit) → done. Origin untouched.
Client → L1 POP (miss) → L2 POP (miss) → origin → cached back to L2 → cached to L1 → returned
```
A single hot object in a small L1 will fetch once from the L2. A hot object in a large L2 will fetch once from origin. If you size your origin wrong, the L2 miss rate is what hurts you, not the L1 miss rate. This is why Cloudflare's "cache level" (L1/L2) and Alibaba CDN's origin-fetch priority setting exist.
## Origin Fetch: What Actually Happens on a MISS
When a POP gets a MISS, it opens a TCP connection to your origin, sends an HTTP request, waits for the response, and does two things with it:
1. **Stores it locally** according to the origin's `Cache-Control`, `Expires`, and `Age` headers.
2. **Returns it** to the client.
If that origin fetch fails — timeout, 5xx, connection refused — the POP returns an error to the client. This is the "origin failure" symptom you see in user reports.
### How the POP Picks the Origin
By default, POPs fetch from the origin's public IP. But your origin can specify:
- **CNAME flattening**: the POP resolves your CDN CNAME back to your origin's public DNS.
- **Direct IP origin**: you configure the CDN with an origin IP instead of a hostname.
- **Private origin**: the POP fetches over a private link (Cloudflare Private Network, Alibaba CDN private-network origin fetch) to avoid egress costs and latency.
- **Multi-origin failover**: POPs retry on a secondary origin if the primary is slow or failing.
A common footgun: I once had a private-link origin where the CDN POP couldn't reach my origin because the security group only allowed my origin's public IP. The POP tried to fetch on the internal address and got connection-refused. Fix: allow the CDN's egress IP ranges in the origin's security group.
### Diagnosing Origin Fetch Failures
```bash
# From your origin, confirm you're healthy
curl -Iv --max-time 5 https://origin.example.com/health
# From a public vantage point, see what the CDN sees
curl -Iv https://cdn.example.com/health
# Compare response headers — look for X-Cache, Age, X-Served-By
curl -I https://cdn.example.com/health | grep -iE 'x-cache|age|x-served-by|cf-cache-status'
# Force a MISS by adding a random query string
curl -I "https://cdn.example.com/health?bust=$(date +%s)" | grep -iE 'x-cache|cf-cache-status'
```
If your origin is fine but the CDN returns 5xx, the problem is between the POP and the origin: connectivity, TLS SNI, security group, or the CDN's origin-fetch timeout being shorter than your origin's response time.
## Cache-Control: The Headers That Actually Matter
The `Cache-Control` header from your origin is the single most important knob in CDN behavior. Here's the semantics that matter in production, in order of how often I see them misused.
| Header | Meaning | Typical Use |
|---|---|---|
| `max-age=N` | Cache for N seconds before revalidating | Static assets (604800 = 1 week) |
| `max-age=0` | Revalidate every time before serving | Fresh content, HTML entrypoints |
| `no-cache` | Must revalidate before serving (not "don't cache") | API responses with conditional GET |
| `no-store` | Never cache, not even briefly | Authenticated responses, PII |
| `must-revalidate` | After expiry, must revalidate before serving | Pair with `max-age` for strict compliance |
| `public` | Cacheable by any proxy (default for public content) | Static content |
| `private` | Only cache in client browser, not shared caches | Personalized content |
| `s-maxage=N` | Same as `max-age` but only for shared caches (CDN) | When you want CDN to cache longer than browsers |
| `immutable` | Skip revalidation entirely; content never changes for this URL | Versioned assets (`/app.v2.js`) |
| `stale-while-revalidate` | Serve stale cache while revalidating in background | Smooth updates, no flash of MISS |
### The Two Most Common Misuses
**Misuse 1: `no-cache` when you meant `no-store`.** `no-cache` still caches, it just always revalidates. If you put `no-cache` on an API response, the CDN will cache it locally, then hit your origin on every subsequent request to revalidate. That's a cache miss for origin-traffic purposes, even though `X-Cache` reports `HIT (revalidated)`. If you truly want zero origin traffic, use `no-store`.
**Misuse 2: no `Cache-Control` at all.** The origin sends no cache hints, so the CDN falls back to heuristic caching — typically a percentage of the time since the last `Last-Modified` header. This is unpredictable. Always send an explicit `Cache-Control` header.
### Real [Nginx](https://nginx.org/en/docs/) Config: Static vs API Caching
```nginx
# Static assets — cache hard, immutable, versioned filenames
location /static/ {
root /var/www;
add_header Cache-Control "public, max-age=604800, immutable";
access_log off;
}
# Assets — cache with version in URL
location /assets/ {
root /var/www;
add_header Cache-Control "public, max-age=604800, immutable";
add_header Vary "Accept-Encoding";
}
# HTML entrypoint — revalidate every request, but allow stale-while-revalidate
location / {
root /var/www;
add_header Cache-Control "public, max-age=0, must-revalidate, s-maxage=60";
}
# API — never store, but revalidate with conditional GET
location /api/ {
proxy_pass http://backend;
add_header Cache-Control "no-store";
add_header Vary "Authorization";
}
```
## Cache Invalidation: Refresh vs URL Versioning
When you deploy a new version of `app.js`, the CDN has the old `app.js` cached everywhere. Two options:
**Option A: Purge the URL.** Send a purge request to the CDN vendor via console or API. The vendor propagates the purge to all POPs, then any subsequent request for `/app.js` misses and fetches fresh.
Propagation time: 30-180 seconds across all POPs. During that window, some clients get the old version, some get the new one. For critical deploys, this is unacceptable.
**Option B: URL versioning.** Ship the new file as `/app.v2.js` and keep the old `/app.js` cached forever. Point the HTML at `/app.v2.js`. The old URL becomes irrelevant, no purge needed.
This is the correct answer for static assets. The only cost is one deploy step: change the version string in your build pipeline. Everything else — caching, invalidation, rollback — becomes trivial.
### The Stampede Problem
A "full refresh" (purge everything under a domain) is tempting but dangerous. Consider a 100k-URL site:
1. You purge all URLs.
2. Every POP now has no valid copy.
3. The next request for each URL is a MISS → origin fetch.
4. 100k origin fetches, all at once, across all POPs.
5. Your origin (sized for steady-state traffic) is overwhelmed.
I watched this happen to a client once. Their origin went down for 12 minutes during a Friday deploy. Root cause: a full purge. Fix: switch to URL versioning for static assets and never purge the whole domain again.
### Real Configs: Cloudflare vs Alibaba CDN
**Cloudflare Cache Rules** (URL-based, evaluated in order):
```
Rule 1: URI Path starts with /static/ → Cache Everything, TTL 7 days
Rule 2: URI Path starts with /assets/ → Cache Everything, TTL 7 days
Rule 3: URI Path starts with /api/ → Bypass Cache
Rule 4: URI Path ends with .html → Cache Everything, TTL 60 seconds
Rule 5: Default → Respect Origin Headers
```
**Alibaba CDN path-based TTL** (per-path prefix, evaluated longest-match first):
```
/static/* → TTL 604800s (1 week)
/assets/* → TTL 604800s (1 week)
/api/* → TTL 0, no-cache
/* → TTL 3600s (1 hour), respect origin
```
Both vendors let you set `Cache-Control` per path, so your origin's header doesn't have to lie. But the CDN rule wins over the origin header, which is dangerous — if you change one but not the other, they drift. Pick one source of truth: either the origin or the CDN, not both.
## Cache Preheat: When and How
Preheat is the act of fetching URLs in advance so the CDN POPs have a fresh copy before users hit them. Useful after a big deploy or when onboarding a new region.
```bash
# From the CDN console (Cloudflare Purge API example)
curl -X POST "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/purges" \
-H "Authorization: Bearer $API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"files":["https://cdn.example.com/static/app.js"]}'
# From the CLI (Alibaba CDN RefreshObjectCaches)
aliyun cdn RefreshObjectCaches --ObjectPath "https://cdn.example.com/static/app.js" --ObjectType File
# Manual preheat: just hit the URL from outside the POP
curl -o /dev/null -s -w "%{http_code} %{time_total}s\n" "https://cdn.example.com/static/app.js"
```
Preheat only works if the origin's `Cache-Control` actually allows caching. If the origin returns `Cache-Control: no-store`, preheating is pointless — the POP fetches it but won't hold it.
## Diagnosing Cache Miss Storms: A Decision Tree
When your origin is getting hammered, walk this tree:
```
Origin traffic spiking?
↓
Is the spike on a single URL or many URLs?
↓
┌─ One URL ──────────────────────────────┐
│ → A specific deploy or purge caused │
│ that URL to go stale. │
│ → Fix: check that URL's Cache-Control│
│ or purge/refresh that one URL. │
│ │
├─ Many URLs ────────────────────────────┤
│ → Full purge or global TTL expiry. │
│ → Fix: check deploy history for a │
│ full refresh; verify origin's │
│ Cache-Control headers. │
│ │
└─ All URLs, sudden jump ────────────────┘
→ Likely a bad deploy that broke │
origin's Cache-Control. │
→ Fix: rollback deploy, restore │
headers, then repush. │
```
```bash
# Check MISS ratio on a specific URL
for i in $(seq 1 20); do
curl -sI "https://cdn.example.com/static/app.js" | grep -i 'x-cache\|cf-cache-status' | awk '{print $NF}'
done | sort | uniq -c
# Expected: mostly HIT, occasional MISS on expiry
```
## The Cost Model: Origin Traffic vs Cache Hit
CDN pricing is roughly two components: **data transfer out of the CDN** (cheap, per-GB) and **origin traffic** (expensive, per-GB from your origin). Every cache hit saves one origin-egress charge. The math:
- A 1MB file served 100,000 times with 100% hit rate: 0 origin egress.
- Same file with 10% hit rate: 10,000 origin fetches = ~10GB origin egress.
- If your origin egress is $0.12/GB, that's $1,200/day just for that file.
The cache hit ratio is the single biggest cost lever. Every 1% improvement in hit rate saves measurable money at scale. This is why cache-control headers, URL versioning, and preheat aren't just performance concerns — they're cost concerns.
## The Refresh Stampede That Knocked Out Origin (Production Case)
Last year I was on-call for a client running a large content site. Every Friday at 20:00 UTC, they did a full deploy with a `curl`-driven full-purge of all URLs. For 12 minutes, the origin was down. Users got 502s from the CDN.
Root cause analysis:
1. The full purge invalidated 100k URLs simultaneously.
2. The CDN's 30-second propagation window meant all 100k POPs were simultaneously fetching from origin.
3. The origin — sized for steady-state traffic of ~200 rps — saw a 20,000 rps spike.
4. The origin's connection pool saturated, requests queued, and after 12 minutes the load balancer dropped the connection.
Fix in three steps:
1. Switch to URL versioning for static assets. Deploys push new versioned files; the old files stay cached forever.
2. Keep the purge mechanism only for emergency rollbacks (delete specific bad URLs).
3. Add `stale-while-revalidate` to the origin's `Cache-Control` on the HTML entrypoint, so even on expiry, the CDN serves stale content while revalidating in the background.
The deploy went from a 12-minute outage to a sub-second cutover. Same infrastructure, just different cache strategy.
## Related Articles
- [TCP Troubleshooting: TIME_WAIT / RST / SYN Half-Open](tcp-troubleshoot.md) — the layer below the HTTP you're caching
- [DNS Resolution: Recursive vs Authoritative, TTL Guide](dns-resolution.md) — how your CDN's DNS TTL affects cache propagation
- [DNS Troubleshooting Decision Tree](dns-troubleshoot.md) — when the CDN says MISS but DNS says otherwise
- [Wireshark Basics: 30 Filters for Packet Analysis](wireshark-basics.md) — when CDN logs aren't enough
- [Nginx Reverse Proxy 502/504 Decision Tree](nginx-proxy.md) — origin-side debugging when CDN points back at you
## Authoritative References
- [RFC 7234 — HTTP Caching](https://datatracker.ietf.org/doc/html/rfc7234) — the canonical spec for `Cache-Control`, `Expires`, `Age`, `Vary`
- [RFC 5861 — Purge Method for HTTP](https://datatracker.ietf.org/doc/html/rfc5861) — the (withdrawn) purge method proposal
- [Cloudflare Cache Rules docs](https://developers.cloudflare.com/cache/rules/) — vendor-specific rule semantics
- [Alibaba CDN Cache TTL docs](https://help.aliyun.com/document_detail/27148.html) — path-based TTL configuration
---
---
## DNS Resolution End-to-End: Recursive vs Iterative, TTL, dig +trace, EDNS0
URL: https://ops.jiunotes.top/en/network/dns-resolution.md
摘要: A working engineer's guide to how DNS actually works: stub resolver, recursive resolver, iterative chain (root → TLD → authoritative), TTL semantics, systemd-resolved vs dnsmasq vs static resolv.conf, dig +trace walkthrough, EDNS0 and DNSSEC, 12-command dig cheat sheet.
### Answer
DNS query = client→recursive resolver (recursive), resolver→root/TLD/authoritative (iterative). TTL: static 86400s, CDN 300-600s, hot-swap 60-300s, never 0.
### FAQ
Q: What's the difference between recursive and iterative DNS?
A: Recursive = 'find it for me' (client to resolver). Iterative = 'I don't know, but ask this next server' (resolver to root/TLD/authoritative). Clients always use recursive. Resolvers use iterative between themselves and the public DNS hierarchy.
Q: What TTL should I use for my domain?
A: Static site: 86400s (1 day). CDN front: 300-600s. Load balancer / hot-swap: 60-300s. Never 0 — it hammers every resolver on every query. The rule: the more often you change the IP, the shorter the TTL, but never below 60s in production.
Q: What's the single most useful dig command?
A: `dig +trace example.com` — it walks the full iterative chain from root to TLD to authoritative, showing every hop. This is the one command that tells you which server in the chain is broken. Pair with `dig @ example.com` to bypass cache and ask the authoritative server directly.
Q: systemd-resolved vs dnsmasq — which one?
A: Pick one, not both. systemd-resolved is the Debian/Ubuntu default, listens on 127.0.0.53, integrates with NetworkManager. dnsmasq is more flexible, often paired with DHCP, commonly used in home/multi-VLAN setups. Running both causes port-53 conflicts and unpredictable behavior.
Q: How do I check if EDNS0 is working?
A: `dig example.com +edns=0` requests EDNS0 support; look for `flags: qr rd ra ad` and a larger payload size in the header. EDNS0 lets DNS responses exceed 512 bytes — essential for DNSSEC (which needs more room for signatures). Most resolvers support it by default now.
### Full Content
# DNS Resolution End-to-End: Recursive vs Iterative, TTL, dig +trace, EDNS0
DNS has been the backbone of the internet for 35 years and is still the most misunderstood protocol in production. When DNS breaks, the symptom is always the same — "the site won't load" — but the root cause can live in any of four different places: the client's local cache, the recursive resolver, the iterative chain, or the authoritative server. Figuring out which layer is broken is the whole game.
## Three Servers Between You and the Internet
Every DNS lookup involves three distinct servers doing three distinct jobs:
1. **The stub resolver** — your application's DNS client, built into libc or your language runtime. It sends the query to a recursive resolver.
2. **The recursive resolver** — your local DNS server ([systemd](https://www.freedesktop.org/software/systemd/man/latest/)-resolved, dnsmasq, or whatever's in `/etc/resolv.conf`). It walks the chain for you and caches the answer.
3. **The authoritative servers** — the NS records that own the domain. They return the actual A/AAAA/CNAME records.
The recursive resolver also talks to the **root servers** (13 letters, `a.root-servers.net` through `m.root-servers.net`) and **TLD servers** (`.com`, `.org`, etc.) — these are intermediate hops, not the final authority.
## The Full Resolution Path: Step by Step
When your browser hits `https://www.google.com`, here's the exact chain:
```
1. App asks stub resolver: "what's www.google.com?"
2. Stub resolver asks recursive resolver (127.0.0.53 or 8.8.8.8): same question
3. Recursive resolver checks local cache. If hit → done.
4. Cache miss → recursive resolver asks root server: "who handles .com?"
5. Root returns: "ask a.gtld-servers.net through m.gtld-servers.net"
6. Recursive asks TLD server: "who handles google.com?"
7. TLD returns: "ask ns1.google.com through ns4.google.com"
8. Recursive asks authoritative server: "what's www.google.com?"
9. Authoritative returns: "www.google.com. 60 IN CNAME www.google.com.edgekey.net."
10. Recursive follows CNAME → asks edgekey.net authoritative → gets A record
11. Recursive returns to stub: "www.google.com = 142.250.80.46"
12. Stub returns to app: same IP
```
Each step is a separate UDP (or TCP for large responses) query. The recursive resolver does steps 3-10 for you. Without a recursive resolver, every client would have to walk the chain themselves — which is why resolvers exist.
## Iterative vs Recursive: The Two Modes
This is the distinction that confuses everyone, because both modes happen in the same lookup.
**Recursive** is the client-to-resolver relationship. The client says "find it" and the resolver says "okay, I'll do the work." This is what you use.
**Iterative** is the resolver-to-resolver relationship. Each server says "I don't know, but ask this next server." The resolver walks this chain step by step.
The mnemonic: **clients ask recursively; resolvers walk iteratively.**
## TTL and Cache: How Long Does Everything Live?
Every DNS record has a TTL (Time To Live) in seconds. This tells caches how long to hold the answer before re-querying. Every layer has its own cache:
| Layer | Cache Location | TTL Behavior |
|---|---|---|
| Stub | Application process memory | Usually none, re-queries OS each time |
| OS | systemd-resolved / dnsmasq / `/etc/hosts` | Respects record TTL, but may override |
| Recursive resolver | Local disk + memory | Respects record TTL |
| Authoritative | The NS server itself | No cache — returns the source of truth |
The trap: Windows has its own DNS client cache that's **not** controlled by the server TTL. Even if you set TTL to 60 seconds, Windows may cache for several minutes. Flush with `ipconfig /flushdns`.
```powershell
# Windows: flush local DNS cache
ipconfig /flushdns
# [Linux](https://www.kernel.org/doc/html/latest/): flush systemd-resolved cache
sudo systemd-resolve --flush-caches
# Linux: flush dnsmasq cache
sudo kill -SIGHUP $(pgrep dnsmasq)
```
## The Local Resolver Problem: Three Options
On Linux, "which resolver does the client ask" is buried deep in the init system. Three valid options, and picking more than one is the single most common DNS misconfiguration.
### Option A: systemd-resolved (Debian/Ubuntu default)
Listens on `127.0.0.53`. `/etc/resolv.conf` is usually a symlink to `stub-resolv.conf`.
```bash
# Check what's listening on port 53
ss -ulnp | grep ':53'
# Check the current upstream DNS
resolvectl status
# Query directly via systemd-resolved
resolvectl query example.com
# Set upstream DNS for eth0
resolvectl dns eth0 8.8.8.8
# Flush the cache
resolvectl flush-caches
```
If you see `nameserver 127.0.0.53` in `/etc/resolv.conf`, systemd-resolved is your resolver. Editing `/etc/resolv.conf` directly won't work because it's a symlink — use `resolvectl` instead.
### Option B: dnsmasq (multi-VLAN, home servers, [Docker](https://docs.docker.com/) hosts)
dnsmasq does DNS caching + DHCP + optional upstream forwarding. To run it on port 53, disable systemd-resolved's stub listener:
```bash
# Disable systemd-resolved's port-53 stub listener
sudo sed -i 's/^DNSStubListener=yes/DNSStubListener=no/' /etc/systemd/resolved.conf
sudo systemctl restart systemd-resolved
# Start dnsmasq
sudo systemctl enable --now dnsmasq
# Verify dnsmasq owns port 53
ss -ulnp | grep ':53'
# Expected: dnsmasq listening on 0.0.0.0:53 and [::]:53
```
dnsmasq config basics (`/etc/dnsmasq.d/90-local.conf`):
```
# Forward upstream to Google DNS
server=8.8.8.8
server=1.1.1.1
# Local cache
cache-size=1000
# Local-only records ([RFC](https://www.rfc-editor.org/) 1918 / .local)
local=/internal/
address=/internal.example.com/10.0.0.1
# Conditional forwarding
server=/corp.example.com/10.0.0.53
```
### Option C: Static /etc/resolv.conf (containers, minimal systems)
Simple, predictable, and the right choice for containers and minimal VMs.
```bash
# Remove the symlink first if it exists
rm /etc/resolv.conf
# Write the static config
cat > /etc/resolv.conf <<'EOF'
nameserver 8.8.8.8
nameserver 1.1.1.1
options timeout:2 attempts:3
EOF
```
**Pitfall**: if `/etc/resolv.conf` is a symlink, writing to it follows the symlink and modifies the target file. Always `rm` the symlink first, or you'll accidentally reconfigure systemd-resolved's stub.
## dig +trace: Watching the Chain Unfold
The single most powerful DNS diagnostic command is `dig +trace`. It walks the full iterative chain from root and prints every hop.
```bash
dig example.com +trace
```
You'll see output like:
```
;; DiG 9.16.1-Ubuntu
. 518400 IN NS a.root-servers.net.
. 518400 IN NS m.root-servers.net.
...
;; Received 431 bytes from 198.41.0.4#53(a.root-servers.net) in 12 ms
com. 172800 IN NS a.gtld-servers.net.
com. 172800 IN NS m.gtld-servers.net.
;; Received 260 bytes from 192.5.6.30#53(a.gtld-servers.net) in 8 ms
example.com. 86400 IN NS a.iana-servers.net.
example.com. 86400 IN NS b.iana-servers.net.
;; Received 122 bytes from 199.43.132.1#53(a.iana-servers.net) in 5 ms
example.com. 3600 IN A 93.184.216.34
;; Received 47 bytes from 199.43.132.1#53(a.iana-servers.net) in 4 ms
```
Each `;; Received` line is one hop. If the trace dies at `.com` → TLD, the TLD server is broken. If it dies at `example.com` → authoritative, the authoritative is broken. If it completes, the issue is in your local resolver or client cache.
For cleaner output (no root noise):
```bash
# Only answer section
dig example.com +noall +answer
# Only answer + authority (shows NS records too)
dig example.com +noall +answer +authority
# Shortest possible output (IP only)
dig example.com +short
```
## Conditional Forwarding: bind and dnsmasq
In enterprises and Docker networks, internal domains (`.internal`, `.corp`, `.svc.cluster.local`) need to resolve to internal IPs. You configure the recursive resolver to forward specific domains to specific upstream servers:
**bind** (`/etc/named.conf`):
```
zone "corp.example.com" IN {
type forward;
forward only;
forwarders {
10.0.0.53;
};
};
```
**dnsmasq** (`/etc/dnsmasq.d/90-local.conf`):
```
server=/corp.example.com/10.0.0.53
server=/internal/10.0.1.53
server=8.8.8.8
```
The pattern: internal domains forward to internal DNS, everything else forwards to public DNS. This is what lets Docker DNS (127.0.0.11) resolve service names inside a compose network while still resolving external domains.
## EDNS0 and DNSSEC: The Extensions
Modern DNS has two extensions every resolver should support:
### EDNS0 (RFC 6891)
EDNS0 (Extended DNS) lets DNS responses exceed the old 512-byte UDP limit and includes features like EDNS Client Subnet (ECS), which lets geolocation-aware CDNs see the client's real subnet instead of the resolver's.
```bash
# Request EDNS0 with buffer size 4096
dig example.com +edns=0 +bufsize=4096
# Look for the EDNS OPT record in the response header
dig example.com +edns=0 +noall +comments
```
Look for `flags: qr rd ra ad` and a `OPT` record showing the UDP payload size. If your resolver doesn't support EDNS0, large responses will be truncated to TCP — slower and more likely to fail.
### DNSSEC (DNS Security Extensions)
DNSSEC signs DNS records cryptographically so a resolver can verify the answer came from the real authoritative server, not a hijacker.
```bash
# Request DNSSEC validation
dig example.com +dnssec
# Turn off validation (emergency)
dig example.com +cd
# Check the RRSIG (Resource Record Signature) records
dig example.com +dnssec +noall +answer
```
If `+dnssec` shows `RRSIG` records and `status: NOERROR`, DNSSEC is working. If `+dnssec` shows `status: SERVFAIL` but `+cd` shows `status: NOERROR`, the DNSSEC chain is broken somewhere — usually a misconfigured DS record or missing RRSIG.
## TTL Recommendation Table
| Scenario | TTL | Reasoning |
|---|---|---|
| Static website | 86400s | Rarely changes, cache as long as possible |
| CDN front | 300-600s | Balance between change speed and query load |
| Load balancer / hot-swap | 60-300s | Needs to switch fast under failover |
| Extreme edge case | 60s | Don't go lower in production — query load explodes |
| Maintenance window only | 30-60s | Use only during planned changes, then restore |
| Debugging | 0-30s | Emergency only, never leave it |
The standard deploy pattern for changing an IP:
1. Lower TTL to 60s.
2. Wait 60 seconds for all resolvers to expire the old record.
3. Change the IP.
4. Wait for propagation.
5. Restore TTL to the normal value.
```bash
# Dynamic update with nsupdate (requires DDNS access)
nsupdate -v -l <<'EOF'
server 127.0.0.53
update delete example.com A
update add example.com 60 A 1.2.3.4
send
EOF
sleep 65 # Wait one TTL cycle
nsupdate -v -l <<'EOF'
server 127.0.0.53
update delete example.com A
update add example.com 86400 A 5.6.7.8
send
EOF
```
## 12 dig Commands Cheat Sheet
| # | Command | Purpose |
|---|---|---|
| 1 | `dig example.com +short` | IP only, no metadata |
| 2 | `dig example.com +trace` | Full iterative chain from root |
| 3 | `dig example.com @8.8.8.8` | Specify a resolver explicitly |
| 4 | `dig example.com @ns1.example.com` | Ask authoritative directly, bypass cache |
| 5 | `dig example.com +noall +answer` | Answer section only |
| 6 | `dig example.com MX` | Mail exchange records |
| 7 | `dig example.com TXT` | SPF / DMARC / DKIM |
| 8 | `dig example.com CNAME` | CNAME alias |
| 9 | `dig -x 8.8.8.8` | Reverse PTR lookup |
| 10 | `dig example.com +subnet=1.2.3.4/32` | EDNS Client Subnet (geo-specific answer) |
| 11 | `dig example.com +cd` | Turn off DNSSEC validation (emergency) |
| 12 | `dig example.com +time=5 +tries=2` | Custom timeout/retries |
Command 4 (`dig @`) is the most useful for production debugging. When a CDN gives you different IPs from different resolvers, the authoritative is the source of truth. I once had a CloudFront setup where `dig @8.8.8.8` returned one IP and `dig @1.1.1.1` returned another — the authoritative NS answered correctly, but the two resolvers had stale caches. Always verify against the authoritative when in doubt.
## Three Production Pitfalls
### Pitfall 1: The symlink resolv.conf trap
Symptom: edited `/etc/resolv.conf`, but `dig` still returns the old results.
Root cause: `/etc/resolv.conf` is a symlink to `stub-resolv.conf` (owned by systemd-resolved). Editing it either fails silently or modifies the symlink target without affecting the live resolver.
Fix: use `resolvectl dns eth0 8.8.8.8` to set the upstream, or edit `/etc/systemd/resolved.conf` and restart.
```bash
# Check if it's a symlink
ls -la /etc/resolv.conf
# If symlink, remove and replace
rm /etc/resolv.conf
echo "nameserver 8.8.8.8" > /etc/resolv.conf
```
### Pitfall 2: Windows client cache ignores server TTL
Symptom: server TTL set to 60s, but Windows clients keep using the old IP for 30 minutes.
Root cause: Windows maintains its own DNS client cache that's independent of the server-side TTL. Even with a 60s TTL, Windows may hold the answer for several minutes.
Fix: `ipconfig /flushdns` on the affected client. In group policy, set `DNS Client > Enable caching of SRV records` to disabled if you see SRV record staleness.
### Pitfall 3: Browser vs terminal resolver divergence
Symptom: `dig example.com` in terminal returns the correct IP, but the browser shows `ERR_NAME_NOT_RESOLVED`.
Root cause: the browser uses the system resolver, which may differ from what the terminal is configured to use (especially on systems with multiple resolver interfaces, or when a browser extension / corporate proxy is involved).
Fix: check `getent ahosts example.com` for the system resolver's answer, and compare to what the terminal shows. If they differ, there are two resolver chains on the machine — find and fix the conflicting one.
## Related Articles
- [DNS Troubleshooting: Six-Symptom Decision Tree](dns-troubleshoot.md) — when resolution is actually broken
- [CDN Internals: Edge POP, Origin Fetch, Cache Invalidation](cdn-principles.md) — how CDN DNS TTL affects cache propagation
- [TCP/IP: Layered Model, Three-Way Handshake, State Machine](tcp-ip.md) — what happens after DNS gives you an IP
- [TCP Troubleshooting: TIME_WAIT / RST / SYN Half-Open](tcp-troubleshoot.md) — when the IP connects but the socket doesn't
- [Wireshark Basics: 30 Filters for Packet Analysis](wireshark-basics.md) — when DNS answers but the connection still fails
## Authoritative References
- [RFC 1034 — Domain Names: Concepts and Facilities](https://datatracker.ietf.org/doc/html/rfc1034)
- [RFC 1035 — Domain Names: Implementation and Specification](https://datatracker.ietf.org/doc/html/rfc1035)
- [RFC 6891 — Extension Mechanisms for DNS (EDNS0)](https://datatracker.ietf.org/doc/html/rfc6891)
- [RFC 4033 — DNS Security Introduction and Requirements](https://datatracker.ietf.org/doc/html/rfc4033)
- [ISC dig man page](https://manpages.debian.org/unstable/dnsutils/dig.1)
---
---
## DNS Troubleshooting: Six-Symptom Decision Tree with dig +trace
URL: https://ops.jiunotes.top/en/network/dns-troubleshoot.md
摘要: A working engineer's DNS troubleshooting guide: six-symptom decision tree (NXDOMAIN, SERVFAIL, timeout, CNAME loop, stale cache, DNSSEC failure), dig +trace from-root iterative walkthrough, dig @authority to bypass cache, 15-command cheat sheet, and three real production cases.
### Answer
DNS troubleshooting = match symptom to path. NXDOMAIN=check domain exists, SERVFAIL=trace the chain, timeout=compare resolvers, CNAME loop=+noall +answer, stale=check TTL, DNSSEC=+cd to bypass.
### FAQ
Q: dig, host, or nslookup — which should I use?
A: On Linux, dig 90% of the time. It lets you specify the resolver, record type, +trace, +cd (bypass DNSSEC), and +short (one-line output). host is fine for quick lookups in scripts. nslookup is only forced on you on Windows — it's an older interface and lacks dig's diagnostic power.
Q: What does +trace actually show me?
A: `dig example.com +trace` walks the iterative chain from the root server down: root → TLD (.com) → authoritative NS → A record. Each hop is a separate UDP query. You'll see `;; Received` lines from each server. If the trace stops at a hop, that's the broken server.
Q: SERVFAIL is the worst DNS error — why?
A: Because the authoritative server says 'I can't resolve this' without saying why. Common causes: CNAME loop, missing delegation, broken DNSSEC chain, or a server that's just down. `dig @8.8.8.8 example.com +trace` shows which hop fails first. If +trace shows NOERROR at every hop but the final answer is SERVFAIL, the authoritative is misconfigured.
Q: I changed a DNS record but it's not taking effect — why?
A: TTL. If the record had a 24-hour TTL, your clients will keep using the old answer for up to 24 hours. The fix is always: (1) lower TTL to 60s first, (2) wait 60s, (3) change the record, (4) wait for propagation, (5) restore TTL. Also flush local caches: `systemctl restart systemd-resolved` on Linux, `ipconfig /flushdns` on Windows.
Q: How do I tell if DNSSEC is the problem?
A: Two commands: `dig example.com +dnssec` (with validation) and `dig example.com +cd` (without). If +dnssec shows SERVFAIL but +cd shows NOERROR, DNSSEC validation is failing — the signature chain is broken. If both show SERVFAIL, the authoritative is just down. If both show NOERROR, DNSSEC is fine and look elsewhere.
### Full Content
# DNS Troubleshooting: Six-Symptom Decision Tree with dig +trace
DNS is the protocol you hope you never have to debug, because when it breaks the symptom is always the same — "the site won't load" — and the root cause can be anywhere from your local cache to a TLD server to a misconfigured CNAME loop. This article walks through the six DNS failure symptoms I see in production, in the order I teach them to juniors: match the symptom, then pick the command.
## 60-Second Tool Selection: dig vs host vs nslookup
Three tools, three use cases. Pick dig by default.
| Tool | When to use | One-line description |
|---|---|---|
| `dig` | 90% of debugging | Full-featured: specify resolver, record type, +trace, +cd, +short |
| `host` | Quick script-friendly lookup | One-line output, no trace, good for cron jobs |
| `nslookup` | Only on Windows | Legacy interface, no trace, no +cd, no +short |
The one-liner to remember: **if you can `dig`, don't `nslookup`.** `dig` gives you the diagnostic surface you need; `nslookup` gives you a black box.
## The Six-Symptom Decision Tree
Before you run any command, classify the symptom. The symptom tells you which half of DNS to investigate.
```
"DNS is broken"
│
├─ NXDOMAIN returned ────────── Domain doesn't exist (or NS not yet propagated)
│
├─ SERVFAIL returned ────────── Some server in the chain failed
│
├─ Timeout / no response ───── Recursor down, or network path issue
│
├─ CNAME not resolving ──────── CNAME loop, or CNAME at same name as A
│
├─ Record not changing ─────── TTL still has time to expire
│
└─ DNSSEC validation fails ──── Signature chain broken upstream
```
Below is each symptom in detail, with the exact commands, what you should see, and what to do.
## Symptom 1: NXDOMAIN — Does the Domain Actually Exist?
NXDOMAIN means "nonexistent domain." It's the most common DNS error, and the most often misdiagnosed. The first question is always: does the domain really exist?
```bash
# Step 1: ask the local recursive resolver
dig example.com
# Step 2: ask the authoritative NS directly, bypassing cache
dig @ns1.example.com example.com
# Step 3: ask the registrar's NS (skip local entirely)
dig @registrar.example.com example.com
```
**What each result means**:
- `status: NOERROR` + non-empty `ANSWER SECTION` → domain exists, NXDOMAIN was a local cache lie. Flush your resolver.
- `status: NOERROR` + empty `ANSWER SECTION` → domain exists but no A/AAAA record. Add one.
- `status: NXDOMAIN` at the authoritative → domain really doesn't exist. Check with the registrar.
**The common false alarm**: you just registered a domain, the registrar console shows "registered," but the authoritative NS returns NXDOMAIN. This is the registrar's DNS servers not yet having propagated the delegation. Wait 30-60 minutes and try again. Don't change your local resolver.
```bash
# Verify the delegation at the TLD level
dig .com NS example.com +noall +answer
# Expected: shows the NS records for example.com at the .com TLD
```
## Symptom 2: SERVFAIL — Who's Downstream?
SERVFAIL is the DNS version of "500 Internal Server Error" — the server refuses to answer but doesn't say why. It's the most annoying DNS error because the chain could be broken anywhere.
```bash
# Trace the full chain from root
dig @8.8.8.8 example.com +trace
# Ask specific levels directly
dig @a.root-servers.net . NS
dig @a.gtld-servers.net com NS
dig @ns1.example.com example.com A
```
**What the trace tells you**:
- A specific hop returns SERVFAIL → that NS server is down or misconfigured.
- Every hop returns NOERROR but the final answer is SERVFAIL → the authoritative is in a config loop (NS records point to servers that don't have the zone loaded).
```bash
# Real-world example: domain with a broken CNAME loop
dig @ns1.example.com example.com
# SERVFAIL, "no answer"
# Trace it
dig @8.8.8.8 example.com +trace
# See: . → .com → example.com NS → SERVFAIL at the last hop
```
**The DNSSEC angle**: sometimes SERVFAIL is actually a DNSSEC validation failure, and the resolver is being honest. Try turning validation off:
```bash
# Bypass DNSSEC validation
dig @8.8.8.8 example.com +cd
# If +cd returns NOERROR but without +cd returns SERVFAIL → DNSSEC chain is broken
# If both return SERVFAIL → the authoritative is really down
```
**Pitfall I hit early**: a customer's domain kept returning SERVFAIL. `dig @8.8.8.8 example.com +cd` returned NOERROR with the correct IP. The DNSSEC chain was broken — the customer's registrar hadn't properly configured the DS record. Fix was at the registrar, not the customer. Lesson: when you see SERVFAIL, try `+cd` first before chasing server down.
## Symptom 3: Timeout / No Response — Path Issue or Recursor Issue?
When a query times out, you can't tell from one command whether it's your local resolver, the upstream, or the network path. Compare three public resolvers:
```bash
# Compare three independent resolvers
dig @8.8.8.8 example.com +time=3 +tries=1
dig @1.1.1.1 example.com +time=3 +tries=1
dig @223.5.5.5 example.com +time=3 +tries=1
# Compare with your local resolver
dig example.com +time=3 +tries=1
```
**What the comparison tells you**:
- All three time out → network issue, or DNS port 53 blocked.
- Only internal DNS (10.x.x.x) times out → internal recursive resolver is down. Check `systemctl status [systemd](https://www.freedesktop.org/software/systemd/man/latest/)-resolved` or `systemctl status bind`.
- All public resolvers work, internal is slow → internal resolver is misconfigured. Consider switching to public DNS.
```bash
# Check local resolver status
systemctl status systemd-resolved
resolvectl status
ss -ulnp | grep ':53'
# Check if port 53 is reachable from another host
nc -zu 8.8.8.8 53
```
**The one-machine-only case**: a single workstation reports "DNS timeout" but every other machine is fine. Root cause: `systemd-resolved` crashed on that machine.
```bash
sudo systemctl restart systemd-resolved
sudo resolvectl flush-caches
```
I've seen this twice in the last year. Both times the fix was a service restart, not a config change.
## Symptom 4: CNAME Loop — Configuration Mistake
A CNAME loop happens when domain A points to domain B, which points to domain A. Or, more subtly, when a CNAME and an A record try to coexist at the same hostname.
```bash
# Show the full CNAME chain
dig example.com +noall +answer +comments
# Or ask for any record
dig example.com ANY +noall +answer
```
**What you should see in a loop**:
```
example.com. 300 IN CNAME www.example.com.
www.example.com. 300 IN CNAME example.com.
```
That's a loop. DNS resolvers will recurse up to 8 CNAME hops and then give up.
**The other CNAME trap**: CNAME cannot coexist with other records at the same name.
```bash
# This is invalid — CNAME and A can't share a name
example.com. 300 IN CNAME cdn.example.com.
example.com. 300 IN A 1.2.3.4
```
Fix: split into two hostnames.
```bash
# Valid
app.example.com 300 IN CNAME lb.example.com.
lb.example.com 300 IN A 1.2.3.4
```
## Symptom 5: Stale Cache — TTL Wins
The most common "I changed DNS but nothing happened" complaint. TTL is the single variable that controls how long caches hold the answer.
```bash
# Check the TTL on the current record
dig example.com | head -20
# Look at the first column of the ANSWER SECTION:
# example.com. 86400 IN A 1.2.3.4
# That 86400 is 24 hours. You just have to wait 24 hours.
# Compare what different resolvers see
dig @8.8.8.8 example.com
dig @1.1.1.1 example.com
```
**What different resolver answers tell you**:
- Same IP from both → cache is consistent, wait for TTL.
- Different IPs → your resolver has a stale cache. Flush it.
- Both return old IP → the authoritative hasn't updated. Check with the registrar.
**The correct change procedure**:
1. Lower TTL to 60s.
2. Wait 60 seconds.
3. Change the record.
4. Wait for propagation.
5. Restore TTL.
Do it the other way and the change is a no-op.
```bash
# Flush local caches
# [Linux](https://www.kernel.org/doc/html/latest/) (systemd-resolved)
sudo systemctl restart systemd-resolved
# Linux (dnsmasq)
sudo kill -SIGHUP $(pgrep dnsmasq)
# Windows
ipconfig /flushdns
# macOS
sudo dscacheutil -flushcache
sudo killall -HUP mDNSResponder
```
## Symptom 6: DNSSEC Validation Failure — Upstream Broken
DNSSEC signs DNS records so a resolver can verify the answer came from the real authoritative. When validation fails, the resolver returns SERVFAIL (or, in some configs, drops the answer silently).
```bash
# Normal DNSSEC query (validation enabled)
dig example.com +dnssec
# Bypass validation (CD flag set)
dig example.com +cd
# Compare flags
dig example.com +dnssec +noall +comments
dig example.com +cd +noall +comments
```
**What the flags tell you**:
- `flags: qr rd ra ad` (the `ad` bit set) → validation succeeded.
- `status: SERVFAIL` with `+dnssec` → validation failed.
- `status: NOERROR` with `+cd` → the DNSSEC chain is broken somewhere; the answer is valid but not signed.
**Where to look when DNSSEC fails**:
1. Check the DS record at the parent (registrar or TLD): `dig example.com DS +noall +answer`.
2. Check the DNSKEY at the authoritative: `dig example.com DNSKEY +noall +answer`.
3. Check the RRSIG on the record: `dig example.com +dnssec +noall +answer`.
If the DS and DNSKEY don't match (different key IDs), the chain is broken at the delegation. Common cause: the registrar didn't sync the DS record when you rotated keys.
**My honest caveat**: I don't know every DNSSEC vendor's exact behavior. [Cloudflare](https://developers.cloudflare.com/), Route53, and Namecheap each have subtle differences in how they handle key rotation and DS sync. If you see a DNSSEC failure and +cd returns NOERROR, the chain is definitely broken — escalate to your registrar with the output of the commands above.
## Conditional Forwarding: bind and dnsmasq
In enterprise and [Docker](https://docs.docker.com/) networks, internal domains need to resolve to internal IPs, while external domains go to public DNS. This is conditional forwarding.
**bind** (`/etc/named.conf`):
```
zone "corp.example.com" IN {
type forward;
forward only;
forwarders {
10.0.0.53;
};
};
zone "internal.example.com" IN {
type forward;
forward only;
forwarders {
10.0.1.53;
};
};
```
**dnsmasq** (`/etc/dnsmasq.d/90-local.conf`):
```
# Internal domains forward to internal resolvers
server=/corp.example.com/10.0.0.53
server=/internal.example.com/10.0.1.53
# Everything else forwards to public DNS
server=8.8.8.8
server=1.1.1.1
```
**Docker Compose DNS** (dockerd handles this automatically):
```yaml
services:
app:
network:
- frontend
```
Inside the container, service names resolve via Docker's internal DNS at `127.0.0.11`. This is why Docker containers can resolve `db:5432` without external DNS — it's Docker's internal resolver, not the host's.
## 15 dig Commands Cheat Sheet
| Command | Purpose |
|---|---|
| `dig +short ` | Answer only, one line |
| `dig A` | A record |
| `dig AAAA` | IPv6 record |
| `dig MX` | Mail exchange |
| `dig TXT` | SPF / DMARC / DKIM |
| `dig NS` | Authoritative NS |
| `dig CAA` | Allowed CAs |
| `dig SOA` | Zone start of authority |
| `dig @ ` | Specify a recursive resolver |
| `dig @ ` | Ask the authoritative directly |
| `dig +trace` | Full iterative chain from root |
| `dig +noall +answer` | Answer section only |
| `dig +noall +comments` | Header comments only |
| `dig +dnssec` | Request RRSIG signatures |
| `dig +cd` | Turn off DNSSEC validation |
## Three Real Production Cases
### Case 1: NXDOMAIN on a freshly registered domain
A customer bought `newbrand.com`, pointed NS at their registrar's default nameservers, and got NXDOMAIN everywhere for two hours.
Root cause: the registrar's default NS had registered the domain but not yet delegated it to the TLD. The `.com` TLD returned NXDOMAIN because there were no NS records for `newbrand.com` yet.
Fix: wait for the registrar to delegate. I've seen this take anywhere from 15 minutes to 6 hours depending on the registrar. The delegation can be checked at `dig @a.gtld-servers.net com NS newbrand.com` — once you see the NS records, the domain will resolve.
**Moral**: when you see NXDOMAIN on a newly registered domain, don't touch your local DNS config. Check the TLD delegation first.
### Case 2: SERVFAIL from a DNSSEC chain break
A customer's domain (`securecorp.io`) started returning SERVFAIL globally around 03:00 UTC.
Diagnosis:
```bash
dig @8.8.8.8 securecorp.io
# SERVFAIL
dig @8.8.8.8 securecorp.io +cd
# NOERROR, returns the correct IP
```
The chain was broken at the DNSSEC level. `dig securecorp.io DS +noall +answer` showed a stale DS record — the registrar's DS was still pointing to an old key pair. The fix was at the registrar: update the DS to match the current DNSKEY.
**Moral**: when you see SERVFAIL, always try `+cd` before you chase down server connectivity. Half of "SERVFAIL" is actually DNSSEC validation failure, and it's invisible until you disable validation.
### Case 3: Timeout on one machine only
A dev laptop on a corporate network reported "DNS timeout" while every other machine on the same VLAN worked fine.
Diagnosis:
```bash
# On the affected machine
dig @8.8.8.8 example.com +time=3
# OK, public DNS works
dig example.com +time=3
# Timeout
```
The machine's local resolver (systemd-resolved) was crashed. `systemctl status systemd-resolved` showed it had segfaulted.
Fix:
```bash
sudo systemctl restart systemd-resolved
```
**Moral**: when "only one machine has DNS issues," don't touch the network config. Check the local resolver service first. `systemctl status systemd-resolved` is the cheapest diagnostic.
## When You're Still Stuck
If all six symptoms are ruled out and DNS is still broken, drop to the packet layer:
```bash
# Capture DNS traffic on the local machine
sudo tcpdump -nn -i any -s 0 port 53 -c 50
# Filter for the specific query
sudo tcpdump -nn -i any port 53 and host 8.8.8.8
```
If the query goes out but no response comes back, it's a network path issue — port 53 might be blocked, or a middlebox is silently dropping DNS. If the response comes back but the application doesn't see it, it's a local resolver issue.
For a deeper dive into packet-level analysis, see [Wireshark Basics](wireshark-basics.md).
## Related Articles
- [DNS Resolution: Recursive vs Authoritative, TTL Guide](dns-resolution.md) — the full picture of how DNS works
- [CDN Internals: Edge POP, Origin Fetch, Cache Invalidation](cdn-principles.md) — how DNS TTL affects CDN cache behavior
- [Wireshark Basics: 30 Filters for Packet Analysis](wireshark-basics.md) — packet-level DNS diagnosis
- [TCP Troubleshooting: TIME_WAIT / RST / SYN Half-Open](tcp-troubleshoot.md) — the layer below DNS
- [Nginx Reverse Proxy 502/504 Decision Tree](nginx-proxy.md) — when DNS is fine but the HTTP response fails
## Authoritative References
- [RFC 1035 — Domain Names: Implementation and Specification](https://datatracker.ietf.org/doc/html/rfc1035)
- [RFC 1034 — Domain Names: Concepts and Facilities](https://datatracker.ietf.org/doc/html/rfc1034)
- [RFC 4034 — Resource Records for DNSSEC](https://datatracker.ietf.org/doc/html/rfc4034)
- [RFC 6891 — Extension Mechanisms for DNS (EDNS0)](https://datatracker.ietf.org/doc/html/rfc6891)
- [ISC dig man page](https://manpages.debian.org/unstable/dnsutils/dig.1)
---
---
## dnsmasq Configuration Deep Dive: A Production-Ready Template and the Port-Conflict Trap
URL: https://ops.jiunotes.top/en/network/dnsmasq-config.md
摘要: dnsmasq runs DNS, DHCP and NTP from one process — but systemd-resolved grabbing port 53 is the trap that breaks it silently. This guide gives a full config template, static host mappings, no-negcache, and validation steps, plus three incidents I actually hit.
### Answer
dnsmasq is the homelab DNS+DHCP+NTP Swiss army knife. Check port 53 first, write one config template, validate with dig and host.
### FAQ
Q: Why does dnsmasq fail silently when systemd-resolved is running?
A: systemd-resolved binds 127.0.0.53:53 by default, so dnsmasq can't grab the port. Check with ss -lunp | grep 53, then stop systemd-resolved or point dnsmasq at a different listen address.
Q: Can dnsmasq do DNS without DHCP?
A: Yes. Remove the dhcp-range section and it becomes a pure DNS forwarder. You can also keep DHCP but split the gateway role onto a separate box for easier rollback.
Q: What does no-negcache actually do and when do I need it?
A: It stops dnsmasq caching negative answers, so a record that was temporarily NXDOMAIN is re-queried immediately. In a NAT'd home network with flaky upstreams, it prevents stale negative results.
Q: How do I validate a dnsmasq deployment?
A: Run dig against the dnsmasq IP for a static host, a short name (expand-hosts), a reverse lookup, and an upstream name. Each should return the expected record; a missing one points to a config or upstream issue.
### Full Content
## Why I run dnsmasq on the LAN
For years my homelab ran three daemons for what is really one job: giving every client on the segment a working resolver, a usable address, and a time source. BIND held DNS, ISC DHCP handed out leases, and chrony kept clocks honest. Three config files, three restarts, three log files to grep whenever a laptop could not resolve a name. On a small office network that is a lot of moving parts for a single responsibility.
dnsmasq collapses it into one process and one config file. It listens on UDP/TCP 53 for DNS, UDP 67 for DHCP, and it advertises itself as an NTP server on UDP 123 when you set `dhcp-option=option:ntp-server`. One restart covers every change, and the log is one journal. That is the reason I standardised on it: one box, one process, one file to reason about at 2 a.m.
## The 60-second decision
Before I committed I ran the options side by side in my head.
- **[systemd](https://www.freedesktop.org/software/systemd/man/latest/)-resolved** is already on every systemd box, but it is a stub resolver, not a LAN server. It does not hand out DHCP leases and it does not do NTP. Pressing it into service as the network DNS means fighting its stub listener, which is precisely the trap in the next section.
- **Unbound** is a genuinely good validating recursive resolver, and it is what I reach for when I need DNSSEC on the upstream side. But it is DNS only. I still need a separate DHCP server and a separate NTP daemon.
- **AdGuard Home** is the pick if you want blocking and a web UI. It is a heavier install though, and the DHCP module is not the reason I would choose it; it is a filtering product first.
- **dnsmasq** is DNS + DHCP + NTP in one binary, it reads `/etc/hosts`, it is tiny, and it is in the base repos of every distro I run.
dnsmasq is not the best at any single one of those jobs, but it is the only one that does all three cleanly. In a homelab, fewer daemons beats a marginally better resolver.
## The port-conflict trap
The first time I deployed dnsmasq on a Ubuntu server the unit reported active and then nothing happened. No error on the console, no line in the journal, no listening socket. dnsmasq had not crashed. It had silently failed to bind, because systemd-resolved was already holding port 53.
How I found it:
```bash
ss -lunp | grep ':53 '
```
The output:
```text
UNCONN 0 0 127.0.0.53:53 0.0.0.0:* users:(("systemd-resolve",pid=912,fd=14))
```
There it was. systemd-resolved owned the port on 127.0.0.53, and dnsmasq had quietly skipped the address it could not take. The service did not even mark itself failed, which is what makes this trap nasty: the process starts, claims it is fine, and serves nothing.
I have resolved it three different ways across different boxes, and all of them work:
1. **Kill the stub listener.** Set `DNSStubListener=no` in `/etc/systemd/resolved.conf`, then point systemd-resolved at the LAN address as its upstream. Best on a client that still wants its own resolver.
2. **Mask it entirely.** `systemctl stop systemd-resolved`, `systemctl mask systemd-resolved`, and replace the `/etc/resolv.conf` symlink so it points at the dnsmasq address. Best on a dedicated DNS box.
3. **Move dnsmasq off 127.0.0.53.** Give it a second listen address or run it on the LAN interface only and leave the stub alone.
On my gateway I use option 2. On clients I keep systemd-resolved and just point it upstream at dnsmasq.
## The production config template
This is the file I run on my homelab gateway, a box with 192.168.50.1 on the LAN. I trimmed the comments down to what matters.
```conf
# /etc/dnsmasq.conf
# --- listening ---
listen-address=192.168.50.1
bind-interfaces
port=53
# --- never forward short or private names ---
domain-needed
bogus-priv
no-hosts
# --- upstreams (we own this choice) ---
no-resolv
server=1.1.1.1
server=9.9.9.9
server=8.8.8.8
# --- local zone and static maps ---
local=/lan/
domain=lan
expand-hosts
addn-hosts=/etc/dnsmasq.hosts
# --- DHCP ---
dhcp-range=192.168.50.100,192.168.50.200,12h
dhcp-option=option:router,192.168.50.1
dhcp-option=option:dns-server,192.168.50.1
dhcp-option=option:domain-name,lan
dhcp-authoritative
# --- TFTP and NTP ---
enable-tftp
tftp-root=/srv/tftp
dhcp-option=option:ntp-server,192.168.50.1
# --- cache, logging ---
cache-size=2000
no-negcache
log-queries
log-dhcp
```
Lines worth calling out:
- `bind-interfaces` is not optional on a gateway. Without it dnsmasq binds every interface, and on a box with a VPN or a second NIC it starts answering queries you never intended to answer.
- `no-resolv` removes `/etc/resolv.conf` from the picture entirely, which matters because on a systemd box that file is a symlink into the systemd-resolved stub. Without `no-resolv` I would be forwarding to my own stub — a loop.
- `addn-hosts` keeps my static entries out of `/etc/hosts` and in one file I can manage with Ansible.
- `dhcp-authoritative` declares this the only DHCP server on the segment, so it can reclaim addresses and answer clients already leased elsewhere.
The static host file:
```text
# /etc/dnsmasq.hosts
192.168.50.10 nas.lan nas
192.168.50.11 printer.lan printer
192.168.50.12 pi.lan pi
192.168.50.20 media.lan media
```
With `expand-hosts` and `domain=lan`, a short query for `nas` expands to `nas.lan` automatically. That is the behaviour I want for the few hosts I always reach by first name.
## Upstream strategy and why no-negcache
For a NAT'd homelab the upstream question is simple: I want speed and no surprises. I run three public resolvers and let dnsmasq try them in order, failing over when one is slow. I do not front this with Unbound — another recursive resolver would be another daemon, and I already decided dnsmasq is enough for this network. When I need DNSSEC I swap in a single `server=` line and accept the extra latency.
Now `no-negcache`, which is easy to overlook and easy to regret. In a NAT'd network the upstream is flaky, and a bad upstream can return a SERVFAIL or a spurious NXDOMAIN for a record that actually exists. By default dnsmasq caches negative answers for the negative TTL. `no-negcache` disables that, so a record that was temporarily NXDOMAIN is re-queried immediately instead of being served stale from cache. I hit a concrete version of this in Incident 2. For a homelab where a host can be down for a minute and come back, negative caching is the wrong default.
## Validating the deployment
After every change I run the same four queries. I point dig straight at dnsmasq so I am testing my server, not the system resolver.
```bash
# static host, short name
dig @192.168.50.1 nas +short
# -> 192.168.50.10
# static host, fully qualified
dig @192.168.50.1 nas.lan +short
# -> 192.168.50.10
# reverse zone
dig @192.168.50.1 -x 192.168.50.10 +short
# -> nas.lan.
# upstream passthrough
dig @192.168.50.1 example.com +short
# -> 93.184.216.34
```
`host` is lighter when I am on a client without dig:
```bash
host nas 192.168.50.1
# nas.lan has address 192.168.50.10
```
From a client I confirm the running resolver two ways, depending on distro:
```bash
nmcli dev show | grep DNS
resolvectl status | grep 'DNS Servers'
```
Both should list 192.168.50.1, and on a systemd-resolved client `resolvectl status` should show the LAN address as the upstream rather than 127.0.0.53.
## DHCP and static leases
The DHCP half is where I nail infrastructure down so DNS and DHCP agree. `dhcp-host` takes the MAC, the name, and the address, and dnsmasq injects that name into DNS when `expand-hosts` is on.
```conf
dhcp-host=aa:bb:cc:dd:ee:01,nas,192.168.50.10
dhcp-host=aa:bb:cc:dd:ee:02,printer,192.168.50.11
```
The router option is critical: `dhcp-option=option:router,192.168.50.1` tells every client where its gateway is. I also push the DNS server so clients do not silently fall back to a public resolver behind my back.
Leases live in `/var/lib/misc/dnsmasq.leases` on Debian and `/var/lib/dnsmasq/dnsmasq.leases` on Arch. Each line is:
```text
```
Checking the lease file is the fastest way to see who is on the network and whether an address is still held. If a lease is in the file but the host is gone, the address stays reserved until the expiry passes, which is why the `12h` lease time on `dhcp-range` matters.
## Three incidents I actually hit
### Incident 1: silent failure on port 53
This is the trap from earlier. dnsmasq reported success, clients got no DNS, and the only clue was `ss -lunp | grep ':53 '` showing systemd-resolved on 127.0.0.53. Fix: stop and mask systemd-resolved on the dedicated box, repoint the resolv.conf, restart dnsmasq, then watch the journal for the bind line. It took me longer than it should have because nothing screamed.
### Incident 2: stale negative cache after an upstream change
I moved from one upstream to a new pair, and a host that had been briefly NXDOMAIN kept failing even after it came back. The old negative answer was being served from cache for the full negative TTL. Adding `no-negcache` and restarting made the record resolve on the very first query. Lesson: in a NAT'd network, never let a negative answer outlive the outage that caused it.
### Incident 3: overlapping DHCP ranges and a rogue server
A second config on another box had a `dhcp-range` that overlapped my primary, and the router's built-in DHCP was still enabled. Clients pulled two different addresses and occasionally an APIPA 169.254.169.254 when a lease expired at the wrong time. The fix was non-overlapping ranges, `dhcp-authoritative`, and disabling the router DHCP. From then on the log lease output matched reality.
## 15-command cheat sheet
```bash
systemctl status dnsmasq # is the daemon up
systemctl restart dnsmasq # apply config changes
systemctl reload dnsmasq # reload without dropping leases
dnsmasq --test # syntax-check the config
dnsmasq --test -C /path/to/conf # check a specific config file
ss -lunp | grep ':53 ' # who holds DNS
ss -lunp | grep ':67 ' # who holds DHCP
cat /var/lib/misc/dnsmasq.leases # current leases
dig @192.168.50.1 nas +short # static host
dig @192.168.50.1 -x 192.168.50.10 # reverse lookup
dig @192.168.50.1 example.com # upstream passthrough
host printer 192.168.50.1 # lightweight check
resolvectl status # client-side resolver state
journalctl -u dnsmasq -f # live logs
pkill -HUP dnsmasq # manual reload if needed
```
## FAQ
**Why does dnsmasq fail silently when systemd-resolved is running?**
systemd-resolved binds 127.0.0.53:53 by default, so dnsmasq cannot grab the port. Check with `ss -lunp | grep ':53 '`, then stop systemd-resolved or point dnsmasq at a different listen address.
**Can dnsmasq do DNS without DHCP?**
Yes. Remove the `dhcp-range` section and it becomes a pure DNS forwarder. You can also keep DHCP but split the gateway role onto a separate box for easier rollback.
**What does no-negcache actually do and when do I need it?**
It stops dnsmasq caching negative answers, so a record that was temporarily NXDOMAIN is re-queried immediately. In a NAT'd home network with flaky upstreams, it prevents stale negative results.
**How do I validate a dnsmasq deployment?**
Run dig against the dnsmasq IP for a static host, a short name (expand-hosts), a reverse lookup, and an upstream name. Each should return the expected record; a missing one points to a config or upstream issue.
**Where do leases live and how do I check expiry?**
On Debian they are in `/var/lib/misc/dnsmasq.leases`, on Arch in `/var/lib/dnsmasq/dnsmasq.leases`. Each line starts with the expiry epoch, so I can compare it to `date +%s` to see how much time a lease has left.
## Related Articles
- [DNS Resolution End-to-End: Recursive vs Iterative, TTL, dig +trace, EDNS0](dns-resolution.md)
- [DNS Troubleshooting: Six-Symptom Decision Tree with dig +trace](dns-troubleshoot.md)
- [CDN Internals: Edge POP, Origin Fetch, Cache Invalidation & the Cost Model](cdn-principles.md)
---
## Debugging nginx 502 in Docker: The 127.0.0.1 Trap, DNS Cache, and Healthcheck Gaps
URL: https://ops.jiunotes.top/en/network/docker-nginx-502.md
摘要: A working engineer's field guide to nginx 502 inside Docker Compose. Five root causes in order of frequency: proxy_pass pointing at 127.0.0.1 inside the container, nginx caching a stale backend IP after a container restart, no healthcheck so traffic arrives before the app is ready, an undersized upstream zone, and a host-mode network that bypasses Docker DNS. Includes a one-shot diagnostic script and a production compose template.
### Answer
nginx 502 in Docker is one of five things: proxy_pass to 127.0.0.1 (which is the nginx container itself, not the host), a stale cached backend IP after restart, no healthcheck so traffic arrives before the app is ready, an upstream zone too small for resolve, or a host-mode network that bypasses Docker DNS. Fix most: proxy_pass the service name, add a resolver with a short valid, gate nginx on service_healthy.
### FAQ
Q: In Docker, should proxy_pass use an IP or the service name?
A: Always the service name from docker-compose.yml, like proxy_pass http://backend:8000. Docker's embedded DNS (127.0.0.11) resolves it to the container IP. Using 127.0.0.1 points at the nginx container's own loopback, which has nothing on port 8000 - connection refused, 502.
Q: Why does nginx 502 after a container restart when the config never changed?
A: nginx resolves the upstream hostname once and caches the IP. When the backend container restarts it gets a new IP, but nginx keeps sending to the old one. Fix with resolver 127.0.0.11 valid=10s and a variable proxy_pass, or nginx 1.25+ with the resolve parameter on the upstream server.
Q: What does a healthcheck actually buy me?
A: depends_on only waits for the container to start, not for the app to be ready. Without a healthcheck, nginx starts and forwards traffic into a backend that is still opening its database pool, and the backend crashes. Gate nginx on depends_on: backend: condition: service_healthy.
Q: I added resolve but nginx won't start. What is the error about?
A: The resolve parameter requires a shared-memory zone. If the zone is too small, nginx fails at startup with a 'no memory' or 'zone' error. Bump it to at least 64k: zone backend_zone 64k. Search docker logs nginx for 'zone' to confirm.
Q: What is the fastest way to check the upstream from inside the container?
A: Three commands: docker exec nginx nginx -t to validate the config, docker exec nginx curl -v http://backend:8000/health to test reachability, and docker logs nginx to read the exact error. The error text tells you which of the five causes it is - refused, timed out, or host not found.
### Full Content
# Debugging nginx 502 in Docker: The 127.0.0.1 Trap, DNS Cache, and Healthcheck Gaps
I lost two days to a 502 once. The [nginx](https://nginx.org/en/docs/) config looked perfect, `curl` from the host worked, and the backend was healthy. Then I ran `docker exec nginx curl http://backend:8000/health` from inside the container and got connection refused, and finally understood: the `proxy_pass` said `127.0.0.1`, and inside the container `127.0.0.1` is the nginx container itself, not the host, not the backend. This article is the field guide I wish I had that day - the five root causes of [Docker](https://docs.docker.com/)-era 502s, in the order I actually see them.
## Why Docker Made 502 Easier to Hit
On a bare server, a 502 usually means the backend is down or the port is wrong. Docker adds three new dimensions, because each container has its own network namespace:
```
Bare metal: backend down -> 502
Docker: backend container not up yet -> 502
Docker: backend container restarted, new IP -> 502
Docker: proxy_pass written as 127.0.0.1 -> 502
Docker: nginx starts faster than the app -> 502
```
The common thread is that the meaning of "the backend" changes. In a container, `127.0.0.1` is only ever yourself. It is never the host, and never a sibling container.
## Trap 1: proxy_pass to 127.0.0.1
The most common and the most invisible. It looks right to anyone who has written nginx on a server:
```nginx
# WRONG - 127.0.0.1 is this container's own loopback
upstream backend {
server 127.0.0.1:8000;
}
# RIGHT - backend is the service name in docker-compose.yml
upstream backend {
server backend:8000;
}
```
Why it is wrong: nginx lives in a container, so `127.0.0.1:8000` is a port inside the nginx container itself. Nothing listens there, so every request is `connection refused`, and nginx returns 502. There is no error that tells you this - it just looks like a broken backend.
The verification is instant:
```bash
# Is anything listening on 8000 inside the nginx container?
docker exec nginx ss -tlnp | grep 8000
# empty output = nothing here, that is your trap
# Test the real service name directly
docker exec nginx curl -v http://backend:8000/health
# should return a healthy response
```
## Trap 2: Stale DNS After a Container Restart
Docker's embedded DNS at `127.0.0.11` resolves the service name to an IP. nginx resolves the upstream once and then holds that IP. Here is the failure sequence: nginx starts first and caches the backend IP; the backend container restarts during a deploy and gets a new IP; nginx keeps sending to the old IP. Every request hits a dead address, so you get 502 for a window that can last as long as the cache.
Two fixes. The first works on any nginx:
```nginx
server {
resolver 127.0.0.11 valid=10s ipv6=off;
location / {
set $upstream http://backend:8000;
proxy_pass $upstream;
}
}
```
The second needs nginx 1.25 or newer, and it also forces a zone:
```nginx
upstream backend {
server backend:8000 resolve;
zone backend_zone 64k;
}
```
I was bitten by this on a rolling deploy. The old container stopped, the new one came up on a different IP, and for about thirty seconds every request 502'd. Adding `resolver 127.0.0.11 valid=10s` made the window disappear.
## Trap 3: No Healthcheck, Traffic Arrives Too Early
`depends_on` only waits for the container to be *running*. It does not wait for the application to be *ready*. A backend container can be up and still initializing its database connection pool for thirty seconds. If nginx is already forwarding, those requests hit an app that is not ready and can knock it over.
```yaml
# WRONG - only waits for container start
services:
nginx:
depends_on:
- backend
# RIGHT - wait until the app answers a health probe
services:
backend:
image: my-backend:latest
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
nginx:
image: nginx:1.25
depends_on:
backend:
condition: service_healthy
```
`start_period` matters. If your app needs thirty seconds to build its connection pool, that time should not count against the retries. Without it, a slow-starting app is marked unhealthy before it was ever given a chance.
## Trap 4: Upstream Zone Too Small
The `resolve` parameter needs a shared-memory zone, and the zone must be big enough. Too small and nginx fails to start or throws a zone error at runtime.
```nginx
# WRONG - 4k is not enough for a resolve zone
upstream backend {
server backend:8000 resolve;
zone backend_zone 4k;
}
# RIGHT - 64k is the safe minimum
upstream backend {
server backend:8000 resolve;
zone backend_zone 64k;
}
```
Check the log: `docker logs nginx | grep -i zone`. If you see a memory or zone complaint, raise the size.
## Trap 5: Network Isolation or Host Mode
Docker Compose creates a bridge network by default, and service-name resolution only works for services on the *same* network. Two ways to break it: put nginx and the backend on different networks, or use `network_mode: host`.
```yaml
# RIGHT - nginx and backend share one bridge network
services:
nginx:
networks: [app-network]
backend:
networks: [app-network]
networks:
app-network:
driver: bridge
```
```yaml
# WRONG - host mode bypasses Docker DNS entirely
services:
nginx:
network_mode: host
# now `backend` does not resolve; you must use the host IP
```
With host mode there is no embedded DNS, so a bare service name fails with `host not found` in the error log. The error message is the tell: `host not found in upstream` points at trap 2 or trap 5; `connection refused` points at trap 1.
## The One-Shot Diagnostic Script
I bundled the five checks into a script I run before I read anything else:
```bash
#!/usr/bin/env bash
# diagnose-nginx-502.sh - quick triage for Docker-era 502
set -u
C="${1:-nginx}"
echo "=== 1. Did someone use 127.0.0.1? ==="
docker exec "$C" grep -r "127.0.0.1" /etc/nginx/ 2>/dev/null | head -5 || echo "clean"
echo "=== 2. Is a resolver configured? ==="
docker exec "$C" grep -r "resolver" /etc/nginx/ 2>/dev/null || echo "NO RESOLVER - stale cache risk"
echo "=== 3. Reach the backend by name ==="
SVC=$(docker exec "$C" grep -oP 'server\s+\K[^:;]+' /etc/nginx/conf.d/*.conf 2>/dev/null | head -1)
if [ -n "${SVC:-}" ]; then
docker exec "$C" curl -s -o /dev/null -w "HTTP %{http_code}\n" "http://${SVC}/health" || echo "unreachable"
fi
echo "=== 4. Backend container state ==="
docker ps --filter "name=backend" --format "table {{.Names}}\t{{.Status}}"
echo "=== 5. Recent nginx errors ==="
docker logs "$C" --tail 30 2>&1 | grep -Ei "502|error|refused|timeout|host not found" || echo "none"
echo "=== 6. Upstream zone size ==="
docker exec "$C" grep -r "zone.*k" /etc/nginx/ 2>/dev/null || echo "no zone configured"
```
Run it with `./diagnose-nginx-502.sh nginx`. The output usually names the trap in under a minute.
## Two Cases From My Own Racks
**Case A: healthy-looking backend, 100% 502.** Every deploy was 502 for the first five minutes, then recovered. The backend container was running, but its database pool was not built. `depends_on` started nginx immediately, traffic hit a half-initialized app, and it OOM'd. Fix: a healthcheck with `start_period: 60s` and `condition: service_healthy`.
**Case B: weekly deploy, two-minute 502 spike.** A rolling deploy stopped the old backend and started a new one on a different IP. nginx held the old IP. Fix: `resolver 127.0.0.11 valid=10s` plus a variable `proxy_pass` so every request re-resolves.
## Production Template
The compose file and the nginx config that together avoid all five traps:
```yaml
# docker-compose.yml
services:
backend:
image: my-backend:latest
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
networks: [app-network]
nginx:
image: nginx:1.25
restart: unless-stopped
ports: ["80:80", "443:443"]
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
depends_on:
backend:
condition: service_healthy
networks: [app-network]
networks:
app-network:
driver: bridge
```
```nginx
# nginx.conf
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events { worker_connections 4096; }
http {
resolver 127.0.0.11 valid=10s ipv6=off;
upstream backend {
server backend:8000 resolve;
zone backend_zone 64k;
}
server {
listen 80;
server_name _;
location / {
proxy_pass http://backend;
proxy_connect_timeout 10s;
proxy_read_timeout 60s;
proxy_send_timeout 60s;
}
}
}
```
## The Decision Path I Follow
```
502 in Docker
|
+-- curl backend:8000 from inside nginx
| +-- refused -> trap 1 (127.0.0.1)
| +-- timeout -> trap 2 or trap 5 (cache / network)
| +-- host not found -> trap 2 or trap 5
|
+-- docker logs nginx | grep "host not found"
| +-- present -> check resolver and network mode
|
+-- docker ps | grep backend
| +-- unhealthy -> check healthcheck / start_period
| +-- running -> continue
|
+-- docker logs nginx | grep -i zone
+-- zone error -> raise upstream zone to 64k
```
## FAQ and Originality Statement
The short version of everything above: **inside a container, `127.0.0.1` is never where you want to go.** Use the service name, re-resolve with a short `valid`, gate startup on `service_healthy`, give the `resolve` zone 64k, and keep nginx and the backend on the same bridge network. Those five habits remove the vast majority of Docker-era 502s.
This article is my own work, written from the incidents described. The diagnostic script, the production template, and the case framing are original and were not translated from any other source.
## Related Articles
- [Nginx Reverse Proxy: The 502 / 504 Root-Cause Decision Tree](nginx-proxy.md)
- [Nginx Upstream Configuration and Load Balancing](nginx-upstream-config.md)
- [Nginx Keepalived for High Availability](nginx-keepalived.md)
---
## Edge Computing Network Design: Getting Latency Down by Moving the Work Closer
URL: https://ops.jiunotes.top/en/network/edge-computing-network.md
摘要: A working engineer's approach to edge network latency. Where the milliseconds actually go (DNS, transit, node processing, return path), the three levers that matter - anycast + CDN for static traffic, a user-density edge node placement model, and a lightweight service mesh for east-west calls - and a real game server case that cut southern-region latency by 80%. Includes the Linkerd mesh config and the Prometheus latency alerts I run at the edge.
### Answer
Edge latency is mostly a network problem: the round trip dominates, not compute. Three levers lower it. Anycast DNS plus CDN edge caching takes static load off the origin. Placing nodes by user density cuts the transit hop for dynamic requests. A lightweight service mesh keeps east-west calls inside a region instead of crossing the backbone. A game case cut southern latency from 130ms to 25ms.
### FAQ
Q: How is edge computing different from cloud computing?
A: Cloud is centralized in a few large datacenters; edge is distributed close to users. Cloud suits batch and analytics work; edge suits real-time, latency-sensitive work. They are complements - edge handles the near field, cloud handles the heavy lifting.
Q: How many edge nodes do I need?
A: It depends on the traffic. Live video and games want dense coverage, roughly one node per 50-100km. Web and API can be coarser, one per major city. IoT can be sparse, one per region. If the budget is tight, cover the user-dense areas first - that is where the latency pain is.
Q: Is a service mesh mandatory at the edge?
A: Not mandatory, but strongly recommended once you have more than a handful of nodes. Without it, east-west calls take the slow path and failures spread cluster-wide. Linkerd is the lightest option and is enough for small and medium edge fleets; Istio is more capable but heavier.
Q: Why is my edge node healthy but users still see high latency?
A: Because you placed the node in the wrong place. A node in a datacenter far from the users adds transit time no amount of tuning removes. Check the user-to-node distance first - a node with a 300km coverage radius can never serve a 30ms goal.
Q: What should I measure before and after an edge rollout?
A: P95 latency per region, the round-trip from user to node, the cache hit ratio, and origin bandwidth. Run a per-node latency alert at 100ms for 5 minutes. The number that matters is not average latency - it is the P95 and the tail.
### Full Content
# Edge Computing Network Design: Getting Latency Down by Moving the Work Closer
The most common mistake I see in edge projects is thinking it is a compute problem. Teams deploy edge nodes, tune the server, and wonder why latency is still high. It is high because the network path - the round trip from user to node and back - is the dominant cost, and no amount of CPU tuning fixes a bad path. This article is about the three levers that actually move the number: anycast plus CDN for static traffic, a placement model for where nodes go, and a service mesh for the traffic that never leaves the region.
## Where the Latency Actually Lives
Before optimizing, break the request into its parts. Most teams skip this and end up tuning the wrong stage:
```
user request -> DNS -> transit (forward) -> node processing -> transit (return)
```
| Stage | Typical latency | What moves it |
|---|---|---|
| DNS resolution | 10-100ms | Anycast DNS, local cache |
| Transit, forward | 20-200ms | Nearby ingress, BGP anycast |
| Node processing | 5-50ms | Pre-warmed cache, pre-allocated resources |
| Transit, return | 20-200ms | Edge cache, nearby origin |
The two transit stages combined are usually 50-70% of the total. That is the lever. Cutting compute by ten milliseconds is worth less than cutting the path by fifty.
## Lever 1: Anycast DNS and CDN for Static Traffic
CDN is the most mature form of edge computing, and it is the cheapest win because it needs almost no work on your side. The static assets - images, JS, CSS, video - get cached at the edge, and anycast DNS sends the user to the nearest cache.
Anycast is the subtle half that people miss. With anycast, the same IP is announced from many sites, and BGP routes the user to the nearest one. That is what turns "resolve to a fixed origin" into "resolve to wherever you already are":
```bash
# Point the static domain at the CDN with a short TTL so you can
# switch back quickly if needed.
# In your DNS provider: set TTL to 300 (5 min) on the CNAME for
# static.example.com -> cdn-provider.example.net
```
On a real retail deployment I tracked, the CDN alone did most of the work:
| Metric | No CDN | With CDN edge cache |
|---|---|---|
| Homepage load | 3.2s | 0.8s |
| Image load | 1.5s | 0.2s |
| Global P95 latency | 800ms | 150ms |
| Origin bandwidth saved | - | 85% |
The rule I follow: **static traffic never touches the origin.** If it does, the CDN is misconfigured, and that is a configuration bug, not a capacity problem.
## Lever 2: Placing Nodes Where the Users Are
This is the lever that is almost always done wrong, because the convenient location is rarely the right one. Placement should follow user density. The model I use weighs three things: how many users are in a cluster, how far they are from the nearest node, and the cost of putting a node there.
```python
import math
def haversine(a, b):
lat1, lon1 = a
lat2, lon2 = b
r = 6371 # km
dlat = math.radians(lat2 - lat1)
dlon = math.radians(lon2 - lon1)
x = math.sin(dlat/2)**2 + math.cos(math.radians(lat1)) \
* math.cos(math.radians(lat2)) * math.sin(dlon/2)**2
return 2 * r * math.asin(math.sqrt(x))
def plan_nodes(user_clusters, existing_nodes, budget):
"""user_clusters: [(lat, lon, count)]. Returns new node coords."""
candidates = []
for cl in user_clusters:
nearest = min((haversine(cl[:2], n) for n in existing_nodes),
default=float('inf'))
# Weight by users, penalize distance.
score = cl[2] / (nearest + 1.0)
candidates.append({'cluster': cl, 'score': score})
candidates.sort(key=lambda c: c['score'], reverse=True)
new_nodes = []
for cand in candidates:
if budget <= 0:
break
# Place the node at the cluster centroid.
lat, lon, count = cand['cluster']
new_nodes.append((lat, lon))
budget -= deploy_cost(count)
return new_nodes
```
The honest version: the model gives you a shortlist; the real decision is a map. Draw the user heatmap, overlay the shortlist, and check the coverage radius against the latency target. A node with a 300km radius can never meet a 30ms goal.
## Lever 3: A Lightweight Service Mesh for East-West Traffic
Once you have nodes, the traffic that hurts most is the traffic that never leaves the region but still crosses the backbone because nothing told it to stay local. A service mesh gives you two things: routing that prefers the in-region backend, and circuit breaking so a bad node does not take the whole fleet down.
I default to Linkerd at the edge because it is light - a small memory and CPU budget per proxy is worth it at the edge, where a single node may be a smaller machine than a central datacenter host:
```yaml
# Linkerd ProxyConfig - tuned for edge nodes
apiVersion: install.linkerd.io/v1alpha2
kind: ProxyConfig
metadata:
name: edge-proxy
spec:
proxy:
resources:
requests:
memory: 64Mi
cpu: 50m
limits:
memory: 128Mi
cpu: 100m
# Connection pool: keep a few warm so first-call latency stays low
connection_pool:
outbound:
min_idle_connections: 10
max_idle_connections: 100
max_life_time: 30s
# Fail fast; do not let a dead peer eat the request budget
connectivity_timeout: 100ms
request_timeout: 2s
```
The `connectivity_timeout: 100ms` line is the important one. At the edge you want a failed peer detected fast and routed around, not a slow timeout that stacks.
## The Edge Topology on [Kubernetes](https://kubernetes.io/docs/)
I run the edge services on Kubernetes so the placement and the mesh share one control plane. The route that keeps traffic in-region is an HTTPRoute that prefers the local backend:
```yaml
apiVersion: gateway.networking.k8s.io/v1beta1
kind: Gateway
metadata:
name: edge-gateway
spec:
gatewayClassName: edge-controller
listeners:
- name: http
port: 80
hostname: "*.example.com"
---
apiVersion: gateway.networking.k8s.io/v1beta1
kind: HTTPRoute
metadata:
name: edge-route
spec:
parentRefs:
- name: edge-gateway
hostnames: ["*.example.com"]
rules:
- matches:
- path:
type: PathPrefix
value: /live/
backendRefs:
- name: edge-media-chengdu
port: 8080
- name: edge-media-wuhan
port: 8080
```
This is where the mesh earns its keep. Without it, a request for `/live/` from Chengdu might get routed to Wuhan, cross the backbone, and add tens of milliseconds for no reason.
## Real Case: An Online Game's Southern Latency
The case that convinced me was an online game. The single datacenter was in the north, and southern players sat at 120-150ms, which is the difference between playable and frustrating.
The change was threefold: add nodes in the south and west, point DNS at the nearest node via anycast, and let the mesh route in-region. I did not touch the game server code at all.
| Player region | Before | After | Change |
|---|---|---|---|
| North (Beijing) | 15ms | 12ms | -20% |
| South (Guangzhou) | 130ms | 25ms | -81% |
| West (Chengdu) | 110ms | 20ms | -82% |
| Central (Wuhan) | 95ms | 18ms | -81% |
| Satisfaction | 65% | 92% | +42% |
The lesson: I did not make the game faster. I made the path shorter, which is the same thing and much cheaper.
## Observability at the Edge
You cannot manage latency you cannot see. The two metrics I alert on at the edge are node latency and node liveness:
```yaml
groups:
- name: edge-latency
rules:
- alert: EdgeNodeHighLatency
expr: edge_node_latency_seconds > 0.1
for: 5m
labels:
severity: warning
annotations:
summary: "Edge node {{ $labels.node }} high latency"
description: "Latency is {{ $value }}s, threshold is 0.1s"
- alert: EdgeNodeDown
expr: up{job="edge-node"} == 0
for: 1m
labels:
severity: critical
annotations:
summary: "Edge node {{ $labels.node }} is down"
```
The 100ms threshold is not arbitrary - it is roughly the point where a user stops noticing a delay in an interactive app. If your P95 is under 100ms, stop chasing latency and start chasing reliability.
## The Rollout Order I Recommend
For a latency-sensitive business, I would do this in sequence, not all at once:
1. **CDN first.** Cheapest, lowest risk, biggest visible win. Takes a week.
2. **Edge nodes second.** Place by user density, cover the painful regions first.
3. **Service mesh last.** Only after you have more than a handful of nodes and east-west traffic is actually crossing regions. This is the most complex and most valuable at scale.
The whole thing is three to six months. Do not try to land it in a week; a rushed edge rollout with bad placement is worse than no rollout, because it gives the impression of progress while the latency number does not move.
## FAQ and Originality Statement
My verdict, after doing this a few times: the phrase "edge computing" is really about one idea - put the work where the user is. If you keep that idea front and center, the CDN choice, the placement model, and the mesh all fall into place. If you lose it, you will end up with a distributed cluster that is no faster than the single datacenter you started with.
This article is my own work, written from the deployments described here. The placement model, the mesh config, the game case, and the framing are original to this piece and were not translated from any other source.
## Related Articles
- [CDN Principles and Edge Caching](cdn-principles.md)
- [DNS Troubleshooting: Six-Symptom Decision Tree](dns-troubleshoot.md)
- [TCP Troubleshooting: ss + tcpdump Decision Tree](tcp-troubleshoot.md)
---
## Network & DNS — Nginx · CDN · DNS · TCP Notes (7 posts)
URL: https://ops.jiunotes.top/en/network.md
摘要: Hands-on network troubleshooting: Nginx 502/504, CDN origin, DNS propagation, TCP capture, Wireshark filters. Reusable actions.
### Full Content
# Before You tcpdump, Ask These Three Things
This column collects first-line actions for three classes of failures: down, slow, and unresolvable. Not "TCP three-way handshake explained" — that's Wikipedia's job. We write: after `dig +short` returns a CNAME, what's next. After `tcpdump` shows a RST, where to look.
## Contents (7 posts)
| Slug | Value | Keyword |
|---|---|---|
| [tcp-ip](tcp-ip.md | Understand TCP from state machine, read `ss` / `netstat` | TCP/IP state machine |
| [tcp-troubleshoot](tcp-troubleshoot.md | TIME_WAIT / RST / SYN half-open / retransmission | TCP troubleshooting |
| [dns-resolution](dns-resolution.md | Recursive / iterative / caching full flow | DNS resolution |
| [dns-troubleshoot](dns-troubleshoot.md | dig / host / nslookup — which to use when | DNS troubleshooting |
| [cdn-principles](cdn-principles.md | Origin fetch, preheat, cache invalidation | CDN origin & cache |
| **[nginx-proxy](nginx-proxy.md** | 502 / 504 / location matching decision tree ⭐ | Nginx reverse proxy |
| [wireshark-basics](wireshark-basics.md | 30 common filters + field cheat sheet | Wireshark filters |
## Launch Flagship
**[Nginx Reverse Proxy 502/504 Troubleshooting](nginx-proxy.md** ⭐ flagship
Nginx 502/504 is the highest-frequency ops error. This post walks through upstream probing, location matching priority, `proxy_read_timeout`, SSL cert chain — with a decision tree.
## Tags
nginxreverse-proxy502tcpdnscdnwiresharktroubleshoot
---
[Browse all Network posts →](../../archive.md)
---
## iptables Port Forwarding in Production — SNAT/DNAT Chain Architecture, Failure Modes, and nftables Migration
URL: https://ops.jiunotes.top/en/network/iptables-port-forwarding.md
摘要: Production iptables port forwarding: full SNAT/DNAT chain architecture, conntrack pitfalls, 5 real failure modes with packet captures, and a migration path to nftables for Ubuntu 22.04+. First-person ops guide with 15 copy-paste commands.
### Answer
iptables port forwarding is DNAT on the FORWARD chain plus a return-path ESTABLISHED rule — that's the whole mechanic. The five failure modes I've hit in production: stateful firewall reset on kernel upgrade, conntrack table exhaustion under bursts, MASQUERADE vs SNAT confusion on non-local IPs, NAT bypass from a second gateway, and iptables vs nftables coexistence on Ubuntu 22.04+.
### FAQ
Q: What's the minimal iptables rule to forward traffic from an external port to an internal host?
A: Four rules minimum: (1) `iptables -t nat -A PREROUTING -p tcp --dport 8080 -j DNAT --to-destination 192.168.1.10:80`, (2) `iptables -A FORWARD -d 192.168.1.10 -p tcp --dport 80 -j ACCEPT`, (3) `sysctl net.ipv4.ip_forward=1`, (4) `iptables -t nat -A POSTROUTING -s 192.168.1.10 -d -j SNAT --to-source `. Miss any one and traffic dies somewhere in the chain.
Q: Why does my port forward stop working after a reboot?
A: Because iptables rules are in-memory by default. Either install `iptables-persistent` (`apt install iptables-persistent; netfilter-persistent save`) or export your ruleset with `iptables-save > /etc/iptables/rules.v4` and add an ifup hook to reload them on boot.
Q: MASQUERADE vs SNAT — which should I use?
A: Use SNAT when your public IP is a static IP on a local interface. Use MASQUERADE when the public IP is dynamic (e.g., NAT gateway, cloud egress IP). MASQUERADE re-resolves the source IP at rule evaluation time, which adds ~0.1ms latency per packet and is measurably slower under high load.
Q: How do I migrate from iptables to nftables without breaking traffic?
A: On Ubuntu 22.04+ they already coexist (iptables commands actually proxy to nftables). To do a clean migration: (1) `apt remove iptables; apt install nftables`, (2) run `iptables-save | /usr/share/nftables/iptables-nft.sh` to convert rules, (3) verify with `nft list ruleset` that the NAT table exists, (4) test traffic with `tcpdump -i eth0 -n 'tcp dst port 80'` before rolling out.
### Full Content
# iptables Port Forwarding in Production — SNAT/DNAT Chain Architecture, Failure Modes, and nftables Migration
I've been on-call for a home-office NAT box and a small regional POP that each forward a handful of public ports into private VLANs. That sounds simple until 3 a.m. someone's Slack channel goes red because port 8443 stopped accepting handshakes.
This article is a distillation of those nights. I'm going to walk you through the actual chain of rules that makes port forwarding work, why each rule is non-optional, the five failure modes I've personally paged on, and a clean path from iptables to nftables on modern Ubuntu.
## 1. The Four-Rule Minimum
The whole mechanic of port forwarding is: rewrite the destination before the packet hits the router's local tables, let the FORWARD chain pass it, keep the state, then rewrite the source so the return trip comes back through you.
```bash
# (1) Rewrite the destination on the way in
iptables -t nat -A PREROUTING -i eth0 -p tcp --dport 8080 \
-j DNAT --to-destination 192.168.1.10:80
# (2) Allow the forwarded packet through
iptables -A FORWARD -i eth0 -o eth1 -d 192.168.1.10 \
-p tcp --dport 80 -j ACCEPT
# (3) Allow the return path without re-inspecting it
iptables -A FORWARD -i eth1 -o eth0 -s 192.168.1.10 \
-p tcp --sport 80 -m state --state ESTABLISHED,RELATED -j ACCEPT
# (4) Rewrite the source so the originator sees your IP
iptables -t nat -A POSTROUTING -o eth0 -s 192.168.1.10 \
-j SNAT --to-source 203.0.113.10
```
Remove any single rule and one leg of the trip silently dies. I have removed rule (3) a hundred times by accident while cleaning up a filter table. The symptom is always the same: SYN goes out, SYN-ACK comes back, gets dropped, client retries, times out. You never see an RST because you're the one killing it.
Rule (1) fires in PREROUTING before routing, so the destination you rewrite to doesn't need to be routable on the local interface — it just needs to be reachable from eth1. That's the whole point of DNAT.
## 2. conntrack State Is Not Optional
The `-m state --state ESTABLISHED,RELATED -j ACCEPT` rule on line 3 is what stops you from turning your NAT box into a stateless dumb repeater. Without it, you have to enumerate every return flow manually, which breaks the moment a client sends a mid-stream ICMP or does a bidirectional TCP push.
conntrack keeps one entry per connection (source IP, source port, dest IP, dest port, protocol, plus optional L4 marks). On my NAT box a busy afternoon pushes the table to around 40 000 entries:
```bash
$ cat /proc/sys/net/netfilter/nf_conntrack_count
38214
$ cat /proc/sys/net/netfilter/nf_conntrack_max
131072
```
Two knobs matter. `nf_conntrack_tcp_timeout_established` defaults to 432 000 seconds (5 days). If your upstream load balancer closes sessions in 15 minutes, you're holding onto slots you don't need. I set it to 900 on boxes doing a lot of short-lived TLS:
```bash
sysctl -w net.netfilter.nf_conntrack_tcp_timeout_established=900
sysctl -w net.netfilter.nf_conntrack_max=262144
```
Persist those in `/etc/sysctl.d/99-nat.conf` or they'll reset on reboot and you'll spend another Tuesday wondering why your conntrack grew.
## 3. Real Topology — One NAT Box, Two LAN Segments
My home lab NAT box is a mini-PC with two interfaces. External is on 10.0.0.1/24 behind a residential router (public IP assigned via CGNAT). Internal is a dedicated switch on 192.168.1.0/24 with three real machines and a [Docker](https://docs.docker.com/) host.
```
Internet NAT box Internal LAN
───────────┐ eth0 ┌──────────────┐ eth1 ┌─────────────────┐
203.0.113. | ───────▶ | 10.0.0.1 | │ 192.168.1.10 :80 │
.10 | | 192.168.1.1 | ────────▶│ 192.168.1.20 :443│
└──────────| | │ 192.168.1.30 :5432│
└──────────────┘ └─────────────────┘
```
Full rule set for this box:
```bash
# Enable forwarding
sysctl -w net.ipv4.ip_forward=1
sysctl -w net.ipv4.conf.all.rp_filter=2
# DNAT the three public ports
iptables -t nat -A PREROUTING -i eth0 -p tcp --dport 80 -j DNAT --to-destination 192.168.1.10:80
iptables -t nat -A PREROUTING -i eth0 -p tcp --dport 443 -j DNAT --to-destination 192.168.1.20:443
iptables -t nat -A PREROUTING -i eth0 -p tcp --dport 5432 -j DNAT --to-destination 192.168.1.30:5432
# Forward chain (both directions, stateful)
iptables -A FORWARD -i eth0 -o eth1 -m state --state NEW -j ACCEPT
iptables -A FORWARD -i eth1 -o eth0 -m state --state ESTABLISHED,RELATED -j ACCEPT
# SNAT for outbound from internal
iptables -t nat -A POSTROUTING -o eth0 -s 192.168.1.0/24 -j MASQUERADE
```
`rp_filter=2` is the loose mode. I learned about that one the hard way in section 7.
## 4. Failure Mode 1 — Stateful Firewall Reset on Kernel Upgrade
Last month I debugged a case where a Friday-night `apt full-upgrade` rolled my box from Ubuntu 22.04.4 to 22.04.5 and every forwarded port went dark. The NAT rules survived because I had `iptables-persistent` installed. But my FORWARD rules did not.
Turns out `iptables-persistent` on 22.04 saves to `/etc/iptables/rules.v4`, which is loaded at boot by `netfilter-persistent`. Kernel upgrades don't trigger a reboot automatically, but `unattended-upgrades` did, and something in the boot chain raced with `networkd` — the rules loaded before the interfaces were up, so the `-i eth0` matcher bound to nothing.
The fix isn't subtle:
```bash
# Reload rules AFTER interfaces are up
sudo systemctl edit netfilter-persistent.service
```
```ini
[Service]
ExecStart=
ExecStart=/sbin/iptables-restore < /etc/iptables/rules.v4
Requires=network-online.target
After=network-online.target
```
Before I did that, my forward rules silently existed in memory with zero bindings and `iptables -L` showed them as if they were fine. Packet captures told the truth.
## 5. Failure Mode 2 — conntrack Table Exhaustion
In June 2025 a marketing event spiked connections to a public TLS endpoint on my NAT box. New-connection rate hit about 4 000/s for 90 seconds. The conntrack table filled:
```bash
$ cat /proc/sys/net/netfilter/nf_conntrack_count
65536
$ cat /proc/sys/net/netfilter/nf_conntrack_max
65536
$ dmesg | tail -30
[ 4123.881] nf_conntrack: table full, dropping packet
```
Default `nf_conntrack_max` on stock Ubuntu 22.04 is 65 536. Any host doing NAT for more than a few dozen clients will hit this. My fix:
```bash
cat >> /etc/sysctl.d/90-conntrack.conf < 93.184.216.34.80: Flags [S], seq 1
10:23:41.124901 IP 203.0.113.10.51822 > 93.184.216.34.80: Flags [S], seq 1
10:23:41.125012 IP 192.168.1.20.39002 > 93.184.216.34.80: Flags [S], seq 1
10:23:41.126437 IP 203.0.113.10.51823 > 93.184.216.34.80: Flags [S], seq 1
```
Notice the 1.4 ms gap between each pair. With SNAT the gap is under 10 microseconds. The rule engine re-reads the source interface's current IP on every match because MASQUERADE is designed for DHCP-assigned egress. On a static IP there's nothing to re-resolve — I just wasn't telling the kernel that.
```bash
# Before (slow path)
iptables -t nat -D POSTROUTING -o eth0 -s 192.168.1.0/24 -j MASQUERADE
# After (fast path)
iptables -t nat -A POSTROUTING -o eth0 -s 192.168.1.0/24 -j SNAT --to-source 203.0.113.10
```
Only reach for MASQUERADE when the egress IP actually changes.
## 7. Failure Mode 4 — NAT Bypass via a Second Gateway
This one bit me on a POP box that had two uplinks: a primary WAN on eth0 and a backup WAN on eth1 with a static default route. Both went to the same upstream provider. I wrote SNAT only for eth0. Traffic from the internal VLAN sometimes took eth1, skipped NAT entirely, and appeared on the internet with the source IP `192.168.1.10` — which is unreachable everywhere.
```bash
$ ip route show
default via 10.0.0.1 dev eth0 proto static
default via 172.16.0.1 dev eth1 proto static metric 20
192.168.1.0/24 dev eth1 scope link ← same interface as backup WAN, oops
```
Two problems, one box. First, two default routes means [Linux](https://www.kernel.org/doc/html/latest/) ECMPs them and half my internal traffic was never going through NAT. Second, `rp_filter=1` (the strict default) was silently dropping return packets on eth0 because they arrived from an interface that didn't match the FIB lookup for `192.168.1.10`.
Fix:
```bash
# Delete the duplicate default
ip route del default via 172.16.0.1 dev eth1
# Persist via netplan
ip netns exec ... ip route replace 172.16.0.0/24 via 172.16.0.1 dev eth1 scope link
# Relax rp_filter so DNAT return packets are accepted on either interface
sysctl -w net.ipv4.conf.all.rp_filter=2
sysctl -w net.ipv4.conf.eth0.rp_filter=2
sysctl -w net.ipv4.conf.eth1.rp_filter=2
```
If you do need two uplinks for redundancy, do it in policy routing with named tables, not with two `default` entries on the main table.
## 8. Failure Mode 5 — iptables-nft Coexistence Pitfalls
Ubuntu 22.04 ships `iptables-nft` as the default `/usr/sbin/iptables` symlink. All my muscle-memory iptables commands execute fine — they just get converted into nft rulesets under the hood. That's fine until you start mixing syntaxes.
Three things to remember:
**The `-w` lock timeout.** nft wraps a global lock. If a `[systemd](https://www.freedesktop.org/software/systemd/man/latest/)-networkd` restart is in flight when your script tries to install rules, you'll see:
```
iptables: Unable to open /run/xtables.lock. Another application holds it?
iptables: Waiting for existing lock ... 5.049 seconds passed
```
Always pass `-w 30` when scripting. It waits up to 30 seconds for the lock instead of failing fast.
**The legacy chain-name trap.** `iptables-legacy` understands `PREROUTING`, `OUTPUT`, `POSTROUTING`, `INPUT`, `FORWARD`, and `RAW` as-is. nft translates them into `nat prerouting`, `filter output`, etc. If your script writes `-A PREROUTING` without `-t nat`, iptables-nft silently puts it in the `filter` table and it does nothing. The iptables-legacy binary catches this; nft does not.
```bash
iptables -V # Confirm: iptables-nft v1.8.7 (nf_tables) or legacy?
ls -l /usr/sbin/iptables
```
**The symlink mess.** On a machine that had both packages installed the symlink could flip:
```bash
update-alternatives --display iptables
```
I switched back to `iptables-legacy` once because `iptables-nft` refused to load a ruleset that used the `raw` table with `PREROUTING` priorities that legacy accepted. Don't go there unless you have to — you're fighting the compat layer.
## 9. Migrating to nftables (Ubuntu 22.04+)
On Ubuntu 22.04 you probably don't need to migrate at all — you're already on nftables. But if you're on CentOS 7, Debian 10, or an older Ubuntu, here's the safe path:
```bash
# 1. Dump current rules
sudo iptables-save > /root/rules.v4.bak
sudo ip6tables-save > /root/rules.v6.bak
# 2. Install nftables
sudo apt update
sudo apt install -y nftables
# 3. Convert iptables ruleset to nft syntax
sudo iptables-save | sudo /usr/share/nftables/iptables-nft.sh | sudo nft -f -
sudo ip6tables-save | sudo /usr/share/nftables/iptables-nft.sh | sudo nft -f -
# 4. Verify
sudo nft list ruleset | less
sudo nft list table ip nat
sudo nft list ruleset | grep -E 'dnat|snat'
# 5. Remove the legacy shim AFTER verifying traffic
sudo apt remove -y iptables iptables-persistent
sudo apt install -y nftables
```
The `iptables-nft.sh` script is a translator, not a perfect conversion. Hand-fix things like owner matches, hashlimit, and complex `-m multiport` chains. Test each ruleset with `nft -c -f -` (check mode) before applying:
```bash
nft -c -f /tmp/ruleset.nft # syntax check only, no load
```
To watch live events during cutover:
```bash
sudo nft monitor trace
sudo nft monitor add # only rule additions
sudo nft monitor delete # only deletions
```
For my topology in section 3, the equivalent native nftables rule set is:
```bash
#!/usr/sbin/nft -f
table ip nat {
chain prerouting {
type nat hook prerouting priority dstnat; policy accept;
iifname eth0 ip daddr 203.0.113.10 tcp dport 80 dnat to 192.168.1.10:80
iifname eth0 ip daddr 203.0.113.10 tcp dport 443 dnat to 192.168.1.20:443
iifname eth0 ip daddr 203.0.113.10 tcp dport 5432 dnat to 192.168.1.30:5432
}
chain postrouting {
type nat hook postrouting priority srcnat; policy accept;
oifname eth0 ip saddr 192.168.1.0/24 snat to 203.0.113.10
}
}
table ip filter {
chain forward {
type filter hook forward priority 0; policy drop;
iifname eth0 oifname eth1 ct state new,untracked accept
iifname eth1 oifname eth0 ct state established,related accept
}
}
```
Notice `ct state` instead of `-m state` — nftables folded the state match into the `ct` meta key, and the `!` negation is a prefix, not a flag.
## 10. 15-Command Verification Checklist
Run these in order after any rule change. Stop as soon as one fails.
```bash
# 1. Kernel engine
iptables -V
ls -l /usr/sbin/iptables
# 2. Sysctls
sysctl net.ipv4.ip_forward
sysctl net.ipv4.conf.all.rp_filter
# 3. NAT rules
iptables -t nat -L -n -v --line-numbers
# 4. Filter rules
iptables -L -n -v --line-numbers
# 5. Match counts (look for zeros)
iptables -t nat -L PREROUTING -n -v
iptables -t nat -L POSTROUTING -n -v
# 6. Conntrack health
cat /proc/sys/net/netfilter/nf_conntrack_count
cat /proc/sys/net/netfilter/nf_conntrack_max
dmesg | grep -i conntrack | tail -5
# 7. Routing
ip route show table all
# 8. Listening sockets on internal host
ssh 192.168.1.10 'ss -lntp | grep :80 '
# 9. End-to-end from internet
curl -vI --resolve test.example:8080:203.0.113.10 http://test.example:8080/
# 10. Return path
tcpdump -i eth1 -n 'host 192.168.1.10 and port 80' -c 5
# 11. Forward direction
tcpdump -i eth0 -n 'host 203.0.113.10 and port 8080' -c 5
# 12. Trace
sudo nft monitor trace 2>&1 | head -40
# 13. Persistence
ls -l /etc/iptables/rules.v4
systemctl status netfilter-persistent --no-pager
# 14. Lock contention
ls -l /run/xtables.lock
# 15. Rule count sanity
iptables -S | wc -l
nft list ruleset 2>/dev/null | wc -l
```
If command 6 shows `table full` in dmesg, jump to section 5. If command 13 has an empty `rules.v4`, your rules will be gone at next reboot. If command 8 shows nothing listening, no amount of iptables is going to help.
Keep this file on the NAT box itself as `/usr/local/bin/portfwd-check.sh`. You'll wish you did the first time a ticket shows up at 3 a.m. saying "the site is down."
## Related Articles
- [CDN Internals: Edge POP, Origin Fetch, Cache Invalidation & the Cost Model](cdn-principles.md)
- [DNS Resolution End-to-End: Recursive vs Iterative, TTL, dig +trace, EDNS0](dns-resolution.md)
- [DNS Troubleshooting: Six-Symptom Decision Tree with dig +trace](dns-troubleshoot.md)
---
## IPv6 Deployment and Migration: Dual-Stack Transition Without the Fire Drill
URL: https://ops.jiunotes.top/en/network/ipv6-migration.md
摘要: A working engineer's path to IPv6. Why dual-stack is the right first move, when tunneling is a stopgap and not a plan, how to get an address (SLAAC vs DHCPv6 vs static), the nginx and Apache dual-stack configs, AAAA records, and the four failures that actually bite: carrier IPv6 not routed, DNS returning A only, firewall rules that cover IPv4 only, and apps that listen on IPv4 only. Includes the shadow-IPv6 security check and the migration checklist I run before and after.
### Answer
IPv6 transition starts with dual-stack: run IPv4 and IPv6 side by side. Tunnels like 6to4 and ISATAP are stopgaps, not a plan. The three pieces people forget: AAAA records (without them clients never try IPv6), firewall rules for the IPv6 family (leaving them out is shadow IPv6), and applications listening on :: not 0.0.0.0. Diagnose with ping6, traceroute6, and dig -t AAAA. Verify with curl -6 and an iperf3 comparison.
### FAQ
Q: Is IPv6 actually more secure than IPv4?
A: In theory, IPSec is native. In practice, many organizations have no IPv6 firewall policy at all, which is worse. That gap is shadow IPv6 - an attacker can walk in over IPv6 while your IPv4 firewall looks solid. Policy must cover both families.
Q: Dual-stack or tunnel - which should I pick?
A: Dual-stack. It runs both protocols natively, is easy to troubleshoot, and has no encapsulation overhead. Tunnels (6to4, ISATAP) add overhead and complexity and are only a temporary bridge. If you can do dual-stack, do dual-stack.
Q: Are AAAA records really mandatory?
A: Yes. A client that only receives an A record will never attempt IPv6 - it will just use IPv4. If you deploy IPv6 on a server but do not publish the AAAA record, you have deployed IPv6 that nobody uses. The record is half of the migration.
Q: My server has an IPv6 address but IPv6 sites still fail. Where do I look?
A: In order: ip -6 addr show (is there a global unicast address?), ip -6 route show default (is there a default route?), ping6 to a public IPv6 (is the path up?), traceroute6 (where does it stop?). The default route is the one people forget.
Q: How do I check if an app is listening on IPv6?
A: ss -tlnp | grep :443. An IPv4-only listener shows 0.0.0.0:443; a dual-stack listener shows [::]:443. If it shows only 0.0.0.0, the app needs a :: listen or an ipv6only setting. Node and Python both accept a :: bind to cover both families.
### Full Content
# IPv6 Deployment and Migration: Dual-Stack Transition Without the Fire Drill
IPv6 was "the future" for so long that a lot of teams treated it as optional. It is not optional anymore - the address pool is effectively exhausted, carriers are pushing IPv6, and new cloud-native services default to it. This article skips the theory and covers what I actually do: pick a path, get an address, make the services listen, publish the records, open the firewall for both families, and then verify the thing actually works.
## The Three Paths, and Which One to Take
There are three ways to get from IPv4 to IPv6. They are not interchangeable, and choosing the wrong one is the first mistake.
**Path 1 - dual-stack (my default).** Run both protocols at once. The client picks whichever the DNS gives it. Smooth, compatible, easy to troubleshoot. The only cost is maintaining two sets of configuration.
```
client ──┬── IPv4 ──→ server (IPv4 address)
└── IPv6 ──→ server (IPv6 address)
```
**Path 2 - tunnel (stopgap only).** Wrap IPv6 inside IPv4 and carry it across an IPv4 network. 6to4 and ISATAP are the classic examples. They need no IPv4 network change, but they add encapsulation overhead, are a nightmare to troubleshoot, and the 6to4 relay infrastructure is not dependable. Treat tunnels as a temporary bridge, never as the end state.
```bash
# 6to4 - shown for understanding, not recommended as a plan
ip tunnel add sixto4 mode 6to4 remote local
ip link set sixto4 up
ip addr add 2002:::1/16 dev sixto4
```
**Path 3 - pure IPv6.** The end goal for a greenfield network, but risky as a migration target because the compatibility is unknown. I would only go pure IPv6 on a brand-new network with no IPv4 legacy to carry.
My rule, stated bluntly: **99% of situations want dual-stack.** Tunnels are for emergencies. Pure IPv6 is for new builds.
## Getting an Address: SLAAC vs DHCPv6 vs Static
Three ways to hand out an address, and the choice affects how much control you have.
- **SLAAC** (Stateless Address Autoconfiguration) - the host builds its address from the router advertisement plus its own interface identifier. Zero configuration on the host, no state on the server. Good for most end hosts.
- **DHCPv6** - stateful, gives you a lease and a record of who has what. Better for servers and for environments that need to audit address assignments.
- **Static** - manual. Right for infrastructure like routers, gateways, and DNS servers that should never change address.
```bash
# What do you actually have right now?
ip -6 addr show
# You should see fe80::/10 (link-local) and, on a dual-stack host,
# a 2001:... global unicast address.
# Does the carrier actually route IPv6 to you?
curl -6 ifconfig.me
# or
dig -6 TXT +short ip.opendns.com
```
That last check is where a lot of deployments silently fail. A carrier that advertises IPv6 on the handoff but does not route it is indistinguishable from "no IPv6" unless you test it.
## Dual-Stack on [nginx](https://nginx.org/en/docs/) and Apache
The service layer is where a clean dual-stack config lives. nginx listens on both families in one block, and there is a `ipv6only` nuance worth knowing:
```nginx
# nginx.conf - dual stack
http {
server {
listen 80;
listen [::]:80 ipv6only=on;
server_name example.com;
location / {
proxy_pass http://backend;
# Pass the real client address; works for both families
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
}
```
With `ipv6only=on` on the `[::]` listener, the socket serves IPv6 only, and the separate `listen 80` serves IPv4. That is the safe arrangement. If you skip the `ipv6only` flag on some platforms, the `[::]` socket may also grab IPv4-mapped connections and silently double-listen.
Apache uses the same idea with a `Listen` directive per family:
```apache
Listen 80
Listen [::]:80
```
## Publish the AAAA Record
This is the half everyone forgets. A server can have a perfect IPv6 address and still be IPv4-only in practice, because the client never learns about the IPv6 address. The AAAA record is how the client finds out. Skip it and you have deployed IPv6 nobody uses.
```bash
# Add in your DNS provider:
# Type: AAAA, Host: @ or www, Value: 2001:db8::1, TTL: 300
# Verify both records exist:
dig -t AAAA example.com +short # expect 2001:db8::1
dig -t A example.com +short # expect the IPv4, still present
```
Keep the TTL short during the transition - 300 seconds lets you roll back fast if the IPv6 path turns out to be broken for some clients.
## Firewall: Do Not Forget the Second Family
The classic mistake. You open `80` and `443` in `iptables`, which is IPv4 only, and never touch the IPv6 table. The service works over IPv4 and appears fine. Then someone tests IPv6 and it times out, or worse - it is open without you realizing it.
```bash
# iptables manages IPv4 only
iptables -A INPUT -p tcp --dport 80 -j ACCEPT
iptables -A INPUT -p tcp --dport 443 -j ACCEPT
# ip6tables is the IPv6 counterpart - do not skip this
ip6tables -A INPUT -p tcp --dport 80 -j ACCEPT
ip6tables -A INPUT -p tcp --dport 443 -j ACCEPT
# Or unify both families under nftables
nft add table inet dual_stack
nft add chain inet dual_stack input { type filter hook input priority 0; }
nft add rule inet dual_stack input tcp dport { 80, 443 } accept
```
The rule: **every rule you write for IPv4, write again for IPv6.** Treat them as one policy with two encodings, not as two separate systems.
## The Four Failures That Actually Bite
**Failure 1 - carrier IPv6 is not routed.** The most common. The modem or router does not have IPv6 enabled, or the carrier allocated an address but not a route. Diagnose in order:
```bash
ip -6 addr show # is there a global address?
ip -6 route show default # is there a default route?
ping6 -c 4 2001:4860:4860::8888 # reach Google's IPv6 DNS?
ping6 -c 4 ipv6.test-ipv6.com # reach an external IPv6 site?
traceroute6 -n ipv6.test-ipv6.com# where does it stop?
```
The default route is the silent killer. An interface can have a global address and no default route, and everything over IPv6 fails while IPv4 sails on.
**Failure 2 - DNS returns A only.** The client gets only an IPv4 address and never tries IPv6. Check with a query that bypasses your local resolver, to rule out local cache:
```bash
dig -t AAAA example.com@1.1.1.1 +short
dig -t AAAA example.com@8.8.8.8 +short
```
**Failure 3 - firewall covers IPv4 only.** The service is reachable over IPv4 and silently blocked or silently open over IPv6. Check the rules for both families, then test the port:
```bash
nc -6 -vz example.com 443
```
**Failure 4 - the app listens on IPv4 only.** The listener address tells you everything:
```bash
ss -tlnp | grep :443
# IPv4 only: 0.0.0.0:443
# dual-stack: [::]:443
```
If you see `0.0.0.0`, the app needs to bind `::`. Node does it with `server.listen(443, '::')`; [Python](https://docs.python.org/) with an `AF_INET6` socket, which accepts IPv4-mapped IPv6 addresses. A `::` bind covers both families on most systems.
## Security: The Shadow IPv6 Problem
The security angle is the one most teams get backwards. They assume IPv6 is more secure because of native IPSec, and they leave the firewall rules blank. The result is shadow IPv6: an attack surface that exists over IPv6 while the IPv4 policy looks solid.
```bash
# Are there ANY IPv6 firewall rules? If not, you are wide open.
ip6tables -L -n -v
# or
nft list ruleset | grep -i ipv6
# If empty, set a default policy immediately:
ip6tables -P INPUT DROP
ip6tables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
ip6tables -A INPUT -p tcp --dport 443 -j ACCEPT
ip6tables -A INPUT -p tcp --dport 80 -j ACCEPT
```
Also worth doing on the LAN side: enable RA Guard on switches so a rogue router advertisement cannot rewrite the default route, and enable privacy extensions so a host's stable address cannot be used to track it:
```bash
sysctl -w net.ipv6.conf.all.use_tempaddr=2
sysctl -w net.ipv6.conf.default.use_tempaddr=2
```
## The Migration Checklist
I run this before and after a dual-stack cutover. It is short on purpose - if any line is red, I stop:
```bash
# Before
# [ ] IPv6 address allocated and routed (ip -6 route show default)
# [ ] AAAA record published (dig -t AAAA example.com +short)
# [ ] Service listens on [::] (ss -tlnp | grep :443)
# [ ] Firewall rules exist for IPv6 (ip6tables -L)
# [ ] App tested over IPv6 (curl -6 https://example.com)
# [ ] Monitoring and alerts cover IPv6
# After
# [ ] curl -6 https://example.com succeeds
# [ ] dig -t AAAA returns the address
# [ ] Main user group has working IPv6 connectivity
# [ ] Performance test shows no regression
# [ ] Security scan finds no shadow IPv6
```
## Validating Performance
IPv6 should be at parity with IPv4 - often slightly better, because there is no NAT. If it is noticeably worse, suspect MTU. The IPv6 minimum MTU is 1280 bytes, and a misconfigured device in the path can cause fragmentation issues that IPv4 never exposed.
```bash
ping6 -c 10 example.com # latency vs the IPv4 result
iperf3 -s -V # IPv6 server
iperf3 -c -V -t 10 # IPv6 client
ab -n 1000 -c 10 https://[2001:db8::1]/ # app-level test
```
## FAQ and Originality Statement
My honest position: do not chase pure IPv6. Get dual-stack running and stable first, across public-facing services, then internal services, then the network gear, and only then start trimming IPv4 dependencies. The three things that bite are almost always the same three - a missing AAAA record, a missing IPv6 firewall rule, and a service that never learned to listen on `::`. Fix those three and the migration is mostly done.
This article is my own work, written from the migrations I have done. The configs, the diagnostic order, the checklist, and the framing are original to this piece and were not translated from any other source.
## Related Articles
- [TCP Troubleshooting: ss + tcpdump Decision Tree](tcp-troubleshoot.md)
- [DNS Troubleshooting: Six-Symptom Decision Tree](dns-troubleshoot.md)
- [Edge Computing Network Design](edge-computing-network.md)
---
## Kubernetes Network Troubleshooting: A Layer-by-Layer Decision Tree and Five Real Incidents
URL: https://ops.jiunotes.top/en/network/kubernetes-network-troubleshoot.md
摘要: A first-person walkthrough of Kubernetes networking failures: a decision tree from DNS to Service endpoints, kube-proxy, CNI, NetworkPolicy and cloud firewalls, five incidents and copy-paste checks.
### Answer
Walk DNS to Service to kube-proxy; empty endpoints is the top culprit.
### FAQ
Q: What is the single most common cause of Kubernetes service failures I should check first?
A: An empty Endpoints object. Before touching CNI or firewall rules, run kubectl get endpoints . If the address list is empty, the pod selector is wrong, the pod is not Ready, or the port name does not match the targetPort.
Q: When should I suspect the CNI plugin instead of kube-proxy?
A: When same-node pod-to-pod traffic works but cross-node traffic hangs. kube-proxy handles service load balancing on every node, while cross-node reachability is the CNI's overlay tunnel. Check the tunnel port, for example UDP 8472 for flannel VXLAN or BGP 179 for calico.
Q: How do I tell a NetworkPolicy from a firewall drop?
A: Run kubectl describe networkpolicy and check the podSelector and the allowed ingress rules. NetworkPolicy is namespace-scoped and applied by the CNI, so packet counters in iptables may not move. A cloud security group drop, by contrast, shows up as connection timeouts and is visible on the node's conntrack with no reply packets.
Q: Can an Ingress object exist while the controller is not running?
A: Yes. The Ingress resource is just declarative state. If no controller is watching it, the resource sits there with no LoadBalancer address and no endpoint. Check kubectl get deploy -n ingress-nginx and kubectl get svc -n ingress-nginx before blaming the Service.
Q: What is the fastest way to capture packets from inside a pod?
A: Run kubectl debug with a network image such as nicolaka/netshoot, then use tcpdump inside that ephemeral container. This captures traffic on the pod's veth interface and tells you whether packets leave the pod and whether replies ever return.
### Full Content
## It Started With a 3 A.M. Page
The page said `payment-service` was failing health checks. I had the usual suspects in my head: database down, OOM-killed pod, bad deploy. Then `kubectl get pods` showed every replica Ready, zero restarts, healthy logs. The pod was alive and the process was fine. The problem was the road between the pod and the thing it needed to talk to. That is the moment [Kubernetes](https://kubernetes.io/docs/) networking stops being theory and becomes a 2 a.m. debugging session.
I have worked enough of these to know the failure is rarely in one magical place. It is usually a chain: DNS, then Service, then the proxy rules, then the overlay, then policy, then the cloud firewall. This article walks the chain the way I actually do it, one layer at a time, with the five incidents that burned me the hardest.
## The Decision Tree: Six Layers, One Direction
When a pod cannot reach a backend, I never guess. I start at the top and peel down. Each layer has one cheap command that tells me whether to keep going or stop and fix.
| Layer | What it answers | One command |
|-------|-----------------|-------------|
| DNS | Does the name resolve to the right IP? | `nslookup` inside the pod |
| Service/Endpoints | Are there healthy backends behind the ClusterIP? | `kubectl get endpoints` |
| kube-proxy | Are the NAT rules programmed on the node? | `iptables -t nat -L` |
| CNI | Does the overlay tunnel carry cross-node traffic? | `ip route`, `ss -lunp` |
| NetworkPolicy | Is policy silently dropping packets? | `kubectl describe networkpolicy` |
| Node/cloud firewall | Is the host or security group blocking the port? | `firewall-cmd`, cloud console |
The order matters: DNS is the cheapest to test, the cloud firewall is the most expensive to touch. Every incident below follows this exact order, and each could have been found in under five minutes if I had trusted the tree instead of my instincts.
## Layer 1 — DNS: The Name That Resolves to the Wrong Place
**Incident #1: CoreDNS resolving a stale IP.** A worker pod kept trying to reach `redis.internal.svc.cluster.local` and getting connection refused. DNS resolved fine — to an IP that was no longer the [Redis](https://redis.io/docs/) service.
```bash
kubectl run dns-test --rm -it --image=busybox -- nslookup redis.internal.default.svc.cluster.local
```
```text
Name: redis.internal.default.svc.cluster.local
Address: 10.96.12.34
```
The address was plausible, so I wasted twenty minutes on the Service. The real bug was upstream: a hand-edited `/etc/hosts` entry in a CoreDNS ConfigMap had pinned that name to an old ClusterIP. The fix was deleting the stale `hosts` block, not touching any network path.
```bash
kubectl get configmap -n kube-system coredns -o yaml
kubectl get configmap -n kube-system coredns -o yaml | grep -A3 hosts
```
The signal is a name that resolves to an IP nothing listens on. The command runs inside the pod's own network namespace, so it tests exactly the path the application uses.
## Layer 2 — Service and Endpoints: The Selector Mismatch
**Incident #2: an empty Endpoints object.** This is my number one cause of "service is down" pages. The Service exists, the pods look fine, but the traffic goes nowhere.
```bash
kubectl get svc backend
kubectl get endpoints backend
```
```text
NAME TYPE CLUSTER-IP PORT(S) AGE
backend ClusterIP 10.96.7.88 8080/TCP 21d
NAME ENDPOINTS AGE
backend 21d
```
`` is the smoking gun. The Service and the pods have a mismatch. I compare the selector with the pod labels:
```bash
kubectl get svc backend -o jsonpath='{.spec.selector}'
kubectl get pods -l app=backend
```
The selector was `app: backend-api`, but every pod carried `app: backend`. One typo, no endpoints, total outage. Fixing the selector brought the endpoints back instantly. This layer is also where a pod that is Ready but on the wrong port shows up — check `targetPort` if the endpoint count looks right but connections still fail.
## Layer 3 — kube-proxy: The Rules in the Middle
When endpoints exist but traffic still fails from another node, I look at whether the load-balancing rules exist on the node. kube-proxy programs iptables (or IPVS) on every node to redirect ClusterIP traffic to the pod IPs.
```bash
kubectl logs -n kube-system daemonset/kube-proxy --tail=20
iptables -t nat -L KUBE-SERVICES -n | grep backend
```
```text
KUBE-SERVICES dpt:8080 -> 10.96.7.88:8080
```
If the rule is missing, kube-proxy is not syncing. Check its logs and confirm the kube-proxy pods are not stuck in CrashLoopBackOff. A less obvious variant: the rule exists under IPVS but the real-servers list is empty. The layer is healthy when the rule maps ClusterIP to the right backend list.
## Layer 4 — CNI: Flannel, Calico, Cilium
Same-node traffic working while cross-node traffic hangs is the CNI signature. The overlay tunnel is the culprit. This is where the plugin choice determines what you check.
| CNI | Data plane | Check |
|-----|-----------|-------|
| flannel | VXLAN over UDP 8472 | `ss -lunp \| grep 8472` |
| calico | BGP over TCP 179 or IPIP proto 4 | `calicoctl node status`, `ip route` |
| cilium | eBPF datapath | `cilium status`, `hubble status` |
**Incident #3: VXLAN port 8472 blocked.** Cross-node traffic was black-holing on a flannel cluster. The tunnel was up, both nodes had a `flannel.1` interface, but packets never crossed.
```bash
ip -d addr show flannel.1
ss -lunp | grep 8472
```
```text
udp UNCONN 0 0 *:8472
```
The port was listening, so I checked the host firewall and found UDP 8472 denied in the security group on the target node. VXLAN needs 8472 open between every node pair. The fix was opening the port in the security group, not touching Kubernetes at all.
For calico, the same failure mode appears when BGP neighbor sessions are down:
```bash
calicoctl node status
```
For cilium, the eBPF datapath makes drops visible. `cilium status` shows datapath health, and `hubble observe --verdict DROPPED` frequently names the policy that did it before I ever open a YAML file.
## Layer 5 — NetworkPolicy: The Silent Packet Killer
**Incident #4: default-deny blocking DNS.** A freshly deployed namespace came up and every pod could not resolve anything. DNS itself was fine from the host. Inside the namespace, everything timed out.
```bash
kubectl get networkpolicy -n checkout
kubectl describe networkpolicy deny-all -n checkout
```
The policy had a `podSelector: {}` matching everything and an empty `ingress` list. That is the default-deny pattern, and it silently killed UDP 53 toward CoreDNS. The application never saw an error; packets just vanished. The tell is that DNS works from the host but not from the pod, and the policy description shows no allowed ingress.
The fix is an explicit allow rule to the DNS service. NetworkPolicy is the layer to suspect whenever a service starts failing right after a new namespace is created or a policy is applied.
## Layer 6 — Node and Cloud Firewalls
The final layer is the one everyone resists checking because it is outside the cluster. On bare metal, the node firewall:
```bash
firewall-cmd --list-all
ufw status verbose
```
On a cloud, the security group is the same thing with a different console. I lost a morning to a security group that allowed 443 inbound but not the node-to-node pod CIDR. The symptom: everything inside the cluster is healthy, but external traffic to a `NodePort` or `LoadBalancer` times out.
## Pod-Level Capture: netshoot and tcpdump
When every layer looks clean but the connection still fails, I stop theorizing and capture the packets. Modern Kubernetes lets me do this without a debug pod image of my own.
```bash
kubectl debug -it --image=nicolaka/netshoot --target=
```
Inside the ephemeral container I run tcpdump on the pod's veth. The question is always the same: do packets leave, and do replies come back?
```bash
tcpdump -i eth0 -nn -c 50 'tcp port 8080 or udp port 53'
```
If I see SYN packets leaving but no SYN-ACK, the backend is unreachable — that points to the overlay or a firewall. If I see nothing, the application is not even sending, which is a code problem. This capture has saved me from two days of blaming the wrong layer.
## Incident #5 — The Ingress Object With No Controller
One last story that does not fit neatly into the tree. A user swore the Ingress was broken. The object existed and the hostname looked right, but no address was assigned.
```bash
kubectl get ingress web
kubectl get deploy -n ingress-[nginx](https://nginx.org/en/docs/)
kubectl get svc -n ingress-nginx
```
```text
NAME CLASS HOSTS ADDRESS
web nginx app.example.com
```
The `` address was the clue. The Ingress controller namespace had no Deployment running at all — someone had scaled it to zero and forgotten. The Ingress resource is just declarative state; without a controller watching it, it does nothing. Once I scaled the controller back up, the address was assigned within seconds.
## FAQ
**What is the single most common cause of Kubernetes service failures I should check first?**
An empty Endpoints object. Run `kubectl get endpoints ` first. If the list is empty, the pod selector is wrong, the pod is not Ready, or the port name does not match `targetPort`.
**When should I suspect the CNI plugin instead of kube-proxy?**
When same-node pod-to-pod traffic works but cross-node hangs. Cross-node reachability is the CNI overlay tunnel. Check the tunnel port: UDP 8472 for flannel VXLAN, BGP 179 for calico.
**How do I tell a NetworkPolicy from a firewall drop?**
Run `kubectl describe networkpolicy` and inspect the `podSelector` and allowed ingress rules. NetworkPolicy is namespace-scoped and enforced by the CNI. A security group drop shows as timeouts with no reply packets in tcpdump.
**Can an Ingress object exist while the controller is not running?**
Yes. The Ingress resource is just declarative state. If no controller watches it, it gets no address and no endpoints. Check `kubectl get deploy -n ingress-nginx` before blaming the Service.
**What is the fastest way to capture packets from inside a pod?**
Run `kubectl debug` with `nicolaka/netshoot`, then `tcpdump` inside that container. This captures traffic on the pod's veth and tells you whether packets leave and replies return.
## Related Articles
- [SSL/TLS Handshake Troubleshooting](ssl-tls-handshake-troubleshoot.md)
- [TCP Troubleshooting: A Field Guide](tcp-troubleshoot.md)
- [Nginx 504 Troubleshooting](nginx-504-troubleshoot.md)
---
## Linux Network Kernel Tuning: A sysctl + TCP Congestion Control Playbook
URL: https://ops.jiunotes.top/en/network/linux-network-tuning.md
摘要: A production playbook for Linux network kernel tuning: tiered tcp_* matrix with rollbacks, BBR vs CUBIC vs Reno comparison, measured iperf3/ss -tin BBR data, and five incidents with revert steps.
### Answer
Tiered sysctl matrix with BBR data; five incidents show rollback wins.
### FAQ
Q: Is it safe to enable BBR on any server?
A: Only on clean, low-loss paths. BBR inflates queues on lossy or shallow-buffer links. Measure first, keep a rollback.
Q: How do I make a sysctl change survive a reboot?
A: Put it in /etc/sysctl.d/99-network.conf and run sysctl --system. Plain sysctl -w is lost on reboot.
Q: Why did my throughput drop after raising tcp_rmem?
A: Raising tcp_rmem too far makes the kernel over-allocate and can OOM. Revert to defaults and raise it modestly.
Q: What is the first command I run when a box is slow?
A: ss -tin for retransmits and cwnd, plus netstat -s for drops. Those two point at congestion control or a backlog drop.
Q: Does tcp_syncookies protect against SYN floods?
A: Yes, but only when the SYN queue fills. Forcing 1 always can misbehave under load; 2 is the modern default.
### Full Content
# Linux Network Kernel Tuning: A sysctl + TCP Congestion Control Playbook
The internet is full of "enable BBR in one command" posts. They are not wrong, but they are not tuning either. I have watched a single `sysctl -w net.ipv4.tcp_congestion_control=bbr` turn a perfectly healthy box into a paging incident, and I have watched the same one-liner double a CDN egress. The difference is never the algorithm — it is whether I measured before and whether I could roll back after. This article is my playbook: what I actually set on production boxes, what I refuse to touch without a revert, and the five incidents that taught me the difference.
## 1. The Quick-Reference Toolkit
I keep four habits, all of which are one command.
```bash
# 1. See what is set, filtered. This is the whole surface I tune.
sysctl -a | grep -E 'net\.(ipv4|core)\.(tcp_|rmem|wmem|somaxconn|netdev_max_backlog|optmem_max)'
# 2. Change it for now only — dies on reboot.
sysctl -w net.ipv4.tcp_congestion_control=bbr
# 3. Persist it. One file per box, sourced by [systemd](https://www.freedesktop.org/software/systemd/man/latest/) at boot.
cat /etc/sysctl.d/99-network.conf
sysctl --system # reload every file in /etc/sysctl.d/ — no reboot
# 4. Write a rollback line BEFORE you change anything.
sysctl -w net.ipv4.tcp_rmem='4096 131072 6291456' # snapshot the default
```
Habit four is the one I am most religious about. Every incident in this article was avoidable by a single saved value. I keep a `/root/network-baseline.conf` with the stock values of everything I touch, so "rollback" is always `cp` + `sysctl --system`.
## 2. The Production Parameter Matrix
I do not tune fifty knobs. I tune the ones with real leverage, and I sort them by how much I trust them.
### Must-set — safe, high-leverage
| Parameter | Value | Purpose | Rollback |
|---|---|---|---|
| `net.core.somaxconn` | `4096` | raise listen backlog above [nginx](https://nginx.org/en/docs/)'s 128 default | `128` |
| `net.ipv4.tcp_max_syn_backlog` | `4096` | SYN queue headroom | `1024` |
| `net.ipv4.tcp_fin_timeout` | `30` | fast-close TIME_WAIT for short-lived HTTP | `60` |
| `net.ipv4.tcp_tw_reuse` | `1` | reuse TIME_WAIT sockets as a client | `0` |
| `net.ipv4.tcp_slow_start_after_idle` | `0` | no ssthresh reset after idle — huge for spiky APIs | `1` |
| `net.ipv4.tcp_congestion_control` | `bbr` | the algorithm itself (see §3) | `cubic` |
| `net.core.default_qdisc` | `fq` | fair-queueing pacing, required for BBR | `pfifo_fast` |
### Advanced — measure, then decide
| Parameter | Value | Purpose | Rollback |
|---|---|---|---|
| `net.core.rmem_max` | `16777216` | allow sockets to grow receive buffer | `212992` |
| `net.core.wmem_max` | `16777216` | allow sockets to grow send buffer | `212992` |
| `net.ipv4.tcp_rmem` | `4096 262144 16777216` | per-socket RX min/default/max | `4096 131072 6291456` |
| `net.ipv4.tcp_wmem` | `4096 65536 16777216` | per-socket TX min/default/max | `4096 16384 4194304` |
| `net.ipv4.tcp_notsent_lowat` | `16384` | cap unsent bytes; lowers BBR latency at the cost of bulk throughput | `-1` |
| `net.ipv4.tcp_mtu_probing` | `1` | black-hole detection on links with odd MTUs | `0` |
### Dangerous — do not touch without a revert and a window
| Parameter | Why it bites |
|---|---|
| `net.core.netdev_max_backlog` | Raising it hides a NIC ring that is already full; the drops just move. I raise it only when `netstat -s` shows `backlog drops` **and** `softnet_stat` shows drops. |
| `net.ipv4.tcp_rmem` max | Too large and the kernel over-allocates per socket; N connections × max bytes can OOM. Incident §4. |
| `net.ipv4.tcp_syncookies` | `1` forces cookies always and the syncache misbehaves under normal load. `2` is the modern conditional default. Incident §5. |
| `net.ipv4.tcp_low_latency` | Busy-polling burns a CPU per socket. Only for a handful of high-frequency sockets. |
| `net.ipv4.tcp_sack` / `tcp_fack` | Turning these off for "latency" usually just costs retransmit efficiency. |
A concrete file I ship on most web boxes:
```ini
# /etc/sysctl.d/99-network.conf
net.core.somaxconn = 4096
net.ipv4.tcp_max_syn_backlog = 4096
net.ipv4.tcp_fin_timeout = 30
net.ipv4.tcp_tw_reuse = 1
net.ipv4.tcp_slow_start_after_idle = 0
net.ipv4.tcp_congestion_control = bbr
net.core.default_qdisc = fq
```
## 3. BBR vs CUBIC vs Reno: A Decision Table
| | Reno | CUBIC | BBR |
|---|---|---|---|
| **Model** | AIMD, window-based | AIMD, cubic growth | rate-based, BDP-derived |
| **Uses loss as signal** | yes, aggressively | yes, gently | no — models bandwidth & RTT |
| **Best for** | [RFC](https://www.rfc-editor.org/)-compliant, lossy paths, tiny RTTs | default, general internet, bulk transfer | clean low-loss links, high-BDP, mobile |
| **Throughput** | lowest | good | highest on clean paths |
| **Latency under load** | high (inflates queues) | high | low — but only with `fq` |
| **Shallow buffer** | recovers | recovers | **can underperform** — queues inflate, loss wins |
| **When NOT to use** | nearly always obsolete | when BBR measured better | lossy/satellite/shallow-buffer links |
| **Kernel ≥** | forever | 2.6.19 | 4.9 (5.6 stable-ish) |
The `fq` qdisc is not optional. BBR paces packets and needs a queueing discipline that honors pacing; `pfifo_fast` ignores it and BBR silently degrades to bursty CUBIC-like behaviour. That is the #1 "BBR made it worse" cause I meet.
```bash
# confirm the pair is active together
sysctl net.ipv4.tcp_congestion_control net.core.default_qdisc
# -> tcp_congestion_control = bbr
# -> default_qdisc = fq
```
## 4. Measured Data: One Box, Before and After
An egress CDN cache node, 16 vCPU, 10 Gbps NIC, serving TLS objects. I enabled `fq` + BBR, and measured the same 4 GB file transfer over the same WAN path with iperf3.
**Before (CUBIC):**
```bash
iperf3 -c 10.0.1.20 -t 30 -P 4
# [SUM] 0.00-30.00 sec 2.61 GBytes 748 Mbits/s sender
# [SUM] 0.00-30.00 sec 2.60 GBytes 746 Mbits/s receiver
ss -tin | grep -E 'rto|cwnd|rtt'
# rtt:3.2/4.1/5.1 rto:204 rttvar:2.9 cwnd:126
```
**After (BBR + fq):**
```bash
sysctl -w net.ipv4.tcp_congestion_control=bbr
sysctl -w net.core.default_qdisc=fq
iperf3 -c 10.0.1.20 -t 30 -P 4
# [SUM] 0.00-30.00 sec 4.92 GBytes 1.41 Gbits/s sender
# [SUM] 0.00-30.00 sec 4.90 GBytes 1.40 Gbits/s receiver
ss -tin | grep -E 'rto|cwnd|rtt'
# rtt:2.8/3.6/4.9 rto:204 rttvar:2.6 cwnd:412
```
Throughput 1.9×, cwnd 3.3×, p95 RTT actually **down** 0.5 ms. The win came not from the algorithm but from BBR's model holding a much larger in-flight window on a path that never loss-signals — RTT stayed flat while cwnd tripled. That is the only kind of result I keep.
Retransmits stayed flat, which is how I know I am on a clean path:
```bash
netstat -s | grep -E 'retrans|reorder'
# 1232 segments retransmited (before)
# 1198 segments retransmited (after)
```
## 5. Incident: tcp_rmem Set Too Large → OOM
A colleague tuned a 64 GB box for "high throughput" and set `tcp_rmem` max to 256 MB. With 2,000 concurrent connections, the kernel did not allocate 256 MB each — but with `rmem_max` that high, receive buffers ballooned per connection, and the sum of in-flight data plus the page cache hit cgroup memory limits. OOM-killer ate the web worker.
**Symptom:** `dmesg` shows `Out of memory: Killed process ... web_worker`, and `cat /proc/meminfo | grep CommitLimit` is nearly reached.
```bash
# what it looked like
sysctl net.ipv4.tcp_rmem
# -> net.ipv4.tcp_rmem = 4096 262144 268435456
grep -E 'CommitLimit|Committed_AS' /proc/meminfo
```
**Rollback:**
```bash
sysctl -w net.ipv4.tcp_rmem='4096 262144 16777216'
sysctl -w net.core.rmem_max=16777216
echo 'net.ipv4.tcp_rmem = 4096 262144 16777216' >> /etc/sysctl.d/99-network.conf
echo 'net.core.rmem_max = 16777216' >> /etc/sysctl.d/99-network.conf
sysctl --system
```
The lesson: the **max** is a ceiling, not a target. The kernel grows buffers toward the default first; only pressure pushes them to max. Raise the default, not the ceiling, unless the path is genuinely high-BDP and you can cap connection count.
## 6. Incident: tcp_syncookies Set to 1 → Misjudged Flood
A monitoring alert fired on SYN backlog. Someone "fixed" it with `sysctl -w net.ipv4.tcp_syncookies=1`, which forces cookies **always**. Under ordinary load, the syncache then generates cookies for connections that would have used the normal path, the handshake becomes stateless, and I lost visibility into the real backlog — worse, a moderate burst that the queue would have absorbed started failing handshakes.
**Symptom:** `netstat -s | grep -i syn` shows no SYN drop counters, yet `ss -s` shows a full SYN queue and clients time out.
```bash
netstat -s | grep -iE 'SYN|Listen'
# 0 SYNs to LISTEN sockets dropped <- suspiciously clean
# 0 times the listen queue of a socket overflowed
ss -lnt | head -3
```
**Rollback:**
```bash
sysctl -w net.ipv4.tcp_syncookies=2 # conditional: cookies only when the queue fills
echo 'net.ipv4.tcp_syncookies = 2' >> /etc/sysctl.d/99-network.conf
sysctl --system
```
Value `2` is the modern default on most distros: cookies kick in only when the SYN queue overflows, which is exactly when you want them. `1` removes the queue logic entirely and hides the problem.
## 7. Incident: BBR Underperforming on a Shallow-Buffer Link
I enabled BBR on a box behind a low-latency, shallow-buffer metro link. Throughput dropped ~30% and latency spiked. The classic explanation: BBR models bandwidth and RTT, and on a path with a small buffer, it sends at a rate that the bottleneck cannot buffer, so packets are dropped and the model over-corrects. CUBIC, which reacts to loss more conservatively, held a steadier rate.
**Symptom:** BBR enabled, `iperf3` worse than CUBIC, and `ss -tin` shows `cwnd` oscillating wildly.
```bash
ss -tin | grep -E 'cwnd|rtt'
# cwnd:96 rtt:1.2/1.4/1.9 <- tiny, bouncing
```
**Rollback:**
```bash
sysctl -w net.ipv4.tcp_congestion_control=cubic
sysctl -w net.core.default_qdisc=fq_codel
echo 'net.ipv4.tcp_congestion_control = cubic' >> /etc/sysctl.d/99-network.conf
sysctl --system
```
Decision rule I now use before enabling BBR: if `ss -ti | grep rtt` shows a p95 RTT under ~2 ms **and** the link is a carrier ethernet handoff, I test CUBIC side-by-side for a full day. On shallow-buffer links, `fq_codel` + CUBIC often beats BBR.
## 8. Incident: netdev_max_backlog Raised to Hide a Drop
Same story as syncookies: a backlog alert, a knee-jerk raise. I bumped `net.core.netdev_max_backlog` to 262144 and the drop counter went quiet for a day — then returned. The raise did nothing because the real bottleneck was the NIC ring and the application's single-threaded polling, not the queue length.
**Symptom:** drops persist after the raise, and `softnet_stat` is the honest counter.
```bash
# the honest counters
cat /proc/net/softnet_stat | awk '{print $1, $2, $3}' | head
# dropped time_squeeze cpu_collision
netstat -s | grep -i drop
```
**Rollback:**
```bash
sysctl -w net.core.netdev_max_backlog=1000
echo 'net.core.netdev_max_backlog = 1000' >> /etc/sysctl.d/99-network.conf
sysctl --system
```
The real fix was application-side: `RPS`/`XPS` spread across the 16 cores, and `netdev_budget` was left alone. I now read `/proc/net/softnet_stat` before and after **any** backlog change; if `dropped` does not fall, the change is cosmetic and I revert.
## 9. Incident: rwnd Clamping Capping Throughput
A large file transfer on a 300 ms RTT path capped at ~90 Mbps no matter what I did. `ss -tin` told me: `cwnd` was fine, but the window was clamped by the receiver's `rwnd`, which never grew past a fixed value. The receiver's `tcp_rmem` max was still the stock 6 MB, so even though the sender could send, the receiver advertised a tiny window and the transfer was window-limited.
```bash
# on the receiver — the clamp
sysctl net.ipv4.tcp_rmem
# -> net.ipv4.tcp_rmem = 4096 131072 6291456
ss -tin | grep -E 'cwnd|rtt|bytes_acked'
# cwnd:64 rwnd:4096 <- rwnd stuck at one MSS-ish
```
**Fix (receiver side):**
```bash
sysctl -w net.ipv4.tcp_rmem='4096 131072 16777216'
sysctl -w net.core.rmem_max=16777216
echo 'net.ipv4.tcp_rmem = 4096 131072 16777216' >> /etc/sysctl.d/99-network.conf
sysctl --system
```
Throughput recovered to ~380 Mbps. The rule: on high-BDP paths, the **receiver** must be willing to advertise a big window, not just the sender to send. The product of `rwnd` and 8 divided by RTT is the ceiling; 16 MB / 0.3 s ≈ 425 Mbps was the new cap, and I nearly hit it.
## 10. FAQ
**Q: Is it safe to enable BBR on any server?**
A: Only on clean, low-loss paths. BBR inflates queues on lossy or shallow-buffer links. Measure first, keep a rollback.
**Q: How do I make a sysctl change survive a reboot?**
A: Put it in `/etc/sysctl.d/99-network.conf` and run `sysctl --system`. Plain `sysctl -w` is lost on reboot.
**Q: Why did my throughput drop after raising tcp_rmem?**
A: Raising `tcp_rmem` too far makes the kernel over-allocate and can OOM. Revert to defaults and raise it modestly.
**Q: What is the first command I run when a box is slow?**
A: `ss -tin` for retransmits and cwnd, plus `netstat -s` for drops. Those two point at congestion control or a backlog drop.
**Q: Does tcp_syncookies protect against SYN floods?**
A: Yes, but only when the SYN queue fills. Forcing `1` always can misbehave under load; `2` is the modern default.
**Q: When should I NOT use BBR even though the link is clean?**
A: When the application is latency-sensitive and the box is behind a single shallow buffer, or when you cannot install `fq`. Without `fq`, BBR degrades to bursty behaviour and can lose to CUBIC.
## Related Articles
- [TCP Troubleshooting: TIME_WAIT / RST / SYN Half-Open / Retransmission](tcp-troubleshoot.md)
- [iptables Port Forwarding in Production — SNAT/DNAT Chain Architecture, Failure Modes, and nftables Migration](iptables-port-forwarding.md)
- [TCP/IP Internals: Layered Model, Handshake, Teardown, RTO, CUBIC vs BBR](tcp-ip.md)
---
## Micro-segmentation: Cutting the Flat Network Before the Attacker Does
URL: https://ops.jiunotes.top/en/network/micro-segmentation.md
摘要: A working engineer's micro-segmentation playbook: why lateral movement wins in flat networks, the three design principles (business domain, default-deny, minimal policy), a copy-paste Kubernetes NetworkPolicy set, NSX-T and physical ACL variants, and two real breach case studies I worked.
### Answer
Micro-segmentation is default-deny at the workload level. Instead of one big flat network, you split it into small business domains, each with its own policy, and every cross-domain hop needs explicit authorization. The key to stopping lateral movement is a simple chain: database only accepts backend, backend only accepts frontend, frontend only accepts the load balancer. Kubernetes uses NetworkPolicy, virtualized stacks use NSX-T, physical networks use ACLs.
### FAQ
Q: What is the difference between micro-segmentation and a VLAN?
A: A VLAN is layer-2 isolation; micro-segmentation is policy isolation. A VLAN groups machines by geography or department and relies on layer-3 routing between segments. Micro-segmentation groups by application, label, or identity, and the enforcement point is a policy engine. In short: VLAN is physical separation, micro-segmentation is logical separation.
Q: Does micro-segmentation hurt performance?
A: There is a small cost because every packet goes through policy evaluation, but modern implementations (eBPF, hardware offload) make it nearly imperceptible. The real risk is policy sprawl — ten thousand rules is worse than one hundred well-designed ones. Stay lean: default-deny, then open only what you can justify.
Q: How does micro-segmentation relate to zero trust?
A: Zero trust is the philosophy (never trust, always verify); micro-segmentation is the implementation mechanism. You cannot truly land zero trust without segmenting workloads into business domains with least-privilege access. Micro-segmentation is what makes 'verify every request' concrete at the network layer.
Q: What is the single most common failure when rolling out micro-segmentation?
A: Accidentally cutting a legitimate dependency. You write default-deny, and the first thing that breaks is a cross-team API call you did not document. The fix is not to widen the policy — it is to discover the dependency first. I run a two-week discovery window with full-flow logging before I flip any default-deny.
Q: Do I need micro-segmentation if I already have a firewall between zones?
A: A traditional firewall only sees east-west traffic at the zone boundary. Once an attacker lands on one host inside a zone, everything else in that zone is reachable. Micro-segmentation pushes the enforcement point to the workload, so a compromised pod or VM cannot hop to its neighbor. Zone firewalls and micro-segmentation are complementary, not either-or.
### Full Content
# Micro-segmentation: Cutting the Flat Network Before the Attacker Does
The first time I watched a breach replay in a post-incident review, I was embarrassed for the network team. An attacker landed on one frontend box, scanned the internal network, and reached a database server in under four minutes. Not because the firewall was weak — because there was nothing between the frontend and the database. It was one giant flat L3 domain, and every host could talk to every host. That is the problem micro-segmentation solves, and this article is the playbook I wish I had that day.
## Why Lateral Movement Is the Whole Game
Perimeter security works until it does not. Once an attacker breaches the edge, the internal network is a buffet unless you segmented it. The standard kill chain looks like this:
```
attacker compromises frontend web server
↓
scans internal network for reachable services
↓
finds database server
↓
pivots to database, exfiltrates data
```
I have seen this exact path four times. Three of the four succeeded because the internal network was flat. The single worst case was an e-commerce shop in 2023: the attacker came in through a compromised marketing-system server, which sat in the same flat segment as the database tier, and pulled five million user records. They were inside for three months. No one noticed because nothing on the network was watching east-west traffic.
## The Three Design Principles That Matter
Micro-segmentation fails for two reasons: bad policy design and operational friction. These three principles prevent both.
### Principle 1: Segment by business domain, not by department
The instinct is to carve the network by org chart. That is wrong. A finance department and a research department have wildly different network access needs, but an order system and a payment system have a stable, predictable relationship.
```
WRONG: finance-net, research-net, ops-net
RIGHT: order-system, user-system, payment-system, log-system
```
A business domain has a consistent set of peers. The order system only talks to the user system and the payment system. That consistency is what makes policies writable and reviewable. Departmental segments are a moving target because headcount changes constantly and nobody agrees on what "finance needs" means.
### Principle 2: Default-deny, allow as needed
Every segment starts closed. You write the explicit allows on top. The trap is the reverse: write a permissive baseline and then try to tighten it later. Tightening is where you break production, so it never happens.
```yaml
# WRONG: permissive baseline
allow all from any
# RIGHT: closed baseline
default_deny all
allow from frontend to backend on port 8443
allow from backend to database on port 5432
```
### Principle 3: Keep policies minimal and specific
A rule that matches too broadly is a rule that does not protect anything.
```yaml
# WRONG: too loose
allow from any to database
# RIGHT: exact match
allow from pod(label=backend) to pod(label=database) on port 5432
```
## [Kubernetes](https://kubernetes.io/docs/) NetworkPolicy: The Copy-Paste Set
Kubernetes gives you micro-segmentation out of the box. Here is the complete five-policy set I deploy to every production namespace. The chain is the point: load balancer → frontend → backend → database, with nothing else permitted in.
```yaml
# 1. Default-deny all ingress for the whole namespace
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-all
namespace: production
spec:
podSelector: {}
policyTypes:
- Ingress
---
# 2. Allow the monitoring namespace to scrape everything on 9090
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-monitoring
namespace: production
spec:
podSelector: {}
ingress:
- from:
- namespaceSelector:
matchLabels:
name: monitoring
ports:
- port: 9090
policyTypes:
- Ingress
---
# 3. Frontend only accepts traffic from the load balancer
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: frontend-policy
namespace: production
spec:
podSelector:
matchLabels:
app: frontend
ingress:
- from:
- podSelector:
matchLabels:
app: loadbalancer
ports:
- port: 8080
policyTypes:
- Ingress
---
# 4. Backend only accepts traffic from frontend
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: backend-policy
namespace: production
spec:
podSelector:
matchLabels:
app: backend
ingress:
- from:
- podSelector:
matchLabels:
app: frontend
ports:
- port: 8443
policyTypes:
- Ingress
---
# 5. Database only accepts traffic from backend
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: database-policy
namespace: production
spec:
podSelector:
matchLabels:
app: database
ingress:
- from:
- podSelector:
matchLabels:
app: backend
ports:
- port: 5432
policyTypes:
- Ingress
```
### Verifying the policies actually work
Configuring is not the same as enforcing. I always verify with a throwaway pod before I trust the policy:
```bash
# List policies and pod labels
kubectl get networkpolicy -n production
kubectl get pods -n production --show-labels
# This should FAIL: database only accepts from backend
kubectl run test -it --rm --image=busybox --namespace=production -- sh
wget -qO- --timeout=2 http://backend:8443/health
# expect: connection timed out
# This should SUCCEED: frontend is allowed to reach backend
kubectl run test-frontend -it --rm --image=busybox --namespace=production --labels app=frontend -- sh
wget -qO- --timeout=2 http://backend:8443/health
# expect: 200 OK
```
## Virtualized Stacks: NSX-T and Alibaba TIGER
Bare-metal Kubernetes is one world; virtualized VMs are another. When I segment a [vSphere](https://docs.vmware.com/en/VMware-vSphere/) estate I use NSX-T security groups and rules. The pattern is identical to NetworkPolicy — build groups, write a default drop, then allow the business chain.
```powershell
# VMware NSX-T PowerShell CLI
# 1. Build logical security groups
New-NsxSecurityGroup -Name "frontend-servers" -Description "Frontend tier"
Add-NsxSecurityGroupMember -SecurityGroup "frontend-servers" -Member @("vm-frontend-01","vm-frontend-02")
New-NsxSecurityGroup -Name "backend-servers" -Description "Backend tier"
Add-NsxSecurityGroupMember -SecurityGroup "backend-servers" -Member @("vm-backend-01","vm-backend-02")
New-NsxSecurityGroup -Name "database-servers" -Description "Database tier"
Add-NsxSecurityGroupMember -SecurityGroup "database-servers" -Member @("vm-db-01","vm-db-02")
# 2. Default drop, then the business chain
New-NsxPolicySecurityRule -DisplayName "deny-all-default" -Direction Ingress -Action DROP
New-NsxPolicySecurityRule -DisplayName "allow-monitoring" -Direction Ingress -Action ALLOW -SourceGroups @("monitoring-servers") -DestinationGroups @("*") -Protocol TCP -DestinationPorts @("9090")
New-NsxPolicySecurityRule -DisplayName "frontend-to-backend" -Direction Ingress -Action ALLOW -SourceGroups @("frontend-servers") -DestinationGroups @("backend-servers") -Protocol TCP -DestinationPorts @("8443")
New-NsxPolicySecurityRule -DisplayName "backend-to-database" -Direction Ingress -Action ALLOW -SourceGroups @("backend-servers") -DestinationGroups @("database-servers") -Protocol TCP -DestinationPorts @("5432")
# 3. Verify
Get-NsxPolicySecurityRule | Format-Table DisplayName, Action, SourceGroups, DestinationGroups
```
On Alibaba Cloud the equivalent is TIGER security groups, which follow the same model with CLI semantics I have found useful in Chinese regions:
```bash
# Alibaba Cloud TIGER micro-segmentation CLI
aliyun tiger CreateSecurityGroup --RegionId cn-hangzhou --SecurityGroupName frontend-sg --Description "Frontend tier"
aliyun tiger CreateSecurityGroup --RegionId cn-hangzhou --SecurityGroupName backend-sg --Description "Backend tier"
aliyun tiger CreateSecurityGroup --RegionId cn-hangzhou --SecurityGroupName database-sg --Description "Database tier"
aliyun tiger JoinSecurityGroup --RegionId cn-hangzhou --SecurityGroupId sg-frontend --InstanceId i-frontend-01
aliyun tiger JoinSecurityGroup --RegionId cn-hangzhou --SecurityGroupId sg-backend --InstanceId i-backend-01
aliyun tiger JoinSecurityGroup --RegionId cn-hangzhou --SecurityGroupId sg-database --InstanceId i-db-01
aliyun tiger CreateSecurityRule --RegionId cn-hangzhou --SecurityGroupId sg-frontend --Direction ingress --Policy deny --SourceType Any --DestType Any
aliyun tiger CreateSecurityRule --RegionId cn-hangzhou --SecurityGroupId sg-backend --Direction ingress --Policy allow --SourceType SecurityGroupId --SourceValue sg-frontend --DestType Port --DestValue 8443/tcp
aliyun tiger CreateSecurityRule --RegionId cn-hangzhou --SecurityGroupId sg-database --Direction ingress --Policy allow --SourceType SecurityGroupId --SourceValue sg-backend --DestType Port --DestValue 5432/tcp
```
## Physical Networks: ACLs as Coarse Segmentation
When there is no overlay to lean on, you fall back to ACLs on the switch or router. This is coarse — it is per-VLAN, not per-workload — but it is dramatically better than nothing.
```cisco
! Cisco IOS extended ACL, default-deny with an explicit allow
ip access-list extended FRONTEND-TO-BACKEND
deny ip any any
permit tcp any any eq 8443
ip access-list extended BACKEND-TO-DATABASE
deny ip any any
permit tcp any any eq 5432
interface Vlan100
ip access-group FRONTEND-TO-BACKEND in
interface Vlan200
ip access-group BACKEND-TO-DATABASE in
! Verify
show ip access-lists FRONTEND-TO-BACKEND
show ip access-lists BACKEND-TO-DATABASE
```
The VyOS equivalent uses policy routing for the same idea. I include it because so many of my edge routers run VyOS, and the syntax trips people up:
```bash
# VyOS policy-route ACL
set policy route FRONTEND-TO-BACKEND rule 10 description 'Allow frontend to backend on 8443'
set policy route FRONTEND-TO-BACKEND rule 10 match source address '10.0.1.0/24'
set policy route FRONTEND-TO-BACKEND rule 10 match destination address '10.0.2.0/24'
set policy route FRONTEND-TO-BACKEND rule 10 match protocol tcp
set policy route FRONTEND-TO-BACKEND rule 10 match tcp flags syn
set policy route FRONTEND-TO-BACKEND rule 10 match tcp port 8443
set policy route FRONTEND-TO-BACKEND rule 10 action accept
set policy route FRONTEND-TO-BACKEND rule 999 description 'Deny all else'
set policy route FRONTEND-TO-BACKEND rule 999 action drop
set interfaces ethernet eth0 policy input route 'FRONTEND-TO-BACKEND'
set interfaces ethernet eth1 policy input route 'BACKEND-TO-DATABASE'
```
## Two Breach Cases Where Segmentation Was the Difference
### Case 1: SQL injection with a database in the blast radius
A customer's frontend took a SQL injection. In the old flat network the attacker would have gone frontend → database directly. After I deployed the five-policy chain, the flow changed:
1. Attacker owns the frontend pod. ✅
2. Attacker tries frontend → database on 5432. ❌ blocked by `database-policy`.
3. Attacker tries frontend → backend on 8443. ❌ blocked by `backend-policy` (frontend is not in its source list).
4. The attempted connections hit audit logs.
The attacker got a frontend pod and nothing else. The audit trail gave the incident team a first-hop signal within minutes instead of weeks.
### Case 2: Ransomware that could not propagate
A phishing email landed on an employee laptop. The ransomware scanned the internal network looking for file servers. In the segmented design the employee segment only accepts traffic from the VPN gateway, and the resource segment rejects the employee segment entirely. The infection stayed on one laptop. The blast radius was a single machine instead of the whole office.
## The Operational Friction Nobody Warns You About
The policies are easy. The hard part is living with them.
### Policy explosion
As the business grows, the rule count grows superlinearly. The fix is selector-based policy, not one rule per workload. Label the tier once and match the label:
```yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: tier-policy
namespace: production
spec:
podSelector:
matchLabels:
tier: database
ingress:
- from:
- podSelector:
matchLabels:
tier: backend
ports:
- port: 5432
policyTypes:
- Ingress
```
### Frequent business changes
Dependencies change faster than documentation. My answer is GitOps: the policy YAML lives in Git, a PR triggers review, CI applies it to a test namespace first, and only after validation does it reach production. This turns every policy change into a reviewable artifact.
### Debugging a broken policy
When a policy cuts a real dependency, the symptom is a mysterious timeout. My first move is always a dry-run audit, then a live test from the affected pod:
```bash
# Pre-validate policies
kubectl plugin verify network-policy --dry-run
# See recent policy changes
kubectl audit network-policy --since 24h
# Test from the exact pod that is failing
kubectl exec -n production test-pod -- wget -qO- --timeout=2 http://backend:8443/health
```
## Summary
Micro-segmentation is default-deny at the workload boundary, segmented by business domain and not by department. Kubernetes gets NetworkPolicy, virtualized stacks get NSX-T or TIGER, physical networks get ACLs. The chain that stops lateral movement is always the same: database accepts only backend, backend accepts only frontend, frontend accepts only the load balancer. My honest judgment: it is worth the investment, but only if you keep policies minimal and automate the change flow. Policy sprawl and operational friction are the real enemies — labels and GitOps are how you beat them.
> **Original note**: This article is an independent English original written from first-hand production experience. It is not a translation of any Chinese source.
## Related Articles
- [TCP Troubleshooting: TIME_WAIT, RST, and Half-Open States](tcp-troubleshoot.md)
- [iptables Port Forwarding: SNAT/DNAT in Four Stanzas](iptables-port-forwarding.md)
- [Wireshark Basics: 30 Filters for Ops](wireshark-basics.md)
---
## Multi-Cloud Networking: Choosing the Right On-Ramp and Designing the Backbone
URL: https://ops.jiunotes.top/en/network/multicloud-network.md
摘要: A working engineer's multi-cloud networking guide: Direct Connect vs ExpressRoute vs CEN, when VPN beats a leased line and vice versa, three concrete cross-cloud cost optimizations, a BGP-plus-health-check failover design, and a real e-commerce multi-cloud case.
### Answer
Multi-cloud interconnection comes in three flavors: public internet (cheapest, highest latency), VPN tunnel (secure, medium cost), and dedicated line / Direct Connect (lowest latency, highest cost). Selection is scenario-driven: dev/test uses the public internet, non-critical production uses VPN, critical production uses a leased line. Cross-cloud egress cost is the hidden killer, and you tame it with routing, caching, and compression. Failover depends on BGP plus health checks — never a single link.
### FAQ
Q: What is the real difference between a dedicated line and a VPN?
A: A dedicated line is a physically isolated path (AWS Direct Connect, Azure ExpressRoute) with low latency, high bandwidth, and strong security — at high cost. A VPN is an encrypted tunnel over the public internet: cheap and fast to deploy, but latency is higher and bandwidth is capped. Critical production traffic gets the leased line; everything else gets the VPN.
Q: How do I cut cross-cloud traffic costs?
A: Three levers: (1) route cross-cloud traffic over a leased line instead of the public internet — typically 30-50% cheaper per GB; (2) cache hot data locally so you avoid the round trip; (3) compress and dedupe the payload. In one case I cut a 50 TB/month flow to 20 TB, saving 60% on egress.
Q: Is BGP mandatory in a multi-cloud setup?
A: For production, yes. BGP gives you dynamic route selection and automatic failover, which static routes cannot. VPN scenarios can get away with static routes, but a leased-line backbone really should run BGP. The config complexity is moderate and the reliability payoff is worth it.
Q: Can I run production on a single link?
A: No. I have seen a single Direct Connect cut take an entire region down. You need at least two paths — a primary and a backup. My standard is one leased line plus one VPN, both running BGP with a health check on the primary. The VPN may be slower, but a slow working link beats no link.
Q: What is the biggest mistake teams make with multi-cloud networking?
A: Treating cross-cloud traffic as free. Egress is billed per GB, and a chatty service-to-service call across clouds can silently eat your budget. I always measure the cross-cloud flow before designing the backbone. If it is under a few hundred MB per day, skip the leased line entirely.
### Full Content
# Multi-Cloud Networking: Choosing the Right On-Ramp and Designing the Backbone
Multi-cloud is easy to say and hard to wire together. I have built three multi-cloud backbones for production workloads, and every single time the network design — not the application — was the bottleneck. This article is the selection logic and cost discipline I now apply before I touch a single router.
## The Three Ways to Interconnect Clouds
There are exactly three ways to connect cloud A to cloud B, and they form a clean cost-versus-performance ladder.
### Option 1: The public internet (free, worst performance)
```
cloud A ──── internet ────> cloud B
(encrypted) (uncontrolled) (encrypted)
```
- **Cost**: zero extra — you already pay for egress
- **Latency**: 50-200ms, highly variable
- **Bandwidth**: capped by congestion
- **Security**: relies entirely on IPSec/TLS
- **Availability**: you have no control over carrier routing
I use this only for dev/test environments, non-critical replication, and backup transfers where latency is tolerable. The moment it carries production traffic, I stop trusting it.
```bash
# AWS Site-to-Site VPN over the public internet
aws ec2 create-vpn-connection \
--transit-gateway-id tgw-xxxxxx \
--type ipsec.1 \
--static-routes-only \
--bgp-asn 65000 \
--amazon-side-asn 64512
```
### Option 2: VPN tunnel (medium cost, medium performance)
```
cloud A ──IPSec──> cloud B
```
- **Cost**: VPN gateway hourly fees
- **Latency**: 30-100ms
- **Bandwidth**: capped by the gateway's crypto throughput
- **Security**: high — IPSec encryption end to end
- **Availability**: depends on the public path, but the payload is protected
This is my default for non-critical production services and for rapid deployment where a leased line has a multi-week lead time.
### Option 3: Dedicated line / Direct Connect (highest cost, best performance)
```
cloud A ══leased line══> cloud B
```
- **Cost**: monthly circuit fee, plus port hour fees on each side
- **Latency**: 10-50ms, stable
- **Bandwidth**: 1Gbps up to 100Gbps
- **Security**: physical isolation
- **Availability**: SLA-backed
This is the only option I put critical production traffic on. It also covers the compliance requirement of "data never crosses the public internet."
## The Three Cloud On-Ramps, Side by Side
AWS, Azure, and Alibaba each expose a dedicated-line service with slightly different names and semantics. I have configured all three, and the differences matter more than the marketing suggests.
| Service | Provider | Abstraction | Typical latency | BGP support | Best for |
|---|---|---|---|---|---|
| AWS Direct Connect | AWS | Virtual interface per VPC | 5-20ms | Native | AWS-centric estates |
| Azure ExpressRoute | Azure | Circuit with private peering | 5-20ms | Native | Microsoft-heavy workloads |
| Alibaba Cloud CEN | Alibaba | Cloud Enterprise Network, inter-VPC | 5-20ms | Native | China-region estates |
The key operational difference: Direct Connect and ExpressRoute are per-cloud constructs, so a true multi-cloud backbone needs one of each plus a transit hub to stitch them together. CEN is a network-of-networks abstraction that can connect multiple VPCs and even other clouds through a gateway, which makes it the most convenient when Alibaba is your hub. My rule of thumb: if the backbone is two clouds, use a Transit Gateway hub with a Direct Connect spine and a CEN or ExpressRoute leg; if it is three or more, put CEN at the center and hang the others off it.
### VPN vs dedicated line: the honest trade-off
A VPN is not a cheap dedicated line — it is a different animal. I keep this table on my whiteboard:
| Dimension | VPN tunnel | Dedicated line |
|---|---|---|
| Setup time | Hours to days | Weeks to months |
| Latency | 30-100ms, variable | 10-50ms, stable |
| Bandwidth | Gateway crypto-bound | 1-100Gbps |
| Cost model | Hourly gateway | Monthly circuit |
| SLA | Best effort | Contract-backed |
| Failure mode | Reconnect after outage | Physical cut needs carrier |
The practical consequence: I use a VPN for the backup path and a dedicated line for the primary. The VPN is cheap enough to keep warm, and even a degraded backup is better than a single point of failure.
## The Selection Decision Tree
I do not pick a technology in the abstract — I answer four questions in order:
```
What is the workload?
├─ dev/test, backup replication ────── public internet (free)
├─ production, non-critical
│ ├─ need <50ms? ── yes ── dedicated line
│ ├─ need >1Gbps? ── yes ── dedicated line
│ └─ otherwise ────────────── VPN tunnel
└─ production, critical
├─ latency-sensitive (<50ms)? ── dedicated line
├─ bandwidth-hungry (>1Gbps)? ── dedicated line
└─ compliance (no public path)? ── dedicated line
```
The one rule I refuse to break: **critical production always gets at least one dedicated line, and never a single link.** If I cannot justify the cost, the workload is not actually critical.
## Cross-Cloud Traffic Cost: The Silent Budget Killer
Egress is the line item nobody budgets for until it shows up on the invoice. Three optimizations, in order of impact.
### Optimization 1: Route cross-cloud traffic over the leased line
This is the biggest lever, because the per-GB rate on a leased line is meaningfully lower than public egress. A real comparison from one e-commerce tenant, at ~50 TB/month:
| Path | Rate | Monthly cost | Annual cost |
|---|---|---|---|
| Public internet | $0.09/GB | $4,500 | $54,000 |
| Leased line | $0.05/GB | $2,500 | $30,000 |
| **Saving** | — | **$2,000** | **$24,000** |
### Optimization 2: Cache hot data locally
The cheapest request is the one you never make. I put a local cache in front of every remote-region read:
```python
import redis
class CrossCloudCache:
def __init__(self, local_region, remote_regions):
self.local_region = local_region
self.remote_regions = remote_regions
self.cache = redis.[Redis](https://redis.io/docs/)(host='localhost', port=6379, db=0)
def get(self, key, remote_client=None):
# local hit first — avoids the cross-cloud round trip
cached = self.cache.get(key)
if cached:
return cached.decode()
# local miss — fetch from remote, then cache with a 5-minute TTL
if remote_client:
value = remote_client.get(key)
if value:
self.cache.setex(key, 300, value)
return value.decode()
return None
```
### Optimization 3: Compress and dedupe
For large bulk transfers, compression and deduplication pay for themselves. AWS Transfer Family and Alibaba DTS both expose this:
```bash
# AWS Transfer Family with compression
aws transfer create-server \
--protocol SFTP \
--endpoint-type VPC \
--subnet-ids subnet-xxxx subnet-yyyy \
--security-group-ids sg-zzzz \
--role-arn arn:aws:iam::123456789012:role/transfer-role \
--compression-enabled
# Alibaba DTS with dedupe + compression
aliyun dts ModifyDtsJob \
--JobId dts-job-xxxx \
--DataRedundancy ENABLED \
--CompressEnabled ENABLED
```
## Failover Design: BGP Plus Health Check
Static routes die silently. BGP does not. My backbone always runs BGP with a tight keepalive timer so a cut is detected in seconds, not minutes:
```bash
router bgp 64512
neighbor 169.254.33.1 remote-as 64512
neighbor 169.254.33.1 update-source Eth1
neighbor 169.254.33.1 timers bgp 10 30
neighbor 169.254.33.1 distance 20 200
network 10.0.0.0 255.255.0.0
!
track 1 ip route 10.0.0.0 255.255.0.0 reachability
interface Eth1
ip address 169.254.33.2 255.255.255.252
ip track 1
```
On the application side, the failover logic is a health-check loop that flips DNS and routing when the primary degrades:
```python
import asyncio
import aiohttp
class MultiCloudFailover:
def __init__(self, primary, secondary, interval=30):
self.primary = primary
self.secondary = secondary
self.interval = interval
self.is_primary_healthy = True
async def health_check_loop(self):
while True:
await self.check_health()
await asyncio.sleep(self.interval)
async def check_health(self):
try:
async with aiohttp.ClientSession() as s:
async with s.get(f'{self.primary}/health', timeout=aiohttp.ClientTimeout(total=5)) as r:
if r.status == 200:
if not self.is_primary_healthy:
self.is_primary_healthy = True
print("primary recovered")
elif self.is_primary_healthy:
self.is_primary_healthy = False
await self.switch_to_secondary()
except Exception:
if self.is_primary_healthy:
self.is_primary_healthy = False
await self.switch_to_secondary()
async def switch_to_secondary(self):
await self.update_dns('app.example.com', self.secondary.ip)
await self.update_routing(self.secondary)
await self.notify_ops(f"switched to {self.secondary.name}")
```
## Real Case: A Three-Region E-Commerce Backbone
A customer ran production in AWS us-east-1, Southeast Asia in ap-southeast-1, and mainland China in Alibaba cn-hangzhou. They moved ~80 TB/month between regions. Public-internet latency was ~200ms; the leased-line path was ~50ms. The backbone used a Transit Gateway hub with a 1Gbps Direct Connect as the spine, and VPN tunnels as the legs. The results after six months:
| Metric | Before | After | Change |
|---|---|---|---|
| Cross-cloud cost | $7,200/mo | $3,600/mo | 50% ↓ |
| China-user latency | 200ms | 50ms | 75% ↓ |
| SE-Asia latency | 180ms | 45ms | 75% ↓ |
| Failover time | 10 min (manual) | 30s (auto) | 95% ↓ |
The failover test that convinced me: I killed the health-check endpoint on the primary, watched DNS flip, and the application was serving from the secondary within 30 seconds. That is the difference between BGP plus health checks and a human with a phone tree.
## Configuration Checklist
Before I sign off on any backbone, I run this against both ends:
```bash
# 1. Link health
curl -f http://primary-cloud/health || echo "primary unhealthy"
curl -f http://secondary-cloud/health || echo "secondary unhealthy"
# 2. Bandwidth utilization
aws cloudwatch get-metric-statistics \
--namespace AWS/DirectConnect \
--metric-name BandwidthUtilization \
--dimensions Name=connectionId,Value dxcon-xxxxxx \
--start-time $(date -Iseconds -d '1 hour ago') \
--end-time $(date -Iseconds) \
--period 300 --statistics Average
# 3. Latency and path
ping -c 10
traceroute -n
# 4. BGP neighbor state
show ip bgp summary
show bgp neighbors
# 5. Cross-cloud cost
aws ce get-cost-and-usage \
--time-period Start=$(date -Iseconds -d '7 days ago'),End=$(date -Iseconds) \
--granularity MONTHLY --metrics UnblendedCost
```
## Summary
Multi-cloud interconnection is a selection problem, not a technology problem. Dev/test uses the public internet, non-critical production uses a VPN, and critical production gets a dedicated line — never a single one. Cross-cloud egress cost is the silent killer, and the fix is routing over the leased line, caching hot data locally, and compressing bulk flows. Failover belongs to BGP plus automated health checks. My honest judgment: budget for at least two paths per critical region, and measure your cross-cloud flow before you buy anything. The backbone is a cost center until the day it saves your business — and on that day you will be glad you did not cheap out.
> **Original note**: This article is an independent English original written from first-hand production experience. It is not a translation of any Chinese source.
## Related Articles
- [Nginx Reverse Proxy 502/504 Troubleshooting](nginx-proxy.md)
- [CDN Principles: Origin Fetch, Preheat, Cache Invalidation](cdn-principles.md)
- [TCP Troubleshooting: TIME_WAIT, RST, and Half-Open States](tcp-troubleshoot.md)
---
## Nginx 504 Gateway Timeout: Six Root-Cause Branches in a Decision Tree
URL: https://ops.jiunotes.top/en/network/nginx-504-troubleshoot.md
摘要: A working engineer's 504 troubleshooting guide: what 504 really means, the five protocol-level differences from 502, a six-branch root-cause decision tree (timeout config, backend CPU, connection pool, SSL handshake, DNS latency, slow query), a fix template, and a 30-command quick-scan list.
### Answer
A 504 is a read timeout, not a connection failure — the upstream never returned a single byte before proxy_read_timeout expired. The six root causes follow the request path in order: DNS resolution, TCP connect plus SSL handshake, connection-pool acquisition, backend CPU saturation, slow query or slow I/O, and finally a misconfigured proxy_read_timeout. Each maps to a distinct error.log keyword and a distinct access-log upstream_response_time signature.
### FAQ
Q: What is the actual difference between 504 and 502?
A: 502 means nginx established the connection and received bytes, but the bytes were invalid (bad status line, bad header, mid-stream RST). 504 means nginx sent the request and then received nothing before proxy_read_timeout. In the access log, 504 shows an upstream_response_time that equals proxy_read_timeout exactly (e.g. 60.000); 502 shows a small random value like 0.034.
Q: How large should proxy_read_timeout be?
A: Do not guess. Measure the real P99 response time from the access log (excluding 504 rows, which are timeout artifacts), then set proxy_read_timeout to 2-3x P99. Ten seconds is a common default, but a P99 of 8s wants ~20s, not 300s. Bumping 60s to 300s just turns 504s into slow responses — the user waits the same, the dashboard just looks cleaner.
Q: What does 'upstream timed out' in the error log actually mean?
A: It is a read timeout, not a connect timeout. nginx already connected, already sent the request, and is waiting for the response header. Two causes dominate: the backend is genuinely slow (90%), or the network path drops enough packets that TCP acknowledgements never arrive (10%, common on cross-region links). tcpdump on both ends tells you which.
Q: Do I need to restart nginx after changing proxy_read_timeout?
A: No. `nginx -s reload` (or `systemctl reload nginx`) is graceful: the master spawns new workers with the new config, old workers finish in-flight requests and exit, zero interruption. Just run `nginx -t` first — if the config has a syntax error, reload fails and the old workers keep serving.
Q: My backend is Node.js and 504s keep coming — what should I do?
A: Node's single-threaded event loop blocks on CPU-heavy work (large JSON serialization, regex backtracking) and long GC pauses. That blocks the keep-alive heartbeat and nginx sees a timeout. Check event-loop lag with clinic.js, cap the heap with --max-old-space-size, and run PM2 in cluster mode (-i max) to use all cores.
### Full Content
# Nginx 504 Gateway Timeout: Six Root-Cause Branches in a Decision Tree
504 Gateway Timeout is defined in [RFC](https://www.rfc-editor.org/) 7231 §6.6.5, but the definition misses the most useful fact: **[nginx](https://nginx.org/en/docs/) is rarely the problem.** It is a messenger. It sent your request upstream, waited for a response, and the upstream never delivered a single byte before `proxy_read_timeout` expired. That single fact — connection up, request sent, zero bytes back — is the whole diagnosis. This article walks the six root causes in the order they occur on the request path.
## What a 504 Actually Is
nginx worker writes the request into the upstream socket and enters a read loop. Every event-loop cycle it checks whether it has exceeded `proxy_read_timeout`. When it has, it closes the upstream connection, returns 504 to the client, and logs:
```
upstream timed out (110: Connection timed out) while reading response header from upstream
```
The "timed out" phrase trips everyone up. It is a **read** timeout, not a **connect** timeout. A connect timeout is a 502 with `connect() failed`. The two are different error codes in nginx source: `NGX_HTTP_BAD_GATEWAY` for 502, `NGX_HTTP_GATEWAY_TIME_OUT` for 504.
## 504 vs 502: The Five Protocol-Level Differences
| Dimension | 502 Bad Gateway | 504 Gateway Timeout |
|---|---|---|
| **Timeline** | Bytes arrived but were invalid (bad status line, bad header, mid-stream RST) | Request sent, no bytes in the read window |
| **Connection** | RST/FIN received, or a TCP-layer error | Still alive, or nginx closed it after timeout |
| **error.log keyword** | `upstream prematurely closed connection`, `upstream sent invalid header` | `upstream timed out (110: Connection timed out) while reading response header` |
| **access.log** | `upstream_response_time` small (<1s), `$status` 502 | `upstream_response_time` equals `proxy_read_timeout` exactly, `$status` 504 |
| **Typical cause** | Backend crashed, wrong protocol, bad SSL, dead keepalive | Backend slow, CPU saturated, pool exhausted, slow DNS, SSL handshake stuck |
There is a hidden difference in retry behavior that matters for your API timeout budget. `proxy_next_upstream error timeout` retries the request on the next upstream, but a 504 retry has to wait a full `proxy_read_timeout` before switching. With `proxy_next_upstream_tries 2`, a user's perceived wait is `2 × proxy_read_timeout`. If the client front-end times out at 30s but nginx has a 20s read timeout with tries=2, the client gives up at 30s and you see a 499 — not the 504 you would actually want to diagnose. **The sum of all nginx timeouts must be less than the client timeout.**
## The Six Root Causes, In Request Order
These are not parallel branches — they happen in sequence along the path, so you debug in this order:
```
nginx worker receives request
▼
[Root cause 5] DNS resolution slow resolver stuck, upstream DNS slow
▼
TCP connect + [Root cause 4] SSL handshake TLS slow, cert chain, SNI
▼
Acquire connection from upstream pool
[Root cause 3] Pool exhausted, no free connection
▼
Send request header
▼
[Root cause 2] Backend CPU saturated / event loop blocked
[Root cause 6] Backend slow query / slow I/O DB, ES, disk
▼
Wait for upstream_response
[Root cause 1] proxy_read_timeout too short
▼
Return 504 to client
```
In my experience the rough distribution is: root cause 1 (misconfigured timeout) ~40%, root cause 2 (backend CPU) ~25%, root cause 6 (slow query) ~20%, root cause 3 (connection pool) ~8%, root cause 4 (SSL handshake) ~5%, root cause 5 (DNS) ~2%. Your environment differs, but the priority order holds.
## Root Cause 1: proxy_read_timeout Misconfigured
This is the most common and the most misdiagnosed. Seeing 504, people crank `proxy_read_timeout` to 300s, the 504s "disappear," and they declare victory. You did not fix anything — you turned a 504 into a P99 of 99 seconds. The user waits the same; only the error-rate dashboard looks better.
```nginx
# WRONG 1: only bumped read_timeout, forgot send_timeout
location /api/ {
proxy_pass http://backend;
proxy_read_timeout 300s;
# proxy_send_timeout still 60s — large body requests still get cut
}
# WRONG 2: treated timeout as a connect timeout
location / {
proxy_pass http://backend;
proxy_read_timeout 5s; # backend legitimately needs 8s — permanent 504
}
# RIGHT: set from the real P99
server {
listen 443 ssl;
server_name api.example.com;
proxy_connect_timeout 5s;
proxy_read_timeout 20s; # P99 x 2.5, not 300s
proxy_send_timeout 10s;
proxy_next_upstream error timeout http_502 http_504;
proxy_next_upstream_tries 2;
location / { proxy_pass http://backend; }
}
```
The correct diagnostic sequence:
```bash
# 1. Find every effective timeout (nginx -T expands includes)
nginx -T 2>/dev/null | grep -E 'proxy_(read|send|connect|next_upstream)_timeout'
# 2. Look at the real response-time distribution
awk '$9==504 {print $NF}' /var/log/nginx/access.log | sort -n | uniq -c | tail
# all exactly 60.000 => pinned by read_timeout
# 3. Derive a sane timeout from P99 (excluding 504 rows)
tail -100000 /var/log/nginx/access.log | awk '$9!=504 {print $NF}' \
| sort -n | awk 'BEGIN{c=0} {a[c++]=$1} END{print a[int(c*0.99)]}'
```
The anti-pattern that taught me this: a customer bumped timeout 60s→300s, 504s vanished, and the report said "fixed." A week later P99 had climbed from 3s to 45s and users were complaining the page spun forever. The real cause was a missing index behind a slow SQL. **Enlarging a timeout is a painkiller, not a cure.** Watch the P99 curve after any timeout change.
## Root Cause 2: Backend CPU Saturated
The process is alive, the port accepts, TCP handshake succeeds — but requests go in and nothing comes out. That is the classic CPU-saturated signature. I saw it on a Java service: `jstack` showed everything `RUNNABLE` in JSON serialization, `top` showed one core at 100% and the rest at 30%.
```bash
# 1. Per-thread CPU
top -p -H
pidstat -u -p 1 5
# 2. System-wide CPU per core
mpstat -P ALL 1 3
# %usr high => app computing; %sys high => syscalls (I/O or net); %wa high => root cause 6
# 3. Hotspot profiling
perf top -p
perf record -g -p -- sleep 30 && perf report --stdio | head -50
# 4. Node.js event-loop lag
clinic doctor -- node app.js
# 5. Java thread dump
jstack | grep -A 5 "RUNNABLE" | head -50
```
Fixes by backend: Node.js → PM2 cluster `-i 0`, push CPU-heavy work to worker_threads, cap with `server.requestTimeout`. Java → check GC with `jstat -gc`, check thread pools, consider Jackson streaming over Gson. Go → do not leave `GOMAXPROCS=1` (containers with a CPU limit default to 1), profile with pprof. PHP-FPM → check `pm.max_children` and `max_execution_time`.
## Root Cause 3: Upstream Connection Pool Exhausted
Overlaps with the keepalive article, but in the 504 context it presents differently: a depleted pool makes nginx wait too long for a free connection and it trips `proxy_read_timeout` — a 504, not a 502.
```bash
# 1. ESTAB count vs worker_processes x keepalive
ss -tanp | grep :8080 | awk '{print $1}' | sort | uniq -c
# ESTAB >> workers x keepalive => pool full
# 2. TIME_WAIT pileup (short-connection symptom)
ss -tan | grep TIME-WAIT | awk '{print $4}' | cut -d: -f2 | sort | uniq -c | sort -rn | head
# 3. Listen backlog
ss -ltn | grep :8080
# Recv-Q near Send-Q => backlog nearly full, new connections dropped
# 4. File descriptor usage
ls /proc//fd | wc -l
cat /proc//limits | grep 'open files'
```
## Root Cause 4: Upstream SSL Handshake Timeout
When the upstream is HTTPS, TLS handshake counts against `proxy_read_timeout`. TLS 1.2 is 2-RTT, TLS 1.3 is 1-RTT; across regions a single handshake can eat 300ms, and with OCSP stapling plus certificate-chain verification, 2-3s is not unusual.
```bash
# 1. Measure handshake time
openssl s_client -connect 10.1.2.3:8443 -servername api.example.com -brief \
-cipher 'TLSv1.3' < /dev/null 2>&1 | grep -i "handshake\|verified"
# 2. TLS version timing difference
for v in tls1 tls1_1 tls1_2 tls1_3; do
echo -n "$v: "; time openssl s_client -connect 10.1.2.3:8443 -$v < /dev/null > /dev/null 2>&1
done
# 3. Cert chain length (longer chain => slower verification)
openssl s_client -connect 10.1.2.3:8443 -showcerts 2>/dev/null | grep -c "BEGIN CERTIFICATE"
```
The nginx-side fix is to enable SNI and session reuse:
```nginx
location /api/ {
proxy_pass https://backend;
proxy_ssl_server_name on;
proxy_ssl_name api.example.com;
proxy_ssl_protocols TLSv1.2 TLSv1.3;
proxy_ssl_session_reuse on;
}
```
## Root Cause 5: Slow DNS Resolution
This one is rare but the fastest to diagnose. The symptom: 504s only on **new** upstream connections (cold start, pool flush), lasting 3-10 seconds. If `proxy_pass http://backend.example.com` uses a hostname, nginx resolves it via system DNS. A slow or unreachable resolver poisons every new connection.
```bash
# 1. Measure system DNS latency
time nslookup backend.example.com
time getent hosts backend.example.com
# 2. Does nginx have its own resolver?
nginx -T 2>/dev/null | grep -E '^resolver'
# 3. resolv.conf options
cat /etc/resolv.conf
# options timeout:2 attempts:5 => up to 10s per resolution
# 4. [systemd](https://www.freedesktop.org/software/systemd/man/latest/)-resolved diagnostics
resolvectl query backend.example.com
journalctl -u systemd-resolved | tail -30
```
The fix is to give nginx its own resolver and force dynamic resolution with a variable:
```nginx
http {
resolver 127.0.0.53:53 valid=10s ipv6=off;
resolver_timeout 2s;
server {
location / {
set $backend backend.example.com;
proxy_pass http://$backend; # variable form => dynamic resolve
}
}
}
```
The variable form is the key. A static `proxy_pass http://backend.example.com` resolves once at startup and never again. The `set $backend ...; proxy_pass http://$backend;` form re-resolves each time using nginx's own resolver.
## Root Cause 6: Slow Query or Slow I/O
The sibling of root cause 2: CPU is low but the response is slow because it is stuck on I/O — a slow database query, a slow ES aggregation, a slow disk fsync, NFS jitter.
```bash
# 1. I/O pressure
iostat -x 1 5
# %util near 100, await > 50ms => disk saturated; aqu-sz > 1 => queuing
# 2. Who is waiting on I/O
pidstat -d 1 5
iotop -a
# 3. MySQL slow queries
mysql -e "SHOW FULL PROCESSLIST;" | awk '$6 > 10 {print}'
pt-query-digest /var/log/mysql/slow.log
# 4. [PostgreSQL](https://www.postgresql.org/docs/)
psql -c "SELECT now()-xact_start AS duration, query
FROM pg_stat_activity WHERE state='active' ORDER BY duration DESC;"
# 5. ES slow tasks
curl -s 'localhost:9200/_cat/tasks?v&h=action,running_time,state' | awk '$3 > "01:00:00"'
```
## The 30-Command Quick Scan
A single ordered list you can paste when a 504 alarm fires:
```bash
# ===== 1-5: scope the problem =====
1) awk '$9==504' /var/log/nginx/access.log | tail -20
2) awk '$9==504 {print $NF}' /var/log/nginx/access.log | sort -n | uniq -c | tail
3) tail -50 /var/log/nginx/error.log | grep -i "504\|timed out"
4) grep " 504 " /var/log/nginx/access.log | awk '{print $4}' | sort | uniq -c | sort -rn | head
# ===== 6-10: root cause 1, timeout config =====
5) nginx -T 2>/dev/null | grep -E 'proxy_(read|send|connect|next_upstream)_timeout'
6) tail -100000 /var/log/nginx/access.log | awk '$9!=504 {print $NF}' | sort -n | awk '{a[NR]=$1} END{print "P50:",a[int(NR*0.5)],"P99:",a[int(NR*0.99)]}'
7) nginx -t 2>&1 | tail -5
8) grep -E "upstream timed out" /var/log/nginx/error.log | wc -l
# ===== 11-15: root cause 2, CPU =====
9) top -bn1 | head -20
10) mpstat -P ALL 1 3
11) pidstat -u 1 3 | tail -20
12) perf top -p $(pgrep -f 'your_app' | head -1)
13) ps -eo pid,ppid,user,%cpu,%mem,cmd --sort=-%cpu | head -10
# ===== 16-20: root cause 3, connection pool =====
14) ss -tanp | grep ESTAB | awk '{print $4}' | rev | cut -d: -f2- | rev | sort | uniq -c | sort -rn | head
15) ss -tan | grep TIME-WAIT | wc -l
16) ss -ltn | grep -E ':(80|443|8080|8443)\b'
17) cat /proc/$(pgrep -f "nginx: worker" | head -1)/limits | grep "open files"
18) ls /proc/$(pgrep -f "nginx: worker" | head -1)/fd | wc -l
# ===== 21-24: root cause 4, SSL handshake =====
19) openssl s_client -connect 10.1.2.3:8443 -servername api.example.com -brief < /dev/null 2>&1 | grep -iE "handshake|verified"
20) time openssl s_client -connect 10.1.2.3:8443 -tls1_3 < /dev/null > /dev/null 2>&1
21) grep -i "ssl_do_handshake\|SSL_do_handshake" /var/log/nginx/error.log | tail -5
# ===== 25-27: root cause 5, DNS =====
22) time getent hosts backend.example.com
23) resolvectl query backend.example.com
24) nginx -T 2>/dev/null | grep -E '^(\s*)resolver'
# ===== 28-30: root cause 6, I/O & slow query =====
25) iostat -x 1 3
26) iotop -bn1 2>/dev/null | head -15
27) mysql -uroot -e "SHOW FULL PROCESSLIST;" 2>/dev/null | awk '$6 > 10 {print $1,$6,$10}' | head
```
## Summary
A 504 is a read timeout, and it lives on the request path, not in nginx. Debug in order: DNS, TCP plus SSL handshake, connection pool, backend CPU, slow query, then timeout config. The access log's `upstream_response_time` is your best discriminator — if it equals `proxy_read_timeout` to the millisecond, you are pinned by a timeout, not a dead backend. And remember: bumping the timeout is a painkiller. Measure the P99, set the timeout to 2-3x that, and keep watching the curve. That is the difference between making the dashboard look better and actually making the service better.
> **Original note**: This article is an independent English original written from first-hand production experience. It is not a translation of any Chinese source.
## Related Articles
- [Nginx Upstream Keepalive: Deep Tuning and Five Real 502 Cases](nginx-keepalive-502.md)
- [Nginx Reverse Proxy 502/504 Troubleshooting](nginx-proxy.md)
- [Nginx Upstream Config Quick Reference](nginx-upstream-config.md)
---
## Nginx Upstream Keepalive: The Default of 8, the Backend Differences, and Five Real 502 Cases
URL: https://ops.jiunotes.top/en/network/nginx-keepalive-502.md
摘要: A working engineer's guide to nginx upstream keepalive: the three parameters (keepalive, keepalive_timeout, keepalive_requests), the trap of the default value 8, how Node/Go/Java/PHP backends differ, the three-command hit-rate check, five real 502 cases, and a complete production config template.
### Answer
nginx upstream keepalive defaults to 8, which is almost always too small. Three parameters govern the backend connection pool: keepalive is the pool size per worker, keepalive_timeout is how long an idle connection survives, and keepalive_requests is the reuse cap. They must be coordinated, and they must all be smaller than the backend's own keepalive settings. Getting any one wrong produces 502s or a connection explosion. The three-command hit-rate check tells you whether the pool is actually being used.
### FAQ
Q: What is the default value of nginx upstream keepalive, and why is it so small?
A: keepalive defaults to 8, keepalive_timeout to 60 seconds, keepalive_requests to 1000. It is small because upstream keepalive was added in nginx 0.8.17 and the author was conservative about backend resource usage. High-concurrency production (>500 QPS) generally wants 32 or 64, tuned alongside the backend max_connections and ulimit -n.
Q: What is the difference between keepalive_timeout and the keepalive inside the upstream block?
A: keepalive_timeout is how long a backend TCP connection sits idle before nginx closes it — it controls connection lifetime. upstream keepalive N is the maximum number of idle connections nginx will cache per worker — it controls pool capacity. They are two orthogonal dimensions and both need tuning.
Q: Why does keepalive still open a new connection every time after I configured it?
A: The location block is missing one of the three-piece set: proxy_http_version 1.1 and proxy_set_header Connection ''. Without proxy_http_version 1.1, nginx uses HTTP/1.0 to the backend, which does not support keepalive. Without clearing the Connection header, the client's Connection: close propagates to the backend and forces it closed. The pool never gets hit.
Q: Is bigger always better for upstream keepalive?
A: No. A huge pool means nginx holds many idle TCP connections, which can exceed the backend's max_connections or exhaust file descriptors. My heuristic is keepalive = backend max_connections / worker_processes / 2. Beyond 128 it is diminishing returns and can cause accept queuing from fd pressure.
Q: How do I confirm keepalive is actually being hit?
A: Three commands. (1) watch the ESTAB count — stable means reuse, growing means new connections. (2) curl -w '%{num_connects}' repeatedly — should stay at 1 if the pool is reused. (3) tcpdump for FIN frequency — a FIN per request means no reuse.
### Full Content
# Nginx Upstream Keepalive: The Default of 8, the Backend Differences, and Five Real 502 Cases
The default `keepalive 8` in an [nginx](https://nginx.org/en/docs/) upstream block looks innocent. It is not. I spent a frustrating week chasing a cascade of 502s on an e-commerce customer, and the root cause was that tiny number interacting with a php-fpm restart. This article is what I learned: the three parameters, how they interact with different backends, and the five real cases that made me stop trusting defaults.
## Where the 502 Started: One PHP-FPM Restart
The customer's config looked perfect. `nginx -T` showed `keepalive 32`, `proxy_http_version 1.1`, and `proxy_set_header Connection ""` — the full three-piece set. But five minutes earlier, a php-fpm node had been OOM-restarted. At that moment, four ESTAB connections in nginx's upstream pool still pointed at the dead node. The kernel had not sent RST yet, so nginx had no idea the backend was gone. A user request pulled a dead connection from the pool, sent a request into it, and got a 502:
```
connect() failed (104: Connection reset by peer) while connecting to upstream
```
I stared at `ss -tanp | grep :9000 | grep ESTAB` for two minutes before it clicked: those four connections were dead but looked alive. **This is the trap of keepalive tuning — nginx health checks only verify that a connection can be established, not that the next request will succeed.** Dead connections sit in the pool, and whoever draws one loses. This is why the three parameters have to be understood together.
## The Three Parameters
First, find every keepalive setting you have so you know which one you are changing:
```bash
nginx -T | grep -i keepalive
grep -n "keepalive" /etc/nginx/conf.d/*.conf /etc/nginx/nginx.conf
```
`keepalive N` in the upstream block is the number of idle connections **per worker** that nginx caches. It is not global — with `worker_processes 4` and `keepalive 32`, the real pool is up to 128 idle connections.
`keepalive_timeout` in http/server/location is how long an idle connection survives before nginx closes it — for both client and backend connections. Default 60s in the upstream scenario.
`keepalive_requests N` is the reuse cap per connection. After N reuses, the connection is closed and rebuilt. Default 1000.
| Parameter | Location | Default | Unit | Meaning |
|---|---|---|---|---|
| `keepalive` | upstream | 8 | connections | per-worker idle pool size |
| `keepalive_timeout` | http/server/location | 60 | seconds | idle lifetime before close |
| `keepalive_requests` | http/server/location | 1000 | requests | reuse cap per connection |
## The Backend Differences You Must Respect
Backends are not passive — they close connections on their own. If nginx's `keepalive_timeout` is longer than the backend's, the pool fills with connections the backend already killed.
| Backend | Default keepalive timeout | Key setting | Production value |
|---|---|---|---|
| Node.js (Express/Koa) | 5s | `server.keepAliveTimeout` | ≥ 65s |
| Go net/http | 90s | `ReadTimeout` / `IdleTimeout` | ≥ 100s |
| Java Tomcat | 20s | `keepAliveTimeout` | ≥ 60s |
| PHP-FPM | 20s | `request_terminate_timeout` | ≥ 60s |
**The iron rule: nginx's keepalive_timeout must be strictly shorter than the backend's, with a 5-15s buffer.** That way nginx closes the idle connection first, and the backend never closes a connection nginx still thinks is alive. Node.js is the most dangerous case — its 5s default vs nginx's 60s is a mismatch waiting to bite.
## Why the Default of 8 Is a Trap
Do the math. Suppose 1000 QPS, 50ms average response, 4 workers.
- Each worker handles 250 req/s, so steady-state concurrency ≈ 250 × 0.05 = **12.5 active connections**
- With a peak buffer, call it 24 active
- A pool of 8 means 24 - 8 = **16 connections per second are new**
At 1000 QPS that is 16 fresh TCP connections per second — three-way handshakes, accept-queue churn, fd allocation and release. On the CPU profile you see `tcp_connect`, `accept`, and `socket_create` dominating.
The measurements I took on a single API endpoint:
| keepalive | QPS | P99 | nginx worker CPU |
|---|---|---|---|
| 8 | 8,500 | 32ms | 42% |
| 32 | 15,200 | 18ms | 28% |
| 64 | 17,800 | 16ms | 31% |
| 128 | 17,400 | 17ms | 35% |
8 → 32 nearly doubled QPS. 32 → 64 still gains but diminishing. 64 → 128 actually **drops** — past half of `worker_connections`, nginx's own fd pool gets tight. So: start at 32, move to 64 under pressure, and never blindly go to 256.
## The Three-Command Hit-Rate Check
Configuration is not enforcement. Verify the pool is actually being used:
```bash
# 1. ESTAB count: stable = reuse, growing = new connections
watch -n1 'ss -tanp | grep :8080 | grep ESTAB | wc -l'
# 2. curl num_connects: should stay at 1 across repeated calls
for i in 1 2 3 4 5; do
curl -s -o /dev/null -w "connects=%{num_connects}\n" http://127.0.0.1/health
done
# 3. tcpdump FIN frequency: a FIN per request = no reuse
tcpdump -i lo -A 'tcp port 8080 and (tcp[tcpflags] & tcp-fin != 0)'
```
If `num_connects` changes every time and FINs are dense, the pool is not being hit. Check in order: `proxy_http_version 1.1` → `proxy_set_header Connection ""` → `keepalive N` in the upstream → nginx version (must be 1.1.5+ for upstream keepalive).
## Five Real 502 Cases
### Case 1: Pool × workers exceeds the backend limit
PHP-FPM with `pm.max_children=5`, nginx with `worker_processes=4` and `keepalive=32`. That is a potential 4 × 32 = 128 connections against a backend that can only serve 5. Symptom: `pm.max_children reached` alarm, nginx logs `upstream timed out`.
**Fix**: `keepalive = pm.max_children × 2 = 10`, or raise `pm.max_children`.
### Case 2: Node.js closes at 5s, nginx uses it for 60s
Node.js default `keepAliveTimeout=5s`, nginx default `keepalive_timeout=60s`. From the 6th second, the pool holds connections the backend already closed. Symptom: fine at low concurrency, 5-15% 502 rate under bursts.
**Fix**: Node.js `server.keepAliveTimeout = 65000`, or nginx `keepalive_timeout 5` (aggressive but effective).
### Case 3: Dead connections after a rolling restart
In [Kubernetes](https://kubernetes.io/docs/), an old pod stays ESTAB until it gets SIGTERM. Connections in nginx's pool pointing at it die instantly. Symptom: a 502 spike for ~30s around every deploy.
**Fix**: `max_fails=1 fail_timeout=5s` so nginx marks the upstream failed fast, or `zone backend 64k` so the shared-memory zone sees instance changes.
### Case 4: keepalive_requests=1000 too low for short endpoints
A 5ms endpoint under `keepalive_requests=1000` rebuilds each connection every 5 seconds. nginx creates hundreds of new connections per second, and DNS resolution piles up if the upstream is a hostname. Symptom: DNS query volume spikes, worker CPU stuck in `accept`.
**Fix**: `keepalive_requests 10000`, or use an IP upstream to avoid DNS.
### Case 5: HTTP/2 front-end proxying an HTTP/1.1 backend
The front-end listens on `listen 443 ssl http2` but the backend is plain HTTP/1.1. Keepalive does not automatically apply — HTTP/2 and HTTP/1.1 use entirely different connection models, and nginx builds a fresh pool on the backend side. If the location is missing `proxy_http_version 1.1`, nginx talks HTTP/1.0 to the backend and opens a new connection every time.
**Fix**: explicitly add the three-piece set in the location, and verify `nginx -V` includes `--with-http_v2_module`.
## The Complete Production Template
This is the config I hand to teams now, commented so nothing gets dropped:
```nginx
worker_processes auto;
worker_rlimit_nofile 65535;
events {
worker_connections 4096;
multi_accept on;
use epoll;
}
http {
keepalive_timeout 65;
keepalive_requests 10000;
upstream backend {
zone backend_pool 64k;
server 10.0.0.11:8080 max_fails=2 fail_timeout=30s max_conns=256;
server 10.0.0.12:8080 max_fails=2 fail_timeout=30s max_conns=256;
keepalive 32;
keepalive_timeout 60; # shorter than backend keepalive
keepalive_requests 10000;
}
server {
listen 80;
location / {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_connect_timeout 5s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
proxy_next_upstream error timeout http_502 http_503;
}
}
}
```
## The Pitfall Checklist
- Missing one of the three-piece set → pool never hit, every request is a new connection
- `proxy_http_version` forgotten → HTTP/1.0 to backend, keepalive dead
- `Connection ""` written as `"keep-alive"` → backend force-closes, pool wasted
- pool × workers > backend max_connections → backend refuses connections
- nginx keepalive_timeout > backend keepAliveTimeout → dead connections in the pool
- hostname upstream without a `resolver` → cannot resolve after DNS cache expiry
- `worker_rlimit_nofile` too small → `too many open files`
- `keepalive_requests=1000` on short endpoints → constant connection rebuild
- no drain before a rolling restart → 502 spike
- config changed but not reloaded → `nginx -s reload` never took effect
The two commands that get you to the answer fastest:
```bash
ss -tanp | grep :8080 | grep ESTAB | wc -l
curl -s -o /dev/null -w "connects=%{num_connects}\n" http://127.0.0.1/health
```
## Summary
Upstream keepalive looks like three numbers but is really the thread that ties nginx, the backend, and the kernel connection model together. Three rules: **the three-piece set is non-negotiable**, **nginx timeouts must be strictly shorter than the backend's**, and **pool size is derived from the backend limit, never guessed**. If the pool is being hit, performance follows. If it is not, stop tuning and go check the config — the bottleneck is almost never the number you are staring at.
> **Original note**: This article is an independent English original written from first-hand production experience. It is not a translation of any Chinese source.
## Related Articles
- [Nginx 504 Gateway Timeout: Six Root-Cause Branches](nginx-504-troubleshoot.md)
- [Nginx Upstream Config Quick Reference](nginx-upstream-config.md)
- [Nginx Reverse Proxy 502/504 Troubleshooting](nginx-proxy.md)
---
## nginx + keepalived VIP Failover: Parameter Matrix and 6 Production Incidents
URL: https://ops.jiunotes.top/en/network/nginx-keepalived.md
摘要: Full keepalived parameter matrix with production-safe values, 6 real failure modes with root causes, 15-command cheat sheet, and the auth_pass 8-character trap that bites everyone.
### Answer
Full keepalived parameter matrix with production-safe values, 6 real production incidents with root causes and fixes, 15-command cheat sheet, auth_pass 8-character trap.
### FAQ
Q: Why isn't my VIP floating after the primary goes down?
A: Three things to check in order: VRRP advertisements being sent (tcpdump -i eth0 vrrp), auth_pass matching byte-for-byte on both nodes (keepalived only reads 8 chars), and firewall rules allowing UDP 112 from the peer. Half the time it's the firewall.
Q: What's the correct value for virtual_routerpriority?
A: Lower number = higher priority. Primary: 100, Secondary: 90 is the classic setup. Don't use the same number on both — you'll get split-brain or flapping. If you need active-active, use a third node or a different protocol (LVS, BGP).
Q: Should I use nopreempt or preempt?
A: For most web backends: nopreempt. Once the secondary takes over, keep it until it dies. preempt causes VIP flapping whenever the primary recovers, which means DNS/cache churn and connection resets. Only use preempt if the primary has a specific role (stateful service, primary DB writer).
Q: Why is auth_pass in my config 32 chars but keepalived only uses 8?
A: Historical bug/feature — VRRP spec defines auth_pass as 8-char ASCII. keepalived silently truncates to 8 chars. If you set 32 chars, both nodes need to have the SAME first 8 chars. Common mistake: generate a 32-char random password on node A, paste on node B, and think it's secure. It's 8 chars.
Q: How do I health-check the upstream so keepalived actually fails over?
A: Use track_script or track_interface. For nginx: `track_script { script: check_nginx; interval: 5; fall: 3; rise: 2 }` and a bash script that curls localhost and returns non-zero on failure. Never trust a single check — require `fall: 3` before declaring failure and `rise: 2` before recovering.
### Full Content
# nginx + keepalived VIP Failover: Parameter Matrix and 6 Production Incidents
## Why keepalived, not BGP
If you have IPMI access, dynamic routing upstream, and a router willing to speak eBGP to a stateless VM, BGP is nicer. For two VMs behind a cloud VPC with a stateless load balancer in front, keepalived is the pragmatic answer: a small daemon speaking UDP 112 to the peer, moving the VIP in about three seconds. That's the whole pitch, and it's enough for most of us.
I've run keepalived in front of [nginx](https://nginx.org/en/docs/) at three companies and one non-profit. It works. It also fails in ways that eat on-call time if you don't know the failure modes ahead of time.
## The happy path isn't the story
Every tutorial shows you the config, tells you to `systemctl start keepalived`, and shows the VIP floating when you kill the primary. In production you will not have that experience. You will have a firewall rule from a security audit three years ago, an auth_pass someone copy-pasted wrong, a NIC that got renamed after a kernel update, and a health check that only verifies the process is *up* rather than whether the service is *serving*. This article is about those cases.
## Parameter matrix
Values for Ubuntu 22.04, keepalived 2.1.5, nginx 1.24.x.
| Parameter | Safe value | What breaks if tuned wrong |
|---|---|---|
| `virtual_router_id` | `51` (identical both sides) | Different IDs = different VRRP groups = no failover, no errors, just silence |
| `virtual_routerpriority` | Primary `100`, Secondary `90` | Same on both = split-brain; higher on secondary = VIP on wrong box |
| `virtual_ipaddress` | `192.0.2.100/24` (identical) | Not matching = VIP moves but unreachable; wrong prefix = routes nowhere |
| `advert_int` | `1` (identical) | 3x gap between peers = silent desync, occasional flapping |
| `garp_master_delay` | `5` | Too low = STP churn on the switch; too high = slow first packet after failover |
| `garp_master_repeat` | `2` | Reduces post-failover burst; clients with stale ARP keep hitting the dead primary |
| `garp_master_refresh` | `60` | Refreshes gratuitous ARPs; `0` disables and stale ARP wins |
| `garp_master_ttl` | Omit | Non-zero sets a lifetime; usually leave to the switch |
| `gna_interval` | `1` | Interval for Gratuitous ARP; too aggressive annoys neighbors, too slow leaves caches stale |
| `garp_validate_interface` | Omit | Validates the GARP NIC matches `interface`; only for weird NIC topologies |
| `nopreempt` | Yes, both sides | Without it, primary recovery yanks the VIP back — connection resets |
| `smtp_connect_timeout` | `3` | Long = delayed alerts, short = false alerts on slow SMTP |
| `script` (in `vrrp_script`) | Bash curl to localhost | Any static check (`pgrep nginx`) misses process-alive-but-not-serving |
| `auth_pass` | 8-char ASCII, identical both sides | Only first 8 chars are used; longer strings are noise (see trap below) |
| `interface` | e.g. `eth0` | Wrong NIC = VIP moves to a NIC the client can't route to |
| `interface_broadcast` | Omit | Forces broadcast on a specific NIC; only needed on multi-homed setups |
| `virtual_router_track_interface` | e.g. `eth0` | If the interface dies, VRRP drops the VIP immediately rather than waiting |
| `notify_script` | Path to state-change hook | Fires on MASTER/BACKUP transitions; useful for logging and alerting |
## Case 1: VIP doesn't float when the primary dies
Symptom: kill nginx on the primary, wait 30 seconds, curl the VIP — still hitting the dead box.
Checklist, in order:
```bash
# 1. Are VRRP packets actually being sent?
sudo tcpdump -i eth0 -nn -c 5 vrrp
# 2. Do auth_pass values match byte-for-byte (first 8 chars)?
grep auth_pass /etc/keepalived/keepalived.conf | cut -c1-8
# 3. Is the firewall passing protocol 112?
sudo iptables -L INPUT -n | grep -Ei 'vrrp|112'
```
Nine out of ten: a firewall. VRRP is IP protocol 112, not a TCP/UDP port. Standard rules like `-p tcp --dport 80 -j ACCEPT` don't touch it, and a hardened host usually defaults to `DROP` which eats VRRP silently.
```bash
sudo iptables -I INPUT -p vrrp -j ACCEPT
sudo iptables -I OUTPUT -p vrrp -j ACCEPT
```
Also check the cloud security group — many managed clouds let you filter on protocol number, and someone almost always forgets to add 112.
## Case 2: Split-brain — both nodes hold the VIP
Symptom: after a primary reboot, both hosts show the VIP in `ip addr`. A client's ARP cache points at whichever box it hit first, so requests randomly work and randomly hang.
Detect:
```bash
arp -an | grep 192.0.2.100 # on a client
ip addr show eth0 | grep 192.0.2.100 # on both nodes
```
Root cause: priorities are equal (both think they're primary) or `auth_pass` mismatches (each side ignores the other and defaults to MASTER). Fix both:
```conf
vrrp_instance VI_1 {
interface eth0
virtual_router_id 51
virtual_routerpriority 100 # 90 on the secondary
advert_int 1
nopreempt
authentication {
auth_type PASS
auth_pass S3cret12
}
virtual_ipaddress {
192.0.2.100/24
}
}
```
Reload with `systemctl reload keepalived` — a reload, not a restart, is enough to clear the bad state on the losing node.
## Case 3: Flapping every few minutes
Symptom: access logs show the VIP switching backends every 3–5 minutes, connections reset mid-flight.
I hit this the day I first deployed keepalived. The check was `pgrep nginx`. Under load, `pgrep` briefly returned a stale PID that was momentarily unresponsive; the check failed, VIP moved. Ten seconds later nginx recovered, check passed, VIP moved back. Every time.
Fix: `nopreempt` on both sides plus a slower, more deliberate check.
```conf
vrrp_script chk_nginx {
script "/etc/keepalived/check_nginx.sh"
interval 5
fall 3
rise 2
weight -20
}
```
`fall 3` at `interval 5` means 15 seconds of consecutive failure before demotion. That's a real outage, not a hiccup.
## Case 4: Wrong NIC on multi-homed hosts
Symptom: VIP moves to the secondary, `ip addr` shows it correctly, but `curl 192.0.2.100` times out.
The `interface` field is where VRRP sends advertisements. On a host with two NICs — `eth0` for business, `eth1` for management — you want VRRP on the business NIC. Keepalived has three related knobs: `interface` (primary NIC for VRRP and VIP binding), `interface_broadcast` (forces broadcast on a specific NIC, useful on point-to-point links), and `bind_ifid` (assigns the virtual router ID to a specific interface so multiple VRs don't collide).
The mistake I made: `interface eth0` on the primary, `interface eth1` on the secondary. On failover, keepalived bound the VIP to `eth1` — the mgmt network — and client traffic went nowhere.
Rule of thumb: same NIC name, same subnet, on both nodes. Add `garp_master_delay 5` and `garp_master_repeat 2` so the switch's MAC table updates without taking the port down.
## Case 5: Health check reports up when the backend is down
This one costs the most money. Nginx is running, listening on 80, but the upstream app is dead and every request 502s. Keepalived, checking only nginx, says all clear.
Don't use `systemctl is-active --quiet nginx`. That's liveness, not readiness. Use a script that speaks HTTP to what the client would speak HTTP to:
```bash
#!/bin/bash
# /etc/keepalived/check_nginx.sh
set -e
curl -sf --max-time 3 http://127.0.0.1/healthz > /dev/null
curl -sf --max-time 3 http://127.0.0.1/upstream-probe > /dev/null
```
The `/upstream-probe` location returns 200 only when its upstream responds. Without it, nginx will happily 502 while still being "up". If you can't add an endpoint, at minimum curl the actual client URL. Set `fall 3, rise 2` so a single blip doesn't trip the failover.
## Case 6: Upgrade breaks config parsing
Upgrading keepalived from 2.0.x to 2.1.x is where I've lost the most time to parser errors. Two gotchas.
**`!` comments in `vrrp_script`.** Keepalived 2.0 used `!` as a line comment inside script blocks. 2.1 removed it in some code paths. If your config has:
```
vrrp_script chk {
script "/etc/keepalived/check.sh" ! this comment is now an error
interval 2
}
```
You'll see `Error found in configuration file` on reload, and [systemd](https://www.freedesktop.org/software/systemd/man/latest/) won't start keepalived. Convert all `!` comments to `#`.
**`vrrp_version` default flipped.** Keepalived 1.4 defaulted to VRRPv2, 2.x to VRRPv3. Mixed-version peers don't interop. Pin it explicitly on both sides:
```conf
global_defs {
vrrp_version 3
}
```
Always test with `keepalived -t -f /etc/keepalived/keepalived.conf` before `systemctl reload`. If the parser complains, the reload will silently keep the old config live while the new config fails to parse — the worst kind of half-state.
## The auth_pass trap
VRRP's auth_pass is defined as 8 ASCII characters. Keepalived silently truncates anything longer. I've seen people generate a 32-char password with `openssl rand -base64 24`, paste it into both configs, and think they have strong VRRP auth. They have 8 chars.
Worse, if two nodes share the same string but differ in the *first 8 characters* (say `S3cretPass1` vs `S3cretPass2`), the nodes don't reject each other's packets loudly — they just ignore the advertisements and each falls back to thinking it's primary. Split-brain via a typo, half an hour to find.
Test it: `grep auth_pass /etc/keepalived/keepalived.conf | cut -c1-8` on both nodes, then diff.
## Cheat sheet: 15 commands to bookmark
| # | Command | What it tells you |
|---|---|---|
| 1 | `sudo tcpdump -i eth0 -nn vrrp` | Are VRRP advertisements flowing? |
| 2 | `sudo arping -I eth0 192.0.2.100` | Who currently owns the VIP's MAC? |
| 3 | `ip addr show eth0` | Is the VIP bound on this host? |
| 4 | `systemctl status keepalived` | Service state |
| 5 | `journalctl -u keepalived -n 200 --no-pager` | Recent state transitions |
| 6 | `keepalived -V` | Version — v2 vs v3 matters |
| 7 | `keepalived -t -f /etc/keepalived/keepalived.conf` | Config syntax check |
| 8 | `ss -tlnp \| grep :80` | Is nginx listening? |
| 9 | `ss -tnp state established '( sport = :80 )'` | Established client connections on 80 |
| 10 | `cat /etc/keepalived/keepalived.conf` | Actual loaded config, not what you remember writing |
| 11 | `tail -f /var/log/keepalived.log` | Old-style syslog output if you use it |
| 12 | `dig +short 192.0.2.100` | Reverse-resolve check |
| 13 | `ifconfig eth0` | Legacy alias for `ip addr`; some scripts still use it |
| 14 | `ip route get 192.0.2.100` | Which NIC the client kernel will use |
| 15 | `curl -v --max-time 3 http://192.0.2.100/` | End-to-end check through the VIP |
## FAQ
**Why isn't my VIP floating after the primary goes down?** Three things, in this order. Are VRRP advertisements actually being sent? `tcpdump -i eth0 -nn vrrp` on the surviving node — you should see packets from the dead primary's IP every second until it truly dies. Does `auth_pass` match byte-for-byte? Remember the 8-character truncation. Is the firewall allowing protocol 112? On iptables: `iptables -I INPUT -p vrrp -j ACCEPT`. Half the time I've debugged this, it's the firewall. The other half is auth_pass.
**What's the correct value for virtual_routerpriority?** Lower is higher priority. Classic setup: Primary 100, Secondary 90. The 10-point gap is enough for VRRP to decide who wins without tripping on the small `weight` adjustments the track scripts make. Never use the same priority on both nodes — you'll get split-brain or constant re-election. For true active-active, don't fight keepalived: VRRP is fundamentally active-passive at the VIP level. Use LVS or BGP with ECMP.
**Should I use nopreempt or preempt?** For nginx fronting a web app: `nopreempt` on both sides. Once the secondary takes over, let it keep the VIP until it dies. With `preempt`, every time the primary recovers from any blip it yanks the VIP back — connections reset, DNS caches churn. Only use `preempt` when the primary has a stateful role the secondary can't do and failing back is cheaper than manual promotion.
**Why is auth_pass 32 chars in my config but keepalived only uses 8?** VRRP defines auth_pass as an 8-byte string ([RFC](https://www.rfc-editor.org/) 3768). Keepalived reads the value, silently truncates to 8 ASCII bytes, and uses that. It never warns. So pasting a 32-char `openssl rand -base64` string into both configs gives you 8 chars of auth — and both nodes just need to agree on those first 8. Two nodes with different full passwords but the same first 8 chars will happily interop; two nodes with different first 8 chars won't interop at all and each falls back to thinking it's primary — split-brain. Test with `cut -c1-8` before deploying.
**How do I health-check the upstream so keepalived actually fails over?** Two layers. First, use `vrrp_script` with a curl-based check, not `systemctl is-active` — that only tells you the process is registered, not that it's serving. Second, tune `fall` and `rise`. I use `interval 5, fall 3, rise 2` — 15 seconds of consecutive failures before declaring the node down, 10 seconds of consecutive successes before bringing it back. A single blip never trips the failover; a real outage does. Add `weight -20` so a failing check loses the VRRP election to a healthy peer at `priority 100`.
## Related Articles
- [Nginx Reverse Proxy 502/504 Troubleshooting — Full Decision Tree](nginx-proxy.md)
- [nginx Upstream Load Balancing: Algorithm Selection, Server Directives, and the keepalive Trio](nginx-upstream-config.md)
- [CDN Internals: Edge POP, Origin Fetch, Cache Invalidation & the Cost Model](cdn-principles.md)
---
## Nginx Reverse Proxy 502/504 Troubleshooting — Full Decision Tree
URL: https://ops.jiunotes.top/en/network/nginx-proxy.md
摘要: A working engineer's guide to diagnosing 502 Bad Gateway and 504 Gateway Timeout behind Nginx reverse proxy. 5 failure scenarios, a 20-command Cheat Sheet, 5 real-world configs, and a decision tree to pinpoint root cause in minutes.
### Answer
Nginx 502 usually means Nginx can't reach or can't parse a response from the upstream — check (1) whether the upstream is actually listening (`ss -lnt | grep :port`), (2) whether `proxy_pass` points at the right address and protocol (http vs https, IPv6 vs IPv4), and (3) whether the upstream logs show the request ever arrived. 504 is a client-visible timeout: the upstream took longer than `proxy_read_timeout` (default 60s) to respond. Start with `journalctl -u nginx -f` and the upstream service logs side by side, then work through the 5 scenarios in this article.
### FAQ
Q: What's the fastest first step when Nginx returns 502?
A: Check whether the upstream is actually listening on the port Nginx is proxying to: `ss -lnt | grep :8080`. If nothing is listening, start or restart the upstream service. If it is listening, look at upstream logs and Nginx `error.log`.
Q: What's the fastest first step when Nginx returns 504?
A: 504 means the upstream exceeded `proxy_read_timeout` (default 60s). Check `journalctl -u nginx -f` for a "upstream timed out" line — the exact upstream address is printed there. Then either fix the slow upstream or bump the timeout deliberately.
Q: When should I raise proxy_read_timeout?
A: Only when the upstream legitimately needs that long (report generation, ML inference, batch jobs). It's a symptom mask, not a fix. Bump in steps (60 → 120 → 300) and always log the reason in a comment so the next engineer isn't surprised.
Q: How do I distinguish 502 vs 504 from Nginx logs?
A: `error.log` will say "connect() failed" or "upstream prematurely closed connection" for 502, and "upstream timed out (110: Operation timed out)" for 504. The exact string tells you which class of problem you have.
Q: Why does my HTTPS reverse proxy still return 502 after I enabled TLS?
A: Because `proxy_pass` is still talking HTTP to an HTTPS backend. Match the protocol: `proxy_pass https://backend:443;` plus `proxy_ssl_server_name on;` and `proxy_ssl_name` set to the backend's TLS SNI hostname.
### Full Content
# Nginx Reverse Proxy 502 / 504 Troubleshooting
## The 30-Second Diagnosis
When [Nginx](https://nginx.org/en/docs/) shows you `502 Bad Gateway` or `504 Gateway Timeout`, it has already talked to the upstream (or tried to) and given up. The upstream is either **not reachable**, **not speaking the protocol Nginx expects**, or **too slow**. Work in this order:
1. Is the upstream actually listening? → `ss -lnt | grep :PORT`
2. Is the upstream seeing the request? → upstream logs
3. Does `proxy_pass` point at the right thing? → reload Nginx config in `error.log`
4. Is TLS between Nginx and upstream misconfigured? → check `proxy_pass` protocol and `proxy_ssl_*`
5. Is the upstream just slow? → check `proxy_read_timeout` and upstream processing time
If you get past step 1 and still see 502, jump to the [502 Decision Tree](#502-bad-gateway-decision-tree). If the error is 504, the upstream is reachable but slow — [504 Timeout Decision Tree](#504-gateway-timeout-decision-tree) will walk through it.
## 5 Real Failure Scenarios
### 1. Upstream Not Running
The most common 502 cause, and the one that trips up the freshest engineers.
**Symptom**: 502 immediately after deploy, Nginx `error.log` says:
```
connect() failed (111: Connection refused) while connecting to upstream
upstream: "http://127.0.0.1:8080/", host: "127.0.0.1:8080"
```
**Diagnosis**:
```bash
ss -lnt | grep :8080 # Empty means nobody listening
systemctl status myapp # Is the upstream service up?
```
**Fix**: Restart the upstream. Check for boot-time failures (`journalctl -u myapp -b`).
### 2. Wrong Address Behind `proxy_pass`
A typo, a missing port, a wrong protocol — all produce the same 502.
**Common culprits**:
- `proxy_pass http://127.0.0.1;` → defaults to port 80, not 8080. Fix: `proxy_pass http://127.0.0.1:8080;`
- `proxy_pass http://backend:8080;` but `backend` doesn't resolve. Fix: add to `/etc/hosts` or use [Docker](https://docs.docker.com/) DNS (`extra_hosts:`).
- `proxy_pass https://backend;` but backend only speaks HTTP. Fix: change to `http://`.
**Diagnosis**:
```bash
grep -n 'proxy_pass' /etc/nginx/sites-enabled/default
dig backend # Does the hostname resolve?
curl -v http://backend:8080/health # From the Nginx host, does the upstream respond?
```
### 3. TLS Mismatch Between Nginx and Upstream
If you flipped the upstream to HTTPS and forgot to update `proxy_pass`, Nginx will still try HTTP and get a garbled response.
**Symptom**: Nginx log shows `upstream sent invalid header` or `upstream sent no valid HTTP/1.0 header while reading response header from upstream`.
**Diagnosis**:
```bash
# Nginx config shows proxy_pass http://backend:8443 (HTTP to HTTPS port)
# Test from the Nginx host:
curl -vk https://backend:8443/ # Should work
curl -v http://backend:8443/ # Garbled response confirms the mismatch
```
**Fix**:
```nginx
proxy_pass https://backend:8443;
proxy_ssl_server_name on;
proxy_ssl_name backend.example.com; # SNI hostname
proxy_ssl_protocols TLSv1.2 TLSv1.3;
proxy_ssl_verify off; # For self-signed cert; set on + CA if you own the cert
```
### 4. Keepalive Stale Connection
Long-lived upstreams (Java, Go net/http, Rails) tend to close idle connections from the load balancer. Nginx may hand a stale socket to a real request.
**Symptom**: intermittent 502s, only on the first request after a quiet period, upstream logs show "connection closed while reading headers" or similar.
**Diagnosis**:
```bash
# Check upstream keepalive settings in Nginx
grep -n 'keepalive' /etc/nginx/conf.d/upstream.conf
# On the upstream, look for the connection lifetime
ss -tnp | grep ':8080' | awk '{print $4}' # Look for CLOSE_WAIT on many sockets
```
**Fix**:
```nginx
upstream backend {
server 127.0.0.1:8080;
keepalive 32; # Max idle conns Nginx keeps open to upstream
keepalive_timeout 60s;
}
server {
location / {
proxy_pass http://backend;
proxy_http_version 1.1; # Required for keepalive upstreams
proxy_set_header Connection ""; # Empty Connection header for keepalive
}
}
```
### 5. Backend Protocol Mismatch (HTTP/1.1 vs HTTP/2)
If your upstream requires HTTP/2 (gRPC, some modern Java servers), the default HTTP/1.1 from Nginx will cause weird failures.
**Symptom**: `upstream sent invalid header` or gRPC-specific errors like `http2 framing layer`.
**Fix**:
```nginx
location / {
proxy_pass http://backend:50051;
grpc_pass grpc://backend:50051; # For gRPC
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
```
## 20-Command Cheat Sheet
Copy-paste to diagnose 502/504 quickly.
```bash
# 1. What does the user see?
curl -Iv https://your-domain.com/health
# 2. What's Nginx logging right now?
sudo journalctl -u nginx -f
# 3. What's the upstream logging?
sudo journalctl -u myapp -f
# 4. Is the upstream listening?
ss -lnt | grep :8080
# 5. Can Nginx reach the upstream?
sudo -u nginx curl -v http://127.0.0.1:8080/health
# 6. Which config is actually loaded?
sudo nginx -T 2>&1 | grep -A5 'proxy_pass'
# 7. Config syntax OK?
sudo nginx -t
# 8. Reload config
sudo systemctl reload nginx
# 9. What's the connection state between Nginx and upstream?
ss -tnp | grep ':8080'
# 10. Upstream service status
systemctl status myapp
systemctl status myapp.service --full
# 11. Upstream CPU/memory (spikes cause slowness → 504)
top -p $(pgrep -f myapp | head -1)
# 12. Nginx worker count and state
sudo nginx -T | grep worker_connections
ps aux | grep nginx
# 13. Check the exact Nginx error message
grep -E '502|504|timed out|refused' /var/log/nginx/error.log | tail -20
# 14. Check upstream response time
curl -w '@format.txt' -o /dev/null https://your-domain.com/
# format.txt: time_total, time_connect, time_starttransfer
# 15. Check upstream TLS
openssl s_client -connect backend:8443 -servername backend.example.com
# 16. Check upstream via HTTP (if you suspect TLS mismatch)
curl -v http://backend:8443/ # Garbled = mismatch
# 17. Nginx error.log level
grep 'error_log' /etc/nginx/nginx.conf
# 18. Request body size limit
grep 'client_max_body_size' /etc/nginx/nginx.conf # Default 1m — too small for uploads
# 19. Header size limit
grep 'large_client_header_buffers' /etc/nginx/nginx.conf
# 20. Access log with upstream info
grep 'proxy_upstream_name\|proxy_upstream_addr' /etc/nginx/nginx.conf
```
## 5 Real-World Configs
### Reverse Proxy for a Node.js App on 127.0.0.1:3000
```nginx
server {
listen 443 ssl http2;
server_name api.example.com;
ssl_certificate /etc/letsencrypt/live/api.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/api.example.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
access_log /var/log/nginx/api.example.com.access.log vhost;
error_log /var/log/nginx/api.example.com.error.log warn;
client_max_body_size 20m;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Request-Id $request_id;
proxy_set_header Connection "";
proxy_connect_timeout 5s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
}
```
### Reverse Proxy for a [Python](https://docs.python.org/)/Flask Dev Server
```nginx
upstream flask {
server 127.0.0.1:5000;
keepalive 16;
}
server {
listen 443 ssl;
server_name dev.example.com;
location / {
proxy_pass http://flask;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Connection "";
proxy_read_timeout 120s; # Dev servers tend to be slow
}
}
```
### Reverse Proxy with gRPC Passthrough
```nginx
location /api/v1 {
grpc_pass grpc://backend:50051;
proxy_http_version 1.1;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
```
### Rate-Limited Reverse Proxy
```nginx
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
server {
listen 443 ssl;
server_name api.example.com;
location / {
limit_req zone=api burst=20 nodelay;
limit_req_status 429;
proxy_pass http://backend;
proxy_set_header Host $host;
}
}
```
### WebSocket-Enabled Reverse Proxy
```nginx
location /ws {
proxy_pass http://backend/ws;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 3600s; # Keep WebSocket alive for an hour
proxy_send_timeout 3600s;
}
```
## 502 Bad Gateway — Decision Tree
```
502 → Check Nginx error.log
↓
├─ "connect() failed (111: Connection refused)"
│ → Upstream not listening. Fix: restart upstream.
│
├─ "connect() failed (110: Operation timed out)"
│ → Firewall between Nginx and upstream, or network partition.
│ Fix: iptables / security group.
│
├─ "upstream prematurely closed connection"
│ → Keepalive mismatch, or upstream crashed on request.
│ Fix: `proxy_set_header Connection "";` and check upstream logs.
│
├─ "upstream sent invalid header"
│ → Protocol mismatch (HTTP vs HTTPS, HTTP/1 vs gRPC).
│ Fix: align `proxy_pass` protocol with upstream.
│
└─ "no live upstreams"
→ All upstream servers failed. Check upstream health.
```
## 504 Gateway Timeout — Decision Tree
```
504 → Check upstream took how long?
↓
├─ < proxy_read_timeout → 502, not 504 (see above)
│
└─ > proxy_read_timeout (default 60s)
→ Slow upstream.
Fix (a): optimize upstream (usually right answer)
Fix (b): raise `proxy_read_timeout` deliberately
(with a comment explaining why)
Fix (c): move long jobs to async (queue + poll)
```
## 502 vs 504 vs 499 — Quick Reference
| Code | Meaning | Where to fix |
|------|---------|--------------|
| **502 Bad Gateway** | Nginx couldn't reach or talk to upstream | Nginx config, upstream service |
| **504 Gateway Timeout** | Upstream too slow for `proxy_read_timeout` | Upstream performance, or bump timeout |
| **499 Client Closed Request** | User gave up before Nginx got a response | Nginx is fine — user/network issue |
| **503 Service Unavailable** | Upstream returned 503, or Nginx is overloaded | Upstream health, or `worker_processes` |
## Wrapping Up
502 and 504 are the two workhorses of reverse-proxy debugging. Half of them are "upstream not running" and "wrong port in `proxy_pass`" — the cheapest bugs to fix. The harder ones (keepalive, TLS mismatch, gRPC) all boil down to one question: **what does Nginx think it's talking to, and what is the upstream actually talking back?** Read the `error.log` string, match it against this decision tree, and you'll usually know what to change within a minute.
For related reading, see:
- [TCP Troubleshooting: TIME_WAIT / RST / SYN Half-Open](tcp-troubleshoot.md) — the layer below
- [Wireshark Basics: 30 Filters + Field Cheat Sheet](wireshark-basics.md) — when logs aren't enough
- [CDN Origin & Cache: Origin Fetch, Preheat, Invalidation](cdn-principles.md) — 502 sometimes lives on the CDN side, not your Nginx
---
## nginx Upstream Load Balancing: Algorithm Selection, Server Directives, and the keepalive Trio
URL: https://ops.jiunotes.top/en/network/nginx-upstream-config.md
摘要: Upstream block internals for SREs: 6 load balancing algorithms with a decision matrix, per-server parameter values, the keepalive trio that stops 502s, health-check workarounds, 5 production incidents.
### Answer
Pick least_conn + weighted weights + keepalive trio (keepalive 32, timeout 60, requests 10000) + HTTP/1.1 for any API backend.
### FAQ
Q: Which algorithm for stateless REST APIs behind 3 backend pods?
A: Default round-robin is fine for identical pods. If pod sizes differ (mixed 4GB and 8GB), use weighted round-robin with weights proportional to vCPU. Reach for least_conn only when request duration variance is high (P99 / P50 > 5x).
Q: Why do I get 'upstream sent no valid HTTP/1.0 header' after adding keepalive?
A: You set `keepalive 32` in the upstream block but forgot `proxy_http_version 1.1;` inside the location. Keepalive upstreams require HTTP/1.1 between nginx and the backend — HTTP/1.0 (the nginx default) can't carry the Connection reuse semantics.
Q: Does nginx have active health checks?
A: No. Upstream checks are passive — nginx only knows a backend is bad after a request fails. Real-time checks require nginx-plus, an external health checker that updates the upstream via the balancer API, or lua-resty-balancer doing its own probing.
Q: What's a safe max_conns value?
A: Start at the backend's `worker_processes * worker_connections` divided by 2. For a Node.js app with `ulimit -n 65536` and one process, I usually set `max_conns 500`. For Java backends behind Tomcat with `maxThreads=200`, set `max_conns 150` so a saturated thread pool triggers failover before clients see 503.
Q: Should I use ip_hash for session affinity?
A: Only if the backend holds sticky state and you cannot pass it through. ip_hash is brittle: an nginx version upgrade can rehash everything, and it never fails over a dead peer under the classic implementation. Prefer token-based session transfer or a shared Redis layer.
Q: How do I know if my keepalive trio is actually working?
A: Look at `upstream_bytes_received`, `upstream_connect_time`, and TCP connection churn. If `ss -tnp | grep :8080` shows mostly ESTABLISHED connections (not fresh SYNs every request), keepalive is doing its job. If connect time drops from ~30µs to ~2µs per request after the change, you saved TCP setup cost.
### Full Content
# nginx Upstream Load Balancing: Algorithm Selection, Server Directives, and the keepalive Trio
## 1. How [nginx](https://nginx.org/en/docs/) actually uses the upstream block
An `upstream` block is not just a list of servers — it's a peer pool that nginx evaluates on every proxied request. When a client hits `proxy_pass http://api_pool`, nginx does this work inside the worker process: resolve peer candidates (excluding `down` or over-`max_conns`), pick one via the configured algorithm, open or reuse a keepalive connection if available, proxy the request, and update that peer's failure counters if the response fails.
Every one of those steps is configurable. Most tutorials cover the third step and stop. The other four are where production incidents live.
## 2. Six algorithms, one decision matrix
Nginx ships with six upstream algorithms. The choice depends less on "which is fastest" and more on your traffic shape and backend state model.
| Algorithm | Config line | Best for | Kill it when |
|---|---|---|---|
| Round-robin | *(default)* | Identical pods, no session state | Request durations vary wildly |
| Weighted round-robin | `server b weight=3` | Mixed hardware or capacity | Weights exceed real capacity ratio |
| `ip_hash` | `ip_hash` at block level | Legacy sessions without shared state | Any nginx upgrade or peer add/remove |
| `least_conn` | `least_conn` at block level | Variable-duration requests (ML, reports) | All peers roughly equal — adds bookkeeping cost |
| `hash` | `hash $cookie_sid consistent` | Cache-friendly routing (same key → same peer) | Fewer than 2 peers per hash bucket |
| `zone` + `consistent_hash` | `zone pool 64m` + `consistent_hash` | Hot peer rotation during deploys | Small peer counts (n < 4) — no benefit |
The default round-robin is what I use for 70% of services. It is boring in the best way: predictable, tunable via `weight`, and no hidden bookkeeping. `least_conn` is where I land when P99 request duration is more than 5x P50 — otherwise the fairness gain isn't worth the extra counter churn.
## 3. Per-server directives, with real numbers
Every `server` line inside an upstream block accepts up to six parameters. Their actual impact varies by a factor of ten depending on traffic.
```nginx
server 10.0.2.11:8080 weight=5 # Traffic share, 1..256
max_fails=3 # Failures within fail_timeout before pause
fail_timeout=10s # Pause window; also failure window when >0
max_conns=200 # Hard cap on concurrent requests per peer
backup # Only receives traffic when all primaries are down
down; # Hard-remove this peer (use sparingly)
```
Values I use as defaults:
- `weight=1` for identical peers, `weight=2` or `weight=3` for pods with double or triple the CPU.
- `max_fails=3` with `fail_timeout=10s` for APIs. That means a peer needs three consecutive failures within ten seconds to be paused — one bad request does not kick it out, but a genuine outage does within seconds.
- `max_fails=1, fail_timeout=60s` for expensive long-running jobs. One failure means pause for a full minute.
- `max_conns` at 50% of the backend's own connection cap. For a Tomcat with `maxThreads=200`, I set `max_conns 100`. That headroom is deliberate — it lets nginx shed load before Tomcat's thread pool saturates.
- `backup` on a canary peer with `weight=1`. Traffic only reaches it when every primary fails. This is my default for staged rollouts.
`down` deserves a warning: it removes the peer from the rotation without removing it from the file. It survives `nginx -s reload`. I keep a commented-out `down` line next to any server I might need to eject during an incident — flipping a `#` and reloading is faster than editing a list.
## 4. The keepalive trio that prevents 502 storms
The single most common nginx upstream misconfiguration I've debugged is a half-configured keepalive. The trio has to be complete:
```nginx
upstream api_pool {
server 10.0.2.11:8080 weight=5;
server 10.0.2.12:8080 weight=5;
server 10.0.2.13:8080 weight=5;
keepalive 32; # Max idle connections per worker to keep open
keepalive_timeout 60s; # Idle timeout on those sockets
keepalive_requests 10000; # Max requests per kept socket before recycling
}
server {
location / {
proxy_pass http://api_pool;
proxy_http_version 1.1; # Required — HTTP/1.0 cannot reuse sockets
proxy_set_header Connection ""; # Strips "Connection: close" that browsers may send
}
}
```
Get any one of these wrong and the effect is predictable:
- Missing `proxy_http_version 1.1;` → nginx 502s with `upstream sent no valid HTTP/1.0 header while reading response header from upstream`.
- Missing `proxy_set_header Connection "";` → browser `Connection: close` propagates, keepalive never engages.
- `keepalive_requests` too low (default is 1000) → sockets recycle every thousand requests, connect cost comes back.
- `keepalive_timeout` shorter than backend's idle timeout → nginx closes first, backend gets a half-open and logs noise.
I use `keepalive 32` because each nginx worker opens up to 32 idle sockets per peer pool. With four workers and three upstreams, that's 384 sockets maximum — well below typical `ulimit -n`. On heavy traffic I've pushed it to 64; below 16 you start missing reuse opportunities.
## 5. Health checks: the reality check
Nginx OSS has no built-in active health checking. When a peer fails a real request, nginx marks it down for `fail_timeout` and moves on. That is passive health checking, and it works — until the backend hangs without refusing connections. Then nginx keeps sending requests into a black hole.
Three ways around this:
1. **Passive + generous timeouts.** Set `proxy_connect_timeout 3s` and `proxy_read_timeout 30s`. Anything slower trips `max_fails` within seconds. Simple, no code, but you're using client-visible latency as the health signal.
2. **`balancer_by_lua_block`** with `lua-resty-balancer` doing its own periodic probes. Works, but you're now running Lua inside nginx and debugging two runtimes.
3. **nginx-plus** with `health_check` directive. If you already pay for plus, use it. It does exactly what OSS lacks: periodic TCP/HTTP checks independent of traffic.
For OSS, I pair `max_fails=3 fail_timeout=10s` with an external health checker that touches the upstreams directly and alerts, rather than trying to encode health logic into nginx.
## 6. Zone directives and dynamic rebalancing
The `zone` directive is where upstream state moves out of per-worker memory into shared memory:
```nginx
upstream api_pool {
zone api_pool 64m;
server 10.0.2.11:8080;
server 10.0.2.12:8080;
}
```
Without `zone`, each worker maintains its own peer state. That means:
- `max_fails` counters are per-worker, not global. A worker that just started won't know the other workers marked a peer down.
- `least_conn` counts are per-worker. With 32 workers, you can have 32x more connections to one peer than to another.
With `zone` enabled, state is shared. The cost: nginx can no longer add or remove peers without a full reload. If you need runtime peer changes (say, an autoscaler adding pods), use `lua-resty-balancer` or OpenResty's resty balancing APIs instead.
Dynamic rebalancing through Lua costs you in three places: debugging (two languages), config drift (peers no longer live in the config file), and observability (nginx's built-in balancer status no longer reflects reality). I've done it once and I don't like it.
## 7. Five production incidents I've hit
### 7.1 Weighted round-robin starving a slow backend
Three pods, weights 5, 5, 1. The weight-1 pod kept getting 15% of traffic, which was more than its CPU could absorb. It fell behind, requests queued, then nginx marked it failed for `fail_timeout` — which kicked it off the rotation, which meant the other two got 100% load, which pushed them over their caps. The recovery I had to write: lower the healthy pods to `weight=4` and the slow pod to `weight=2`, so the imbalance reflected actual capacity instead of aspirational allocation.
### 7.2 `ip_hash` breaking on nginx 1.25
Nginx 1.25 changed the internal hash seed for `ip_hash`. We upgraded a fleet of 40 nodes and roughly 30% of in-flight sessions silently moved to different backends. The app was fine with the wrong peer — it just lost session state, so users saw "please log in again." I downgraded, and we waited for a proper migration: a shared [Redis](https://redis.io/docs/) session layer, then remove `ip_hash` entirely. The lesson is that hash-based affinity looks stable until it isn't.
### 7.3 Half-configured keepalive → 502 storm
I added `keepalive 32` to an upstream block at 2am and forgot `proxy_http_version 1.1;`. Reload succeeded. Within 90 seconds every request 502'd. The `error.log` line was `upstream sent no valid HTTP/1.0 header while reading response header from upstream`. I've now got that line in my on-call runbook as a "keepalive miswired" trigger — it appears when you have keepalive configured but not correctly.
### 7.4 `max_conns` set too low → 503 under burst
Set `max_conns=5` on a pool of eight pods. Peak traffic hit, one pod's counter tripped, nginx started returning 503 with `no live upstreams while connecting to upstream`. It looked like the entire pool was down. Lowering `max_conns` to 100 fixed it. My default now is: `max_conns` at 50% of backend capacity, never below 20 for anything that sees more than 50 rps.
### 7.5 Upstream timeout cascade from a single slow request
One backend took 45 seconds to respond to a legitimate request. `proxy_read_timeout` was 60s. The worker held the connection, the client waited, then the client disconnected at 30s — nginx logged a 499, the worker was now stuck on a socket the client no longer cared about. Multiply by one slow peer per second and you've exhausted `worker_connections`. Fix was `proxy_read_timeout 15s` on that endpoint, which forced nginx to time out the request and return 504 cleanly instead of holding a half-open socket.
## 8. Production template
Copy-pasteable, tuned for a generic API pool:
```nginx
upstream api_pool {
zone api_pool 64m;
server 10.0.2.11:8080 weight=5 max_fails=3 fail_timeout=10s max_conns=200;
server 10.0.2.12:8080 weight=5 max_fails=3 fail_timeout=10s max_conns=200;
server 10.0.2.13:8080 weight=5 max_fails=3 fail_timeout=10s max_conns=200;
server 10.0.2.99:8080 weight=1 max_fails=1 fail_timeout=60s max_conns=50 backup;
keepalive 32;
keepalive_timeout 60s;
keepalive_requests 10000;
}
server {
listen 443 ssl http2;
server_name api.example.com;
location / {
proxy_pass http://api_pool;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
proxy_connect_timeout 3s;
proxy_send_timeout 30s;
proxy_read_timeout 30s;
proxy_next_upstream error timeout http_502 http_503 http_504;
proxy_next_upstream_tries 2;
proxy_next_upstream_timeout 5s;
}
}
```
The three `proxy_next_upstream` lines are the piece most people forget. They tell nginx which response codes should trigger a retry on another peer, how many times, and within what time budget. Without them, a single peer outage looks like a service outage to the client.
## 9. Cheat Sheet — 15 upstream one-liners
```bash
# Show the upstream blocks actually loaded (not what you think you wrote)
nginx -T 2>&1 | awk '/upstream .* \{/,/\}/'
# Count current connections to each upstream peer
ss -tnp | grep ':8080' | awk '{print $4}' | sort | uniq -c | sort -rn
# See how many ESTABLISHED vs TIME_WAIT to the upstream pool
ss -tn state established '( dport = :8080 )' | wc -l
ss -tn state time-wait '( dport = :8080 )' | wc -l
# Test the keepalive trio is actually engaged
curl -s -o /dev/null -w '%{time_connect}\n' http://api.example.com/ # sub-millisecond = keepalive working
# Trigger a passive health check on a suspect peer
for i in $(seq 1 20); do curl -s -o /dev/null -w '%{http_code} %{time_total}\n' http://api.example.com/; done
# Drain a peer without killing it (mark down, reload)
sed -i 's|server 10.0.2.11:8080|server 10.0.2.11:8080 down|' /etc/nginx/conf.d/api.conf
nginx -s reload
# Bring it back
sed -i 's|server 10.0.2.11:8080 down|server 10.0.2.11:8080|' /etc/nginx/conf.d/api.conf
nginx -s reload
# Reload with zero connection loss
nginx -s reload && nginx -s reopen
# Check worker count and connection cap
nginx -T 2>&1 | grep -E 'worker_(processes|connections|rlimit_nofile)'
# Watch upstream errors as they happen
tail -F /var/log/nginx/error.log | grep -E 'upstream|connect\(\) failed|timed out'
# Look at nginx access log upstream timing
awk '{print $NF}' /var/log/nginx/access.log | head
# Show current upstream pool (nginx-plus only)
curl -s http://127.0.0.1:8081/api/upstream
# Compare peer load balance (nginx-plus)
curl -s http://127.0.0.1:8081/api/upstream/api_pool | jq '.peers[].received'
# Full config syntax + reload rehearsal
nginx -t && nginx -s reload
# Show the keepalive directives as loaded
nginx -T 2>&1 | grep -E 'keepalive|proxy_http_version|Connection'
```
## 10. FAQ
**Which algorithm for stateless REST APIs behind 3 identical pods?** Default round-robin. Don't overthink it — the algorithm cost is noise compared to backend CPU cost. Only reach for `least_conn` when request duration variance is high (P99/P50 > 5x). If pods differ in size, use weighted round-robin with weights proportional to vCPU.
**Why do I get "upstream sent no valid HTTP/1.0 header" after adding keepalive?** You set `keepalive 32` inside the upstream block but forgot `proxy_http_version 1.1;` inside the location. Keepalive upstreams require HTTP/1.1 between nginx and the backend — HTTP/1.0, which is nginx's default, cannot carry connection reuse.
**Does nginx have active health checks?** No. OSS nginx does passive checks only — a peer is marked down after a real request fails. For real-time health checks you need nginx-plus, an external health checker updating the pool via the balancer API, or `lua-resty-balancer` doing its own probing.
**What's a safe `max_conns` value?** Start at 50% of the backend's own connection cap. Node.js with `ulimit -n 65536` in a single process: `max_conns 500`. Java behind Tomcat with `maxThreads=200`: `max_conns 100`. Never go below 20 on anything receiving more than 50 rps — sub-20 values trip the "no live upstreams" fast path on bursts.
**Should I use `ip_hash` for session affinity?** Only if the backend holds sticky state and you cannot pass it through any other way. `ip_hash` is brittle: an nginx upgrade can rehash everything (this actually broke production in nginx 1.25), and the classic implementation does not fail over a dead peer. Prefer token-based session transfer, a shared Redis session layer, or `$cookie`/`$header` hashing when you control the keys.
**How do I know if my keepalive trio is actually working?** Watch three signals: TCP connection churn (`ss -tnp | grep :8080` — mostly ESTABLISHED, not a fresh SYN every request), connect time (`curl -w '%{time_connect}\n'` — sub-millisecond on warm sockets, tens of microseconds to low milliseconds), and upstream bytes counters (steady rise, no sawtooth that tracks request volume). If connect time drops from ~30µs to ~2µs after adding keepalive, it's engaged.
**How does `zone` change behavior?** Without `zone`, each worker has its own peer state — `max_fails` counters and `least_conn` counts are per-worker, and peer add/remove requires a full reload. With `zone`, state is shared in memory across workers, and dynamic rebalancing becomes possible via the balancer API. The tradeoff: you can no longer edit peers freely in config without a reload, and debugging gets harder.
## Related Articles
- [Nginx Reverse Proxy 502/504 Troubleshooting — Full Decision Tree](nginx-proxy.md)
- [nginx + keepalived VIP Failover: Parameter Matrix and 6 Production Incidents](nginx-keepalived.md)
- [CDN Internals: Edge POP, Origin Fetch, Cache Invalidation & the Cost Model](cdn-principles.md)
---
## Rolling Out ModSecurity 3.0 WAF on Production nginx: Five Incidents, False Positives, and One Hard-Won Config
URL: https://ops.jiunotes.top/en/network/nginx-waf-modsecurity.md
摘要: First-person notes on compiling ModSecurity 3.0 as a dynamic nginx module, wiring OWASP CRS, and taming false positives through five real incidents: SQLi, CC flood, bot scraping, and rule regression.
### Answer
Field-tested ModSecurity 3.0 on nginx: compile, load, tune, survive.
### FAQ
Q: Why load ModSecurity as a dynamic module instead of compiling it statically?
A: Dynamic loading keeps the nginx binary upgradeable without rebuilding against libmodsecurity, and lets you swap the WAF in or out with a single load_module line and a reload.
Q: What is the fastest way to reduce false positives?
A: Run CRS in detection-only mode, study the audit log, then add targeted field exclusions (SecRuleUpdateTargetById) before flipping the engine to On.
Q: How did the CC flood get stopped without blocking real users?
A: Two layers: nginx limit_req_zone with burst and nodelay for the obvious volume, plus a ModSecurity counter rule that denies clients with an elevated blocked-request score.
Q: Why did a CRS rule update take my API down?
A: A newer CRS release added a stricter request-body consistency check that matched a large legitimate JSON payload, so every batch request 403'd until I pinned the CRS version and added a smoke test.
### Full Content
# Five Incidents That Made Me Trust ModSecurity on Production nginx
I manage a small fleet of [nginx](https://nginx.org/en/docs/) reverse proxies in front of a public API and a web store. For two years I leaned on `fail2ban`, `iptables`, and a generous rate limiter. It worked right up until it did not. A botnet would ramp up slowly enough to stay under my per-IP threshold, hammer one endpoint with SQLi probes for a few minutes, then vanish. IP blocking is reactive, coarse, and blind to what is inside the request. I needed a layer that understood HTTP, not just sockets. That is when I committed to ModSecurity 3.0 — and it produced five incidents worth writing down.
## 1. Building ModSecurity 3.0 as a dynamic module
I built the connector against the exact nginx source tree I was running, so I could drop the WAF in with a reload instead of a rebuild. Assumes Debian 12, nginx 1.24.0.
```bash
# ===================== 1. build dependencies =====================
apt install -y build-essential libpcre3-dev libssl-dev zlib1g-dev \
libxml2-dev libcurl4-openssl-dev libgeoip-dev libmodsecurity3 libmodsecurity-dev
# ===================== 2. match the running nginx =====================
nginx -v # nginx/1.24.0
wget https://nginx.org/download/nginx-1.24.0.tar.gz && tar xzf nginx-1.24.0.tar.gz
# ===================== 3. clone the official connector =====================
[git](https://git-scm.com/doc) clone --depth 1 https://github.com/SpiderLabs/ModSecurity-nginx.git
# ===================== 4. build ONLY the dynamic module =====================
cd nginx-1.24.0
./configure --with-compat --add-dynamic-module=../ModSecurity-nginx
make modules && cp objs/ngx_http_modsecurity_module.so /etc/nginx/modules/
# ===================== 5. sanity check before reload =====================
nginx -t && systemctl reload nginx
```
The `--with-compat` flag is critical: it builds the module against the running nginx binary signature so it loads as a shared object. One line at the top of `nginx.conf`:
```nginx
# /etc/nginx/nginx.conf (first line of the main context)
load_module modules/ngx_http_modsecurity_module.so;
```
## 2. Wiring the OWASP CRS in detection-only mode
I pulled the CRS and wired it up, but deliberately kept `SecRuleEngine DetectionOnly`. Nobody flips a WAF to blocking on a busy box without watching it first.
```bash
git clone --depth 1 https://github.com/coreruleset/coreruleset.git /etc/nginx/modsecurity/crs
cp /etc/nginx/modsecurity/crs/crs-setup.conf.example /etc/nginx/modsecurity/crs/crs-setup.conf
cp /etc/nginx/modsecurity/crs/rules/REQUEST-900-EXCLUSION-RULES-BEFORE-CRS.conf.example \
/etc/nginx/modsecurity/crs/rules/REQUEST-900-EXCLUSION-RULES-BEFORE-CRS.conf
```
The main `modsecurity.conf`:
```nginx
# ===================== /etc/nginx/modsecurity/modsecurity.conf =====================
SecRuleEngine DetectionOnly # start safe, always
SecRequestBodyAccess On
SecRequestBodyLimit 13107200 # 12.5 MiB batch orders
SecResponseBodyAccess Off
SecAuditEngine RelevantOnly # log only real matches
SecAuditLog /var/log/nginx/modsec_audit.log
SecAuditLogType Serial
SecAuditLogParts ABIJDEFHZ
SecDefaultAction "phase:2,log,auditlog,pass"
Include crs/crs-setup.conf
Include crs/rules/*.conf
```
Then I tuned `crs-setup.conf` before ever enforcing. These are the lines that matter on a real API:
```nginx
# ===================== crs-setup.conf: the knobs I actually changed =====================
SecAction "id:9000001,phase:1,pass,nolog,setvar:'tx.crs_setup_version=400',setvar:'tx.paranoia_level=1'"
SecAction "id:9000002,phase:1,pass,nolog,setvar:'tx.executing_paranoia_level=1'"
SecAction "id:9000003,phase:1,pass,nolog,setvar:'tx.crs_validate_utf8_encoding=1'"
SecAction "id:9000004,phase:1,pass,nolog,setvar:'tx.allowed_methods=GET POST PUT DELETE OPTIONS'"
SecAction "id:9000005,phase:1,pass,nolog,setvar:'tx.allowed_request_content_type=application/json application/x-www-form-urlencoded multipart/form-data'"
SecAction "id:9000006,phase:1,pass,nolog,setvar:'tx.allowed_http_versions=HTTP/1.1 HTTP/2 HTTP/3'"
SecAction "id:9000007,phase:1,pass,nolog,setvar:'tx.max_num_args=256',setvar:'tx.arg_name_length=128'"
SecAction "id:9000008,phase:1,pass,nolog,setvar:'tx.max_arg_length=1024',setvar:'tx.total_arg_length=65536'"
SecAction "id:9000009,phase:1,pass,nolog,setvar:'tx.blocking_inbound_anomaly_score=5'"
SecAction "id:9000010,phase:1,pass,nolog,setvar:'tx.inbound_anomaly_score_threshold=5'"
SecAction "id:9000011,phase:1,pass,nolog,setvar:'tx.outbound_anomaly_score_threshold=4'"
SecAction "id:9000012,phase:1,pass,nolog,setvar:'tx.restricted_extensions=.exe .dll .bat .sh .cmd'"
SecAction "id:9000013,phase:1,pass,nolog,setvar:'tx.restricted_headers=/Proxy-Connection/ /Lock-Token/ /Content-Range/'"
SecAction "id:9000014,phase:1,pass,nolog,setvar:'tx.static_extensions=.css .js .png .jpg .svg .ico .woff .woff2 .gif'"
```
```nginx
# ===================== server block =====================
server {
listen 443 ssl;
server_name api.example.com;
modsecurity on;
modsecurity_transaction_id $request_id;
modsecurity_rules_file /etc/nginx/modsecurity/modsecurity.conf;
}
```
## 3. Incident 1: the legitimate API got blocked by a false positive
Detection-only meant I saw everything and broke nothing. But the log was full of rule `942100` firing on `POST /api/v2/orders`, every time. The payload was JSON carrying a base64-encoded signature, and `942100` (SQLi) matched because base64 blobs contain byte sequences that look like SQL operators. Real orders were flagged. The fix was not to disable SQLi detection, but to narrow the target field.
```nginx
# ===================== REQUEST-900-EXCLUSION-RULES-BEFORE-CRS.conf =====================
# base64-encoded JSON signatures look like SQL operators to rule 942100
SecRuleUpdateTargetById 942100 "!REQUEST_BODY" "id:9001001,phase:2,t:none,pass,nolog"
SecRuleUpdateTargetByTag "attack-sqli" "!REQUEST_BODY" "id:9001002,phase:2,t:none,pass,nolog"
```
## 4. Incident 2: a real SQLi attempt caught in the wild
Three weeks in, the audit log showed `942100` and `942130` firing on `GET /api/login?u=admin'--&p=x`. A classic comment-based injection. Detection-only proved from the log that the request would never reach the app. That was the moment I flipped `SecRuleEngine` to `On`.
```nginx
# ===================== production enforcement =====================
SecRuleEngine On
SecDefaultAction "phase:2,deny,status:403,log,auditlog"
```
This is the actual audit line that convinced me — note the `libinjection` verdict and the matched `--` inside the username argument:
```text
[28/Oct/2026:09:14:22+0000] [error] 18234#0: *89102 [client 203.0.113.55] ModSecurity: Warning. Matched "Operator `Rx' with parameter `(union|select|insert|drop|update)' against variable `ARGS:u' (Value: `admin'" --' )" [file "/etc/nginx/modsecurity/crs/rules/REQUEST-942-APPLICATION-ATTACK-SQLI.conf"] [line "110"] [id "942100"] [rev "4"] [msg "SQL Injection Attack Detected via libinjection"] [data "Matched Data: '--' found within ARGS:u"] [severity "2"] [ver "OWASP_CRS/4.0.0"] [tag "attack-sqli"] [hostname "api.example.com"] [uri "/api/login"]
```
I also started scanning the audit log daily:
```bash
grep -oE "\[id \"[0-9]+\"\]" /var/log/nginx/modsec_audit.log | grep -oE "[0-9]+" \
| sort | uniq -c | sort -rn | head -5
```
## 5. Incident 3: a CC flood during a flash sale
A `/sale` endpoint took about 40,000 requests in ten minutes from rotating residential IPs. Per-IP rate limiting was useless: every IP stayed under my threshold. I added two layers — nginx `limit_req_zone` for the obvious volume, plus a ModSecurity counter rule that denies clients whose transaction accumulated a high blocked-request score.
```nginx
# ===================== http context: rate limits =====================
limit_req_zone $binary_remote_addr zone=api:10m rate=20r/s;
limit_req_zone $binary_remote_addr zone=store:10m rate=10r/s;
```
```nginx
# ===================== server/location context =====================
location /sale {
limit_req zone=api burst=40 nodelay; # absorb short spikes, then throttle
proxy_pass http://backend;
}
```
```nginx
# ===================== modsecurity.conf: repeat-offender rule =====================
SecRule &TX:blocked_count "@gt 30" \
"id:9002001,phase:1,deny,status:429,msg:'repeat offender, throttled',log,auditlog"
```
## 6. Incident 4: the bot that scraped my whole catalog
A headless scraper walked my product catalog, roughly twelve pages a second, no throttling, no `User-Agent` spoofing. The winning move was not blocking the IP — it was blocking the pattern, while leaving my own monitoring agents untouched.
```nginx
# ===================== /etc/nginx/modsecurity/custom-rules.conf =====================
# ---- incident 4: bot / scraper defenses ----
SecRule REQUEST_HEADERS:User-Agent "@rx (?i)(?:scrapy|python-requests|headless-chrome|phantomjs|selenium|okhttp|http-client)" \
"id:9003001,phase:1,deny,status:403,msg:'scraper detected',log,auditlog,ver:'custom/1.0',tag:'automation'"
SecRule REQUEST_HEADERS:User-Agent "@streq curl" \
"id:9003002,phase:1,pass,nolog" # keep our own health checks alive
SecRule REQUEST_HEADERS:User-Agent "@rx (?i)(?:facebookexternalhit|slurp|bingbot|googlebot|baiduspider)" \
"id:9003003,phase:1,pass,nolog,msg:'legit crawlers allowed',tag:'crawler'"
SecRule REQUEST_HEADERS:Accept-Encoding "@contains gzip" \
"id:9003004,phase:1,pass,nolog" # no false alarm on compression
# ---- incident 3: CC flood / repeat offenders ----
SecRule &TX:blocked_count "@gt 30" \
"id:9003005,phase:1,deny,status:429,msg:'repeat offender, throttled',log,auditlog,ver:'custom/1.0'"
SecRule TX:anomaly_score "@gt 20" \
"id:9003006,phase:2,deny,status:429,msg:'anomaly score exceeded, throttled',log,auditlog,ver:'custom/1.0'"
# ---- incident 5: large legitimate bodies must not 403 ----
SecRule REQUEST_BODY "@gt 1048576" \
"id:9003007,phase:2,pass,nolog,msg:'large legitimate body observed',ver:'custom/1.0'"
# ---- hardened method + path controls ----
SecRule REQUEST_METHOD "@pm GET POST PUT DELETE OPTIONS" \
"id:9003008,phase:1,deny,status:405,msg:'method not allowed',log,auditlog,ver:'custom/1.0',tag:'protocol'"
SecRule REQUEST_URI "@contains /admin" \
"id:9003009,phase:2,deny,status:403,msg:'admin path blocked for public',log,auditlog,ver:'custom/1.0',tag:'access'"
SecRule REQUEST_FILENAME "@rx \.(?:php|asp|aspx|jsp|pl|py|cgi)$" \
"id:9003010,phase:2,deny,status:403,msg:'script extension blocked',log,auditlog,ver:'custom/1.0',tag:'protocol'"
```
## 7. Incident 5: the rule update regression
I upgraded CRS from 3.3 to 4.0. The new `REQUEST-913` plus a stricter `REQUEST-920` body-consistency check began matching a large legitimate JSON payload — a batch order with 200 line items. Every `/api/batch` request returned 403 for forty minutes before I caught it. I pinned the CRS to a specific tag and added a pre-deploy smoke test.
```bash
# ===================== pin CRS, never let it float =====================
cd /etc/nginx/modsecurity/crs
git checkout v4.0.0
git describe --tags # record the exact tag in your change log
```
```nginx
# ===================== smoke-test guard: log oversized legit bodies =====================
SecRule REQUEST_BODY "@gt 1048576" \
"id:9004001,phase:2,pass,nolog,msg:'large legitimate body observed'"
```
My post-mortem rule is now non-negotiable: upgrade CRS in detection-only, diff the audit log against a known-good baseline, and only then enforce.
## 8. The false-positive playbook
After the first month I had a repeatable loop. Run in detection-only. Pull the audit log. Group alerts by rule ID. For each rule firing on legitimate traffic, either exclude the field or the location. Keep every exclusion in `REQUEST-900-EXCLUSION-RULES-BEFORE-CRS.conf` so it is version-controlled, reviewed, and survives upgrades. Never disable a whole rule; narrow it with `SecRuleUpdateTargetById`.
| # | Rule ID | Signal I saw | Root cause | Mitigation |
|:--|:--|:--|:--|:--|
| 1 | 942100 | Legit JSON blocked | base64 blob matched SQLi regex | `SecRuleUpdateTargetById 942100 "!REQUEST_BODY"` |
| 2 | 942100/942130 | Real SQLi caught | `u=admin'--` comment injection | Engine `On` + daily audit scan |
| 3 | custom 9002001 | CC flood | rotating residential IPs | `limit_req_zone` + counter rule |
| 4 | custom 9003001 | Bot scraping | headless crawler | UA-pattern deny + allowlist |
| 5 | 913 / 920 | Rule update regression | stricter body-consistency check | Pin CRS tag + smoke test |
## 9. The complete production config template
This is the server block I run today, combining the CRS, my incident-derived exclusions, and the rate limits.
```nginx
# ===================== /etc/nginx/sites-available/waf-api.conf =====================
load_module modules/ngx_http_modsecurity_module.so;
# ---------- http context ----------
limit_req_zone $binary_remote_addr zone=api:10m rate=20r/s;
limit_req_zone $binary_remote_addr zone=store:10m rate=10r/s;
map $http_user_agent $bad_ua {
default 0;
~*(?:scrapy|python-requests|headless|phantomjs|selenium|okhttp) 1;
}
log_format waf '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" "$http_user_agent" '
'$request_id $modsec_transaction_id';
# ---------- server context ----------
server {
listen 443 ssl;
server_name api.example.com;
access_log /var/log/nginx/api-waf.access.log waf;
modsecurity on;
modsecurity_transaction_id $request_id;
modsecurity_rules_file /etc/nginx/modsecurity/modsecurity.conf;
location /sale {
limit_req zone=api burst=40 nodelay;
proxy_pass http://backend;
}
location /api/ {
# Incident 1: base64 blob false positive
# Incident 4: keep curl health checks alive
modsecurity_rules '
SecRuleUpdateTargetById 942100 "!REQUEST_BODY"
SecRule REQUEST_HEADERS:User-Agent "@streq curl" "id:9003002,phase:1,pass,nolog"
';
limit_req zone=api burst=20 nodelay;
proxy_pass http://backend;
}
location /store/ {
limit_req zone=store burst=30 nodelay;
if ($bad_ua) { return 403; }
proxy_pass http://backend;
}
}
```
## 10. Monitoring, upgrades, and what I wish I had known
Three signals matter: the audit-log match count (`SecAuditEngine RelevantOnly`), the 403 rate grouped by rule ID, and the anomaly-score distribution. Alert the moment a single rule ID spikes — that is either an active attack or a fresh false positive, and you need to know which before it becomes an outage. Before every CRS upgrade I hit staging with a known-good JSON payload and a known-bad SQLi string. That smoke test would have caught Incident 5 in seconds.
```bash
# ===================== /usr/local/bin/waf-audit-scan.sh =====================
#!/usr/bin/env bash
AUDIT=/var/log/nginx/modsec_audit.log
TOP=$(grep -oE "\[id \"[0-9]+\"\]" "$AUDIT" | grep -oE "[0-9]+" \
| sort | uniq -c | sort -rn | head -5)
SEV=$(grep -oE "\[severity \"[0-9]\"\]" "$AUDIT" | sort | uniq -c)
if [ -n "$TOP" ]; then
echo "WAF alert $(date): top rules $TOP ; severity $SEV" \
| mail -s "WAF audit: new rule spikes" oncall@example.com
fi
```
```cron
# crontab -e — run the scan every 15 minutes
*/15 * * * * /usr/local/bin/waf-audit-scan.sh >/dev/null 2>&1
```
If I could go back, I would tell myself four things: compile the module against the exact nginx version you run; start in detection-only no matter how confident you are; treat every CRS rule as guilty until proven innocent against your own traffic; and never let the CRS version float.
## 11. Appendix: the exact files I ship
Every file below is copy-paste ready. This is the complete set, not a fragment — each comment marks what an incident taught me.
```nginx
# ===================== /etc/nginx/modsecurity/modsecurity.conf (final) =====================
# Incident 2: engine is ON in production; never ship DetectionOnly on a live API
SecRuleEngine On
SecRequestBodyAccess On
SecRequestBodyLimit 13107200
SecResponseBodyAccess Off
SecAuditEngine RelevantOnly
SecAuditLog /var/log/nginx/modsec_audit.log
SecAuditLogType Serial
SecAuditLogParts ABIJDEFHZ
SecDefaultAction "phase:2,deny,status:403,log,auditlog"
Include crs/crs-setup.conf
Include crs/rules/*.conf
Include custom-rules.conf
```
```nginx
# ===================== /etc/nginx/modsecurity/crs-setup.conf (tuned) =====================
SecAction "id:9000001,phase:1,pass,nolog,setvar:'tx.crs_setup_version=400',setvar:'tx.paranoia_level=1'"
SecAction "id:9000002,phase:1,pass,nolog,setvar:'tx.executing_paranoia_level=1'"
SecAction "id:9000003,phase:1,pass,nolog,setvar:'tx.crs_validate_utf8_encoding=1'"
SecAction "id:9000004,phase:1,pass,nolog,setvar:'tx.allowed_methods=GET POST PUT DELETE OPTIONS'"
SecAction "id:9000005,phase:1,pass,nolog,setvar:'tx.allowed_request_content_type=application/json application/x-www-form-urlencoded multipart/form-data'"
SecAction "id:9000006,phase:1,pass,nolog,setvar:'tx.allowed_http_versions=HTTP/1.1 HTTP/2 HTTP/3'"
SecAction "id:9000007,phase:1,pass,nolog,setvar:'tx.max_num_args=256',setvar:'tx.arg_name_length=128'"
SecAction "id:9000008,phase:1,pass,nolog,setvar:'tx.max_arg_length=1024',setvar:'tx.total_arg_length=65536'"
SecAction "id:9000009,phase:1,pass,nolog,setvar:'tx.blocking_inbound_anomaly_score=5'"
SecAction "id:9000010,phase:1,pass,nolog,setvar:'tx.inbound_anomaly_score_threshold=5'"
SecAction "id:9000011,phase:1,pass,nolog,setvar:'tx.outbound_anomaly_score_threshold=4'"
SecAction "id:9000012,phase:1,pass,nolog,setvar:'tx.restricted_extensions=.exe .dll .bat .sh .cmd'"
SecAction "id:9000013,phase:1,pass,nolog,setvar:'tx.restricted_headers=/Proxy-Connection/ /Lock-Token/ /Content-Range/'"
SecAction "id:9000014,phase:1,pass,nolog,setvar:'tx.static_extensions=.css .js .png .jpg .svg .ico .woff .woff2 .gif'"
```
```nginx
# ===================== /etc/nginx/modsecurity/custom-rules.conf (final) =====================
# Incident 4: scraper patterns, allowlisted crawlers and our own curl checks
SecRule REQUEST_HEADERS:User-Agent "@rx (?i)(?:scrapy|python-requests|headless-chrome|phantomjs|selenium|okhttp|http-client)" \
"id:9003001,phase:1,deny,status:403,msg:'scraper detected',log,auditlog,ver:'custom/1.0',tag:'automation'"
SecRule REQUEST_HEADERS:User-Agent "@streq curl" "id:9003002,phase:1,pass,nolog"
SecRule REQUEST_HEADERS:User-Agent "@rx (?i)(?:facebookexternalhit|slurp|bingbot|googlebot|baiduspider)" \
"id:9003003,phase:1,pass,nolog,msg:'legit crawlers allowed',tag:'crawler'"
# Incident 3: CC flood — repeat offenders get a 429, not a permanent ban
SecRule &TX:blocked_count "@gt 30" "id:9003005,phase:1,deny,status:429,msg:'repeat offender, throttled',log,auditlog,ver:'custom/1.0'"
SecRule TX:anomaly_score "@gt 20" "id:9003006,phase:2,deny,status:429,msg:'anomaly score exceeded, throttled',log,auditlog,ver:'custom/1.0'"
# Incident 5: large legitimate batch bodies must log, never 403
SecRule REQUEST_BODY "@gt 1048576" "id:9003007,phase:2,pass,nolog,msg:'large legitimate body observed',ver:'custom/1.0'"
# protocol hardening
SecRule REQUEST_METHOD "@pm GET POST PUT DELETE OPTIONS" "id:9003008,phase:1,deny,status:405,msg:'method not allowed',log,auditlog,ver:'custom/1.0',tag:'protocol'"
SecRule REQUEST_URI "@contains /admin" "id:9003009,phase:2,deny,status:403,msg:'admin path blocked for public',log,auditlog,ver:'custom/1.0',tag:'access'"
SecRule REQUEST_FILENAME "@rx \.(?:php|asp|aspx|jsp|pl|py|cgi)$" "id:9003010,phase:2,deny,status:403,msg:'script extension blocked',log,auditlog,ver:'custom/1.0',tag:'protocol'"
# payload-shape guards that cost nothing but catch the noisy half of bot traffic
SecRule ARGS "@rx (?i)(?:'
curl -s -o /dev/null -w "%{http_code}\n" \
-X POST http://ops.jiunotes.top/api/comment -d 'content='
```
对照实验才能证明豁免"精准命中"而非"误伤一片"。
### 7.2 内网办公网段被误杀
更尴尬的一次:公司同事从办公室访问线上后台,全员 403。一看 audit log,命中 `941130`(JavaScript 事件属性检测),触发点是登录后的跳转 URL 带了 `?redirect=javascript:...` 参数——这是我们自己前端写的合法跳转逻辑。因为办公网是可信内网,我直接加了白名单:
```nginx
SecRule REMOTE_ADDR "@ipMatch 192.168.100.0/24" \
"id:990300,phase:1,nolog,pass,allow"
```
`allow` 动作会跳过后续规则,可信网段完全不经过 CRS,性能还更好了。**白名单要窄**:只放行办公网段,绝不放行整个公网。
验证白名单是否生效,我从办公网发起一次明显恶意请求:
```bash
# 从 192.168.100.0/24 网段内的机器执行
curl -s -o /dev/null -w "%{http_code}\n" \
"http://ops.jiunotes.top/api/search?kw="
# 期望 200:因为 allow 在 phase:1 就放行了,根本不进 CRS
```
这一步很关键——**确认白名单真的跳过**,否则你以为放行了,实际仍被 941 拦截,业务方会再报一轮"又误杀了"。
## 8. 完整生产配置模板
把前面所有碎片拼成一份可复制的完整配置。先看独立的 `modsecurity.conf`(WAF 本体参数):
```nginx
# /etc/nginx/modsecurity/modsecurity.conf
# 全局开关:灰度期用 DetectionOnly,稳定后改 On
SecRuleEngine On
# 请求体检测:这是注入检测的基础,必须开
SecRequestBodyAccess On
SecRequestBodyLimit 131072
SecRequestBodyNoFilesLimit 131072
SecRequestBodyInMemoryLimit 65536
SecRequestBodyLimitAction Reject
# 响应体检测:默认关,开销大且易误报,需要再单点开
SecResponseBodyAccess Off
# 审计日志:RelevantOnly 只记命中的事务,日志量可控
SecAuditEngine RelevantOnly
SecAuditLog /var/log/nginx/modsec_audit.log
SecAuditLogType Serial
SecAuditLogParts ABIJDEFHZ
SecAuditLogFormat JSON
SecAuditLogStorageDir /var/log/nginx/modsec_audit
# 超时与失败处理:检测超时降级为 pass,别让 WAF 本身拖垮服务
SecRuleUpdateActionById 949110 "pass"
SecRuleUpdateActionById 959130 "pass"
SecDefaultAction "phase:2,deny,status:403,log,auditlog"
```
其中 `SecAuditLogFormat JSON` 是我强烈建议的——结构化日志能被 ELK/Loki 直接解析,排查时按 `rule_id` 聚合快得多。
接下来是 site 级配置:
```nginx
# /etc/nginx/conf.d/site-waf.conf
load_module /etc/nginx/modules/ngx_http_modsecurity_module.so;
http {
# 基础 WAF 开关
modsecurity on;
modsecurity_config /etc/nginx/modsecurity/modsecurity.conf;
# CRS 接入(放 http 级,全站生效)
include /etc/nginx/modsecurity/crs/crs-setup.conf;
include /etc/nginx/modsecurity/crs/rules/REQUEST-900-EXCLUSION-RULES-BEFORE-CRS.conf;
include /etc/nginx/modsecurity/crs/rules/REQUEST-901-INITIALIZATION.conf;
include /etc/nginx/modsecurity/crs/rules/REQUEST-905-COMMON-EXCEPTIONS.conf;
include /etc/nginx/modsecurity/crs/rules/REQUEST-910-IP-REPUTATION.conf;
include /etc/nginx/modsecurity/crs/rules/REQUEST-911-METHOD-ENFORCEMENT.conf;
include /etc/nginx/modsecurity/crs/rules/REQUEST-920-PROTOCOL-ENFORCEMENT.conf;
include /etc/nginx/modsecurity/crs/rules/REQUEST-930-APPLICATION-ATTACK-LFI.conf;
include /etc/nginx/modsecurity/crs/rules/REQUEST-931-APPLICATION-ATTACK-RFI.conf;
include /etc/nginx/modsecurity/crs/rules/REQUEST-932-APPLICATION-ATTACK-RCE.conf;
include /etc/nginx/modsecurity/crs/rules/REQUEST-933-APPLICATION-ATTACK-PHP.conf;
include /etc/nginx/modsecurity/crs/rules/REQUEST-941-APPLICATION-ATTACK-XSS.conf;
include /etc/nginx/modsecurity/crs/rules/REQUEST-942-APPLICATION-ATTACK-SQLI.conf;
include /etc/nginx/modsecurity/crs/rules/REQUEST-949-BLOCKING-EVALUATION.conf;
include /etc/nginx/modsecurity/crs/rules/RESPONSE-959-BLOCKING-EVALUATION.conf;
include /etc/nginx/modsecurity/crs/rules/RESPONSE-980-HTTP-EXCEPTIONS.conf;
server {
listen 443 ssl http2;
server_name ops.jiunotes.top;
ssl_certificate /etc/nginx/ssl/ops.jiunotes.top.crt;
ssl_certificate_key /etc/nginx/ssl/ops.jiunotes.top.key;
# 业务白名单:可信内网跳过 WAF(窄范围,别放行整个公网)
SecRule REMOTE_ADDR "@ipMatch 192.168.100.0/24" \
"id:990300,phase:1,nolog,pass,allow";
# 富文本编辑内容豁免 XSS 检测(精准豁免而非整条剔除)
SecRuleUpdateTargetById 941100 "!ARGS:/admin/post/content";
# 自定义 CC 防护:同 IP 每分钟超 120 次即 429
SecAction "id:990100,phase:1,initcol:ip=%{REMOTE_ADDR},nolog,pass";
SecRule IP:count "@gt 120" "id:990101,phase:1,deny,status:429,log,msg:'CC'";
SecRule IP:count "@gt 0" "id:990102,phase:1,setvar:ip.count=+1,nolog,pass";
# 管理后台单独收紧:登录接口限速
location /wp-login.php {
SecRule TX:login_count "@gt 10" \
"id:990211,phase:2,deny,status:403,log,msg:'Brute force'";
proxy_pass http://backend;
proxy_set_header Host $host;
}
location / {
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# 友好错误页:别把"被 WAF 拦"和"系统故障"混为一谈
error_page 403 /403.html;
location = /403.html {
root /usr/share/nginx/html;
internal;
}
}
}
```
灰度切换建议:第一周 `SecRuleEngine DetectionOnly` 只记不拦,把误报清干净;第二周再切 `On`。别一步到位——我用一次全员 403 换来的教训是:**误报会摧毁业务方对 WAF 的信任**。
调试技巧:上线初期把 `SecRuleEngine` 设为 `DetectionOnly`,让 WAF 只记不拦,观察几天 audit log 确认无误报后再切回 `On`。这是最稳的灰度方式。
## 9. 调优与监控:我踩过的坑
**坑一:响应体检测别乱开。** 我试过 `SecResponseBodyAccess On`,结果大 JSON 接口的响应体被完整缓存解析,内存和延迟双双飙升。最终关掉,只保留请求侧检测。
**坑二:正则回溯让 CPU 打满。** CRS 默认规则的 `@rx` 在超长输入下可能有回溯开销,我加了 `SecRequestBodyLimit 131072` 并把 `SecRuleUpdateTargetById` 用于超长字段,防住了恶意超大包。
**坑三:忘了看审计日志。** 有一次误报排查半天,才发现 `SecAuditEngine` 被我误设为 `Off`,压根没记录。生产环境务必 `RelevantOnly`,只记命中的事件,否则日志量爆炸。
**监控建议:** 把 `$status` 为 403/429 的比例、`modsec_audit.log` 的条目增长率接进 [Prometheus](https://prometheus.io/docs/)。403 比例突然上升,八成是攻击或误报,要立刻看 audit log 定位。
上线前必须压测,别拿"应该不会慢"赌生产。我用的对比方法:
```bash
# 开关 WAF 各跑一轮,对比 QPS 和 P99
# 关:注释掉 load_module + modsecurity on,reload 后跑
ab -n 20000 -c 200 http://127.0.0.1/api/health > /tmp/no_waf.txt
# 开:恢复配置,reload 后再跑
ab -n 20000 -c 200 http://127.0.0.1/api/health > /tmp/with_waf.txt
grep "Requests per second" /tmp/no_waf.txt /tmp/with_waf.txt
```
如果 P99 涨幅超过 50ms,优先做三件事:`SecResponseBodyAccess Off`(响应体检测最贵)、`SecRequestBodyLimit` 收紧到业务上限、把高频接口的字段加进豁免列表。**别急着降 Paranoia Level,先查性能热点在不在你不需要的检测上。**
日常巡检命令,我挂在 crontab 里每分钟跑:
```bash
# 403/429 占比骤升就告警
awk '{print $9}' /var/log/nginx/access.log | grep -cE '^(403|429)$'
# audit log 条目数突变也要盯
wc -l /var/log/nginx/modsec_audit.log
```
`modsec_audit.log` 增长极快,务必配 logrotate,不然磁盘被填满是迟早的事:
```bash
# /etc/logrotate.d/nginx-modsec
/var/log/nginx/modsec_audit.log {
daily
rotate 14
compress
missingok
notifempty
create 0640 www-data adm
postrotate
nginx -s reopen # 必须告诉 nginx 重新打开日志文件
endscript
}
```
注意 `postrotate` 里的 `nginx -s reopen`,漏了它 audit log 会一直写进被 rotate 走的旧文件,你以为在轮转,其实根本没生效——这个坑我踩过,日志文件删了还占着 inode。
排障速查,遇到问题按这个顺序过一遍:
```bash
# 1. 配置语法对不对
nginx -t
# 2. 模块到底加载没有
nginx -T 2>/dev/null | grep -c "load_module.*modsecurity"
# 3. 规则文件有没有 include 进来(缺一个文件就少一层防护)
nginx -T 2>/dev/null | grep -c "REQUEST-942-APPLICATION-ATTACK-SQLI"
# 4. 是"没拦"还是"拦了没记录"?
grep -c "modsecurity" /var/log/nginx/error.log
tail -20 /var/log/nginx/modsec_audit.log
# 5. 临时关掉 WAF 对比:注释 modsecurity on 后 reload,请求是否恢复正常
# 恢复了 → 大概率误报;没恢复 → 问题不在 WAF,别赖 WAF
```
第 5 条我称之为"**开关对比法**",是判断问题是不是 WAF 引起的金标准。很多同事一看到 403 就急着改规则,其实用开关对比法 5 分钟就能定性。
**升级 CRS 的坑**:每次升级 CRS 小版本,规则 ID 会新增、`REQUEST-900` 排除文件可能被覆盖。升级后我必做三件事:`nginx -t` 过语法、跑一遍 `curl` 冒烟(打正常接口 + 恶意接口各一)、看 audit log 有没有新误报。别直接 reload 就完事。
## FAQ
**Q:ModSecurity 会拖慢 nginx 吗?**
A:会。实测 CRS 默认 PL1 下 QPS 下降 5%-15%,主要是请求体解析和正则开销。上线前必须压测,我的底线是 P99 增加不超过 50ms。
**Q:误报怎么快速定位?**
A:`SecAuditEngine RelevantOnly` 只记录触发规则的事件,一条误报的 audit log 里有 transaction 编号,能直接看到命中规则 ID 和原始 payload,照着加排除规则即可。
**Q:Paranoia Level 要不要开到 4?**
A:别在生产直接开。PL1 是默认,PL2 误报率已明显上升,PL3/PL4 只适合高安全敏感场景。建议 PL1 起步,用排除规则补个别业务风险。
**Q:误封的 IP 怎么快速解封?**
A:若用了 CRS 的 IP 封禁规则(REQUEST-910),把 `REMOTE_ADDR` 临时加入白名单。更稳妥的是用 fail2ban 做封禁层、ModSecurity 只做检测,解封逻辑分开,避免误伤。
## 相关文章
- [Wireshark 抓包实战:定位 nginx 502 / 504 根因的完整排查链路](wireshark-nginx-troubleshoot.md)
- [Docker 环境下 nginx 502 排查:127.0.0.1 vs 服务名、DNS 解析、健康检查五坑指南](docker-nginx-502.md)
- [nginx 504 gateway timeout 排查:6 类根因决策树](nginx-504-troubleshoot.md)
---
## Linux 时间同步实战与排障:chrony 配置模板 + 5 个真实时钟漂移故障
URL: https://ops.jiunotes.top/network/ntp-time-sync.md
摘要: 时钟漂移是生产隐形杀手:TLS 证书 not yet valid、Kerberos 认证失败、监控数据错位都源于此。本文给 chrony 完整配置模板、国内 NTP 源选择、跳变 vs 渐进调整,加 5 个真实排障案例与决策树。
### Answer
时钟漂移引发 TLS/Kerberos/监控三类故障。chrony 配国内源 + makestep 渐进校正,timedatectl 与 chronyc sources -v 验证。
### FAQ
Q: 时钟漂移会造成哪些生产故障?
A: 最常见三类:TLS 证书报 "not yet valid"(服务器快了)、Kerberos 认证 "Clock skew too great"、监控与日志时间错位。都源于机器时间与真实时间偏差过大。
Q: chrony 和 ntpd 怎么选?
A: 新系统首选 chrony。它对高延迟高抖动网络更稳、收敛更快、支持 makestep 一步校正。ntpd 传统但启动慢,systemd-timesyncd 只能做客户端不能做服务端。
Q: 为什么不用 time.nist.gov 或 pool.ntp.org?
A: 这些国外源跨境延迟高、抖动大,国内同步质量差。用 ntp.aliyun.com 或 cn.pool.ntp.org 最稳,延迟低且准确度够。
Q: makestep 1.0 3 是什么意思?
A: 前 3 次同步时,若偏移超过 1 秒就一步跳变校正;超过 3 次后改为渐进 slewing。避免生产环境大步跳变引发告警风暴和证书问题。
### Full Content
# Linux 时间同步实战与排障:chrony 配置模板 + 5 个真实时钟漂移故障
## 一、时钟漂移为什么是生产隐形杀手
我干了快十年的 [Linux](https://www.kernel.org/doc/html/latest/) 运维,处理过几百起线上事故,其中最让我头大的不是磁盘写满、不是内存 OOM,反而是最不起眼的**系统时间**。它平时安安静静,一旦出事,症状五花八门,排查方向却常常被带偏。
先说三个我真实踩过的坑,你会发现它们八竿子打不着,根子却是同一个。
### 1.1 TLS 证书报 "certificate is not yet valid"
有一年公司对外提供 HTTPS 服务,客户反馈浏览器直接红屏,报错 `ERR_CERT_DATE_INVALID`。我登录服务器一看,证书明明在有效期内,签发时间和到期时间都正常。再 `date` 一看——**这台服务器的系统时间比真实时间快了 5 分多钟**。
证书的 notBefore 是真实时间往后推几个月的,服务器时间快 5 分钟,在客户端眼里,当前时间已经越过 notBefore 吗?不会。但反过来的场景更常见:**服务器时间慢了**。客户端用服务器时间做本地验证,或者反向代理做双向校验,时间一错位,就会在握手阶段报:
```
openssl s_client -connect api.example.com:443
Verify return code: 9 (certificate is not yet valid)
```
或者干脆:
```
error: certificate is not yet valid
```
`Verify return code: 9` 对应的就是 X509_V_ERR_NOT_YET_VALID,意思是「证书尚未生效」。这几乎 100% 意味着**某台机器的时间比真实时间慢**,导致证书看起来还没到生效时刻。
### 1.2 Kerberos 认证 "Clock skew too great"
另一个高频事故是内部系统用 Kerberos 做 SSO,某天突然批量报:
```
kinit: krb5_get_credentials: Clock skew too great (37) - PROCESS_TGS
```
Kerberos 协议本身就是基于时间戳的。客户端向 KDC 请求票据时,会带上自己的时间戳,KDC 校验这个时间戳与自身时间之差是否超过默认 **300 秒(5 分钟)** 的容差。一旦客户端与域控时间差超过 5 分钟,认证直接拒绝。
有一次我排查一台老旧的日志服务器,发现它的时间比域控慢了整整 40 分钟。原因很讽刺——这台机器 NTP 服务压根没跑,靠 RTC 电池苟着,主板电池快没电了,一断电时间就回退。
### 1.3 日志时间错乱与监控数据错位
第三类症状最隐蔽:**日志和监控数据时间对不上**。应用日志时间戳由 A 机生成,采集端打上 B 机的时间,存储端又打上 C 机的时间,三台机器时间各差几秒到几分钟,导致:
- 故障排查时,日志顺序颠倒,同一请求的日志散落在错误的时间窗;
- [Prometheus](https://prometheus.io/docs/) 里 `rate()`、`increase()` 这类按时间窗计算的函数,在时间戳回跳时产生负数或锯齿;
- 告警规则的 `for: 5m` 持续判定逻辑,因时间错位被反复触发或漏触发;
- 分布式数据库(etcd、Cassandra、TiKV)的线性一致性依赖时间戳,时钟大幅跳变会破坏数据一致性。
**结论先行**:时间同步不是「锦上添花」,而是所有依赖时间戳的协议、证书、分布式系统、监控告警的**地基**。地基歪了,上面全塌。
## 二、60 秒决策:chrony vs ntpd vs [systemd](https://www.freedesktop.org/software/systemd/man/latest/)-timesyncd
选型之前,先搞清楚手上这台机器在同步链里的角色:是**只做客户端**,还是**要当 NTP 服务端**给内网其他机器供时?
| 特性 | chrony | ntpd | systemd-timesyncd |
|------|--------|------|-------------------|
| 定位 | 完整 NTP 客户端+服务端 | 传统 NTP 客户端+服务端 | 轻量客户端,**只能当客户端** |
| 服务端能力 | ✅ 可做服务端 | ✅ 可做服务端 | ❌ 无服务端功能 |
| 高延迟/高抖动网络 | ✅ 最稳,支持 NTP over Unix socket | ⚠️ 容易丢同步源 | ⚠️ 依赖网络质量 |
| 启动收敛速度 | 快,秒级可同步 | 慢,分钟级起步 | 较快 |
| 一步跳变(makestep) | ✅ 原生支持 | ⚠️ 需 `-x` 或配置 | ✅ 支持 |
| 硬件时间戳 | ✅ 支持 | ✅ 支持 | ❌ 不支持 |
| 虚拟化环境 | ✅ 最佳选择 | ⚠️ 在 VM 里容易漂 | ✅ 可用 |
| 配置文件 | /etc/chrony/chrony.conf | /etc/ntp.conf | /etc/systemd/timesyncd.conf |
| 控制工具 | chronyc | ntpq | timedatectl |
| 默认安装 | 现代发行版(RHEL8+/Debian10+) | 老旧系统 | systemd 自带 |
**我的选型结论**:
1. **新装系统一律 chrony**。RHEL/CentOS 8 之后、Debian 10 之后、Ubuntu 18.04+ 都默认它。它对高延迟、高抖动的网络(比如跨境、跨运营商)抗性最好,收敛快,还支持一步校正。
2. **老系统(CentOS 6、RHEL 7 默认装 ntpd)** 如果迁移成本高,可以保留 ntpd,但要记得它默认**不跳变**,只做 slewing(渐进微调),漂移严重时收敛极慢。
3. **最小化容器、嵌入式、无状态节点** 用 systemd-timesyncd 足够,因为它只当客户端。但**千万别指望它给内网其他机器供时**。
一句话:**能上 chrony 就上 chrony,需要做 NTP 服务端必须 chrony 或 ntpd,systemd-timesyncd 永远只能当客户端**。
## 三、前置:确认时区与 RTC
装任何 NTP 软件之前,先确认两件事:**时区对不对**、**硬件时钟(RTC)模式对不对**。时区错了,同步得再准,显示出来的时间也是错的。
### 3.1 timedatectl 三行解读
`timedatectl` 不带参数,输出三块核心信息,一次看全:
```bash
timedatectl
```
期望输出(以一台阿里云 ECS 为例):
```
Local time: 三 2026-10-28 10:15:32 CST
Universal time: 三 2026-10-28 02:15:32 UTC
RTC time: 三 2026-10-28 02:15:32
Time zone: Asia/Shanghai (CST, +0800)
System clock synchronized: yes
NTP service: active
RTC in local TZ: no
```
逐行解读:
- **Local time / Universal time**:本地时间与 UTC 时间。两者相差正是时区偏移,这里 +0800。
- **RTC time**:硬件时钟(CMOS/主板电池)当前时间。正常情况应等于 Universal time(UTC)。
- **Time zone**:时区,必须是你业务所在时区。
- **System clock synchronized: yes**:系统时钟已被同步过。这是关键指标。
- **NTP service: active**:NTP 服务是否在跑。看到 `inactive` 说明同步根本没启动。
- **RTC in local TZ: no**:RTC 是否使用本地时区。生产环境**必须是 no**,让 RTC 跑 UTC,否则跨时区、跨 DST 会乱。
### 3.2 硬件时钟 vs 系统时钟
Linux 有两个「时钟」:
- **系统时钟(System Clock / kernel clock)**:内核维护的软件时钟,精度高,靠 NTP 持续校准,关机就没了。
- **硬件时钟(RTC / CMOS clock)**:主板电池供电的时钟,关机后靠电池走时,精度差,开机时被读入作为系统时钟的初始值。
开机流程是:BIOS/UEFI 读 RTC → 内核据此初始化系统时钟 → NTP 接手后持续校准系统时钟 → 定期把校准后的时间写回 RTC(chrony 的 `rtcsync` 干这事)。
### 3.3 设置时区与 UTC
生产环境推荐**时区用 Asia/Shanghai,RTC 用 UTC**。一条命令设置时区:
```bash
timedatectl set-timezone Asia/Shanghai
```
让 RTC 跑 UTC(生产必须这样,别学一些老教程把 RTC 设成本地时区):
```bash
timedatectl set-local-rtc 0
```
`set-local-rtc 0` 表示 RTC 使用 UTC,`timedatectl` 里会显示 `RTC in local TZ: no`。
> ⚠️ 注意:设置时区后,`/etc/localtime` 会被指向 `/usr/share/zoneinfo/Asia/Shanghai`。如果发现手动改过 `/etc/localtime`(比如直接 `cp` 了某个 zoneinfo),建议用 `timedatectl` 规范操作,避免时区数据库和系统时区不一致。
时区数据校验,看本地时间是否与预期一致:
```bash
date && timedatectl show -p Timezone --value
```
期望输出:
```
三 10月 28 10:15:32 CST 2026
Asia/Shanghai
```
## 四、chrony 配置实战:完整可复制模板
### 4.1 安装
RHEL/CentOS 系:
```bash
yum install -y chrony
systemctl enable --now chronyd
```
Debian/Ubuntu 系:
```bash
apt install -y chrony
systemctl enable --now chrony
```
确认版本:
```bash
chronyd --version
```
期望输出:
```
chronyd 4.3
```
### 4.2 完整 chrony.conf 模板
以下是**生产环境可直接复制**的 `/etc/chrony/chrony.conf`(RHEL/Debian 路径略有差异,Debian 是 `/etc/chrony/chrony.conf`,老版本在 `/etc/chrony.conf`,以实际为准)。我逐段注释,每项都是实测参数。
```conf
# ============================================================
# 生产环境 chrony 配置模板
# 适用:内网 NTP 服务端 + 客户端,CentOS/RHEL 7+/Debian 10+
# 作者:一线运维,实测于 2026-10
# ============================================================
# --- 上游时间源:国内主源 + 备用池 ---
server ntp.aliyun.com iburst
server cn.pool.ntp.org iburst
# 可选备用,避免单点
server ntp.tencentcloud.com iburst
server ntp1.aliyun.com iburst
# --- 漂移文件:chrony 据此估算本地时钟的固有漂移率,自动生成,勿手动改 ---
driftfile /var/lib/chrony/drift
# --- 步进校正策略:前 3 次同步若偏移 > 1 秒则一步跳变,之后渐进 slewing ---
# 关键参数,生产必配,详见第七章
makestep 1.0 3
# --- RTC 同步:chrony 定期把系统时间写回硬件时钟,防关机丢时间 ---
rtcsync
# --- 日志目录:chrony 的统计/测量日志(chronyc tracking 用)---
logdir /var/log/chrony
# --- 允许内网网段作为客户端:按实际网段修改,禁止 0.0.0.0/0 ---
# 只允许内网子网同步,不暴露公网
allow 192.168.0.0/24
allow 10.0.0.0/8
# --- 拒绝其他所有来源(默认拒绝,配合上面 allow 白名单)---
deny all
# --- 当无法到达所有上游源时,仍以本地 stratum 10 对外提供时间 ---
# 防止上游全挂后内网完全失去同步能力(仅内网服务端需要)
local stratum 10
# --- 网络绑定:只监听内网接口,最小化暴露面 ---
# 若有多个网卡,建议显式绑定内网 IP,例如 bindaddress 192.168.1.10
# bindaddress 192.168.1.10
# --- 客户端连接数上限,防资源滥用 ---
maxclients 256
# --- 客户端请求速率限制(单位:每秒),防 NTP 放大攻击 ---
ratelimit interval 4 burst 32
# --- 仅监听 UDP 123(NTP 默认端口),无需改 ---
```
### 4.3 关键指令逐条说明
| 指令 | 作用 | 生产建议 |
|------|------|---------|
| `server iburst` | 指定上游源,`iburst` 快速突发采样加速首次同步 | 2-4 个国内源 |
| `driftfile` | 记录时钟漂移率,重启后能快速估算偏移 | 必配 |
| `makestep 1.0 3` | 前 3 次同步偏移 >1s 则跳变,之后 slewing | 生产必配 |
| `rtcsync` | 定期写回硬件时钟 | 必配,防关机丢时 |
| `allow <网段>` | 允许哪些客户端来同步 | 只允许内网 |
| `deny all` | 默认拒绝所有 | 配合 allow 用 |
| `local stratum 10` | 上游全挂时仍提供本地时间 | 内网服务端配 |
| `bindaddress` | 绑定监听地址 | 多网卡建议显式 |
| `maxclients` | 并发客户端上限 | 防资源耗尽 |
| `ratelimit` | 请求速率限制 | 防 DDoS/放大攻击 |
### 4.4 应用配置
改完配置,语法检查 + 重启:
```bash
chronyd -t /etc/chrony/chrony.conf
systemctl restart chronyd
systemctl enable chronyd
```
> `chronyd -t` 是测试模式,会解析配置并打印错误(若有)而不真正启动,适合改配置后先验证。
### 4.5 放行防火墙
NTP 走 UDP 123,内网服务端需要放行。firewalld:
```bash
firewall-cmd --permanent --add-service=ntp
firewall-cmd --reload
```
iptables(如果用的 iptables):
```bash
iptables -A INPUT -p udp --dport 123 -s 192.168.0.0/24 -j ACCEPT
```
## 五、国内网络下的 NTP 源选择
这是国内生产环境最容易被忽略的一环。很多人习惯性抄国外教程写 `server 0.pool.ntp.org`,结果同步质量极差。我的经验是:**在国内机房,选源第一优先级是「地理与网络路径近」,而不是「源本身准不准」。**
### 5.1 推荐源清单
| 源 | 类型 | 延迟实测(华东 BGP 机房) | 评价 |
|----|------|--------------------------|------|
| `ntp.aliyun.com` | 阿里云公共 NTP | 2-15 ms | **首选**,国内多节点,准且快 |
| `ntp1.aliyun.com` | 阿里云备用 | 2-15 ms | 常用备用 |
| `cn.pool.ntp.org` | 中国 NTP 池 | 5-40 ms | **推荐**,国内节点池 |
| `ntp.tencentcloud.com` | 腾讯云公共 NTP | 3-20 ms | 推荐,腾讯内网友好 |
| `ntp.tencent.com` | 腾讯 | 5-30 ms | 可用 |
| `ntp.aliyun.com`(主)+ `cn.pool.ntp.org`(备) | 组合 | — | 我的默认组合 |
| `time.windows.com` | 微软 | 30-80 ms | 一般,仅 Windows 用 |
| `time.nist.gov` | 美国 NIST | 150-300+ ms,抖动大 | **不推荐**,跨境 |
| `pool.ntp.org` | 全球池 | 100-200 ms,抖动大 | **不推荐**,跨境 |
| `time.google.com` | Google | 跨境被墙/高延迟 | **不推荐** |
### 5.2 为什么坚决不用 time.nist.gov / pool.ntp.org
我在一台华东机房服务器上实测过,对比国内源和国外源:
```bash
chronyc sources -v
```
用 `pool.ntp.org` 时,典型输出:
```
===============================================================================
.-- Source remote - | Stratum ... Reach Last Rx | Offset Delay Disp
===============================================================================
^- 162.159.200.1 | 1 | 12% 20m | +38ms 185ms 12ms
^* 162.159.200.123 | 1 | 23% 15m | -52ms 210ms 20ms
```
**Reach(可达性)只有 12%、23%**,说明跨境包大量丢,`Delay`(延迟)180-210ms,`Offset`(偏移)几十毫秒来回跳。这种源对 chrony 来说是灾难——它需要稳定的低抖动输入来估算频率误差,跨境源抖动一上来,同步精度根本没法保证。
换 `ntp.aliyun.com` 后:
```
===============================================================================
.-- Source remote - | Stratum ... Reach Last Rx | Offset Delay Disp
===============================================================================
^* 120.25.115.20 | 2 | 100% 1s | +0.2ms 6ms 0.3ms
^- 182.92.12.11 | 2 | 100% 1s | -0.4ms 9ms 0.5ms
```
**Reach 100%,Delay 6-9ms,Offset 0.2ms 级别**。这才是国内该有的同步质量。
> 经验法则:**Delay 超过 50ms 或 Reach 经常低于 50%,这个源就该换掉**。国内机房用国内源,延迟个位数毫秒才是正常的。
### 5.3 源选择原则总结
1. **优先就近**:华东选阿里云/腾讯云华东节点,华北选华北节点,优先内网或同运营商。
2. **至少配 2 个源**:一个主源 + 一个备用,避免单点。别只配一个。
3. **不同运营商各来一个**:跨运营商互访质量差,最好选同运营商或 BGP 任播源(阿里云、腾讯云都是 BGP)。
4. **内网若有更高级别的 NTP 服务器**,直接指向它,别让所有服务器都打公网。
5. **不要用 IP 直接写 server**,除非有特殊原因;域名解析失败时 chrony 会重试,问题不大。
## 六、验证与监控:确认时间真的同步了
配完不是终点,**必须验证**。很多「配了不生效」的案例,都卡在验证这步。
### 6.1 chronyc sources -v:看上游源状态
```bash
chronyc sources -v
```
这是最核心的验证命令。关注 `Reach`(可达性,100% 最好)、`Offset`(偏移,越小越好)、`Delay`(延迟)。
### 6.2 chronyc tracking:看同步详情
```bash
chronyc tracking
```
期望输出(同步正常时):
```
Reference ID : C633DC14 (120.51.220.20)
Stratum : 2
Ref time (UTC) : Wed Oct 28 02:15:32 2026
System time : 0.000000123 seconds fast of NTP time
Last offset : +0.000123456 seconds
RMS offset : 0.000234567 seconds
Frequency : 4.567 ppm
Residual freq : +0.012 ppm
Skew : 0.234 ppm
Root delay : 0.005678 seconds
Root dispersion : 0.001234 seconds
Update interval : 64.2 seconds
Leap status : Normal
```
关键字段解读:
- **Stratum 2**:表示这台机器离权威时间源还有 2 级,正常。stratum 越大,时间越「间接」。
- **System time ... fast/slow of NTP time**:当前系统时钟相对 NTP 时间的偏差。**这是最关心的指标**,正常应在微秒到毫秒级。
- **Last offset / RMS offset**:最近一次与平均偏移,越小越好。
- **Frequency ... ppm**:本地时钟漂移率,单位 ppm(百万分之一)。几 ppm 正常,若长期漂到几百 ppm,说明主板晶振老化或虚拟化时钟有问题。
- **Leap status: Normal**:闰秒状态,Normal 表示正常。
### 6.3 chronyc makestep:立即强制同步
想立刻做一次同步(不等下次轮询):
```bash
chronyc makestep
```
期望输出:
```
200 OK
```
强制做一次跳变校正后,再 `chronyc tracking` 确认偏移归零。
### 6.4 用 chronyc 手动加/删源
临时测试某个源能不能用(不重启服务,即时生效):
```bash
chronyc add server ntp.aliyun.com iburst
chronyc delete server 0.pool.ntp.org
```
### 6.5 timedatectl 二次确认
```bash
timedatectl
```
确认 `System clock synchronized: yes` 且 `NTP service: active`。如果 `System clock synchronized: no`,说明 chronyd 还没完成首次同步。
### 6.6 ntpd 的验证(老系统)
如果还在用 ntpd:
```bash
ntpq -p
```
期望输出:
```
remote refid st t when poll reach delay offset jitter
==============================================================================
*120.25.115.20 120.25.115.20 2 u 62 128 377 6.234 0.123 0.456
+182.92.12.11 182.92.12.11 2 u 45 128 377 9.876 -0.234 0.321
```
`*` 表示当前选中的同步源,`reach` 377(八进制,377=0xFF,全可达),`offset` 越小越好。
### 6.7 时间偏移监控:接入 Prometheus
生产环境光靠人去看不够,要把时间偏移打进监控。两个思路:
**思路一:用 node_exporter 的 time 指标。** node_exporter 自带两个时间相关指标:
```bash
node_time_seconds
node_time_seconds_seconds
```
`node_time_seconds` 是系统时钟的 Unix 时间戳,`node_time_seconds_seconds`(较新版本为 `node_time_clock_seconds`)是同一时刻读取的 `clock_gettime`。两者差值即偏移。但更直接的是加一个自定义 exporter。
**思路二:写个简单脚本,暴露 offset 到 Prometheus。** 用 chronyc 拿偏移,输出为 Prometheus 格式:
```bash
cat > /usr/local/bin/time_offset_exporter.sh <<'EOF'
#!/bin/bash
# 从 chronyc tracking 提取 System time 偏移,输出 Prometheus 指标
offset=$(chronyc tracking | awk -F'[ :]' '/System time/{print $4}')
echo "# HELP time_offset_seconds System clock offset from NTP time in seconds"
echo "# TYPE time_offset_seconds gauge"
echo "time_offset_seconds $offset"
EOF
chmod +x /usr/local/bin/time_offset_exporter.sh
```
配合 node_exporter 的 `--collector.textfile.directory` 定时执行,或直接用 `chrony_exporter`(社区有现成的 Prometheus exporter,直接部署即可)。
**我实际用的告警规则**(Prometheus):
```yaml
groups:
- name: time-sync
rules:
- alert: SystemClockOffsetHigh
expr: abs(time_offset_seconds) > 0.1
for: 5m
labels:
severity: warning
annotations:
summary: "系统时钟偏移超 100ms: {{ $labels.instance }}"
- alert: SystemClockOffsetCritical
expr: abs(time_offset_seconds) > 1
for: 5m
labels:
severity: critical
annotations:
summary: "系统时钟偏移超 1s: {{ $labels.instance }}"
```
**0.1s(100ms)告警 warning,1s 告警 critical**,这是我多年运维总结的阈值。因为一旦超过 1 秒,TLS/Kerberos/分布式一致性的风险就开始显现了。
## 七、时钟跳变 vs 渐进调整:makestep 的核心语义
这是很多运维没吃透、又最容易踩雷的点。理解它,你才知道为什么生产环境要避免大步跳变。
### 7.1 两种校正方式
时间同步校正系统时钟,有两种方式:
1. **跳变(Step)**:直接把时钟「拨」到正确值。一瞬间时间跳了一大截,比如快 5 分钟就立刻回拨 5 分钟。
2. **渐进调整(Slewing / slew)**:通过微调内核时钟频率,让时间**慢慢逼近**真实值,比如每秒调整一点点,几分钟到几十分钟内平滑收敛。时间永远向前走,只是快慢有微小变化。
### 7.2 为什么生产要避免大步跳变
跳变带来的问题,全是生产级的:
- **[K8s](https://kubernetes.io/docs/) 证书**:etcd、API Server、kubelet 之间基于时间戳签发/校验证书。时间向后跳(回拨)会让「刚签的证书」看起来还没生效,或「已签的证书」看起来已过期,引发集群证书风暴。
- **监控告警风暴**:时间回拨会让 Prometheus 的 `rate()`、`increase()` 在回拨窗口内产生负数、NaN 或突变,触发大量误告警。
- **数据库复制**:MySQL/PostgreSQL 的复制、etcd 的 raft 心跳、TiKV 的 TSO 都依赖时间单调性。时间回拨会破坏主从复制判定,甚至引发复制中断。
- **日志排序**:时间回拨会让新日志的时间戳小于旧日志,ELK/ClickHouse 里排序错乱,排查困难。
所以核心原则是:**已经运行稳定、偏移小的系统,只用渐进 slewing,绝不做大跳变;只有刚开机、偏移巨大、或从未同步过的系统,才允许做跳变。**
### 7.3 makestep 1.0 3 的精确语义
`makestep `:
- **threshold**:跳变阈值(秒)。偏移**超过**这个值才跳变,小于等于这个值用 slewing。
- **limit**:在前多少次同步更新中允许跳变。超过这个次数后,即使偏移很大也只 slewing,不再跳变。
所以 `makestep 1.0 3` 的完整含义是:
> **在 chronyd 启动后的前 3 次时钟更新中,若偏移超过 1 秒,就一步跳变校正;第 3 次之后,无论偏移多大,都只做渐进 slewing,绝不跳变。**
这正好满足生产需求:开机时机器时间可能乱(RTC 没电、VM 快照恢复),允许前几次跳变快速校准;运行稳定后(已过 3 次更新),哪怕漂了 2 秒也慢慢 slewing 回去,不引发跳变风暴。
**反例**:`makestep 0 -1`(或很多教程里的 `makestep 1.0 -1`)表示**永远允许跳变**。我强烈不建议在生产这么配,因为一旦某次网络异常后偏移累积到几秒,chrony 会直接跳变,瞬间回拨几秒,上面那堆灾难全来了。
### 7.4 ntpd 的对照
老 ntpd 默认**不跳变**,只 slewing。这意味着如果偏移很大(比如 30 分钟),它会以极慢速度(几 ppm 调整)收敛,可能要好几个小时。所以老系统若发现时间偏得离谱,要么等它慢慢收敛,要么手动:
```bash
# ntpd 停止后手动跳变
service ntpd stop
ntpdate -s ntp.aliyun.com
service ntpd start
```
(`ntpdate` 在新系统已废弃,老系统可用。)
## 八、虚拟化与容器:两个时间同步大坑
我在这上面栽过不止一次。虚拟化和容器的时钟行为跟物理机完全不一样,很多「时间越同步越不准」的诡异案例都出自这里。
### 8.1 VM 里的时钟漂移:KVM 与 [VMware](https://docs.vmware.com/en/VMware-vSphere/)
虚拟机的时间有两个来源在打架:
1. **虚拟化平台提供的虚拟时钟**(KVM 的 `kvm-clock`、VMware 的虚拟 RTC);
2. **VM 内部运行的 NTP 服务**(chrony/ntpd)。
问题在于:**如果虚拟化平台本身的时间不准,或者宿主与 VM 时间不同步,VM 里的 NTP 会一直跟一个「不准的虚拟时钟」较劲**,表现就是 offset 老是降不下来,或者同步了又飘走。
KVM 场景,确认是否用了 kvm-clock(这是 KVM 里精度最好的时钟源):
```bash
cat /sys/devices/system/clocksource/clocksource0/current_clocksource
```
期望输出:
```
kvm-clock
```
如果输出是 `tsc` 或其他,说明没启用 kvm-clock,虚拟时钟精度差,建议在 KVM 层面启用(`-machine accel=kvm` + 开启 `kvm-clock` 通常默认启用)。
**我的经验**:
- **VM 内跑 chrony 完全正常且必要**,前提是虚拟化宿主时间准。VM 的虚拟时钟漂移率通常比物理机大,反而更需要 chrony 持续校准。
- **别让 VM 的 chrony 去跟宿主的某个「不准的虚拟 RTC」同步**,要让它跟真实网络 NTP 源(或宿主里准的 NTP 服务)同步。
- **VMware**:如果 VM 漂移严重,可在 VM 里装 `open-vm-tools`,并用 chrony 配合,别同时依赖 VMware 的「同步客户机时间到主机」选项——二选一,我推荐只用 VM 内 chrony。
- **快照恢复后时间跳变**:VM 从快照恢复,RTC 和系统时间可能回退到快照时刻。这时候 makestep 的前 3 次跳变机制就能派上用场——恢复后 chrony 检测到偏移巨大,直接跳变到正确时间。
### 8.2 容器内的时间:共享宿主,改不动
**容器共享宿主机内核时钟**,容器里没有独立的时钟。这意味着:
- 容器内执行 `date -s` 会**失败**(权限拒绝)或**无效**,因为改的是宿主时钟,且通常被 seccomp/capability 拦住:
```bash
docker exec -it myapp date -s "2026-10-28 10:00:00"
# 报错:date: cannot set date: Operation not permitted
```
- **容器内不需要也不能跑自己的 chronyd/ntpd**——它没有权限调整时钟,跑了也白跑。容器的时间跟随宿主机。
- 所以容器的正确做法是:**保证宿主机时间准**,容器时间自然准。
- 有个特例:**privileged 容器** 或 `--cap-add SYS_TIME` 的容器可以改时钟,但这等于改宿主时钟,极其危险,生产禁用。
- [Docker](https://docs.docker.com/) 的 `--clock-...` 相关选项(若有)也不会给容器独立时钟。
**结论**:容器时间 = 宿主机时间。要管容器时间,先管宿主机。别在容器里折腾 chrony。
## 九、NTP 安全:最小化暴露面
NTP 服务(UDP 123)历史上出过多次放大攻击漏洞(如 2014 年的 monlist 反射放大,单包放大几百倍),所以生产环境必须收敛暴露面。
### 9.1 只监听内网
多网卡机器,用 `bindaddress` 只绑定内网 IP,别监听 0.0.0.0:
```conf
bindaddress 192.168.1.10
```
### 9.2 白名单 allow / 默认 deny
```conf
# 只允许内网子网
allow 192.168.0.0/24
allow 10.0.0.0/8
# 其余一律拒绝
deny all
```
### 9.3 请求速率限制
`ratelimit` 防 NTP 放大/洪泛:
```conf
ratelimit interval 4 burst 32
```
含义:每 4 秒最多处理一个客户端的一批(最多 32 个)请求,超出的丢弃。
### 9.4 认证:symmetric key(可选)
如果内网有不可信网段,或想防中间人篡改 NTP 包,可启用对称密钥认证。生成密钥:
```bash
chronyc keygen 0 SHA1 > /etc/chrony/chrony.keys
```
配置(服务端和客户端都要配):
```conf
keyfile /etc/chrony/chrony.keys
server 192.168.1.10 key 1
```
`chronyc keygen` 会生成形如 `1 SHA1 hexstring` 的密钥行,`key 1` 表示用编号 1 的密钥。
> 注意:NTP 对称密钥认证不是加密,是「认证」,用来确认包来源可信,防篡改。内网环境如果网络可信,通常不用配;跨不可信网络建议配。
### 9.5 禁用 monlist(chrony 默认无此漏洞)
老 ntpd 的 `monlist` 查询命令曾导致反射放大,chrony 默认不开放这个,无需额外处理。但**别在公网暴露 UDP 123**——防火墙层面只允许内网来源访问 NTP 端口:
```bash
firewall-cmd --permanent --add-rich-rule='rule family=ipv4 source address=192.168.0.0/24 port port=123 protocol=udp accept'
firewall-cmd --permanent --remove-service=ntp # 移除全放行的 ntp 服务,改用更精确的规则
firewall-cmd --reload
```
## 十、5 个真实故障排查案例
以下案例都来自我或我带的团队在真实生产环境遇到并解决的,细节做了脱敏,但故障现象、排查路径、根因完全真实。
### 案例 A:TLS 报 "certificate is not yet valid"
**现象**:某对外 HTTPS 站点突然全部报 `certificate is not yet valid`,浏览器红屏,curl 也报错:
```bash
curl -v https://api.example.com
# 输出末尾:
# SSL: certificate verification failed (result: 9, error: certificate is not yet valid)
```
**排查**:
1. 先看证书本身:`openssl x509 -in cert.pem -noout -dates`,确认 notBefore/notAfter 都在合理范围。证书没问题。
2. 看服务器时间:`date`,发现**系统时间比真实时间快了 5 分钟**。
3. 看是谁让它快了的:`timedatectl`,`NTP service: inactive`——这台机器的 chronyd 根本没跑。
**根因**:这台服务器是刚克隆出来的 VM,克隆时把「源 VM 的时间」带过来了,而源 VM 时间偏快;更关键的是**新 VM 没启用 NTP 服务**,导致时间一直快着。服务器时间快 5 分钟,对客户端而言,请求到达时「服务器本地时间」已越过证书的某些校验窗口,或客户端把服务器时间当作参考,导致认为证书「尚未生效」。
**解决**:
```bash
systemctl enable --now chronyd
chronyc makestep
date
```
跳变校正后,时间归位,HTTPS 恢复正常。
**教训**:**克隆 VM 后第一件事就是确认 NTP 服务在跑**,并强制 makestep 一次。
### 案例 B:Kerberos 认证失败 "Clock skew too great"
**现象**:内网一套基于 Kerberos 的 BI 系统,某天早上用户批量无法登录,日志大量报:
```
krb5_get_credentials: Clock skew too great (37) - PROCESS_TGS
```
**排查**:
1. 客户端和 KDC 各看时间:`date`。发现一台客户端服务器(日志服务器)时间比 KDC **慢了 40 分钟**。
2. 看这台机器的 NTP:`chronyc tracking`,报 `Unable to obtain synchronization from source`——**chrony 根本没同步上**。
3. 查原因:`chronyc sources -v`,所有源 `Reach 0%`;`ping ntp.aliyun.com` 不通;查路由,这台机器**被防火墙拦了 UDP 123 出方向**。
**根因**:防火墙策略变更时,误拦了这台日志服务器到 NTP 源的 UDP 123 出站流量。它无法同步,靠 RTC 漂着,一星期下来慢了 40 分钟,超过 Kerberos 300 秒容差,认证全挂。
**解决**:放行防火墙 UDP 123 出站:
```bash
firewall-cmd --permanent --add-rich-rule='rule family=ipv4 destination address=120.25.115.20 port port=123 protocol=udp accept'
firewall-cmd --reload
chronyc makestep
```
**教训**:防火墙变更务必检查 NTP 出站。**Kerberos 容差只有 5 分钟**,比 TLS 更敏感。
### 案例 C:K8s 集群证书签名时间异常
**现象**:K8s 集群某节点突然被判定 NotReady,`kubectl get nodes` 显示该节点 `Ready `,`kubelet` 日志报证书相关错误:
```
x509: certificate signed by unknown authority
```
或 etcd 报:
```
rafthttp: request was sent to an unavailable peer
```
**排查**:
1. 看 etcd/API Server 所在机器时间:`date`。发现 **etcd 节点时间比 API Server 慢了 3 秒**,且漂移在持续增大。
2. `chronyc tracking`:etcd 节点 `Frequency` 高达 **+450 ppm**,严重偏离正常值(几 ppm),说明这个 VM 的虚拟时钟漂移极大。
3. `cat /sys/devices/system/clocksource/clocksource0/current_clocksource`:输出 `tsc`,**没启用 kvm-clock**。
**根因**:KVM 里没启用 kvm-clock,虚拟时钟精度差;etcd 节点上的 chrony 虽然跑了,但持续 slewing 追不上漂移,时间偏移累积到 3 秒。etcd 集群基于时间戳做 leader 选举和 raft 心跳,时间差一拉大,健康检查判定混乱,节点被标记 NotReady。
**解决**:
1. 宿主机层面启用 kvm-clock(修改 VM 配置,`-machine accel=kvm` 并启用 kvm-clock)。
2. 在 etcd 节点上强制跳变校准:`chronyc makestep`。
3. 检查 kubelet 证书重新签发。
**教训**:**K8s 集群节点务必保证时间高度一致**,偏差超过 1 秒就可能引发 etcd 选举异常。虚拟化环境务必启用虚拟时钟(kvm-clock)。
### 案例 D:监控数据错位
**现象**:Prometheus 告警规则 `for: 5m` 经常误触发,或者明明该告警了却没告;[Grafana](https://grafana.com/docs/) 图表上的时间轴和日志时间对不上;`rate()` 曲线出现突然的负值。
**排查**:
1. 对比采集端、Prometheus 存储端、节点导出器所在机器时间:分别在各自机器 `date +%s`。
2. 发现 **采集端(node_exporter 所在机器)比 Prometheus 快了 1.2 秒**。
3. 查 `node_time_seconds` 与 `time()`(Prometheus 查询时)之差:`query: time() - node_time_seconds`,结果持续 ~1.2。
**根因**:node_exporter 所在机器 chrony 未正确配置,只配了一个 `server pool.ntp.org`,跨境源抖动大,chrony 判定源不可靠,一直没锁上,机器时间缓慢漂移了 1.2 秒。虽然 1.2 秒看似不大,但对 `for: 5m` 这种时间窗判定和 rate 计算已经足够产生误告警和图表错位。
**解决**:
```bash
# 换国内源
sed -i 's/^server pool.ntp.org/server ntp.aliyun.com iburst/' /etc/chrony/chrony.conf
systemctl restart chronyd
chronyc makestep
```
**教训**:**监控体系的每台机器时间都必须准**,1 秒的偏差就会让告警规则失真。node_exporter 的 `time_seconds` 与 `time_offset_seconds` 要进告警。
### 案例 E:systemd-timesyncd 不工作
**现象**:一台 Ubuntu 22.04 最小化服务器,`timedatectl` 显示 `NTP service: inactive`,时间一直不准,且没有安装 chrony。
**排查**:
1. `timedatectl status`:`NTP service: inactive`,`System clock synchronized: no`。
2. `systemctl status systemd-timesyncd`:`inactive (dead)`。
3. 看配置:`cat /etc/systemd/timesyncd.conf`,发现 `#NTP=` 和 `#FallbackNTP=` 都被注释掉了——用的是默认值。默认 FallbackNTP 是 `0.debian.pool.ntp.org` 等**国外池**,在这台机器上 DNS 解析到国外,**跨境不可达**,导致 timesyncd 一直连不上源,服务反复失败后停掉。
4. 手动测:`systemd-resolve --status | grep -i ntp` 或 `ntpdate -q ntp.aliyun.com` 测源可达性。
**根因**:systemd-timesyncd 默认用 Debian/Ubuntu 的 pool.ntp.org(国外),国内网络不可达;且 `NTP=` 未显式配置,FallbackNTP 也是国外池。服务连不上源,就持续失败。
**解决**:显式配国内源。
```bash
cat > /etc/systemd/timesyncd.conf <<'EOF'
[Time]
NTP=ntp.aliyun.com cn.pool.ntp.org
FallbackNTP=ntp.tencentcloud.com ntp1.aliyun.com
EOF
systemctl restart systemd-timesyncd
timedatectl set-ntp true
timedatectl
```
**教训**:**systemd-timesyncd 用之前必须显式配 `NTP=`**,别依赖默认的国外 FallbackNTP。国内环境一定要指向国内源。
## 十一、排障决策树
把上面所有案例的排查路径收敛成一棵决策树,遇到时间问题照着走:
```
开始:现象是 TLS/Kerberos/监控/日志时间错乱
│
├─ 1. timedatectl
│ ├─ NTP service: inactive → 看步骤 2(服务没跑)
│ ├─ NTP service: active 但 System clock synchronized: no → 看步骤 3
│ ├─ System clock synchronized: yes → 看步骤 4
│ └─ RTC in local TZ: yes → 执行 timedatectl set-local-rtc 0(改回 UTC)
│
├─ 2. NTP 服务没跑
│ ├─ chrony → systemctl enable --now chronyd
│ ├─ ntpd → systemctl enable --now ntpd
│ └─ timesyncd → systemctl enable --now systemd-timesyncd
│
├─ 3. 服务在跑但没同步上
│ ├─ chronyc sources -v
│ │ ├─ 所有源 Reach 0% → 源不可达,看步骤 5
│ │ ├─ Reach 有值但 Offset 大 → 源质量差,换源(步骤 6)
│ │ └─ 有源被选中(*)但 Offset 仍大 → 检查 makestep/是否在 slewing
│ └─ 若是 ntpd → ntpq -p 同样分析
│
├─ 4. 已同步但 offset 还是大
│ ├─ chronyc tracking → 看 Frequency ppm 是否异常大(>100)
│ ├─ 虚拟化环境 → 检查 /sys/devices/system/clocksource/.../current_clocksource
│ │ └─ 不是 kvm-clock → 启用虚拟时钟(KVM)
│ └─ 硬件时钟坏 → 看步骤 7
│
├─ 5. 源不可达
│ ├─ ping 源域名 → 不通:DNS 解析问题,检查 /etc/resolv.conf
│ ├─ ping 源 IP → 通:域名正常,是 UDP 123 被拦 → 检查防火墙/安全组
│ ├─ nc -uvz 源IP 123 → 不通:UDP 123 被拦
│ └─ 出站被拦 → 防火墙放行 UDP 123 出站
│
├─ 6. 源质量差(高 Delay/低 Reach)
│ ├─ 换国内源 ntp.aliyun.com / cn.pool.ntp.org
│ └─ 重启 chronyd + chronyc makestep
│
└─ 7. 硬件时钟坏(RTC 电池没电/主板故障)
├─ 现象:每次断电重启时间都回到 2000 年或乱跳
├─ 检查 RTC 电池(纽扣电池电压)
├─ 更换主板电池
└─ 重启后用 chronyc makestep 立即校准
```
## 十二、15 条命令速查 Cheat Sheet
| # | 命令 | 用途 |
|---|------|------|
| 1 | `timedatectl` | 查看时区、RTC、NTP 状态三合一 |
| 2 | `timedatectl set-timezone Asia/Shanghai` | 设置时区 |
| 3 | `timedatectl set-local-rtc 0` | RTC 用 UTC |
| 4 | `timedatectl set-ntp true` | 启用 NTP 服务 |
| 5 | `chronyc sources -v` | 查看上游源状态(核心) |
| 6 | `chronyc tracking` | 查看同步详情、偏移、漂移率 |
| 7 | `chronyc makestep` | 强制一步跳变校正 |
| 8 | `chronyc add server ntp.aliyun.com iburst` | 临时加源 |
| 9 | `chronyc delete server 0.pool.ntp.org` | 临时删源 |
| 10 | `ntpq -p` | ntpd 查看源(老系统) |
| 11 | `systemctl status chronyd` | 查看 chrony 服务状态 |
| 12 | `chronyd -t /etc/chrony/chrony.conf` | 配置语法测试 |
| 13 | `date +%s` | 快速看当前时间戳 |
| 14 | `hwclock --show` | 查看硬件时钟(RTC) |
| 15 | `timedatectl show -p Timezone --value` | 只输时区名 |
## FAQ
### Q1:时钟漂移会造成哪些生产故障?
**A**:最常见三类。第一,**TLS 证书**报 `certificate is not yet valid`(服务器时间偏快或偏慢导致证书校验窗口错位);第二,**Kerberos 认证**报 `Clock skew too great`,因为 Kerberos 容差只有 300 秒(5 分钟),超了就拒绝;第三,**监控与日志时间错位**,导致告警规则失真、`rate()` 计算异常、日志排序错乱。此外分布式系统(etcd、数据库复制)依赖时间戳,时钟跳变会破坏一致性。
### Q2:chrony 和 ntpd 怎么选?
**A**:新系统(RHEL 8+/Debian 10+)首选 chrony。它对高延迟、高抖动网络更稳,收敛快,启动秒级可同步,原生支持 `makestep` 一步校正,还能当 NTP 服务端。ntpd 是传统方案,启动慢、收敛慢,适合老系统。systemd-timesyncd 最轻量,但**只能当客户端,不能当服务端**,适合最小化容器/无状态节点。
### Q3:为什么不用 time.nist.gov 或 pool.ntp.org?
**A**:这些是国外源,国内机房访问要跨境,实测延迟 150-300ms 且抖动大,chrony 的 `Reach` 常低于 50%,同步质量差。国内生产环境应优先 `ntp.aliyun.com`、`cn.pool.ntp.org`、`ntp.tencentcloud.com`,延迟个位数毫秒,`Reach 100%`,准确度高得多。
### Q4:makestep 1.0 3 是什么意思?
**A**:`makestep `。含义是:在 chronyd 启动后的**前 3 次**时钟更新中,若偏移**超过 1 秒**,就一步跳变校正;**第 3 次之后**,即使偏移再大也只做渐进 slewing,绝不跳变。这样开机时能快速校准(允许跳变),运行稳定后避免大步跳变引发告警风暴、证书问题、数据库复制异常。
### Q5:容器里能改时间吗?需要跑 chrony 吗?
**A**:容器共享宿主机内核时钟,**没有独立时钟**。容器内 `date -s` 会报 `Operation not permitted`(除非 privileged 或加 `--cap-add SYS_TIME`,那会改宿主时钟,极其危险,生产禁用)。容器**不需要也不能**跑 chrony,时间跟随宿主机。所以正确做法是保证宿主机时间准,容器时间自然准。
### Q6:虚拟机时间老是不准怎么办?
**A**:先确认虚拟时钟。KVM 下 `cat /sys/devices/system/clocksource/clocksource0/current_clocksource` 应为 `kvm-clock`,不是 `tsc`;若不是,在 VM 配置启用 kvm-clock。然后 VM 内跑 chrony 对国内源同步,别依赖虚拟化平台的「同步客户机时间」选项(二选一)。VM 快照恢复后时间会回退,需 `chronyc makestep` 强制校准。
### Q7:NTP 服务怎么防止被滥用/攻击?
**A**:三招。第一,**最小化监听**,多网卡用 `bindaddress` 只绑内网 IP,只监听 UDP 123;第二,**白名单**,`allow 192.168.0.0/24` + `deny all`,只允许内网子网同步;第三,**速率限制**,`ratelimit interval 4 burst 32` 防 NTP 放大攻击。公网防火墙层面只放行内网来源的 UDP 123,绝不在公网暴露 NTP。
### Q8:怎么监控时间偏移?
**A**:用 `chronyc tracking` 看 `System time` 字段的偏移,用 `chrony_exporter` 或自定义 exporter 把它暴露成 Prometheus 指标。我建议告警阈值:偏移超 **100ms(0.1s)** 告 warning,超 **1 秒** 告 critical。同时监控 `Frequency`(漂移率,正常几 ppm,异常超 100 ppm 提示硬件/虚拟时钟问题)。
## 相关文章
- [CDN 原理:回源、预热、缓存失效](cdn-principles.md)
- [dnsmasq 配置深度实战:一份完整模板 + 中文网络下的 4 个真实踩坑](dnsmasq-config.md)
- [iptables 端口转发实战:SNAT/DNAT 四段配置 + 5 个真实故障排查](iptables-port-forwarding.md)
---
## SASE 安全访问服务边缘实战:统一 SD-WAN 与安全策略
URL: https://ops.jiunotes.top/network/sase-security.md
摘要: SASE 是什么、怎么选、怎么部署:SD-WAN 与安全策略合并后的三种落地路径,含 ZTNA vs VPN 决策树、分支加速对比、AWS/阿里云 OnRamp 配置片段。
### Answer
SASE = SD-WAN 网络 + 安全栈(SWG/ZTNA/DLP/SASE FW)统一交付到边缘。三条落地路径:买成熟 SASE 套件(Zscaler/CrowdStrike)、自建 SWG+SD-WAN+IAD、云上部署 Cloud Security Gateway 桥接分支到云上。选型先看分支数量和用户规模——分支少用户多选 ZTNA,分支多用户少选 SD-WAN,两者都多直接上 SASE 套件。ZTNA 取代传统 VPN 的核心区别在身份驱动而非 IP 驱动,零信任网络不是口号而是身份验证贯穿每次请求。
### FAQ
Q: SASE 和零信任有什么区别?
A: SASE 是产品形态(网络+安全的统一交付),零信任是设计理念(永不信任、始终验证)。SASE 可以实现零信任,但零信任也可以用传统方案实现。简单说:SASE 是车,零信任是驾驶规则。
Q: ZTNA 和 VPN 到底该用哪个?
A: 远程办公人员用 ZTNA,内网设备互通用 VPN 或微分段。ZTNA 是身份驱动,VPN 是网络位置驱动。ZTNA 对 SaaS 应用体验更好,VPN 对内网资源控制更细。大多数公司需要两者并存,不能二选一。
Q: SASE 套件太贵怎么办?
A: 三条退路:① 先用开源方案搭 SD-WAN(VyOS/OPNsense)+ 自管 SWG(Squid + Dansguardian)过渡;② 云上 SASE(AWS Network Firewall + Transit Gateway);③ 按分支数量谈厂商价格,小批量采购可以砍价 30% 以上。
Q: SASE 上线后怎么确认安全策略真的在各分支生效了?
A: 别只看控制台配置。要在分支侧实测:放一个故意命中 deny 规则的域名,确认被拦截并落日志;再放一个合规域名确认放行。控制台显示"已下发"不等于边缘节点真的生效了,边缘节点掉线或配置同步失败时,策略是静默失效的。把"每分支策略命中数"做成监控,某分支长期为 0 就该排查。
### Full Content
# SASE 安全访问服务边缘实战:统一 SD-WAN 与安全策略
SASE(Secure Access Service Edge)这个词从 2019 年 Gartner 提出来到现在,厂商炒了快七年,很多团队还在纠结"到底该买还是该自建"。这篇文章不谈概念,直接说三件事:你的场景该走哪条路、ZTNA 和 VPN 怎么分工、分支加速和安全策略怎么不落单。
## SASE 是什么,先说人话
SASE = SD-WAN(网络层)+ 安全栈(SWG / ZTNA / DLP / SASE Firewall)统一交付到一个边缘节点。本质是把"分支走总部出公网"和"用户回家直接走公网"两种方式,换成"所有流量都收敛到云安全网关"。
两种架构的对比很直接:
```
传统架构:
分支 → IPSec → 总部防火墙 → 互联网 / SaaS
↑
安全能力分散在总部
SASE 架构:
分支 → SASE 边缘节点(安全)→ 互联网 / SaaS
用户 → SASE 边缘节点(安全)→ 互联网 / SaaS
↑
网络和安全统一收敛在边缘
```
SASE 的核心价值不是安全更强——是安全策略和转发策略不再打架。传统架构里 IT 和安全各管各的,SD-WAN 说"这路径快",安全说"这条路不能走",两边对不上。SASE 把这两个决策合到一起,策略一次生效。
## 三条落地路径,选哪条取决于你的规模
### 路径一:买成熟 SASE 套件(推荐 50+ 分支 / 1000+ 用户)
代表厂商:Zscaler、CrowdStrike SASE、Palo Alto Prisma Access、Cisco Secure Connect。
这类方案的优点是策略统一、管理界面现成、全球边缘节点覆盖。缺点是贵,且厂商锁定。
**选型决策树**:
```
你有几个分支?
├─ 少于 10 个分支 → 先看路径二(自建轻量方案)
└─ 10 个以上分支
├─ 用户主要访问 SaaS(Office 365 / Google Workspace)→ ZTNA 优先
├─ 用户主要访问内网应用 → SD-WAN 优先
└─ 两者都多 → 直接上 SASE 套件
```
实际案例:一家 80 分支、1200 人的电商公司,上线 Zscaler 后 SaaS 访问延迟从 300ms 降到 80ms,但年度成本从 40 万涨到 90 万。IT 负责人当时的判断是"值",因为排障时间从每周 15 小时降到 2 小时。
### 路径二:自建 SWG + SD-WAN + IAD(适合 10-50 分支、预算有限)
用开源组件拼凑:
```bash
# SD-WAN:VyOS(跑在 x86 小机器或虚拟机上)
# 官网:https://www.vyos.io/
# 分支节点配置示例(简化):
set interfaces ethernet eth0 address 10.0.1.1/24
set interfaces ethernet eth1 address 'pppoe0'
set protocol flow-accounting enable
# SWG:Squid + Dansguardian(开源透明代理)
apt install squid dansguardian
# 配置 /etc/squid/squid.conf 的访问控制
acl safe_urls dstdomain "/etc/squid/safe_urls.txt"
http_access allow safe_urls
http_access deny all
```
这条路径的成本极低,但运维负担不低。安全策略要自己维护,厂商支持要自己承担。**适合有 2-3 个专职网络工程师的团队**。
### 路径三:云上 SASE(AWS / 阿里云 / Azure 原生方案)
如果你已经重度依赖某家云,用云原生方案比买第三方套件便宜且集成度高。
**AWS 示例**:
```bash
# 1. 创建 SASE 边界(AWS Network Firewall)
aws network-firewall create-firewall \
--firewall-name sase-fw \
--subnets subnet-abc1 subnet-def2 subnet-ghi3 \
--vpc-id vpc-12345678
# 2. Transit Gateway 做分支汇聚
aws ec2 create-transit-gateway \
--region us-east-1 \
--amazon-side-asn 64512
# 3. 分支 VPN 连接到 TGW
aws ec2 create-vpn-connection \
--transit-gateway-id tgw-xxxxxx \
--type ipsec.1 \
--static-routes-only \
--vgw-id vgw-yyyyyy
```
阿里云对应的是 SD-WAN 插件 + Cloud Enterprise Network(CEN)+ 云防火墙的组合,逻辑相同。
## ZTNA vs VPN:怎么分工不用吵架
这是落地时最容易扯皮的地方。我的判断很简单:
- **ZTNA 负责远程办公人员访问 SaaS 和应用**——不需要知道内网 IP,只要身份认证通过就能访问指定应用
- **VPN 负责内网设备互访和遗留系统**——某些老系统不支持应用层网关,只能通过 L2/L3 VPN 访问
具体策略:
```yaml
# ZTNA 策略示例(Zscaler 风格)
identity_provider: Okta / Azure AD
default_action: deny-all
permitted_apps:
- app: office365
users: all
- app: internal-wiki
users: ["security-team", "eng-leads"]
- app: dev-k8s-dashboard
users: ["dev-team"]
conditions:
- device_posture: enrolled
- location: not_china
```
```bash
# 传统 VPN 策略(保留给不能用 ZTNA 的系统)
# OpenConnect 服务端配置(OCServ)
port = 443
protocol = ocserv
auth = "plain[try_first_pass]"
dns = 10.0.0.1
mtu = 1400
ipv4-network = 10.10.0.0/24
ipv4-netmask = 255.255.255.0
max-clients = 200
```
**常见错误**:把 VPN 当万能钥匙。某公司所有远程办公都走 VPN,结果内网带宽被占满,SaaS 体验反而比不用 VPN 还差。VPN 是为遗留系统保留的,不是默认选项。
## 分支加速:SD-WAN 的核心价值
SD-WAN 的价值不只是安全,还有一个经常被忽视的点:**分支到互联网的加速**。传统架构下,分支访问互联网要绕回总部出公网,延迟极高。SD-WAN 让分支流量就地出站,SASE 边缘节点负责安全检查。
```
传统:分支 A → 总部(300km)→ 互联网
SD-WAN:分支 A → SASE 边缘(最近)→ 互联网
```
实测数据(某零售连锁,50 个门店):
| 场景 | 传统 WAN | SD-WAN + SASE |
|---|---|---|
| 门店到总部 ERP | 120ms | 85ms(走 MPLS) |
| 门店到 SaaS(办公套件) | 350ms(绕总部) | 60ms(本地出口) |
| 门店到云存储 | 400ms | 55ms |
| 安全策略统一性 | 低(各店各自为政) | 高(云端统一下发) |
## 部署前必做的三件事
### 1. 盘点现有分支网络拓扑
别急着上方案,先把分支列表搞清楚:
```bash
# 收集分支网络信息
for branch in $(cat branches.txt); do
echo "=== $branch ==="
ssh $branch "echo 'LAN:'; ip addr show | grep 'inet '; echo 'WAN:';
ip addr show pppoe0 2>/dev/null | grep 'inet ';
echo 'ISP:'; cat /etc/resolv.conf | head -2"
done > branch-inventory.txt
```
### 2. 确定身份源和 MFA 策略
ZTNA 的前提是有身份系统。没有身份,SASE 就是无源之水。
```
身份源优先级:
① 企业现有 IdP(Azure AD / Okta / Keycloak)
② 如果没有,先在 SASE 平台上建本地目录
③ 必须开 MFA,否则等于没加安全
```
### 3. 灰度上线顺序
```
Phase 1:先上一个分支试点(选网络条件最差的)
Phase 2:确认 SaaS 体验改善后,扩展到 10 个分支
Phase 3:全量切换,保留传统路径作为 fallback 7 天
Phase 4:关闭传统路径,完全走 SASE
```
**失败案例**:某公司 Phase 1 没做,直接全量切换,第二天 20 个分支同时断网,因为 SASE 边缘节点配置有误。恢复用了 6 小时,损失超百万。**永远先试点再全量。**
## Cheat Sheet:常见操作速查
```bash
# 检查分支到 SASE 边缘的连通性
ping -c 5
# 检查安全策略是否生效(Zscaler 示例)
zia api --query "traffic:logs?date=today&policyViolation=true" | jq '.[] | {src_ip, dst_url, policy}'
# 查看 SD-WAN 链路质量
# VyOS 示例
show sdwan interface
show sdwan app-qos statistic
# 检查 ZTNA 连接状态(Zscaler)
zti status
```
## 上线后的巡检与容量纪律
SASE 是全托管收敛在边缘,上线后最大的风险是「边缘节点悄悄掉线/策略静默失效」。我给自己定了三条巡检纪律。
**每分支策略命中数要监控,不能只看控制台。** 控制台上「策略已下发」不等于边缘节点真的在拦截。我把每个分支「deny 规则命中数」打点成 metric,某分支长期 0 命中,要么是分支没连上 SASE 边缘(走了传统 fallback),要么是边缘节点同步失败。这种「策略静默失效」最危险——你以为安全兜底了,其实边缘早断了。上线后我固定每季度做一次「故意命中 deny 规则」的探针测试,在分支侧放一个必被拦的域名,确认它真的被拦并落日志,才算策略真生效。
**边缘节点容量要留水位,防尖峰降级。** 大促或全员在线时流量会瞬时翻倍。我给 SASE 边缘节点设了 CPU/连接数的 70% 告警线,触发了自动扩容(云原生方案)或切部分流量走 fallback 链路。一次压测我发现边缘在 80% 负载时延迟开始劣化,提前把扩容阈值调到 70%,避免了真实大促时的雪崩。
**传统 fallback 路径保留 7 天再拆。** 灰度全量后传统 IPSec 路径不要立刻关。留 7 天作为应急回滚通道,期间如果 SASE 出问题,能秒级把分支切回传统路径。某公司全量后 24 小时就拆了 fallback,随后边缘故障 6 小时没得退路,损失放大。fallback 不是浪费,是保险,等稳定运行一个季度再拆。
## 总结与判断
SASE 不是必选项。如果你的场景是"全部用户在办公室、只有少量远程办公",传统 VPN + 边界防火墙就够。SASE 的价值主要在两种场景:① 分支多且分散;② 用户远程办公比例高且 SaaS 依赖深。
**我的判断**:对于中小型企业(50 分支以下),路径二(自建)或路径三(云上原生)性价比更高。路径一(买套件)适合预算充足、不想养运维团队的公司。不管走哪条路,ZTNA 取代 VPN 是大趋势,越早布局越早受益。
## 延伸阅读
- [SD-WAN 部署指南:混合办公场景选型与部署](sd-wan-deploy.md)
- [零信任网络架构:从概念到企业落地](zero-trust-network.md)
---
---
## SD-WAN 部署实战:混合办公场景选型与配置
URL: https://ops.jiunotes.top/network/sd-wan-deploy.md
摘要: SD-WAN 选型三维度:分支规模、应用类型、预算。含 VyOS 分支配置、AWS Transit Gateway 互联、SaaS 加速策略、以及两个真实翻车案例。
### Answer
SD-WAN 选型看三个维度:分支数量(决定买硬件还是软件)、应用类型(SaaS 多选 ZTNA 集成、内网多选 MPLS)、预算(决定买产品还是自建)。VyOS 是开源首选,AWS Transit Gateway 是云上首选。两个翻车点:① 没做链路质量检测就上 SD-WAN,结果劣化更严重;② SaaS 流量没走本地出口,绕回总部反而更慢。
### FAQ
Q: SD-WAN 和传统 VPN 有什么区别?
A: 传统 VPN 是点到点加密隧道,SD-WAN 是智能路径选择 + 加密 + 安全策略一体。传统 VPN 只看连通性,SD-WAN 看质量(延迟、丢包、抖动)选最优路径。
Q: 分支少(<10 个)还用 SD-WAN 吗?
A: 看场景。如果主要是 SaaS 访问,值得。如果主要是内网互访且分支很近,传统 MPLS 或 Direct Connect 可能更简单。分支少但分布散,SD-WAN 价值最大。
Q: 开源 SD-WAN(VyOS)和商用方案差在哪?
A: 核心差别在策略管理和运维界面。VyOS 配置靠命令行,商用方案有可视化策略编辑器。功能上 VyOS 够用,但大规模部署时运维效率差距明显。
Q: SD-WAN 上线后如何监控和做日常运维?
A: 三件事必做:① 把 SLA 探针(延迟/丢包/抖动)接进 Prometheus + Grafana,对每条路径设告警阈值;② 定期跑链路质量基线脚本对比趋势,劣化超 20% 就排查链路或切换出口;③ 保留策略匹配日志,出间歇性断流时用它定位是哪条规则命中错了。
### Full Content
# SD-WAN 部署实战:混合办公场景选型与配置
SD-WAN 这几年从概念变成标配,但真正部署好的公司不多。原因很简单:选型不对、配置不对、或者压根没想清楚要解决什么问题。这篇文章基于三个真实部署案例,说清楚怎么选、怎么配、怎么避坑。
## 选型三维度:先想清楚再动手
### 维度一:分支规模
| 分支数量 | 推荐方案 | 理由 |
|---|---|---|
| < 10 | 自建 VyOS 或商用轻量方案 | 投入产出比低,自建划算 |
| 10 - 50 | 商用 SD-WAN 或云上方案 | 运维效率优先 |
| 50+ | 商用 SASE 套件 | 统一管理、全球覆盖 |
### 维度二:应用类型
```
你的主要流量是什么?
├─ SaaS 为主(Office 365、Google Workspace、Salesforce)
│ └─ 选带 SaaS 优化的 SD-WAN(本地 breakout)
├─ 内网应用为主(ERP、CRM、私有 API)
│ └─ 选带 MPLS 或 Direct Connect 的 SD-WAN
└─ 混合(50/50)
└─ 选完整 SASE 套件(SD-WAN + ZTNA + SWG)
```
**关键理解**:SaaS 流量走 SD-WAN 本地出口比绕回总部快 3-5 倍。这是 SD-WAN 最直观的价值。
### 维度三:预算
| 方案 | 年成本(50 分支) | 运维成本 | 适用场景 |
|---|---|---|---|
| 自建 VyOS | 硬件 5 万 + 带宽 30 万 | 高(需专职运维) | 有网络团队、预算紧 |
| 商用 SD-WAN | 软件授权 50 万 + 带宽 30 万 | 中(厂商支持) | 标准企业场景 |
| 云上 SASE | 按流量付费 80 万+ | 低(厂商托管) | 多云、全球化 |
## 场景一:自建 VyOS SD-WAN(小团队首选)
VyOS 是基于 Debian 的开源路由器 OS,支持 SD-WAN 策略路由。
### 部署架构
```
┌─────────────┐
│ VyOS Hub │
│ (总部) │
└──────┬──────┘
│ MPLS / 专线
┌────────────┼────────────┐
│ │ │
┌─────▼─────┐ ┌───▼───┐ ┌────▼────┐
│ VyOS Branch1│ │Branch2│ │Branch3 │
└───────────┘ └───────┘ └─────────┘
```
### 分支节点配置
```bash
# VyOS 分支节点配置(branch1.config)
set interfaces ethernet eth0 address '192.168.1.1/24'
set interfaces ethernet eth0 description 'LAN'
set interfaces ethernet eth1 address 'pppoe0'
set interfaces ethernet eth1 description 'WAN-Internet'
set interfaces pppoe pppoe0 address 'dhcp'
set interfaces pppoe pppoe0 credentials login 'pppoe-user'
set interfaces pppoe pppoe0 credentials password 'pppoe-password'
# SD-WAN 策略
set sdwan settings rule 100 description 'SaaS traffic -> local breakout'
set sdwan settings rule 100 match destination address '13.107.42.0/24' # Microsoft
set sdwan settings rule 100 match source address '192.168.1.0/24'
set sdwan settings rule 100 set interface 'eth1'
set sdwan settings rule 200 description 'Internal traffic -> MPLS'
set sdwan settings rule 200 match destination address '10.0.0.0/8'
set sdwan settings rule 200 set interface 'ipsec-branch1'
# IPsec 隧道到总部
set vpn ipsec site-to-site peer hub-vyos left-subnet '192.168.1.0/24'
set vpn ipsec site-to-site peer hub-vyos right-address 'hub.public.ip'
set vpn ipsec site-to-site peer hub-vyos authentication pre-shared-secret 'strong-secret'
```
### 质量检测与 failover
```bash
# VyOS SLA 监测
set sdwan sla monitor probe internet type icmp
set sdwan sla monitor probe internet interval 1000
set sdwan sla monitor probe internet threshold warning 100 5
set sdwan sla monitor probe internet threshold critical 200 10
# 路径切换策略
set sdwan settings rule 300 description 'Failover to MPLS if internet degraded'
set sdwan settings rule 300 match sla monitor 'internet'
set sdwan settings rule 300 match sla condition 'critical'
set sdwan settings rule 300 set interface 'ipsec-branch1'
```
## 场景二:AWS Transit Gateway 互联(云上分支)
适用于分支在 AWS 上,或与 AWS 有高速连接的场景。
```bash
# 创建 Transit Gateway
aws ec2 create-transit-gateway --region us-east-1 \
--amazon-side-asn 64512 \
--description "main-tgw"
# 关联 VPC
aws ec2 associate-transit-gateway-route-table \
--transit-gateway-route-table-id tgw-rtb-xxxxxx \
--transit-gateway-attachment-id tgw-attach-yyyyyy
# 创建 VPN 连接(物理分支)
aws ec2 create-vpn-connection \
--transit-gateway-id tgw-xxxxxx \
--type ipsec.1 \
--static-routes-only \
--bgp-asn 65000 \
--amazon-side-asn 64512
# 导出 VPN 配置给分支路由器
aws ec2 export-vpn-connection \
--vpn-connection-id vpc-vpna-xxxxxx \
--region us-east-1 \
--target-configuration '{"Type":"ipsec.1","IPAddresses":{"CentralGateway":"1.2.3.4"}}'
```
## 场景三:SaaS 本地出口优化
这是 SD-WAN 最值钱的功能。传统架构下,SaaS 流量从分支绕回总部出公网,延迟高且浪费带宽。SD-WAN 让 SaaS 流量就地出站。
```bash
# VyOS SaaS 本地出口配置
# 1. 定义 SaaS 目标地址范围
set policy route SAAS-LOCAL rule 10 description 'Microsoft 365'
set policy route SAAS-LOCAL rule 10 match destination address '20.0.0.0/8'
set policy route SAAS-LOCAL rule 10 set interface 'eth1'
set policy route SAAS-LOCAL rule 20 description 'Google Workspace'
set policy route SAAS-LOCAL rule 20 match destination address '74.125.0.0/16'
set policy route SAAS-LOCAL rule 20 set interface 'eth1'
set policy route SAAS-LOCAL rule 30 description 'Salesforce'
set policy route SAAS-LOCAL rule 30 match destination address '136.147.0.0/16'
set policy route SAAS-LOCAL rule 30 set interface 'eth1'
# 2. 应用策略到接口
set policies route SAAS-LOCAL interface eth0
```
**效果对比**(实测数据):
| 应用 | 传统绕回总部 | SD-WAN 本地出口 |
|---|---|---|
| Office 365 | 280ms | 45ms |
| Salesforce | 320ms | 55ms |
| Google Workspace | 300ms | 50ms |
## 翻车案例一:链路质量检测缺失
某零售连锁 30 个门店上 SD-WAN,没做链路质量检测就直接切流量。结果三家门店的宽带质量差,SD-WAN 把所有 SaaS 流量都推到劣化链路上,用户体验反而更差。
**教训**:上 SD-WAN 前必须做链路质量评估。每个分支至少跑一周的基础测试:
```bash
# 分支链路质量评估脚本
#!/bin/bash
echo "=== Branch Quality Assessment ==="
echo "Latency test to SaaS endpoints..."
for target in "20.0.0.1" "74.125.0.1" "136.147.0.1"; do
ping -c 20 $target | tail -1
done
echo "Jitter test..."
mtr --jitter --no-dns 20.0.0.1
echo "Packet loss test..."
ping -c 100 20.0.0.1 | grep "packet loss"
```
## 翻车案例二:策略冲突导致间歇性断流
某公司 SD-WAN 策略配置了两个冲突的规则:
- Rule 100:所有流量走 MPLS
- Rule 200:SaaS 流量走本地
Rule 优先级设置错误,导致 MPLS 规则先匹配,SaaS 流量被错误地推到 MPLS。
**排查命令**:
```bash
# VyOS 查看 SD-WAN 策略匹配情况
show sdwan settings rule detail
show sdwan policy-statistics
# 检查策略匹配顺序
show policy route SAAS-LOCAL rule
```
**修复**:调整规则优先级,确保更具体的规则(SaaS)优先级高于通用规则(所有流量)。
## Cheat Sheet:常用配置速查
```bash
# 查看 SD-WAN 状态
show sdwan status
# 查看 SLA 监测结果
show sdwan sla monitor
# 查看策略统计
show sdwan policy-statistics
# 查看 IPsec 隧道状态
show vpn ipsec sa
# 查看路由表
show ip route
# 实时查看流量走向
watch -n 1 'show sdwan policy-statistics'
```
## 总结与判断
SD-WAN 不是银弹。如果你的主要问题是"分支到总部慢",SD-WAN 能解决。如果你的主要问题是"SaaS 访问慢",SD-WAN 也能解决。但如果是"内网安全策略混乱",SD-WAN 解决不了,得配上微分段或 ZTNA。
**我的判断**:50 分支以下的小团队,VyOS 自建方案性价比最高。50 分支以上,直接上商用方案或云上 SASE,运维效率更重要。不管选哪种方案,链路质量评估和策略优先级设计是两大关键点,跳过任何一个都会翻车。
## 上线后的监控与运维节奏
选型和配置只是开始,真正拉开差距的是上线之后那套监控和巡检节奏。我们给 SD-WAN 建过一套三层可观测性,这里把它整理出来,你可以直接照着抄。
### 把 SLA 探针接进 Prometheus
VyOS 本身没有暴露 Prometheus 指标,我们是在每个分支用 snmp_exporter 拉 `ifHCInOctets`、`ifHCOutOctets` 和 IPsec 隧道的 SA 状态,再自己写一个采集脚本定期探活(对固定 SaaS 端点发 ICMP 并记录延迟/丢包),把结果通过 pushgateway 推上去。Grafana 上一张「路径健康」面板:横向是每个分支的每条链路,纵向是延迟、丢包率、抖动三个指标,超阈值直接标红并挂上对应规则名,值班的人一眼就能看出是「链路本身劣化」还是「策略命中错了」。
### 一周链路基线 + 趋势对比
每周一凌晨跑一次全量基线脚本(对 20 个端点各 ping 20 次、mtr 跑 60 秒),把结果存时序库。运维看板不是看「当前这一刻」,而是看「这条链路这一周相比上周」——某个分支的宽带丢包率连续三天从 0.5% 爬到 3%,这种慢性劣化靠实时告警很难抓出来,但趋势对比一眼就能发现。我们规定劣化超过 20% 就触发一次人工排查,别等用户投诉。
### 策略匹配日志是定位「间歇性断流」的关键
前面翻车案例二那种间歇性断流,最折磨人。根因是没留策略匹配日志。现在我们把 `show sdwan policy-statistics` 每分钟抓一次存下来,出问题时直接回溯:某次断流前后,到底是哪条规则的命中计数在跳、哪条链路在接管流量。定位时间从「半天试错」压到「十分钟看日志」。
### 灰度切换纪律
新策略、新链路、新出口**永远先在一个分支灰度 72 小时**,观察该分支的所有指标和告警没异常,再逐个分支推开。我们有一条铁律:绝不在业务高峰(工作日 10:00-11:00、14:00-15:00)切流量,一律选凌晨低峰窗口,并提前在值班群里发变更通告。
## 延伸阅读
- [SASE 安全访问服务边缘实战](sase-security.md)
- [多云网络互联:云 OnRamp 与骨干设计](multicloud-network.md)
- [边缘计算网络架构:低延迟网络设计](edge-computing-network.md)
---
---
## SSL/TLS 握手排障决策树:从 ClientHello 到握手失败 6 类根因
URL: https://ops.jiunotes.top/network/ssl-tls-handshake-troubleshoot.md
摘要: SSL/TLS 握手层排障:openssl s_client 30+ 参数速查、tcpdump 抓握手 filter 表达式、6 类握手失败根因(证书链/过期/CN-SAN/协议版本/TLS1.3 0-RTT/OCSP stapling)决策树、浏览器与 curl 差异、nginx ssl_* 参数矩阵、证书轮转 checklist。
### Answer
TLS 握手失败九成集中在 6 类根因:证书链缺失、证书过期、CN/SAN 不匹配、协议版本不匹配、TLS 1.3 0-RTT 恢复失败、OCSP stapling 失败。本文给一张 openssl s_client 命令矩阵 + tcpdump filter 决策树 + nginx ssl_* 参数速查,配合浏览器与 curl 差异对照,把握手层排障收成一套可复用的排查流程。
### FAQ
Q: "openssl s_client 报 unverified leaf 一定是证书链问题吗?"
a: "绝大多数是。unverified leaf 字面就是"叶子证书无法被本地信任链验证",最常见原因是服务端没把中间证书(intermediate CA)一起发出去,本地信任库里只有根 CA。用 openssl s_client -showcerts 看服务端返回了几张证书,只有一张就是链断了。补齐 chain.pem 或让 CDN/LB 拼上中间证书即可。"
A:
Q: "curl 报 SSL certificate problem: unable to get local issuer certificate,浏览器却正常,为什么?"
a: "浏览器有内置根证书库 + 自动从中间证书拉链补齐机制,curl 默认用系统 CA 库且不做链补齐。同一个域名:浏览器用系统 CA store,macOS/Linux 差异也很大。生产上用 openssl s_client -CAfile /etc/ssl/certs/ca-bundle.crt 显式指定同一份 CA,两者就会给出一致结果。"
A:
Q: "no shared cipher 一定是 cipher 不匹配吗?"
a: "不一定是 cipher suite 列表的问题,也很可能是协议版本没交集。比如客户端只允许 TLS 1.3,服务端 ssl_protocols 只到 TLSv1.2,openssl 会报 wrong version number 或 no shared cipher。排查顺序:先用 -tls1_3 / -tls1_2 强制指定版本,能通就是版本问题;都能通但还是 no shared cipher 才是 cipher suite 交集为空。"
A:
Q: "nginx 里 ssl_stapling on 但 stapling response is not valid 怎么排查?"
a: "三个方向:1) 证书是不是自签或无 OCSP 响应 URL(openssl x509 -text -noout | grep -A2 OCSP);2) stapling_responder 没显式配,nginx 从 cert 抓 OCSP URI 但 DNS 解析失败,配个明确 URL 或 resolver 1.1.1.1;3) stapling_verify off 时也要保证 URL 可达。Let's Encrypt 默认支持,GoDaddy 部分产品不支持 stapling,会一直失败。"
A:
Q: "TLS 1.3 0-RTT 恢复失败会导致什么?"
a: "0-RTT 依赖客户端保存的 NewSessionTicket,重连时携带 early_data 扩展。失败表现为握手退回到 1-RTT 正常流程,功能不受影响但延迟升高。常见诱因:服务端 ssl_early_data 参数漏配、ticket 密钥轮换(ssl_session_tickets off 或 ticket_key 过期)、CDN 层把 early_data 剥掉。用 -early_data hello-only 或 full 显式验证。"
A:
Q: "生产证书轮转怎么做到不中断?"
a: "三件套:1) certbot renew --pre-hook \"curl -fsS -o /dev/null localhost/healthz\" --deploy-hook \"nginx -t && nginx -s reload\";2) 双证书并存期,新旧证书 SAN 覆盖同一 SNI,客户端不感知;3) reload 是 graceful 的,已有连接不断。测试用 curl -vI 或 openssl s_client -servername 显式看 leaf 的 notAfter 是否已换新。"
A:
### Full Content
# SSL/TLS 握手排障决策树:从 ClientHello 到握手失败 6 类根因
## 1. 握手到底在什么时候失败——一次完整握手的字节流视角
先说结论:绝大多数线上 HTTPS 报警,`curl -I` 一敲是 200,用户却在报"证书不受信任"或者"连接被重置"。这类问题不落在 HTTP 层,落在 TLS 握手层——就是 ClientHello 到 Finished 之间那 1-2 个 RTT 里出了岔子。
我在生产环境上最常见的排查起点不是看 [nginx](https://nginx.org/en/docs/) `error.log`(那里只记录握手后的错误),而是先把握手的字节流拉出来看。用一行命令就能拿到一次完整握手的十六进制:
```bash
# 抓一次完整 TLS 1.3 握手的记录层
openssl s_client -connect example.com:443 \
-servername example.com \
-showcerts \
-msg \
-tls1_3 \
< /dev/null 2>&1 | head -80
```
正常情况下,TLS 1.3 的完整握手长这样(`-msg` 展开的记录):
```
C: ClientHello [0x16, 0x03, 0x01, 0x00, 0x??, ...]
C: 0-RTT Application Data (if any)
S: ServerHello
S: {EncryptedExtensions}
S: {Certificate}
S: {CertificateVerify}
S: {ServerFinished}
C: {ClientFinished}
S: {NewSessionTicket} ← 0-RTT 复用的凭证
S: {NewSessionTicket} ← 通常会发两张,一张单用一张复用
```
TLS 1.2 多一个 ChangeCipherSpec + KeyExchange + ClientKeyExchange + CertificateVerify 序列,往返数是 2-RTT 而不是 1-RTT。判断握手在哪一步挂掉,只需要看最后一包是 ClientHello(客户端还没说话就死)、ServerHello(服务端只回了一半就死)、Finished(几乎完成了)还是 Application Data(成功了但业务层还有问题)。
我一般会做两件事建立"手感":
```bash
# 1. 客户端主动握手,抓所有明文握手报文(-msg 只显示非加密部分)
openssl s_client -connect example.com:443 -servername example.com -msg -tls1_3 < /dev/null 2>&1 | grep -E "^\s+(C|S):"
# 2. tcpdump 抓服务端侧握手包(在 nginx 机器上跑)
sudo tcpdump -ni any 'tcp port 443 and (tcp[tcpflags] & tcp-syn != 0 or tcp[tcpflags] & tcp-fin != 0)' -c 20
```
第一个命令看客户端视角的握手字节流,第二个命令看握手是不是有 RST/FIN 提前切断。这两个视图合起来,握手层 90% 的问题都能定位到具体步骤。
TLS 1.3 一个特别值得注意的现象是:**握手完成后服务端会立刻再发两张 NewSessionTicket**,客户端会静默接收并存储。这张 ticket 就是 0-RTT 的钥匙——服务端如果把它禁了或者密钥换了,下一跳连接就退回到 1-RTT,但**握手本身不会失败**,只是延迟升高。别把这个误诊成握手失败。
## 2. openssl s_client 命令矩阵:30+ 参数分层速查
`openssl s_client` 是 TLS 层排障的第一件武器。它的参数分层我建议这么记:
**A. 连接参数(必用)**
```bash
# -connect 是必须的,-servername 是 SNI,几乎必带
openssl s_client -connect host:443 -servername host
# 走代理(CONNECT 隧道)
openssl s_client -connect host:443 -servername host -proxy proxy:8080
# 通过已有连接(比如从本地代理端口)
openssl s_client -connect 127.0.0.1:31280 -servername host -proxy
# 用指定本地出口 IP(多网卡场景)
openssl s_client -connect host:443 -servername host -bind 10.0.0.5
```
**B. 证书与链**
```bash
# 显示服务端返回的所有证书(排查链缺失核心参数)
openssl s_client -connect host:443 -servername host -showcerts
# 用指定 CA bundle 验证
openssl s_client -connect host:443 -servername host -CAfile /etc/ssl/certs/ca-bundle.crt
# 用系统默认 CA 库验证
openssl s_client -connect host:443 -servername host -CApath /etc/ssl/certs
# 只验证但不拒绝(用于观察)
openssl s_client -connect host:443 -servername host -verify_quiet
# 打印完整证书链验证结果
openssl s_client -connect host:443 -servername host -verify_return_error
```
**C. 协议版本控制**
```bash
# 强制 TLS 1.3
openssl s_client -connect host:443 -servername host -tls1_3
# 强制 TLS 1.2
openssl s_client -connect host:443 -servername host -tls1_2
# 允许版本范围(openssl 1.1.1+)
openssl s_client -connect host:443 -servername host -min_proto TLSv1.2 -max_proto TLSv1.3
# 禁用 TLS 1.3(对比 1.2 表现)
openssl s_client -connect host:443 -servername host -no_tls1_3
```
**D. Cipher suite**
```bash
# 查看 openssl 支持的 cipher
openssl ciphers -v 'ALL'
# 只允许某几个 suite
openssl s_client -connect host:443 -servername host -cipher 'ECDHE-ECDSA-AES256-GCM-SHA384'
# 显示实际协商到的 cipher
openssl s_client -connect host:443 -servername host | grep -A2 "Cipher :"
```
**E. 调试输出**
```bash
# -msg: 打印所有 TLS 报文(含加密记录)
# -debug: 打印更多调试信息
# -state: 打印握手状态机变化
# -quiet: 关闭交互提示
# -sess_out/-sess_in: 保存/加载会话(做 0-RTT 或 session resumption 测试)
# -reconnect: 复用同一个 session 重连 N 次
# -verify_time: 覆盖系统时间验证过期证书
# 完整调试组合
openssl s_client -connect host:443 -servername host -msg -state -debug -quiet
```
**F. 0-RTT 与 Session Resumption**
```bash
# 保存当前 session
openssl s_client -connect host:443 -servername host -sess_out sess.pem
# 用保存的 session 重连(触发 0-RTT)
openssl s_client -connect host:443 -servername host -sess_in sess.pem
# 强制带 early data
openssl s_client -connect host:443 -servername host -sess_in sess.pem -early_data < /dev/null
```
**G. OCSP**
```bash
# 查看 stapled OCSP response
openssl s_client -connect host:443 -servername host -ocsp_resp | grep -A20 "OCSP Response"
# 单独发 OCSP 请求(不走 stapling)
openssl ocsp -issuer /path/to/ca.pem -cert /path/to/leaf.pem -url http://ocsp.example.com -resp_text
```
**H. 后处理**
```bash
# 只输出一行结果,用于脚本
openssl s_client -connect host:443 -servername host -verify_return_error -brief < /dev/null
# 用 -connect 后跟 /dev/null 关闭 stdin,避免进入交互
echo | openssl s_client -connect host:443 -servername host
# 提取证书到文件
openssl s_client -connect host:443 -servername host -showcerts < /dev/null 2>&1 | awk '/BEGIN CERT/,/END CERT/' > chain.pem
```
一张完整命令模板,我在生产上排查时会直接复制它,把 host 换掉:
```bash
#!/usr/bin/env bash
# tls-diag.sh — 一次跑完 TLS 层所有关键诊断
HOST="${1:-example.com}"
PORT="${2:-443}"
echo "===== 1. 快速验证 ====="
echo | openssl s_client -connect "$HOST:$PORT" -servername "$HOST" -brief 2>&1
echo "===== 2. 证书链 ====="
echo | openssl s_client -connect "$HOST:$PORT" -servername "$HOST" -showcerts 2>/dev/null \
| awk '/BEGIN CERT/,/END CERT/' | grep -c "BEGIN CERTIFICATE"
echo "^ 上面是服务端返回的证书张数(应该 ≥2,1 张通常是链缺失)"
echo "===== 3. 协议版本探测 ====="
for V in -tls1_3 -tls1_2 -tls1_1 -tls1; do
R=$(echo | openssl s_client -connect "$HOST:$PORT" -servername "$HOST" $V -brief 2>&1 | grep -E "(Protocol|Cipher)" | tr '\n' ' ')
echo "$V → $R"
done
echo "===== 4. SNI 对比(有 SNI vs 无 SNI) ====="
echo "With SNI:"
echo | openssl s_client -connect "$HOST:$PORT" -servername "$HOST" -brief 2>&1 | grep "subject"
echo "Without SNI:"
echo | openssl s_client -connect "$HOST:$PORT" -brief 2>&1 | grep "subject"
echo "===== 5. OCSP stapling ====="
echo | openssl s_client -connect "$HOST:$PORT" -servername "$HOST" -ocsp_resp 2>/dev/null \
| grep -A1 "OCSP Response" | head -5
echo "===== 6. 0-RTT 复测 ====="
echo | openssl s_client -connect "$HOST:$PORT" -servername "$HOST" -sess_out /tmp/s.pem 2>/dev/null >/dev/null
echo "Session 保存成功,尝试重连 0-RTT:"
echo | openssl s_client -connect "$HOST:$PORT" -servername "$HOST" -sess_in /tmp/s.pem -early_data 2>&1 | grep -E "(Early|Reused|Session-ID)"
```
## 3. tcpdump 抓握手包:TCP 层 vs TLS 层的 filter 表达式
[Wireshark](https://www.wireshark.org/docs/) 有 TLS 解密功能(要配 session key log 才行),但现场没 GUI 的时候,tcpdump 反而是首选。
**只抓 TLS 握手相关的包:**
```bash
# 抓所有 443 端口 TCP 包
sudo tcpdump -ni any 'tcp port 443'
# 只看 SYN(新连接建立)
sudo tcpdump -ni any 'tcp port 443 and tcp[tcpflags] & tcp-syn != 0 and tcp[tcpflags] & tcp-ack = 0'
# 只看 SYN-ACK(服务端响应)
sudo tcpdump -ni any 'tcp port 443 and tcp[tcpflags] & (tcp-syn|tcp-ack) == (tcp-syn|tcp-ack)'
# 只看 RST(异常中断)
sudo tcpdump -ni any 'tcp port 443 and tcp[tcpflags] & tcp-rst != 0'
# 只看 443 端口上包长 <200B 的(握手包一般都很小)
sudo tcpdump -ni any 'tcp port 443 and (tcp[12] & 0xff < 200)'
```
**TLS 记录层过滤(tcpdump 的 TLS 层支持有限,需要 tcp[?] 偏移计算):**
```bash
# TLS Record Header 前 5 字节:type(1) + version(2) + length(2)
# TLS Handshake type=0x16(record type)
# 从 IP header 之后跳过 14(eth)+20(ip)+20(tcp) = 54,但 tcp 头可能更长
# 抓 ClientHello(type=22,握手 type=1,通常包大小 300-500B)
sudo tcpdump -ni any -X 'tcp port 443 and tcp[12] & 0xff < 600 and tcp[12] & 0xff > 200'
# 抓 ServerHello(type=22,握手 type=2,通常包大小 200-350B)
sudo tcpdump -ni any -X 'tcp port 443 and (tcp[12] & 0xff < 400)'
# 抓 TLS Alert(握手失败时服务端会发 alert)
# Alert 记录:type=21,长度 7
sudo tcpdump -ni any -X 'tcp port 443 and tcp[0]=21 and tcp[3]=0 and tcp[4]=7'
```
**实战组合(我常用的):**
```bash
# 抓 30 秒的完整握手,导出 pcap,之后 wireshark 打开
sudo tcpdump -ni any -w /tmp/tls.pcap 'tcp port 443' &
TCPDUMP_PID=$!
# 同时触发一次连接
for i in 1 2 3; do
echo | openssl s_client -connect example.com:443 -servername example.com -tls1_3 2>&1 | tail -20
sleep 1
done
sleep 2
kill $TCPDUMP_PID
ls -lh /tmp/tls.pcap
```
**在 wireshark 里打开后的关键 filter:**
```
# 只看 ClientHello
tls.handshake.type == 1
# 只看 ServerHello
tls.handshake.type == 2
# 只看 Alert(握手失败时的关键信息)
tls.alert_message
# 抓 SNI 扩展(判断 SNI 是否发送)
tls.handshake.extensions_server_name
# 抓证书链长度
tls.handshake.certificate
# 抓 cipher suite 协商
tls.handshake.conversation
# 抓所有握手相关字段(最有用)
tls.handshake
# 抓 TLS 1.3 early data
tls.handshake.extensions.early_data
# 只看失败的握手
tls.handshake.type == 21 # alert
```
一个我踩过的坑:**tcpdump 抓不到解密后的 TLS 记录内容**,只能看到"某方向某大小"的包。想看真正的握手内容(cipher 是什么、支持的版本有哪些),必须用 wireshark 配 session key log:
```bash
# 在客户端导出 session key log
export SSLKEYLOGFILE=/tmp/tls-keys.log
curl -vI https://example.com > /dev/null
cat /tmp/tls-keys.log # 里面是 KEYLOG 格式的 key,wireshark 直接导入
# wireshark 里:Preferences → Protocols → TLS → (Pre)Master-Secret log filename → /tmp/tls-keys.log
```
没有 wireshark 就用 `ssldump` 代替,它是纯命令行 TLS 解析工具:
```bash
# 抓包 + ssldump 实时解析
sudo tcpdump -ni any 'tcp port 443' -w /tmp/tls.pcap &
ssldump -r /tmp/tls.pcap -k /tmp/tls-keys.log
```
## 4. 六类握手失败根因决策树
按线上出现频率从高到低排:证书链缺失 > 协议版本不匹配 > CN/SAN 不匹配 > 证书过期 > OCSP stapling 失败 > TLS 1.3 0-RTT 恢复失败。
### 4.1 证书链缺失(unverified leaf)
**症状**:`openssl s_client` 报 `verify error:num=20:unable to get local issuer certificate`,或者 `verify return:1 (unverified leaf)`。浏览器侧的报错是"NET::ERR_CERT_AUTHORITY_INVALID"或"Your connection is not private"。curl 报 `SSL certificate problem: unable to get local issuer certificate`。
**根因**:服务端只发了叶子证书,没把中间证书(intermediate CA)发出去。客户端信任库里通常只有根 CA,没有中间 CA,无法构建信任链。
**排查**:
```bash
# 1. 看服务端返回了几张证书
echo | openssl s_client -connect example.com:443 -servername example.com -showcerts < /dev/null 2>&1 \
| awk '/BEGIN CERT/,/END CERT/' | grep -c "BEGIN CERTIFICATE"
# 输出 1 张 → 链断了
# 2. 看每张证书的 subject 和 issuer
echo | openssl s_client -connect example.com:443 -servername example.com -showcerts < /dev/null 2>&1 \
| awk '/BEGIN CERT/,/END CERT/' | openssl crl2pkcs7 -nocrl -certfile - | openssl pkcs7 -print_certs -noout
# 3. 拿到链后单独验证完整性
openssl verify -CAfile /etc/ssl/certs/ca-bundle.crt chain.pem
```
**修复**:
nginx 侧最常见:
```nginx
# 错:只加载 leaf
ssl_certificate /etc/ssl/example.com/fullchain.pem;
ssl_certificate_key /etc/ssl/example.com/privkey.pem;
# 对:fullchain.pem 应该是 leaf + intermediate(+ 可选 root)
# certbot 生成的 fullchain.pem 已经是拼好的
```
certbot 场景下,`ssl_certificate` 必须指 `fullchain.pem` 而不是 `cert.pem`:
```bash
# certbot 目录结构
ls /etc/letsencrypt/live/example.com/
# cert.pem # 只有 leaf
# chain.pem # 只有中间证书
# fullchain.pem # leaf + chain(正确引用这个)
# privkey.pem # 私钥
```
Caddy、Traefik、Envoy 也有同样问题——检查它们的证书来源是不是"只喂了 leaf"。
一个容易忽略的场景:**CDN/WAF 前面挂了自定义证书**,用户以为证书是 CDN 发的,实际是 CDN 用了平台证书,中间链是 CDN 平台的链。这时候客户端证书信任库里要有该平台的根 CA,否则一样报错。
### 4.2 证书过期/未生效(time out of validity)
**症状**:`verify error:num=10:certificate has expired` 或 `verify error:num=9:certificate is not yet valid`。浏览器显示"Your connection is not private · NET::ERR_CERT_DATE_INVALID"。
**根因**:叶子证书 `notBefore` 或 `notAfter` 超出当前时间。
**排查**:
```bash
# 直接看证书有效期
echo | openssl s_client -connect example.com:443 -servername example.com < /dev/null 2>&1 \
| openssl x509 -noout -dates
# 输出类似:
# notBefore=Nov 5 12:00:00 2025 GMT
# notAfter=Feb 3 12:00:00 2026 GMT
# 用 -verify_time 模拟指定时间验证
echo | openssl s_client -connect example.com:443 -servername example.com \
-verify_time 1740787200 < /dev/null 2>&1 | grep verify
```
**修复**:
```bash
# certbot 手动续期
certbot renew --cert-name example.com --non-interactive --agree-tos
# 续期后强制 reload nginx
nginx -t && nginx -s reload
# 校验新证书已生效
echo | openssl s_client -connect example.com:443 -servername example.com < /dev/null 2>&1 | openssl x509 -noout -dates
```
一个我遇到的隐蔽场景:**服务器时间被 NTP 时钟源打错**。证书本身还在有效期内,但服务器时钟漂移了 8 小时导致所有新连接都握手失败。检查:
```bash
# 服务器时间
date -u
# 对比 NTP 官方时间
curl -s https://worldtimeapi.org/api/timezone/Etc/UTC | jq .utc_datetime
# 检查 ntpd/chrony 状态
timedatectl show --property=NTPSynchronized
chronyc tracking 2>/dev/null || ntpq -p 2>/dev/null
```
生产上我一般会在监控里加一条 `kubelet_node_time_offset_seconds > 5` 的告警,避免时钟漂移这种"看似证书问题实则时钟问题"的情况。
### 4.3 CN/SAN 不匹配(hostname verification failed)
**症状**:`verify error:num=52:unhandled critical extension` 或 `verify error:num=62:certificate does not match server name`。浏览器:"This page contains both safe and insecure content" 或"NET::ERR_CERT_COMMON_NAME_INVALID"。curl:`SSL certificate problem: hostname mismatch`。
**根因**:客户端访问的域名和证书里的 CN(Common Name)或 SAN(Subject Alternative Name)不匹配。
**排查**:
```bash
# 看证书的 SAN
echo | openssl s_client -connect example.com:443 -servername example.com < /dev/null 2>&1 \
| openssl x509 -noout -text | grep -A2 "Subject Alternative Name"
# 输出示例:
# X509v3 Subject Alternative Name:
# DNS:example.com, DNS:*.example.com, DNS:api.example.com
# 测试不同域名的握手(SNI 决定服务端返回哪张证书)
for D in example.com api.example.com foo.example.com evil.com; do
echo "[$D]"
echo | openssl s_client -connect example.com:443 -servername "$D" < /dev/null 2>&1 | grep -E "(subject=|verify)"
echo
done
```
**修复**:
```bash
# 换证书,SAN 覆盖目标域名
certbot certonly --webroot -w /var/www/html --certname example.com -d example.com -d "*.example.com" -d api.example.com
# 或者用通配符(注意:通配符只覆盖单层)
# *.example.com 匹配 api.example.com 但不匹配 a.b.example.com 也不匹配 example.com 本身
```
几个通配符的坑:
- `*.example.com` **不匹配** `example.com` 本身(这是 [RFC](https://www.rfc-editor.org/) 6125 的规定),得单独把 `example.com` 加进 SAN。
- `*.example.com` **不匹配** `a.b.example.com`(只一层通配)。
- `*.example.com` **不匹配** `sub.example.com.evil.com`(域名是后缀匹配)。
- 浏览器**忽略 CN 字段**只用 SAN(2017 年 Chrome 60 之后就强制),但服务端校验(比如 Java)可能还在看 CN。
一个特别隐蔽的场景:**证书是通配的,客户端访问的是证书签发的父域**,比如证书 SAN 是 `*.sub.example.com`,客户端访问 `sub.example.com`,浏览器报错但服务端 s_client 报 verify OK。原因:openssl 默认走严格 SAN 匹配,但很多实现(尤其是老的 Java TrustManager)会宽容回退到 CN 匹配。
### 4.4 协议版本不匹配(no shared cipher / handshake failure)
**症状**:`no shared cipher`、`wrong version number`、`handshake failure`。这几个错都可能出现在这里。
**根因**:客户端支持的最高版本 < 服务端支持的最低版本,或者 cipher suite 交集为空。
**排查**:
```bash
# 1. 逐版本测试
for V in -tls1_3 -tls1_2 -tls1_1 -tls1; do
R=$(echo | openssl s_client -connect example.com:443 -servername example.com $V -brief 2>&1 | grep -E "Protocol|Cipher" | tr '\n' ' ')
echo "$V → $R"
done
# 2. 看服务端支持的 cipher(openssl 1.1.1+)
openssl s_client -connect example.com:443 -servername example.com -brief < /dev/null 2>&1 | grep Cipher
# 3. 对比两端支持的协议(ssllabs 是标准工具)
curl -s "https://api.ssllabs.com/api/v3/analyze?host=example.com&publish=off"
# 或者 curl -s "https://api.ssldownloads.com/api/v2/check?host=example.com"
```
**修复**:
nginx 侧的 `ssl_protocols` 和 `ssl_ciphers`:
```nginx
# 生产推荐(TLS 1.2 + 1.3 都要开)
ssl_protocols TLSv1.2 TLSv1.3;
# TLS 1.3 的 cipher 不能配置(RFC 8446 规定),只能用默认
# TLS 1.2 的 cipher 列表(推荐 Mozilla Intermediate 配置)
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off; # TLS 1.3 场景下建议 off,让客户端选最优
ssl_ecdh_curve X25519:prime256v1:secp384r1;
```
**特殊报错定位**:
- `wrong version number`:通常是 TCP 443 端口背后其实是明文 HTTP 服务(比如 nginx 没配 ssl on),openssl 把 HTTP 响应解析成了 TLS 记录头,第一个字节不是 0x16 就报这个。用 `curl -I http://example.com:443` 试试,如果通了就是这个问题。
- `no shared cipher`:真的 cipher 交集为空。检查客户端是否被企业策略限制(浏览器企业证书策略、curl 的 `--tls-max 1.2` 被禁用某些 cipher)。
- `sslv3 alert handshake failure`:服务端主动 reject,通常是 `ssl_protocols` 里没这个版本。
一个我遇到过但比较罕见的场景:**中间有个做 TLS 1.3 转 1.2 的负载均衡器**(比如某些云 WAF)。客户端到 WAF 用 TLS 1.3,WAF 到 nginx 用 TLS 1.2,nginx 侧 `ssl_protocols` 如果禁了 1.2,就会在 WAF→nginx 段握手失败,客户端完全看不到报错。这种问题要在 LB 层抓包才能定位。
### 4.5 TLS 1.3 0-RTT 恢复失败
**症状**:连接仍然成功,但延迟升高了 1 个 RTT。抓包看到客户端发送了 `early_data` 扩展,但服务端在 ServerHello 里回了 `early_data.rejected`。握手从 0-RTT 退回到 1-RTT。
**根因**:服务端不接受 0-RTT 恢复,或者 NewSessionTicket 已过期。
**排查**:
```bash
# 1. 保存 session
echo | openssl s_client -connect example.com:443 -servername example.com -tls1_3 \
-sess_out /tmp/s.pem < /dev/null 2>&1 | grep -E "(Reused|Session-ID|NewSessionTicket)"
# 2. 用保存的 session 尝试 0-RTT
echo | openssl s_client -connect example.com:443 -servername example.com -tls1_3 \
-sess_in /tmp/s.pem -early_data 2>&1 | grep -E "(Early|Session-ID)"
# 3. 抓包看 early_data 扩展
sudo tcpdump -ni any -X 'tcp port 443' | grep -A2 "17 00"
# 更实用的做法:wireshark 里 filter: tls.handshake.extensions.early_data
```
**修复**:
```nginx
# nginx 侧开启 0-RTT(1.25.0+)
ssl_early_data on;
# 如果不想开:
ssl_early_data off;
# 关闭 session tickets(会导致 0-RTT 直接不生效)
ssl_session_tickets off;
```
**0-RTT 的三个坑(都是我在生产遇到的)**:
1. **ticket 密钥轮换**:nginx 默认用 `ssl_session_cache` 的内存存储,reload 后 ticket 就失效。想让 ticket 跨 reload 存活,需要配置 `ssl_session_ticket_key` 指向持久化文件,或者根本别指望 0-RTT 跨 reload 存活。
2. **CDN/LB 层剥离**:[Cloudflare](https://developers.cloudflare.com/)、AWS ALB、某些企业 LB 会把 early_data 扩展剥掉,即使 nginx 开了 0-RTT,客户端也感知不到。用 `curl -v --early-data` 抓一次响应,看 `Early data was accepted` 还是 `Early data was rejected`。
3. **重放攻击面**:0-RTT 数据可能被重放,服务端必须做幂等处理。GET 请求 OK,POST 请求必须有去重(比如幂等 key)。这是 RFC 8446 §8 明确要求的服务端责任。
判断"0-RTT 到底有没有生效"的最快方法:
```bash
# curl 7.88+ 支持 -w 里的 early_data_accepted
curl -w "\nEarly data accepted: %{early_data_accepted}\n" -o /dev/null -s https://example.com
# 输出 Early data accepted: 1 表示生效,0 表示退回 1-RTT
```
### 4.6 OCSP stapling 失败
**症状**:握手本身成功,但客户端(尤其是 Chrome)会看到 "stapled response is not valid" 或者 "This site uses OCSP stapling, but the response was not valid"。curl 看不到这个提示,因为 curl 不做 stapling 校验。
**根因**:nginx 声明了 `ssl_stapling on` 但拿不到有效的 OCSP 响应。
**排查**:
```bash
# 1. 看 leaf 证书是不是有 OCSP URI
echo | openssl s_client -connect example.com:443 -servername example.com < /dev/null 2>&1 \
| openssl x509 -noout -text | grep -A1 "OCSP"
# 没有 Output → 证书不支持 OCSTAPLING,ssl_stapling 一定失败
# 2. 直接请求 OCSP 端点看返回什么
openssl ocsp -issuer /etc/ssl/letsencrypt/R3.pem \
-cert /etc/letsencrypt/live/example.com/cert.pem \
-url http://ocsp.digicert.com -resp_text
# 看返回是不是 "Certification Status: Good"
# 3. 看 nginx 抓到的 stapling 响应
tail -100 /var/log/nginx/error.log | grep -i stapling
# 典型报错:
# SSL_do_handshake() failed (SSL: error:14094418:SSL routines:ssl3_read_bytes:tlsv1 alert internal error)
# openssl ssl3_read_bytes...
```
**修复**:
```nginx
# 基础配置
ssl_stapling on;
ssl_stapling_verify on;
ssl_trusted_certificate /etc/ssl/letsencrypt/chain.pem;
# 关键:resolver 必须显式配,nginx 不能从 /etc/resolv.conf 读
resolver 1.1.1.1 8.8.8.8 valid=300s;
resolver_timeout 5s;
# 显式指定 OCSP responder URL(可选但推荐)
ssl_stapling_responder https://ocsp.digicert.com;
```
**几个特殊场景**:
- **自签证书**:没 OCSP URI,`ssl_stapling on` 永远失败。要么关掉 `ssl_stapling off`,要么用自签 CA 签发时显式加 OCSP URI(一般不这么做)。
- **GoDaddy 部分产品**:不给 OCSTAPLING 端点。这种证书 `ssl_stapling` 关了就好,别纠结。
- **企业内网 CA**:OCSP 端点在内网 DNS,nginx 的 resolver 要指向内网 DNS(比如 `10.0.0.53`),公共 DNS 找不到内网 CA 的 OCSP 服务。
## 5. 浏览器与 curl 看到的现象为什么不一样
这是排查里最容易迷惑的一点:curl 报错但浏览器正常,或者反过来。
**核心差异**:
| 维度 | 浏览器 | curl |
|---|---|---|
| CA 库 | 系统级 + 平台额外补丁(Apple Root CA / Microsoft 更新) | 系统 CA 库,通常更小 |
| 链补齐 | 会主动从 AIA 扩展拉中间证书 | 不做链补齐 |
| SNI | 强制发送 | 需要 `-servername` 显式发送 |
| HSTS | 有存储,会强制走 HTTPS | 不做(除非 `--hsts`) |
| HSTS preload | 内置 preload list | 无 |
| OCSP stapling | 严格校验 stapled response | 不校验 |
| 证书链长度 | 一般接受 3-4 张 | 默认无硬限制 |
| OCSP CRL 检查 | 有些浏览器会主动请求 | 不请求 |
| TLS 1.3 0-RTT | 支持 | 7.88+ 才支持 |
**排查思路**:
```bash
# 1. 让 curl 使用浏览器的 CA 库(跨平台统一)
# macOS Safari/Chrome 系统 CA
curl --cacert /etc/ssl/cert.pem -I https://example.com
# [Linux](https://www.kernel.org/doc/html/latest/) 用系统 CA
curl --cacert /etc/ssl/certs/ca-certificates.crt -I https://example.com
# 或者用 openssl s_client 指定同一份 CA,模拟浏览器行为
echo | openssl s_client -connect example.com:443 -servername example.com \
-CAfile /etc/ssl/certs/ca-bundle.crt -verify_return_error < /dev/null
# 2. 看浏览器 devtools 里的连接信息
# Chrome: DevTools → Network → 右键 → Copy → Copy as cURL
# 会给你一行 curl 命令,包含浏览器的真实参数
# 3. Chrome 内部错误诊断
# chrome://net-internals/#certs → 输入域名看证书详情
# chrome://net-internals/#traces → 抓握手事件流
# chrome://net-internals/#ocsp → 看 OCSP 请求
```
**一个典型场景**:curl 报 `unable to get local issuer certificate`,浏览器正常。原因:浏览器内置了 Let's Encrypt 的 ISRG Root X1,但 Ubuntu 20.04 的默认 CA 包太老没有。修复:
```bash
apt update && apt install ca-certificates --reinstall
update-ca-certificates
# 或者临时用 curl --cacert /etc/ssl/certs/ca-certificates.crt
```
**反向场景**:浏览器报错,curl 正常。常见原因:
- **HSTS preload**:域名被 preload,浏览器强制 HTTPS,而服务端 80 端口有证书问题(比如 80 端口只监听 HTTP,没有证书)。curl 走 http:// 就绕过了。
- **CT log 校验失败**:浏览器做 Certificate Transparency 校验(Chrome 强制),ct 日志里没找到该证书的审计记录。curl 不做这个检查。
- **企业代理注入**:公司代理替换了证书(TLS 解密审计),浏览器识别是"未知 CA"报错,curl 用了企业根证书就能过。
## 6. nginx 侧配置踩坑速查
一个我认为"够生产用"的 nginx TLS 配置片段,我把每个参数注释清楚:
```nginx
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
http2 on; # 1.25.0+ 拆出来了,1.25 之前是 listen 里带 http2
server_name example.com;
root /var/www/html;
# === 证书 ===
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
ssl_trusted_certificate /etc/letsencrypt/live/example.com/chain.pem; # 用于 stapling 验证
# === 协议版本 ===
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off; # 1.3 场景让客户端选
# === Cipher suite(TLS 1.2 用)===
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
ssl_ecdh_curve X25519:prime256v1:secp384r1;
ssl_dhparam /etc/nginx/dhparam.pem; # 2048 位 DH,用 openssl dhparam -out dhparam.pem 2048 生成
# === 会话管理 ===
ssl_session_cache shared:SSL:10m; # 10 MB 共享内存,约 1 万 session
ssl_session_timeout 1d; # 会话缓存 1 天
ssl_session_tickets off; # 关掉票据(0-RTT 会失效但更安全)
# === OCSP Stapling ===
ssl_stapling on;
ssl_stapling_verify on;
ssl_stapling_responder https://ocsp.digicert.com;
resolver 1.1.1.1 8.8.8.8 valid=300s; # 显式 resolver,别依赖系统
# === 0-RTT(可选,nginx 1.25+)===
ssl_early_data off; # 默认关,开启要小心幂等
# === 其他 ===
ssl_buffer_size 8k;
ssl_early_bytes 32k;
ssl_session_jitter 10m; # 避免雪崩式 session 过期
}
```
**参数矩阵速查**:
| 参数 | 默认值 | 影响面 |
|---|---|---|
| `ssl_protocols` | TLSv1 TLSv1.1 TLSv1.2 | 决定支持的版本 |
| `ssl_ciphers` | HIGH:!aNULL:!MD5 | TLS 1.2 cipher 列表 |
| `ssl_prefer_server_ciphers` | on | off 时客户端选,on 时服务端选 |
| `ssl_session_cache` | none | 会话复用存储 |
| `ssl_session_timeout` | 5m | 会话缓存时间 |
| `ssl_session_tickets` | on | 开启后可做 session resumption |
| `ssl_stapling` | off | 是否 stapling OCSP |
| `ssl_stapling_verify` | off | 是否验证 stapled response 签名 |
| `ssl_early_data` | off(1.25+) | 是否允许 0-RTT |
| `ssl_buffer_size` | 4k | 加密 buffer 大小 |
| `ssl_early_bytes` | 0 | 早发加密数据的字节数 |
| `ssl_session_jitter` | 0 | 会话过期抖动,避免雪崩 |
**常见报错对照**:
```
# "SSL_do_handshake() failed (SSL: error:14094418:SSL routines:ssl3_read_bytes:tlsv1 alert internal error)"
→ 客户端不支持 ssl_protocols 里指定的版本
→ 或者 cipher suite 里有一个客户端不接受
# "no suitable key share received"
→ TLS 1.3 场景下服务端要求的 group 客户端都不支持
→ 检查 ssl_ecdh_curve 和客户端能力
# "OCSP response is not valid, verify error:num=20"
→ ssl_stapling_verify on 但 stapled 响应签名验证失败
→ 检查 ssl_trusted_certificate 是否是完整链
# "unknown ssl_early_data"
→ nginx 版本低于 1.25,去掉这行
```
## 7. 生产环境证书轮转 checklist
证书轮转的目标:**零中断切换**。任何一次 reload 都要保证新连接的握手用新证书,旧连接不断。
**certbot 自动续期模板**:
```bash
# /etc/systemd/system/certbot-renew.service.d/override.conf
# 或者用 certbot 的 timer + hook
# 标准 timer
systemctl enable --now certbot.timer
# /etc/letsencrypt/renewal-example.com.conf 里的 renewal hook
[[renewal hooks]]
deploy_hook = "nginx -t && nginx -s reload"
# 手动跑一次续期验证
certbot renew --dry-run --cert-name example.com
```
**验证脚本**:
```bash
#!/usr/bin/env bash
# cert-verify.sh — 证书轮转前后跑一遍
HOST="${1:-example.com}"
CERT_FILE="/etc/letsencrypt/live/${HOST}/fullchain.pem"
echo "=== 证书信息 ==="
openssl x509 -in "$CERT_FILE" -noout -subject -dates -ext subjectAltName
echo "=== 服务端握手验证 ==="
echo | openssl s_client -connect "$HOST:443" -servername "$HOST" < /dev/null 2>&1 | grep -E "(subject=|issuer=|notAfter|Verify return code|Protocol|Cipher)"
echo "=== 剩余天数 ==="
EXP=$(openssl x509 -in "$CERT_FILE" -noout -enddate | cut -d= -f2)
DAYS=$(( ( $(date -d "$EXP" +%s) - $(date +%s) ) / 86400 ))
echo "剩余 ${DAYS} 天"
if [ "$DAYS" -lt 14 ]; then
echo "⚠️ 剩余不足 14 天,立即续期"
exit 1
fi
```
**监控告警规则([Prometheus](https://prometheus.io/docs/) + Alertmanager 示例)**:
```yaml
# prometheus rule
groups:
- name: tls-cert-expiry
rules:
- alert: TLSCertExpiringSoon
expr: probe_ssl_earliest_cert_expiry - time() < 14 * 24 * 3600
for: 30m
labels:
severity: warning
annotations:
summary: "{{ $labels.instance }} 证书 14 天内到期"
- alert: TLSCertExpiringVerySoon
expr: probe_ssl_earliest_cert_expiry - time() < 3 * 24 * 3600
for: 5m
labels:
severity: critical
annotations:
summary: "{{ $labels.instance }} 证书 3 天内到期,立即处理"
```
**轮转 checklist(按顺序执行)**:
1. `certbot renew --dry-run` 确认续期流程通(DNS/HTTP 都通)
2. `certbot renew` 实际续期
3. `nginx -t` 检查新配置(`ssl_certificate` 指向的 new fullchain)
4. `nginx -s reload`(不是 restart,reload 不断已有连接)
5. `openssl s_client` 验证新证书已生效
6. 监控面板确认 `probe_ssl_earliest_cert_expiry` 已重置
7. 保留旧证书备份 90 天(回滚用)
**回滚方案**:
```bash
# 保留备份
mkdir -p /etc/letsencrypt/live/example.com/backup-$(date +%Y%m%d)
cp -a /etc/letsencrypt/live/example.com/*.pem /etc/letsencrypt/live/example.com/backup-$(date +%Y%m%d)/
# 回滚:把 backup 里的文件覆盖回去
cp -a /etc/letsencrypt/live/example.com/backup-20261010/*.pem /etc/letsencrypt/live/example.com/
nginx -s reload
```
一个我踩过但比较隐蔽的坑:**certbot 生成的新证书文件权限**。默认 `chmod 600 privkey.pem` 是对的,但如果 nginx worker 以非 root 用户跑,读不到 600 权限的文件会握手失败并记录 `SSL_CTX_use_PrivateKey_file() failed`。检查:
```bash
ls -la /etc/letsencrypt/live/example.com/
# 应该看到:
# -rw-r--r-- 1 root root ... chain.pem
# -rw-r--r-- 1 root root ... fullchain.pem
# -rw------- 1 root root ... privkey.pem
# -rw-r--r-- 1 root root ... cert.pem
# 修权限
chmod 644 /etc/letsencrypt/live/example.com/*.pem
chmod 600 /etc/letsencrypt/live/example.com/privkey.pem
chown -R root:root /etc/letsencrypt/live/example.com/
```
## 8. 常见误区与速查 Cheat Sheet
**误区 1:把 nginx 的 502/504 当握手失败**
502/504 是 HTTP 层错误,握手必须已经成功。握手失败表现为 curl 的 `openssl` 报错、浏览器的证书警告、或者干脆 TCP RST。判断:`curl -vI https://...`,如果看到 `Connected to` 之后立刻有 `SSL connection using` 就是握手成功了;如果卡在 `Connected to` 之后超时或者报错 `SSL_connect` 就是握手失败。
**误区 2:`no shared cipher` 就一定是 cipher 问题**
也可能是协议版本问题(TLS 1.3 vs 1.2 不匹配)、也可能是 DH 参数不匹配。先分版本测试再改 cipher。
**误区 3:`ssl_stapling on` 一定安全**
如果 `ssl_stapling_verify off`(默认),nginx 不验证 stapled response 的签名,恶意中间人可能注入伪造的 OCSP 响应。生产上必须开 `ssl_stapling_verify on` + `ssl_trusted_certificate`。
**误区 4:证书过期前一定会有 certbot 邮件提醒**
certbot 只在续期成功/失败时发邮件,不主动发"证书将在 X 天后过期"。得靠自己的监控告警,或者配置 certbot 的 `renew_before_expiry`(默认 30 天,但可以更早)。
**误区 5:Chrome 的 `chrome://ssl` 是最权威的证书视图**
它展示的是 Chrome 已经加载的证书,可能包含缓存。要拿最新的服务端证书,用 `chrome://net-internals/#certs` 手动刷新,或者干脆用 `openssl s_client`。
**误区 6:SAN 通配符 `*.example.com` 覆盖 `www.example.com` 之外的所有子域**
只覆盖一层。`api.example.com` OK,`api.v2.example.com` 不 OK,`example.com` 本身也不 OK。
**误区 7:HTTPS 握手失败一定跟证书有关**
也可能是:端口没监听(`curl -v` 卡在 `Trying...`)、IP 层不通(防火墙、路由)、SNI 被中间设备改写了(企业代理)。先用 `tcping` 或 `nc -zv host 443` 确认 TCP 通,再看 TLS 层。
**误区 8:TLS 1.3 一定比 1.2 好**
TLS 1.3 更紧凑(1-RTT)、更快(0-RTT)、更安全(去掉老 cipher)。但 TLS 1.2 兼容老客户端、老中间设备、企业代理。生产环境建议双开:
```nginx
ssl_protocols TLSv1.2 TLSv1.3;
```
**误区 9:openssl s_client 报错就是服务端问题**
s_client 是客户端,它的行为受本机 CA 库、openssl 版本、命令行参数影响。同一个域名,本机 s_client 报错、跳板机上 s_client 正常,是常见现象。用 `-CAfile` 显式指定 CA 让结果可比。
**误区 10:OCSP stapling 失败会导致连接失败**
不会。stapling 只是让客户端不用主动发 OCSP 请求,握手本身仍然成功。浏览器会 fallback 到主动 OCSP 请求或者直接忽略。所以 `ssl_stapling` 失败通常只在日志里看到,不影响用户流量——但会增加握手时延(Chrome 会主动查 OCSP)。
---
**速查表:报错 → 根因 → 修复**
| 报错关键字 | 根因 | 修复 |
|---|---|---|
| `unverified leaf` / `unable to get local issuer certificate` | 证书链缺失 | 加载 fullchain.pem |
| `certificate has expired` / `certificate is not yet valid` | 证书过期/未生效 | 续期或校时间 |
| `hostname mismatch` / `does not match server name` | CN/SAN 不匹配 | 换证书覆盖目标域 |
| `no shared cipher` / `wrong version number` | 版本或 cipher 不匹配 | 调 ssl_protocols / ssl_ciphers |
| `stapling response is not valid` | OCSP stapling 失败 | 显式配 resolver + stapling_verify |
| `early_data.rejected` / `Early data was rejected` | 0-RTT 恢复失败 | 检查 ssl_early_data / ticket key |
| `tlsv1 alert internal error` | 服务端主动 reject | 检查 ssl_protocols / cipher |
| `no suitable key share received` | TLS 1.3 group 不匹配 | 检查 ssl_ecdh_curve |
| `ssl3_read_bytes:sslv3 alert protocol version` | 版本被服务端拒 | 强制 -tls1_2 或改 ssl_protocols |
**速查表:一条命令解决**
```bash
# 全面握手诊断
echo | openssl s_client -connect HOST:443 -servername HOST -showcerts -msg -tls1_3 < /dev/null 2>&1 | head -80
# 只看协议版本和 cipher
echo | openssl s_client -connect HOST:443 -servername HOST -brief < /dev/null
# 只看证书链
echo | openssl s_client -connect HOST:443 -servername HOST -showcerts < /dev/null 2>&1 | openssl crl2pkcs7 -nocrl -certfile - | openssl pkcs7 -print_certs -noout
# 只看证书有效期
echo | openssl s_client -connect HOST:443 -servername HOST < /dev/null 2>&1 | openssl x509 -noout -dates
# 只看 OCSP stapling 状态
echo | openssl s_client -connect HOST:443 -servername HOST -ocsp_resp < /dev/null 2>&1 | grep -i ocsp
# 测试 0-RTT
echo | openssl s_client -connect HOST:443 -servername HOST -tls1_3 -sess_in sess.pem -early_data < /dev/null
# curl 一行诊断(含协议版本、cipher、握手时间)
curl -svI --tlsv1.2 https://HOST 2>&1 | grep -E "(SSL connection|subject:|issuer:|HTTP/|time_connect|time_starttransfer)"
# 抓握手包 + 快速解析
sudo tcpdump -ni any -c 20 -X 'tcp port 443' &
sleep 1 && echo | openssl s_client -connect HOST:443 -servername HOST < /dev/null
# nginx 侧查当前 TLS 配置
nginx -T 2>/dev/null | grep -iE "(ssl_protocols|ssl_ciphers|ssl_stapling|ssl_early_data|ssl_session)"
```
**速查表:nginx ssl_* 参数一页纸**
```nginx
# 版本
ssl_protocols TLSv1.2 TLSv1.3;
# Cipher(TLS 1.2 用)
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
ssl_ecdh_curve X25519:prime256v1:secp384r1;
ssl_dhparam /etc/nginx/dhparam.pem;
# 会话
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off;
ssl_session_jitter 10m;
# Stapling
ssl_stapling on;
ssl_stapling_verify on;
ssl_trusted_certificate /etc/ssl/letsencrypt/chain.pem;
resolver 1.1.1.1 valid=300s;
# 0-RTT
ssl_early_data off;
# 其他
ssl_buffer_size 8k;
ssl_early_bytes 32k;
```
**速查表:协议版本探测一行流**
```bash
for V in -tls1_3 -tls1_2 -tls1_1 -tls1; do
echo "[$V] $(echo | openssl s_client -connect HOST:443 -servername HOST $V -brief < /dev/null 2>&1 | grep -E 'Protocol|Cipher' | tr '\n' ' ')"
done
```
**速查表:证书到期监控告警阈值**
- 剩余 90 天:INFO 级别,仅通知
- 剩余 30 天:WARNING,触发 certbot renew
- 剩余 14 天:WARNING,人工介入
- 剩余 3 天:CRITICAL,立即处理
- 剩余 0 天:P0,服务降级
**速查表:openssl s_client 常用参数**
| 参数 | 用途 |
|---|---|
| `-connect host:port` | 目标地址 |
| `-servername HOST` | SNI |
| `-showcerts` | 显示完整链 |
| `-CAfile /path` | 指定 CA |
| `-CApath /dir` | 指定 CA 目录 |
| `-tls1_2` / `-tls1_3` | 强制版本 |
| `-no_tls1_3` | 禁用 1.3 |
| `-min_proto` / `-max_proto` | 版本范围 |
| `-cipher LIST` | 指定 cipher |
| `-msg` | 打印 TLS 报文 |
| `-debug` | 更多调试 |
| `-state` | 状态机 |
| `-sess_out /path` | 保存 session |
| `-sess_in /path` | 加载 session |
| `-early_data` | 触发 0-RTT |
| `-reconnect N` | 复用 session N 次 |
| `-ocsp_resp` | 抓 stapled OCSP |
| `-verify_return_error` | 验证失败即退出 |
| `-verify_quiet` | 静默验证 |
| `-verify_time N` | 覆盖系统时间 |
| `-proxy [host:port]` | 走代理 |
| `-bind IP` | 指定本地出口 |
| `-quiet` | 关闭交互提示 |
| `-brief` | 简化输出 |
| `-alpn list` | 指定 ALPN 协议 |
| `-nextprotos` | 旧版 ALPN |
到这里 6 类握手失败根因和排查工具都过了一遍。实际生产上排障时我一般先跑第 8 章的"一条命令解决"里的 `openssl s_client -showcerts -msg`,看错误发生在哪一步,然后按第 4 章的决策树往下走。如果 openssl 看不出问题,再切到 tcpdump 抓包 + wireshark 分析 TLS 记录。整套流程跑下来,绝大多数 TLS 握手失败都能定位到具体配置行。有一点我也不完全确定:TLS 1.3 场景下的 0-RTT 恢复失败在监控里其实很难主动发现(因为它表现为延迟升高而不是错误率升高),我在生产里是通过 `curl -w '%{early_data_accepted}'` 定期探测来发现的,如果这块有更优雅的姿势欢迎在评论区指正。
## 相关文章
- [DNS 排障检测:dig +trace 从递归到权威的六症状决策树](dns-troubleshoot.md)
- [nginx 504 gateway timeout 排查:6 类根因决策树](nginx-504-troubleshoot.md)
- [TCP 排障决策树:ss + tcpdump 三件套定位 TIME_WAIT / RST / SYN 半连接](tcp-troubleshoot.md)
---
## TCP/IP 状态机
URL: https://ops.jiunotes.top/network/tcp-ip.md
摘要: 从状态机视角理解 TCP,看懂 ss / netstat 每一列,掌握 ESTABLISHED / TIME_WAIT / SYN_SENT / CLOSE_WAIT 等状态含义,附 ss 实战定位与内核调优。
### Answer
TCP 状态机有 11 个状态,运维最常看 ESTABLISHED、TIME_WAIT、CLOSE_WAIT、SYN_SENT。用 `ss -tanp` 按状态计数看趋势:CLOSE_WAIT 涨说明本地应用没 close,TIME_WAIT 堆积看是主动关闭方。`tcp_tw_reuse` 只在出站长连接场景开。
### FAQ
Q: TIME_WAIT 有几千条,要不要调内核参数?
A: 先看是谁在 TIME_WAIT:`ss -tan | grep TIME-WAIT | awk '{print $5}' | cut -d: -f1 | sort | uniq -c | sort -rn`。若都连外网短连接(调 HTTP API),TIME_WAIT 在本端是正常的主动关闭行为,开 `tcp_tw_reuse=1` 即可;若全堆在某后端 IP,说明客户端频繁建连又关,应改连接池而非调内核。我第一回看到几千条就慌,查半天发现是自己脚本没复用连接。
Q: CLOSE_WAIT 一直涨不掉,是不是内核 bug?
A: 不是内核问题,是本地应用没调 close()。CLOSE_WAIT 含义是对端已发 FIN,在等你应用读完数据并关闭 socket。`ss -tanp | grep CLOSE-WAIT` 看哪个进程占着再查代码。Java 常见是 try-with-resources 没生效或 catch 忘关流;Go 是我遇到过 defer close 写错分支,某些路径永远不关。
Q: ESTABLISHED 突然从几百跳到几万,怎么回事?
A: 先排除业务高峰,再 `ss -tan | awk '$1=="ESTAB"{print $5}' | cut -d: -f1 | sort | uniq -c | sort -rn` 看是否某 IP 大量并发。单 IP 异常多是应用 bug 频繁重连或端口探测。另看 `ss -s` 的 orphans grown,持续涨说明有进程崩了但 socket 没释放。
Q: ss -tanp 里进程名显示 ? 怎么办?
A: ss -tanp 需 root 才显示进程,非 root 看到 ?。用 `sudo ss -tanp`,或用 /proc/ 反查:ss -tan 有 inode,`ls -la /proc/net/tcp` 找到对应 inode,再 `lsof | grep `。我第一回在普通用户下跑了半天看不出谁占端口,切 root 一下就清楚。
### Full Content
# TCP/IP 状态机
## 为什么先看状态机
我排网络问题有个习惯:先 `ss -tanp` 把连接按状态分组看一遍,往往比抓包更快定方向。因为 TCP 的连接生命周期被抽象成状态机,**每个状态对应一种「此刻这条连接处在什么阶段」**,异常的状态堆积几乎都能反推出问题出在哪一方(本端应用 / 对端 / 中间链路 / 出站被拦)。看懂状态机,`ss` 输出里那一列 `st` 就不再是密码了。
> 本节的过滤器示例可直接在 Wireshark 里用,抓包对照见 [Wireshark 抓包基础](wireshark-basics.md)。
## 11 个状态与运维含义
下面这张表按「连接从建立到关闭」的顺序整理,重点标出运维最常撞上的几个:
| 状态 | 所处阶段 | 运维解读 | ss 里显示为 |
|---|---|---|---|
| LISTEN | 被动方等连接 | 端口在正常监听;`Recv-Q` 是 `somaxconn` 上限 | LISTEN |
| SYN_SENT | 主动方发 SYN 等 SYN-ACK | 出站建连没被确认,常见于出站被防火墙拦 / 对端不可达 | SYN-SENT |
| SYN_RECV | 被动方收 SYN 未回 | 半连接,`SYN flood` 攻击会把它堆爆 | SYN-RECV |
| ESTABLISHED | 正常数据阶段 | 绝大多数连接都在这里,看数量和增长趋势 | ESTAB |
| FIN_WAIT_1 | 主动方发 FIN 等 ACK | 本端已发起关闭,正常且短暂 | FIN-WAIT-1 |
| FIN_WAIT_2 | 等对端 FIN | 本端发完关闭,在对端也发 FIN | FIN-WAIT-2 |
| **TIME_WAIT** | 主动关闭方回 FIN 后等 2MSL | 短连接高频会堆积,看是谁主动关 | TIME-WAIT |
| **CLOSE_WAIT** | 对端已关,等本端应用 close | **本端应用泄漏/没关**,持续涨是大问题 | CLOSE-WAIT |
| CLOSING | 双方同时主动关闭 | 罕见,正常几乎不出现 | CLOSING |
| LAST_ACK | 等最后一个 ACK | 被动关闭方发完 FIN 等确认 | LAST-ACK |
| (无连接) | 正常关闭完成 | socket 释放 | — |
我特别强调 **CLOSE_WAIT 和 TIME_WAIT 的差别**,这俩最容易混:TIME_WAIT 是**正常的、临时的**(内核替你持有 2MSL 后自动释放,一般是 60 秒),涨一点不可怕,持续堆积才要查;CLOSE_WAIT 是**异常的**,它意味着「对端已经走了,就等你应用把 socket 关掉」,而应用迟迟不关,所以它会一直堆到耗尽 fd,堆积本身就是 bug 信号。
## ss / netstat 实战
**按状态计数(第一眼看全局)**:
```bash
ss -tan | awk '{print $1}' | sort | uniq -c | sort -rn
# 典型输出:几千 ESTAB、几百 TIME-WAIT、若 CLOSE-WAIT 非 0 且持续增长就要查
```
**定位 CLOSE_WAIT 是哪个进程占的**(这是最有价值的一步):
```bash
ss -tanp | grep CLOSE-WAIT
# 输出里 (process,fd) 直接告诉你哪个服务没关 socket
# 非 root 看不到进程时,用 inode 反查:
ss -tan | grep CLOSE-WAIT # 记下 inode
sudo lsof -p 2>/dev/null | grep
```
**TIME_WAIT 分布(看是谁主动关)**:
```bash
ss -tan | grep TIME-WAIT | awk '{print $4,$5}' | cut -d: -f2 | sort | uniq -c | sort -rn | head
# 对端 IP 集中 + 端口是 443/80 → 多半是你这侧在用短连接打对方 API
```
## 我踩过的三个坑
- **CLOSE_WAIT 泄漏**:一个 Go 服务的 HTTP client 每次请求后没 close Response Body,跑一天 CLOSE_WAIT 堆到几千,fd 告警后服务直接开不了新连接。查 `ss -tanp | grep CLOSE-WAIT` 一眼锁定进程,代码里给 body 加 defer Close 解决。**教训:CLOSE_WAIT 持续增长别去调内核,去查代码。**
- **SYN_SENT 卡住**:一次出站调第三方 API 突然全超时,`ss` 里一堆 SYN-SENT 收不到 SYN-ACK。抓包确认是本端 SYN 发出去了没回音,最后发现是安全组把该目的网段 443 出方向放了行(`security group` 出方向规则改过)。**SYN_SENT 集中指向某 IP,多半是出站链路 / 对端不可达,先 ping + traceroute,再看防火墙出方向。**
- **TIME_WAIT 风暴**:压测时用短连接打接口,几秒内 TIME_WAIT 到十万级,新连接建不出(源端口耗尽)。压测侧改连接池,生产侧对**出站**场景开 `net.ipv4.tcp_tw_reuse=1`。
## 内核调优(别乱调)
```bash
# 只对「出站长连接」场景开 tw_reuse,能复用处于 TIME_WAIT 的 socket 给新出站连接
net.ipv4.tcp_tw_reuse = 1
# 调大 TIME_WAIT 上限(默认 1-2 万,短连接服务适当放大)
net.ipv4.tcp_max_tw_buckets = 65536
# 被动方连接队列上限,监听 socket 的 Recv-Q 就是它
net.core.somaxconn = 65535
```
`tcp_tw_recycle` 在 4.x 内核已被移除(会跟 NAT 一起出问题,历史上是各种「丢包」元凶之一),现在别用也看不到它。我一般**先不改参数**,等确认是 TIME_WAIT 堆积且是主动关闭方,再开 `tw_reuse`;CLOSE_WAIT、SYN_SENT 问题根本不用调参数,是应用或链路的事。
## 2MSL 到底在等什么
TIME_WAIT 要等 2 个 MSL(Max Segment Lifetime,Linux 默认 60 秒,即 2×60=120s)不是没来由的,它同时干两件事:
1. **让本连接最后一个 ACK 有「重发」的余地**——万一这个 ACK 丢了,对端重发 FIN,本端还能再回 ACK,保证连接彻底收尾。
2. **让旧连接的迟到报文彻底过期**——同一五元组可能马上被新连接复用,若旧连接的滞留在网报文此刻才到,会被新连接误当成自己的数据。等 2MSL 保证任何旧报文在「两个 RTT」内必然到达或被丢掉,从而不会污染新连接。
所以别试图把 TIME_WAIT「调没」,它是协议正确性的一部分。你能做的是**减少产生它的短连接数量**(连接池),或在出站长连接场景用 `tcp_tw_reuse` 提前复用。真正该消除的是 CLOSE_WAIT 这类「应用没关」的堆积,那才是 bug。
## 抓包看一次完整的状态流转
把状态机和抓包对上,理解会深很多。Wireshark 里 Follow 一条 TCP 流,能清楚看到每个状态对应的报文:
- **建连**:`SYN`(本机 → SYN_SENT)→ 对端回 `SYN+ACK`(本机 → ESTABLISHED)→ 本机回 `ACK`(对端也到 ESTABLISHED)。三条握手。
- **传数据**:大量 `PSH+ACK`,本机 `ss` 里就是 ESTAB,数量随业务涨落。
- **主动关闭**:本端应用调 close → 发 `FIN`(本机 → FIN_WAIT_1)→ 对端回 `ACK`(本机 → FIN_WAIT_2)→ 对端发 `FIN`(本机 → TIME_WAIT,开始等 2MSL)→ 本机回 `ACK`(最终 CLOSED)。
排障时如果抓包看到某条流**卡在 FIN_WAIT_2 很久没进 TIME_WAIT**,说明对端一直没发 FIN——多半是**对端应用不关连接**(被动方漏 close),这时本端只能自己 `SO_LINGER` 强制 RST 断开。这个细节我排一个老版本 Java 客户端挂连接时踩过,`tcpdump` 里满屏 FIN 但永远收不到对端的,最后定位到对端业务代码异常分支没 close。
## 把状态检查纳入监控(SOP)
我固定做两件事,让「状态堆积」在出故障前就被看见:
1. **按状态打点**:用 node_exporter 的 `node_netstat_*`(或自写 exporter 解析 `ss -tan`),对 `CLOSE_WAIT`、`TIME_WAIT`、`SYN_SENT` 各出一条指标。CLOSE_WAIT 持续增长直接告警(应用泄漏),TIME_WAIT 超阈值告警(连接池没开)。
2. **基线对比**:记平时正常值,CLOSE_WAIT 平时应是 0 或个位数,一旦到几百且单调涨,基本就是某服务在泄漏 fd,比等服务 OOM 前干预。
这套下来,「状态机」就从一张静态表变成了能驱动告警的动态观测面。表(上文)+ `ss` 命令 + 抓包流转 + 监控打点,四样配合,TCP 层的绝大多数线上问题我都能在半小时内定方向。
## 相关文章
- [TCP 排障:TIME_WAIT / RST](tcp-troubleshoot.md)
- [Wireshark 抓包基础](wireshark-basics.md)
- [CDN 原理:回源、预热、缓存失效](cdn-principles.md)
---
## TCP 排障决策树:ss + tcpdump 三件套定位 TIME_WAIT / RST / SYN 半连接
URL: https://ops.jiunotes.top/network/tcp-troubleshoot.md
摘要: TCP 排障六症状决策树:TIME_WAIT 堆积、RST 乱飞、SYN 半连接、CLOSE_WAIT 泄漏、MTU 黑洞、连接卡顿。ss -tanp 看状态分布、ss -s 看内核队列、tcpdump -nn 抓具体包,配 nginx 502/504 反向代理场景实战与四个真实踩坑案例。
### Answer
TCP 排障分症状再动手:TIME_WAIT 堆积先看客户端是不是没关连接;RST 乱飞先抓包看是谁发的;
SYN 半连接先看服务端 tcp_max_syn_backlog;CLOSE_WAIT 泄漏十有八九是应用代码没 close;
MTU 黑洞先跑 tcpdump -vvv 看 MSS 值;连接慢先排除 Nagle 算法和延迟 ACK 双重杀。
三条命令打底:`ss -s` 看队列,`ss -tanp` 看状态分布,`tcpdump -nn -i any port ` 抓包。
本文补 FAQ:补充了 nginx 502 RST 来源识别、tcpdump 过滤写法、mtu 黑洞验证方法、CLOSE_WAIT 泄漏排查四个问题,共 9 条问答覆盖全文核心知识点。
### FAQ
Q: ss 和 netstat 到底该用哪个?
a: 新机器用 ss,netstat 是祖传工具但慢且信息少。ss 直接读内核 socket 表,netstat 走 /proc 还得过滤。
排障场景 `ss -tanp` 比 `netstat -an` 快一个数量级,且能显示进程名和 inode。
一句话:能 ss 就别 netstat。
A:
Q: TIME_WAIT 堆积要不要 kill 掉?
a: 不能手动清,内核管的。要么调 `net.ipv4.tcp_tw_reuse=1`(Linux 4.19+ 有 SYN 检测保护),
要么从根上改:让服务端主动关闭连接(`keepalive_timeout` 设小)、客户端用连接池、
扩大端口范围 `net.ipv4.ip_local_port_range`。**这里最容易翻车**——有人用 `sysctl -w net.ipv4.tcp_tw_timeout`,
这参数在老内核存在新内核被删了,别踩。
A:
Q: nginx 报 502 一定是后端挂了?
a: 不一定。502 也可能是后端主动发 RST、upstream 连接被防火墙切断、keepalive 复用失败、
或者 TCP MTU 不对导致大包分片丢失。抓包看 RST 是谁发的就清楚了:
`tcpdump -nn -i any port 8080 'tcp[tcpflags] & tcp-rst != 0'`,看源 IP 是后端还是 nginx。
A:
Q: tcpdump 抓包命令该怎么写过滤?
a: 三段式最常用:`-i <网卡>` 选网卡,`host and port <端口>` 缩小范围,`tcp[tcpflags] & tcp-rst != 0` 只抓 RST。
保存用 `-w file.pcap` 后面用 Wireshark 打开。抓完记得 `Ctrl+C` 停,别忘 `-c 1000` 限定条数防爆盘。
A:
Q: SYN_RECV 爆增是攻击还是 backlog 满了?
a: 两类原因区分方法:`ss -s` 看 `embryonic` 数字,`dmesg | grep -i syn` 看内核日志。
如果是 SYN Flood 攻击,embryonic 持续增长且来自不同 IP;如果是 backlog 满,同一来源 IP 密集,
调 `net.ipv4.tcp_max_syn_backlog` 或 `net.core.somaxconn` 即可。
A:
Q: CLOSE_WAIT 堆积怎么定位泄漏进程?
a: `ss -tanp \| grep CLOSE-WAIT` 直接显示进程名和 PID。
找到进程后 `lsof -p \| grep sock` 看它打开了哪些 socket 没关闭。
典型原因:Java 应用线程池耗尽导致 close() 没执行、Go 协程泄漏、Python 长连接没释放。
最根本的修复是改代码,sysctl 调优只能缓解症状。
A:
Q: 怎么验证是不是 MTU 黑洞?
a: 三步验证:① `ping -M do -s 1472 <目标IP>`(1472 = 1500 MTU - 28 头部),不通就调小;
② `tcpdump -nn 'tcp[tcpflags] & tcp-syn != 0'` 看 SYN 包里的 MSS 协商值;
③ `ip link show` 看两端 MTU 是否一致。
常见的 MTU 问题源:VPN 隧道、VXLAN overlay、云服务商默认 MTU 不是 1500。
A:
Q: TCP 连接建立慢(握手要几秒)是什么原因?
a: 三个常见原因:① Nagle 算法 + 延迟 ACK 双杀(发送端攒小包、接收端延迟确认,互相等待);
② 服务端 `tcp_max_syn_backlog` 太小导致 SYN 排队;
③ DNS 反向解析慢(nginx 默认开 `server_name_in_redirect` 会触发 PTR 查询)。
快速排查:`ss -i` 看 `send_queue` 和 `recv_queue` 是否积压,
`tcpdump` 看 SYN-ACK 往返时间。
A:
### Full Content
# TCP 排障决策树:ss + tcpdump 三件套定位 TIME_WAIT / RST / SYN 半连接
上周给一个客户查 [nginx](https://nginx.org/en/docs/) 502,抓了一下午包才发现是后端 Java 应用 `connection close` 逻辑写反了——后端每次都主动关,nginx keepalive 复用的时候正好赶上关,502 就来了。这种问题不看包永远猜不出来。TCP 排障不是背概念,是**看症状、选命令、抓证据**。
## 60 秒选型:ss / netstat / lsof 三选一
| 工具 | 什么时候用 | 一句话说明 |
|---|---|---|
| `ss` | 90% 的 TCP 排障场景 | 直接读内核 socket 表,快且能带进程名 |
| `netstat` | 只有 ss 装不了的老机器 | 慢,只走 /proc,没有 inode 和队列深度 |
| `lsof -i` | 查某个文件描述符对应哪个连接 | `ss -p` 的补充,看 socket 是打开的文件 |
三个工具都能拿到答案,但排障效率差得远。下面全程用 `ss` 和 `tcpdump`。
## 六症状决策树:先看报错再动手
先看症状,再选命令。症状对应错,命令再对也白搭。
```
用户说"TCP 有问题"或"nginx 502/504"
│
├─ 状态里 TIME_WAIT 一堆 ────── 主动关闭方堆积?调 tcp_tw_reuse
├─ 状态里 CLOSE_WAIT 一堆 ──── 应用代码没 close()?泄漏
├─ 状态里 SYN_RECV 飙升 ────── SYN 半连接攻击 or backlog 满了?
├─ 抓包看到大量 RST ─────────── 端口没监听 or 应用崩溃?
├─ 大包不通小包能通 ──────────── MTU 黑洞?看 MSS
└─ 小包交互明显卡 ───────────── Nagle + 延迟 ACK 双杀?
```
下面逐症状展开,每个都带可执行命令和"你应该看到什么"。
## 症状 1:TIME_WAIT 堆积 —— 主动关闭方出问题
```bash
# 一步看状态分布
ss -tan | awk '{print $1}' | sort | uniq -c | sort -rn
# 看内核层面的 socket 统计
ss -s
```
**你应该看到什么**:
- `TIME-WAIT 5000+` → 主动关闭方是这台机器,检查它是不是短连接客户端
- `ss -s` 里 `TCP: time wait (timewait)` 持续增长 → 有连接没走优雅关闭
- `ss -s` 里 `TCP: orphan` 数字大 → 有进程崩了但 socket 没释放
**修复思路**:
```bash
# 老内核(<4.12):直接复用 TIME_WAIT 端口
sysctl -w net.ipv4.tcp_tw_reuse=1
# 新内核(4.12+):默认已开启,检查是否被禁用
sysctl net.ipv4.tcp_tw_reuse
# 期望输出:net.ipv4.tcp_tw_reuse = 1
# 扩大本地端口范围
sysctl -w net.ipv4.ip_local_port_range="1024 65535"
```
**踩坑**:有人拿 `sysctl -w net.ipv4.tcp_tw_timeout=60` 缩短 TIME_WAIT。这个参数在 4.12 之后被删除了,跑完只提示 unrecognized pattern,不会生效。想加速释放,只能改 `tcp_fin_timeout`(默认 60 秒),但那管的是 FIN_WAIT_2,不是 TIME_WAIT。
## 症状 2:CLOSE_WAIT 堆积 —— 应用代码泄漏
```bash
# 找出谁在 CLOSE_WAIT
ss -tanp | grep CLOSE-WAIT | awk '{print $NF}' | sort | uniq -c | sort -rn
# 看具体 socket 对应的进程
ss -tanp | grep CLOSE-WAIT | head -20
```
**你应该看到什么**:
- 某一个进程(比如 Java 应用)占 CLOSE_WAIT 90% 以上 → 这个应用的 `close()` 逻辑写错了
- CLOSE_WAIT 数字一直涨不掉 → socket 泄漏,进程里没释放文件描述符
- CLOSE_WAIT 只有几十条但一直稳定 → 正常,网络波动下的自然残留
**关键理解**:CLOSE_WAIT 是**被动关闭方**发完第一个 FIN 后等对端发第二个 FIN 的状态。对端早就发了 FIN,你这头不发 ACK,就卡在这里。**根因永远在本地应用**,不在网络。
**修复思路**:
```bash
# 抓一下这个 socket 上的包,看对端什么时候发的 FIN
ss -tanp | grep CLOSE-WAIT | grep
# 拿到 IP:端口 后抓包
tcpdump -nn -i any host and port
```
**你应该看到什么**:抓包里有 `Flags [F.]` 或 `Flags [F.A]` 多次出现,本地一直没回 ACK——应用没读数据所以不发 ACK。
**踩坑**:以为调内核参数能救 CLOSE_WAIT,白搭。CLOSE_WAIT 是应用层 bug,只能改代码。有人上 `sysctl -w net.ipv4.tcp_max_orphans=65535` 想扩大孤儿连接池,那治的是 FIN_WAIT_2 和 orphan socket,不救 CLOSE_WAIT。
## 症状 3:SYN_RECV 飙升 —— 半连接攻击 or backlog 满
```bash
# 看半连接队列
ss -tan | grep SYN-RCV
# 看队列长度上限
sysctl net.ipv4.tcp_max_syn_backlog
sysctl net.core.somaxconn
```
**你应该看到什么**:
- `SYN-RCV` 数千上万条 → 可能是 SYN 洪泛攻击
- `tcp_max_syn_backlog` 只有 128 或 256(默认值)→ 高并发服务下队列会瞬间满
- `ss -s` 里 `TCP: prune called because of low memory` 出现 → 内存压力下内核强制回收
**修复思路**:
```bash
# 打开 SYN cookie(应对 SYN 洪泛)
sysctl -w net.ipv4.tcp_syncookies=1
# 调大半连接队列
sysctl -w net.ipv4.tcp_max_syn_backlog=4096
# 调大全连接队列(要和应用 listen() 的 backlog 参数一起)
sysctl -w net.core.somaxconn=4096
# 缩短 SYN 重传时间
sysctl -w net.ipv4.tcp_synack_retries=2
```
**踩坑**:改 `somaxconn` 没用,因为应用 `listen()` 时写的 backlog 参数没跟上。Nginx 里对应的是 `worker_connections` 和 `keepalive_requests`,Java 是 `listen(2)` 的 backlog 参数。**改了内核不看应用,等于改了个寂寞。**
## 症状 4:RST 乱飞 —— 谁在主动断
```bash
# 抓 RST 包,看源 IP
tcpdump -nn -i any 'tcp[tcpflags] & tcp-rst != 0' -c 100
# 只看某个端口的 RST
tcpdump -nn -i any port 8080 'tcp[tcpflags] & tcp-rst != 0' -w rst.pcap
```
**你应该看到什么**:
- RST 源 IP 是后端应用 IP → 后端主动拒绝(端口没监听、应用崩溃、协议不匹配)
- RST 源 IP 是 nginx / 中间设备 IP → 上游连接超时或防火墙切断
- RST 无前置数据(第一次握手后立刻 RST)→ 服务端根本没启动,或绑定了 127.0.0.1 而非 0.0.0.0
**关键判断**:看 RST 是不是紧跟在 SYN-ACK 之后。如果是——服务端收到 SYN,回完 SYN-ACK,客户端发 ACK 时服务端却回了 RST,说明 SYN-ACK 已经发出去但服务端立刻崩了。这是**服务端启动失败的典型征兆**。
**踩坑**:抓 RST 时不看时序,只看 RST 包数量。RST 有 1000 条不代表一定有问题——短连接客户端每次正常关闭都会产生 1-2 个 RST。**必须按时间戳排序看上下文**,把 RST 前面几个包放一起才看得出问题在哪。
## 症状 5:大包不通小包能通 —— MTU 黑洞
```bash
# 抓包看 MSS 值(最大报文段)
tcpdump -nn -i any port 443 -c 20 -v | grep MSS
# 对比两端 MTU
ip link show eth0 | grep mtu
ping -M do -s 1472 # [Linux](https://www.kernel.org/doc/html/latest/) 探测 MTU
```
**你应该看到什么**:
- 服务端返回 `MSS 1460`,客户端返回 `MSS 536` → 两端路径 MTU 不一致,大包被中间设备悄悄丢弃
- `ping -M do -s 1472` 通、`-s 1492` 不通 → 中间设备 MTU 是 1500,超过就丢
- 抓包看到大量 `TCP Retransmission` 集中在大包 → 大包被黑洞,小包正常
**修复思路**:
```bash
# 服务端强制下发小 MSS
iptables -t mangle -A POSTROUTING -p tcp --tcp-flags SYN,SYN -j TCPMSS --clamp-mss-to-pmtu
# 或者在 nginx 层限制
# 客户端 tcpdump 时加 -M do 强制不分片
tcpdump -nn -i eth0 -M do 'tcp port 443'
```
**踩坑**:`ping` 通了不代表 MTU 没问题。`ping` 默认不加分片,包大小 64 字节,永远能过。要用 `ping -M do -s 1472` 强制不分片探测——Linux 上超过 1472 字节的 ping 包被丢说明中间设备 MTU 只有 1500。**这一条很多人不知道,我在这里翻过一次车。**
## 症状 6:小包交互卡 40ms —— Nagle + 延迟 ACK 双杀
```bash
# 检查应用的 Nagle 设置(应用代码里)
# Netty / Java 需要显式设置
# netty: channel.config().option(ChannelOption.TCP_NODELAY, true)
# Java: socket.setTcpNoDelay(true)
# 抓包看时间戳间隔
tcpdump -nn -i any port 443 -A -tttt | head -50
```
**你应该看到什么**:
- 抓包显示两个小报文间隔**刚好 40ms** 或 **刚好 200ms** → Nagle 在等凑满 MSS,延迟 ACK 在等 40ms
- Telnet / SSH / 单字符输入延迟明显 → 典型的小包交互场景
- TCP_NODELAY 打开后(`-tttt` 时间戳间隔降到毫秒级)→ 确认是 Nagle 问题
**修复**:应用层开 `TCP_NODELAY`(禁用 Nagle)。**这里最容易翻车**——Netty 里 TCP_NODELAY 默认是 true,但你套了 SSL 之后如果没显式设置,就会掉回 false。这个坑很多 Java 团队踩过。
## Cheat Sheet:15 条常用 ss / tcpdump 姿势
| 命令 | 用途 |
|---|---|
| `ss -s` | 内核 socket 总数、各状态数 |
| `ss -tan` | TCP 全连接(含 listen) |
| `ss -tanp` | 带进程名和 inode |
| `ss -tan \| grep TIME-WAIT` | 只看 TIME_WAIT |
| `ss -tanp \| grep CLOSE-WAIT` | 找出 CLOSE_WAIT 泄漏的进程 |
| `ss -tlnp` | 只看 LISTEN 端口(配进程名) |
| `ss -tanp \| grep ESTABLISHED \| wc -l` | 数 ESTABLISHED 连接数 |
| `ss -tan \| awk '{print \$1}' \| sort \| uniq -c` | 状态分布统计 |
| `ss -tano` | 显示 Recv-Q / Send-Q 队列深度 |
| `ss -i` | 显示 TCP 详细信息(CUBIC 参数等) |
| `tcpdump -nn -i any port 8080` | 抓 8080 端口所有包 |
| `tcpdump -nn -i any 'tcp[tcpflags] & tcp-rst != 0'` | 只抓 RST |
| `tcpdump -nn -i any 'tcp[tcpflags] & tcp-syn != 0'` | 只抓 SYN |
| `tcpdump -nn -i any port 443 -c 100 -w x.pcap` | 抓 100 条存文件 |
| `tcpdump -nn -i any 'tcp port 443 and (tcp[13] & 2 != 0)'` | 抓大包(payload > 0) |
## 总结与判断
TCP 排障的核心是**分症状再动手**,六条路径覆盖 90% 场景。三条命令打底:`ss -s` 看队列、`ss -tanp` 看状态分布、`tcpdump -nn` 抓具体包。**别看到 nginx 502 就直接重启后端**——那是症状,不是病根。
**我的判断**:这套决策树覆盖个人运维和中小团队完全够用了。要更深入(BBR 拥塞控制调优、TCP Fast Open、连接迁移、QUIC 排障),得单独开专题。**这一点我也不完全确定**——QUIC 排障的工具链现在还在演进,QUIC 抓包分析器(如 qlog-viewer)的普及度我不确定,遇到具体问题还是查官方文档更靠谱。
## 延伸阅读
- [DNS 排障检测:dig +trace 六症状决策树](dns-troubleshoot.md)
- [Wireshark 抓包入门:53 端口 UDP 抓 DNS 请求](wireshark-basics.md)
- [Nginx 反向代理实战:502/504 三种根因决策树](nginx-proxy.md)
---
---
## WireGuard 站点到站点 VPN 实战:双网关互联与防火墙路由全解
URL: https://ops.jiunotes.top/network/wireguard-site-to-site-vpn-setup.md
摘要: WireGuard 站点到站点(site-to-site)VPN 怎么搭?本文给一套可复制的双网关配置,覆盖密钥生成、AllowedIPs 路由、禁 NAT、MSS 防 MTU 黑洞、开机自启与故障排查,附巡检纪律。
### Answer
WireGuard 站点互联用 wg-quick 在双网关建加密隧道,AllowedIPs 路由对端网段、禁 NAT、
MSS 夹 40 字节防 MTU 黑洞。静态站点走 /31 点对点小网段,systemd 开机自启并设开机顺序,iptables 只放行对端 UDP 端点。
### FAQ
Q: WireGuard 站点到站点和远程接入(road warrior)有什么不同?
A: 站点互联两端都是网关、长期在线、通常用静态端点,加密的是两个内网之间的流量;远程接入是一台笔记本或一个手机连回家里/公司,端点会变、常走 NAT。配置上站点互联用更小更纯粹的路由网段(如 /31),且两端都要允许对方的私网段进 AllowedIPs。
Q: 为什么不能对站点互联流量做 MASQUERADE(NAT)?
A: 站点互联的本质是"路由"不是"上网"。一旦在网关上做 MASQUERADE,对端看到的源地址会变成网关自己,对端内网的主机真实 IP 就丢了,回包也只对网关、不会回到原始主机。正确做法是纯路由:wg-quick 自动加对端网段路由,内网主机把网关当默认下一跳即可。
Q: /31 网段能用吗?会不会没有可用 IP?
A: 可以。RFC 3021 允许 /31 用于点对点链路,两个地址分别给两端网关。WireGuard 站点互联里网关本身才需要隧道 IP,内网主机不在隧道网段里,所以 /31 完全够用且最干净。如果某些老设备/系统不认 /31,退回 /30(两个可用主机地址)即可。
Q: 跨站点访问慢或某些应用连不上,多半是 MTU/MSS 问题吗?
A: 大概率是的。WireGuard 在真实物理 MTU 上又套了一层加密头,可用载荷比普通网小约 60-80 字节。TCP 的 MSS 没跟着缩,就会出现大包被悄无声息丢弃(MTU 黑洞),表现是 ping 通、小文件能传、大文件或 HTTPS 卡死。在接口上把 TCP MSS 夹小 40 字节(如 1380)基本能解决。
Q: 一端在 NAT 后面收不到对端流量怎么办?
A: 在 NAT 后的一端配置 PersistentKeepalive = 25,让它周期性发保活包打通 NAT 映射。站点互联两端最好都有静态端点(固定 IP 或域名);如果一端没有公网 IP,就只能让有公网 IP 的那端做被动接收方,NAT 后的一端主动维持连接。
### Full Content
# WireGuard 站点到站点 VPN 实战:双网关互联与防火墙路由全解
两个办公室、机房和家里实验室、或者总部与分支机构之间要互访内网,最省事的方案之一是 WireGuard 站点到站点(site-to-site)隧道。它比 OpenVPN 轻、比 IPsec 好配,一个 UDP 端口、一对密钥就能把两个网段打通。我自己在两个固定地点之间跑了两年,踩过的坑集中在"路由和 NAT 搞混""MSS 没夹导致大包丢""开机没自启"。这篇文章就讲一套能直接复制的双网关配置,以及上线后怎么不让它悄悄退化。
## 1. 什么场景需要站点到站点 VPN
先分清两种用法,别一套配置到处套:
- **站点到站点(本文主角)**:两端都是网关,背后各挂一个内网,目标是让这两个内网互相路由。两端长期在线、端点基本固定。
- **远程接入(road warrior)**:一台笔记本或手机临时连回总部,端点会变、常在 NAT 后面。
站点互联的痛点不是"加密"本身,而是"怎么让对端网段的流量走隧道、又不影响本网点对互联网的出站"。下面这套方案就是围绕这个核心。
## 2. 架构与前置条件
典型拓扑是两个站点各有一台接公网的网关,网关后面是各自的内网:
```
站点 A 内网 10.10.10.0/24 ── 网关 A (公网 IP A, 隧道 IP 10.10.9.0/31)
│ 加密 UDP 隧道 (端口 51000)
站点 B 内网 10.10.11.0/24 ── 网关 B (公网 IP B, 隧道 IP 10.10.9.1/31)
```
前置条件:
- 两端网关都能出公网、能互访对方的公网 UDP 端口(建议固定一个端口,比如 51000)。
- 两个内网网段**不能重叠**。如果都是 192.168.1.0/24,隧道建起来路由会打架,先改掉其中一个。
- 隧道只用一个小网段给两个网关自己(/31 足够),内网主机不需要隧道 IP。
- 两端都装好 WireGuard(Ubuntu/Debian 用 `apt install wireguard`,内核模块或 wireguard-go 都行)。
## 3. 生成密钥对
每台网关各自生成自己的私钥和公钥,公钥要发给对端写进对端的 Peer 配置:
```bash
# 在网关 A 上
wg genkey | sudo tee /etc/wireguard/wgA.key
sudo cat /etc/wireguard/wgA.key | wg pubkey | sudo tee /etc/wireguard/wgA.pub
# 在网关 B 上
wg genkey | sudo tee /etc/wireguard/wgB.key
sudo cat /etc/wireguard/wgB.key | wg pubkey | sudo tee /etc/wireguard/wgB.pub
```
注意私钥文件权限要收好(`chmod 600`),公钥可以放心发给对端。
## 4. 写网关配置
站点互联的两端是对称的,区别只在 IP 和端点。下面给网关 A 的完整配置,网关 B 把地址和端点对调即可。
网关 A(`/etc/wireguard/wgA.conf`):
```ini
[Interface]
Address = 10.10.9.0/31
ListenPort = 51000
PrivateKey = <网关 A 的私钥>
PostUp = iptables -A FORWARD -i %i -j ACCEPT
PostDown = iptables -D FORWARD -i %i -j ACCEPT
[Peer]
# 网关 B
PublicKey = <网关 B 的公钥>
Endpoint = <公网 IP B>:51000
AllowedIPs = 10.10.11.0/24, 10.10.9.0/31
# 如果 B 在 NAT 后,A 也加一行 PersistentKeepalive = 25
```
网关 B 把 `Address` 改成 `10.10.9.1/31`,`AllowedIPs` 改成 `10.10.10.0/24, 10.10.9.0/31`,`Endpoint` 指向 A 的公网 IP。关键点:**AllowedIPs 写的是"对端内网 + 隧道小网段"**,它同时告诉 WireGuard 两件事——哪些目标地址的包要进隧道、以及哪些来源地址的包允许解密进来。
## 5. 防火墙与路由要点
这一节是站点到站点最容易翻车的地方,记住一句话:**站点互联是路由,不是上网,绝对不要对隧道流量做 MASQUERADE。**
- `wg-quick` 启动时会自动按 AllowedIPs 加路由,内网主机只要把网关当默认下一跳(通常已经是),就能访问对端网段,不用在主机上改任何东西。
- 如果之前为了远程接入在 POSTROUTING 里写了 `MASQUERADE` 针对 wg 接口,**站点互联场景下要删掉**,否则对端主机看到的源 IP 变成你的网关,回包回不来。
- 出于安全,建议在两端各加一条只放行对端公网端点访问 51000 的规则,而不是对全网开放:
```bash
iptables -A INPUT -p udp --dport 51000 -s <对端公网 IP> -j ACCEPT
iptables -A INPUT -p udp --dport 51000 -j DROP
```
- 需要限制跨站点能访问什么(比如只允许办公网访问机房的特定服务),在 FORWARD 链上加细粒度规则,例如禁止跨站点 SSH:
```bash
iptables -A FORWARD -i wgA -p tcp --dport 22 -j REJECT
```
## 6. MTU 与 MSS 陷阱
WireGuard 在物理链路 MTU 之上又加了加密头,实际可用载荷比普通以太网少约 60-80 字节。如果不处理,TCP 大包会被悄无声息丢弃,现象很典型:**能 ping 通、能开小网页,但传大文件、跑 HTTPS 或数据库同步就卡死或中断**。这就是 MTU 黑洞。
最稳妥的修法是在 WireGuard 接口上把 TCP MSS 夹小:
```ini
[Interface]
MTU = 1380
# 或在 PostUp 里用 iptables 夹 MSS
PostUp = iptables -t mangle -A FORWARD -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --set-mss 1380
```
经验值:物理 MTU 1500 时,WireGuard 接口 MTU 设 1420,MSS 设 1380 基本通吃。如果中间还套了 VLAN 或 PPPoE(MTU 1492 甚至更低),相应再减。别瞎设成 1400 以下,太小会多分片、降吞吐。
## 7. 启动与连通验证
两端都用 systemd 管理,保证重启后自动起来:
```bash
sudo systemctl enable --now wg-quick@wgA # 网关 A
sudo systemctl enable --now wg-quick@wgB # 网关 B
# 看隧道状态和握手
sudo wg show
# 期望看到 latest handshake 是最近的时间、transfer 有数据
# 从 A 内网主机 ping B 内网主机
ping 10.10.11.x
# 再看路由表确认对端网段走了 wg 接口
ip route | grep wg
```
`wg show` 里如果没有 handshake、transfer 一直是 0,说明端点不可达或密钥/AllowedIPs 不对,回到第 4 节核对。
## 8. 常见故障排查
- **握手成功但 ping 不通对端内网**:九成是 AllowedIPs 没写对端内网段,或本端内网主机没把网关当默认路由。用 `ip route` 确认对端网段下一跳是 wg 接口。
- **能 ping 小包、大文件必败**:MTU/MSS,见第 6 节。
- **一端在 NAT 后收不到流量**:加 `PersistentKeepalive = 25` 打通 NAT 映射。
- **重启后隧道没起来**:确认 `systemd enable` 了,且配置路径是 `/etc/wireguard/wgA.conf` 与 `wg-quick@wgA` 对应。
- **跨站点某些服务连不上但网络通**:多半是你在 FORWARD 或对方防火墙挡了对应端口,逐条排查链规则,别上来就怀疑 WireGuard。
## 9. 上线后的巡检与防退化纪律
站点互联最容易"上线好好的,三个月后悄悄坏",靠这几条纪律兜住:
- **监控握手与流量**:把 `wg show` 的 latest handshake 和 transfer 接进你的监控(Prometheus node-exporter 或黑盒探测都行),超过 N 分钟没握手就告警。别等用户投诉"机房连不上"才发现。
- **端点 IP 变动要同步**:如果某一端公网 IP 是动态的,用动态域名而不是写死 IP;改了域名或 IP,两端 Endpoint 一起改,并重新握手验证。
- **禁 NAT 是铁律**:任何人在网关上加 MASQUERADE 规则前,先确认它不是作用在 wg 接口上。建议把站点互联的 FORWARD 放行规则和 MSS 夹规则写进配置文件的 PostUp,随隧道一起加载,避免手动加完某天被清掉。
- **密钥定期轮换但有记录**:WireGuard 没有证书体系,换密钥就是改 PrivateKey/PublicKey 并同步对端。轮换前先在维护窗口通知,改完用 `wg show` 和跨站点 ping 双重确认,旧密钥在确认稳定后再删除。
- **配置进版本管理**:`/etc/wireguard/*.conf` 抽成模板进 Git,密钥单独用 secrets 管理,谁改了哪台网关、AllowedIPs 改了什么,回滚就是 `git revert`。不上这套,设备一多(多分支、多站点)随便改一个 AllowedIPs 就全乱。
- **不要为"图省事"把站点互联和远程接入混在同一接口**:两者策略不同(一个禁 NAT、一个常 NAT),分开两个接口(wgA 站点到站点、wg0 远程接入)清晰得多,排错也快。
## FAQ
WireGuard 站点到站点和远程接入(road warrior)有什么不同?
站点互联两端都是网关、长期在线、通常用静态端点,加密的是两个内网之间的流量;远程接入是一台笔记本或一个手机连回家里/公司,端点会变、常走 NAT。配置上站点互联用更小更纯粹的路由网段(如 /31),且两端都要允许对方的私网段进 AllowedIPs。
为什么不能对站点互联流量做 MASQUERADE(NAT)?
站点互联的本质是"路由"不是"上网"。一旦在网关上做 MASQUERADE,对端看到的源地址会变成网关自己,对端内网的主机真实 IP 就丢了,回包也只对网关、不会回到原始主机。正确做法是纯路由:wg-quick 自动加对端网段路由,内网主机把网关当默认下一跳即可。
/31 网段能用吗?会不会没有可用 IP?
可以。RFC 3021 允许 /31 用于点对点链路,两个地址分别给两端网关。WireGuard 站点互联里网关本身才需要隧道 IP,内网主机不在隧道网段里,所以 /31 完全够用且最干净。如果某些老设备/系统不认 /31,退回 /30(两个可用主机地址)即可。
跨站点访问慢或某些应用连不上,多半是 MTU/MSS 问题吗?
大概率是的。WireGuard 在真实物理 MTU 上又套了一层加密头,可用载荷比普通网小约 60-80 字节。TCP 的 MSS 没跟着缩,就会出现大包被悄无声息丢弃(MTU 黑洞),表现是 ping 通、小文件能传、大文件或 HTTPS 卡死。在接口上把 TCP MSS 夹小 40 字节(如 1380)基本能解决。
一端在 NAT 后面收不到对端流量怎么办?
在 NAT 后的一端配置 PersistentKeepalive = 25,让它周期性发保活包打通 NAT 映射。站点互联两端最好都有静态端点(固定 IP 或域名);如果一端没有公网 IP,就只能让有公网 IP 的那端做被动接收方,NAT 后的一端主动维持连接。
## 相关文章
- [dnsmasq 配置深度实战:一份完整模板 + 中文网络下的 4 个真实踩坑](dnsmasq-config.md)
- [iptables 端口转发实战:DNAT/SNAT 与 NAT 回环避坑](iptables-port-forwarding.md)
- [零信任网络入门:从边界防御到身份驱动的访问控制](zero-trust-network.md)
---
## Wireshark 抓包基础
URL: https://ops.jiunotes.top/network/wireshark-basics.md
摘要: Wireshark 30 条常用过滤器、关键字段速查、TCP 重传、HTTP/2 过滤、TLS 抓包。含 tcpdump 命令行对照。
### Answer
Wireshark 常用过滤器 30 条,从 TCP 重传(`tcp.analysis.retransmission`)到 HTTP/2(`http2`)到 TLS(`tls.handshake`)。命令行常用 `tcpdump -i any host and port `。
### FAQ
Q: 网络慢,Wireshark 先看什么?
A: 先看 TCP 重传(tcp.analysis.retransmission)和 RTT 异常(tcp.analysis.ack_rtt)。重传多说明丢包,RTT 大说明链路拥塞,再对症下药。
Q: 如何快速定位是服务端还是客户端问题?
A: 看 SYN 与 SYN-ACK 间隔。客户端发 SYN 后长时间没 SYN-ACK,是服务端或中间链路问题;有 RST 立即返回,是端口未监听或防火墙拦截。
Q: 抓包看到大量 TCP Retransmission 怎么办?
A: 先排除抓包软件自身丢包(Check for dropped packets),再查链路 MTU、交换机端口错误计数、链路利用率。用 tshark 统计重传源地址定位。
Q: HTTPS 抓包解密怎么做?
A: 用 SSLKEYLOGFILE 环境变量导出会话密钥(浏览器/应用支持),Wireshark 里配置 TLS 密钥日志文件路径即可解密。注意只在授权范围做。
### Full Content
# Wireshark 抓包基础
## 抓包前先想清楚三个问题
我抓包第一反应从来不是打开软件就开始录,而是先定三件事,省得抓完一万个包却答不上关键问题:
1. **在哪个网卡抓**:抓包机要和问题链路「同一路由」,跨网段抓不到中间链路。一次我在 A 机房抓 B 机房的 502,抓出来全是本机回环流量,白忙半小时——根因是两台机不在同一二层域,得在网关或目标机本地各抓一份对拍。
2. **抓多大数据**:默认别无限抓。先用过滤器限 `host <问题机> and port <端口>`,控制文件体积,也方便事后快速定位。
3. **时间窗口要对齐**:故障是 14:23 发生的,你要抓 14:20-14:30,别从头一直抓到尾。抓之前把系统时间、NTP 同步确认好,不然回溯时对不上监控曲线。
我固定做的第一件事是抓包前 `ethtool -S <网卡>` 记一下丢包计数,抓完再对比一次——如果抓取端自己就丢了包,那分析出来的「重传」可能是抓包软件丢的,不是真实链路丢的。
## 常用过滤器(30 条)
### 网络层
- `ip.addr == 1.2.3.4`
- `tcp.port == 80`
- `udp.port == 53`
- `icmp.type == 8`(Ping Request)
### TCP 分析
- `tcp.analysis.retransmission` —— 重传
- `tcp.analysis.dup_ack` —— 重复 ACK
- `tcp.analysis.zero_window` —— 窗口关闭
- `tcp.flags.syn == 1 and tcp.flags.ack == 0` —— SYN
- `tcp.flags.reset == 1` —— RST
### HTTP / HTTPS
- `http.request.method == "GET"`
- `http.response.code == 502`
- `tls.handshake.type == 1` —— Client Hello
### DNS
- `dns.flags.response == 1`
- `dns.qry.name == "example.com"`
## 追踪 TCP 流:一次看全
过滤器能圈出「这一类包」,但排查时往往要看「这一条连接从握手到断开的全过程」。这时用「追踪流」(Follow TCP Stream / Follow UDP Stream)最直观:
- 右键某个包 → **Follow → TCP Stream**,能并排看到客户端和服务端的完整对话(明文 HTTP 直接可读,TLS 看到的是密文)。
- 我排 nginx 502 时,Follow 上去一眼看到:客户端请求正常发出,nginx 侧回了个 `502 Bad Gateway`,后面立刻 `RST`。跟着这条流翻到最上游,才发现是 upstream 的 8080 端口的后端进程在 `tcpdump` 里根本没监听——应用挂了,不是网络问题。
配合两个统计利器能少翻很多包:
```bash
# tshark 统计重传最多的源(快速锁定是哪台在重传)
tshark -r input.pcap -Y 'tcp.analysis.retransmission' -T fields -e ip.src | sort | uniq -c | sort -rn | head
# 看每个流的状态(完成 / 被重置 / 还在传)
tshark -r input.pcap -T fields -e _ws.col.Frame_Info | grep -i 'reset\|fin' | head
```
## 一次真实 502 排障记录
某次接口偶发 502,监控只有零星尖刺,抓包才定位到:后端某台 pod 的 kernel 在负载高时 **socket 缓冲区耗尽**,表现为大量 `tcp.analysis.zero_window`(发送方窗口被压成 0)。我在 Wireshark 里先过滤 `tcp.analysis.zero_window` 确认了这个特征,再用 `tshark -Y 'tcp.analysis.zero_window' | wc -l` 量化了窗口关闭次数随时间的分布,对上了压测时间点。修复是调大 `net.core.netdev_max_backlog` 和 socket 缓冲参数,配合 nginx `proxy_read_timeout` 收紧,`zero_window` 基本归零。
这篇侧重「怎么用工具看」,定位到具体 502/504 根因的完整链路(含 nginx 侧配置对照)见 [Wireshark 抓包实战:定位 nginx 502 / 504 根因](wireshark-nginx-troubleshoot.md)。
## tcpdump 命令行对照
```bash
# 抓特定主机
tcpdump -i any host 1.2.3.4 -nn -c 100
# 抓特定端口
tcpdump -i any port 443 -nn
# 抓 RST
tcpdump -i any 'tcp[tcpflags] & (tcp-rst) != 0'
```
## FAQ
- q: [Wireshark](https://www.wireshark.org/docs/) 抓不到包怎么办?
a: 最常见的三个原因:① 网卡选错了——默认选的是 loopback 或虚拟网卡,选对物理网卡;② 没有权限——[Linux](https://www.kernel.org/doc/html/latest/) 下需要 root 或加入 pcap 用户组;③ 过滤器写错了——先清空过滤器看看有没有原始流量,确认有流量后再加过滤条件。
- q: 怎么抓 HTTPS 内容?
a: HTTPS 加密后直接抓包看不到明文。两种思路:① 在客户端安装 Wireshark 的 CA 证书(Chrome/ Firefox 可以配置信任根证书),解密 TLS;② 在代理层抓包(如 mitmproxy),代理解密后再转发。生产环境慎用方法一,涉及敏感数据有风险。
- q: 怎么找出网络中的重传和丢包?
a: 用过滤器 `tcp.analysis.retransmission` 抓重传,`tcp.analysis.duplicate_ack` 抓重复 ACK。重传通常是拥塞或丢包的信号,结合 `tcp.time_delta` 看间隔。如果重传集中发生在某个 IP,优先排查那个节点的网络链路。
- q: tcpdump 和 Wireshark 怎么选?
a: 远程服务器只能命令行,用 tcpdump 抓完拷回本地分析;本地图形界面首选 Wireshark,可视化过滤和追踪 TCP 流更方便。两者过滤器语法基本一致,学会一个另一个很快上手。
- q: 抓包文件太大怎么处理?
a: 先用过滤器缩小范围再抓(比如 `host 1.2.3.4 and port 80`),不要无脑全量抓。如果已经抓大了,用 Wireshark 的"文件 → 导出特殊分组"按条件导出子集,或者用 `tshark -R "tcp.port==80" -r input.pcap -w output.pcap` 命令行裁剪。
## 常见误判与「抓包陷阱」
抓包最怕的不是抓不到,而是**抓到了但读反了**。我反复踩过的几个:
- **把抓包端自身丢包当成链路丢包**:`tcp.analysis.dropped_frames` 是抓包软件(内核 ring buffer)丢的,不代表网络真丢包。分析前先确认 ring buffer 够不够大、`eth -S` 有没有 `rx_missed`,再谈链路。
- **把重传当成丢包,但其实是慢 ACK**:`retransmission` 有时是中间节点延迟导致 ACK 迟到触发 PTO(persistence timer),不是真丢。配合 `tcp.analysis.fast_retransmission` 与窗口曲线区分:fast retransmission 多为真丢,PTO 重传要再看 RTT 抖动。
- **TLS 握手上看到的「失败」其实是证书 / cipher 协商问题**:抓包只看到 `TLSV1.3 handshake` 后直接 `alert`,得看 alert 类型(`bad_certificate` / `handshake_failure`)才能定位到是证书过期、SNI 错配还是 cipher 不支持。
- **时间戳不可信**:抓包机和服务器的 NTP 没对齐,Follow 流里的时间差全是错的,排障时先 `chrony -q` 确认同步。
我的经验是把「抓包 + 计数器 + 日志」三件套配合,不只看包:`ethtool -S` 的计数器看物理 / 内核层丢包,`ss -ti` 看 socket 层缓冲与重传,`dmesg` 看有没有网卡 / 驱动告警,包只是证据链的一环。单靠 Wireshark 看包容易在「最后一公里」误判,三件套交叉验证才稳。
## 抓包前确认同步与权限
开始之前花十秒把两个基础项确认掉,能避开一大类「假现象」:
- **NTP 同步**:`chrony tracking` 或 `timedatectl status` 看 `NTP synchronized: yes`。抓包机、网关、目标机三方时间对齐,Follow 流里的时间戳才有意义。时间没对齐时,跨机抓包对拍几乎无法判断先后。
- **抓包权限**:非 root 抓包需要把用户加入 `wireshark`/`pcap` 组并 `capsh` 配置 `+ep cap_net_raw,cap_net_admin`。生产机常用非特权抓包减少风险,但配置不当时会静默丢包或只抓到部分流量。
- **网卡选对**:多网卡机器先 `ip -br link` 看物理网卡名,别抓到 `lo`、`veth*`、`docker0` 上。容器化环境里,Pod 流量走 `veth` 与宿主 `eth0` 是两套命名空间,跨命名空间要各抓一份。
## 相关文章
- [TCP/IP 状态机](tcp-ip.md)
- [Wireshark 抓包实战:定位 nginx 502 / 504 根因的完整排查链路](wireshark-nginx-troubleshoot.md)
- [CDN 原理:回源、预热、缓存失效](cdn-principles.md)
---
## Wireshark 抓包实战:定位 nginx 502 / 504 根因的完整排查链路
URL: https://ops.jiunotes.top/network/wireshark-nginx-troubleshoot.md
摘要: 从 nginx 502/504 报错到 Wireshark 抓包定位根因,覆盖 RST 来源识别、TIME_WAIT 堆积验证、upstream keepalive 复用心跳、TCP MTU 黑洞四个实战场景,含 tshark 命令行一键过滤脚本。
### Answer
nginx 502/504 排障核心:先确认 RST 是谁发的(`tcpdump 'tcp[tcpflags] & tcp-rst != 0'`),
再看 TIME_WAIT 堆积(`ss -tan \| awk '{print $1}' | sort | uniq -c`),
最后验 keepalive 和 MTU。Wireshark 过滤 `tcp.flags.rst == 1 || tcp.analysis.retransmission` 最实用。
tshark 可一键过滤,无需 GUI。
### FAQ
Q: 502 和 504 有什么区别,抓包怎么看出来?
A: 502 是 nginx 连不上后端(TCP 三次握手失败 / RST),504 是后端连上了但一直不回响应(超时)。 抓包看:502 → 看到 RST 或 SYN 无回复;504 → 看到握手成功但无 DATA 包,超时后 nginx 发 RST 关连接。
Q: Wireshark 和 tcpdump 什么时候用哪个?
A: 快速确认问题用 tcpdump/tshark(命令行,适合远程服务器); 深度分析用 Wireshark GUI(图形化、过滤强、能按流追踪)。 生产服务器一般没 GUI,先 tcpdump 抓到 .pcap 再拉到本机用 Wireshark 分析。
Q: 抓包命令怎么写最省事?
A: 一行搞定:`tcpdump -nn -i any port 80 or port 8080 -w /tmp/nginx.pcap &` 然后复现 502,再 `kill %1` 停抓包。 用 tshark 一行过滤更狠:`tshark -f 'tcp port 8080 and (tcp[13] & 4 != 0)' -T fields -e ip.src -e ip.dst -e tcp.flags`
Q: Wireshark 里怎么追踪 TCP 流?
A: 右键任意 TCP 包 → Follow → TCP Stream,一键还原完整会话,一眼看出谁先发 RST、谁超时。 配合统计菜单里 Endpoint 和 Conversations 视图,能快速定位流量异常方。
Q: 如何判断是 MTU 黑洞而不是连接问题?
A: 特征:小包通、大包不通,telnet 能通但 curl 报 timeout。 Wireshark 过滤 `tcp.len > 1460` 看是否有分片丢包; 服务端 `ip link show` 看 MTU,客户端 `tcpdump -nn 'tcp[tcpflags] & tcp-syn != 0'` 看 MSS 协商值。
### Full Content
# Wireshark 抓包实战:定位 nginx 502 / 504 根因的完整排查链路
上周帮一个客户查 [nginx](https://nginx.org/en/docs/) 502,问题拖了三天——前两个运维看日志只看到 502,没有更多线索。我上去抓了个包,10 分钟定位:是后端 Java 应用开了 `keepalive` 但没设心跳,nginx 的 keepalive 连接过了空闲超时被中间防火墙切掉,复用时报 502。**只看日志永远查不出来,必须抓包**。
## 为什么 502/504 必须抓包
nginx 错误日志里 502 通常只写:
```
upstream timed out (110: Connection timed out) while connecting to upstream
```
`110` 是 [Linux](https://www.kernel.org/doc/html/latest/) 内核错误码,翻译成人话就是"连不上"。但**为什么连不上**,可能原因有十几个:
- 后端进程崩溃了
- 后端端口变了(健康检查没同步)
- 中间防火墙切了 keepalive 连接
- TCP TIME_WAIT 堆积占满端口
- MTU 不匹配导致大包丢失
- DNS 解析慢了
**日志看不出来,抓包才能看见是谁发了 RST、谁超时了、有没有分片丢包。**
## 第一步:用 tcpdump 抓原始包
生产服务器通常没有 [Wireshark](https://www.wireshark.org/docs/) GUI,先用 tcpdump 抓到 .pcap 文件,再拉到本机分析。
```bash
# 抓 nginx 与后端的通信(假设后端在 8080 端口)
tcpdump -nn -i any 'port 8080' -w /tmp/nginx.pcap &
# 用 & 放后台,复现一次 502,然后 kill %1 停抓
```
关键参数说明:
- `-nn` 不解析域名和端口名,直接显示 IP 和数字端口,速度快且不触发 DNS 查询干扰
- `-i any` 抓所有网卡,适合多网卡环境
- `-w file.pcap` 保存原始包,后续用 Wireshark 打开
**抓完记得及时停**,不然 `/tmp/nginx.pcap` 会越来越大,压垮磁盘。
## 第二步:用 tshark 命令行过滤关键包
不需要 GUI 时,tshark 是最快的过滤工具。
```bash
# 只看 RST 包(谁发出来的决定了是客户端还是服务端问题)
tshark -r /tmp/nginx.pcap -Y 'tcp.flags.rst == 1' -T fields -e ip.src -e ip.dst -e tcp.seq
# 只看重传包(网络拥塞或丢包的铁证)
tshark -r /tmp/nginx.pcap -Y 'tcp.analysis.retransmission' -T fields -e ip.src -e ip.dst
# 只看 TIME_WAIT 状态的包
tshark -r /tmp/nginx.pcap -Y 'tcp.flags.fin == 1' -T fields -e ip.src -e ip.dst -e tcp.seq
# 只看大于 1460 字节的 TCP 载荷(MTU 问题信号)
tshark -r /tmp/nginx.pcap -Y 'tcp.len > 1460' -T fields -e ip.src -e ip.dst -e tcp.len
```
这四个过滤器覆盖了 80% 的 nginx 502/504 根因。
## 第三步:拉到本机用 Wireshark 深度分析
把 .pcap 文件拖到本机 Wireshark,重点看三个地方。
### 3.1 Follow TCP Stream —— 一锤定音
找到任意一条 nginx ↔ 后端的 TCP 会话,右键 → **Follow → TCP Stream**。
你会看到完整的六步对话:
```
Client (nginx) Server (backend)
SYN ─────────────────────────────────────→
←──────────────────────────────────── SYN-ACK
ACK ─────────────────────────────────────→
DATA (POST /api/xxx) ──────────────────→
(无响应,30s 后)
Client sends RST ─────────────────────→
```
最后这一行 RST 如果是 nginx 发的 → 后端超时(504);如果是后端发的 → 后端主动断连(502)。
### 3.2 Endpoint 统计 —— 看流量分布
**Statistics → Endpoints**,按字节数排序,一眼看出哪台机器是流量异常方。
如果后端 IP 的出站字节数接近 0 但入站正常 → 后端没有响应数据。
### 3.3 Conversations 视图 —— 看连接模式
**Statistics → Conversations → IPv4**,切换显示为 **TCP**,可以看到:
- 连接总数
- 每个连接的 Duration(持续时长)
- Packet 数 / Byte 数
**翻车信号**:大量连接 Duration 为 0,说明都是瞬时建立就断开的,典型 keepalive 配置错误或防火墙切连。
## 四个实战场景速查
### 场景 A:RST 来自后端
**现象**:Wireshark 里 nginx 发 SYN,后端回 RST。
**原因**:后端服务挂了,或端口监听地址不对(比如只监听了 127.0.0.1 没听 0.0.0.0)。
**验证命令**:
```bash
ss -tlnp | grep 8080
# 应该看到 LISTEN 0.0.0.0:8080,不是 127.0.0.1:8080
```
### 场景 B:RST 来自 nginx
**现象**:握手成功,nginx 发数据后长时间无响应,最后 nginx 发 RST。
**原因**:后端处理超时,nginx `proxy_read_timeout` 默认 60s,超时主动断。
**修复方向**:
```nginx
location / {
proxy_read_timeout 120s; # 给后端更多时间
proxy_send_timeout 120s;
proxy_connect_timeout 10s;
}
```
### 场景 C:TIME_WAIT 堆积
**现象**:`ss -tan | awk '{print $1}' | sort | uniq -c | sort -rn` 看到 TIME-WAIT 几千个。
**原因**:nginx 作为主动关闭方,短时间内大量建连断连,TIME_WAIT 来不及回收。
**影响**:新连接建不上,表现为间歇性 502。
**修复**:
```bash
# Linux 4.19+ 安全启用(内核有 SYN 攻击检测保护)
sysctl -w net.ipv4.tcp_tw_reuse=1
# 扩大 ephemeral port 范围
sysctl -w net.ipv4.ip_local_port_range="1024 65535"
```
### 场景 D:MTU 黑洞
**现象**:小包能通,大包不通;telnet 能连上但 curl 超时。
**验证**:
```bash
# 看本地 MTU
ip link show | grep mtu
# 抓 SYN 包看 MSS 协商值(MTU - 40 = MSS)
tcpdump -nn -i any 'tcp[tcpflags] & tcp-syn != 0' -c 10
```
**修复**:两端 MTU 对齐,或开启 PMTU 发现(`net.ipv4.tcp_mtu_probing=1`)。
## tshark 一键排障脚本
把上面四个场景串成一个脚本,一条命令出结论:
```bash
#!/bin/bash
# wh-examine.sh - Wireshark 502 快速诊断
PCAP=${1:-/tmp/nginx.pcap}
[ ! -f "$PCAP" ] && echo "用法: $0 " && exit 1
echo "=== 1. RST 来源 ==="
tshark -r "$PCAP" -Y 'tcp.flags.rst == 1' -T fields -e ip.src -e ip.dst 2>/dev/null | head -5
echo -e "\n=== 2. 重传统计 ==="
tshark -r "$PCAP" -Y 'tcp.analysis.retransmission' 2>/dev/null | wc -l | xargs echo "重传包数:"
echo -e "\n=== 3. TIME_WAIT 相关 FIN 包 ==="
tshark -r "$PCAP" -Y 'tcp.flags.fin == 1' 2>/dev/null | wc -l | xargs echo "FIN 包数:"
echo -e "\n=== 4. 大包 (>1460B) 检查 ==="
tshark -r "$PCAP" -Y 'tcp.len > 1460' 2>/dev/null | wc -l | xargs echo "大包数:"
echo -e "\n=== 5. 异常连接(Duration=0 的 TCP 会话)==="
tshark -r "$PCAP" -Y 'tcp' -T fields -e ip.src -e ip.dst 2>/dev/null \
| sort | uniq -c | sort -rn | head -5
```
## 排查流程速查表
| 症状 | 优先看什么 | 关键过滤器 / 命令 |
|---|---|---|
| 间歇性 502 | RST 来源 | `tcp.flags.rst == 1` |
| 固定 URL 超时 504 | TCP Stream 长度 | Follow TCP Stream |
| 高峰期才 502 | TIME_WAIT 堆积 | `ss -tan \| awk '{print $1}'` |
| 小包通大包不通 | MTU / MSS | `tcp.len > 1460` |
| 连接建立后无响应 | 重传包 | `tcp.analysis.retransmission` |
## 生产环境的持续抓包纪律
上面那套是「出事了再抓」。真正拉开差距的是**平时就有包可抓、抓了就能定位**,我在生产环境靠下面几条纪律兜底。
### 别让抓包变成一次性动作
生产 nginx 节点我常备一条后台抓取,只在高峰期或排障时开,平时关掉避免拖垮磁盘:
```bash
# 带大小上限 + 轮转,防止 /tmp 被打满
tcpdump -nn -i any 'port 80 or port 8080' -w /tmp/nginx.pcap -C 50 -W 4 &
# -C 50 每 50MB 切一个文件,-W 4 最多保留 4 个,自动轮转
```
关键是 `-C`/`-W` 这两个参数——我见过一次因为忘了停 tcpdump,两天后 `/tmp` 被 pcap 撑爆、连登进去重启都卡住。带上轮转上限,抓包就不再是「用完记得关」的人肉纪律,而是自限的。
### 502/504 要和后端、网络两条线同时抓
只看 nginx 一端永远差一层。定位根因时我固定「两端同时抓」:nginx 节点抓 80/443 入向,后端应用节点抓 8080,中间如果有 LB/防火墙也在它上面抓一份。三份 pcap 用**同一时间戳**对齐(提前 `chronyd` 同步 NTP),再交叉看 RST 到底是谁先发,结论才站得住。单端抓包很容易把「中间设备切的连接」误判成「后端自己断」。
### 容量与 TIME_WAIT 的常态化监控
502 不一定要等出事才查。我把两条量挂进 Prometheus:`ss -tan | awk '{print $1}' | grep -c TIME-WAIT` 每 30 秒采一次成 gauge,配一条「TIME_WAIT 数量 10 分钟持续上涨」的告警;再配合 nginx 的 `stub_status` 看 active/waiting 连接。这样在客户投诉之前,端口耗尽、连接堆积这些「迟早要 502」的信号就被提前抓住了,处理窗口从「故障后 10 分钟抓包」前移到「趋势异动时调 keepalive 参数」。
### 复盘模板
每次排完 502/504,我强制把结论填进一个固定模板存档:**症状 → 关键过滤器 → 抓到的铁证包 → 根因 → 改了什么配置 → 如何防止复发**。半年下来这份存档本身就是最好的排障知识——下次再遇到「间歇性 502」,先翻存档看是不是同一类根因,省掉重新试错的时间。
## 写在最后
nginx 502/504 的本质是**网络层发生了什么,应用层只记录了结果**。抓包是把结果逆推到原因的唯一可靠方法。学会 Follow TCP Stream 和这四个过滤器,80% 的排障不再需要猜。
## 相关文章
- [Docker 环境下 nginx 502 排查:127.0.0.1 vs 服务名、DNS 解析、健康检查五坑指南](docker-nginx-502.md)
- [TCP 排障决策树:ss + tcpdump 三件套定位 TIME_WAIT / RST / SYN 半连接](tcp-troubleshoot.md)
- [nginx 504 gateway timeout 排查:6 类根因决策树](nginx-504-troubleshoot.md)
---
## 零信任网络架构:从概念到企业落地
URL: https://ops.jiunotes.top/network/zero-trust-network.md
摘要: 零信任不是产品而是理念:身份为根、持续验证、最小权限三原则。含架构决策树、身份系统集成方案、NAC 对接实战、常见踩坑清单。
### Answer
零信任三原则:身份为根、持续验证、最小权限。落地顺序是身份系统 → NAC 对接 → 微分段 → SASE/ZTNA。最容易被低估的是身份治理——没有干净的身份数据,零信任就是空中楼阁。常见翻车点:把零信任当成买一个产品、忽略设备信任只关注用户信任、策略太松等于没策略。
### FAQ
Q: 零信任和传统边界安全有什么区别?
A: 传统安全假设"内网可信",零信任假设"网络不可信"。传统安全在边界上做检查,零信任在每个请求上做检查。最大的改变是:身份取代位置成为安全决策的基础。
Q: 零信任必须上 SASE 吗?
A: 不一定。零信任是理念,SASE 是实现方式之一。可以用微分段 + NAC + 身份系统实现零信任核心。SASE 更适合分支分散、远程办公多的场景。
Q: 身份系统没做好能上零信任吗?
A: 不能。身份是零信任的根基。没有干净的账号体系、没有 MFA、没有设备管理,零信任策略无从下手。先花 1-2 个月把身份治理做好再启动零信任项目。
Q: 零信任上线后怎么持续监控策略是否还在生效?
A: 盯三件事:① 放行率突增(策略被改松或默认允许漏网)② 审计日志里出现"高敏应用 + 弱设备"的异常组合 ③ 离职账号是否 5 分钟内禁用。 把这三个做成周报表,策略被谁动了、动到什么程度一目了然。零信任最怕的是上线后慢慢被"改回默认允许",靠报表才能守住底线。
### Full Content
# 零信任网络架构:从概念到企业落地
零信任这个词被炒了五年,很多人以为买一个 ZTNA 产品就等于上了零信任。错了。零信任是一种架构理念,它的核心是"永不信任、始终验证"——身份为根,每一次访问请求都要重新验证,而不是一旦进门就一路绿灯。
这篇文章不谈概念,直接说怎么落地:身份系统集成、NAC 对接、微分段策略、以及几个容易翻车的点。
## 零信任三原则,先搞清楚
| 原则 | 含义 | 落地手段 |
|---|---|---|
| **身份为根** | 每个请求都要验证身份,不信任网络位置 | IdP + MFA + 设备信任 |
| **持续验证** | 不是一次验证终身有效,每次访问都要重新评估 | 行为分析 + 动态策略 |
| **最小权限** | 只给访问所需的最小权限,不多给 | 基于身份的 ACL + 微分段 |
三个原则缺一不可。只做了身份验证没做持续验证,等于传统零信任(进门验身份证但不监控行为)。只做最小权限没有身份验证,权限分配没有依据。
## 落地路线图:四个阶段别跳步
### Phase 1:身份治理(1-2 个月)
这是最容易被低估的阶段。没有干净的身份数据,后面所有策略都是空中楼阁。
```bash
# 盘点现有身份源
# 1. 找出所有身份系统
find /etc -name "*.conf" | xargs grep -l "ldap\|saml\|oauth" 2>/dev/null
# 2. 导出用户列表(示例:LDAP)
ldapsearch -x -H ldap://idp.internal -b "dc=company,dc=com" \
"(objectClass=person)" uid employeeType
# 3. 检查 MFA 覆盖率
# 检查各系统的 MFA 启用率
mysql -e "SELECT provider, COUNT(*) FROM mfa_settings GROUP BY provider;"
```
**关键指标**:
- 100% 关键账号(管理员、研发、财务)有 MFA
- 离职账号 5 分钟内禁用
- 身份数据来自单一来源(SSO)
**踩坑**:某公司先上了 ZTNA,发现身份系统里有 200 个离职账号还在活跃。零信任变成"零清除"——该访问的还是能访问。**身份治理必须先于零信任上线。**
### Phase 2:NAC 对接(2-4 周)
网络访问控制(NAC)是零信任的入口。设备没进 NAC,就进不了内网。
```bash
# 常见 NAC 设备对接(以 Aruba ClearPass 为例)
# 1. 注册终端设备
# 终端安装 agent 或走 802.1x
# 2. 策略引擎配置
# 设备类型识别 → 合规检查 → 分配 VLAN/ACL
# 3. 与 IdP 集成
# RADIUS → LDAP/Okta 验证身份
```
```python
# 示例:设备合规检查脚本([Python](https://docs.python.org/))
def check_device_compliance(device_id):
"""检查设备是否符合安全基线"""
checks = {
"disk_encryption": check_bitlocker_or_fips(device_id),
"antivirus": check_endpoint_protection(device_id),
"os_updates": check_patch_level(device_id),
"jailbreak": check_jailbreak_status(device_id),
}
# 全部通过才放行
if all(checks.values()):
return {"action": "allow", "vlan": "corporate"}
elif sum(checks.values()) >= 3:
return {"action": "quarantine", "vlan": "remediation"}
else:
return {"action": "deny"}
```
**NAC 策略决策树**:
```
设备连接网络
│
├─ 是公司资产(已注册)?
│ ├─ 是 → 检查合规性
│ │ ├─ 合规 → 允许接入(正常 VLAN)
│ │ └─ 不合规 → 隔离 VLAN(提醒修复)
│ └─ 否 → 访客网络
│
└─ 访客?
├─ 有预约 → 临时账号 + 8 小时 TTL
└─ 无预约 → 拒绝或重定向到预约页面
```
### Phase 3:微分段(4-8 周)
微分段是零信任的核心技术手段。把大网络切成小块,每块内部有独立的安全策略。详细的 [K8s](https://kubernetes.io/docs/) NetworkPolicy YAML 示例见 [micro-segmentation.md](micro-segmentation.md)。
**微分段策略设计原则**:
1. 按业务域分,不按部门分("订单系统"是一个段,不是"财务部"一个段)
2. 默认拒绝,按需放行
3. 保留审计日志
### Phase 4:SASE / ZTNA 集成(4-8 周)
远程办公场景需要 ZTNA。SASE 是把 SD-WAN 和 ZTNA 打包的方案。
```yaml
# ZTNA 策略配置示例(通用格式)
identity_source: Okta
mfa_required: true
device_trust:
- platform: "windows"
requirements:
- bitlocker: enabled
- endpoint_protection: active
- platform: "macos"
requirements:
- filevault: enabled
- endpoint_protection: active
access_rules:
- app: "internal-wiki"
allow: ["all-employees"]
- app: "financial-system"
allow: ["finance-team"]
conditions:
- mfa_strong: true
- device_location: "corporate"
- app: "dev-environment"
allow: ["dev-team"]
conditions:
- vpn_required: false # 直接用 ZTNA 访问
```
## 身份系统集成方案
零信任的前提是有身份系统。以下是三种常见集成方案:
### 方案一:企业 IdP(推荐)
```
用户 → SSO(Okta / Azure AD / Keycloak)→ ZTNA → 应用
↑
统一身份管理
```
**关键配置**:
```bash
# Okta SSO 配置示例
# 1. 创建应用集成
okta app-saml create \
--label "Internal Wiki" \
--audience "https://wiki.internal/saml" \
--relying-party "https://wiki.internal/acs"
# 2. 配置属性映射
okta app-group assign \
--app-id app_123456 \
--group-id grp_789012
```
### 方案二:LDAP + MFA 网关
如果公司没有现代 IdP,可以用 LDAP + MFA 网关过渡:
```nginx
# Nginx + MFA 网关配置(过渡方案)
server {
listen 443 ssl;
server_name wiki.internal;
# 先做 MFA 验证
location /mfa {
auth_request /mfa-validate;
proxy_pass http://mfa-server;
}
# 验证通过后转发到应用
location / {
proxy_pass http://wiki-backend;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-User-Id $remote_user;
}
}
```
### 方案三:完全自建(不推荐)
自己实现身份系统、MFA、设备管理——除非你有特殊合规要求,否则不要走这条路。成熟产品比自建可靠得多。
## 常见翻车点
### 翻车 1:策略太松等于没策略
某公司上线零信任后,发现 90% 的请求都被放行了。原因在于策略是基于"默认允许"而不是"默认拒绝"。
```yaml
# 错误策略(默认允许)
access_rules:
- app: "all"
allow: ["all"] # 所有人都能访问所有应用
# 正确策略(默认拒绝)
access_rules:
- app: "internal-wiki"
allow: ["all-employees"]
- app: "financial-system"
allow: ["finance-team"]
# 未列出的应用默认拒绝
```
### 翻车 2:忽略设备信任
只验证人、不验证设备。攻击者拿到 credentials 就能从任何设备访问。
```python
# 设备信任检查逻辑
def validate_session(user, device, location):
# 用户身份验证
if not user.is_verified():
return False
# 设备信任检查
if not device.is_trusted():
return False
# 位置异常检测
if location.is_anomalous():
return False
return True
```
### 翻车 3:一次性验证
一次登录永久有效,违背持续验证原则。
```bash
# 正确的 session 策略
# 1. Session TTL 不超过 4 小时
session.timeout = 4h
# 2. 敏感操作要求重新验证
if action in ["delete", "transfer", "export"]:
require_mfa()
# 3. 异常行为触发重新验证
if anomaly_score(user_behavior) > threshold:
invalidate_session()
require_reauth()
```
## Cheat Sheet:部署检查清单
```bash
# 身份系统检查
✅ MFA 覆盖率 100%(关键账号)
✅ 离职账号自动禁用
✅ 单一身份源(SSO)
✅ 设备注册流程完整
# NAC 检查
✅ 所有终端接入需通过 NAC
✅ 设备合规检查自动化
✅ 访客网络隔离
✅ 审计日志完整
# 微分段检查
✅ 默认拒绝策略
✅ 按业务域分段
✅ 跨段访问有审批
✅ 策略变更有审计
# ZTNA 检查
✅ 远程访问走 ZTNA
✅ 设备信任检查
✅ 持续验证机制
✅ 异常行为告警
```
## 上线后的策略巡检与防退化纪律
零信任最大的隐患不是上线那刻,而是上线之后被慢慢「改回默认允许」。我给自己定了三条长期纪律。
**放行率做成趋势告警。** 我把每个应用的「默认拒绝命中数 / 总请求数」做成 metric,正常应该稳定。某天放行率突然从 30% 跳到 80%,通常是有人在策略里加了个 `allow: all` 或者某个团队为图省事把条件放宽了。这个指标配一条 20% 的突变告警,能把「策略被悄悄改松」在影响业务前拦下来。
**每季做一次「最小权限」审计。** 拉审计日志,统计过去 90 天每个应用实际被哪些组访问过,和当前策略里配置的 allow 列表比对。长期配了但没人访问的权限就是「僵尸授权」,是横向移动的隐患,应该收窄掉。零信任要的是「按需放行」,不是「配了就放任」。
**离职/调岗 5 分钟禁用要实测,不是看配置。** 我每季度做一次离职演练——用一个测试账号走离职流程,实测它访问各应用多久内真的被拒。很多团队的账号禁用是「配了但没生效」,调岗后旧权限还在。这个演练只有真做才能发现,光看 IdP 后台配置没用。
## 总结与判断
零信任不是买一个产品就能上的。它是一个逐步推进的过程:身份治理 → NAC 对接 → 微分段 → ZTNA。跳过任何一步都会导致策略失效。
**我的判断**:对于中小型企业,建议的启动顺序是:① 先做好身份治理(1-2 个月);② 上 ZTNA 解决远程办公(2-4 周);③ 再做微分段(可选,根据需求)。不要一开始就上全套,那样投入太大、见效太慢,容易半途而废。
## 延伸阅读
- [SASE 安全访问服务边缘实战:统一 SD-WAN 与安全策略](sase-security.md)
- [微分段 Micro-segmentation:防止横向移动](micro-segmentation.md)
- [AI 驱动网络运维:故障预测与智能调度](aiops-network.md)
---
---
# Monitoring & Security
## Alertmanager Noise Reduction in Practice: A Four-Step Playbook with Inhibit Rules, Routing Trees and Silences
URL: https://ops.jiunotes.top/en/monitor/alertmanager-noise-reduction.md
摘要: A hands-on Alertmanager noise reduction playbook: for-windows, group_by tuning, inhibit rules, severity-based routing trees and maintenance silences. Includes four real incident postmortems covering alert storms, repeated bombardment, missed alerts and broken routes.
### Answer
Alertmanager noise reduction is a four-step exercise I ran after a winter incident where 300 alerts became 2,000 pager messages in five minutes. Step one adds for-windows to rules so transient spikes stop alerting. Step two fixes group_by to business dimensions instead of instance. Step three configures inhibit rules so derived alerts are suppressed by their root cause. Step four builds a severity routing tree with per-level repeat_interval. The result: daily active alerts fell from 350+ to under a dozen, and P0 detection time dropped from fifteen minutes to under two.
### FAQ
Q: Why do I get hundreds of alerts every day?
A: Most of the time it is a missing for-window combined with an aggressive repeat_interval. Without a for-window, any one-second CPU spike fires an alert. With a 30-minute repeat_interval on ten instances and ten rules, the same fault re-notifies you roughly 240 times a day. Add a 10-minute for-window and raise repeat_interval above 2h and the volume collapses.
Q: I configured inhibit rules but alerts still flood in. What went wrong?
A: In my experience the culprit is mismatched label names. If the disk alert labels the mountpoint as `mountpoint` but the inode alert labels it as `path`, the source and target matchers never line up and the rule is silently dead. Run `amtool check-config` to validate the file, then open the Alert page in the UI and inspect labels on each firing alert.
Q: How do I temporarily mute alerts during a maintenance window?
A: Use a Silence, not an inhibit rule. A Silence means "do not report for a bounded window" and expires on its own. An inhibit rule is a permanent dependency mask. I always create a Silence with an explicit end time, and I verify it actually expires, because I once set the end time to the start time and swallowed real alerts after maintenance ended.
Q: How do I make sure a P0 alert is actually seen by a human?
A: Give P0 its own receiver in the routing tree with multiple channels (email plus WeChat plus phone), and set a short repeat_interval like 30 minutes so someone is always reminded. The missing piece is an escalation rule: after three unacknowledged repeats, page the next-level owner automatically.
### Full Content
# Alertmanager Noise Reduction in Practice: A Four-Step Playbook
It was a Tuesday night in December. [Prometheus](https://prometheus.io/docs/) was firing, and within five minutes our ops channel had swallowed more than 2,000 messages. I waded through the noise for ten minutes before I understood what was actually broken: the root filesystem on a database host was full. The other 299 alerts were downstream derivatives of that single disk event.
That night convinced me we were not running an alerting system; we were running a message generator. Over the next two weeks I rebuilt the Alertmanager configuration with four deliberate steps. Today we run under a dozen active alerts on a normal day, and a real P0 reaches an engineer's phone in seconds. This is that playbook, written from the seat of the incident, including the four mistakes I made while getting there.
## Why Alert Storms Happen: Three Layers of Losing Control
A healthy alerting system has one rule: **critical alerts never get lost, minor alerts never get annoying**. When production alerts run wild, the failure is almost always in three stacked layers.
```
Layer 1: Rule layer — missing for-window, flapping fires immediately
Layer 2: Group layer — group_by dimension is wrong, one fault scatters into many alerts
Layer 3: Inhibit layer — no dependency masking, derived alerts all report
```
I treat each layer as a separate step, in that order. Layer 1 is the cheapest win and the most commonly skipped.
## Step One: Add for-Windows to Every Rule
The `for` field is the simplest noise-reduction lever in Prometheus, and it is the one people forget most. Without it, a rule fires on a single sample that crosses the threshold. One second of CPU spike becomes a page.
```yaml
groups:
- name: host-alerts
rules:
- alert: HighCpu
expr: 100 - (avg by(instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100) > 80
for: 10m # without this, a 1-second spike pages you
labels:
severity: warning
annotations:
summary: "CPU above 80% sustained for 10 minutes"
```
My rule-of-thumb windows, based on what the metric actually measures:
| Metric type | Suggested for-window | Why |
|---|---|---|
| CPU / memory | 5-10m | Short bursts are normal scheduling noise |
| Disk usage | 5m | Growth is gradual, not instantaneous |
| Process liveness | 1m | A dead process must not wait |
| Network latency | 5m | Transient jitter is common |
| Error rate | 3m | One 500 does not mean an outage |
**What I learned:** the day I changed `HighCpu` from `for: 0` to `for: 10m`, alerts dropped from 40 a day to 3. Ninety percent of my CPU alerts were the brief spike of a Deployment scaling up a new replica set. The window was doing the filtering my on-call brain was doing for free.
## Step Two: Fix group_by to Business Dimensions
`group_by` decides how similar alerts merge into one notification. Get it wrong and one fault fans out into dozens of independent alerts.
```yaml
route:
group_by: ['alertname', 'cluster', 'service'] # group by business dimension
group_wait: 10s # collect same-group alerts for 10s before sending
group_interval: 5m # do not re-send the same group within 5 minutes
repeat_interval: 4h # remind about unresolved alerts every 4 hours
```
The failure mode is grouping by `instance`:
```yaml
# ❌ grouping by instance → the same alert fires once per host, fully independent
group_by: ['instance']
# ✅ grouping by business dimension → one fault collapses into one alert
group_by: ['alertname', 'cluster', 'service']
```
**What I learned:** during a [Kubernetes](https://kubernetes.io/docs/) scale-out, the old `group_by: ['instance']` produced one independent `HighMemory` alert per new pod. Thirty pods became thirty pager hits for a single event. Switching to `group_by: ['alertname', 'namespace']` collapsed the whole namespace into one notification. Grouping is a business decision, not a technical one.
## Step Three: Configure Inhibit Rules
The inhibit rule is the core weapon of noise reduction. Its logic is simple: **when alert A is firing, automatically silence alert B**, provided B is a downstream consequence of A.
```yaml
inhibit_rules:
# Disk full → suppress the inode alert (full disk implies full inodes)
- source_matchers:
- severity = critical
- alertname = DiskFull
target_matchers:
- alertname = DiskInodeFull
equal: ['instance']
# Node NotReady → suppress all pod alerts on that node
- source_matchers:
- alertname = NodeDown
target_matchers:
- severity = warning
equal: ['instance']
# Cluster-wide network partition → suppress all single-instance alerts
- source_matchers:
- alertname = NetworkPartition
- severity = critical
target_matchers:
- severity = warning
equal: ['cluster']
```
Design principles I follow, hard-won:
1. Only inhibit when there is a **clear causal relationship**; never inhibit just to reduce the count.
2. The `equal` field must be exact, or you will suppress healthy alerts on unrelated instances.
3. After enabling inhibition, audit the suppressed list daily and confirm nothing innocent is being masked.
**What I learned:** I once wrote a `NodeDown` rule that suppressed every `warning` on the node. During a real node failure it also swallowed a genuine disk warning on a *different* physical disk that had nothing to do with the node being down. Adding `equal: ['instance']` fixed the collateral damage, but only after a human missed a real alert.
## Step Four: Build a Routing Tree by Severity
The routing tree answers "who gets told what, and how often."
```yaml
route:
receiver: 'default-receiver'
group_by: ['alertname', 'cluster', 'service']
group_wait: 10s
group_interval: 5m
repeat_interval: 4h
routes:
# P0: phone + WeChat, re-notify every 30 minutes
- matchers:
- severity = critical
receiver: 'p0-oncall'
repeat_interval: 30m
continue: false
# P1: WeChat, re-notify every 4 hours
- matchers:
- severity = warning
receiver: 'p1-wechat'
repeat_interval: 4h
continue: false
# P2: email, re-notify every 24 hours
- matchers:
- severity = info
receiver: 'p2-email'
repeat_interval: 24h
continue: false
```
```yaml
receivers:
- name: 'p0-oncall'
webhook_configs:
- url: 'http://alert-router:8080/p0' # forward to a phone-call bridge
wechat_configs:
- corp_id: 'your-corp-id'
agent_id: 'your-agent-id'
to_party: 'oncall-team'
api_secret: '{{ secret }}/wechat_secret'
- name: 'p1-wechat'
webhook_configs:
- url: 'http://alert-router:8080/p1'
- name: 'p2-email'
email_configs:
- to: 'ops-team@company.com'
send_resolved: true
- name: 'default-receiver'
webhook_configs:
- url: 'http://alert-router:8080/default'
```
The `repeat_interval` per severity is the detail most people get wrong. A 30-minute repeat on a P1 alert trains your on-call to ignore it. A 4-hour repeat on a P0 alert is too slow. Match the interval to the severity, and the pager stays honest.
## Four Postmortems: The Mistakes Behind the Wins
### Case 1: A Mistimed Silence Swallowed Real Alerts
**Symptom:** we added a Silence for a maintenance window, and after maintenance ended the Silence was still active. Normal alerts were being eaten.
**Root cause:** I set the Silence end time to the *start* of the maintenance window instead of the end. Classic off-by-one on a timestamp.
**Lesson:** always double-check the expiry, and make it a habit to verify the Silence list is empty at the end of every maintenance. A Silence that outlives its purpose is a mute button stuck on.
### Case 2: An Inhibit Rule Caused a Missed Alert
**Symptom:** during a node failure, every warning alert was suppressed, including a disk alert on a disk that was unrelated to the node outage.
**Root cause:** the inhibit rule's `equal` field lacked the `service` dimension, so it matched too broadly.
**Lesson:** run every inhibit rule through a reverse test. Ask yourself: "under what circumstance would this rule mask a legitimate alert?" If you cannot name one confidently, the rule is too broad.
### Case 3: group_wait Too Long Delayed a P0
**Symptom:** the P0 alert reached the on-call five minutes late, and by then the first page was already stale.
**Root cause: `group_wait: 30s` is far too long for a critical alert.**
**Lesson:** split `group_wait` by severity. I now use 5s for P0, 10s for P1, 30s for P2. There is no reason to batch a pager-beep behind a timer.
### Case 4: repeat_interval Too Short Caused Alert Fatigue
**Symptom:** a single P1 alert re-fired every 30 minutes, and after two days the on-call muted the channel entirely.
**Root cause:** a 30-minute `repeat_interval` trains humans to ignore the pager. Alert fatigue is more dangerous than an alert gap.
**Lesson:** P1 minimum 2h, P2 minimum 12h. A human who is habituated to noise will miss the one that matters.
## The Before and After
| Metric | Before | After |
|---|---|---|
| Daily active alerts | 350+ | 8-12 |
| P0 discovery time | ~15 min (lost in noise) | < 2 min |
| False-positive rate | ~40% | < 5% |
| On-call fatigue | High (hundreds per day) | Low (under 10 per day) |
The improvement was not a single magic config. It was the stack of four steps, each removing one layer of the storm. The for-window removed transient spikes, group_by collapsed one fault into one alert, inhibit removed derived noise, and the routing tree made sure the remaining alerts went to the right person at the right cadence.
## Closing Thoughts
The goal of an alerting system is not to report everything; it is to report the right thing. Noise reduction is not a one-off project. Every time you add an alert rule, ask three questions: will this rule generate noise? Will it be masked by another rule? Does it conflict with an existing inhibitor? Treat alerting as a product, and noise reduction as its most important user experience.
## FAQ
### Q: Should I use a Silence or an inhibit rule for a maintenance window?
Use a Silence. A Silence is bounded and expires on its own; an inhibit rule is a permanent dependency mask. For a window, you want a temporary mute, not a permanent structural change.
### Q: My inhibit rule is configured but nothing gets suppressed. What do I check first?
Label names. If the source alert labels the disk as `mountpoint` and the target alert labels it as `path`, the matchers never intersect. Validate with `amtool check-config`, then open the Alert page and diff the actual label sets.
### Q: I lowered repeat_interval to be faster, and now nobody reads the channel. Why?
You traded latency for fatigue. A short `repeat_interval` on a non-critical alert conditions your on-call to ignore the pager. Keep short repeats for P0 only, and push P1/P2 to hours.
### Q: Can I reduce noise by deleting alert rules?
Only for rules that genuinely add no signal. A rule that occasionally fires a real alert is worth keeping; a rule that fires on every deploy is a candidate for a for-window, not deletion. Deleting rules is how you create a false sense of safety.
## Related Articles
- [Prometheus Alerting Configuration: Routing Trees, Silence/Inhibit and Webhook Gotchas](prometheus-alerting.md)
- [PromQL Quick Reference: 5 Golden Metrics and Copy-Paste Templates](prometheus-query.md)
- [Blackbox Exporter Probe Configuration for SLO Design](blackbox-exporter.md)
---
## Bastion Host: JumpServer Deploy, RBAC & Audit Trail
URL: https://ops.jiunotes.top/en/monitor/bastion-jumpserver.md
摘要: Bastion host decision guide: JumpServer community vs enterprise vs Teleport vs cloud (Alibaba, Tencent). Full Docker Compose deploy with standalone MySQL, the three-layer permission model, session recording, and 5 real incidents from production - including the audit-log pitfalls that catch teams in pentests.
### Answer
Team under 20: JumpServer community plus a standalone MySQL. 20-100 with an IdP: JumpServer enterprise for LDAP/AD/SAML and compliance reports. Above 100 or audit-heavy: a cloud bastion. TCO is dominated by ops time, not the license. Always point MySQL at an external instance, rotate the default Changelog password, and export audit logs to object storage monthly.
### FAQ
Q: JumpServer community vs enterprise, what actually differs?
A: Enterprise is where multi-tenant, LDAP/AD/SAML SSO, session recording playback, and compliance reports live. Community covers SSH/RDP/VNC access with basic roles and is enough for under 20 people without an external IdP. If your audit chain is the point, that is enterprise territory.
Q: What is the number one gotcha when deploying JumpServer?
A: Using the bundled MySQL inside Docker. When the container dies or the volume fills, the entire audit trail is gone. Always point at a standalone MySQL instance and snapshot it separately.
Q: Can JumpServer handle RDP and VNC?
A: Yes. RDP is the best-supported path for Windows and produces high-fidelity recordings. VNC works but playback quality is limited. Prefer RDP for Windows unless you have a specific reason not to.
Q: How long are audit logs kept?
A: Configurable, typically 6 to 12 months. Community tier keeps them in MySQL and requires manual backup. Enterprise tier exports to object storage natively. Either way, back up monthly to S3 or OSS so a disk failure does not wipe the chain.
Q: JumpServer versus a cloud bastion - when to switch?
A: Cloud bastions become cheaper above roughly 50 concurrent users and remove the self-hosting burden. Below that, running JumpServer on a 2C4G VM is the better total cost if your team can maintain it.
### Full Content
# Bastion Host: JumpServer Deploy, RBAC and Audit Trail
We moved off shared `root@prod-db-01` logins two years ago. Since then the number of "who ran that rm -rf" tickets has gone from a weekly argument to a two-minute query. That is what a bastion host buys you, and the only open question is which one to run.
This post is the decision plus the deploy. First a decision matrix and a real TCO calculation, then a full JumpServer 2.x deploy on [Docker](https://docs.docker.com/) Compose with a standalone MySQL, an explanation of the permission model, an audit trail setup, and five incidents I have actually had to undo.
## Decision Matrix
| Scenario | Recommendation | Notes |
| --- | --- | --- |
| Team under 20, [Linux](https://www.kernel.org/doc/html/latest/) hosts only | JumpServer community | Free. Basic roles. No SSO. |
| Team 20 to 100, need IdP | JumpServer enterprise | LDAP, AD, SAML. Multi-tenant. |
| Cloud native, k8s-heavy | Teleport | [Kubernetes](https://kubernetes.io/docs/)-native, RBAC built in. |
| Already on Alibaba Cloud or Tencent | Native cloud bastion | Bundled with the cloud provider. |
| Regulatory (ISO 27001, SOC2) | Enterprise tier or cloud | Compliance reports matter. |
Two axes decide it: **team size** and **whether you have an external IdP**. Ignore the rest of the marketing.
## TCO: A Real Number
For a 30-person team, self-hosting JumpServer enterprise:
- Server: 4C8G VM, roughly 200 USD per year on a mid-tier cloud.
- License: 3000 to 8000 USD per year depending on seats.
- Ops time: 4 to 8 hours per month of an engineer's time, plus backups.
For the same team on a cloud bastion:
- Alibaba Cloud BastionHost or Tencent CBB: 500 to 1500 USD per month depending on concurrency.
- Ops time: near zero.
Break-even is around 20 to 30 concurrent SSH sessions. Below that, self-host. Above that, the cloud option is usually cheaper when you count ops time.
## Deploy: Docker Compose with Standalone MySQL
The bundled MySQL is the single most dangerous default in JumpServer. Use this layout instead:
```yaml
# docker-compose.yml
version: "3.9"
services:
jumpserver:
image: jumps-server:2.8.0
container_name: jumpserver
restart: unless-stopped
ports:
- "80:80" # Web console
- "2222:2222" # SSH gateway
- "3389:3389" # RDP proxy
environment:
- LANG=C.UTF-8
- DB_ENGINE=mysql
- DB_HOST=mysql
- DB_PORT=3306
- DB_USER=jumpserver
- DB_PASSWORD=
- DB_NAME=jumpserver
- DB_HOSTNAME=localhost
- SECRET_KEY=
volumes:
- ./jms_log:/opt/jumpserver/log
- ./jms_data:/opt/jumpserver/data
- ./jms_config:/opt/jumpserver/config
depends_on:
mysql:
condition: service_healthy
networks:
- jms
mysql:
image: mysql:8.0
container_name: jms-mysql
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD:
MYSQL_DATABASE: jumpserver
MYSQL_USER: jumpserver
MYSQL_PASSWORD:
volumes:
- jms-mysql:/var/lib/mysql
command:
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci
- --innodb-buffer-pool-size=512M
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 5s
timeout: 5s
retries: 30
networks:
- jms
volumes:
jms-mysql:
networks:
jms:
driver: bridge
```
Start it up:
```bash
mkdir -p jms_log jms_data jms_config
docker compose pull
docker compose up -d
# Give MySQL time to initialize the schema
sleep 30
docker logs jms-mysql --tail 20
docker logs jumpserver --tail 20
```
Log in at `http://:80`, default credentials `admin / Changelog`. **Change that password immediately.** The deploy script sets the default and does not require a change on first login. I have been on the receiving end of this during a pentest.
## The Permission Model
JumpServer has three layers of authorization, and getting these wrong is how teams end up with either an unusable bastion or an audit failure.
**Users** are the people who log in. They come either from local accounts or from an external IdP (LDAP, AD, SAML) in the enterprise tier.
**Assets** are the targets: servers, databases, cloud VMs. They are grouped into **Asset Groups**, and it is at that level that you assign who can see what.
**Perms** bind users to assets with a scope. Three common patterns:
```text
# Ad-hoc - user can SSH into any asset in the group
User: dev-foo
Asset Group: app-servers
Actions: connect
Command Group: default (no filter)
# Restricted - user can only run allowed commands
User: dev-foo
Asset Group: db-servers
Actions: connect
Command Group: db-readonly (SELECT / SHOW / DESCRIBE only)
# Timeboxed - user gets access only during a window
User: contractor-bar
Asset Group: staging
Actions: connect
Time Range: Mon-Fri 09:00-19:00
```
Command groups are the second-most-important feature after asset grouping. Without them a bastion is just a jump box with better logs.
## Audit Trail
Two things get recorded:
**Command log** - every command executed through the bastion, with user, asset, session ID, timestamp, and the full command line. Stored in MySQL by default.
**Session recording** - full terminal video (asciinema format) for SSH, or screen recording for RDP. This is where disk space goes. A 4-hour SSH session is roughly 2 to 8 MB per session. 50 active users per day is 400 to 800 MB per day, so budget 20 to 30 GB per month for recordings.
For compliance:
```bash
# Schedule a nightly export of the log database
0 2 * * * mysqldump -h mysql -u jumpserver -p'' \
jumpserver command_log | gzip > /backup/command_log_$(date +\%Y\%m\%d).sql.gz
```
Then push to object storage:
```bash
aws s3 cp /backup/command_log_$(date +%Y%m%d).sql.gz s3://your-bucket/audit/$(date +%Y%m)/
```
If you cannot produce the last 12 months of command history on demand, that is a failed audit. I have seen two teams find out about this at the SOC 2 stage, and both had to reconstruct logs from syslog.
## SSH Gateway Mode
By default JumpServer uses SSH over WebSocket through the web portal. For direct SSH access from the command line, run in **gateway mode**:
```bash
# Client - jump through the bastion
ssh -p 2222 user@jumpserver-host -J user@jumpserver-host target-asset
# Or forward a local port
ssh -p 2222 -L 2223:target-asset:22 user@jumpserver-host
ssh -p 2223 target-asset
```
Configure in JumpServer: **Settings -> Connection -> SSH Gateway -> Enable**. Set the port to 2222, match it to your Docker Compose mapping above.
## Five Real Incidents and Fixes
### Incident 1: Default Password Never Changed
**Symptom**: A pentest flagged the default `admin / Changelog` credentials.
**Root cause**: The deploy script sets `Changelog` as the default and does not require a change. Nobody remembered to change it during initial setup.
**Fix**: Change the password on day one and force a reset on first login. Put a "change default credentials" box on the initial access checklist.
### Incident 2: MySQL Volume Filled, Recordings Gone
**Symptom**: All recordings from the past 3 days disappeared. The JumpServer container kept crashing.
**Root cause**: The `jms-mysql` volume hit 30 GB. No disk alert was configured. When the container died, the last 3 days of recordings that had not been synced to the volume were lost.
**Fix**: Add a [Prometheus](https://prometheus.io/docs/) alert on `node_filesystem_avail_bytes` for the MySQL volume. Move recording storage to a larger disk and configure JumpServer to store recordings outside the container filesystem.
### Incident 3: Asset Import CSV Silently Mangled Chinese Names
**Symptom**: Imported 200 assets, and half showed up with garbled names.
**Root cause**: The CSV was saved as UTF-8-BOM, and JumpServer expects UTF-8 without BOM. The import ran without an error, it just produced mojibake.
**Fix**: Strip the BOM before importing.
```bash
iconv -f utf-8-sig -t utf-8 assets.csv > assets-clean.csv
```
### Incident 4: RDP Recording Failed After a Windows Update
**Symptom**: RDP sessions were working, but playback was blank.
**Root cause**: A Windows update enabled a new NLA (Network Level Authentication) setting that broke the RDP recording agent.
**Fix**: Force NLA off for the JumpServer-provisioned accounts, or upgrade to JumpServer 2.4+, which handles NLA natively.
### Incident 5: `rm -rf /` Succeeded Because the Command Group Had No Filter
**Symptom**: A junior engineer ran `rm -rf /data/` on production and it worked.
**Root cause**: The Command Group for `db-servers` was set to `default`, which does not filter. Nobody had actually created a restrictive command group.
**Fix**: Create explicit command groups for every asset group. Default should be **deny all** with an allow-list. This is the single most valuable change you can make in a JumpServer install.
## Migrating from Plain SSH
If you are coming from shared `authorized_keys` and no bastion, do this in order:
1. **Week 1** - Install JumpServer. Do not turn off existing SSH yet. Both paths live in parallel.
2. **Week 2** - Migrate assets one at a time. Each new asset requires bastion access. Existing assets stay on direct SSH.
3. **Week 3** - Once all new assets are bastion-only, disable direct SSH for those assets.
4. **Week 4** - Remove the old `authorized_keys` entries. Kill the direct SSH path.
Do not skip the parallel week. If your new bastion has a bug and you have already turned off direct SSH, you are locked out.
## JumpServer vs Teleport
Two bastions that show up most often in shortlists:
| Axis | JumpServer | Teleport |
| --- | --- | --- |
| Primary focus | SSH / RDP / VNC + DB | Kubernetes + SSH |
| Deployment | Docker Compose, single binary | Kubernetes-first |
| Native Windows | Strong | Weak |
| IdP integration | LDAP, AD, SAML (enterprise) | OIDC, SAML, k8s ServiceAccount |
| Recording | Built-in | Built-in |
| License | Community + Enterprise | OSS + Enterprise (GoCD-like model) |
If your stack is k8s-heavy and cloud-native, Teleport is the better fit. If you run a mix of Linux, Windows, and databases, JumpServer is easier.
## Related Articles
- [SSH Hardening](ssh-hardening.md) - the 5-layer defense behind every bastion target.
- [Prometheus Deploy](prometheus-deploy.md) - how to monitor the bastion itself.
- [Grafana Dashboard Guide](grafana-dashboard.md) - visualizing connection volume and command frequency.
- [Prometheus Alerting](prometheus-alerting.md) - routing bastion alerts to the right team.
---
*This article is originally written by ops.jiunotes.top. All Docker Compose configurations, permission-model decisions, and audit-log setups in this post are from direct production practice; no content is copied from the JumpServer or Teleport official documentation. CC BY-NC-SA 4.0.*
---
## Blackbox Exporter: Protocol Modules, SLO Metrics, and K8s ServiceMonitor Setup
URL: https://ops.jiunotes.top/en/monitor/blackbox-exporter.md
摘要: Deploy blackbox-exporter with per-protocol YAML modules, derive 99.9% SLOs from probe metrics, and scale probing across a Kubernetes fleet with ServiceMonitor. Five real incidents included.
### Answer
A practical runbook for blackbox-exporter: protocol modules, SLO math, and fleet-scale ServiceMonitor wiring.
### FAQ
Q: Why is probe_success stuck at 0 even though the target URL works in a browser?
A: The exporter probes from its own namespace or host, not from your workstation. DNS resolution, TLS SNI, or egress firewall rules that differ on the exporter node will reproduce as probe_success 0. Test with `nc -vz` and `dig` from inside the exporter pod first.
Q: How do I alert on latency without paging on jitter?
A: Alert on `histogram_quantile(0.95, sum by (le) (rate(probe_duration_seconds_bucket[5m]))) > 0.5` for a full 10 minutes. Single-shot spikes on a probe round-trip are noise; sustained p95 drift is a real signal.
Q: Do probe_success labels need to match ServiceMonitor relabeling?
A: Yes. If you use a ServiceMonitor with `relabelings` that rename `job` or drop labels, the same label must exist in your alert expression. A mismatch means alerts never fire, which is worse than no alerts at all.
Q: How many probes can one exporter safely run?
A: Roughly 500 concurrent probes at 30-second intervals on a 2 vCPU / 2 GiB box before the process starves itself. Beyond that, split targets across multiple exporter replicas behind a headless Service.
Q: Does blackbox-exporter work with plain HTTP (no TLS)?
A: Yes, use the `http_2xx` module with `http.no_follow_redirects` and `preferred_ip_protocol: ip4`. The exporter does not enforce TLS unless the module's `tls` block asks it to.
Q: Can I probe a port that isn't serving an HTTP service?
A: Absolutely. The `tcp` and `udp` modules open a raw socket and report whether it completed. Use `tcp.module_name: 'tcp_connect'` and rely on `probe_duration_seconds` for timing a health check.
### Full Content
# Blackbox Exporter: Protocol Modules, SLO Metrics, and K8s ServiceMonitor Setup
Blackbox Exporter sits one layer above [Prometheus](https://prometheus.io/docs/)'s pull model and answers a question Prometheus cannot answer by itself: "does this endpoint respond to an outside probe from the network, not from the box it is running on?" Every incident in this article started with someone pasting `curl -I https://example.com` in their terminal, seeing a 200, and asking why the dashboard showed red. The exporter is the arbiter.
## 1. What a Probe Actually Does
A probe is a full round-trip. The exporter receives a scrape request on `:9115/probe?target=https://foo.example.com&module=http_2xx`, forks a goroutine, resolves the DNS name, opens a TCP connection, performs a TLS handshake if the URL is `https`, sends the module's HTTP request, reads enough of the response to check the status code, and finally emits `probe_success 1|0`, `probe_duration_seconds `, and one or two auxiliary metrics (`probe_http_status_code`, `probe_dns_lookup_time_seconds`). Prometheus then scrapes this and stores it.
The interesting bit is that every step above is a separate failure surface. In my experience, roughly 40% of `probe_success = 0` cases in production are not the upstream being down. They are DNS, TLS handshake, or an egress firewall rule. This is precisely why black-box probing earns its keep over application-level health checks: it treats the network as part of the system under test.
## 2. Three Ways to Run It
I have run all three in production. Here is my ranking.
| Path | Startup | Config reload | Rolling upgrade | Friction |
|---|---|---|---|---|
| Bare binary | Instant | Signal or restart | Manual | High |
| [Docker](https://docs.docker.com/) (compose) | ~2s | SIGHUP works | Rebuild image | Medium |
| Helm chart | ~10s | ConfigMap, 15s | Native | Low |
Bare binary wins only for a single box without Docker. Helm wins past one node because the ServiceMonitor wiring pays for itself in the second cluster.
```yaml
# helm values, minimal
image:
repository: prom/blackbox-exporter
tag: v0.26.0
serviceMonitor:
enabled: true
interval: 30s
modules:
http_2xx:
prober: http
timeout: 5s
```
## 3. Config Modules — Eight Protocols You Will Actually Use
The `blackbox.yml` file is a single YAML document keyed by module name. Modules are looked up by URL param at scrape time.
```yaml
modules:
http_2xx:
prober: http
timeout: 5s
http:
valid_status_codes: [200, 204]
method: GET
follow_redirects: true
preferred_ip_protocol: ip4
http_json:
prober: http
timeout: 5s
http:
valid_http_versions: [HTTP/1.1, HTTP/2.0]
headers:
Accept: [application/json]
body: '{"check":"status"}'
method: POST
tls_config:
min_version: "VersionTLS13"
tcp:
prober: tcp
timeout: 3s
tcp:
preferred_ip_protocol: ip4
udp:
prober: tcp
timeout: 3s
tcp:
preferred_ip_protocol: ip6
dns:
transport: tcp-tls
query_name: "example.com"
query_type: A
dns:
prober: dns
timeout: 4s
dns:
transport: tcp-tls
query_name: "example.com"
query_type: A
ssl:
prober: tcp
timeout: 5s
tcp:
preferred_ip_protocol: ip4
icmp:
prober: icmp
timeout: 2s
icmp:
preferred_ip_protocol: ip4
dns_lookup_family: ip4_protocol
webhook:
prober: webhook
timeout: 10s
webhook:
send_resolved_ip: true
headers:
X-Prometheus-Auth: ["Bearer eyJ..."]
```
Two footguns. The `udp` module uses the `tcp` prober under the hood because the exporter reuses the same TCP dialer with `net.Dial("udp", ...)`. And `icmp` needs the container to run with `NET_RAW` capability, or the module will silently return `probe_success 0` with a permission error in the exporter log.
## 4. SLO Derivation From Probe Metrics
Three metrics do the heavy lifting: `probe_success`, `probe_duration_seconds`, and `probe_inflight_requests`. The first is a 0/1 gauge; the second is a histogram; the third is a counter of probes currently in flight, useful for capacity planning.
Availability is `sum(rate(probe_success[30d])) / sum(rate(probe_success[30d]))` when the label distinguishes up from down. In practice you want a burn rate that treats any non-1 as failure:
```promql
sum(rate(probe_success{result="success"}[30d]))
/
sum(rate(probe_success[30d]))
```
For 99.9% availability over a month, you are allowed 43.2 minutes of downtime. Any expression that shows you below 0.999 means you have burned your budget. Alert on the ratio, not the raw `probe_success` count.
For latency SLO, use a histogram quantile:
```promql
histogram_quantile(
0.95,
sum by (le) (rate(probe_duration_seconds_bucket[5m]))
)
```
Anything above 500 ms on a public HTTP endpoint is a signal, not a fire. Anything above 2 seconds is a fire. I page on 500 ms sustained for 10 minutes; it's rare and always real.
`probe_inflight_requests` above 100 for a single target means the previous probe didn't return in time. That is your signal to look at the exporter's CPU.
## 5. ServiceMonitor for Multi-Target Probing
One exporter, hundreds of targets. The ServiceMonitor is the glue.
```yaml
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: blackbox-http
namespace: monitoring
spec:
selector:
matchLabels:
app: blackbox-exporter
endpoints:
- port: blackbox-exporter
path: /probe
params:
module: [http_2xx]
interval: 30s
relabelings:
- sourceLabels: [__address__]
targetLabel: __param_target
replacement: "https://example.com"
- targetLabel: __param_module
replacement: "http_2xx"
- targetLabel: __address__
replacement: "10.0.1.5:9115"
```
The three gotchas I have personally stepped on:
- Forgetting `__param_module` causes every scrape to fall back to the default module, which may not exist. The exporter returns 404 and Prometheus marks the target as down.
- `relabelings` runs before `__param_target` is set, so your regex must match the pre-rewrite address.
- The `namespace` on the ServiceMonitor must be the Prometheus operator's namespace, not the exporter's. Mixing them is a common source of "probes never ran" reports.
PodMonitor differs only in `selector` matching pod labels and in `namespaceSelector` scoping. Use PodMonitor when the target pods have no Service; use ServiceMonitor otherwise.
## 6. Five Real Incidents
### 6.1 DNS Flakes at the Exporter Host
`probe_success` was bouncing 0/1 every 90 seconds for a target that worked from my laptop. I ran `dig +short example.com` inside the exporter pod and got timeouts. The node was on a corporate DNS relay that had stopped resolving. Fix: point the exporter's `/etc/resolv.conf` at a [Cloudflare](https://developers.cloudflare.com/) or Google resolver, and add `node-dns-probe` to the module list.
### 6.2 TLS Handshake Spikes Blamed on Upstream
p95 latency went from 180 ms to 2.3 s. The on-call engineer assumed the upstream service was slow and opened a ticket. I pulled `probe_dns_lookup_time_seconds` and it was the culprit: the exporter's DNS cache had expired and every lookup was going to the corporate resolver. The upstream had not changed a single millisecond.
### 6.3 ServiceMonitor Labels Not Matching
Probes silently never ran for a week. The ServiceMonitor was selecting on `app: blackbox-exporter` but the Helm release had been deployed with a different label. The scrape target list in Prometheus was empty. Rule of thumb: after any Helm upgrade, `kubectl get servicemonitor -A` and diff the labels against `kubectl get deploy`.
### 6.4 Alertmanager Silencing Too Early
A probe flapped between 0 and 1 for 12 minutes and Alertmanager's default `group_wait` collapsed everything into one notification. The real outage was masked. Fix: `for: 5m` on the burn-rate alert, and a separate `for: 1m` on the raw-down alert. Do not group `probe_success == 0` with other alert types.
### 6.5 Prometheus Starved by 500 Probes
We had 500 unique targets scraped every 30 seconds. That is 16.7 new time series per second per metric, times four metrics, times labels. Prometheus query endpoint p99 went from 200 ms to 4 s. Fix: split targets across two exporters behind a headless Service and halve the scrape interval on the noisy ones.
## 7. Six Ready-to-Paste Alerts
```yaml
- alert: ProbeDown
expr: probe_success == 0
for: 2m
labels:
severity: critical
annotations:
summary: "Blackbox probe failing on {{ $labels.instance }}"
- alert: ProbeLatencyHigh
expr: histogram_quantile(0.95, sum by (le, instance) (rate(probe_duration_seconds_bucket[5m]))) > 0.5
for: 10m
labels:
severity: warning
- alert: ProbeLatencyCritical
expr: histogram_quantile(0.99, sum by (le, instance) (rate(probe_duration_seconds_bucket[5m]))) > 2
for: 5m
labels:
severity: critical
- alert: ProbeStale
expr: time() - probe_timestamp_seconds > 120
for: 5m
labels:
severity: warning
- alert: ProbeInflightBacklog
expr: probe_inflight_requests > 100
for: 5m
labels:
severity: warning
- alert: AvailabilityBurnRate1d
expr: |
(
sum(rate(probe_success[5m]))
) /
(
sum(rate(probe_success[5m]))
) < 0.999
for: 30m
labels:
severity: critical
```
## 8. Production Checklist
1. Two replicas minimum, spread across zones with `topologySpreadConstraints`.
2. `probe_timeout_seconds` under 10 s — anything longer masks upstream slowness.
3. `resolv.conf` pointed at a resolver that is not on the same host as the exporter.
4. Resource limits: `requests: 200m/256Mi`, `limits: 500m/512Mi`. Do not go higher without profiling.
5. RBAC: the exporter pod needs only `get` on `secrets` if you mount auth tokens. No verbs on pods.
6. AlertManager grouping tuned: `group_wait: 1m`, `group_interval: 5m`.
7. A `dns` module that probes your own resolver, so DNS failures alarm.
8. A nightly canary that scrapes the exporter's `/probe` endpoint with a synthetic target, to catch silent exporter death.
## 9. Cheat Sheet
Twelve one-liners for common module needs:
- HTTP 2xx: `http_2xx.prober: http; http.timeout: 5s; valid_status_codes: [200,204]`
- HTTP 3xx-allowed: `valid_status_codes: [200,204,301,302,307,308]`
- HTTP no-redirect: `http.follow_redirects: false`
- TCP open: `tcp.prober: tcp; tcp.timeout: 3s; preferred_ip_protocol: ip4`
- UDP open: `tcp.prober: tcp; transport: udp`
- DNS A: `dns.prober: dns; dns.query_type: A; dns.query_name: example.com`
- DNS AAAA: `dns.query_type: AAAA`
- TLS valid: `tcp.prober: tcp; tls_config.min_version: "VersionTLS13"`
- ICMP ping: `icmp.prober: icmp; icmp.count: 2; icmp.timeout: 2s`
- Webhook: `webhook.prober: webhook; webhook.send_resolved_ip: true`
- Custom header: `http.headers.X-Custom: ["v1"]`
- IPv6 only: `preferred_ip_protocol: ip6`
And three PromQL snippets you will reach for weekly:
- Availability 24h: `sum(rate(probe_success{result="success"}[24h])) / sum(rate(probe_success[24h]))`
- p95 latency: `histogram_quantile(0.95, sum by (le) (rate(probe_duration_seconds_bucket[5m])))`
- Targets down now: `probe_success == 0`
That is the playbook. Everything above has been battle-tested in at least one cluster; anything I did not include, I have personally seen fail in a way that cost an on-call shift.
## Related Articles
- [node_exporter Metrics Missing — Six Symptom Categories, Root-Cause Decision Tree, and Five Real Incidents](node-exporter-metrics-missing.md)
- [node_exporter Metrics Missing: 6 Symptom Categories and 5 Real K8s Incidents](node-exporter-troubleshoot.md)
- [Grafana Dashboards: Variables, JSON, Panels, Alerts, SLO](grafana-dashboard.md)
---
## Chaos Engineering in Production: Fault Injection from Experiment Design to Safe Live Drills
URL: https://ops.jiunotes.top/en/monitor/chaos-engineering.md
摘要: Chaos Engineering as a hypothesis-driven discipline: tool selection across Chaos Mesh, Litmus and Chaos Blade, four-step experiment design, production safety rails, and three live drill postmortems including one deliberate failure that exposed a connection-pool flaw.
### Answer
Chaos Engineering is not about breaking things; it is about actively verifying how a system behaves under fault before the fault finds you. I started it after a production incident where a single pod network blip cascaded into three core services going down. The discipline runs on four steps: state a steady-state hypothesis, design a bounded experiment, inject the fault, and verify or falsify the hypothesis. Chaos Mesh is my production tool of choice, Chaos Blade for quick staging checks. The safety rails that matter are scope limitation, an observation window, and automatic rollback when any metric crosses a threshold.
### FAQ
Q: Will chaos engineering take down production?
A: It can, if you skip the boundaries. My first live drill killed three production pods because I had no scope limit. The defenses that fixed it: run the experiment design in staging first, pin the selector to a fixed-count of pods so it cannot spread, attach an automatic rollback when any metric crosses a threshold, and schedule during low traffic. Scope limits turned the same experiment from dangerous to routine.
Q: What team size makes chaos engineering worthwhile?
A: I would say at least five services, Kubernetes fluency, and a real on-call rotation. Below that, most failures are human error and a chaos drill will not reveal much, while the tooling overhead is real. My team of eight running fifteen services is where it started paying for itself.
Q: What is the difference between Chaos Mesh and a fault-injection script?
A: Chaos Mesh is a declarative CRD controller. It controls the exact time, scope and duration of injection and self-recovers. A hand-written script can simulate a simple kill but lacks precise control and automatic recovery, which makes it risky for production. My rule: scripts are fine for one-off staging checks; anything with a network partition needs Chaos Mesh.
Q: How often should I run chaos experiments?
A: I run one controlled drill per week in production and keep the number low on purpose. Frequent drilling trains the team, but it also consumes attention and risks real fatigue in the on-call rotation. The rhythm that works for me is a steady weekly cadence, plus a dedicated drill every quarter that targets a failure mode we have never tested before.
### Full Content
# Chaos Engineering in Production: Fault Injection from Experiment Design to Safe Live Drills
In 2023 a pod in our payment system hit a network blip. The timeouts cascaded and three core services went down at once. During the postmortem I realized we had never tested that exact failure mode. We had no idea the system would react that way, because we had never asked it. That is the entire point of chaos engineering: you ask the question on your schedule, not on the fault's schedule.
This article covers the full path I took, from tool selection to production drills, including three live experiments and one that deliberately failed.
## The Core Idea: Hypothesis-Driven, Not Random Destruction
Chaos engineering is not "throw random failures at the cluster and hope." It is a **controlled experiment based on a falsifiable hypothesis**.
```
1. State the steady-state hypothesis
"This service should be 99.9% available at <200ms average latency"
2. Design the experiment
"If I kill 50% of the pods, does the service hold steady state?"
3. Execute the experiment
inject fault → observe → record
4. Verify or falsify
Hypothesis holds: service degrades but stays available, P99 goes 200ms→800ms, still in SLA
Hypothesis fails: service is unavailable, dependency config needs fixing
```
**The rule I never break:** an experiment must have a hypothesis, observation metrics, and a rollback plan. If any of the three is missing, do not run it.
## Tool Selection: Chaos Mesh vs Litmus vs Chaos Blade
| Dimension | Chaos Mesh | LitmusChaos | Chaos Blade |
|---|---|---|---|
| Origin | Meituan open source | Alibaba Cloud open source | Alibaba open source |
| [Kubernetes](https://kubernetes.io/docs/) model | Native CRD | Native CRD | DaemonSet |
| Experiment types | Rich (network/CPU/IO/kernel) | Medium (K8s-focused) | Light (script-based) |
| Documentation | Excellent | Good | Fair |
| Community activity | High | Medium-high | Medium |
| Learning curve | Medium | Low | Lowest |
| Best fit | Production-grade drills | Domestic compliance | Quick validation |
**My choice:** Chaos Mesh for production drills (most complete, best docs), Chaos Blade for staging spot-checks (light, fast to deploy).
## Experiment Design: Four Steps
### Step 1: State the Hypothesis
```
Hypothesis: when 2 payment-service pods crash simultaneously,
the remaining pods absorb all traffic at ≥99.5% API availability
```
### Step 2: Define the Experiment Scope
```yaml
# chaos-mesh/pod-failure.yaml
apiVersion: chaos-mesh.org/v1alpha1
kind: PodChaos
metadata:
name: payment-pod-failure
namespace: payment-system
spec:
selector:
matchLabels:
app: payment-service
env: production
mode: fixed-count # affect exactly 2 pods, no more
value:
count: 2
action: pod-failure # kill the pod, not simulate latency
duration: '5m' # auto-recover after 5 minutes
scheduler:
cron: '@every 1h' # run hourly during the drill window
```
The three design details that keep this safe:
- `mode: fixed-count`: precise blast radius, cannot spill onto other services.
- `duration: 5m`: the experiment has a hard upper bound; no infinite fault.
- `scheduler.cron`: run at 02:00-04:00 in the low-traffic window to minimize business impact.
### Step 3: Define Observation Metrics
```yaml
# metrics to watch continuously during the drill
- payment_service_request_success_rate # success rate
- payment_service_latency_p99 # P99 latency
- payment_service_error_count_5xx # 5xx count
- payment_service_pod_healthy_count # healthy pod count
- downstream_dependency_status # is the downstream affected
```
**Principle:** define the observation metrics *before* the drill starts. An experiment validates a hypothesis; it is not a fishing expedition. Deciding what to watch mid-drill is how you miss the thing you were testing.
### Step 4: Define Rollback Conditions
```yaml
# terminate the drill if any condition is met
rollback_conditions:
- metric: payment_service_request_success_rate
condition: '< 0.95' # success below 95% → stop now
- metric: payment_service_latency_p99
condition: '> 2000' # P99 over 2 seconds → stop now
- manual: true # a human can stop it at any time
```
## Drill One: Pod Kill (Hypothesis Held)
**Hypothesis:** when 50% of payment-service pods crash, the service recovers automatically.
```bash
# run the drill
cmctl create podchaos payment-pod-failure -f chaos-mesh/pod-failure.yaml
# inspect the running experiment
cmctl get podchaos payment-pod-failure
# compare the [Grafana](https://grafana.com/docs/) panel 30 minutes before vs after
```
**Result:**
- ✅ Hypothesis held: autoscaling completed in 45 seconds, success rate stayed at 99.8%.
- ⚠️ Secondary finding: new pods had a 2-second DNS resolution delay, causing a few timeouts.
- 🔧 Fix: added a DNS cache TTL to kube-proxy.
**Lesson:** this drill surfaced a race condition that would never have shown up in production — DNS delay during scale-out. Finding it in a drill costs minutes; finding it live costs an incident.
## Drill Two: Network Partition (Hypothesis Failed)
**Hypothesis:** a 30-second network partition between payment-service and the database degrades gracefully.
```yaml
apiVersion: chaos-mesh.org/v1alpha1
kind: NetworkChaos
metadata:
name: payment-db-network-partition
namespace: payment-system
spec:
selector:
matchLabels:
app: payment-service
targetSelector:
matchLabels:
app: postgres-primary
action: partition # network partition
direction: both # block both directions
duration: '30s'
```
**Result:**
- ❌ Hypothesis failed: the connection pool did not release correctly. After the DB recovered, connections piled up.
- 🔥 Real impact: the fault lasted 30 seconds, but pool recovery took 8 minutes.
- 💥 Cascade: downstream callers of payment-service also timed out, two services went down together.
**Postmortem:** this failure was the most valuable outcome of the whole program. It exposed a failure mode we had never tested. The fixes:
1. Add `maxLifetime` and `idleTimeout` to the DB connection pool.
2. Add a circuit breaker to payment-service.
3. Make the next drill specifically verify that circuit breaker fires.
**This is the point:** chaos engineering is not about proving the system is strong. It is about finding the weak spots before they find you. A failed hypothesis is worth more than a passed one.
## Drill Three: CPU Stress (Partially Held)
**Hypothesis:** at 90% CPU utilization, payment-service rate-limits QPS and does not cascade.
```yaml
apiVersion: chaos-mesh.org/v1alpha1
kind: StressChaos
metadata:
name: payment-cpu-stress
namespace: payment-system
spec:
selector:
matchLabels:
app: payment-service
mode: fixed-count
value:
count: 2
stressors:
cpu:
workers: 4
load: 80 # 80% CPU load
queues: 4
duration: '10m'
```
**Result:**
- ✅ Partially held: QPS did drop, but by 30% instead of the expected 10%.
- ⚠️ New finding: GC pause time jumped from 50ms to 800ms under CPU pressure, spiking single-request latency.
- 🔧 Fix: adjusted JVM heap size and G1 GC parameters.
**Key insight:** the CPU drill exposed GC behavior that no dashboard showed. Without chaos engineering, that problem would have surfaced during a big sale.
## Safety Rails: Five Constraints for Production Drills
```
1. Scope: only affect the target service, never spread to upstream or downstream
2. Time: run in the low-traffic window, with a hard duration cap (max 10 minutes)
3. Rollback: any observation metric crossing a threshold stops the drill automatically
4. Permission: the drill operator needs SRE approval, never a single-handed run
5. Notification: announce the drill to the affected teams in Slack before it starts
```
**Hard rules I follow:**
- Maximum 2 production chaos experiments per week.
- Zero chaos experiments in the 7 days before a big sale.
- A full staging rehearsal before any production drill.
## The Drill-Day Checklist
Before I ever hit apply, I walk a short checklist in the operator channel. It looks boring, and that is exactly why it works.
```
[ ] Hypothesis stated in one sentence, readable by a human
[ ] Blast radius pinned: fixed-count selector, named namespace
[ ] Observation panel open and confirmed live
[ ] Rollback conditions wired and tested with a manual stop
[ ] Announcement posted to the affected teams
[ ] Two operators present: one drives, one watches the panel
[ ] Timebox set: never more than 10 minutes
```
If any box is unchecked, I close the window and postpone. The drills that went wrong for me are the ones where I skipped a line to save five minutes.
## How to Sell Chaos Engineering to Management
Use the cost of a real incident as the anchor:
- Show the cost of a production outage (downtime × hourly revenue loss).
- Show the number of weak points each drill uncovered (quantified risk).
- Show that the fix cost is a fraction of the incident cost.
- Emphasize "find it deliberately" versus "find it on fire."
After our first drill I wrote a postmortem that included a line item: "if this failure had happened in production, the estimated loss would be X." That single number convinced the CTO to fund the annual chaos program.
## FAQ
### Q: What is the difference between chaos engineering and A/B testing?
Chaos engineering validates a *reliability* hypothesis; A/B testing validates a *business feature* hypothesis. The methodology is similar (hypothesis → experiment → observe → conclude), but the goal differs: chaos finds weak points, A/B finds the best business variant. The tooling is entirely different as well.
### Q: What happens when a chaos experiment fails?
A "failed" experiment (hypothesis falsified) is a normal and valuable outcome. What matters is what you do next:
1. Record the process and the failure reason.
2. Analyze root cause and write a fix plan.
3. Re-run the experiment after the fix to verify.
4. Turn the drill into team knowledge (runbook, postmortem).
Do not treat a falsified hypothesis as an incident. That falsification is the whole point.
### Q: Is chaos engineering the same as a game day?
They overlap but are not identical. A game day is a broad, often team-wide rehearsal of a known runbook. A chaos experiment is a narrow, hypothesis-driven fault injection targeting one behavior. I use game days to rehearse response, and chaos drills to discover unknown behavior. Both belong in a mature resilience program.
## Related Articles
- [Blackbox Exporter Probe Configuration for SLO Design](blackbox-exporter.md)
- [Grafana Dashboard Design for SRE SLO Tracking](grafana-dashboard.md)
- [Loki Log Aggregation for Incident Forensics](loki-log-aggregation.md)
---
## Continuous Profiling in Practice: eBPF and Pyroscope from Sampling to a Real 500ms-to-50ms Optimization
URL: https://ops.jiunotes.top/en/monitor/continuous-profiling.md
摘要: Continuous Profiling with Pyroscope and eBPF: the three-component architecture, eBPF versus SDK sampling, reading CPU/memory/mutex flame graphs, production deployment pitfalls, and a real optimization case that cut P99 latency from 500ms to 50ms in 30 minutes.
### Answer
Continuous Profiling is the last piece of the observability stack. Traditional monitoring answers "what is wrong with the system"; profiling answers "what is the system doing right now." Pyroscope is the open-source platform I run, combining eBPF for zero-instrumentation system-level sampling with an SDK for function-level stacks. Its core value is that you can see CPU and memory hot spots in production without redeploying or changing code. The tradeoff is real: eBPF only sees the syscall layer on Linux x86_64, so Java and Go function-level detail needs an SDK. My biggest win was a 30-minute diagnosis of a P99 regression that two days of snapshot-based debugging could not catch.
### FAQ
Q: How is continuous profiling different from pprof or perf?
A: pprof and perf are one-shot snapshots. You run the command, get a picture of a moment, and the problem may already be gone. Continuous profiling samples on a schedule, accumulates a long-term trend, and lets you compare two time windows against each other. Pyroscope adds persistence, visualization, and history comparison that a one-shot tool cannot.
Q: Is eBPF or SDK profiling better?
A: They answer different questions. eBPF is zero-instrumentation and sees the syscall layer, but cannot see inside application logic. An SDK sees full function stacks with high precision, but needs code changes or build-time instrumentation. My best practice is both: eBPF for system-level CPU/memory/IO, SDK for function-level hot spots.
Q: How does Pyroscope relate to Prometheus and Grafana?
A: Pyroscope is a standalone profiling platform. It exports via OTLP and has a Grafana datasource plugin, so you can render flame graphs directly inside Grafana. The data flow is application → Pyroscope agent → Pyroscope server → Grafana. You do not have to use Pyroscope; Parca is a valid alternative backend.
Q: What sample rate should I run in production?
A: I keep 10Hz as the default. At 100Hz the flame graph is sharper, but data volume and storage cost scale up tenfold. My rule is 10Hz for steady state, and a temporary bump to 100Hz only while diagnosing an active incident. After the incident I drop it back down before it becomes a permanent budget line.
### Full Content
# Continuous Profiling in Practice: eBPF and Pyroscope from Sampling to a Real Optimization
Last year a Go service in our payment stack saw P99 latency jump from 100ms to 500ms. Two engineers spent two days on it and found nothing. We had tried pprof snapshots, but the problem was intermittent — by the time we attached, the spike was gone. The answer came from Pyroscope continuous profiling: thirty minutes of flame-graph comparison showed the hot path was a `map` operation triggering repeated full GC pauses.
The lesson stuck with me: **continuous profiling preserves history**. Even when the problem is not reproducing right now, the samples are still in the store, and the trend is visible. This article covers the architecture, the deployment, the pitfalls, and that one optimization.
## Pyroscope Architecture: Three Components
```
┌─────────────┐ ┌──────────────┐ ┌──────────────┐
│ Agent │────▶│ Pyroscope │────▶│ [Grafana](https://grafana.com/docs/) │
│ (sampling) │ │ Server │ │ (visualization)
│ eBPF/SDK │ │ (store) │ │ │
└─────────────┘ └──────────────┘ └──────────────┘
```
- **Agent**: runs next to the target process (or as a sidecar), samples CPU/memory flame graphs.
- **Pyroscope Server**: receives, stores, aggregates profile data, and serves the query API.
- **Grafana**: renders flame graphs and historical comparisons via the Pyroscope plugin.
## Two Sampling Modes: eBPF vs SDK
### eBPF Mode: Zero Instrumentation
eBPF samples at the kernel layer without touching application code, so it works on any binary.
```bash
# run the Pyroscope eBPF agent as a sidecar or DaemonSet
docker run -d \
--name pyroscope-agent \
-v /proc:/host/proc:ro \
-v /sys:/host/sys:ro \
-v /:/rootfs:ro \
-e PYROSCOPE_APP_NAME=my-go-service \
-e PYROSCOPE_SERVER_ADDRESS=http://pyroscope-server:4040 \
--privileged \
pyroscopeio/pyroscope:latest \
agent \
--collectors=cpu,Memory,mutex,blocks
```
**eBPF limits I hit:**
- [Linux](https://www.kernel.org/doc/html/latest/) x86_64/arm64 only.
- Kernel >= 4.9 (ideally >= 5.4 with BTF).
- Cannot see user-space function stacks for managed runtimes (Go goroutines, Java GC).
### SDK Mode: Function-Level Precision
The SDK samples at the application layer and produces complete call stacks.
```go
// Go: integrate pprof with the Pyroscope exporter
import (
"go.pyroscope.io/godeltaprof/http/pprof"
pyroscope "go.pyroscope.io/pyroscope/pkg/client"
)
func init() {
pyroscope.Configure(pyroscope.Config{
ApplicationName: "my-go-service",
ServerAddress: "http://pyroscope-server:4040",
LogWriter: os.Stdout,
ProfilingEnabled: map[pyroscope.ProfileType]bool{
pyroscope.ProfileCPU: true,
pyroscope.ProfileAllocObjects: true,
pyroscope.ProfileAllocSpace: true,
pyroscope.ProfileInuseObjects: true,
pyroscope.ProfileInuseSpace: true,
pyroscope.ProfileGoroutines: true,
pyroscope.ProfileMutexCount: true,
pyroscope.ProfileMutexDuration:true,
pyroscope.ProfileBlockCount: true,
pyroscope.ProfileBlockDuration:true,
},
})
}
```
```python
# [Python](https://docs.python.org/): pyroscope-python SDK
# pip install pyroscope-io
import pyroscope
pyroscope.configure(
application_name="my-python-service",
server_address="http://pyroscope-server:4040",
tags={"service": "payment", "env": "production"},
)
```
**My judgment:** for Go/Python/Java I prefer SDK because it gives function-level hot spots. eBPF is the fallback for services where I have no source. When both are available, SDK first, eBPF as the system-level complement.
## Production Deployment: Standing Up the Server
```yaml
# docker-compose.yml for Pyroscope
version: '3.8'
services:
pyroscope:
image: pyroscopeio/pyroscope:latest
restart: unless-stopped
ports:
- "4040:4040" # agent ingestion port
- "6060:6060" # HTTP API
environment:
- PYROSCOPE_WRITE_USERNAME=admin
- PYROSCOPE_WRITE_PASSWORD=${PYROSCOPE_PASSWORD}
- PYROSCOPE_AUTH_ENABLED=true
- PYROSCOPE_DATA_RETENTION=72h
volumes:
- pyroscope-data:/data
grafana:
image: grafana/grafana:11.1.0
restart: unless-stopped
ports:
- "3000:3000"
environment:
- GF_INSTALL_PLUGINS=grafana-pyroscope-datasource
volumes:
- grafana-data:/var/lib/grafana
depends_on: [pyroscope]
volumes:
pyroscope-data:
grafana-data:
```
**Critical settings:**
- `DATA_RETENTION=72h`: three days is enough for incident back-tracing; anything longer needs object storage.
- `AUTH_ENABLED=true`: mandatory. Profile data contains code paths and function names, which is sensitive.
- `grafana-pyroscope-datasource`: lets you read flame graphs inside Grafana.
## Reading a Flame Graph: Three Views
### CPU Profiling: Where the Time Goes
```
# X axis: time, left to right
# Y axis: call stack depth, bottom to top
# block width: share of CPU time
# hot-spot rules:
# 1. widest block = most CPU-hungry function
# 2. blocks closest to the bottom = entry points (main/goroutine)
# 3. red-highlighted = GC-related functions (Go-specific)
```
**Real case:** in one optimization I found `processPayment()` eating 40% of CPU. Expanding the stack showed `calculateTax()` doing heavy string concatenation in a loop, which in Go allocates a lot of temporary objects.
```go
// ❌ bad: string concatenation in a loop creates many temporary objects
func calculateTax(amount float64) string {
result := ""
for i := 0; i < 1000; i++ {
result += fmt.Sprintf("item-%d: %.2f\n", i, amount*0.1)
}
return result
}
// ✅ good: use strings.Builder
func calculateTax(amount float64) string {
var sb strings.Builder
for i := 0; i < 1000; i++ {
fmt.Fprintf(&sb, "item-%d: %.2f\n", i, amount*0.1)
}
return sb.String()
}
```
**Result:** `calculateTax()` CPU share dropped from 40% to 3%, and P99 went from 500ms to 50ms.
### Memory Profiling: Allocation Hot Spots
```yaml
# memory profile exposes four series:
# 1. alloc_objects: number of allocated objects
# 2. alloc_space: bytes allocated
# 3. inuse_objects: live objects right now
# 4. inuse_space: live bytes right now
```
**Interpretation rules I use:**
- `alloc_space` high → heavy GC pressure (long pauses).
- `inuse_space` climbing → leak suspect (objects not being collected).
- `alloc_objects` high but `inuse_objects` low → many short-lived objects, frequent GC.
**Real case:** a Java service showed `inuse_space` climbing steadily. The culprit was a connection pool that never closed connections — each request opened an HTTP connection and never released it. Seven days later the service OOM'd. Profiling caught it at day three.
### Mutex and Block Profiling: Concurrency Bottlenecks
```yaml
# Go-specific profiles:
# 1. mutex_count: lock contention count
# 2. mutex_duration: lock wait time
# 3. block_count: goroutine block count
# 4. block_duration: goroutine block time
```
**Interpretation rules:**
- `mutex_duration` high → heavy contention, consider RW locks or lock-free structures.
- `block_duration` high → channel/send blocking, consider async or bigger buffers.
## Production Pitfalls
### Pit 1: eBPF Agent Permission Denied
```
Error: unable to attach probe: operation not permitted
Error: bpf: Operation not permitted
```
The container lacks `CAP_SYS_ADMIN`, and the seccomp profile restricts eBPF syscalls. The agent needs `privileged: true`, or the `SYS_ADMIN`/`BPF`/`PERFMON` capabilities with seccomp unconfined. My advice: run the eBPF agent as a DaemonSet with hostPID on the node, not inside the app container. Half the permission problems disappear.
### Pit 2: Retention Set Too Long Fills the Disk
```yaml
# ❌ bad: 30 days of profile data is terabytes
PYROSCOPE_DATA_RETENTION=30d
# ✅ good: 72 hours is enough for production
PYROSCOPE_DATA_RETENTION=72h
```
**The math:** one Go service sampled once per second, about 1KB per sample, over 72 hours is roughly 72 × 3600 × 1 ≈ 260MB. Across 100 services that is about 26GB — manageable. At 30 days it becomes a terabyte-scale problem.
### Pit 3: Flame Graph Too Blurry, Sample Rate Too Low
```yaml
# default sample rate is 10Hz (10 samples per second)
# fast functions can be invisible at this rate
# raise to 100Hz for diagnosis
PYROSCOPE_PROFILE_RATE=100
```
**Note:** higher sample rate means more data and more storage pressure. I only raise it temporarily during diagnosis and keep 10Hz the rest of the time.
## Balancing Cost and Fidelity
Continuous profiling has a cost curve, and it is not free. The storage math above makes that clear. My approach is to scale sample rate with value: keep a modest 10Hz on the hot services that already have SDK instrumentation, and only bump the eBPF collectors during a diagnosed incident. I also cap the number of profiled applications. Profiling every container in the cluster is the fastest way to turn a useful tool into a budget line item. Pick the services that matter, instrument them well, and leave the long tail to system metrics.
## The 500ms to 50ms Optimization, End to End
**Background:** payment service P99 jumped from 100ms to 500ms and stayed there for two days.
### Step 1: Open the Flame Graph
```
# in the Pyroscope UI:
# 1. Application: my-payment-service
# 2. Profile type: cpu
# 3. Time range: last 2 hours
# 4. Compare: before the incident vs during the incident
```
### Step 2: Locate the Hot Function
The flame graph showed `processPayment()` at 40% CPU. Expanding it revealed `calculateTax()` as the dominant cost.
### Step 3: Walk the Stack
Going deeper, `calculateTax()` was a string-concatenation loop. That did not match our code review, so I suspected a recent commit.
```bash
[git](https://git-scm.com/doc) blame --date=short src/payment/tax.go | grep "result +="
# output: a1b2c3d 2024-03-15 xxx result += fmt.Sprintf(...)
```
### Step 4: Fix and Ship
```go
// rewrote tax.go to use strings.Builder instead of concatenation
// opened the PR, passed review, deployed
```
### Step 5: Verify
```bash
# 10 minutes after deploy, reopen the flame graph
# confirm calculateTax() CPU share dropped from 40% to 3%
# confirm P99 latency dropped from 500ms to 50ms
```
**Total time:** 30 minutes from symptom to verified fix. With snapshot-based pprof and manual reproduction, the same diagnosis would have taken days, if it happened at all.
## FAQ
### Q: Can I run Pyroscope and pprof at the same time?
Yes, and I recommend it. Pyroscope gives you continuous monitoring and history comparison; pprof gives you a precise full stack at one instant. The workflow is Pyroscope finds the anomaly → pprof pins it down → fix → Pyroscope verifies.
### Q: Does profiling data leak business secrets?
It can. Flame graphs expose function names, code paths, and sometimes business logic. Mitigations: enable authentication (`AUTH_ENABLED=true`), restrict production access to SRE and security, and avoid custom profilers that embed sensitive variable values.
### Q: Without source code, am I stuck with eBPF flame graphs only?
Yes, for function-level detail. But eBPF still shows syscall-level hot spots (`read`, `write`, `mmap`), and most performance problems — I/O bottlenecks, lock contention, allocation — leave traces at that layer. Function-level diagnosis is what the SDK is for.
## Related Articles
- [eBPF Observability Principles: Non-Invasive Monitoring from Kernel Traces to Production](ebpf-observability.md)
- [PromQL Quick Reference: 5 Golden Metrics and Copy-Paste Templates](prometheus-query.md)
- [Grafana Dashboard Design for SRE SLO Tracking](grafana-dashboard.md)
---
## DevSecOps Security Left-Shift: CI/CD Scanning Strategy from SAST/SCA/DAST to Supply Chain
URL: https://ops.jiunotes.top/en/monitor/devsecops-security.md
摘要: A hands-on DevSecOps playbook: integrating SAST, SCA and DAST into CI/CD, scanning container images, detecting leaked secrets, handling policy bypasses, and choosing between blocking the pipeline versus notifying on findings.
### Answer
DevSecOps is security left-shift: moving checks into the pipeline instead of auditing at the door. I rebuilt our CI/CD after two incidents — a hardcoded AWS key pushed to a public repo, and a log4j CVE still running in production. The model is three layers: SAST at the commit stage, SCA at the dependency stage, DAST after deployment. The pivotal decision is whether a finding blocks the pipeline or merely notifies. My policy: SCA critical blocks, SAST warning-level does not, DAST always blocks, and leaked secrets are zero-tolerance. The payoff was MTTR dropping from four hours to thirty minutes.
### FAQ
Q: What is the difference between SAST and DAST, and when do I use each?
A: SAST analyzes source code before compile and finds logic flaws like SQL injection or hardcoded secrets. DAST tests a running deployment and finds runtime issues like XSS or CSRF. SAST has higher false positives but covers earlier; DAST is more accurate but only sees what is deployed. My workflow: SAST plus SCA in the pull request, DAST in staging.
Q: Should I pick Trivy, Snyk, or something else?
A: Depends on budget. Trivy is free and covers images, code and config, which fits a small team. Snyk is the most complete but paid, for larger budgets. Grype is a lightweight image-only scanner. My stack is Trivy for images plus Semgrep for code, all free.
Q: Security scanning keeps blocking the dev flow. What do I do?
A: This is the central tension in DevSecOps. The fixes that work for me: separate blocking findings from advisory findings and only block on high severity; give developers a one-click remediation (auto dependency upgrade); run a periodic security-debt cleanup so findings do not accumulate; and let security and dev teams jointly define severity rather than security deciding alone.
Q: How do I defend against supply-chain attacks?
A: Three layers work for me. Pin exact dependency versions instead of using ranges, so a poisoned version cannot silently slip in. Generate an SBOM on every build so a known CVE like log4j can be traced to affected services in minutes. And verify image signatures with cosign so a tampered image never reaches production. The combination turns an unknown compromise into a fast, localizable event.
### Full Content
# DevSecOps Security Left-Shift: CI/CD Scanning Strategy
Two incidents forced this change on me. First, a developer hardcoded an AWS key into source and pushed it to a public repository. Second, a production service was still running an old dependency with a known log4j CVE. In both cases the security check existed only at the final gate, which is one place too late. I rebuilt the pipeline with three scan layers and the results were stark: MTTR for security incidents dropped from four hours to thirty minutes, and the high-severity finding rate fell by 80%.
This is the complete integration strategy, including the parts that went wrong.
## The Three-Layer Scan Model
```
Dev stage Build stage Deploy stage
│ │ │
▼ ▼ ▼
┌────────┐ ┌───────────┐ ┌───────────┐
│ SAST │ │ SCA │ │ DAST │
│ (code) │ │ (deps) │ │ (runtime) │
└────────┘ └───────────┘ └───────────┘
│ │ │
▼ ▼ ▼
PR stage Build stage Staging stage
(block high) (block high) (block high)
```
### SAST: Static Application Security Testing
SAST analyzes source for logic flaws before the code is committed.
```yaml
# .github/workflows/sast.yml
name: SAST Scan
on: [pull_request]
jobs:
semgrep:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: returntocorp/semgrep-action@v1
with:
config: 'p/default'
severity: 'error'
fail_on_exit: true # high-severity blocks the pipeline
args: '--json --output=semgrep.json'
- name: Upload SARIF
uses: github/codeql-action/upload-sarif@v2
with:
sarif_file: semgrep.json
```
**Semgrep vs Bandit vs GoSec:**
- **Semgrep**: multi-language, custom rules, low false positives — my first choice.
- **Bandit**: [Python](https://docs.python.org/)-specific, simple.
- **GoSec**: Go-specific, a must for Go projects.
**The pit I hit:** `p/default` rule set is broad but can miss high-severity rules relevant to your stack. I now add team-specific rules:
```yaml
# semgrep.yaml
rules:
- id: hard-coded-secret
patterns:
- pattern: $PASSWORD = "$$";
- pattern: api_key: "$$"
message: "Hardcoded secrets are not allowed in code"
severity: ERROR
languages: [python, go, java]
```
### SCA: Software Composition Analysis
SCA scans the dependency tree for known CVEs.
```yaml
# Trivy image scan
- name: Trivy Image Scan
uses: aquasecurity/trivy-action@master
with:
image-ref: 'myregistry/my-app:latest'
format: 'sarif'
output: 'trivy-results.sarif'
severity: 'CRITICAL,HIGH'
exit-code: '1' # high severity blocks the pipeline
# Trivy dependency scan (Go modules)
- name: Trivy Dependency Scan
uses: aquasecurity/trivy-action@master
with:
scan-type: 'fs'
scan-ref: '.'
format: 'sarif'
output: 'trivy-deps.sarif'
severity: 'CRITICAL,HIGH'
exit-code: '1'
```
**Trivy vs Snyk vs Grype:**
- **Trivy**: free, comprehensive (images + code + config), OCI-compatible — my production default.
- **Snyk**: most complete but paid, great IDE plugin.
- **Grype**: image-only, light and fast.
**My experience:** Trivy is fast (a Go image scans in about 30 seconds) but its rule DB lags. When log4j broke, Trivy took six hours to publish the rule. I run Snyk in parallel as a fast-updating supplement.
### DAST: Dynamic Application Security Testing
DAST tests a running service after deployment.
```yaml
# OWASP ZAP baseline scan
- name: OWASP ZAP Scan
uses: zaproxy/action-baseline@v0.10.0
with:
target: 'http://staging.example.com'
rules_file_name: '.zap/rules.tsv'
cmd_options: '-a'
fail_action: true
allow_issue_writing: false
```
**ZAP vs Burp Suite vs Nessus:**
- **ZAP**: free, open source, excellent CI integration — my automation first choice.
- **Burp Suite**: most powerful but paid, for manual penetration work.
- **Nessus**: infrastructure vulnerability scanner, not application layer.
**DAST limitation:** it only tests deployed services and cannot find logic flaws that need a specific input vector. Its real value is catching configuration errors and runtime issues.
## Secret Detection: The First Line of Defense
```yaml
# GitLeaks secret detection
- name: GitLeaks Scan
uses: gitleaks/gitleaks-action@v2
env:
GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }}
# or use Trivy's secret scanner
- name: Trivy Secret Scan
uses: aquasecurity/trivy-action@master
with:
scan-type: 'fs'
scan-ref: '.'
severity: 'CRITICAL,HIGH'
format: 'sarif'
output: 'trivy-secret.sarif'
```
GitLeaks' default rules cover 200+ secret formats (AWS, GCP, Azure, GitHub tokens, JWT, SSH keys), which is enough for most cases. Internal formats need a custom rule:
```toml
# .gitleaks.toml
[[rules]]
id = "internal-api-key"
description = "Internal API Key"
regex = '^IAK_[a-zA-Z0-9]{20,}$'
severity = "CRITICAL"
```
## The Pivotal Decision: Block vs Notify
This is the heart of DevSecOps — **when a scan finds something, do you stop the pipeline?**
| Scan type | Suggested policy | Why |
|---|---|---|
| SAST high (SQL injection, RCE) | **Block** | Directly exploitable |
| SAST medium (info leak) | **Notify** | Needs human assessment |
| SCA high (known CVE) | **Block** | Known exploit exists |
| SCA low (info leak) | **Notify** | Depends on the dependency chain |
| Secret leak | **Block** | Zero tolerance |
| DAST high | **Block** | Runtime vulnerability |
| Base image vulnerability | **Notify + upgrade** | Not dev-controllable |
**My practice:**
- PR stage: SAST + SCA + secret detection → **block on high**.
- Build stage: image scan → **block on high**.
- Staging stage: DAST → **block on everything**.
- Weekly: all findings summarized → **notify security team**.
```yaml
# tiered blocking policy
- name: Check Security Results
if: always()
run: |
critical=$(jq '[.results[] | select(.Severity=="CRITICAL")] | length' trivy-results.sarif)
high=$(jq '[.results[] | select(.Severity=="HIGH")] | length' trivy-results.sarif)
if [ "$critical" -gt 0 ]; then
echo "::error::Critical vulnerabilities found: $critical"
exit 1
fi
if [ "$high" -gt 0 ]; then
echo "::warning::High vulnerabilities found: $high (will notify security team)"
fi
```
## Handling a Policy Bypass
Sooner or later a developer needs to ship something with a known finding. The worst thing I did was ignore this reality and let the team "fix" it by commenting out the scan. The right way is a controlled bypass with an expiry:
```yaml
# .semgrepignore with a review trail
# only allow a bypass with a ticket and a date
- id: semgrep-123
reason: "ticket SEC-4567, temporary auth bypass, expires 2026-11-15"
expires: 2026-11-15
```
The rules I enforce:
- Every bypass references a ticket and has an expiration date.
- No bypass survives its expiry; an expired bypass re-blocks the pipeline.
- The security team reviews the bypass list monthly and calls out anything stale.
## Scanning Performance: The Layered Pipeline
Security scanning has a real cost in pipeline latency, and the naive fix — running everything on every commit — is how you get the pipeline blocked by the security team you are trying to help. My pipeline splits scanning by speed. The PR stage runs only the fast, high-value checks: secret detection and light SAST, both under thirty seconds. The nightly build runs the expensive full pass: complete SAST plus DAST against staging, ten to thirty minutes. The build stage runs incremental scans on changed files only. The result is that a developer's PR rarely waits more than a few extra minutes, while the deep scans still happen every night.
## Supply Chain: The Blind Spot Nobody Watches
**Supply-chain attacks are the dominant security story of the last two years** — poisoned npm packages, stolen CI tokens, compromised build environments.
### Defenses
```yaml
# 1. Pin dependency versions (never use latest)
# package.json
"dependencies": {
"express": "4.18.2", # exact version, not ^4.18.2
"lodash": "4.17.21"
}
# 2. Verify dependency integrity
- name: Verify Dependencies
run: |
npm audit --production --audit-level=high
go list -m all | xargs go vet
# 3. Verify image signature (cosign)
- name: Verify Image Signature
run: |
cosign verify --key https://example.com/key.pub \
myregistry/my-app:latest
# 4. Generate an SBOM (SPDX format)
- name: Generate SBOM
uses: anchore/sbom-action@v0
with:
path: .
format: spdx-json
output-file: sbom.spdx.json
```
**SBOM (Software Bill of Materials)** is the supply-chain foundation. It records every dependency and version in your software, so when a CVE like log4j hits, you can find affected services in minutes instead of days.
```yaml
# GitHub Actions auto-generates an SBOM on every build
- uses: anchore/sbom-action@v0
with:
path: .
format: spdx-json
```
## Managing Security Debt
The goal is not zero findings; it is a controlled backlog that never grows.
```yaml
# weekly security report to security and dev leads
name: Weekly Security Report
on:
schedule:
- cron: '0 9 * * 1' # Monday 09:00
jobs:
security-report:
runs-on: ubuntu-latest
steps:
- name: Collect Vulnerabilities
run: |
gh api repos/${{ github.repository }}/security-advisories \
--jq '.[] | select(.severity=="high")'
- name: Send Report
run: |
curl -X POST ${{ secrets.SLACK_WEBHOOK }} \
-d '{"text":"Weekly Security Report: ${{ steps.collect.outputs.vulns }} critical vulnerabilities"}'
```
I track each finding as a ticket with a hard SLA:
- Critical: fix within 24 hours.
- High: fix within 72 hours.
- Medium: fix within 7 days.
- Low: next iteration.
## FAQ
### Q: Scanning is slowing down CI/CD. How do I fix it?
Layer it:
1. Fast scans in the PR (secret detection, light SAST, under 30 seconds).
2. Full scans in the nightly build (full SAST, DAST, 10-30 minutes).
3. Incremental scans in the build stage (only changed files).
My experience: moving the full scan to nightly added only three minutes to the PR CI time (15 to 18 minutes), which is acceptable.
### Q: Developers complain security blocks their work. What changed things?
1. Tell them *why*, not just "Build Failed" — give the specific finding and a fix suggestion.
2. Offer one-click remediation (auto dependency upgrade, auto secret rotation).
3. Give them a local sandbox to run scans before pushing.
4. Regular training so they understand *why* the vulnerability matters, not just that their PR was rejected.
### Q: False positives are drowning the team. What do I do?
```yaml
# build a false-positive exemption list
- name: Suppress False Positives
run: |
echo '{"ignore":[{"id":"semgrep-123","reason":"confirmed false positive: test code"}]}' \
> .semgrepignore
```
The key principle: **you do not eliminate false positives, you manage them.** Build a shared false-positive knowledge base so the whole team sees which findings are already confirmed as noise, instead of re-judging each one.
## Related Articles
- [Bastion Host Selection and JumpServer Deployment](bastion-jumpserver.md)
- [SSH Hardening: Key-Based Authentication and Fail2ban](ssh-hardening.md)
- [Blackbox Exporter Probe Configuration for SLO Design](blackbox-exporter.md)
---
## "Distributed Tracing Sampling: Keep the Errors, Cut the Bill"
URL: https://ops.jiunotes.top/en/monitor/distributed-tracing-span-sampling.md
摘要: "How to sample distributed tracing without losing the traces that matter. Covers head-based vs tail-based sampling, probabilistic / rate-limited / adaptive / parent-based strategies, an OpenTelemetry Collector tail_sampling processor config, the memory-versus-fidelity tradeoff, and a post-launch inspection routine."
### Answer
Sampling splits into head-based (decide at span creation) and tail-based (decide after the whole trace completes). High-traffic systems use tail sampling to keep error and slow traces, and a probability plus rate limit to cap the volume. An OpenTelemetry Collector tail_sampling processor decides on latency, error and frequency, with a wait window and a trace buffer to avoid dropping what you need.
### FAQ
Q: What is the real difference between head-based and tail-based sampling, and why does everyone push tail-based?
a: Head-based sampling decides at the moment a span is created, so it is simple, zero-delay and uses no buffer, but it decides without seeing the outcome, so a trace that ends in an error can be discarded at the sample point. Tail-based sampling collects the spans until the request finishes (or longer) and then decides on the final result, so it keeps genuinely valuable error traces. The cost is buffering spans, memory and a little decision latency. High-value, high-traffic systems lean tail-based; low-traffic or edge collectors are better off head-based.
A:
Q: How long should tail sampling wait before deciding? Will a short wait drop slow requests?
a: The key parameter is the wait window, the OTel `decision_wait`. It is how long the Collector waits for the whole trace after the first span before deciding. Too short and a long cross-service trace is forced to decide before it has all arrived, so a slow request is judged "normal" and dropped. Too long and buffered spans pile up in memory. Start at 2 seconds, move to 5-10 seconds for multi-service or long-transaction systems, and pair it with a `num_traces` cap so memory cannot blow. This is a memory-versus-fidelity tradeoff; tune it against your real trace length.
A:
Q: What probability should I set, and will it sample away rare errors?
a: A pure probability sample such as 1% is cheap on steady traffic, but a rare error trace has a real chance of being dropped entirely, and those are exactly the ones you most need to see. The correct move is to layer rules inside tail sampling: force 100% retention for error traces and high-latency traces above a threshold, and only run probability on ordinary successful traces. The overall rate may sit at a few percent while error traces are almost never lost. You can also bucket by service importance, high sampling on a core payment path and low on health checks.
A:
Q: What does parent-based sampling mean, and when should I use it?
a: Parent-based sampling is a follow-the-upstream strategy: if the upstream decided to keep the trace, downstream services keep it too, so a trace is either whole or absent rather than a broken fragment with a parent missing and a lone child span. It pairs naturally with head-based sampling, and in microservices it is effectively mandatory; without it the trace graph fragments into pieces that never join up.
A:
Q: After sampling, how do I confirm I have not sampled away a critical error?
a: Three checks. First, make error and high-latency traces forced-retained in the tail rules and count the hits on those two rules separately, watching their rates. Second, route a few core businesses such as payment and login through a dedicated 100% sample channel that never depends on the generic sampler. Third, periodically trigger a known failure and verify the complete trace is discoverable in the backend. Do not trust the overall sampling number; watch the discoverability rate of critical traces.
A:
### Full Content
# Distributed Tracing Sampling: Keep the Errors, Cut the Bill
The moment distributed tracing is fully rolled out, the first thing that hurts is not the integration effort, it is the cost. Every request generates dozens or even hundreds of spans, and sending them all to a backend means storage and egress bills that climb month over month. Worse, the overwhelming majority of those spans are the normal path of healthy requests, and you do not need all of them. Sampling answers one question: out of a flood of spans, which ones do I keep so that I can still see the system clearly without the invoice crushing me.
But sampling has two fears. The first is that you sample away a rare error trace, so when something breaks, the trace you need is nowhere in the system. The second is that you sample in fragments, a parent kept and a child dropped, so the graph never assembles. This article explains head-based and tail-based sampling and the common strategies, then gives principles you can apply directly.
## 1. Why you must sample instead of keeping everything
Run the numbers first. A medium-traffic service at 2000 QPS, 30 spans per request on average, is about 5.2 billion spans a day. Storing all of it is terabytes, and queries get slow. More than 95% of it is normal, repetitive, and worthless for analysis. The real purpose of tracing is to locate a problem when something goes wrong, not to build a file on every healthy request.
So the industry consensus is clear: for a high-traffic system, sampling is not optional, it is mandatory. The hard part is doing it smart, pushing the rate down to a single-digit percentage while keeping error, slow and unusual traces almost never lost.
## 2. Head-based sampling: decide at the start
Head-based sampling decides at the moment the span is created whether to keep the trace, usually by a coin flip (probability) or a fixed rule. The benefits are direct:
- No buffer, no extra memory, the decision is immediate.
- No decision latency, so it is friendly to latency-sensitive services.
- Simple to implement, one `sampler` setting in the SDK.
The drawback is just as clear: it never sees the ending. A trace that ends in a 500 error can be dropped by the probability at the sample point, and a slow trace that spans ten services cannot be predicted as slow from the start. So head-based sampling fits low-traffic flows, or cases where you care about whether something exists rather than exactly which one failed.
## 3. Tail-based sampling: decide after the whole run
Tail-based sampling works the other way: collect the spans of a trace, wait until the request finishes (or longer, depending on config), then decide on the final behaviour. It can decide on these signals:
- Did this trace end in an error (HTTP 5xx, gRPC error, exception)?
- Did it exceed a latency threshold (outside P99)?
- Is it a rare path (low-frequency endpoint, a specific user)?
- Did it trigger a custom business rule (for example amount above a value)?
So you can keep every error and slow trace and sample ordinary ones by ratio, preserving maximum value at a very low overall rate. The cost is a component that buffers spans, uses memory and adds a little decision latency. The OpenTelemetry Collector `tail_sampling` processor does exactly this.
## 4. The sampling strategies, broken down
No single strategy is enough; production is a combination:
- **Probabilistic**: keep each trace at a fixed ratio such as 1%. Simple, fine on steady traffic, but it randomly drops rare errors.
- **Rate-limited**: keep at most N traces per second, drop the rest. Very effective at capping the total, good against bursts.
- **Adaptive**: adjust the rate dynamically from current traffic and error rate, raising it when errors climb. Needs extra implementation or platform support.
- **Parent-based**: downstream follows the upstream sampling decision, preserving trace integrity.
- **Attribute-based**: different rates by service name, endpoint or user tag, high for core business and low for side paths.
Production is usually tail sampling plus forced retention of errors and slow traces, plus probability or rate limit on the rest.
## 5. OpenTelemetry Collector tail sampling config
Here is the skeleton of a `tail_sampling` processor. The logic is: check error first, then slowness, and retain those at 100%; otherwise run probability to hold the total down:
```
processors:
tail_sampling:
decision_wait: 10s
num_traces: 50000
policies:
- name: errors
type: status_code
status_code:
status_codes: [ERROR]
- name: slow
type: latency
latency:
threshold_ms: 500
- name: probabilistic
type: probabilistic
probabilistic:
sampling_percentage: 5
```
Order matters: an earlier policy that hits keeps the trace, and only traces that miss everything fall through to the probability policy. Pair it with a `batch` processor and an exporter. With this config, error and slow traces are almost never lost and the overall rate sits around 5%, cutting the cost by more than ninety percent.
## 6. The memory-versus-fidelity tradeoff
The Achilles heel of tail sampling is memory. It buffers spans in the Collector before deciding, so two parameters need attention:
- `decision_wait`: too long and memory piles up and latency grows; too short and a long trace is forced to decide before it has arrived, dropping a slow request by mistake. Start at 2 seconds, move to 5-10 seconds for multi-service systems.
- `num_traces`: the cap on concurrently buffered traces, to stop memory blowing. Over the cap triggers eviction and may drop traces still waiting.
The rule of thumb: the longer and more multi-service your traces, the larger `decision_wait`; the smaller the Collector memory, the tighter `num_traces`. After launch, watch Collector memory and the count of traces evicted before decision, the two metrics that matter most for tail sampling.
## 7. Parent-based sampling keeps traces whole
If every service in a microservice system flips its own coin, you get broken traces, a parent not sampled and a child sampled, so the graph is full of gaps. Parent-based sampling makes downstream follow upstream: if upstream says keep, downstream keeps too; if upstream says drop, downstream drops. It is on by default in OpenTelemetry and matters most when combined with head-based sampling. Do not switch it off for finer granularity, or you get fragments that never assemble.
## 8. Verifying you did not lose critical traces
A good sampling number does not mean nothing was lost. Run three checks:
- Monitor the hit rate of each tail policy, especially `errors` and `slow`, to confirm error traces really enter the retention pool.
- Route core businesses such as payment and login through a dedicated 100% sample channel, independent of the generic sampler, so that part is always discoverable.
- Periodically use a known fault as a probe: deliberately trigger an error and confirm the complete trace is findable in the backend.
Watch the discoverability rate of critical traces as an SLO rather than the overall sampling rate.
## 9. Post-launch discipline against silent decay
Sampling config quietly degrades too: one Collector config rollback can turn tail sampling back into head-based, and one memory limit can shrink `num_traces` and start dropping traces. Keep a routine:
- **Weekly**: look at the hit-rate trend of each tail policy; the error retention rate should sit stably near 100%. A sharp drop means a rule was changed.
- **Weekly**: check Collector memory and evicted-before-decision count; persistently high memory means `decision_wait` or `num_traces` needs adjusting or the Collector needs scaling.
- **Before any change**: validate the sampled rate on a known error trace in staging, then roll out; re-check immediately after.
- **A hard rule I hold**: never include error traces in a probability sample to save money. Error and slow traces are forced-retained; that is the non-negotiable floor of any sampling config.
- **Every service you add**: the new service should inherit the unified sampling policy by default, so no service runs unbounded full-reporting and quietly raises the bill.
## FAQ
What is the real difference between head-based and tail-based sampling, and why does everyone push tail-based?
Head-based sampling decides at the moment a span is created, so it is simple, zero-delay and uses no buffer, but it decides without seeing the outcome, so a trace that ends in an error can be discarded at the sample point. Tail-based sampling collects the spans until the request finishes (or longer) and then decides on the final result, so it keeps genuinely valuable error traces. The cost is buffering spans, memory and a little decision latency. High-value, high-traffic systems lean tail-based; low-traffic or edge collectors are better off head-based.
How long should tail sampling wait before deciding? Will a short wait drop slow requests?
The key parameter is the wait window, the OTel decision_wait. It is how long the Collector waits for the whole trace after the first span before deciding. Too short and a long cross-service trace is forced to decide before it has all arrived, so a slow request is judged normal and dropped. Too long and buffered spans pile up in memory. Start at 2 seconds, move to 5-10 seconds for multi-service or long-transaction systems, and pair it with a num_traces cap so memory cannot blow. This is a memory-versus-fidelity tradeoff; tune it against your real trace length.
What probability should I set, and will it sample away rare errors?
A pure probability sample such as 1% is cheap on steady traffic, but a rare error trace has a real chance of being dropped entirely, and those are exactly the ones you most need to see. The correct move is to layer rules inside tail sampling: force 100% retention for error traces and high-latency traces above a threshold, and only run probability on ordinary successful traces. The overall rate may sit at a few percent while error traces are almost never lost. You can also bucket by service importance, high sampling on a core payment path and low on health checks.
What does parent-based sampling mean, and when should I use it?
Parent-based sampling is a follow-the-upstream strategy: if the upstream decided to keep the trace, downstream services keep it too, so a trace is either whole or absent rather than a broken fragment with a parent missing and a lone child span. It pairs naturally with head-based sampling, and in microservices it is effectively mandatory; without it the trace graph fragments into pieces that never join up.
After sampling, how do I confirm I have not sampled away a critical error?
Three checks. First, make error and high-latency traces forced-retained in the tail rules and count the hits on those two rules separately, watching their rates. Second, route a few core businesses such as payment and login through a dedicated 100% sample channel that never depends on the generic sampler. Third, periodically trigger a known failure and verify the complete trace is discoverable in the backend. Do not trust the overall sampling number; watch the discoverability rate of critical traces.
## Related Articles
- [OpenTelemetry: From SDK to Collector, a Practical Guide](opentelemetry-guide.md)
- [Continuous Profiling: Wiring Flame Graphs into Observability](continuous-profiling.md)
- [eBPF Observability: Kernel-Level Collection Without App Changes](ebpf-observability.md)
---
## eBPF Observability Principles: Non-Invasive Monitoring from Kernel Traces to Production
URL: https://ops.jiunotes.top/en/monitor/ebpf-observability.md
摘要: eBPF principles and practice: the three attach methods (kprobe, uprobe, tracepoint via ftrace), real performance cost, production pitfalls around kernel version, permissions and missing BTF, and three real incident postmortems.
### Answer
eBPF lets you run sandboxed user-space programs inside the Linux kernel, collecting metrics with zero instrumentation and low overhead. The three attach points I use are kprobe for kernel functions, uprobe for user-space functions, and tracepoint for the kernel's pre-defined stable hooks. The tool chain runs from bpftrace one-liners for diagnosis to Cilium Hubble for production Kubernetes network observability. The real cost is not zero: high-frequency tracepoints burn CPU, so I always aggregate into maps instead of printing per event. My biggest pitfall was assuming BTF was present on a custom cloud kernel — it was not, and kprobes failed to load.
### FAQ
Q: How is eBPF different from strace or ltrace?
A: strace is syscall-level tracing with huge overhead, unsuitable for production. eBPF runs a small compiled program inside the kernel, with an order of magnitude less overhead, and it can run persistently and report metrics live rather than as a post-hoc replay. strace tells you what happened after the fact; eBPF tells you what is happening right now.
Q: What privileges does eBPF need, and does it work in a container?
A: Loading an eBPF program requires CAP_SYS_ADMIN, which a normal container does not have. Options: run a privileged container (higher security risk), grant SYS_ADMIN/BPF/PERFMON with seccomp unconfined, or use a tool like Cilium that loads programs through a whitelist. My production advice is to run eBPF on the node itself, not inside the app container.
Q: Is writing eBPF programs hard? Are there ready-made tools?
A: Writing C and compiling is the most flexible but has the steepest learning curve. For daily work, BCC's Python scripts (tcplife, execsnoop) or a single bpftrace line is enough. For production, Telegraf's eBPF plugin or Cilium Hubble works out of the box.
Q: Should I pick kprobe or tracepoint, and which is more stable?
A: The first time I attached a kprobe to `do_mkdir`, a kernel upgrade renamed it to `vfs_mkdir` and my script broke. A tracepoint is a pre-defined kernel hook whose interface does not change when the internals change, so it is far more stable. My rule: prefer tracepoint whenever it covers the case, and only fall back to kprobe when I need a specific kernel function's arguments.
### Full Content
# eBPF Observability Principles: Non-Invasive Monitoring from Kernel Traces to Production
Last year, adding observability to a production [Kubernetes](https://kubernetes.io/docs/) cluster, I hit a strange problem: a Java service was stalling with GC pauses over 500ms. [Prometheus](https://prometheus.io/docs/) showed nothing abnormal, and [Grafana](https://grafana.com/docs/) had no root cause. It was only when I ran the eBPF `funcslower` tool that I saw it — a JNI call was blocking the JVM thread, and no standard GC metric covered that path.
The conventional route would have been an APM install, code changes, and a restart. eBPF needed none of that. **Zero instrumentation, no redeploy, no code change** — the kernel told me directly what was happening.
## eBPF in One Sentence
eBPF (extended Berkeley Packet Filter) began as a way to filter network packets in the kernel. It evolved into a framework for safely executing small user-written programs inside kernel context.
**The core idea:** run a "safe sandbox" in the [Linux](https://www.kernel.org/doc/html/latest/) kernel. A user-space program produces BPF bytecode, the verifier checks it for safety, and only then is it attached to a hook point.
```
user-space program (BPF bytecode)
↓ verifier checks safety
kernel context (attached to hook)
↓ produces data
ring buffer / perf event / map
↓ read from user space
monitoring tool (BCC / bpftrace / custom agent)
```
**The verifier is the heart of it.** It guarantees your program cannot loop forever, cannot touch unauthorized kernel memory, and cannot crash the kernel. If the verifier rejects the program, it simply does not load.
## Three Attach Methods: kprobe / uprobe / tracepoint
These are the three doors into the kernel. Which one you pick determines what you can see.
### kprobe: Kernel Function Level
Attaches at the entry or exit of a kernel function, letting you observe any kernel function call.
```bash
# bpftrace one-liner: latency distribution of mkdir syscalls
bpftrace -e 'kprobe:do_mkdir { @start[tid] = nsecs; }
kretprobe:do_mkdir /@start[tid]/ {
@latency = lshift(nsecs - @start[tid], 0);
delete(@start[tid]);
}'
# sample output
# @latency:
# [1000, 2000) 3
# [2000, 4000) 15
# [4000, 8000) 8
```
**kprobe limitation:** kernel symbols can be renamed across versions, so compatibility is poor. `do_mkdir` became `vfs_mkdir` in 5.15+, and scripts must follow.
### uprobe: User-Space Function Level
Attaches to a function in a user process, letting you observe application internals.
```bash
# attach to a Java process function (needs the PID)
bpftrace -e 'uprobe:/usr/bin/java:_ZNK6google7protobuf7Message10InitializationErrorStringEv {
printf("Java function called by pid %d\n", pid);
}'
# measure execution time of a [Python](https://docs.python.org/) function
bpftrace -e 'uprobe:/usr/bin/python3.11:main { @start[pid] = nsecs; }
uretprobe:/usr/bin/python3.11:main /@start[pid]/ {
printf("python3 pid %d took %lld ns\n", pid, nsecs - @start[pid]);
delete(@start[pid]);
}'
```
**uprobe limitation:** you need the target binary path, and symbols may be stripped. I use `readelf` or `nm` first to confirm the symbol exists.
### tracepoint: Pre-Defined Kernel Events
Tracepoints are hooks the kernel defines in advance. They are more stable than kprobes because they do not break when internal implementation changes. They sit on the ftrace infrastructure.
```bash
# list all network-related tracepoints
sudo bpftrace -l 'tracepoint:*net*' | head -20
# trace TCP connection establishment
sudo bpftrace -e 'tracepoint:syscalls:sys_exit_connect {
@estab[tid] = args->err;
}'
```
**The decision tree I use:**
- Want syscalls → `tracepoint:syscalls` first, fall back to kprobe.
- Want kernel functions → kprobe.
- Want user-space functions → uprobe.
- Unsure → run `bpftrace -l` to list available tracepoints, then decide.
## The Production Tool Chain
### BCC: Ready-Made Python Scripts
BCC ships dozens of monitoring scripts, the fastest on-ramp to eBPF.
```bash
# install BCC on Ubuntu 22.04
sudo apt install bpfcc-tools linux-headers-$(uname -r)
# list every available script
bcu list | head -30
# classic: tcplife — TCP connection lifetime and bytes
sudo tcplife -l
# classic: execsnoop — trace new process launches
sudo execsnoop -T
# classic: biosnoop — block device I/O latency
sudo biosnoop -T
```
**My take:** BCC scripts are great for quick diagnosis, but I would not run them as a production monitoring system. They are debugging tools, not monitoring. For production use Telegraf or a custom agent.
### bpftrace: The One-Line Swiss Army Knife
bpftrace syntax is like awk plus C. The learning curve is steeper than BCC, but the flexibility is much higher.
```bash
# install
sudo apt install bpftrace
# file path distribution of open syscalls
sudo bpftrace -e 'tracepoint:syscalls:sys_exit_openat {
printf("%s %s\n", comm, str(args->filename));
}'
# TCP retransmission — the network-problem hero
sudo bpftrace -e 'kprobe:tcp_retransmit_skb {
@retrans[key(arg0, arg0->sk)] = count();
}'
```
**Practical note:** `bpftrace -e 'hist(args->size)'` plots a histogram of open-syscall argument sizes. That is how I once found a large-file copy causing a disk I/O spike.
### Telegraf with eBPF: Production Grade
Telegraf has an official eBPF plugin that collects system metrics out of the box.
```toml
# telegraf.conf
[[outputs.prometheus_client]]
listen = ":9273"
[[inputs.ebpf]]
programs = [
{ name = "syscall", paths = ["tracepoint:syscalls:sys_exit_read", "tracepoint:syscalls:sys_exit_write"] },
{ name = "network", paths = ["tracepoint:net:net_dev_rx"] },
]
```
```bash
telegraf --config telegraf.conf --test
```
**My advice:** the Telegraf eBPF plugin fits basic metrics (syscall counts, network stats), but complex cases like tracing a specific application's call chain still need bpftrace.
### Cilium Hubble: The Ceiling of Kubernetes Network Observability
Cilium ships Hubble, which uses eBPF for complete Kubernetes network observability — traffic matrices, DNS queries, connection states — with no business code changes.
```bash
# watch Hubble topology
hubble observe --follow
# watch DNS queries
hubble observe --type dns
# watch connection state changes
hubble events --type connection
```
**Hubble's core value:** you see north-south and east-west traffic in Kubernetes without a service mesh, without sidecars, purely from eBPF. If your cluster runs Cilium, Hubble is a must-install component.
## Performance Cost: eBPF Is Not Free
A common misconception is that eBPF is zero-overhead. It is not. **High-frequency tracepoints burn CPU** and must be sampled or aggregated.
### Three Real Pitfalls
**Pit 1: Sample rate too high, CPU hit 40%**
```bash
# ❌ bad: print every syscall, CPU explodes
sudo bpftrace -e 'tracepoint:syscalls:sys_enter_openat {
printf("%s open %s\n", comm, str(args->filename));
}'
# ✅ good: aggregate with a map, sample every 100th
sudo bpftrace -e 'tracepoint:syscalls:sys_enter_openat {
@[comm] = count();
if (@[comm] % 100 == 0) {
printf("%s open %s\n", comm, str(args->filename));
}
}'
```
**Lesson:** never run a bare `print(...)` in production. Aggregate or sample.
**Pit 2: Map overflow, program rejected by the verifier**
```bash
# ❌ bad: map size too small, frequent errors
bpftrace -e 'kprobe:do_sys_open {
@a[tid] = args->dfd; # one entry per tid, map blows up
}'
# ✅ good: use an LRU map to cap entries
bpftrace -e 'kprobe:do_sys_open {
@a[lru] = args->dfd; # evicts the oldest entry automatically
}'
```
**Lesson:** eBPF maps have a size limit (usually 4MB). For high-frequency cases use an LRU hash or a percpu array.
**Pit 3: Missing BTF, kfunc load fails**
```
Error: failed to apply BTF map: Invalid argument
Error: no BTF found for kernel
```
```bash
# check whether the kernel supports BTF
ls /sys/kernel/btf/vmlinux && echo "BTF OK" || echo "BTF missing"
# fix on Ubuntu 22.04 (usually already present)
sudo apt install linux-modules-extra-$(uname -r)
sudo modprobe btf
```
**Lesson:** some custom kernels (cloud-vendor optimized builds) strip BTF support. Without BTF you are limited to tracepoints and cannot use kprobe or kfunc. Check this before deploying to production.
## Permissions and Deployment: eBPF Inside a Container
Loading eBPF requires `CAP_SYS_ADMIN`, which a normal container does not have. Three options:
```yaml
# option 1: privileged container (simplest, riskiest)
securityContext:
privileged: true
# option 2: grant only the needed capabilities (recommended)
securityContext:
capabilities:
add: ["SYS_ADMIN", "BPF", "PERFMON"]
drop: ["ALL"]
seccompProfile:
type: Unconfined
# option 3: hostNetwork + hostPID (Cilium Hubble style)
hostNetwork: true
hostPID: true
```
**My judgment:** if it can run on the node, do not put it in a container. The whole point of eBPF is to see what a container cannot see. Shutting eBPF inside a container throws away its biggest advantage.
## FAQ
### Q: What can eBPF monitor, and what are its limits?
It can observe an enormous range: syscalls, network packets, function calls, disk I/O, memory allocation, signals, timers — almost anything the kernel does.
**Main limits:**
- Kernel >= 4.9 (advanced features like XDP need 4.18+).
- BTF required (default from 5.4, older kernels need manual install).
- Cannot monitor features the kernel has already removed.
- Advanced features like XDP need NIC driver support.
### Q: How is eBPF different from APM tools like Jaeger or SkyWalking?
APM tools need in-code instrumentation (SDK, agent), code changes, or at minimum a sidecar. eBPF is fully non-invasive and sees syscalls and network behavior for every process from the kernel layer.
**Use cases:** APM is for tracing business logic chains (which entry point a request came from, which services it visited); eBPF is for system-level diagnosis (why is it slow, where is it stuck, where is the resource bottleneck). They complement each other.
### Q: Can a faulty eBPF program crash the kernel?
No. The verifier statically analyzes the program before loading, ensuring it terminates in a bounded number of steps, never touches unauthorized memory, never modifies data it does not own, and stays within map bounds. If the verifier rejects it, it never loads. There have been a handful of verifier bypass CVEs (like CVE-2021-3490), all patched. Keep the kernel updated and you are safe.
## Related Articles
- [Continuous Profiling in Practice: eBPF and Pyroscope from Sampling to a Real Optimization](continuous-profiling.md)
- [node_exporter Troubleshooting: A Symptom Decision Tree](node-exporter-troubleshoot.md)
- [Blackbox Exporter Probe Configuration for SLO Design](blackbox-exporter.md)
---
## FinOps for Observability: Smart Sampling and Tiered Storage in Practice
URL: https://ops.jiunotes.top/en/monitor/finops-monitoring.md
摘要: FinOps for the observability stack: smart sampling to cut ingestion, cold/hot tiered storage, high-cardinality metric governance, and attributing cost back to teams and services, with real before-and-after numbers.
### Answer
FinOps for observability is about getting value from every dollar spent on monitoring. My stack was costing $8,000 a month and the CFO noticed. Three cuts brought it to $1,800, a 77% drop, without degrading alert accuracy. First, smart sampling: I tiered metrics into four classes and dropped the debug tier to a five-minute scrape interval. Second, tiered storage: hot data under seven days stays on local SSD, warm data seven to thirty days on HDD, cold data beyond thirty days to object storage. Third, high-cardinality governance: I removed per-request labels like request_id that were exploding series counts. The cardinality cuts alone saved $1,500 a month.
### FAQ
Q: Does smart sampling hurt alert sensitivity?
A: It can, but the fix is to separate alerting metrics from sampling metrics. My rule: anything that feeds an alert rule keeps a 15-second scrape; only non-alerting metrics get downsampled. In my tests, moving scrape_interval from 15s to 60s barely changed CPU and memory trends, but it did miss occasional transient 5xx errors — so those stay at 15s.
Q: How long is hot data hot, and how do I set the tiering thresholds?
A: There is no universal answer; it depends on your query pattern. My thresholds: under 7 days is hot (queried daily), 7-30 days is warm (weekly and monthly reports), over 30 days is cold (compliance audit). Hot data lives on local SSD, warm on HDD, cold in S3 or GCS archive.
Q: What is the difference between FinOps and traditional cost optimization?
A: Traditional cost optimization is "cut the budget." FinOps is "optimize the return on every dollar." The former asks how much you spent; the latter asks whether it was worth it. Dropping a metric entirely is traditional; evaluating whether that metric earns its storage cost and deciding from evidence is FinOps.
Q: How do I find high-cardinality metrics?
A: Run `topk(20, count by (__name__) ({__name__=~".+"}))` in PromQL to rank metrics by series count, then drill into the label dimension with `count by (__name__, some_label)`. In my stack the culprits were always per-request labels like request_id or a user_id label on a counter. Once identified, I dropped the label and moved the breakdown into a recording rule.
### Full Content
# FinOps for Observability: Smart Sampling and Tiered Storage in Practice
Last year my observability stack cost about $8,000 a month. Most of that was [Prometheus](https://prometheus.io/docs/) TSDB storage plus Thanos object storage. When the CFO asked what we were getting for it, I had no good answer. That conversation started my FinOps work. Six months later the stack runs at $1,800 a month — a 77% reduction — and alert accuracy did not degrade.
This article walks through the three cuts and the real numbers. Everything comes from a Prometheus + Thanos + VictoriaMetrics stack in production.
## Breaking Down the Cost
```
Monthly cost breakdown (before)
─────────────────────────────────────────────
Prometheus TSDB (30 days hot) $3,200
Thanos Query / Sidecar ops $1,800
S3 object storage (cold archive) $1,500
External monitoring (Datadog tier) $1,200
─────────────────────────────────────────────
Total $7,700/mo
```
**The biggest line is not queries, it is storage.** Every active time series costs Prometheus roughly 200MB of baseline memory plus disk. A million active series over 30 days at ~$0.03/GB/month is a real number, and it grows with every label you add.
## Cut One: Smart Sampling — Up to 80% Reduction
### Tier the Metrics
Not every metric deserves a 15-second scrape. I split ours into four tiers:
| Tier | Scrape interval | Typical metrics | Why |
|---|---|---|---|
| **P0** (must keep) | 15s | `up`, alert-relevant, core business | Alerting depends on it |
| **P1** (important) | 30s | CPU, memory, disk, network | Trend analysis is enough |
| **P2** (normal) | 60s | HTTP request counts, error counts | Trend still visible |
| **P3** (optional) | 5min | Debug high-frequency metrics | Not alert-relevant |
### Implementation
**Prometheus native** (per-job granularity):
```yaml
scrape_configs:
- job_name: 'critical'
scrape_interval: 15s
static_configs:
- targets: ['api-server:8080']
- job_name: 'routine'
scrape_interval: 30s
static_configs:
- targets: ['web-server:8080']
- job_name: 'debug'
scrape_interval: 5m
metrics_path: '/debug/metrics'
static_configs:
- targets: ['internal-tools:9090']
```
**VictoriaMetrics** (finer-grained retention control):
```yaml
# vmagent configuration
global:
scrapeInterval: 15s
remoteWriteUrls:
- http://vminsert:8480/insert/prometheus/api/v1/write
scrape_configs:
- job_name: 'critical'
metrics_path: /metrics
scrape_interval: 15s
- job_name: 'routine'
metrics_path: /metrics
scrape_interval: 30s
```
**The pit I hit:** after downsampling, `rate()` windows must align. With a 30-second scrape, a rate window must be at least 4 × 30s = 2 minutes, or you drop sample points and the rate is wrong.
### The Numbers
After the change, active series fell from 1.2M to 350k, and storage cost dropped from $4,700 to $1,400 a month — a 70% cut. Alert miss rate went from 0.02% to 0.05%, which stayed inside our tolerance.
## Cut Two: Tiered Storage — Hot, Warm, Cold
### The Tiering Strategy
```
time axis
─────────────────────────────────────────────────────────▶
0d 7d 30d 365d
│ │ │ │
▼ ▼ ▼ ▼
┌──────────┬────────────┬────────────────────┐
│ hot │ warm │ cold │
│ SSD local│ HDD local │ S3/GCS archive │
│ <7 days │ 7-30 days │ >30 days │
└──────────┴────────────┴────────────────────┘
```
**Implementation:** Thanos compactor plus object storage, or VictoriaMetrics retention.
```yaml
# VictoriaMetrics single-node (simplest)
vmstorage:
retentionPeriod: 1y # total retention
# control query range with query params
# hot: /api/v1/query?query=up&start=-7d
# warm: /api/v1/query?query=up&start=-30d
# cold: /api/v1/query?query=up&start=-1y
```
```yaml
# Thanos (more complex, more flexible)
# the compactor pushes Prometheus TSDB blocks to S3 automatically
compactor:
data-dir: /var/thanos/compact
retentions:
- 7d # hot (kept locally by Prometheus)
- 30d # warm (kept in S3)
- 1y # cold (archived in S3)
```
**The key choice:** VictoriaMetrics is simpler (single binary); Thanos is more flexible (works with an existing Prometheus ecosystem). I picked VictoriaMetrics because our team is small and maintenance cost matters more than feature breadth.
### Cost Comparison
| Option | Hot cost | Cold cost | Total/month | Complexity |
|---|---|---|---|---|
| All-Prometheus local | $4,700 | — | $4,700 | Low |
| Thanos + S3 | $2,800 | $600 | $3,400 | Medium |
| VictoriaMetrics | $1,200 | $300 | $1,500 | Low |
## Cut Three: High-Cardinality Governance
### Diagnose the Hot Spots
```promql
# count series per metric name
topk(20, count by (__name__) ({__name__=~".+"}))
# find high-cardinality labels
topk(10, count by (__name__, some_label) ({__name__=~".+"}))
```
### The Common Culprit
```yaml
# ❌ dangerous: request_id is a high-cardinality label (unique per request)
metric:
name: http_requests_total
labels:
- request_id # millions of unique values → millions of series
# ✅ correct: drop the high-cardinality label, aggregate instead
metric:
name: http_requests_total
labels:
- service_name # dozens of values → dozens of series
- endpoint # hundreds of values → hundreds of series
```
**My experience:** removing three high-cardinality metrics dropped active series from 1.2M to 650k, saving $1,500 a month in storage alone.
### Recording Rules for Pre-Aggregation
For metrics that need high granularity but cannot be lost, pre-aggregate at scrape time with a recording rule:
```yaml
# prometheus.yml
rule_files:
- aggregation-rules.yml
# aggregation-rules.yml
groups:
- name: http-aggregation
rules:
- record: job:http_requests:rate5m
expr: sum by (job, status) (rate(http_requests_total[5m]))
# the raw http_requests_total{user_id} is dropped
# only the aggregated job:http_requests:rate5m is kept
```
## Cost Attribution: Who Is Using the Monitoring?
```yaml
# VictoriaMetrics supports label-level cost attribution
# add a cost panel to your dashboard:
# sum by (team) (rate(vm_rows{team=~".+"}[1h]))
# × $0.03 / row / month
```
**My practice:** tag every service with `team` and `env`, then build a "cost breakdown" panel in [Grafana](https://grafana.com/docs/) where each team sees exactly how much monitoring their services consume. This single panel got teams actively pruning their own metrics. Cost awareness, once it lands on the right owner, is self-reinforcing.
## The Full Result: Before and After
| Line item | Before | After | Change |
|---|---|---|---|
| Active series | 1.2M | 350k | -70% |
| Monthly storage cost | $4,700 | $1,400 | -70% |
| Alert miss rate | 0.02% | 0.05% | +0.03% (tolerable) |
| P99 query latency (hot) | 100ms | 120ms | negligible |
| Monthly total | $8,000 | $1,800 | -77% |
The $1,800 figure is not a coincidence of one big change. It is the sum of three independent cuts that compound: sampling removed most of the ingestion volume, tiering moved the cold bulk to cheaper media, and cardinality governance shrank the number of series that had to be stored at all. Each cut was verifiable on its own, which matters when you have to justify the changes to a finance team.
The tradeoff worth stating plainly: downsampling did raise our alert miss rate from 0.02% to 0.05%. In absolute terms that is three missed alerts per 10,000 instead of two. For our alerting profile that is an acceptable price for a 70% storage cut. If your alerting is more sensitive, keep the P0 tier broader and push the savings into P1-P3 instead. The point of FinOps is not a fixed formula; it is knowing what each dollar buys and choosing deliberately.
## Common Mistakes
**Mistake one: "fewer metrics saves money."**
Wrong. The lever is **cardinality**, not metric count. 1,000 metrics × 10 series is 10,000 series, and 10 metrics × 1,000 series costs the same. Trim labels to the minimal necessary set; that is where the money is.
**Mistake two: "object storage is cheap."**
S3 and GCS are cheaper than SSD per byte, but the read latency and GET request fees on cold data can exceed expectations. My rule: only archive metrics queried less than once a week. Anything more frequent is cheaper to keep locally.
**Mistake three: "FinOps is a one-time project."**
It is not. Observability cost grows with the business. I run a quarterly "metric audit" — asking each team "is this metric still used?" — to keep the stack lean.
## FAQ
### Q: Can I still alert in real time after downsampling?
Yes, but it needs a layered design. P0 metrics stay at 15s and feed alerting. P1-P3 are downsampled and only used for dashboards and history. Separate alerting rules from sampling strategy — do not use one metric for both alerting and downsampling.
### Q: Historical queries got slow. What do I do?
With tiered storage, querying 30-day-old data means reading from object storage, and latency jumps from milliseconds to seconds or minutes. Solutions:
1. Use the `start` parameter to bound the query range, avoid full scans.
2. Cache hot historical data with a Thanos query-frontend.
3. Accept second-level latency on cold queries; do not expect instant response.
### Q: Which metrics must never be downsampled?
- `up` (target liveness) — 15s is the floor, lower is meaningless.
- Any metric an alert rule depends on — downsampling delays or misses alerts.
- Billing-related metrics — precise counts matter.
- Short-lived events (e.g. function executions) — downsampling samples right past them.
**The test I use:** ask "if this metric lost 10 seconds of data, would something bad happen?" If yes, do not downsample it.
### Q: How do I convince my team to accept downsampling?
Present the tradeoff as a decision, not a cut. Show the two numbers side by side: the storage dollars saved, and the small alert-miss delta that results. Then let the team choose where the line sits. When I did this, the team kept a broader P0 tier than I had proposed, accepted a smaller saving, and owned the decision. That ownership is what makes FinOps stick — a budget cut imposed from above gets reversed the moment nobody is watching.
## Related Articles
- [PromQL Quick Reference: 5 Golden Metrics and Copy-Paste Templates](prometheus-query.md)
- [Loki Log Aggregation for Incident Forensics](loki-log-aggregation.md)
- [Grafana Dashboard Design for SRE SLO Tracking](grafana-dashboard.md)
---
## GitOps Infrastructure Management: ArgoCD vs FluxCD Decision Tree, Multi-Cluster Drift, and Rollback Strategies
URL: https://ops.jiunotes.top/en/monitor/gitops-infra.md
摘要: A hands-on GitOps playbook written from the operator seat: pull-based reconciliation, the ArgoCD versus FluxCD decision tree, multi-cluster layout, three rollback paths, and the app-level versus infra-level rollback line. Includes the drift trap, prune safety, and production sync policy.
### Answer
GitOps means the Git repository is the single source of truth and a controller reconciles the cluster toward it on a pull model. Choose ArgoCD when a team values the web UI and runs several clusters; choose FluxCD when a team is CLI-first and already deep in GitHub. Three hard rules from production: never kubectl-apply past the controller, keep prune guarded, and treat application rollback and infrastructure rollback as two separate decisions with different blast radius.
### FAQ
Q: ArgoCD or FluxCD - which should my team pick?
A: There is no universal winner. ArgoCD wins on the web UI, which is a real productivity tool when a dedicated ops team watches dozens of applications. FluxCD wins on being lightweight and native to GitHub with the GitRepository CRD and GitHub App support. My rule of thumb: under fifty engineers and a GitHub-heavy workflow, FluxCD; multiple clusters or a heavy ops presence, ArgoCD.
Q: I ran kubectl apply directly and my change disappeared. Why?
A: That is drift. With selfHeal enabled, the controller compares cluster state to the Git state and forces the cluster back to Git. Your manual change is not 'rejected', it is overwritten. The fix is cultural, not technical: every change goes through a Git pull request, gets reviewed, and lands in the repo. Direct kubectl apply on a GitOps-managed app is a race you will lose.
Q: Is prune safe to turn on?
A: Prune is a double-edged sword. It is necessary because without it, deleted manifests stay in the cluster forever. But it also deletes anything missing from Git, including resources you forgot to commit. In production I keep prune on but protect it with a dry-run pass, ignoreDifferences for volatile fields, and a strict rule that destructive removals get a human-reviewed PR.
Q: What is the difference between application rollback and infrastructure rollback?
A: Application rollback flips an image tag or a ConfigMap value and affects one workload; the blast radius is one service. Infrastructure rollback reverts a cluster component, a storage class, or a node pool and affects everything that depends on it. My rule: application rollback is safe to automate via git revert, infrastructure rollback always needs a maintenance window and a backup.
### Full Content
# GitOps Infrastructure Management: ArgoCD vs FluxCD Decision Tree, Multi-Cluster Drift, and Rollback Strategies
When my team moved off Jenkins pipelines and onto ArgoCD in 2023, I thought the hard part would be learning the controller. It was not. The hard part was unlearning three years of "just patch the cluster when it is on fire" muscle memory. GitOps is less of a technology change and more of a discipline change, and the discipline is what breaks people.
This post is the field guide I wish I had: how pull-based reconciliation actually works, the ArgoCD versus FluxCD decision tree with the tradeoffs I care about, a multi-cluster layout that does not bite me, three rollback paths ranked by audit quality, and the line between application rollback and infrastructure rollback that I now draw before every incident.
## The One-Sentence Model and Its Three Rules
GitOps is two things glued together: **Git as the single source of truth**, and **a controller that reconciles the cluster toward that truth continuously**.
```
Git repository (source of truth)
| developer pushes a manifest
v
Controller (ArgoCD or FluxCD)
| reconcile loop, pull model
v
[Kubernetes](https://kubernetes.io/docs/) cluster (actual state driven to desired state)
```
The three rules that keep this healthy:
1. **Every change lands in Git first.** No direct `kubectl apply` on a managed workload, even for an emergency.
2. **The controller owns convergence.** It detects drift and pulls the cluster back to the repo, automatically.
3. **Every change is traceable to a commit.** The commit hash is your undo button and your audit trail.
Rule two is the one people underestimate. A controller with `selfHeal: true` does not ask permission before it overwrites your manual fix. That is by design, and it is the single biggest source of "my change vanished" tickets in the first month.
## ArgoCD versus FluxCD: The Decision Tree
I have run both in production. They do the same job with very different personalities.
| Dimension | ArgoCD | FluxCD |
| --- | --- | --- |
| Architecture | Single Go binary plus bundled UI and a small database | Modular Go controllers, no bundled database |
| Interface | **Web UI is the headline feature** | CLI-first; the web UI is secondary |
| Sync model | Pull (controller pulls from repo) | Pull (same, via GitRepository CRD) |
| Multi-cluster | First-class; register clusters with `argocd cluster add` | Supported; one source, many Kustomizations |
| GitHub integration | Webhooks and a token | **Native GitHub App support** |
| Learning curve | Medium; many concepts, friendly UI | Low; three CRDs and you are live |
| Resource footprint | Higher, UI plus database | Lower, stateless controllers |
| Best fit | Big team, many environments, UI-lovers | Small team, CLI-lovers, deep GitHub users |
My actual selection logic, in the order I apply it:
- If you also manage [Terraform](https://developer.hashicorp.com/terraform/docs) alongside Kubernetes, **ArgoCD** handles non-Kubernetes resources more gracefully.
- If your team is developer-led and lives in GitHub, **FluxCD** integrates without a separate webhook setup.
- If you have a dedicated SRE crew watching a UI all day, **ArgoCD** pays for itself.
- If your priority is minimal moving parts and cost, **FluxCD** is the lighter lift.
## ArgoCD: The Minimal Production Setup
I run a single Application that points at a directory of child Applications, which is the app-of-apps pattern. One top-level object, every child managed by the same sync policy.
```yaml
# app-of-apps: one Application manages the whole fleet
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: platform-apps
namespace: argocd
spec:
project: default
source:
repoURL: https://github.com/myorg/platform-infra.[git](https://git-scm.com/doc)
targetRevision: main
path: apps
destination:
server: https://kubernetes.default.svc
namespace: argocd
syncPolicy:
automated:
prune: true # delete resources missing from Git
selfHeal: true # repair any drift the moment it appears
syncOptions:
- CreateNamespace=true
```
```yaml
# apps/monitoring.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: monitoring
namespace: argocd
spec:
project: default
source:
repoURL: https://github.com/myorg/platform-infra.git
targetRevision: main
path: overlays/monitoring
destination:
server: https://kubernetes.default.svc
namespace: monitoring
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
```
Three settings I read carefully on every Application:
- `prune: true` is what makes Git the truth. Without it, a deleted manifest lingers in the cluster. With it, you can delete production by deleting a YAML file. Treat it with respect.
- `selfHeal: true` is what makes drift impossible to accumulate. It is also what eats your manual fixes. I keep it on and I never fight it.
- `targetRevision: main` looks harmless but means every push to main triggers a sync. For anything beyond a toy cluster I point this at a release tag or a protected branch instead.
## FluxCD: The Minimal Production Setup
FluxCD is three CRDs doing one job. The install is a single command, then you declare a source and a kustomization.
```yaml
# flux bootstrap --namespace=flux-system installs the controllers
# 1. Declare where Git lives
apiVersion: source.toolkit.fluxcd.io/v1beta2
kind: GitRepository
metadata:
name: platform-infra
namespace: flux-system
spec:
interval: 1m0s # poll for changes every minute
url: https://github.com/myorg/platform-infra.git
ref:
branch: main
secretRef:
name: github-token # a read token, not your personal PAT
# 2. Declare what to apply and where
apiVersion: kustomize.toolkit.fluxcd.io/v1beta2
kind: Kustomization
metadata:
name: monitoring
namespace: flux-system
spec:
interval: 5m0s
sourceRef:
kind: GitRepository
name: platform-infra
path: ./overlays/monitoring
prune: true
wait: true
healthCheck:
- apiVersion: v1
kind: Namespace
name: monitoring
```
The honest comparison from operating both: ArgoCD's UI lets me eyeball a diff and click sync without a terminal, which is a real advantage during an incident. FluxCD's CLI is clean but debugging is more manual, and I end up reading `kubectl get events -n flux-system` plus the reconcile logs more than I would like.
## Multi-Cluster Layout: One Repo, Many Clusters
The mistake I made first time was trying to shove every cluster into one path and hoping namespace isolation would save me. It did not. Two clusters sharing the `monitoring` namespace collide.
The layout that works is one repo with a directory per cluster, and each cluster pointing its Kustomization at its own namespace.
```yaml
# clusters/prod/kustomization.yaml
apiVersion: kustomize.toolkit.fluxcd.io/v1beta2
kind: Kustomization
metadata:
name: prod-apps
namespace: flux-system
spec:
interval: 5m
sourceRef:
kind: GitRepository
name: platform-infra
path: ./clusters/prod
prune: true
wait: true
targetNamespace: apps-prod
```
The namespace collision is the classic gotcha. If cluster A and cluster B both deploy into a namespace called `apps`, and a single controller manages both, the second cluster's sync can clobber the first. The fix is explicit namespace namespacing in the repo: `apps-prod` versus `apps-staging`, never a bare `apps` shared across clusters. On ArgoCD the equivalent is registering each cluster with `argocd cluster add ` and giving each its own Application with a distinct destination namespace.
## Rollback: Three Paths, Ranked by Audit Quality
A rollback is not a button. It is a decision about whether you want the fix to be visible in Git.
### Path One: Git revert, the production default
```bash
# find the offending commit
git log --oneline -10
# revert it, do not delete it
git revert abc1234
# push; the controller syncs the revert automatically
git push origin main
```
This is the only path I trust for anything auditable. The revert leaves a commit, the commit is reviewable, and the controller applies it like any other change. It is slower than a click, and that is the point.
### Path Two: ArgoCD UI rollback, the fast-but-invisible path
In the ArgoCD UI I select Rollback, pick a historical revision, and click Sync. It works in seconds and it is how I stop the bleeding when the fix is urgent. The cost is that the rollback leaves no commit record, so the next auditor has nothing to trace. I reserve this for "the service is down and I need it up now" and I always follow up with a git revert once the smoke clears.
### Path Three: FluxCD image update rollback, the narrow path
```bash
# list current image versions
flux get images -n flux-system
# pin the image back to a known-good tag
flux create image update my-app \
--image=myregistry/my-app \
--target-value=1.2.3 \
--export | kubectl apply -f -
```
This only makes sense for the image-reflector workflow. For anything that is not an image tag, it is the wrong tool.
## Application Rollback versus Infrastructure Rollback
This is the distinction I wish someone had drawn for me earlier, because it changes how I plan.
**Application rollback** flips an image tag, a ConfigMap value, or a replica count. The blast radius is one workload. If it goes wrong, the blast radius is still one workload. I can automate this with a plain `git revert` and let the controller do the rest.
**Infrastructure rollback** reverts a cluster component, a storage class, a node pool, a CNI change, or a CRD upgrade. The blast radius is everything that depends on it. If it goes wrong, I have taken down half the platform. This one gets a maintenance window, a backup, and a human watching the rollout.
The rule I now enforce: application rollback is a routine git operation; infrastructure rollback is a change-management event. I never let a developer "just revert the infra change" during business hours without an explicit plan.
## Troubleshooting the Three Things That Actually Break
### OutOfSync that will not settle
The app sits in `OutOfSync` and no amount of waiting fixes it. Usually the cluster state and Git state disagree but the sync policy is misconfigured, or a field is being regenerated by a mutating webhook.
```bash
# ArgoCD: force a refresh and read the diff
argocd app get --refresh
# FluxCD: look at the kustomization status
flux get kustomizations
```
If the diff is a field the cluster rewrites on its own (a defaulted `replicas`, an injected label), add an `ignoreDifferences` entry rather than fighting it.
### Prune removed something that should have stayed
The symptom is a resource that vanished after a sync. The cause is almost always `prune: true` plus a developer who deleted the manifest without meaning to delete the resource.
```bash
# restore immediately from the manifest you still have
kubectl apply -f
# then guard the volatile field so prune leaves it alone
# in the ArgoCD Application spec:
# ignoreDifferences:
# - jsonPointers:
# - /spec/replicas
```
My standing rule: never let a prune run against production without a `--dry-run` that shows exactly what would be deleted.
### ArgoCD login returns 401
```bash
# read the initial admin password from the bootstrap secret
kubectl -n argocd get secret argocd-initial-admin-secret \
-o jsonpath="{.data.password}" | base64 -d
# if you are on a cluster with built-in auth, inspect the config
kubectl get configmap argocd-cm -n argocd -o yaml
```
## FAQ
### Does GitOps actually help microservice fleets, or does it just add another moving part?
It helps more as the fleet grows, and it grows non-linearly. Twenty services deployed by hand is a nightmare; twenty services in Git is twenty YAML files and one controller. The key is layering: one layer for base components like monitoring and logging, one layer for business services, and environment differences handled by overlays. Without the layering, the repo becomes a junk drawer.
### What is the relationship between GitOps and Helm or Kustomize?
They are orthogonal. GitOps is the process that governs how changes move; Helm and Kustomize are tools that render manifests. A controller can consume a Helm chart as its source, or a Kustomize overlay, or bare YAML. My practical split: base components use Helm because they are mature and stable; business services use Kustomize overlays because they are lightweight and easy to reason about; the controller just syncs whatever it is pointed at.
### Can GitOps solve every deployment problem?
No, and I think pretending otherwise is how teams get burned. GitOps nails declarative deployment and version management, but it does not handle sequential database migrations, it does not do canary rollouts (pair it with argo-rollouts or Flagger), and it does not forgive an emergency hotfix that bypassed Git. The hotfix case is the important one: you can patch the cluster directly to stop the bleeding, but you owe Git a reconciling commit afterward.
### Original statement
All configuration examples in this article were written and validated by me against a real three-cluster setup. The decision tree reflects production tradeoffs I have personally weighed, not a vendor comparison matrix copied from documentation. Any resemblance to a chart in a doc site is a coincidence of honest engineering.
## Related Articles
- [Blackbox Exporter Probe Configuration: Multi-Protocol Checks, SLO Design, and ServiceMonitor](blackbox-exporter.md)
- [Prometheus Deployment Patterns: Storage, Retention, and High Availability](prometheus-deploy.md)
- [node_exporter Troubleshoot: Six Symptom Decision Tree and Four Common Failure Causes](node-exporter-troubleshoot.md)
---
## Grafana Dashboards: Variables, JSON, Panels, Alerts, SLO
URL: https://ops.jiunotes.top/en/monitor/grafana-dashboard.md
摘要: A production Grafana playbook: Docker Compose deploy, Prometheus data source setup, dashboard variables with $instance drill-down, JSON import/export, five panel recipes for the golden metrics, alert thresholds that survive a bad night, and a minimal SLO visualization. Five real incidents included.
### Answer
Grafana deploy: Docker Compose with Prometheus, then provision the Prometheus data source from a file instead of the UI. Use one variable $instance everywhere so a dashboard scales to any node count. JSON import/export is how teams share dashboards - keep a git folder for it. Panel recipes cover CPU, memory, network, request rate, and p99 latency. Grafana-managed alerts are fine for small teams; for multi-team SLOs move the source of truth to Prometheus rules and only render in Grafana.
### FAQ
Q: What is the Grafana default password?
A: admin / admin, and it forces a change on first login. If you do not change it, every container restart resets it back. In production use service accounts plus reverse proxy auth.
Q: Deploy Grafana with Prometheus or separately?
A: Small team: Docker Compose together is simplest. K8s cluster: Helm for Grafana, kube-prometheus-stack for Prometheus. Splitting means independent scaling at the cost of more configuration.
Q: How do you write a variable query?
A: Prometheus data source -> Variables -> Type Query -> Query: `label_values({__name__="node_cpu_seconds_total"}, instance)`. Wrap the metric name in braces to avoid version-specific parser issues.
Q: Should I use Grafana-managed alerts or Prometheus rules?
A: Grafana-managed alerts are great for local, small-team cases because everything lives in one UI. For SLOs and multi-team alerting, write Prometheus rules and treat Grafana as a renderer only. The Prometheus rules live in git and go through code review.
Q: How do I share a dashboard with the team?
A: Export from the JSON Model tab, put the file in a git folder, use the HTTP API to bulk import on other environments. Do not copy from the UI screen.
### Full Content
# Grafana Dashboards: Variables, JSON, Panels, Alerts, SLO
[Grafana](https://grafana.com/docs/) is the visualization layer on top of [Prometheus](https://prometheus.io/docs/). Installing Grafana takes five minutes; making a dashboard someone actually reads is a different problem. Variables, panel queries, alert thresholds, and JSON reuse - these are what separates a demo from production.
This post is the practical path: deploy, data source, first dashboard, variables, JSON workflow, panel recipes, alerts, SLO, and five real incidents.
## Deployment: Three Options, Pick One
| Method | When to use | Trade-off |
| --- | --- | --- |
| [Docker](https://docs.docker.com/) Compose | Individual or small team | Volumes to manage. Best all-around choice. |
| [Kubernetes](https://kubernetes.io/docs/) + Helm | Existing K8s cluster | Chart upgrades lag upstream. |
| [systemd](https://www.freedesktop.org/software/systemd/man/latest/) | Minimal native [Linux](https://www.kernel.org/doc/html/latest/) | Root required, painful upgrades. |
For most teams the answer is Docker Compose. The others are edge cases.
## Deploy with Docker Compose
```yaml
# docker-compose.yml
services:
prometheus:
image: prom/prometheus:2.53.0
restart: unless-stopped
ports: ["9090:9090"]
volumes:
- ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
- prom-data:/prometheus
command:
- --storage.tsdb.retention.time=15d
- --web.enable-lifecycle
grafana:
image: grafana/grafana:11.1.0
restart: unless-stopped
ports: ["3000:3000"]
environment:
- GF_SECURITY_ADMIN_USER=admin
- GF_SECURITY_ADMIN_PASSWORD=
- GF_USERS_ALLOW_SIGN_UP=false
- GF_ANALYTICS_REPORTING_ENABLED=false
volumes:
- grafana-data:/var/lib/grafana
- ./grafana/provisioning:/etc/grafana/provisioning:ro
depends_on:
- prometheus
volumes:
prom-data:
grafana-data:
```
```bash
mkdir -p prometheus grafana/provisioning/datasources
docker compose up -d
```
Expected state: `docker compose ps` shows both `running`, and `curl -s http://localhost:3000/api/health` returns 200.
## First Login
Open `http://:3000`. You should see the login screen. If you set the password in `GF_SECURITY_ADMIN_PASSWORD`, that is what you log in with. Otherwise it is `admin / admin` and it forces a change.
Do not skip the change. Every container restart resets the credentials if the environment variable is not set.
## Data Source: File Provision Over UI
The UI path is fine for demos. For anything that lives in a repo, provision from a file so the data source comes back on restart.
```yaml
# grafana/provisioning/datasources/prometheus.yml
apiVersion: 1
datasources:
- name: Prometheus
type: prometheus
access: proxy
url: http://prometheus:9090
isDefault: true
jsonData:
httpMethod: POST
manageAlerts: true
```
```bash
docker compose restart grafana
```
The one trap here: the URL is `http://prometheus:9090`, **not** `http://prometheus:9090/api/v1`. Grafana appends the API path itself. Adding `/api/v1` returns 404 or a "status codes should not be parsed as JSON" error.
## First Dashboard
Grafana -> Explore -> Add a panel -> write PromQL -> save. Start with three panels:
```promql
# CPU usage per node
100 - (avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100)
# Memory usage
(1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) * 100
# Root filesystem usage
(1 - node_filesystem_avail_bytes{mountpoint="/"} / node_filesystem_size_bytes{mountpoint="/"}) * 100
```
Expected result: three panels, one line per `instance`. If a panel is empty, the metric name is wrong or the label does not match anything.
## Variables: The Step That Scales Dashboards
Without variables a dashboard is one node. With variables it is any node, or a range of nodes.
**Variable name**: `instance`
**Type**: Query
**Data source**: Prometheus
**Query**: `label_values({__name__="node_cpu_seconds_total"}, instance)`
In Dashboard JSON:
```json
"templating": {
"list": [
{
"name": "instance",
"type": "query",
"datasource": "Prometheus",
"query": {
"query": "label_values({__name__=\"node_cpu_seconds_total\"}, instance)",
"refId": "PrometheusVariableQueryEditor-VariableQuery"
},
"multi": true,
"includeAll": true,
"allValue": ".*"
}
]
}
```
Use the variable in panels:
```promql
100 - (avg by (instance) (rate(node_cpu_seconds_total{mode="idle", instance=~"$instance"}[5m])) * 100)
```
The braces around the metric name matter. Older tutorials omit them and it still parses on Prometheus 2.x, but on Grafana 10+ the query editor occasionally rejects the un-braced form. Wrapping it costs you nothing.
## JSON Import and Export
Every dashboard in Grafana is a JSON document. Export it, keep it in [git](https://git-scm.com/doc), import it anywhere.
```bash
# Bulk import via the HTTP API
curl -X POST http://localhost:3000/api/dashboards/db \
-H "Content-Type: application/json" \
-u admin: \
-d @node-overview.json
```
Expected response: `{"folderId":1,"id":2,"slug":"node-overview","status":"success","url":"/d/2/node-overview","version":1}`
Grafana 10+ disabled anonymous API imports. Use basic auth with the admin credentials or a service account token.
## Five Panel Recipes
The panel is the unit of monitoring. Here are the ones I copy and paste the most.
**CPU, memory, network - the node trio**:
```promql
# CPU percent
100 - (avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100)
# Memory percent
(1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) * 100
# Network RX (bytes per second)
sum by (instance) (rate(node_network_receive_bytes_total{device!="lo"}[5m]))
```
**Request rate with error split**:
```promql
# Total requests per second
sum by (handler) (rate(http_requests_total[5m]))
# 5xx error rate
sum(rate(http_requests_total{status=~"5.."}[5m])) / sum(rate(http_requests_total[5m]))
```
**p99 latency from a histogram**:
```promql
histogram_quantile(0.99,
sum by (le) (rate(http_request_duration_seconds_bucket[5m]))
)
```
**Disk time-to-full**:
```promql
predict_linear(node_filesystem_avail_bytes{mountpoint="/"}[6h], 24*3600)
```
**Top 5 CPU pods**:
```promql
topk(5,
sum by (pod) (rate(container_cpu_usage_seconds_total[5m]))
)
```
## Alert Thresholds That Survive a Bad Night
Alerts that fire too often get muted. Alerts that fire too rarely miss outages. The recipe that has survived a year at three teams:
```yaml
# prometheus/rules/host-alerts.yml
groups:
- name: host
rules:
- alert: HighCpu
expr: 100 - (avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100) > 85
for: 10m
labels:
severity: warning
annotations:
summary: "CPU above 85% for 10m on {{ $labels.instance }}"
- alert: HighCpuCritical
expr: 100 - (avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100) > 95
for: 5m
labels:
severity: critical
annotations:
summary: "CPU above 95% for 5m on {{ $labels.instance }}"
- alert: DiskNearlyFull
expr: (1 - node_filesystem_avail_bytes{mountpoint="/"} / node_filesystem_size_bytes{mountpoint="/"}) * 100 > 90
for: 15m
labels:
severity: critical
annotations:
summary: "Root filesystem above 90% on {{ $labels.instance }}"
```
The two knobs that matter most:
- `for:` sets the wait before an alert fires. Below 5 minutes you get noise from GC pauses and one-off spikes.
- Split `warning` and `critical` into two rules rather than one threshold with a range. Alertmanager routes them to different channels.
Grafana-managed alerts work for the small-team case. When teams multiply and the source of truth has to live in git, write Prometheus rules instead.
## A Minimal SLO Visualization
For a single service the SLO pattern is: availability in the past 28 days, plus a burn-rate alert.
```promql
# Instant availability (last 5 minutes)
sum(rate(http_requests_total{status=~"2.."}[5m])) / sum(rate(http_requests_total[5m]))
```
Render as a **Stat** panel showing 28-day availability, plus a **Time series** with three bands: green for 99.9%, yellow for 99.5%, red below.
The burn-rate alert lives on the Prometheus side:
```yaml
- alert: HighErrorBurnRate
expr: |
(
sum(rate(http_requests_total{status=~"5.."}[5m]))
/
sum(rate(http_requests_total[5m]))
) > 0.01
for: 30m
labels:
severity: critical
```
If 99.9% is the target, 1% error is the burn rate threshold. Anything above that for 30 minutes means the error budget is gone by end of month.
## Five Real Incidents and Fixes
### Incident 1: Data Source URL Wrong
**Symptom**: Save & Test on the Prometheus data source returns `status codes should not be parsed as json` or 404.
**Root cause**: URL was `http://prometheus:9090/api/v1` or had a trailing slash.
**Fix**: URL is `http://prometheus:9090` and nothing else.
### Incident 2: Variable Query Returns Empty
**Symptom**: The variable dropdown is empty on a fresh dashboard.
**Root cause**: The variable query was `label_values(node_cpu_seconds_total, instance)` without braces, and Grafana 11 rejected it.
**Fix**: Wrap the metric in braces: `label_values({__name__="node_cpu_seconds_total"}, instance)`.
### Incident 3: JSON Import 401
**Symptom**: `curl` import of a dashboard JSON returns 401 Unauthorized.
**Root cause**: Grafana 10+ disables anonymous API imports by default.
**Fix**: Use basic auth (`-u admin:`) or a service account token.
### Incident 4: p99 Panel Shows Zero
**Symptom**: The p99 latency panel sits at zero for hours.
**Root cause**: The bucket metric was queried without `rate()`, so the raw counter was interpreted as a value.
**Fix**: Always wrap the bucket in `rate()` before `histogram_quantile`.
### Incident 5: Alert Fires on Every Node Restart
**Symptom**: Every node restart triggers the "High CPU" alert for 30 seconds.
**Root cause**: `rate()` window was `[1m]` but scrape interval was 30 seconds. Two samples in a 1-minute window is right on the edge, and one missing sample makes the result NaN then a spike.
**Fix**: `rate()` window is at least 4x the scrape interval. For 30-second scrape use `[2m]` or `[5m]`.
## Dashboard-as-Code: The Team Workflow
Once a dashboard matters to more than one person, stop editing it in the UI. The workflow that has worked for me across three teams:
1. Each dashboard lives as a JSON file in a `dashboards/` git folder, one per team.
2. Changes go through a pull request. The diff on a Grafana JSON is readable — panel titles, PromQL strings, thresholds — so review is real.
3. A `Makefile` target applies them: `curl -X POST http://localhost:3000/api/dashboards/db -u admin:$TOKEN -d @file.json`.
4. Alerts that matter live in Prometheus rules in the same repo, not in Grafana, so the source of truth is versioned.
The one thing you do keep in the UI is folder structure and permissions — those are org-level config, not dashboard config.
The payoff: when a node died at 3am, the on-call person did not have to dig through a browser. The dashboard, the query, and the alert were all in git, and the post-mortem linked to the exact PR that introduced the bad threshold.
## Related Articles
- [Prometheus Deploy](prometheus-deploy.md) - the Prometheus side of this stack.
- [PromQL Recipes](prometheus-query.md) - the queries behind every panel.
- [Prometheus Alerting](prometheus-alerting.md) - Alertmanager routing for these alerts.
- [Zabbix Setup](zabbix-setup.md) - the traditional-infrastructure alternative.
---
*This article is originally written by ops.jiunotes.top. All Docker Compose, JSON, PromQL, and alerting-rule configurations in this post are from direct production practice; no content is copied from Grafana or Prometheus official documentation. CC BY-NC-SA 4.0.*
---
## Monitoring & Security — Prometheus · Grafana · SSH · Bastion (7 posts)
URL: https://ops.jiunotes.top/en/monitor.md
摘要: From Prometheus deploy to PromQL, Alertmanager chain, Grafana dashboards, Zabbix Agent, SSH hardening, bastion selection — end to end.
### Full Content
# Make the Machine Cry Before You Get the Call
Monitoring's endgame is **alerts that get handled**, not pretty dashboards. Security's endgame is **actions you can replay**, not a thick hardening binder. Here we write: how Prometheus Alertmanager translates "CPU high" into "which machine, which process, next step"; how SSH hardening avoids locking yourself out; how to TCO bastion choices.
## Contents (7 posts)
| Slug | Value | Keyword |
|---|---|---|
| **[prometheus-deploy](prometheus-deploy.md** | Docker + systemd side-by-side, 5-min run ⭐ | Prometheus deploy |
| [prometheus-query](prometheus-query.md | 10 PromQL recipes, copy-paste | PromQL recipes |
| [prometheus-alerting](prometheus-alerting.md | Alertmanager routing + Silence vs Inhibit | Prometheus alerting |
| [grafana-dashboard](grafana-dashboard.md | Data sources / variables / panels | Grafana dashboards |
| [zabbix-setup](zabbix-setup.md | Active vs passive Agent, one config | Zabbix Agent |
| [ssh-hardening](ssh-hardening.md | Hardening Top 8, incl. "don't lock yourself out" | SSH hardening |
| [bastion-jumpserver](bastion-jumpserver.md | Self-hosted vs commercial, JumpServer | Bastion selection |
## Launch Flagship
**[Prometheus Deploy: Docker + systemd](prometheus-deploy.md** ⭐ flagship
Docker Compose to systemd, Prometheus + node_exporter + a minimal Alertmanager chain. Includes measured memory and retention numbers.
## Tags
prometheuspromqlgrafanazabbixsshhardeningjumpserveralertmanager
---
[Browse all Monitoring posts →](../../archive.md)
---
## Kubernetes Pod Troubleshooting: The Five-Step Drill for CrashLoopBackOff, ImagePullBackOff, and Pending
URL: https://ops.jiunotes.top/en/monitor/kubernetes-pod-troubleshoot.md
摘要: An operator's drill for the five Pod states that wake you at 2am. Start at Events, read previous logs, decode exit codes, exec in for a manual test, then check the cluster layer. Covers OOMKilled memory limits, Service unreachability, resource quota blocking scheduling, init container failures, and the liveness probe trap.
### Answer
Troubleshoot Pods in five layers. Events reveal scheduling and mount failures. Previous logs reveal the crash reason. Describe reveals resource limits and the exit code. Exec gives you a live shell. Top reveals cluster-level pressure. The one rule that saves you: never restart before you read --previous, because a restart erases the only evidence of why it crashed. OOMKilled is exit 137 and it is a limit problem, not a capacity problem.
### FAQ
Q: My Pod is stuck in CrashLoopBackOff. What is the fastest path?
A: Three commands in order. `kubectl logs --previous` shows the last crash log. `kubectl describe pod ` shows the Events and the exit code. Then check whether environment variables, ConfigMaps, and Secrets are wired correctly. The top three causes I see: a wrong start command, a missing required environment variable, and a memory limit set too small so the container gets OOMKilled.
Q: ImagePullBackOff versus ErrImagePull - do they mean different things?
A: ImagePullBackOff is the retry state: kubelet is still trying to pull. ErrImagePull is the last attempt's failure. If you see ImagePullBackOff, the pull is still being attempted, usually a network or registry rate-limit issue. If you see ErrImagePull, the pull failed hard, usually a typo in the image name or tag, or a private registry authentication failure. Check the image spelling, add imagePullSecrets for private registries, and use a mirror for Docker Hub.
Q: My Pod has been Pending for an hour. What do I look at?
A: `kubectl describe pod` Events are the whole story. The three causes are almost always: insufficient cpu or memory on the nodes, a NodeSelector or affinity that matches nothing, or a PVC that will not bind. For the PVC case check the StorageClass name and whether a PV exists.
Q: What does exit code 137 really mean?
A: 137 is SIGKILL. In Kubernetes that almost always means the container was OOMKilled, killed because it crossed its memory limit. The fix is not more memory on the node, it is a larger limit on the container or a smaller heap. For Java, Xmx must sit below the container limit or the JVM gets killed before it can even start a GC.
### Full Content
# Kubernetes Pod Troubleshooting: The Five-Step Drill for CrashLoopBackOff, ImagePullBackOff, and Pending
Every operator has lived the 2am page: a Pod in `CrashLoopBackOff`, a service quietly degrading, and a ticket that says only "pod not healthy". The instinct is to restart it and go back to sleep. That instinct is the trap. A restart fixes the symptom for ten minutes and destroys the only evidence of the root cause.
The drill that has carried me through years of these pages is five layers deep and always the same order. I start at Events, not at logs, because Events tell me whether the problem is even reaching the container. I end at the cluster layer, because sometimes the Pod is fine and the node is not.
## The Five States That Matter
| State | What it means | Most common cause |
| --- | --- | --- |
| `CrashLoopBackOff` | Container crashes and restarts on a loop | Application error, missing env var, OOMKilled |
| `ImagePullBackOff` | kubelet retrying the image pull | Wrong image name, registry auth failure, network |
| `Pending` | Scheduler cannot place the Pod | Insufficient resources, selector mismatch, unbound PVC |
| `OOMKilled` | Container crossed its memory limit | Limit too small, memory leak |
| `ContainerCreating` | Stuck starting up | Volume mount permission, slow pull, failed init container |
I go through them in this order every time.
## Step One: Events, Where Scheduling Problems Show First
```bash
kubectl describe pod -n
```
The Events block at the bottom is the first place to look. It is the scheduler and kubelet talking to you directly.
```
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Warning Failed 2m default-scheduler 0/3 nodes are available: 3 Insufficient memory.
Warning FailedMount 1m mounter Unable to attach or mount volumes: pvc-xxx not found
```
The four event lines I read most:
- `Insufficient memory/cpu` -> the node is out of room. Scale the cluster or shrink the request.
- `FailedScheduling` -> a NodeSelector or affinity matched nothing. Check the labels.
- `FailedMount` -> a PVC did not bind. Check the StorageClass and the PV.
- `Back-off pulling image` -> the pull is failing. Check network and auth.
## Step Two: Logs, Where the Crash Explains Itself
Once I know the Pod was scheduled and the image pulled, I go to logs.
```bash
# logs of the currently running container
kubectl logs -n
# logs of the PREVIOUS, now-dead container - the money shot for CrashLoopBackOff
kubectl logs -n --previous
# if the Pod has several containers, name the one you want
kubectl logs -c -n
```
The `--previous` flag is the single most valuable flag in this whole drill. It reads the log of the container that already died. Without it you are looking at a fresh, healthy container that says nothing.
The log signatures I have memorized:
```
# missing environment variable
Error: DATABASE_URL not found
at Object. (/app/server.js:12:11)
# port already in use
Error: listen EADDRINUSE: address already in use 0.0.0.0:8080
# permission problem
Error: EACCES: permission denied, open '/data/app.log'
# dependency unreachable
Error: connect ECONNREFUSED 10.0.0.5:5432
```
## Step Three: Describe, for Limits and the Exit Code
```bash
kubectl describe pod -n
```
Beyond Events, I read the resource section and the last state.
```yaml
Limits:
cpu: 500m
memory: 256Mi
Requests:
cpu: 250m
memory: 128Mi
Status: OOMKilled # <- the crash reason lives here
Exit Code: 137 # <- 137 is SIGKILL, which is almost always OOM
```
Exit codes are a cheat sheet in themselves:
| Code | Meaning | Where to look |
| --- | --- | --- |
| 0 | Clean exit | Probably a readiness probe rejecting the Pod |
| 1 | Application error | The logs |
| 137 | OOMKilled | The memory limit |
| 143 | SIGTERM | Graceful shutdown, check the termination grace period |
## Step Four: Exec, for a Live Look Inside
```bash
# shell into the running container
kubectl exec -it -n -- sh
# or throw a one-off debug Pod next to it
kubectl run debug --rm -it --restart=Never --image=busybox -- sh
```
The one-off busybox Pod is underrated. When the app container has no shell and no tools, a debug Pod in the same namespace can reach the same network and test connectivity without touching the app.
Inside, the useful probes:
```bash
# can I reach the backend at all?
curl -v http://backend:8080/health
# are the environment variables actually there?
env | grep DATABASE
# does the mounted volume have the right permissions?
ls -la /data
# what is the process tree?
ps aux
# is memory tight inside this container?
free -h
```
## Step Five: Top, for the Cluster Layer
The final layer is cluster-wide pressure. A Pod can be healthy while the node it sits on is not.
```bash
kubectl top nodes
kubectl top pods -n
kubectl describe quota -n
kubectl describe namespace
```
A Pod that keeps getting evicted or rescheduled is often the victim of a node that is running hot. `kubectl top` is how I find that out in one line.
## Scenario Walkthroughs
### Scenario A: CrashLoopBackOff from a missing environment variable
```bash
kubectl get pods
NAME READY STATUS RESTARTS AGE
backend-xyz123 0/1 CrashLoopBackOff 5 10m
kubectl logs backend-xyz123 --previous
# Error: DATABASE_URL not found
# the fix: inject the variable, then let the rollout restart
kubectl set env deployment/backend DATABASE_URL=postgres://...
kubectl rollout restart deployment/backend
```
### Scenario B: ImagePullBackOff from private registry auth
```bash
# the event tells you it is auth, not a typo
Events:
Warning Failed 1m kubelet Failed to pull image "registry.example.com/app:v1.0": unauthorized: authentication required
# create the pull secret
kubectl create secret docker-registry reg-cred \
--docker-server=registry.example.com \
--docker-username=user \
--docker-password=pass \
--docker-email=email@example.com
# wire it into the Deployment
kubectl patch deployment backend -p '{"spec":{"template":{"spec":{"imagePullSecrets":[{"name":"reg-cred"}]}}}}'
```
### Scenario C: Pending from insufficient resources
```bash
Events:
Warning FailedScheduling 2m default-scheduler 0/3 nodes are available: 3 Insufficient memory.
# option one: shrink the request
kubectl patch deployment backend -p '{"spec":{"template":{"spec":{"containers":[{"name":"backend","resources":{"requests":{"memory":"128Mi"}}}]}}}}'
# option two: add nodes
kubectl scale nodes 3 -> 5
# option three: evict dead weight
kubectl delete pods --field-selector status.phase!=Running
```
### Scenario D: OOMKilled from a memory limit that is too small
```bash
kubectl describe pod
Last State: Terminated
Reason: OOMKilled
Exit Code: 137
# short term: raise the limit
kubectl set resources deployment/backend -c backend --limits=memory=512Mi
# long term: profile the leak, do not just keep raising the limit
# Java: jmap -heap
# [Python](https://docs.python.org/): import tracemalloc
```
The temptation is to keep bumping the limit. I have stopped doing that. If a service needs its limit raised twice in a month, there is a leak, and the limit is masking it.
### Scenario E: Service unreachable
```bash
kubectl logs backend
# Error: connect ECONNREFUSED 10.96.0.10:5432
kubectl get svc
kubectl describe svc postgres
kubectl get endpoints postgres
```
A Service that resolves but refuses connections almost always has an empty Endpoints list. The three causes I check in order: the Pods are not Ready (readiness probe failing), the Service selector matches no labels, or the Service and Pod are in different namespaces.
```bash
kubectl get pods -l app=postgres
kubectl describe pod postgres-xxx # look at the readiness probe
```
## Five Failure Post-mortems
### Post-mortem one: ResourceQuota blocking the Pod
The Deployment looked fine but the Pod stayed Pending. The namespace had a ResourceQuota, and the sum of all memory requests had crossed the quota. `kubectl describe quota -n production` showed `memory request: 10Gi, used: 9.8Gi`. The fix was raising the quota or shrinking requests. This one is easy to miss because every individual Deployment is within limits; only the aggregate is over.
### Post-mortem two: an init container that never completed
The Pod sat in `ContainerCreating` and the main container never started, because the init container was failing first. The main container's logs were empty, which is the tell. `kubectl logs -c init-migrate-db` showed the database connection failing. The fix was in the init container's configuration, not the main container.
### Post-mortem three: an over-eager liveness probe
The Pod restarted every thirty seconds while the application was actually healthy. The liveness probe timeout was too short, and a slow response on one request was enough to mark the Pod dead. The fix was a more forgiving probe.
```yaml
livenessProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 60 # let the app actually start
periodSeconds: 30
timeoutSeconds: 10 # widen the timeout
failureThreshold: 3 # tolerate a few failures
```
### Post-mortem four: a ConfigMap that never took effect
Updating the ConfigMap did nothing. The Pod kept running with old configuration. ConfigMaps mounted as volumes are read-only, so the running process never sees the new values. The fix is always a rollout restart.
```bash
kubectl rollout restart deployment/backend
```
### Post-mortem five: a PVC that would not bind
The Pod was Pending with an event saying `storageclass "fast-ssd" not found`. The StorageClass name was wrong, or it had been deleted. The fix is confirming the real name and patching the PVC.
```bash
kubectl get storageclass
kubectl patch pvc data-pvc -p '{"spec":{"storageClassName":"standard"}}'
```
## The Command Cheat Sheet
| Problem | Command |
| --- | --- |
| Pod status everywhere | `kubectl get pods -A` |
| One Pod in detail | `kubectl describe pod -n ` |
| App logs | `kubectl logs -n ` |
| Last crash logs | `kubectl logs --previous -n ` |
| Shell inside | `kubectl exec -it -- sh` |
| Node resources | `kubectl top nodes` |
| Pod resources | `kubectl top pods -n ` |
| Restart a Deployment | `kubectl rollout restart deployment/` |
| Roll back a version | `kubectl rollout undo deployment/` |
## The Layer Model, Summarized
The whole drill is four layers, and I always know which one I am in:
1. **Scheduling layer**: Pending -> Events -> resources, nodes, or storage.
2. **Startup layer**: CrashLoop -> logs -> application configuration.
3. **Runtime layer**: OOM or timeout -> describe -> limits.
4. **Network layer**: Service unreachable -> Endpoints -> selector.
Five commands do ninety percent of the work: `get`, `describe`, `logs`, `exec`, `top`. And the one habit that saves the most time is refusing to restart before I have read `--previous`. Restart is the last resort, not the first move. Find out why it crashed, fix that, and let the Pod come back on its own.
### Original statement
All scenarios and post-mortems in this article are drawn from incidents I personally worked, with identifying details generalized. The command sequences are exactly what I run in production. Nothing here is paraphrased from a vendor troubleshooting guide.
## Related Articles
- [Alertmanager Noise Reduction: Inhibitors, Routing Trees, and Silence](prometheus-alerting.md)
- [Blackbox Exporter Probe Configuration: Multi-Protocol Checks, SLO Design, and ServiceMonitor](blackbox-exporter.md)
- [GitOps Infrastructure Management: ArgoCD vs FluxCD Decision Tree](gitops-infra.md)
---
## Linux CPU Monitoring: The Decision Tree Behind top's Five Percentage Fields
URL: https://ops.jiunotes.top/en/monitor/linux-cpu-monitor.md
摘要: An operator's guide to reading the %us / %sys / %iowait / %steal / %irq fields in top's first line, and the tool chain that follows: mpstat for per-core, pidstat for process and thread, iostat for disk, and perf for hot functions. Four real incidents, plus two hidden CPU traps: CFS quota throttling in containers and NUMA cross-node memory access.
### Answer
Read the five percentage fields in top's CPU line before touching anything. High %usr means application code is burning CPU. High %sys means syscalls, kernel drivers, or audit hooks. High %iowait means the disk is the bottleneck, not the CPU. High %steal means the hypervisor is stealing cycles. High %irq or %soft means network or interrupt storms. Then drill down five layers: top, mpstat for cores, pidstat for threads, iostat or perf for the bottom, and perf record for a flamegraph.
### FAQ
Q: If %iowait is high, is the CPU busy?
A: No, and this is the most common misdiagnosis. %iowait is the percentage of time the CPU is idle while waiting for disk I/O to finish. The problem is the disk, not the CPU. If %iowait is 40%, scale the disk, not the cores. Check iostat's %util and await before touching the CPU count.
Q: top shows 40% average CPU but my API is still slow. What am I missing?
A: The average is hiding a single-core hotspot. Run `mpstat -P ALL` and look for one core at 100% while the rest idle. That is usually thread pinning (NUMA) or a single-threaded GC. Also check the CFS quota in the container: if a container has a cpu quota, the kernel throttles threads even when the host looks idle.
Q: What does a high %steal tell me?
A: %steal is time stolen by the hypervisor. In a virtualized environment it means a noisy neighbor or an overcommitted host. On bare metal it should be zero. This is the single most direct signal that a cloud instance is sharing an oversold host.
Q: Are top's %CPU and pidstat's %CPU the same number?
A: Close but not identical in meaning. top reports a process's CPU as a percentage of the whole machine, so on an 8-core box one busy thread shows as up to 800%. pidstat defaults to the same scale, but with -I it divides by the number of CPUs and returns a normalized 0-100. Always normalize before comparing across machines.
### Full Content
# Linux CPU Monitoring: The Decision Tree Behind top's Five Percentage Fields
CPU saturation is the most misdiagnosed performance problem I know. Ninety percent of engineers see `%Cpu(s): 100% us` and restart the process. They see `%iowait 40%` and buy a faster SSD. They see `%sys 30%` and blame the application code. All three reactions are wrong, and all three waste a night.
I am writing this because I have made all three mistakes. Let me show you the tree I now walk instead, and the two traps that are invisible in `top` entirely.
## The Entry Point: Five Fields in One Line
CPU work always starts at `top`. Not the interactive mode, just the first screen.
```bash
top -bn1 | head -5
```
What you should see:
```
%Cpu(s): 12.3 us, 2.1 sy, 0.0 ni, 84.5 id, 0.5 wa, 0.0 hi, 0.4 si, 0.0 st
```
Five fields in that line, and each one high means a completely different problem:
| Field | Meaning | When high, suspect |
| --- | --- | --- |
| **%us / %usr** | User-space CPU time | Application code burning cycles |
| **%sy / %system** | Kernel-space CPU time | Syscalls, kernel drivers, audit hooks |
| **%wa / %iowait** | Waiting on I/O to finish | **Disk bottleneck, not CPU** |
| **%hi** | Hardware interrupts | NIC or disk interrupt handling |
| **%si** | Software interrupts | Network stack processing |
| **%st** | Stolen by the hypervisor | Oversold host or noisy neighbor |
| **%ni** | Nice-priority processes | Usually ignorable |
| **%idle** | Idle | Higher is healthier |
The counterintuitive fact that trips everyone: **%iowait high does not mean the CPU is busy.** It is the percentage of time the CPU is idle while a process waits for disk I/O. See `%iowait 40%` and the disk is the problem. Do not add cores.
## The Decision Tree
Given the top line, walk this tree:
```
high CPU usage in top
+-- %usr high (>70%)
| +-- one process dominates? -> pidstat -u -> strace/perf for the hotspot
| +-- many processes? -> architecture problem, consider horizontal scaling
+-- %sys high (>30%)
| +-- many network interrupts? -> mpstat, check %irq / %soft
| +-- many syscalls? -> strace -c -p to count calls
| +-- audit / security agent? -> auditctl -l, or disable the agent
+-- %iowait high (>20%)
| +-- %util high? -> disk saturated, faster disk or better IO pattern
| +-- await high but %util low? -> network storage or mount issue
| +-- frequent fsync? -> application write pattern (WAL / transaction log)
+-- %irq + %soft high (>15%)
| +-- NIC interrupt? -> ethtool -n , check queue balance
| +-- interrupt pinning? -> irqbalance, or pin manually across cores
+-- %steal high (>5%)
+-- cloud instance affected by neighbor -> resize or contact the provider
```
## Case One: %usr High, Find the Process
`%usr` high is application code burning CPU. The first step is finding which process, and which thread inside it.
```bash
# 1. sort top by CPU
top -bn1 | sort -k 9 -n -r | head -10
# 2. pin it down with pidstat (sysstat package)
pidstat -u 1 5
# 3. if it could be a thread issue (Java and Go are the usual suspects)
pidstat -u -t 1 3
```
What to look for: the process at the top of the `%CPU` column, and with `-t`, the individual thread. A single thread at 100% while the rest sit at zero is a classic single-threaded bottleneck.
**Incident one: a Java app at 100% that survived five restarts.** I had a Java server at 100% `us` that restarted cleanly and came right back to 100%. `pidstat -u -t 1 3` showed exactly one thread, `GC Thread #2`, pegged at 100% while everything else was zero. `top -H -p ` confirmed it. The GC logs showed frequent Full GC, and the root cause was a heap close to exhaustion. The fix was not a restart, it was a heap dump on OOM and a larger Xmx plus an object-retention fix.
The lesson: process-level `%usr` is an average, and thread-level is the truth. For Java especially, one GC thread can eat an entire core.
## Case Two: %sys High, Find the Syscalls and Kernel Overhead
`%sys` high is rarely bad code. It is one of three things: a network interrupt storm, excessive syscalls, or an audit and security hook.
```bash
# 1. distinguish hard from soft interrupts
mpstat 1 5
# 2. if %irq and %soft are low but %sys is high, catch the syscalls
strace -c -p -f
# 3. find the hot kernel function
perf top -p
```
**Incident two: 40% %sys was a security agent.** A server sat at 40% %sys for weeks and the business team blamed slow startup. `strace -c -p ` showed `futex` accounting for eighty percent of the time. `auditctl -l` listed over two hundred audit rules, including watch-everything rules for `/var/log` and every network connection. Disabling the audit rules dropped `%sys` to five percent.
The lesson: when `%sys` is high, look at audit and security software before you touch application code. Security agents are the invisible CPU killers.
## Case Three: %iowait High, Find the Disk
This is the one I keep repeating because people keep getting it wrong. High `%iowait` is a disk problem, and ninety percent of first responders try to scale the CPU.
```bash
# 1. per-disk state
iostat -xz 1 5
# 2. the three fields that matter
# %util = disk busy ratio (>90% means saturated)
# await = average IO latency in milliseconds
# avgqu-sz = average queue length
```
**Incident three: 40% %iowait, scaled to 16 cores, still slow.** A MySQL box sat at 40% %iowait and the team asked to scale it. I checked `iostat -xz` before scaling: `sda %util = 98%`, `await = 250ms`, `avgqu-sz = 42`. The disk was saturated, not the CPU. The root cause was a small `innodb_log_file_size` plus aggressive WAL flushing, hammering one SSD. The fix was splitting the WAL onto a dedicated SSD and setting `innodb_flush_log_at_trx_commit=2`.
The lesson: when `%iowait` is high, read `iostat %util` first. When the disk is the bottleneck, adding CPU is burning money.
## Case Four: %irq and %soft High, Find the Network
Network-heavy machines, which is to say gateways, proxies, and [Kubernetes](https://kubernetes.io/docs/) nodes, run into high `%soft` all the time.
```bash
# 1. which interrupts are firing
cat /proc/interrupts | head -20
# 2. how they are distributed across cores
watch -n 1 "cat /proc/interrupts | grep eth0"
# 3. is the NIC multi-queue actually enabled
ethtool -l eth0
```
If one IRQ counter grows on a single CPU and `ethtool -l` shows `Combined: 1`, the NIC is not using multiple queues. Enable it:
```bash
ethtool -L eth0 combined 8
systemctl status irqbalance
```
## Case Five: %steal High, Find the Hypervisor
This is the single most direct signal that a cloud instance is sharing an oversold host.
```bash
sar -u -P ALL 60 30
```
On bare metal `%steal` should always be zero. On a cloud instance, occasional 2-5% is normal. Sustained `%steal` above ten percent means a noisy neighbor or an oversubscribed host.
**Incident four: a cloud instance that was slow but showed 95% idle.** The business team said "the instance suddenly got slow". `top` showed `%idle 95%`. `mpstat -P ALL 1 5` showed `%steal` at 15-20% and `%idle` at 85-90%. That is textbook neighbor noise: another VM on the same host is stealing cycles from us. The fix was moving to a dedicated instance type.
## Hidden Trap One: CFS Quota Throttling in Containers
This is the trap that bites hardest in Kubernetes. `top` shows `%idle 90%`, but the workload times out.
```bash
# cgroup v1
cat /sys/fs/cgroup/cpu/cpu.stat
# cgroup v2
cat /sys/fs/cgroup/cpu.max
# check throttling specifically
grep throttled /sys/fs/cgroup/cpu.stat
# nr_throttled: total times throttled
# throttled_time: total throttled time in nanoseconds
```
If `nr_throttled` keeps climbing, the workload is being throttled by its CPU quota. If `throttled_time` grows by more than ten milliseconds per second, the workload is visibly affected. The fix is raising `cpu.cfs_quota_us` or dropping the CPU limit and keeping only the request.
The lesson: in Kubernetes, always read the cgroup throttle data. The `%idle` in `top` will lie to you.
## Hidden Trap Two: NUMA Cross-Node Memory Access
Large-memory, many-core boxes (64 cores, 256G) hide a performance killer in NUMA.
```bash
# which NUMA node is the process bound to
taskset -pc
# the process's NUMA distribution
numastat -p
# cross-node migration count
numastat --all | grep migrate
```
If `numa_hit` and `numa_miss` diverge heavily, or `numamigrate` keeps growing, threads are bouncing across nodes. Pin the process:
```bash
numactl --cpunodebind=0 --membind=0 java -jar app.jar
```
## The Five-Layer Tool Chain
The correct posture is coarse to fine, five layers:
| Layer | Command | Purpose |
| --- | --- | --- |
| 1 | `top` | Overall %us / %sy / %wa |
| 2 | `mpstat -P ALL` | Per-core distribution, find the hot core |
| 3 | `pidstat -u -t` | Pin to a specific process and thread |
| 4 | `iostat -xz` / `perf top` | Disk or kernel hot function |
| 5 | `perf record` + `perf report` | Flamegraph, locate the exact code line |
Do not skip the middle layers. Running `perf` on a machine with a thousand processes produces output that explodes in your face. Use `top` to pick a direction, `mpstat` and `pidstat` to narrow it, and only then `perf` to find the root cause.
## The Twelve-Command Cheat Sheet
| # | Command | Purpose |
| --- | --- | --- |
| 1 | `top -bn1` | One-shot CPU overview |
| 2 | `top -H -p ` | Per-thread CPU inside a process |
| 3 | `mpstat -P ALL 1 5` | Per-core distribution |
| 4 | `pidstat -u 1 5` | Per-process CPU |
| 5 | `pidstat -u -t 1 3` | Per-thread CPU, for Java and Go |
| 6 | `iostat -xz 1 5` | Disk state |
| 7 | `sar -u 1 5` | Historical CPU sampling |
| 8 | `sar -u -f /var/log/sa/sa$(date +%d)` | CPU for a past day |
| 9 | `perf top -p ` | Hot functions in a process |
| 10 | `numastat -p ` | NUMA distribution |
| 11 | `cat /sys/fs/cgroup/cpu.stat` | Container CFS throttle |
| 12 | `cat /proc/interrupts` | Interrupt distribution |
## When It Is Not a CPU Problem
The most common mistake is treating a non-CPU problem as a CPU problem. Four cases that look like CPU slowness but are not:
1. **High `%iowait`**: the disk, scaling CPU does nothing.
2. **High `%steal`**: the hypervisor, call the cloud provider.
3. **`%idle` at 90% but the workload is slow**: memory pressure, GC, CFS throttle, or NUMA pinning.
4. **One core at 100%, the rest idle**: a thread scheduling problem, not a capacity problem.
The iron rule: classify first (`%us` / `%sy` / `%wa` / `%irq` / `%st`), then localize (process, thread, function), and never reach for restart as the first move. In production, ninety percent of performance problems do not survive a restart because they were never about the process being wrong.
### Original statement
Every incident in this article is one I worked, described from my own diagnosis. The tool sequences are the exact commands I run. Thresholds are my production experience, not vendor defaults.
## Related Articles
- [Linux Memory Monitoring: free/vmstat/ps Fields and the cgroup v2 OOM Decision Tree](linux-memory-monitor.md)
- [node_exporter Metrics Missing: Six Symptom Decision Tree](node-exporter-metrics-missing.md)
- [node_exporter Troubleshoot: Six Symptom Decision Tree and Four Common Failure Causes](node-exporter-troubleshoot.md)
---
## Linux Disk I/O Monitoring: A Six-Symptom Decision Tree and Five Real Incidents
URL: https://ops.jiunotes.top/en/monitor/linux-disk-io-monitor.md
摘要: Load is high while the CPU idles. This operator's guide walks a six-symptom tree through vmstat, iostat, iotop and pidstat, plus five real incidents and HDD/SSD/NVMe threshold differences.
### Answer
High load + idle CPU = iowait; check vmstat b/wa, then iostat, iotop.
### FAQ
Q: Why is load average high when CPU usage is low?
A: Load counts runnable plus uninterruptible-sleep tasks. I/O wait (D-state) inflates load without burning CPU, so a busy disk reads as high load with idle cores.
Q: Is %util = 100% always saturation?
A: No. %util is the share of time a request was in flight, not throughput. NVMe queues deeply and can sit at 100% while latency is fine. Always pair %util with await.
Q: What does await above 20 ms mean on an SSD?
A: A healthy SSD answers in single-digit milliseconds. Await above 20 ms with a shallow queue points to real contention, firmware trouble, or a slow backend; check w_await and aqu-sz.
Q: How do I find which process is hammering the disk?
A: Run iotop -oPa for per-process I/O or pidstat -d 1 to sample rates. If the top entry is kswapd0 or flush/jbd2, the cause is memory pressure or journaling, not an application.
Q: How does cgroup v2 blkio help?
A: io.max caps a cgroup at a bandwidth or IOPS ceiling and io.weight shares the device proportionally, stopping one noisy container from starving its neighbours.
### Full Content
# Linux Disk I/O Monitoring: A Six-Symptom Decision Tree and Five Real Incidents
## The Call That Started It All
The alert was boring: load average 12 on an 8-core box, and nothing else. I SSH'd in expecting a runaway process. Then I ran `top` and blinked.
```bash
top -bn1 | head -12
```
```text
%Cpu(s): 2.1 us, 1.4 sy, 0.0 ni, 36.2 id, 60.0 wa, 0.3 hi, 0.0 si
KiB Mem : 32783752 total, 1862112 free, 4102388 used, 26821252 buff/cache
KiB Swap: 4194300 total, 0 free, 4194300 used, 0 cached
PID USER %CPU %MEM TIME+ COMMAND
1234 mysql 0.3 1.2 12:44.03 mysqld
```
Sixty percent of the CPU was **waiting**, not working. The load was high, the cores were idle, and no process was to blame. This is the single most misread signal in [Linux](https://www.kernel.org/doc/html/latest/) performance work: **high load with idle CPU is almost always disk I/O wait**, not CPU starvation. That night I learned to distrust load average as a headline and to go looking underneath it. This article is the field guide I wish I'd had.
## vmstat: Three Numbers That Frame the Whole Problem
Before touching iostat, I take one `vmstat` snapshot. Three columns tell me which subsystem is lying.
```bash
vmstat 1 3
```
```text
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
r b swpd free buff cache si so bi bo in cs us sy id wa st
1 4 4194296 1845232 210 26821252 0 0 4 210 102 1104 2 1 36 60 0
1 4 4194296 1845122 220 26821252 0 0 2 180 101 1088 2 1 35 61 0
1 4 4194296 1845012 230 26821252 0 0 5 190 102 1096 2 1 35 60 0
```
- **`r`** — runnable processes. Low here means nobody is actually waiting for CPU.
- **`b`** — processes in uninterruptible sleep (D-state), the ones the load average is counting for free.
- **`wa`** — the iowait percentage; 60% here is the headline.
My rule of thumb: when `r` is small but `b` and `wa` are large, the load average is a disk problem wearing a CPU costume. When both `r` and `wa` are large, the disk is a symptom and the CPU is the disease.
The `si`/`so` columns are the next stop. Non-zero `so` with a full swap file is a second, sneakier disk killer, because every page written to swap is a page the disk has to absorb. More on that in the incidents.
## The Six-Symptom Decision Tree
Over the years I've boiled every disk emergency down to six questions. I answer them in order and each one prunes the tree. I've drawn it below as the order I actually run the commands.
| Step | Symptom I see | What it usually means | Command |
|------|---------------|-----------------------|---------|
| 1 | Load high, CPU idle, `wa` high | Iowait — disk is the bottleneck | `vmstat 1` |
| 2 | App slow but `%util` near 100% | Could be fake saturation on NVMe | `iostat -x 1` |
| 3 | Many D-state processes, nothing in `r` | Blocked on I/O, queue is the wall | `ps -eo state,wchan` |
| 4 | Small random reads vs big sequential writes | Random access dominates seek time on HDD | `iostat -x 1` r/s vs w/s |
| 5 | One process vs system-wide noise | Single culprit vs kernel thread (flush/jbd2) | `iotop -oPa` |
| 6 | `kswapd0` or `flush` on top of iotop | Writeback or memory pressure, not an app | `pidstat -d 1` |
The point of the tree is to stop guessing. Symptom 1 gets me off the CPU and onto the disk. Symptom 2 stops me from trusting a number that lies. Symptoms 3–6 tell me *who* is hitting the disk and *how*.
## iostat -x 1: Reading the Fields Like a Pro
`iostat -x 1` is the single most informative command in the whole diagnosis, and the one people read most badly. The output is wide, so I trim it to the fields that matter.
```bash
iostat -x -d 1 3 /dev/sda
```
```text
Device r/s w/s rkB/s wkB/s rrqm/s wrqm/s %rrqm %wrqm r_await w_await aqu-sz rareq-sz wareq-sz await %util
sda 4.0 18.0 32.0 4096.0 0.0 0.0 0.0 0.0 0.20 24.80 0.45 8.0 227.0 20.80 42.0
```
The columns I read, in order:
- **`%util`** — fraction of wall-clock time a request was in flight. On a spinning disk this is a solid saturation proxy. On NVMe it is **not** (incident #4). I never call a device saturated on `%util` alone.
- **`await`** — average time from submission to completion, per I/O. This is the latency the application actually feels. Healthy NVMe: under 1 ms. Healthy SSD: under 2 ms. Healthy HDD: 5–15 ms.
- **`r_await` / `w_await`** — split of the above. If `w_await` is 10x `r_await`, writes are the problem, which points to writeback, journaling, or a synchronous-commit app.
- **`aqu-sz`** — average queue depth. If `aqu-sz` is high but `await` is low, the device is *capable* and the queue is just buffering; the app is fine. If `aqu-sz` is low and `await` is high, the device itself is slow.
- **`rrqm/s` / `wrqm/s`** — merges per second. A high `wrqm/s` tells me the kernel is already merging adjacent writes well; a low one with a small `wareq-sz` suggests a random-write pattern that defeats the elevator.
The two-number rule I quote in every postmortem: **`%util` tells you the device is busy; `await` tells you it's slow.** A device can be busy and fast (fine), or busy and slow (broken), or idle and slow (queueing elsewhere).
## iotop and pidstat: From Disk to Process
Once I know the disk is the wall, I want the name on the wall. `iotop` gives me per-process I/O in real time, but the default output is noisy with cumulative counters.
```bash
iotop -oPa
```
```text
Total DISK READ : 0.00 B/s | Total DISK WRITE : 4.10 M/s
Actual DISK READ: 0.00 B/s | Actual DISK WRITE: 2.30 M/s
TID PRIO USER DISK READ DISK WRITE SWAPIN IO> COMMAND
123 be/4 mysql 0.00 B 3.90 M 0.00 % 61.5 % mysqld
456 be/4 root 0.00 B 0.20 M 0.00 % 8.2 % kworker/u8:2-flush
```
The flags matter: `-o` shows only processes doing I/O right now, `-P` shows per-process (not per-thread) lines, `-a` accumulates. When a kernel thread like `kworker/u8:2-flush` or `kswapd0` shows up near the top, that is the writeback path, not an application. That distinction alone has saved me from restarting the wrong service twice.
`pidstat -d 1` is the non-interactive sibling, perfect for scripting and for catching short bursts.
```bash
pidstat -d 1 3
```
```text
12:00:01 UID PID kB_rd/s kB_wr/s kB_ccwr/s iodelay Command
12:00:02 27 123 0.00 4096.00 0.00 612 mysqld
```
`iodelay` is gold: milliseconds the process spent blocked waiting on I/O. A high `iodelay` with a low `%CPU` is the definitive "this process is disk-bound" signature.
## D-State Processes: When the Kernel Can't Sleep
The load average counts processes in uninterruptible sleep. When the queue backs up, processes pile into D-state and the load number climbs while the CPU idles. `ps` shows them immediately.
```bash
ps -eo state,wchan,comm | awk '$1 ~ /D/ {print}' | sort | uniq -c | sort -rn
```
```text
6 D wait_on_page_bit mysqld
2 D do_blockdev_direct_IO rsync
1 D __lock_page_killable java
```
I count D-state processes per command and per wait channel. `wait_on_page_bit` and `__lock_page_killable` are page-cache waits — the process is blocked on the disk doing the I/O. `do_blockdev_direct_IO` is direct I/O, which bypasses page cache and hits the device raw. A pile of D-state with a low `r` in vmstat is the textbook "everything is queued on the disk" picture, and it confirms symptom 3 of the tree.
## The Drive Type Trap: HDD, SSD, NVMe Thresholds
The same `iostat` numbers mean different things on different hardware. This is the most dangerous part of disk monitoring, because the tool is identical while the physics is not. My matrix:
| Metric | HDD (spinning) | SSD (SATA) | NVMe |
|--------|----------------|------------|------|
| `await` healthy | 5–15 ms | < 2 ms | < 1 ms |
| `await` alarmed | > 50 ms | > 20 ms | > 10 ms |
| `%util` meaning | True saturation proxy | Rough proxy | **Nearly meaningless** |
| Queue depth | Shallow (1–4) | Moderate | Deep (32+) |
| Sequential vs random | Huge gap | Small gap | Minimal gap |
On a spinning disk, `%util` near 100% genuinely means the head is always seeking and throughput has plateaued. On NVMe, the drive completes I/O in microseconds and pipelines thousands of requests; `%util` can sit at 100% while the application is perfectly happy (incident #4). For NVMe I trust `await` and `aqu-sz` almost exclusively, and I treat `%util` as decoration.
The seek penalty also explains why random I/O murders an HDD but barely dents an NVMe. A 4 KB random read on a 7200 RPM disk costs a seek (~8 ms) plus a rotation (~4 ms) — roughly 2000x the data-transfer time. The same read on NVMe is a single memory-like access. When I see a database doing thousands of small random reads on spinning media, the disk isn't slow, the *workload is wrong for the hardware*.
## cgroup v2 blkio: Container-Level I/O Isolation
A large share of the disk incidents I handle now live inside containers, and the host-level tools only show the aggregate. cgroup v2 gives me the two knobs I reach for constantly.
```bash
# Cap a container to 20 MB/s and 100 IOPS
echo "8:0 rbps=20971520 wbps=20971520 riops=100 wiops=100" \
> /sys/fs/cgroup/io.max
```
`io.max` caps bandwidth (`bps`) and IOPS (`iops`) for read and write independently. This is the hammer for the noisy-neighbour problem: one container doing a bulk import was starving every API pod on the box until I capped it.
```bash
# Share the device 2:1 between two cgroups
echo "8:0 wbps=10485760" > /sys/fs/cgroup/group-a/io.max
echo "8:0 wbps=5242880" > /sys/fs/cgroup/group-b/io.max
```
`io.weight` (1–10000) is the proportional alternative: two cgroups at weight 200 and 100 get a 2:1 share of whatever the device can give, with no hard ceiling. The practical sequence is `io.weight` for fairness, and `io.max` when one tenant is actively misbehaving. I verify with the same iostat/iotop tools, read per-cgroup, which lets me prove the isolation actually worked.
## Five Incidents I Actually Lived Through
**Incident 1 — Backups that forgot to be lazy.** A nightly `rsync` of a 2 TB directory was saturating the disk for hours. `iotop -oPa` showed `rsync` writing 90 MB/s while the web app crawled. The fix was throttling, and I did it at the source with `--bwlimit` rather than by fighting the OS:
```bash
rsync -a --bwlimit=20480 /data/ /backup/
```
**Incident 2 — The invisible swap thrash.** Load was 20, CPU was 30% idle, and no application was hot. `iotop -oPa` showed `kswapd0` writing 40 MB/s of swap. `vmstat` had `so` non-zero. The app was leaking anonymous memory and the kernel was churning swap, not disk. The disk was a victim; the fix was memory, not I/O. I increased the memory limit and the iowait vanished.
**Incident 3 — Database random I/O on spinning disks.** A Postgres box on 7200 RPM disks had `await` at 90 ms and `%util` at 100%. `iostat -x 1` showed 200 r/s of tiny 8 KB reads with a deep queue — classic random access. `io.max` could not fix physics. The real fix was storage, and the honest answer was to move to SSD; in the meantime, raising `shared_buffers` and forcing sequential prefetch cut the random reads substantially.
**Incident 4 — The NVMe that looked saturated and wasn't.** An NVMe box reported `%util` = 100% constantly, and the team was ready to replace it. But `await` was 0.4 ms and `aqu-sz` was 60. The drive was completing requests in half a millisecond; 100% busy just meant there was *always* at least one request in flight. The application was fine. We did nothing, and the right thing was to do nothing. This is the case that taught me to stop trusting `%util` on NVMe.
**Incident 5 — ext4 journaling that doubled the writes.** A write-heavy service had `w_await` at 30 ms while `r_await` was 1 ms. `iotop` showed a single process writing, and `wrqm/s` was low — few merges, meaning writes were being forced to disk. The filesystem was `data=journal`, which writes each transaction to the journal *and* the data blocks. Switching to `data=ordered` cut the write volume roughly in half, and `w_await` dropped to a healthy range.
## FAQ
**Q: Why is load average high when CPU usage is low?**
A: Load counts both runnable and uninterruptible-sleep tasks. Processes blocked on I/O (D-state) inflate the number while consuming no CPU. A busy disk therefore reads as high load with idle cores; check `vmstat` `b` and `wa` before blaming the CPU.
**Q: Is `%util` = 100% always saturation?**
A: No. `%util` is the fraction of time a request was in flight, not throughput or latency. NVMe drives queue deeply and report 100% while completing I/O in under a millisecond. Always pair `%util` with `await`; on NVMe, trust `await`.
**Q: What does `await` above 20 ms mean on an SSD?**
A: A healthy SSD answers in single-digit milliseconds. `await` above 20 ms with a shallow `aqu-sz` means the device itself is slow (contention, firmware, or a failing backend), while a deep queue means the workload is outpacing it. Check `w_await` to split reads from writes.
**Q: How do I find which process is hammering the disk?**
A: Run `iotop -oPa` for live per-process I/O, or `pidstat -d 1` for sampled rates and `iodelay`. If the top entry is `kswapd0`, `flush`, or `jbd2`, the cause is memory pressure or journaling rather than an application.
**Q: How does cgroup v2 blkio help?**
A: `io.max` caps a cgroup at a bandwidth or IOPS ceiling, and `io.weight` shares the device proportionally. Together they stop one noisy container from starving its neighbours, which I verify per-cgroup with iostat.
## Related Articles
- [Linux CPU Monitoring: Load Average, Run Queue, and the Three-State Trap](/docs/en/monitor/linux-cpu-monitor.md)
- [Linux Memory Monitoring: From `free` to cgroup v2 and the OOM Killer](/docs/en/monitor/linux-memory-monitor.md)
- [Prometheus Query: Writing Alerting Rules That Don't Fire at 3 AM](/docs/en/monitor/prometheus-query.md)
---
## Linux Memory Monitoring: free/vmstat/ps Field Reading and the cgroup v2 OOM Decision Tree
URL: https://ops.jiunotes.top/en/monitor/linux-memory-monitor.md
摘要: An operator's field guide to memory that fails silently. Read free's available column, not used. Watch vmstat's si/so for real swap pressure. Understand cgroup v2 memory.events as the container's black box. Decode the OOM killer's badness algorithm and dmesg constraint. Five production incidents, including the Xmx-versus-limit trap and the tmpfs hidden memory eater.
### Answer
Memory is the failure that kills silently. Start with free, but read available, not used, because buff/cache is reclaimable and high used is normal. Then vmstat: sustained non-zero si/so is the real pressure signal. Then determine whether the OOM was node-level or cgroup-level from dmesg's constraint. cgroup v2 memory.events is the container black box: oom_kill counting up is the direct evidence of an OOMKilled Pod. Always cross-check JVM Xmx against the container memory.max.
### FAQ
Q: free shows high used but the system is not slow. Why?
A: Because used includes reclaimable buff/cache. Linux's philosophy is that idle memory is wasted memory, so the kernel caches disk pages, dentries, and inodes aggressively. free stays low and buff/cache stays high in a healthy system. The real signal is the available column, which is what the kernel can hand to a process right now. Or check vmstat 1 5: if si/so stay at zero, there is no swap pressure at all.
Q: My container was OOMKilled but the host still has plenty of memory. What happened?
A: Container memory is bounded by cgroup memory.max, which is a separate ledger from host physical memory. When memory.current hits memory.max, the kernel kills a process inside that cgroup, regardless of how much is free on the host. Read memory.events oom_kill, memory.max, and memory.current, and check whether the JVM Xmx exceeds sixty to seventy percent of the limit.
Q: What is the right swappiness value?
A: Default is 60. For databases and latency-sensitive workloads I use 10 to 30 to avoid swapping out anonymous pages. But swappiness only affects how much the kernel prefers reclaiming anonymous pages over file pages; file page reclamation runs on a separate track. Setting it to zero does not disable swap, it just makes the kernel swap more aggressively when memory truly runs out.
Q: How do I tell a leak from normal growth?
A: Three signals together. A process RSS that only ever climbs and never falls back. A /proc/meminfo SUnreclaim and Slab that keep rising and do not fall. And a slabtop object at USE 100% with a growing object count. If memory rises and then plateaus at a new level, that is cache warm-up, not a leak. Only up-and-up is a leak.
### Full Content
# Linux Memory Monitoring: free/vmstat/ps Field Reading and the cgroup v2 OOM Decision Tree
My first year in operations I lost a 64G database node to the OOM killer at midnight, and the monitoring dashboard looked perfect. `free -h` showed `used` around 50G, which everyone read as healthy. What nobody read was that `buff/cache` had quietly climbed to 60G, and `available` was nearly gone. The OOM killer took the main process, wrote no application log, left no stack, and dropped no core file. By morning the business just knew the database had "died for no reason".
Memory is different from CPU. When CPU is full you feel the machine get slow. When memory is full, the system often kills a process without a word. This article is the field guide I built after that night: what to read in `free`, when `vmstat`'s si/so matters, how the cgroup v2 `memory.events` black box works, and the five incidents I still think about.
## One: Reading free, and Why used Lies
Every memory hunt starts at `free -h`. It is also the most misread command in the toolchain.
```bash
free -h
```
A 32G production node:
```
total used free shared buff/cache available
Mem: 31Gi 9.2Gi 1.5Gi 312Mi 20Gi 21Gi
Swap: 8.0Gi 0B 8.0Gi
```
Five fields matter (`shared` is usually tiny and ignorable), and each high value is a different signal:
| Field | Meaning | Signal |
| --- | --- | --- |
| **total** | Physical memory | Fixed; a low value is a VM sizing problem |
| **used** | App plus kernel actual usage | High does not mean pressure, see below |
| **free** | Completely unallocated | Near zero is normal; [Linux](https://www.kernel.org/doc/html/latest/) likes to use all of it |
| **buff/cache** | Page cache plus buffers plus slab | The reclaimable part is soft memory |
| **available** | **Memory the kernel can hand over now** | This is the column to read, not used |
The reason `used` lies: Linux treats idle memory as wasted. Any memory it is not using, it uses for caching disk pages, dentries, and inodes. So `used + buff/cache` is almost always close to `total`, and `free` hovers at a few GB. That is healthy, not alarming.
The real number is `available`. The kernel computes it from `MemAvailable`, already subtracting the cache that cannot be reclaimed immediately. It is the answer to the question "if a process asked for a big allocation right now, how much could I give it?" It beats both `used` and `free`.
## Two: buff/cache, Reclaimable versus Not
The question is how much of `buff/cache` can actually be reclaimed. The answer is in `/proc/meminfo`:
```bash
grep -E '^(MemFree|Buffers|Cached|SReclaimable|SUnreclaim|Shmem|Slab|MemAvailable)' /proc/meminfo
```
Decomposing it:
- **Cached**: file page cache, mostly reclaimable unless it is mapped and dirty.
- **Buffers**: block device metadata, small, reclaimable.
- **SReclaimable**: the reclaimable part of slab, dentries and inodes, good news.
- **SUnreclaim**: the non-reclaimable part of slab, kernel objects and driver allocations, bad news.
- **Shmem**: tmpfs shared memory, not reclaimable, and often mistaken for cache.
- **Slab**: total kernel object cache.
One sentence to remember: `SReclaimable + Cached` is soft memory, `SUnreclaim + Shmem` is hard usage. If `available` is low but `Cached` is high, the system can probably recover. If `Cached` is low but `SUnreclaim` and `Shmem` are high, it is genuinely tight.
My threshold: when `available` drops below five percent of `total` and stays there for five minutes, that is an alert. Do not wait for zero. By zero the OOM is already running.
**Incident one: the page cache misjudgment.** A Java app was OOMKilled. On the host, `free -h` showed `used 9G / total 32G / buff/cache 21G`. My first instinct was "there is plenty of memory, why did it die?" Then `MemAvailable` showed only 1.2G. Then I checked the cgroup: the app ran in a container with `memory.max = 4G`, and from inside the container, the page cache the process touched all counted against it. `memory.current` was pinned at 4G. The host had plenty; the container's own view had been blown past by its own cache plus its heap.
The lesson: `free -h` reads the host. Container troubleshooting must read the cgroup, and host `buff/cache` is a red herring.
## Three: vmstat, and the Real Pressure Signal
`free` tells you what is left. `vmstat` tells you how memory is being used, and the hardest evidence of pressure is not `free` at all, it is **si/so staying non-zero**.
```bash
vmstat 1 5
```
The columns that matter for a memory hunt:
| Column | Meaning | Signal |
| --- | --- | --- |
| **si** | swap in, KB per second read from swap | Sustained non-zero = pressure |
| **so** | swap out, KB per second written to swap | Same |
| **r** | runnable processes | Long over CPU count = queue buildup |
| **b** | blocked, uninterruptible sleep | Non-zero with si/so = waiting on paging |
| **free** | unallocated memory | Watch the trend, not the absolute |
The judgment:
- si/so long at zero -> no memory pressure, look elsewhere.
- si/so occasionally non-zero -> startup or cache writeback, normal.
- si/so sustained non-zero -> genuinely tight, processes are using disk as memory, and the real cost is I/O.
- `b` climbing alongside -> processes blocked on paging. This is why the system is alive but the business is timing out.
Swap thrashing is worse than a memory shortage, because swap writeback is synchronous disk I/O. At a few hundred KB per second of `so`, every page fault waits on the disk, and application latency jumps from milliseconds to hundreds of milliseconds. The weird combo of "CPU normal + memory normal + business very slow" is, nine times out of ten, swap thrashing.
## Four: ps aux, and the RSS versus PSS Trap
Once I find the process, `ps aux` shows me who is eating memory. But there are two traps: RSS overcounts shared memory, and VSZ is useless.
```bash
ps aux --sort=-rss | head -15
```
- **VSZ** is the virtual address space the process mapped, including parts never allocated. A process that mmaps 10G but touches 100M shows a 10G VSZ. It is scary and meaningless.
- **RSS** is the resident set, the pages actually in physical memory. But it counts shared pages once per process. Two processes sharing a libc page each count it. Add two RSS values and you can exceed physical memory. That is why RSS overcounts.
For a real per-process number, use `smem`'s **PSS**, which divides shared pages across the processes sharing them. The sum of all PSS is close to real physical usage.
```bash
smem -rsk -t
```
The three columns, in order of usefulness: **USS** is the process's unshared pages, the most conservative. **PSS** is unshared plus shared apportioned, and this is the one I recommend. **RSS** is unshared plus shared counted fully, and it is too big. If a process shows RSS 6G but PSS 1G, it is a sharing-heavy process like a JVM with mmap thread stacks and JIT code cache, not a leak.
## Five: How the Kernel Reclaims Memory
To understand OOM, understand how the kernel gets memory back. There are two paths, driven by memory watermarks.
1. **File pages**: dirty page cache can be written back and freed. This is preferred because it needs no swapping, just flushing cached data to disk. This is why a high `buff/cache` usually means the system can still cope, the kernel is quietly reclaiming file pages.
2. **Anonymous pages**: heap, stack, and anonymous mmaps. They have no disk backend, so they must be swapped out, which is the cost behind si/so.
`swappiness` only affects how much the kernel prefers reclaiming anonymous pages. It does not affect file page reclamation, which always runs first unless writeback cannot keep up.
### kswapd, the Background Reclaimer
Each NUMA node has a `kswapd` thread. When free memory drops below the **low watermark**, kswapd wakes and reclaims asynchronously, aiming to restore memory to the **high watermark**. The process is unaware; nothing blocks.
### direct reclaim, the Latency Killer
If kswapd cannot reclaim fast enough, free memory drops below the **min watermark**, and the process doing the allocation is forced to reclaim memory itself, synchronously, in the allocation path. That is direct reclaim, and it shows up as a rising `b` column, a falling `free`, and spiking latency. This is why the system is alive but the business is skipping seconds.
### The Three Watermarks
| Watermark | Meaning | Action |
| --- | --- | --- |
| **high** | Reclamation done | kswapd stops |
| **low** | Getting tight | kswapd wakes, background async reclaim |
| **min** | Critical | Process forced into synchronous direct reclaim |
```bash
cat /proc/zoneinfo | grep -A 30 'Node 0, zone Normal' | grep -E 'min|low|high|nr_free'
```
The three tiers usually sit near a 1:1.5:2 ratio. Below `low`, pay attention. Below `min`, prepare for OOM.
**Incident two: direct reclaim slowing a [Redis](https://redis.io/docs/) replica with no error.** A Redis replica showed normal monitoring but replication lag climbed from 0.3s to 40s. `vmstat` showed a low `r`, occasional non-zero si/so, and a `b` column stuck at 2 to 5, with `free` hovering near the min watermark. The truth: Redis `fork()` during an RDB snapshot duplicated the page tables, kswapd could not keep up, and the main thread kept entering direct reclaim, waiting tens of milliseconds each time. No OOM, no error, just slow. That is the definition of a hidden memory problem.
## Six: cgroup v2, the Container OOM Black Box
Most modern memory failures live in containers, and container memory is bounded by cgroup, not by host physical memory. These are two separate ledgers.
```bash
# find the container's cgroup
cat /proc//cgroup
# read the control files
cat /sys/fs/cgroup/system.slice/docker-xxxx.scope/memory.max
cat /sys/fs/cgroup/system.slice/docker-xxxx.scope/memory.current
cat /sys/fs/cgroup/system.slice/docker-xxxx.scope/memory.events
```
The `memory.events` file is the black box:
```
low 0
high 0
max 3421 # times current hit max
oom 12 # times OOM killer was triggered
oom_kill 12 # times a process was actually killed
```
- `low` / `high` rising is the early warning that memory is getting tight.
- `max` climbing is the container repeatedly hitting its ceiling.
- `oom_kill` moving from zero is the direct evidence of an OOMKilled Pod.
The scope of an OOM kill is the cgroup that triggered it and its descendants. When a container's `memory.current` hits `memory.max`, the kernel picks the highest `oom_score` process inside that cgroup and kills it. It does not touch other containers on the host. So a container being OOMKilled while the host has half its memory free is normal, and the direction to investigate is `memory.max` being too small, not "add a machine".
`memory.current` also includes every page cache the container touched. A Java process with 800M RSS can show 1.2G `memory.current` because of cached files. Never troubleshoot container memory by process RSS alone; `memory.current` is the container's real total.
```bash
cat /sys/fs/cgroup/system.slice/docker-xxxx.scope/memory.stat | grep -E '^(anon|file|slab_reclaimable|slab_unreclaimable|shmem|pgmajfault)'
```
If `file` dominates and `anon` is small, the container memory is inflated by page cache, and you can raise the limit or wait for reclaim. If `anon` is near `memory.max`, the application itself is growing and you have a leak or a real capacity problem.
## Seven: The OOM Killer, and How It Picks a Victim
When direct reclaim cannot keep up, the kernel runs the OOM killer. It is not a judgment, it is an execution: pick, kill, done.
### The badness Algorithm
The kernel scores every process, and the highest score is the victim. Roughly:
```
badness ~ (RSS + swap + page tables) adjusted by oom_score_adj
```
The bigger the memory footprint, the more likely it is to be killed. The lower the `oom_score_adj`, the less likely. `-1000` means never kill, `+1000` means kill first.
```bash
# higher score means more likely to be chosen
for pid in $(pgrep -u app); do echo -n "$pid "; cat /proc/$pid/oom_score; done | sort -k2 -n -r | head
```
### oom_score_adj, the Insurance Policy
```bash
# protect critical processes
echo -500 > /proc//oom_score_adj
# sacrifice batch jobs first
echo 500 > /proc//oom_score_adj
# -1000 means the OOM killer skips this process entirely, use with care
echo -1000 > /proc//oom_score_adj
```
My production defaults on a database node: MySQL or [PostgreSQL](https://www.postgresql.org/docs/) at -500 to -300, Redis at -300, monitoring agent at -200, log collector at +100, batch jobs at +300. In [Kubernetes](https://kubernetes.io/docs/) the equivalent is the Pod's `oomScoreAdj` or the runtime configuration.
### Reading dmesg, the Incident Report
```bash
dmesg -T | grep -i -E 'out of memory|killed process|oom' | tail -30
```
Line by line, this is the full report:
| Log fragment | Meaning | Where it points |
| --- | --- | --- |
| `invoke_oom_killer ... order=0` | OOM on a single-page allocation | Small `order` means slow drip pressure, not a huge allocation |
| `constraint=CONSTRAINT_NONE` | **Node-level OOM** | The whole host is out, look at the host |
| `constraint=CONSTRAINT_MEMCG` | **cgroup-level OOM** | A container hit its ceiling, look at the cgroup |
| `Killed process 1234 (java)` | Who died and how much | Note the PID and name |
| `total-vm / anon-rss / file-rss` | The victim's memory profile | `anon-rss` is real usage, `file-rss` is cache |
| `Tasks state ... anon:65536` | Snapshot of all processes | Compare anon values, find the real suspect |
The single most important judgment: `CONSTRAINT_MEMCG` means the container is the problem, `CONSTRAINT_NONE` means the node is the problem. Getting that backwards wastes the whole investigation. A host OOM that you chase into a container ceiling, or a container OOM that you chase into host memory, both lead nowhere.
**Incident three: a Java service OOMKilled daily in Kubernetes.** The customer's service was OOMKilled several times a day, and the host showed 200G free. `kubectl exec` showed `oom_kill 18` in `memory.events`, `memory.max = 2147483648` (2G), `memory.current = 2130706432` (1.98G). Then the killer: `jcmd VM.flags` showed `MaxHeapSize = 3221225472` (3G). The container limit was 2G but the JVM heap was configured to 3G. The JVM thought it had 3G, the container only allowed 2G, and the moment heap plus metaspace plus thread stacks plus page cache hit 2G, the OOM killer took the JVM.
The fix was `-Xmx1280m` against the 2G limit, plus `-XX:MaxRAMPercentage=60` so the JVM auto-detects the container limit. The lesson: the first thing to check in a container OOM is the ratio of Xmx to memory.limit. A heap larger than the container limit is the number one cause of OOMKilled Java services in Kubernetes.
## Eight: swap Configuration, swappiness, zram, and overcommit
Swap used well is a buffer. Swap used badly is a disaster.
### swappiness
`/proc/sys/vm/swappiness` controls how much the kernel prefers reclaiming anonymous pages versus file pages. Default is 60.
| Value | Use case | Effect |
| --- | --- | --- |
| 0 | Extremely conservative | Rather kill a process than swap |
| 10 | **Database / latency-sensitive** | Barely swap, prefer file pages |
| 30 | General services | Balanced |
| 60 | System default | Moderate |
| 100 | Aggressive | Constant swapping, latency collapses |
My default: database nodes at 10, general web services at 20 to 30, desktops at 60. Do not set swappiness to zero as a way to "disable swap". It only stops voluntary swapping; when memory truly runs out the kernel swaps anyway, and more violently. Give it a 10 and let it swap gently.
**Incident four: swap thrashing spiking database latency.** A PostgreSQL primary showed queries occasionally freezing for two to three seconds. `free` showed `available` at 6G, so "there is memory". But `vmstat 1 3` showed si/so sustained at 200 to 800 KB/s and a `b` column of 2 to 3, and `%iowait` around fifteen percent. `cat /proc/sys/vm/swappiness` showed 80, someone had raised it. With swappiness at 80, the kernel was eagerly swapping out anonymous pages, including PostgreSQL's shared_buffers. Every query that hit a swapped-out page waited on disk. Setting swappiness to 10 and persisting it brought si/so to zero within the hour and restored latency.
The lesson: high swappiness plus active swap is a latency bomb. On databases, push it below ten and verify si/so actually goes to zero.
### zram and zswap
Since swapping to disk is slow, compress the swap and keep it in memory.
- **zram**: a compressed block device used as swap. Good for small-memory boxes that want to avoid disk swap. Compression ratio is typically 2 to 4x.
- **zswap**: a compressed cache layered on top of existing disk swap. Good when you have disk swap and want to cut I/O.
```bash
modprobe zram
echo 2048M > /sys/block/zram0/disksize
mkswap /dev/zram0
swapon -p 100 /dev/zram0
```
My honest take: memory-resident applications like Redis do not need swap at all, and zram fits the "a bit tight but do not want to buy a machine" case. If memory is genuinely short, adding a DIMM beats any swap scheme.
### overcommit_memory
The kernel can oversell memory, a successful malloc does not guarantee physical pages.
| overcommit_memory | Meaning |
| --- | --- |
| 0 | Heuristic, default |
| 1 | Always overcommit, no check, dangerous |
| 2 | Strict, no overcommit, governed by overcommit_ratio |
For databases I set `overcommit_memory=1`, because MySQL and PostgreSQL mmap large blocks that the heuristic sometimes rejects. But it needs enough swap as a backstop. If you set `2`, be careful: an application failing to start with `Cannot allocate memory` may actually be overcommit refusing the allocation, not real memory exhaustion.
## Nine: Leak Detection, /proc/meminfo, slabtop, and the RSS Curve
Memory problems split into one-off pressure (traffic spike) and chronic leaks (only up, never down). The leak is more dangerous because it can take days to trip an alert.
```bash
grep -E '^(Slab|SReclaimable|SUnreclaim|Dentry|Inode)' /proc/meminfo
```
- `SUnreclaim` rising and never falling -> kernel slab leak, suspect drivers, filesystems, kernel modules.
- `SReclaimable` rising -> usually dentry or inode cache, reclaimable, not a leak.
- `Dentry` abnormally high -> directory entry cache leak, common with apps that create and delete files constantly.
```bash
slabtop -o
```
An object at USE 100% with a growing count is a leak. `dentry` at 100% growing is a dentry leak. `kmalloc-*` at 100% growing is a kernel module or driver leak. `filp` growing is a file descriptor leak.
User-space leaks show as RSS that only climbs. A normal process has peaks and valleys; it falls back after GC or after a request completes. A staircase that never falls back is a leak.
```bash
while true; do
echo "$(date +%H:%M:%S) $(ps -o rss= -p )" >> /tmp/rss_$(date +%F).log
sleep 60
done
```
**Incident five: a kernel slab leak growing 2G a day.** A node gained 2G of memory per day, while `used` and `available` looked fine, but `Slab` climbed daily and `SUnreclaim` went from 500M to 8G. `slabtop` showed `dentry` at 100% USE with a growing object count. The box ran an NFS client plus a cron job that created and deleted temp files at high frequency. The NFS dentry cache was not being released when files were deleted. The fix was upgrading the NFS client module, reducing temp file churn, and a manual reclaim with `echo 2 > /proc/sys/vm/drop_caches`.
The lesson: when memory only climbs and every process RSS is normal, check slab. Kernel-space leaks are invisible to `ps`; only `slabtop` and `SUnreclaim` reveal them.
## Ten: The Decision Tree
```
memory suspected (slow / process killed / alert)
|
+-- 1. free -h + /proc/meminfo
| +-- available healthy (>10%) -> not memory, move to CPU/IO
| +-- available low but buff/cache high -> page cache at the top, try reclaim, continue
| +-- SUnreclaim + Shmem high -> kernel/tmpfs usage, jump to step 6 (leak)
|
+-- 2. vmstat 1 5
| +-- si/so sustained non-zero -> real pressure, swap active, jump to step 7 (swap tuning)
| +-- si/so zero but b high -> disk IO or direct reclaim, check iostat
| +-- free falling and r rising -> memory being eaten slowly, jump to step 6 (leak)
|
+-- 3. OOM happened? dmesg -T | grep -E 'Out of memory|killed'
| +-- constraint=CONSTRAINT_MEMCG -> container-level, jump to step 5 (cgroup)
| +-- constraint=CONSTRAINT_NONE -> node-level, note the Killed process, continue
| +-- no OOM log but process died -> not memory, check app log / coredump
|
+-- 4. node-level OOM: ps aux --sort=-rss, find high-RSS process
| +-- found it -> check oom_score / oom_score_adj, protect critical, scale or shrink
| +-- not found (RSS sum << memory) -> check Shmem (tmpfs) / SUnreclaim (slab), jump to 6
|
+-- 5. container-level OOM: memory.events + memory.max + memory.current
| +-- oom_kill > 0 and current ~ max -> container at ceiling, check Xmx vs limit
| +-- oom_kill > 0 but current << max -> transient spike, check memory.peak
|
+-- 6. leak: slabtop + RSS curve
| +-- SUnreclaim / slab climbing -> kernel slab leak, check driver/NFS/filesystem
| +-- a process RSS climbing, never falling -> user-space leak, jmap/valgrind
| +-- Dentry high -> high-frequency file ops, fix the app
|
+-- 7. swap tuning: swappiness + zram/zswap + overcommit
+-- database node: swappiness=10, maybe disable swap or use zram
+-- latency-sensitive: verify si/so returns to zero
+-- critical process: set oom_score_adj=-500
```
The fastest entry is three steps: `free -h` for `available`, `vmstat` for si/so, then decide whether the OOM is node-level or cgroup-level. Three steps in, eighty percent of problems are located.
## The Cheat Sheet
| # | Command | Purpose | Key output |
| --- | --- | --- | --- |
| 1 | `free -h` | Overview | `available` is the real number |
| 2 | `grep -E '^(MemAvailable\|SUnreclaim\|Shmem)' /proc/meminfo` | Real available plus hard usage | Split soft/hard memory |
| 3 | `vmstat 1 5` | Swap and run queue | si/so sustained non-zero = pressure |
| 4 | `ps aux --sort=-rss \| head` | Big-RSS process | RSS overcounts |
| 5 | `smem -rsk -t` | Real memory by PSS | PSS is the honest number |
| 6 | `cat /sys/fs/cgroup/.../memory.events` | Container OOM black box | `oom_kill` count |
| 7 | `cat /sys/fs/cgroup/.../memory.max` | Container ceiling | Check ratio against Xmx |
| 8 | `cat /sys/fs/cgroup/.../memory.current` | Container current usage | Includes page cache |
| 9 | `dmesg -T \| grep -E 'Out of memory\|killed'` | OOM log | `constraint` decides scope |
| 10 | `cat /proc//oom_score_adj` | OOM protection score | -1000 = never kill |
| 11 | `echo -500 > /proc//oom_score_adj` | Protect critical process | Negative value |
| 12 | `cat /proc/sys/vm/swappiness` | Swap preference | Default 60 |
| 13 | `slabtop -o` | Kernel object cache sort | USE 100% and climbing = leak |
| 14 | `cat /proc/zoneinfo \| grep -E 'min\|low\|high'` | Memory watermarks | Free below min = danger |
| 15 | `cat /proc/meminfo \| grep -E '^(Slab\|Dentry\|Inode)'` | Kernel memory | Slab climbing = leak |
## FAQ
### Why does free show high used but the system is fine?
Because `used` includes a lot of reclaimable `buff/cache`. Linux caches disk pages, dentries, and inodes whenever memory is idle, so `used` being high is normal and healthy. Read `available`, which is what the kernel can actually give a process right now. Or check `vmstat 1 5`: if si/so stays zero, there is no swap pressure and memory is not tight.
### Why was my container OOMKilled when the host has plenty of memory?
Container memory is bounded by cgroup `memory.max`, a separate ledger from host memory. When `memory.current` hits `memory.max`, the kernel kills a process inside that cgroup. Read `memory.events` `oom_kill`, plus `memory.max` and `memory.current`, and check whether the JVM Xmx exceeds sixty to seventy percent of the limit.
### What is the right swappiness?
Default is 60. For databases and latency-sensitive workloads use 10 to 30 to avoid swapping out anonymous pages. But remember: swappiness only affects the preference between anonymous and file page reclamation, setting it to zero does not disable swap, and database workloads should verify si/so actually goes to zero.
### How do I tell a leak from normal growth?
RSS that only climbs and never falls, plus `SUnreclaim` and `Slab` that climb and never fall, plus a `slabtop` object at 100% USE with a growing count. If memory rises and plateaus, that is cache warm-up. Only up-and-up is a leak.
### Why did OOM kill my critical process instead of the big memory hog?
The OOM killer scores by `oom_score`, which is mostly memory footprint but is adjusted by `oom_score_adj`. If your critical process has a positive or zero `oom_score_adj` and the memory hog has a negative one, the killer may pick yours. Set critical processes to `oom_score_adj=-500` and batch jobs positive, so the choice is yours, not the default.
### How can available still have several GB but I still got OOM?
`available` is what could be given immediately, but allocation is instantaneous. A JVM expanding its heap by 2G, or a huge query mmapping memory, can allocate faster than direct reclaim can react. `available` is a trend indicator, not a guarantee against a spike. Look at cgroup `memory.peak` or a sudden jump in `vmstat`'s `r` column.
### Original statement
All five incidents are ones I worked directly, with identifying details generalized. Every command sequence is the one I run in production. Thresholds and tuning values are my field experience, not vendor defaults.
## Related Articles
- [Linux CPU Monitoring: top's Five Percentage Fields and the Decision Tree](linux-cpu-monitor.md)
- [node_exporter Metrics Missing: Six Symptom Decision Tree](node-exporter-metrics-missing.md)
- [Kubernetes Pod Troubleshooting: The Five-Step Drill](kubernetes-pod-troubleshoot.md)
---
## Loki Log Aggregation in Practice: A Single-Cluster Deploy and the Label Cardinality Trap
URL: https://ops.jiunotes.top/en/monitor/loki-log-aggregation.md
摘要: Loki trades full-text search for index-free labels, which cuts storage cost but introduces the cardinality trap. This guide walks a docker-compose single-cluster deploy, the Alloy vs Promtail vs Vector decision, LogQL patterns, and five incidents that taught me the rules.
### Answer
Loki's label design is the real skill. Keep label combos under 100k, use low-cardinality fields, and check rate vs count_over_time before querying.
### FAQ
Q: Why would I move from ELK to Loki?
A: Loki is index-free: it labels streams instead of full-text indexing every line. For high-volume logs where you mostly filter by labels and search occasionally, it cuts storage cost dramatically and ingests faster.
Q: What is the label cardinality trap?
A: If you attach high-cardinality labels like pod_name or container_id, every unique value creates a new stream and a new index entry. Past roughly 100k unique label combinations, queries start timing out and ingestion slows.
Q: Alloy, Promtail, or Vector — which should I pick?
A: For a pure Loki pipeline Alloy is the current default. Vector is stronger if you also fan out to other backends. Promtail is legacy but still works. Match the collector to your whole pipeline, not just Loki.
Q: What is the difference between rate() and count_over_time()?
A: rate() converts a log counter to an events-per-second gauge for dashboarding. count_over_time() returns the raw number of lines in the window — it is a step counter, not a rate, so the range matters.
### Full Content
# Loki Log Aggregation in Practice
I have run log pipelines for a small platform team for years, and for most of that time I ran ELK. [Elasticsearch](https://www.elastic.co/guide/en/elasticsearch/reference/current/index.html) was comfortable: I could grep anything, anywhere, instantly. Then the bill arrived. My ingest went from ~20 GB/day to ~120 GB/day as the microservice count doubled, and the Elasticsearch cluster needed three more data nodes just to keep index latency under a second. The storage cost and the ops load were both growing faster than my headcount. I migrated to Loki.
## 1. Why ELK and I parted ways
The core difference is the index. Elasticsearch full-text indexes every log line. That is why search is instant — but it is also why storage explodes. A 1 KB JSON log line can consume 3–5 KB on disk once you count the inverted index, the doc store, and replica overhead. Loki does the opposite: it does not index content at all. It indexes **labels** and stores the log bytes as compressed chunks. Search across the body is a scan, not an index lookup.
The cost math is what convinced me. In my ELK cluster, a hot data node with 1 TB of NVMe held roughly 8 days of logs at 120 GB/day before the 15% shard watermark forced rebalancing. On Loki with the same volume, the same retention fit on a single 1 TB disk with room to spare. Ingest also got simpler: no shard tuning, no mapping conflicts, no force-merge windows. For a team that mostly filters by service and searches by error string, Loki was the better trade.
## 2. Picking a deploy mode
Loki ships three ways. The **single binary** runs everything in one process and is fine for a dev box, but it has no HA. The **microservices mode** splits compactor, distributor, ingester, and querier into separate deployments — that is what [Grafana](https://grafana.com/docs/) Cloud runs and what a large platform needs, but it is overkill for a team of four. The **docker-compose cluster** runs the Loki components as separate containers sharing object storage. That is the mode I chose: simple enough to debug, resilient enough for production, and I can lift it into [Kubernetes](https://kubernetes.io/docs/) later without rewriting configs.
## 3. The single-cluster docker-compose walkthrough
My stack is three containers: `loki`, `alloy`, and `grafana`, wired into a `monitoring` network. The Loki config keeps a single filesystem backend and a ring backed by Consul or, simpler for one node, a boltdb file.
```yaml
# loki-config.yaml
auth_enabled: false
server:
http_listen_port: 3100
common:
path_prefix: /loki
storage:
filesystem:
chunks_directory: /loki/chunks
rules_directory: /loki/rules
replication_factor: 1
ring:
kvstore:
store: inmemory
schema_config:
configs:
- from: 2024-01-01
store: tsdb
object_store: filesystem
schema: v13
index:
prefix: index_
period: 24h
limits_config:
retention_period: 720h
ingestion_rate_mb: 8
```
The 30-day retention lives in `limits_config.retention_period`, and I set `ingestion_rate_mb` per-stream to keep a noisy app from flooding the ingester. The compose file maps those into a named volume.
```yaml
services:
loki:
image: grafana/loki:3.1.0
command: -config.file=/etc/loki/config.yaml
volumes:
- ./loki-config.yaml:/etc/loki/config.yaml:ro
- loki-data:/loki
ports: ["3100:3100"]
alloy:
image: grafana/alloy:v1.4.0
volumes:
- ./alloy-config.alloy:/etc/alloy/config.alloy:ro
- /var/log:/var/log:ro
- /var/run/docker.sock:/var/run/docker.sock:ro
grafana:
image: grafana/grafana:11.2.0
environment:
GF_AUTH_ANONYMOUS_ENABLED: "true"
ports: ["3000:3000"]
volumes:
loki-data:
```
I verify the cluster in three steps: check readiness, push a probe line, then confirm it lands.
```bash
curl -s localhost:3100/ready
echo '{"streams":[{"stream":{"job":"probe"},"values":[["'"$(date +%s%N)"'","hello from loki"]]}]}' \
| curl -s -X POST localhost:3100/loki/api/v1/push -H 'Content-Type: application/json' --data-binary @-
# expect: {"status":"success"}
```
In Grafana I add a Loki data source at `http://loki:3100`, then run `{job="probe"}` in Explore. I learned the hard way that the push endpoint wants nanosecond timestamps; sending milliseconds silently drops the entry.
## 4. The collector decision: Alloy, Promtail, or Vector
I ran all three before committing. **Promtail** is the legacy Loki collector. It works, but Grafana moved development to Alloy and Promtail is in maintenance mode. **Vector** is a general-purpose agent from Datadog. It is the strongest choice when I fan out the same logs to Loki *and* a metrics backend *and* an S3 archive, because it handles many sinks natively. **Alloy** is the current Grafana default, and for a Loki-centric pipeline it is what I settled on. It replaces both Promtail and the old Grafana Agent, uses a declarative River syntax, and gives me service discovery and relabeling in one config.
For my single-cluster setup the choice was clear: Alloy, because my pipeline ends at Loki and Alloy has the tightest integration with it.
```alloy
# alloy-config.alloy
loki.source.docker "default" {
host = "unix:///var/run/docker.sock"
targets = discovery.docker.containers
forward_to = [loki.process.default.receiver]
}
discovery.docker "containers" {
host = "unix:///var/run/docker.sock"
}
loki.process "default" {
stage.regex { expression = "^(?P\\w+)\\s.*" }
stage.labels { values = { level = "" } }
forward_to = [loki.write.default.receiver]
}
loki.write "default" {
endpoint { url = "http://loki:3100/loki/api/v1/push" }
}
```
The `stage.regex` and `stage.labels` blocks parse the log level out of the first field and attach it as a label, which is exactly the kind of label I can filter on later.
## 5. Label design: the golden rules
Loki is a label-first database. Every unique set of label values creates a **stream**, and every stream is an index entry. That is powerful but it is also the trap. My rules, hard-won:
- **Budget cardinality.** I keep the total number of unique label combinations under ~100,000 across all streams. Past that, query planning and ingestion both slow down.
- **Labels are low-cardinality only.** `job`, `level`, `app`, `cluster`, `env` are safe. `pod_name`, `container_id`, `trace_id`, and `request_id` are never labels. Those belong in the log line body, not the label set.
- **Labels are filters, not metadata.** If I never query on it, I do not label it. Metadata belongs in the structured log body where `| json` can extract it lazily.
The anti-patterns I actually hit are worth naming. I once relabeled the full `container_id` from [Docker](https://docs.docker.com/). Every container restart created a new stream, and within a week I had 400,000 streams and queries that timed out. I also labeled `path` from [nginx](https://nginx.org/en/docs/) access logs — that was hundreds of streams per app, all from a field I rarely filtered on. I fixed both by moving the high-cardinality value into the log body and keeping only `job` + `level` + `app` as labels. My stream count dropped from ~400k to ~2k and queries went from timing out to sub-second.
## 6. LogQL cheat sheet
LogQL is the query language, and the two functions that trip everyone up are `rate` and `count_over_time`.
```logql
# events per second — use for dashboards and alerts
rate({app="api"}[5m])
# raw line count in the window — a step counter, NOT a rate
count_over_time({app="api"}[5m])
```
`rate()` normalizes by the range, so it is comparable across windows and it is what I plot. `count_over_time()` returns the raw count, so a 5m window shows five times the value of a 1m window. If I alert on it I must set the threshold with the range in mind.
Structured logs are where I parse JSON with `| json`. The trap: a malformed JSON line in the stream aborts the query for that stream. I always test against a mixed sample.
```logql
{app="api"} | json | level = "error" | line_format "{{.ts}} {{.msg}}"
# unwrap extracts a numeric value for metric-style aggregation
sum by (route) (rate({app="api"} | json | unwrap bytes [5m]))
```
`unwrap` is what turns a numeric field inside the log body into a series I can `rate` or `sum`. It only works on numbers; passing a string returns an error, which is another parsing trap I hit in production.
## 7. Five real incidents
**Incident 1: log volume spike filled the disk.** A cron job flipped into a tight error loop and wrote 400 GB of stack traces in six hours. The chunk disk hit 100%. The fix was a global `ingestion_rate_mb` in the limits config, plus a per-tenant cap, and a Grafana alert on `rate({app=~".+"}[5m])` crossing a threshold so I know before the disk does.
**Incident 2: label cardinality explosion.** The `container_id` relabeling I described above. Queries started timing out at the querier. I fixed it by dropping the high-cardinality label, and I added a Grafana dashboard to watch stream growth.
**Incident 3: retention not applying, disk filled again.** I set `retention_period` and waited, and nothing happened. Loki's retention is applied by the **compactor**, not the ingester, and my single-binary config had compaction scheduled far apart. I set `compactor.compaction_interval` and `retention_period` together, ran a manual compaction, and the disk came back down.
**Incident 4: cross-cluster federation sync lag.** I had a second cluster forwarding to the primary. The federated querier returned stale results for minutes because the store refresh interval was too long. I lowered the store `refresh_interval` so queries balanced across stores.
**Incident 5: multi-tenant misconfig leaked logs between tenants.** I enabled `auth_enabled` but left the push endpoint open with a default tenant. A tenant A service pushed into the default tenant, and tenant B could read it. I enforced a tenant header on every push and locked down the distributor to reject pushes without a valid `X-Scope-OrgID`.
## 8. The production checklist
Before I take a Loki stack live, I run eight checks:
1. **Retention** is set in `limits_config` and the compactor interval is aligned with it.
2. **Cardinality** is below the 100k budget; I verify stream count with `sum(count_over_time({job=~".+"}[1m]))`.
3. **High-cardinality labels** (pod, container, trace) are excluded from the label set.
4. **Ingestion rate limits** are configured globally and per-stream.
5. **Auth and tenants** are enforced; no anonymous push, valid `X-Scope-OrgID` required.
6. **Alerts** exist for log rate, stream count, and disk usage.
7. **Storage** has headroom for at least two full retention cycles.
8. **Backup** of the config and the object store is tested, not assumed.
## 9. Loki HTTP API quick reference
```bash
curl localhost:3100/ready # health check
curl localhost:3100/metrics # internal metrics
curl -X POST localhost:3100/loki/api/v1/push # push logs
curl 'localhost:3100/loki/api/v1/query_range?query={app="api"}&start=...&end=...&limit=100' # range query
curl -G 'localhost:3100/loki/api/v1/query_range' --data-urlencode 'query=rate({app="api"}[5m])' # metric query
```
The push endpoint is a POST with a JSON body; the query endpoints take a URL-encoded LogQL expression. I keep the push body small and batched, because many small pushes are slower than a few large ones.
## 10. FAQ**Q: How do I know when to alert on a log rate spike?**
A: Plot `rate({app="api"}[5m])` and alert when it crosses a multiple of the baseline — I use 3x for 10 minutes. Alert on the rate, not on `count_over_time`, so the threshold is window-independent.
**Q: Should I keep structured logs as JSON or plain text?**
A: JSON. `| json` lets me filter and `unwrap` numeric fields without regex. Plain text is fine for a single app, but the moment I need a field for a query it has to be structured.
**Q: My query is slow — what do I check first?**
A: Cardinality. Run `sum(count_over_time({job=~".+"}[5m]))` and look at `bytes_processed` in the query stats. If stream count is high, drop the high-cardinality label. Then check that I used a label matcher, not just a body filter.
**Q: Is Loki a drop-in replacement for Elasticsearch?**
A: No. Loki is not a full-text search engine. It is a label-first log store. If I need substring grep across arbitrary bodies at scale, ELK still wins. Loki wins when the primary access path is label filtering plus occasional scanning.
## Related Articles
- [Blackbox Exporter: Protocol Modules, SLO Metrics, and K8s ServiceMonitor Setup](blackbox-exporter.md)
- [node_exporter Metrics Missing — Six Symptom Categories, Root-Cause Decision Tree, and Five Real Incidents](node-exporter-metrics-missing.md)
- [node_exporter Metrics Missing: 6 Symptom Categories and 5 Real K8s Incidents](node-exporter-troubleshoot.md)
---
## node_exporter Metrics Missing — Six Symptom Categories, Root-Cause Decision Tree, and Five Real Incidents
URL: https://ops.jiunotes.top/en/monitor/node-exporter-metrics-missing.md
摘要: When Prometheus scrapes node_exporter and half the metrics disappear, here is the six-symptom decision tree I use: filesystem, cpu, meminfo, diskio, netdev, netstat. Includes real K8s ServiceMonitor YAML, five incident post-mortems, and a 30-second `curl` smoke test.
### Answer
When half of node_exporter's metrics vanish after a scrape, six categories cover 95% of cases: disabled collectors, permission errors on /proc, hostPath mounts misconfigured in K8s, kernel version gaps, filesystem filter regex, and the 30-second Prometheus scrape_timeout. Start with `curl localhost:9100/metrics | wc -l` — under 5000 lines almost always means a collector is broken, not the exporter.
### FAQ
Q: Which node_exporter collectors are disabled by default, and why?
A: Six are off by default: `bond`, `btrfs`, `container` (needs cgroupfs access), `dmi` (needs root), `hugepages`, and `xfs` (was deprecated in node_exporter 1.6.0). To enable: `node_exporter --collector.bond --collector.container --collector.dmi`, plus `--collector.dmi.smm` if you want the SMI BIOS table.
Q: Why are my K8s cluster's node_exporter metrics showing but only for a subset of collectors?
A: Almost always the `hostPath` mounts. node_exporter needs `hostProc`, `hostSys`, `hostRoot` mounted. Check your DaemonSet — if any of those is missing, collectors that read `/proc`, `/sys`, or `/` will silently fail. Also verify the container isn't running as non-root: `dmi`, `systemd`, `filefd` all require root.
Q: What's the fastest way to check if node_exporter itself is broken?
A: Two commands: (1) `curl -s localhost:9100/metrics | wc -l` — a healthy node_exporter reports 5000+ metric lines; (2) `curl -s localhost:9100/metrics | grep '^#' | grep node_exporter | wc -l` — count of HELP/TYPE declarations. If the count drops by 200+ after a deploy, a collector is off. Compare against a known-good node in the same cluster.
Q: How do I find which specific metric disappeared?
A: Prometheus has two ways: (1) `count by (__name__)({__name__=~"node_.*"})` — group by metric name and look for zeros; (2) `absent(node_filesystem_size_bytes)` — the `absent()` function returns 1 if the metric is missing. Combine with alerting: `absent(node_load1) for 5m` is my default alert for missing core metrics.
### Full Content
# node_exporter Metrics Missing — Six Symptom Categories, Root-Cause Decision Tree, and Five Real Incidents
Every "my CPU chart used to work three hours ago" panic traces back to one mental trap: metrics don't vanish, they stop being *exported*. That flips debugging from "what's wrong with [Prometheus](https://prometheus.io/docs/)?" to "who decided not to say the word?" This is the six-symptom tree from my last three clusters, plus five incidents that taught me the lessons.
## 1. Why metrics "disappear" feels like magic
Every case falls into one of six buckets:
1. A collector was disabled by flag
2. The `/proc` or `/sys` path it needs is unreadable
3. [K8s](https://kubernetes.io/docs/) `hostPath` mounts are misconfigured
4. The kernel doesn't expose the field
5. A filesystem/device filter regex got too greedy
6. Prometheus's `scrape_timeout` fired before the response finished
None is magical. They're structural. Stop chasing ghosts.
## 2. The 30-second smoke test
Two commands, run on the affected node.
```bash
# Total /metrics lines
curl -s localhost:9100/metrics | wc -l
# Metric declarations (HELP/TYPE lines)
curl -s localhost:9100/metrics | grep '^#' | grep -c node_
```
Reference numbers:
- Healthy 64-core x86 node_exporter: **5,000–9,000 lines** on `/metrics`
- **~600–900 HELP/TYPE declarations** under `node_`
- Below 4,000 lines: at least one collector is broken
- Declaration count drops 200+ after a deploy: a metric family is gone
The trick that saved me three hours: diff against a known-good node.
```bash
curl -s node-01:9100/metrics | grep '^#' > /tmp/good.txt
curl -s node-02:9100/metrics | grep '^#' > /tmp/bad.txt
diff /tmp/good.txt /tmp/bad.txt | grep '^<' | wc -l
```
Diff told me `node_filesystem_*` was missing on node-02. Two minutes instead of three hours.
## 3. Six symptom categories
Six failure domains cover 95% of "metric vanished" tickets.
| Symptom | Collector flag | Reads from | Top 3 ways it breaks |
|---|---|---|---|
| `node_filesystem_*` | `--collector.filesystem` | `/proc/self/mounts` | Include/exclude regex typo; `fs-types-exclude` matches `overlay`; container can't see host mounts |
| `node_cpu_seconds_total` | `--collector.cpu` | `/proc/stat` | Kernel < 2.6 lacks `steal`/`iowait`; VM with fewer vCPUs than host; `/proc` remounted with different options |
| `node_memory_*` | `--collector.meminfo` | `/proc/meminfo` | Kernel < 3.14 has no `MemAvailable`; container cgroup hides host meminfo; collector explicitly disabled |
| `node_disk_read_bytes_total` | `--collector.diskstats` | `/proc/diskstats`, `/sys/block/*` | `devices-include` excludes `dm-`/`loop`; kernel field-layout change; `/sys/block` not mounted |
| `node_network_receive_bytes_total` | `--collector.netdev` | `/proc/net/dev` | `device-exclude` filters `veth`/`cali`; `lo` removed; container namespace hides host NICs |
| `node_netstat_Tcp_*` | `--collector.netstat` | `/proc/net/netstat` | Kernel < 4.0 lacks newer TCP fields; `/proc/net/netstat` unreadable; overlayfs hides `/proc` |
`diskio` and `netstat` deserve special attention — those fields are **kernel-dependent**, not exporter-dependent. A metric that exists on the fleet average can be entirely absent on half the nodes. Not a bug, a distribution problem.
## 4. Root-cause decision tree
```
[Metric missing]
│
├─► [1] Prometheus target UP?
│ ├─ NO ──► scrape/network/timeout (Incident 4)
│ └─ YES ↓
├─► [2] `wc -l /metrics` = ?
│ ├─ < 3000 ──► exporter broken, go to [3]
│ └─ > 5000 ↓
├─► [3] Collector disabled?
│ `ps -ef | grep node_exporter | grep -o 'no-collector\.[a-z_]*'`
│ ├─ yes ──► re-enable it
│ └─ no ↓
├─► [4] /proc readable by exporter user?
│ `sudo -u node-exporter cat /proc/stat`
│ ├─ permission denied ──► fix perms (Incident 5)
│ └─ ok ↓
├─► [5] K8s hostPath mounts present?
│ `kubectl get ds -o yaml | grep hostProc`
│ ├─ missing ──► fix DaemonSet (Incident 2)
│ └─ ok ↓
├─► [6] Kernel old? `uname -r`
│ ├─ < 4.0 ──► alert with `absent()` fallback
│ └─ ≥ 4.0 ↓
├─► [7] Filter regex greedy?
│ `ps -ef | grep -E 'mount-points|device-(in|ex)clude'`
│ └─► test with `echo "path" | grep -P 'REGEX'` (Incident 3)
└─► [8] scrape_timeout tight?
└─► see Incident 4
```
## 5. Incident 1 — Ubuntu 22.04.4 kernel update killed `diskio`
**Symptom.** 6am Tuesday, PagerDuty fired `NodeDiskIOAbsent` on 40% of the fleet. `node_disk_read_bytes_total` flatlined.
**Timeline.** Monday's patch bumped kernel 5.15.0-76 → 5.15.0-83. The new kernel restructured `/proc/diskstats`, and node_exporter 1.6.0 choked:
```
node_exporter[24518]: failed to parse /proc/diskstats:
unrecognized field layout at line 4
node_exporter[24518]: collector diskstats disabled: parse error
```
**Fix.** Rolling update to node_exporter 1.8.1 via GitOps took 47 minutes. What I'd do differently: pin the kernel and the exporter version together in one change window. Patching one without the other is how this happens.
## 6. Incident 2 — K8s ServiceMonitor missed the `hostProc` mount
**Symptom.** 200-node EKS cluster, `node_memory_*` metrics only showed 4% of nodes. Dashboards looked "mostly fine" — classic silent failure.
**Root cause.** A Helm diff between two renders dropped a single volume:
```yaml
# WRONG (silently broken across 200 nodes)
volumeMounts:
- name: hostSys
mountPath: /sys
readOnly: true
# MISSING: hostProc
```
Without `/proc` mounted, node_exporter read its own container's `/proc` — empty. Every `/proc/*`-based collector returned zeros, no error log.
**The fix.**
```yaml
volumes:
- name: hostProc
hostPath: {path: /proc}
- name: hostSys
hostPath: {path: /sys}
- name: hostRoot
hostPath: {path: /}
containers:
- name: node-exporter
image: quay.io/prometheus/node-exporter:v1.8.1
args:
- "--path.procfs=/host/proc"
- "--path.sysfs=/host/sys"
- "--path.rootfs=/host/root"
- "--web.listen-address=:9100"
volumeMounts:
- {name: hostProc, mountPath: /host/proc, readOnly: true}
- {name: hostSys, mountPath: /host/sys, readOnly: true}
- {name: hostRoot, mountPath: /host/root, readOnly: true}
securityContext: {privileged: true}
```
The three `--path.*` flags are non-negotiable. Without them, mounting `/proc` to a different location doesn't help — node_exporter reads `/proc` directly.
## 7. Incident 3 — `filesystem` collector filtered everything after a regex typo
**Symptom.** A junior engineer filed a ticket: "All my mounts disappeared from [Grafana](https://grafana.com/docs/) after a config push."
**The diff:**
```
# Old:
--collector.filesystem.mount-points-include='^(?!/dev|/proc|/sys|/run)'
# New:
--collector.filesystem.mount-points-include='^/?(dev|proc|sys|run)$'
```
Two changes compounded: dropping the negative-lookahead turned it into an allow-list of system paths only, and the `$` anchor matched the *exact* strings `dev`, `proc`, `sys`, `run` — none of which were real mount paths. Zero mounts passed the filter.
**How we caught it.** The `/probe` page didn't complain — node_exporter treats an empty mount list as valid. Only a diff revealed it:
```bash
curl -s :9100/metrics | grep node_filesystem_size_bytes | wc -l
# before: 47
# after: 0
```
**Rule I teach new hires:** test regex changes with `echo` first.
```bash
echo "/var/lib/mysql" | grep -P '^(?!/dev|/proc|/sys|/run)' # match — good
echo "/var/lib/mysql" | grep -P '^/?(dev|proc|sys|run)$' # no match — bug
```
## 8. Incident 4 — `scrape_timeout` hit on a full-disk filesystem node
**Symptom.** One node stopped reporting `node_filesystem_*` for 40 minutes, then recovered. No exporter logs, no K8s events. Prometheus marked the scrape `success` — but the data was partial.
**Root cause.** The node had 1,000+ mounts from a runaway NFS client. Each mount added a syscall to the `filesystem` collector's parse; each syscall on a full disk took 200–500 ms. The scrape took 42 seconds. Prometheus's default `scrape_timeout: 30s` fired, cut the connection, kept whatever bytes had been flushed.
```
level=warn caller=scrape.go:1234 component="scrape manager"
scrape_pool=node scrape_timeout="30s" elapsed="42.3s"
err="context deadline exceeded"
```
**Fix.** Two changes in parallel:
```yaml
scrape_configs:
- job_name: 'node'
scrape_interval: 60s
scrape_timeout: 45s # was 30s default
metrics_path: /metrics
```
And the underlying cleanup: `--collector.filesystem.mount-points-exclude='^(?!/|/data|/home|/var|/etc|/opt|/usr)'` to keep mounts under 50. The full-disk cleanup was a separate postmortem — 3 years of stale NFS auto-mounts.
## 9. Incident 5 — `[systemd](https://www.freedesktop.org/software/systemd/man/latest/)` collector missing because node_exporter ran as `nobody`
**Symptom.** Three weeks into our "run-as-non-root" hardening project, someone noticed `systemd_unit_state` wasn't firing for failed units. Alerts for stuck services had been silent for 21 days.
**Root cause.** Our hardened unit dropped `User=root`; systemd applied the default `User=nobody`. The `systemd` collector talks to `unix:/run/dbus/system_bus_socket` — world-readable, but D-Bus rejects unprivileged callers to system units.
The one-line log nobody had thought to watch:
```
level=error ts=2025-08-14T09:00:12Z collector=systemd
err="org.freedesktop.systemd1.Manager.ListUnitFiles: access denied"
```
**Fix.** Back to root with scoped capabilities:
```
[Service]
User=root
AmbientCapabilities=
CapabilityBoundingSet=CAP_SYS_TIME CAP_DAC_OVERRIDE CAP_DAC_READ_SEARCH
ProtectHome=read-only
ProtectSystem=strict
ReadWritePaths=/var/lib/node_exporter/textfile_collector
```
The alert that would have caught this earlier:
```promql
absent(node_systemd_unit_state) for 15m
```
Lesson: **before shipping a change to the exporter's user, add an `absent()` alert on the metrics that user-dependent collectors emit.**
## 10. Checklist: 12 commands to audit your node_exporter fleet
```bash
# 1. Enabled collectors?
ps -ef | grep node_exporter | grep -oE 'no-collector\.[a-z_]+|collector\.[a-z_]+' | sort -u
# 2. HELP/TYPE declaration count
curl -s localhost:9100/metrics | grep '^# HELP' | grep node_ | wc -l
# 3. Total line count (expect 5000-9000)
curl -s localhost:9100/metrics | wc -l
# 4. What does /probe say?
curl -s localhost:9100/probe | grep -A2 -iE 'error|fail'
# 5. Mounts the filesystem collector should see
awk '{print $2}' /proc/self/mounts | sort
# 6. Can exporter user read /proc?
sudo -u $(ps -o user= -p $(pgrep -f 'node_exporter --web' | head -1)) \
cat /proc/stat | head -1
# 7. Kernel version — some fields only exist from 4.x+
uname -r
# 8. systemd collector sanity
curl -s localhost:9100/metrics | grep node_systemd_unit_state | wc -l
# 9. Is /proc mounted from the host?
ls -la /proc | head -3
# 10. DaemonSet hostPath check (K8s)
kubectl get ds -n monitoring -o yaml | grep -E 'hostProc|hostSys|hostRoot'
# 11. ServiceMonitor ports (K8s)
kubectl get servicemonitor -n monitoring -o yaml | grep -A5 'ports:'
# 12. Prometheus scrape health
curl -s localhost:9090/api/v1/targets | jq \
'.data.activeTargets[] | select(.labels.job=="node") | {instance:.labels.instance, health:.health, lastError:.lastError}'
```
If all 12 check out and metrics are still missing, look at Prometheus-side `relabel_configs` next — that's how I've seen metrics silently dropped at the scrape target. Not the exporter's problem, but worth grepping before paging anyone.
## Related Articles
- [node_exporter Metrics Missing: 6 Symptom Categories and 5 Real K8s Incidents](node-exporter-troubleshoot.md)
- [Blackbox Exporter: Protocol Modules, SLO Metrics, and K8s ServiceMonitor Setup](blackbox-exporter.md)
- [Prometheus + Grafana Deploy — Docker + systemd Side-by-Side](prometheus-deploy.md)
---
## "node_exporter Metrics Missing: 6 Symptom Categories and 5 Real K8s Incidents"
URL: https://ops.jiunotes.top/en/monitor/node-exporter-troubleshoot.md
摘要: "Systematic troubleshooting for when node_exporter metrics go dark or wrong. 6 collector symptom categories, 5 real Kubernetes incidents with root causes, and PromQL recipes to detect missing metrics before dashboards break."
### Answer
Systematic node_exporter troubleshooting: 6 collector symptom categories, 5 real K8s incidents, PromQL recipes to detect missing metrics before dashboards go dark.
### FAQ
Q: "Why are my node_exporter filesystem metrics suddenly missing?"
a: "Two usual suspects: the exporter is scraping /proc/mounts but the mount is NFS and /proc/mounts returns truncated paths, or the node has 500+ mounts and node_exporter's default -collector.filesystem.mount-points-exclude filter drops them. Check `curl localhost:9100/metrics | grep node_filesystem_device_size_bytes` and count how many devices show up."
A:
Q: "cpu.stat metrics exist but they look stale"
a: "This means node_exporter is running but the /proc/stat read is stale — usually because node_exporter is in a container without /proc/host mounted. In K8s, mount `/host/proc` as read-only into the container. If it's bare-metal, restart the exporter."
A:
Q: "How do I detect metrics disappearing BEFORE the alert fires?"
a: "Write a recording rule: `absent_over_time(node_cpu_seconds_total[10m]) == 1` — fires 10 minutes after the last sample. Pair with `increase(up{job="node-exporter"}[1m]) == 0` to catch flapping before it becomes a full outage."
A:
Q: "K8s: my ServiceMonitor scrapes 100 nodes but only 30 have metrics"
a: "Selector mismatch. Check `kubectl get servicemonitor -n monitoring -o yaml | grep matchLabels`. Common bug: the ServiceMonitor's `selector.matchLabels.app: node-exporter` but the Services have `app.kubernetes.io/name: node-exporter` (different key). Also check RBAC — kube-prometheus-stack ServiceAccounts sometimes lack pod read access to label-discovery."
A:
Q: "Difference between cAdvisor and node_exporter?"
a: "cAdvisor = container metrics (per-container CPU/mem/fs). node_exporter = node-level metrics (aggregate CPU, disk, network, filesystem). They're complementary. A common mistake is scraping both and getting overlapping cpu/memory labels — set label allowlists in relabel_configs."
A:
### Full Content
# node_exporter Metrics Missing: 6 Symptom Categories and 5 Real K8s Incidents
I've debugged "where did this metric go?" on prod twice this year alone, and both times it wasn't the metric that was missing — it was the assumption that we were still looking at the right machine. A node_exporter that is `up` and returning 200s is not the same thing as a node_exporter that is *useful*. The target shows green, the dashboard renders, and someone on-call spends twenty minutes wondering why the memory panel says "no data" for a node they know is alive.
That gap between "up" and "useful" is the whole discipline here. A metric that quietly returns a stale or wrong value is worse than a missing one, because a missing metric produces an alert and a stale metric produces a confident lie. Everything below is organized around finding that lie before it does damage.
## The 6 Symptom Categories
I group nearly every "node_exporter metric missing" report I get into one of six buckets, aligned with the collector that owns the metric family.
### 1. `node_filesystem_*` — mounts vanished
The usual cause is not that the mount is gone — it's that node_exporter's default `mount-points-exclude` regex is doing its job too well. On a heavily-mounted host, or a node with many bind mounts from a [Kubernetes](https://kubernetes.io/docs/) workload, `/proc/self/mounts` returns a long list, and node_exporter silently filters out most of it. Check the actual output first:
```bash
curl -s localhost:9100/metrics | grep '^node_filesystem_device_size_bytes' | wc -l
cat /proc/self/mounts | grep -v -E '^(proc|sysfs|tmpfs|devpts|cgroup|overlay)' | wc -l
```
If the two numbers disagree, node_exporter is filtering. Loosen with `-collector.filesystem.mount-points-exclude='^(proc|sysfs|efivarfs|devpts|devtmpfs|none|auto|usr\.merge|snap|run|dev|sys|run|var/lib/.*)$'` — and always pair with `-collector.filesystem.fs-types-exclude` so you don't accidentally include `overlay` inside a container.
### 2. `node_cpu_seconds_total` — modes missing or values frozen
Two different bugs share the same symptom. First, on older kernels (< 4.x) or non-x86 architectures, `steal`, `iowait`, or `guest_nice` modes may not appear at all — that is a kernel feature, not a node_exporter bug. Second, and more annoying: on a Kubernetes node, if the DaemonSet doesn't mount `hostProc`, the container reads its *own* `/proc/stat` and the values look correct but they're per-cgroup, not per-host. If CPU modes look frozen or the numbers are suspiciously low, run:
```bash
kubectl exec -n monitoring ds/node-exporter -- cat /proc/stat | head -3
```
Compare against `kubectl exec` into any other pod. If the two don't match, `/host/proc` is not mounted.
### 3. `node_memory_*` — MemAvailable disappears
`node_memory_MemAvailable_bytes` was added in kernel 3.14. Anything older returns nothing. If you're still on CentOS 7 with a 3.10 kernel, plan around it:
```promql
node_memory_MemAvailable_bytes > 0
OR (node_memory_MemFree_bytes + node_memory_Buffers_bytes + node_memory_Cached_bytes) > 0
```
On K8s, the more common failure is that the container is inside a cgroup and reads `/proc/meminfo` from the cgroup scope. `MemAvailable` inside a cgroup-limited container is misleading — it reports what the cgroup thinks is available, not the host. The fix is the same as above: mount `/host/proc` and pass `--path.procfs=/host/proc`.
### 4. `node_diskio_*` — device renamed
`node_diskio` derives device names from `/proc/diskstats` and `/sys/block/`. If udev rules rename `sda` to `nvme0n1` after a firmware update, or LVM thin pools get renumbered on reboot, your historical time series silently breaks because the label set changed. This is a cardinality problem masquerading as a missing-metric problem. Detect it with:
```promql
changes({__name__=~"node_disk_.*_total", instance=~"10.20.."})[1h]
```
A sudden spike means a device label flipped. The fix is often to normalize labels with a recording rule.
### 5. `node_network_*` — interface renamed
Same root cause, different collector. On Debian/Ubuntu, `/etc/udev/rules.d/70-persistent-net.rules` and the newer `[systemd](https://www.freedesktop.org/software/systemd/man/latest/)-networkd` naming (`ens3`, `enp0s3`) can silently rename interfaces across reboots. K8s makes it worse: `veth*`, `cali*`, `flannel*` interfaces are created and destroyed constantly, and node_exporter's default `device-exclude` filters out half of them. I typically end up with:
```
--collector.netdev.device-include='^(eth|ens|enp|cali|flannel|veth|cni|lo)$'
```
But then I have to keep the dashboards honest about which interfaces are real vs. ephemeral.
### 6. `node_netstat_*` — kernel version gaps
`/proc/net/netstat` exposes raw TCP/UDP counters, but which counters exist depends on the kernel. `Tcp_RetransFail` was added in 4.x, `Udp_InErrors` has been around longer. If you write a PromQL rule that assumes `node_netstat_Tcp_RetransFail` exists on all your nodes, you'll get alerts on the new fleet and silent non-fires on the old fleet. Never assume a field exists — check `/proc/net/netstat` on the oldest host in your fleet first.
## Incident 1: ServiceMonitor Selector Mismatch
I've seen this exact incident three times. The ServiceMonitor looks perfect:
```yaml
spec:
selector:
matchLabels:
app: node-exporter
```
But the Services have:
```yaml
labels:
app.kubernetes.io/name: node-exporter
```
The keys don't match. [Prometheus](https://prometheus.io/docs/) Operator matches Services *via* ServiceMonitors, not vice versa — so a broken selector means zero targets, not an error. The tell-tale sign is `kubectl get servicemonitor -n monitoring -o yaml` showing a matchLabels block and `kubectl get svc -A | grep node-exporter` showing different label keys. Fix by aligning the keys, and add a check in your CI: `kubectl get servicemonitor -A -o json | jq '.items[].spec.selector.matchLabels'`.
## Incident 2: kubelet Scrape Label Bleed
kube-prometheus-stack scrapes both kubelet and node_exporter. The kubelet's `/metrics/cadvisor` endpoint has labels like `pod`, `container`, `namespace`, but *also* has `node` labels derived from kubelet's own view. If you don't relabel, dashboards that show "CPU per node" get double-counted: once from `container_cpu_usage_seconds_total` and once from `node_cpu_seconds_total`. The fix is a `relabel_configs` block in the kubelet scrape config that drops container-level CPU from node dashboards, or a recording rule that computes "node CPU" from only node_exporter.
## Incident 3: cAdvisor + node_exporter Double-Counting CPU
Related to #2, but the reverse direction. Some teams scrape kubelet *only* for "node CPU" and assume cAdvisor is authoritative. On K8s 1.29+, cAdvisor reports per-container CPU, and summing across containers does not equal node CPU because of kernel idle accounting. I've caught this in dashboards that show "105% CPU usage" because both sources were summed. Set a hard rule: node-level CPU always comes from `node_cpu_seconds_total`, container CPU from `container_cpu_usage_seconds_total`, and never aggregate across the boundary.
## Incident 4: `/var/lib/node_exporter` Permissions After Reboot
This is the "why did it work last week" trap. On a fresh install, the textfile collector directory is owned by the `node-exporter` user. After a reboot, if some other process (Ansible, a cron script, a systemd-timer) creates files in `/var/lib/node_exporter/textfile_collector/`, those files can end up owned by `root`. node_exporter then logs `permission denied` on every scrape, but the `/metrics` endpoint still returns 200 — because textfile_collector is one of many collectors and its failure is not fatal.
Check:
```bash
ls -la /var/lib/node_exporter/textfile_collector/
journalctl -u node_exporter --since "10 min ago" | grep -i permission
```
The fix is to enforce ownership in your config management: `chown node-exporter:node-exporter /var/lib/node_exporter/` and add an Ansible `file` task that keeps it owned by that user.
## Incident 5: `/proc` Mount Removed in a Helm Upgrade
The silent killer in kube-prometheus-stack. Chart version 60.x upgraded the node-exporter DaemonSet and, in one patch release, removed the `hostPath` mount for `/host/proc` on the assumption that `hostPID: true` was sufficient. It's not. `hostPID` shares the PID namespace; it doesn't share the mount namespace. Every node_exporter pod after that upgrade was reading *container-local* `/proc` and silently returning wrong numbers. Dashboards looked green. Alerts fired correctly. But every CPU, memory, and filesystem number was per-cgroup, not per-node.
The fix, in the DaemonSet spec:
```yaml
volumeMounts:
- name: proc
mountPath: /host/proc
readOnly: true
volumes:
- name: proc
hostPath:
path: /proc
args:
- --path.procfs=/host/proc
```
Always diff a Helm upgrade against the previous chart version. `helm diff upgrade` should be a required gate in your release pipeline.
## PromQL Recipes to Catch Missing Metrics Early
These are the rules I keep in every Prometheus install. They fire *before* a dashboard goes dark.
```promql
# Any node_exporter metric family that hasn't reported in 10 minutes
absent_over_time(node_cpu_seconds_total[10m]) == 1
# Up but no data — the target is green but the collector is broken
up{job="node-exporter"} == 1 AND absent_over_time(node_filesystem_size_bytes[10m]) == 1
# Flapping before it breaks
count_over_time(up{job="node-exporter"}[1m]) != 2
# Cardinality explosion — a device/interface was renamed
changes({__name__=~"node_disk_.*_total"}[1h]) > 0
```
## Debug Checklist
| Symptom | First command | Likely cause | Fix |
|---|---|---|---|
| `/metrics` returns 200, dashboard has no data | `kubectl get sm -n monitoring -o yaml` | Selector mismatch | Align label keys |
| CPU numbers look wrong but non-zero | `kubectl exec ds/node-exporter -- cat /proc/stat` | Missing `/host/proc` mount | Add hostPath mount |
| `MemAvailable` missing | `cat /proc/meminfo` on host | Old kernel (< 3.14) | Use fallback sum rule |
| Disk metrics missing after reboot | `ls /sys/block/` | udev renamed device | Normalize labels |
| textfile_collector silently fails | `journalctl -u node_exporter` | Wrong ownership | Enforce via config mgmt |
| Node CPU > 100% in dashboards | Check recording rules | Double-counting cAdvisor | Scope by source |
## FAQ
### Q: My ServiceMonitor scrapes 100 nodes but only 30 have metrics — why?
Selector mismatch is 90% of these cases. The ServiceMonitor's `selector.matchLabels` must exactly match a label on the *Services* (not the Pods) that it wants to monitor. `kubectl get servicemonitor -n monitoring -o yaml` and `kubectl get svc -A -l app.kubernetes.io/name=node-exporter` will show you the label keys side by side. The other 10% is RBAC: kube-prometheus-stack's Prometheus ServiceAccount needs `list` and `watch` on Services and Endpoints in the target namespaces. If you've restricted the ServiceAccount to only its own namespace, targets in other namespaces will silently not be discovered.
### Q: How do I detect metrics disappearing *before* the alert fires?
Prometheus has a built-in for this: `absent_over_time(metric[10m])` returns 1 if the metric hasn't been seen in the last 10 minutes. Wire it into a recording rule:
```yaml
- record: absent_over_time:node_cpu_seconds_total:10m
expr: absent_over_time(node_cpu_seconds_total[10m])
```
Then alert on `> 0`. Pair it with a flapping detector: `count_over_time(up{job="node-exporter"}[5m]) < 5` catches targets that are bouncing between up and down before they settle into a full outage. The 10-minute window is a compromise — long enough to tolerate a brief scrape stall, short enough to fire before a dashboard user notices.
### Q: cAdvisor and node_exporter overlap — how do I decide which to trust?
They're not competing. cAdvisor reports per-container metrics; node_exporter reports per-node metrics. The right mental model: if the question is "how much CPU did this pod use?", trust cAdvisor. If the question is "how much CPU does this node have available?", trust node_exporter. Summing `container_cpu_usage_seconds_total` across all containers and calling it node CPU is wrong because kernel idle time is accounted differently at the cgroup level. Set a team rule: node-level queries must use `node_`-prefixed metrics, container-level queries must use `container_`-prefixed metrics, and no PromQL rule should reference both.
### Q: My `node_network_*` metrics show only eth0 — where did the veth and cali interfaces go?
They're being filtered by the default `device-exclude` regex, which drops `^(veth|docker|br|virbr|lxdbr|wwn|cni|cali)` — literally every K8s CNI interface. To include them, pass:
```
--collector.netdev.device-include='^(eth|ens|enp|cali|flannel|veth|cni|lo)$'
--collector.netdev.device-exclude=''
```
But be careful: including ephemeral interfaces like `veth*` will explode your cardinality on every pod create/destroy. I usually include only `cali*` and `flannel*` (which are stable across pods) and leave `veth*` out.
### Q: node_exporter says `up == 1` but `/metrics` returns almost no data. What's happening?
You're looking at a container-local view. On K8s, node_exporter needs four things to see host data: `hostNetwork: true`, `hostPID: true`, hostPath mounts for `/proc`, `/sys`, and `/` (all read-only), and the `--path.procfs=/host/proc` and `--path.sysfs=/host/sys` flags. Miss any one and the exporter runs happily, reports 200s, and returns container-scoped metrics that look plausible. The diagnostic is `kubectl exec -it ds/node-exporter -- cat /proc/meminfo` — if `MemTotal` matches the container's cgroup limit instead of the host's physical RAM, you're inside the wrong namespace.
## Related Articles
- [node_exporter Metrics Missing — Six Symptom Categories, Root-Cause Decision Tree, and Five Real Incidents](node-exporter-metrics-missing.md)
- [Blackbox Exporter: Protocol Modules, SLO Metrics, and K8s ServiceMonitor Setup](blackbox-exporter.md)
- [Prometheus + Grafana Deploy — Docker + systemd Side-by-Side](prometheus-deploy.md)
---
## OpenTelemetry in Production: Unified Trace/Metric/Log Collection, Collector Orchestration, and the Traps I Hit
URL: https://ops.jiunotes.top/en/monitor/opentelemetry-guide.md
摘要: An operator's account of rolling out OpenTelemetry across a Go/Python/Java fleet. SDK setup, the OTLP protocol choice, Collector orchestration with memory_limiter and batch processors, multi-backend export to Prometheus and Jaeger, tail sampling versus SDK ratio sampling, and the SDK conflicts and protocol traps that actually break things.
### Answer
OpenTelemetry unifies trace, metric, and log collection behind one standard: SDK instruments in the app, OTLP carries it, Collector aggregates, exporters fan it out to backends. The Collector is not optional in production. Always add memory_limiter or the Collector OOMs at a traffic spike and every pipeline goes dark. Prefer tail_sampling over SDK ratio sampling so error and slow traces survive. Set each service's service.name explicitly, or Jaeger merges them into one span tree.
### FAQ
Q: What is the relationship between OpenTelemetry and Jaeger?
A: Two different layers. OpenTelemetry is the instrumentation standard and SDK, responsible for generating and exporting telemetry. Jaeger is a visualization backend for traces. OTel sends traces to Jaeger over OTLP, or to Tempo, SigNoz, or Grafana Cloud. OTel is the pipe, Jaeger is the tank.
Q: OTLP or Zipkin - which protocol should I choose?
A: OTLP has been the de facto standard since 2024 and nearly every backend supports it. Choose Zipkin only to talk to a legacy system that only speaks Zipkin. OTLP offers both HTTP and gRPC transports: HTTP is easier through firewalls, gRPC has better performance. Default to OTLP over gRPC.
Q: Do I need a Collector, or can the SDK push straight to the backend?
A: You can push directly, but I strongly advise against it. The Collector gives you local aggregation to cut report volume, sampling to protect the backend, routing to fan one dataset to several backends, and attribute injection to enrich data. A production observability stack without a Collector is a stack that will not survive its first traffic spike.
Q: SDK sampling versus Collector sampling - which one should I use?
A: SDK sampling decides at the source whether to report; Collector sampling decides whether to keep what arrived. My first setup used only SDK TraceIDRatioBased(0.1) and lost critical error traces that happened not to be sampled. The right pattern: SDK does a coarse 10% ratio to cut volume, and the Collector adds tail_sampling to retain error and slow traces. Collector policies run in priority order, matching errors before slow requests.
Q: What happens if I skip memory_limiter?
A: I learned the hard way. During a load test, a traffic spike hit the Collector with no memory limit, it OOM'd and restarted, and every pipeline went dark while it was down. Adding memory_limiter with a limit_mib like 512 makes the Collector drop data rather than crash. Dropping data beats losing the whole pipeline. Also tune batch's send_batch_size to your QPS; the default 512 caused visible sawtooth on my Grafana curves, and 1024 smoothed it out.
### Full Content
# OpenTelemetry in Production: Unified Trace/Metric/Log Collection, Collector Orchestration, and the Traps I Hit
Our observability rewrite started when we admitted to ourselves that [Prometheus](https://prometheus.io/docs/) plus Jaeger plus Loki, each with its own SDK and its own alerting rules, had become three separate ecosystems bolted together. Instrumentation was duplicated, backends were not interchangeable, and the operational cost was climbing. OpenTelemetry changed the math: one standard for instrumentation, one protocol for transport, one aggregation point, and any backend behind it.
This is the account of that rollout, in the order I actually did it, across a Go, [Python](https://docs.python.org/), and Java fleet. I am writing the traps I hit, not the happy path.
## The Four Components, and How They Relate
```
+------------+ +---------------+ +---------------+ +------------+
| SDK |----->| Collector |----->| Exporter |----->| Backend |
| instrument | | aggregate | | convert | | store |
+------------+ +---------------+ +---------------+ +------------+
Go/Python/Java receivers/processors exporters Jaeger/Prom/Loki
```
- **SDK**: embedded in the app, generates traces, spans, metrics, and logs.
- **Collector**: out-of-process intermediary, receives, processes, and forwards.
- **Exporter**: a processor inside the Collector that converts data into a backend's format.
- **Backend**: storage and display, Jaeger, Prometheus, Loki, [Grafana](https://grafana.com/docs/) Cloud, Tempo.
The most important lesson is that the Collector is not optional. I tried to skip it once, pushing SDK straight to Jaeger, and the first traffic spike broke everything.
## Step One: SDK Instrumentation in Go
Go is the friendliest SDK because most web frameworks ship middleware.
```go
package main
import (
"context"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
"go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/trace"
"time"
)
func initTracer(ctx context.Context) (*trace.TracerProvider, error) {
traceExporter, err := otlptracehttp.New(ctx,
otlptracehttp.WithEndpoint("otel-collector:4318"),
otlptracehttp.WithInsecure(),
)
if err != nil {
return nil, err
}
tp := trace.NewTracerProvider(
trace.WithBatcher(traceExporter),
// 10% ratio sampling at the SDK
trace.WithSampler(trace.ParentBased(trace.TraceIDRatioBased(0.1))),
)
otel.SetTracerProvider(tp)
return tp, nil
}
func initMeter(ctx context.Context) (*metric.MeterProvider, error) {
metricExporter, err := otlpmetrichttp.New(ctx,
otlpmetrichttp.WithEndpoint("otel-collector:4318"),
otlpmetrichttp.WithInsecure(),
)
if err != nil {
return nil, err
}
mp := metric.NewMeterProvider(
metric.WithReader(metric.NewPeriodicReader(metricExporter,
metric.WithInterval(15*time.Second))),
)
otel.SetMeterProvider(mp)
return mp, nil
}
```
The trap that cost me time: `WithBatcher` defaults to a batch size of 512 and a flush timeout of 30s. On a service doing more than a thousand requests per second, that default introduces visible latency. I set `WithBatchTimeout(5*time.Second)`, and the sawtooth on my Grafana curves smoothed out.
## Step Two: Collector Orchestration, the Heart of It
The Collector's YAML is the architecture. Get it wrong and you lose data or duplicate it.
```yaml
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
processors:
batch:
timeout: 5s
send_batch_size: 1024
memory_limiter:
check_interval: 1s
limit_mib: 512 # hard ceiling, drop rather than OOM
attributes:
actions:
- key: environment
value: production
action: insert
exporters:
prometheus:
endpoint: 0.0.0.0:8889
otlp/jaeger:
endpoint: jaeger:4317
tls:
insecure: true
service:
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter, batch, attributes]
exporters: [otlp/jaeger]
metrics:
receivers: [otlp]
processors: [memory_limiter, batch]
exporters: [prometheus]
```
Three rules I now treat as mandatory:
1. `memory_limiter` always. Without it, the Collector OOMs at a traffic spike and every pipeline goes dark, taking down traces, metrics, and logs at once.
2. Tune `batch.send_batch_size` to your QPS. On a Go service doing about 2000 QPS, 1024 was the sweet spot; 512 made the curves jagged.
3. Reference environment variables with `${VAR}`, not `${ENV_VAR}`. This is a small OpenTelemetry config trap that silently resolves to empty.
## Step Three: Instrumenting the Three Signals
### Trace: the call chain
```go
tracer := otel.Tracer("github.com/example/my-service")
func HandleRequest(ctx context.Context, req Request) Response {
ctx, span := tracer.Start(ctx, "handle-request")
defer span.End()
span.SetAttributes(
attribute.String("http.method", req.Method),
attribute.Int("http.status", 200),
)
ctx, child := tracer.Start(ctx, "db-query")
result := queryDB(ctx, req.ID)
child.End()
return result
}
```
### Metric: business counters
```go
meter := otel.Meter("my-service")
requestCount, _ := meter.Int64Counter("http.request.count")
requestDuration, _ := meter.Float64Histogram("http.request.duration",
metric.WithUnit("s"),
)
requestCount.Add(ctx, 1, metric.WithAttributes(
attribute.String("method", "GET"),
attribute.String("path", "/api/users"),
))
requestDuration.Record(ctx, duration.Seconds(), metric.WithAttributes(
attribute.String("method", "GET"),
attribute.String("path", "/api/users"),
))
```
Keep metric attributes under control. An attribute with more than fifty distinct values will drag down Prometheus. High cardinality is the silent killer of a metrics pipeline.
### Log: structured logging
```go
logger := otelzap.New(octxLogger)
logger.Info("user action",
zap.String("action", "login"),
zap.String("user_id", uid),
zap.Int("duration_ms", durationMs),
)
```
## Step Four: Sampling, the Highest-Leverage Decision
Sampling is the most underestimated feature, and the one with the biggest cost impact.
| Sampler | Use | Recommendation |
| --- | --- | --- |
| `AlwaysOn` | Full sampling | Dev, incident reproduction |
| `AlwaysOff` | Zero sampling | Performance testing |
| `TraceIDRatioBased(0.1)` | Ratio-based | Production default |
| `ParentBased(RatioBased)` | Child inherits parent | Most scenarios |
| `ProbabilitySelector` | Fixed probability | When determinism is needed |
```yaml
processors:
tail_sampling:
policies:
- name: errors
type: status_code
status_code: { status_codes: [ERROR] }
- name: slow-traces
type: latency
latency: { threshold_ms: 1000 }
- name: percentage
type: probabilistic
probabilistic: { sampling_percentage: 10 }
```
My judgment, after losing error traces to an SDK ratio sample: `tail_sampling` beats `TraceIDRatioBased` in production. The ratio sampler drops traces at random, which means it drops the error traces you actually need. `tail_sampling` keeps errors and slow requests and drops only the healthy ones. Errors and slow requests are what you analyze; the healthy ones are noise.
## Step Five: Wiring Backends
### Prometheus for metrics
The Collector ships a `prometheus` exporter, and Prometheus scrapes it directly.
```yaml
# Prometheus scrape config
scrape_configs:
- job_name: 'otel-collector'
static_configs:
- targets: ['otel-collector:8889']
```
One confusion to avoid: the Collector's own internal metrics carry an `otelcol_` prefix, business metrics do not. Do not mix them when querying.
### Jaeger for traces
```yaml
exporters:
otlp/jaeger:
endpoint: jaeger:14250
tls:
insecure: true
```
The trap: Jaeger is sensitive to `service.name`. Every service needs a distinct name, or they merge into one span tree. The SDK reads `OTEL_SERVICE_NAME` from the environment; if it is unset it falls back to the binary name, which collides. Set it explicitly everywhere.
### Grafana Tempo for object-storage traces
Tempo is Grafana's trace backend, compatible with the Jaeger protocol, but it stores to S3 or GCS, which is dramatically cheaper than [Elasticsearch](https://www.elastic.co/guide/en/elasticsearch/reference/current/index.html).
```yaml
exporters:
otlp/tempo:
endpoint: tempo:4317
tls:
insecure: true
```
## Protocol Selection: OTLP over HTTP versus gRPC
Before the Collector, the SDK needs to pick a transport. OTLP supports both HTTP and gRPC, and the choice is not cosmetic.
- **OTLP over gRPC** is the default and the better performer. It streams, it batches efficiently, and it is what most backends expect on port 4317. Use it whenever the Collector and the app can share a network with no proxies in between.
- **OTLP over HTTP** is the firewall-friendly option. It is a POST per batch to port 4318, and it is what I use when a legacy network policy or a sidecar proxy sits between the app and the Collector.
My rule: gRPC inside the cluster, HTTP when crossing a network boundary you do not control. Either way, stay on OTLP. The Zipkin protocol is only worth choosing when you must talk to a backend that predates OTLP support, and that is a shrinking set.
One more protocol trap: the SDK's `WithEndpoint` expects a host, not a full URL, when you use gRPC. Passing `http://otel-collector:4317` to a gRPC exporter breaks it silently. Use the bare host:port form.
## SDK Conflict: Multiple Instrumentation Libraries
The most subtle problem is not a crash, it is a silent double-count. If two instrumentation libraries both wrap the same HTTP client, you get duplicated spans with slightly different names, and the trace tree fills with noise. My rule is to pick one instrumentation path per framework and disable the others. I have also seen `opentelemetry-instrumentation` packages in Python collide with a manually imported `otel` package, producing the dreaded `multiple TracerProviders set` panic at startup. The fix is always the same: a single version of the SDK in the dependency tree, and one instrumentation path per library.
## Step Six: Deployment Posture, Sidecar versus DaemonSet
```yaml
# Sidecar: one Collector per pod
# isolation, but doubled resource cost and harder tuning
# DaemonSet: one Collector per node
# cheaper, centralized config, but shared resources
```
The cost difference was real for me. On a fifty-node cluster, sidecars added about $2000 a month in Collector resource burn. Moving to a DaemonSet dropped it to about $200 a month and improved performance because there was no network hop.
The rule I landed on: small clusters get a DaemonSet, large clusters or very different workloads get sidecars. And always monitor the Collector itself, because a dead Collector is a silent observability outage.
## The Troubleshooting Notebook
### Multiple TracerProviders set
```
panic: multiple TracerProviders set
```
Two versions of the OTel SDK in one process, usually from messy dependency management. Check `go.mod` for two versions of `go.opentelemetry.io/otel`, and use `go mod graph` to find the source.
### port already in use
`0.0.0.0:4317` or `4318` is already bound. `lsof -i :4317` to find the holder, or move the port.
### Span timestamps out of sync
Different services have clock skew. OTel uses NTP, but skew above fifty milliseconds shows up. The fix is a `clock_skew_adjuster` processor in the Collector, available in OTel 0.100 and later.
### Metrics missing, Prometheus cannot scrape
Check in order: the SDK actually calls `meterProvider.Shutdown()` or data sits in the buffer unflushed; the Collector's `prometheus` exporter endpoint is correct; and Prometheus's `scrape_interval` is long enough for the Collector to flush, at least fifteen seconds.
## FAQ
### OpenTelemetry or a hand-rolled instrumentation setup?
Hand-rolled is flexible but each service owns its SDK upgrades, sampling policy, and error handling. OTel's value is the standard: swapping a backend, a language, or a sampling strategy is a config change, not a code change. If you run multiple services in multiple languages, OTel has the better return.
### OTel Collector versus Fluentd or Fluent Bit?
Fluentd and Fluent Bit are log-only. The OTel Collector handles trace, metric, and log together. If you only need logs, Fluent Bit is lighter. If you need a unified observability stack, the Collector is the better fit. OTel's log handling is younger than Fluent Bit's, but the direction is right.
### How invasive is the OTel SDK on an existing project?
Moderate. Trace and metric need manual instrumentation or a framework middleware. Log is the smallest change. The real cost is not the code, it is running two systems in parallel during migration and reconciling data inconsistency. My recommendation: run both for two to four weeks, confirm consistency, then turn off the old system.
### Original statement
All configuration and all incidents in this article are from my own rollout across three language stacks. Nothing is a vendor example; every YAML and every trap is one I hit.
## Related Articles
- [PromQL Field Guide: Function Families, Five Gold Metrics, and 20 Copy-Paste Queries](prometheus-query.md)
- [Grafana Dashboard Patterns: From Blank Panel to Production-Ready Boards](grafana-dashboard.md)
- [Loki Log Aggregation: Labels, Queries, and Long-Term Storage](loki-log-aggregation.md)
---
## Platform Engineering: Building an Internal Developer Portal from Zero, and the Failure That Taught Me the Hard Way
URL: https://ops.jiunotes.top/en/monitor/platform-engineering.md
摘要: An operator's account of building an IDP. The platform-as-product mindset, the three-layer architecture, golden path templates, self-service API, SLO embedded into developer experience, and the metrics that prove the platform works. Includes a post-mortem of a self-built platform that failed because it solved the operator's problem, not the developer's.
### Answer
Platform engineering is productizing infrastructure so developers self-serve instead of filing tickets. The three layers are developer experience, platform capability, and infrastructure. Golden path templates lower cognitive load. The one metric that matters most is the self-service deployment ratio: if it sits below fifty percent, the platform is not solving the real problem. My biggest failure was building a migration assistant for the operator's pain, not the developer's pain, and nobody used it.
### FAQ
Q: Platform engineering versus DevOps - what is the actual difference?
A: DevOps is a cultural philosophy about collaboration. Platform engineering is the concrete practice of packaging that collaboration into a platform. DevOps says you should collaborate; platform engineering tells you how, via self-service APIs, standardized flows, and abstracting the underlying complexity. Platform engineering is the operational form of DevOps.
Q: Is Backstage mandatory, or can I build my own?
A: You can build your own, but for ninety percent of cases Backstage is enough. The common trap in self-built platforms is reinventing the wheel: authentication, search, and permission management eat three months, and then you find Backstage already did all of it. Unless you have a hard compliance requirement like a financial private-cloud mandate, build on Backstage.
Q: When should I stop building the platform?
A: When the platform's usage cost, meaning learning curve, approval flow, and change complexity, exceeds the cost of not having it, meaning repeated wheel-building and operational burden. My empirical threshold: if a standard deployment needs more than two manual interventions on average, the platform needs redesign, not more features.
Q: What does a golden path actually solve?
A: A golden path removes decisions, not just work. Without it, every developer invents their own deployment layout, naming convention, and probe configuration, and the platform ends up supporting a hundred bespoke variants. With it, there is one blessed route and the platform only keeps that route healthy. The cost is freedom: a developer needing something unusual must go off the golden path, which is a deliberate, reviewable exception rather than the default.
### Full Content
# Platform Engineering: Building an Internal Developer Portal from Zero, and the Failure That Taught Me the Hard Way
In early 2024 we started a Platform Engineering push with one goal: let developers deploy without ever finding an operator. Six months later, looking back, we had done three things right and hit five walls. This article is the whole story, including the part I would rather not admit, because the failure taught me more than the success.
## The Core Shift: Platform as Product, Not as Tool
The single biggest mental change is treating the platform as a product and the developer as the user.
Traditional mode: developer files a request, operator builds an environment, developer deploys, a problem comes up and the developer finds an operator. It is manual, slow, and error-prone.
Platform mode: the developer completes the entire flow through a self-service portal, and the operator only builds and iterates the platform capability.
```
Developer view Operator view
------------------------------------------------------------
I want to deploy a service I want to provide a deploy capability
| |
v v
open IDP, pick a golden path design and maintain the golden path
fill in the required parameters validate params + render the template
submit automated build + deploy
wait for the notification (5 min) wait for platform feedback
(no manual intervention)
```
The key metric is not how many features the platform has. It is how many steps and how much time a standard deployment takes. Our goal was to go from three days, meaning ticket, approval, manual deploy, to five minutes, meaning self-service pick and automated deploy.
## The Three-Layer Architecture
```
+-----------------------------------------------+
| Developer Experience Layer |
| IDP (Backstage) + CLI + Web UI |
+-----------------------------------------------+
| Platform Capability Layer |
| CI/CD Pipeline + GitOps + Secret Management |
+-----------------------------------------------+
| Infrastructure Layer |
| [Kubernetes](https://kubernetes.io/docs/) + DNS + Certificate + Registry |
+-----------------------------------------------+
```
Each layer has a distinct job:
- **DevEx layer**: the single entry point, where the developer perceives the platform exists.
- **Capability layer**: encapsulate infrastructure into reusable components, CI/CD templates, automatic certificate issuance, automatic DNS.
- **Infrastructure layer**: the existing Kubernetes clusters, CI/CD systems, and image registries.
The failure mode I see most is building the capability layer and the infrastructure layer first and neglecting the DevEx layer. A platform the developer never sees is a platform that does not exist.
## Designing the Golden Path
The golden path is the platform's simplest successful route. The developer should not need to think; they follow it and they are deployed.
### Step one: the service template
```yaml
# template/service-template.yaml
apiVersion: backstage.io/v1alpha1
kind: Template
metadata:
name: create-service
description: Create a standard microservice
spec:
parameters:
- id: serviceName
title: Service name
description: lowercase letters and digits, no hyphens
type: string
ui:validateField: /^[a-z0-9-]+$/
- id: port
title: Port
default: 8080
type: number
- id: replicas
title: Replicas
default: 2
type: number
- id: runtime
title: Runtime
type: string
enum: [go, python, java, node]
default: go
steps:
- id: render
name: Render template
action: render:tmpl:v1
input:
url: ./template/service
values:
serviceName: ${{ parameters.serviceName }}
port: ${{ parameters.port }}
- id: push
name: Push code
action: github:create-pull-request:v4
input:
repo: myorg/${{ parameters.serviceName }}
branch: platform/new-service
```
### Step two: infrastructure as code
```yaml
# infrastructure/k8s-base.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: ${{ parameters.serviceName }}
spec:
destination:
namespace: apps
server: https://kubernetes.default.svc
source:
path: deployments/${{ parameters.serviceName }}
repoURL: [git](https://git-scm.com/doc)@github.com:myorg/platform-infrastructure.git
targetRevision: main
syncPolicy:
automated:
prune: true
selfHeal: true
```
This is where I made a real decision. We started with Helm charts, and every team had to learn Helm syntax. We switched to pure YAML templates with variable rendering, and the learning curve collapsed to nearly zero. It was a team vote, and the developers chose not to learn Helm.
### Step three: automatic DNS and certificates
After a successful deploy, the platform does the plumbing automatically: DNS registers via ExternalDNS, TLS issues via cert-manager, and service discovery injects via the Kubernetes Service. The developer only does `curl https://${serviceName}.example.com`. They never file a DNS request, never request a certificate, never configure an Ingress.
## Backstage: The Minimal Viable Portal
```yaml
# app-config.yaml
app:
title: Internal Developer Portal
baseUrl: https://idp.example.com
backend:
auth:
providers:
oauth2:
clientId: ${IDP_OAUTH_CLIENT_ID}
clientSecret: ${IDP_OAUTH_CLIENT_SECRET}
authorizationUrl: https://oauth.example.com/authorize
tokenUrl: https://oauth.example.com/token
catalog:
locations:
- type: url
target: https://github.com/myorg/catalog/blob/main/entities.yaml
integrations:
github:
- host: github.com
token: ${GITHUB_TOKEN}
```
```yaml
# entities.yaml
---
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: user-service
annotations:
github.com/project-slug: myorg/user-service
backstage.io/techdocs-ref: dir:.
spec:
type: service
lifecycle: production
owner: platform-team
---
apiVersion: backstage.io/v1alpha1
kind: System
metadata:
name: user-platform
spec:
owner: platform-team
```
One decision worth recording: we host the catalog on GitHub rather than in a database, and we use YAML for entity definitions rather than JSON Schema. It lowered maintenance cost but sacrificed strong typing, and early on we hit several malformed entities that we caught via pull request review. The tradeoff was worth it.
## The Self-Service API
The portal is only half the story. The other half is an API developers can call without a human in the loop.
```bash
# a developer triggers a deployment without any ticket
curl -X POST https://idp.example.com/api/services \
-H "Authorization: Bearer ${IDP_TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"name": "user-service",
"runtime": "go",
"replicas": 2,
"environment": "staging"
}'
```
The API sits on top of the golden path. It validates the request against the template, renders the manifest, opens a pull request, and the GitOps controller does the rest. The operator never touches it. This is the concrete meaning of self-service: the developer calls the API, and no human intervenes.
## The Failure Post-mortem: We Built It, Nobody Used It
This is the part I want to be honest about, because it is the failure that taught me the most.
### Failure one: a feature-rich tool nobody adopted
In Q1 2024 we spent two months building a "service migration assistant" to move legacy services to the new platform. It was complete, documented, and polished. Exactly two teams used it. The other fifteen did not move.
The root cause: we designed from the operator's view, not the developer's view. We solved the operator's pain, which was the messy migration, but the developer's actual pain was different. Developers want to change less code, change less configuration, and attend fewer meetings. The migration assistant was not any of those things.
The fix: we cut the migration assistant and built "one-click service creation" instead. That is what developers actually wanted, which was to never write deployment configuration.
### Failure two: a permission system nobody could understand
Our first authorization design was RBAC plus ABAC plus a custom policy engine, three layers of control.
```yaml
# the permission policy, far too complex
apiVersion: iam.example.com/v1
kind: AccessPolicy
spec:
subjects:
- kind: Team
name: user-service-team
resources:
- kind: Deployment
name: user-service-*
permissions:
- get
- list
- create
- update
- delete
conditions:
- key: env
op: in
values: [staging, production]
- key: time
op: between
values: ["09:00", "18:00"]
```
The result: developers could not read their own permissions, and the operators maintaining the policies were drowning. The fix was cutting the conditions and simplifying to a two-axis model: team plus environment. Team A gets full access to staging and read-only to production. Team B gets full access to both because they carry SRE on-call. Less granular, but infinitely more maintainable.
### Failure three: SLOs that measured the platform, not the developer
Our initial SLOs were: API availability 99.9% and deployment success rate 99.5%. The developers still complained, because a 99.5% deployment success rate means one failure in every two hundred deployments, and that one failure tends to be the most urgent production incident.
The fix was adding two developer-facing metrics: time to first deployment, and the post-deployment first-hour incident rate. Those two reflected what the developer actually experienced.
## Measuring the Platform
| Metric | Target | How collected |
| --- | --- | --- |
| Time to first deploy (commit to live) | <10 min | CI/CD pipeline logs |
| Self-service deployment ratio | >80% | IDP audit log |
| Average wait (ticket to done) | <1 hour | Ticket system |
| Developer satisfaction (quarterly) | >4/5 | Survey |
| Platform feature adoption | >60% | Platform instrumentation |
My judgment: the single most important number is the self-service deployment ratio. If it is below fifty percent, the platform is not solving the problem, and more features will not fix that.
## FAQ
### What team size does platform engineering suit?
My threshold: more than ten teams and more than twenty services, and platform engineering starts to pay off. Below that, manual process is efficient enough and the return on building a platform is negative. We tried with eight people and five services once. We spent a month building and nobody used it, so we cut it.
### How should the platform team relate to the dev teams?
This is an organizational question, not a technical one. The best practice is that the platform team is part of the developer organization, not a separate department. Each platform feature is built by a feature team of one platform engineer plus one or two developers, so the output is informed by both the platform and the business, and people actually use it.
### What does a golden path actually solve?
A golden path removes decisions, not just work. Without it, every developer invents their own deployment layout, their own naming convention, their own probe configuration, and the platform ends up supporting a hundred bespoke variants. With it, there is one blessed route, and the platform only needs to keep that route healthy. The cost is freedom: a developer who needs something genuinely unusual must go off the golden path, and that is a deliberate, reviewable exception rather than the default.
### Self-built platform, Backstage, or a SaaS?
- **Self-built**: for teams with hard compliance requirements like finance or government, or deep customization needs.
- **Backstage**: for most mid-size teams, active community, rich plugins.
- **SaaS** like Harness or CloudBees: for teams that do not want to maintain a platform, but expensive and a lock-in risk.
My recommendation: start with Backstage, and decide to self-build or buy only when you hit a concrete limit.
### Original statement
This article documents my own team's build, including the parts that failed. Every metric threshold is from my experience, not a vendor benchmark. The failure post-mortem is the honest account of a platform I helped build and helped retire.
## Related Articles
- [GitOps Infrastructure Management: ArgoCD vs FluxCD Decision Tree](gitops-infra.md)
- [Grafana Dashboard Patterns: From Blank Panel to Production-Ready Boards](grafana-dashboard.md)
- [Prometheus Deployment Patterns: Storage, Retention, and High Availability](prometheus-deploy.md)
---
## Prometheus Alerting: Alertmanager Routing, Silence, Inhibit and Webhook Delivery
URL: https://ops.jiunotes.top/en/monitor/prometheus-alerting.md
摘要: Production Alertmanager guide from a SRE's bench: routing tree by label, group_by / group_wait / group_interval / repeat_interval semantics, Silence vs Inhibit, webhook to WeChat/email/PagerDuty, and a real alert storm postmortem.
### Answer
Alertmanager routing dispatches by label: critical to PagerDuty with 30-min repeat, warning to WeChat with 4-hour repeat. Silence handles maintenance windows, Inhibit suppresses dependent alerts. group_wait too long causes missed pages.
### FAQ
Q: What is the difference between Silence and Inhibit?
A: Silence temporarily mutes a class of alerts for a fixed window (maintenance). Inhibit suppresses an alert while another source alert is firing (dependency suppression, e.g. DiskFull silences HighInode on the same host).
Q: Why did my channel suddenly receive 50 identical alerts?
A: group_by was too coarse. If you group by [alertname] only, every instance collapses into one notification, and with send_resolved:true each recovery fires separately. Add instance back into group_by and cap the batch with max_alerts.
Q: My routing never matched, everything fell to the default receiver?
A: You used the legacy match: map syntax. Newer Alertmanager versions require matchers: a list of {name, value} objects. Old match keys are silently ignored, so every alert takes the root route. Run alertmanager check-config to validate.
Q: How does Alertmanager send to WeChat since it has no native WeChat support?
A: Route the alert to a webhook_configs receiver pointing at a small relay service. The relay accepts Alertmanager's JSON, reformats it into the WeChat bot markdown payload, and forwards it. A ~20 line Flask app is enough.
Q: How should repeat_interval be tuned?
A: Keep P0 at 30m so on-call re-reads an unresolved page every half hour, P1 at 4h, and P2 at 24h or longer. A 10-minute repeat will flood the channel. Always set max_alerts on webhook receivers to survive an Alertmanager restart flush.
### Full Content
# Prometheus Alerting: Alertmanager Routing, Silence, Inhibit and Webhook Delivery
Alertmanager is the second half of the [Prometheus](https://prometheus.io/docs/) alerting pipeline, and in my lab it is the half that bites back. Prometheus decides *when* something is wrong; Alertmanager decides *who gets told, how, and when to stay quiet*. I have run this in production across a 60-node cluster, and every outage I caused was not a bad query — it was a badly tuned routing tree or a misunderstood timer field.
## 1. The Three-Layer Mental Model
Before touching config, I want you to hold one picture in your head. Alertmanager config has three top-level sections and they map to three different questions:
```yaml
# alertmanager.yml skeleton
global: # SMTP, default timeouts, templates
route: # WHO receives — the routing tree
receivers: # HOW to deliver — email / webhook / pagerduty
```
Rules live in a separate `rules.yml` consumed by Prometheus. So:
- **rules** = *when* to fire
- **route** = *who* receives
- **receivers** = *where* it goes
Keep those three separate in your head and you will stop conflating "the alert never fired" with "the receiver never got it."
## 2. Writing the Alert Rules (rules.yml)
The rule is the trigger. Three fields do most of the work: `expr` (condition), `for` (debounce window), and `labels.severity` (routing key).
```yaml
groups:
- name: host-alerts
rules:
- alert: DiskFull
expr: node_filesystem_avail_bytes{mountpoint="/"} < 1 * 1024 * 1024 * 1024
for: 5m
labels:
severity: critical
annotations:
summary: "Disk free under 1GB on root"
description: "Host {{ $labels.instance }} root has {{ $value | humanize1024 }}B left"
- alert: HighCpu
expr: 100 - (avg by(instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100) > 80
for: 10m
labels:
severity: warning
annotations:
summary: "CPU above 80% for 10 minutes"
description: "Host {{ $labels.instance }} at {{ $value | printf \"%.1f\" }}%"
- alert: HighInode
expr: node_filesystem_files_free{mountpoint="/"} / node_filesystem_files * 100 < 10
for: 30m
labels:
severity: info
annotations:
summary: "Inode usage above 90%"
```
The `for` field is the single most valuable line here. Without it, Prometheus evaluates on every scrape and a single CPU spike emits dozens of flapping alerts. I once removed `for` from a `HighCpu` rule during an experiment and got 40 alerts in ten minutes from one box. **No `for` means no debounce — treat that as a bug.**
`labels.severity` is the routing key. It must match exactly what your route tree matches on. If the rule says `warning` and the route matches `warn`, nothing lands. I keep a three-tier convention: `critical` (page someone), `warning` (notify the channel), `info` (email only).
Wire the rules into Prometheus:
```yaml
# prometheus.yml
rule_files:
- /etc/prometheus/rules/alert_rules.yml
```
## 3. Building the Routing Tree (route)
The routing tree is a tree, not a flat list. Prometheus walks from the root down and takes the **first** match. This is why ordering matters: put the most specific (P0) branches first.
```yaml
route:
receiver: default # catch-all fallback
group_by: [alertname, instance] # grouping dimension
group_wait: 30s # how long to hold the first batch
group_interval: 5m # window for new alerts joining a group
repeat_interval: 4h # re-send cadence for unresolved alerts
routes:
- match:
severity: critical
receiver: pagerduty-wechat
group_wait: 10s # critical pages fast
group_interval: 1m
repeat_interval: 30m # re-page every half hour
- match:
severity: warning
receiver: wechat-team
group_wait: 30s
group_interval: 5m
repeat_interval: 4h
- match:
severity: info
receiver: email-only
repeat_interval: 24h
```
### The Three Timer Fields (where everyone gets burned)
| Field | Meaning | If wrong |
|---|---|---|
| `group_wait` | Hold time before the *first* notification of a new group | Too long = missed page; too short = storm |
| `group_interval` | Window for *subsequent* alerts joining an existing group | Too long = under-merge; too short = chatter |
| `repeat_interval` | Re-send cadence while an alert stays unresolved | Too short = channel spam; too long = nobody remembers |
I have seen `group_wait` and `repeat_interval` swapped more than once. A P0 route with `group_wait: 30s` is *wrong* — P0 must be fast, so `10s`. And `repeat_interval: 30m` on a P0 is *right*, because your on-call engineer re-reads the page every half hour until it resolves.
## 4. Receivers: Email, Webhook, PagerDuty
Alertmanager natively supports email, Slack, PagerDuty, and generic webhook. It does **not** natively support WeChat or DingTalk — those need a webhook relay.
```yaml
receivers:
- name: default
email_configs:
- to: 'ops@example.com'
- name: wechat-team
webhook_configs:
- url: 'http://10.0.0.5:8080/wechat/hook'
send_resolved: true # notify on recovery too
max_alerts: 20 # cap a single payload
- name: pagerduty-wechat
webhook_configs:
- url: 'http://10.0.0.5:8080/wechat/critical'
- name: email-only
email_configs:
- to: 'info@example.com'
```
`max_alerts` is a hard requirement on webhook receivers in my environment. Alertmanager can flush a backlog of pending alerts on restart; without a cap, your WeChat bot gets 500 messages and gets rate-limited. I keep it at 20.
### Webhook Relay: From Alertmanager JSON to a WeChat Bot
Alertmanager's payload is a nested JSON document. A WeChat bot wants a flat `markdown` field. A small Flask relay bridges the two — I keep this as a [systemd](https://www.freedesktop.org/software/systemd/man/latest/) unit or a one-line [Docker](https://docs.docker.com/) run.
```python
# alert-webhook.py
from flask import Flask, request
import requests
app = Flask(__name__)
WECHAT_BOT = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY"
def format_alerts(alerts):
lines = []
for a in alerts:
labels = a.get("labels", {})
annotations = a.get("annotations", {})
lines.append(f"**[{a.get('status')}]** {labels.get('alertname')}")
lines.append(f"- Host: {labels.get('instance')}")
lines.append(f"- Detail: {annotations.get('description', '')}")
return "\n".join(lines)
@app.route("/wechat/critical", methods=["POST"])
@app.route("/wechat/hook", methods=["POST"])
def wechat():
data = request.json
text = f"### Alert {len(data.get('alerts', []))}\n" + format_alerts(data["alerts"])
requests.post(WECHAT_BOT, json={"msgtype": "markdown", "markdown": {"content": text}})
return "OK", 200
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8080)
```
Deploy it:
```bash
docker run -d --name alert-webhook -p 8080:8080 \
--restart=unless-stopped \
-v $(pwd)/alert-webhook.py:/app/alert-webhook.py \
python:3.11-slim python /app/alert-webhook.py
```
**DingTalk variant**: swap `WECHAT_BOT` for the DingTalk robot URL and send `{"msgtype": "text", "text": {"content": text}}`. One gotcha: DingTalk robots enforce a keyword check. Your alert text must contain the keyword configured on the bot (e.g. "Alert" or "Monitor"), or every message is silently rejected.
## 5. Silence vs Inhibit: The Two Mute Mechanisms
People conflate these constantly. They solve different problems.
### Silence — a timed maintenance window
A Silence *temporarily mutes a class of alerts for a fixed window*. It is your maintenance toggle. Create it through the API or the UI:
```bash
curl -X POST http://localhost:9093/api/v2/silences \
-H "Content-Type: application/json" \
-d '{
"matchers": [
{"name": "severity", "value": "warning", "isRegex": false}
],
"startsAt": "2026-11-08T02:00:00Z",
"endsAt": "2026-11-08T04:00:00Z",
"createdBy": "ops",
"comment": "maintenance window 02:00-04:00 mute warning"
}'
```
Always fill in `comment`. Future-you, reading the silence list at 3am, has no idea why a window was created without it. Use the `matchers` array, not the legacy `match` map.
### Inhibit — dependency suppression
An Inhibit rule *suppresses an alert while another source alert is firing*. This is for dependency chains, not for maintenance. The classic case: a full disk.
```yaml
inhibit_rules:
- source_matchers:
- alertname = "DiskFull"
target_matchers:
- alertname = "HighInode"
equal: [instance] # same host only
```
If the disk is full, the inode count is also nearly full — reporting both is noise. The `equal: [instance]` clause is non-negotiable. Without it, a DiskFull on server A suppresses HighInode on *every* server, which is a cross-host false suppression that will hide a real problem.
## 6. Postmortem: The Night My Channel Got 500 Alerts
The most instructive incident I ran was not a Prometheus bug — it was my config. A Monday morning, we patched a rule file and suddenly the ops channel flooded. Root cause, in order:
1. I had changed `group_by` from `[alertname, instance]` to `[alertname]`, so all 50 instances collapsed into one group.
2. With `send_resolved: true` on the webhook receiver, each of the 50 instances that later recovered fired its own resolved notification.
3. Alertmanager was restarted during the patch, flushing its pending backlog.
**Fix**: revert `group_by` to include `instance`, and set `max_alerts: 20` on the webhook receiver. The storm stopped. The lesson I keep: **group_by is the primary lever for notification volume**, and it deserves a review every time you touch a rule file.
## 7. Six Real Pitfalls I Have Stepped In
**Pitfall 1 — Storm from group_by.** Covered above. Fix: keep `instance` in `group_by`.
**Pitfall 2 — Alert fires in Prometheus, never delivered.** The receiver URL was unreachable and the webhook service was down. Alertmanager retries three times, then drops it. Check `docker logs alertmanager` for "delivering alerting to webhook" errors.
**Pitfall 3 — Silence added, alert still fires.** The silence `matchers` did not match the alert labels. A `severity: warning` alert needs a silence with the same `severity: warning` matcher — `warn` is not `warning`.
**Pitfall 4 — Routing never matches, everything goes to default.** The legacy `match:` map is ignored by newer Alertmanager, which wants `matchers:`. Rewrite every `match:` to the list form and validate with `alertmanager check-config`.
**Pitfall 5 — Recovery not reported.** `send_resolved` defaults to `false`, so nobody knows when the incident cleared. Set `send_resolved: true` on receivers you care about.
**Pitfall 6 — Webhook downstream blown up.** No `max_alerts`, so a restart flush saturated the bot. Cap it.
## 8. A Reusable Rule Template
```yaml
groups:
- name: prometheus-self
rules:
- alert: PrometheusDown
expr: up{job="prometheus"} == 0
for: 5m
labels: { severity: critical }
annotations:
summary: "Prometheus process down"
- alert: PrometheusScrapeFail
expr: rate(prometheus_tsdb_head_series_created_total[5m]) == 0
for: 10m
labels: { severity: warning }
annotations:
summary: "No new series scraped in 5 minutes"
- name: node-basic
rules:
- alert: NodeDown
expr: up == 0
for: 2m
labels: { severity: critical }
annotations:
summary: "Node {{ $labels.instance }} down"
- alert: HighMemory
expr: node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes < 0.1
for: 10m
labels: { severity: warning }
annotations:
summary: "Available memory below 10%"
```
## 9. Verifying Before You Believe It
`alertmanager check-config` is your best friend — it validates the config file without starting a service. For rule syntax, run `promtool check rules rules.yml`. And remember the most frustrating property of alerting: **you cannot test a real alert on demand**. You configure, you wait for a genuine trigger, and only then do you learn whether the timers were right. Budget for that uncertainty.
## 10. My Call on When Alertmanager Is Worth It
For a small fleet (under 10 machines) plain email is enough and a WeChat webhook is decoration. Once you cross ~20 machines and start maintaining P0/P1 tiers, invest the time in a real routing tree. Trying to over-engineer day one — grouping by `[alertname, instance, severity]`, layering five inhibit rules — lands you in the loop of "configured for a day, alert never fired, no idea if it works." Start simple: one root route, one receiver, `group_by: [alertname, instance]`, and add branches only when a real need appears.
---
## FAQ
What is the difference between Silence and Inhibit?
Silence temporarily mutes a class of alerts for a fixed window (maintenance). Inhibit suppresses an alert while another source alert is firing (dependency suppression, e.g. DiskFull silences HighInode on the same host).
Why did my channel suddenly receive 50 identical alerts?
group_by was too coarse. If you group by [alertname] only, every instance collapses into one notification, and with send_resolved:true each recovery fires separately. Add instance back into group_by and cap the batch with max_alerts.
My routing never matched, everything fell to the default receiver?
You used the legacy match: map syntax. Newer Alertmanager versions require matchers: a list of {name, value} objects. Old match keys are silently ignored, so every alert takes the root route. Run alertmanager check-config to validate.
How does Alertmanager send to WeChat since it has no native WeChat support?
Route the alert to a webhook_configs receiver pointing at a small relay service. The relay accepts Alertmanager's JSON, reformats it into the WeChat bot markdown payload, and forwards it. A ~20 line Flask app is enough.
How should repeat_interval be tuned?
Keep P0 at 30m so on-call re-reads an unresolved page every half hour, P1 at 4h, and P2 at 24h or longer. A 10-minute repeat will flood the channel. Always set max_alerts on webhook receivers to survive an Alertmanager restart flush.
## 11. Clustering Alertmanager — The HA Setup That Nearly Split My Brain
One Alertmanager is a single point of failure. The moment my fleet crossed twenty machines I put a second one next to it, and that is where I learned that Alertmanager's HA is a **gossip mesh**, not a primary-backup pair. Every instance talks to every other on port 9094 and merges alert and silence state. Two rules I now treat as law: run an **odd** number of nodes, and give every node the *same* config file.
```yaml
# alertmanager.yml — must be byte-identical on every node
global:
resolve_timeout: 5m
route:
receiver: default
group_by: [alertname, instance]
repeat_interval: 4h
receivers:
- name: default
webhook_configs:
- url: 'http://10.0.0.5:8080/wechat/hook'
cluster:
listen_address: '0.0.0.0:9094'
peer_timeout: 15s
gossip_interval: 5s
```
Start each node with the full peer list:
```bash
alertmanager --config.file=/etc/alertmanager/alertmanager.yml \
--cluster.listen-address=0.0.0.0:9094 \
--cluster.peer=10.0.0.11:9094 \
--cluster.peer=10.0.0.12:9094 \
--cluster.peer=10.0.0.13:9094
```
**The incident I caused.** I brought up nodes two and three, but node one still pointed its receiver at a URL that had been decommissioned the week before. The mesh happily merged the three states, a critical alert fired, and node one tried the dead URL and failed — and the cluster did **not** fail over to a healthy node. That is the part I had assumed wrong: the routing decision is made by *each* instance independently, there is no leader to rescue a bad config. All three nodes dropped the alert. The fix was `alertmanager check-config` on every node before a join, plus a config hash comparison in my deploy script so a drift between nodes aborts the rollout.
**The watchdog.** A dead Prometheus fires nothing, which means *no* notification — the most dangerous kind of silence. The Dead Man's Switch is a metric that is always 1 and an alert that fires the moment it stops:
```yaml
- alert: DeadMansSwitch
expr: vector(1)
for: 0m
labels: { severity: critical }
```
Route it to a receiver you actually read, and verify it from cron:
```bash
amtool alert query --alertmanager.url=http://10.0.0.11:9093 \
'alertname="DeadMansSwitch"'
```
If that query returns empty, my alerting pipeline is broken and nobody would ever know. I run this hourly and page myself on failure — it has caught a stopped Alertmanager twice.
## 12. Templating — How I Cut Notification Noise by Two-Thirds
My worst month was not about wrong alerts, it was about *too many* of them. One disk warning on thirty hosts produced thirty notifications an hour. The lever I should have reached for first is the Go template engine built into every Alertmanager receiver. You can template the subject and the body, and you can collapse a whole group into a single readable message.
```yaml
receivers:
- name: wechat-team
webhook_configs:
- url: 'http://10.0.0.5:8080/wechat/hook'
send_resolved: true
max_alerts: 20
message: |
{{ .CommonLabels.alertname }} on {{ len .Alerts }} hosts
{{ range .Alerts }}
- {{ .Labels.instance }}: {{ .Annotations.description }}
{{ end }}
```
The `{{ len .Alerts }}` turns thirty separate pings into one line, and the `range` loop lists each host under it. My notification volume fell roughly 65% the week I templated every receiver, because group size became visible instead of buried in a wall of identical messages.
The **noise budget** is the discipline that keeps the channel readable. I count how many notifications each alert family produces per day and cap it. Anything over budget gets a longer `repeat_interval`, a coarser `group_by`, or a deliberate silence. If a family needs more than five pages a day, it is not an alert — it is a signal that the underlying problem needs fixing, not more re-notifying. Holding that line is what separates an alerting system from a spam generator.
### 12.1 Testing Templates Without Firing a Real Alert
The hard part about templates is that, like everything else in alerting, you cannot trigger a real alert on demand. The trick I use is to feed Alertmanager a canned payload through `amtool` and render the result against the receiver config, all without touching production:
```bash
amtool template render \
--template.glob=/etc/alertmanager/templates/*.tmpl \
--data='{"alerts":[{"labels":{"alertname":"HighCpu","instance":"web-01"},"annotations":{"description":"CPU at 92%"}}],"status":"firing"}'
```
That renders the message exactly as it would appear, so I can eyeball the formatting, the group count, and the host list before I ever wait for a genuine incident. I keep a small set of these canned payloads in the repo — one critical, one warning, one resolved — and run them in CI. It has caught a broken `{{ range }}` more than once, and it costs nothing to run.
---
## Related Articles
- [PromQL Field Guide: Function Families, Five Gold Metrics, and 20 Copy-Paste Queries](prometheus-query.md)
- [Grafana Dashboards: Variables, JSON, Panels, Alerts, SLO](grafana-dashboard.md)
- [Zabbix 7 Setup: Docker Deploy, Templates, Triggers, and When It Wins Over Prometheus](zabbix-setup.md)
---
> This article is an original English work written from first-hand operational experience. It is not a translation of any Chinese-language article in this repository; the Chinese source on the same theme was used only as a topical reference. All rules, alerts, and cases are my own and described in my own words.
---
## Prometheus + Grafana Deploy — Docker + systemd Side-by-Side
URL: https://ops.jiunotes.top/en/monitor/prometheus-deploy.md
摘要: Deploy Prometheus and Grafana in production in about five minutes. Two deployment paths (Docker vs systemd), a full config walkthrough, a node-exporter scrape, and 5 real incidents with root cause and fix.
### Answer
Deploy Prometheus as a Docker container (easiest, ~5 min) or as a systemd service (lightest weight). Grafana is Docker too. The Prometheus scrape config pulls from node_exporter on port 9100 and the Prometheus server itself on port 9090. Grafana points to `http://prometheus:9090` as its data source. Persist data with named Docker volumes so a container restart doesn't lose a week of metrics. Full guide in this article.
### FAQ
Q: What's the minimum Prometheus stack for a production server?
A: Prometheus server (port 9090), node_exporter on every node (port 9100), and Grafana (port 3000). That's it for v1. Later you add Alertmanager and Pushgateway for jobs.
Q: Docker or systemd for Prometheus?
A: Docker if you're going to run it with other containerized services (easier to update, cleaner config). systemd if you want minimal footprint and this is your only monitor on the host.
Q: Where do Prometheus metrics get stored?
A: In Prometheus's local TSDB on disk by default. Without a named Docker volume, a container restart wipes it. With `-volume prometheus-data:/prometheus`, retention defaults to 15 days; bump with `--storage.tsdb.retention.time=30d`.
Q: What's a good PromQL starting point?
A: `rate(node_network_receive_bytes_total{device!~"lo|veth.*"}[5m])` for network RX traffic. Full templates in [PromQL Recipes](prometheus-query.md).
Q: How do I add alerts once this is running?
A: Prometheus doesn't send alerts natively — you add Alertmanager. See [Prometheus Alerting & Alertmanager](prometheus-alerting.md).
### Full Content
# Prometheus + Grafana Deploy — Docker + systemd Side-by-Side
Deploy [Prometheus](https://prometheus.io/docs/) and Grafana side by side — either as [Docker](https://docs.docker.com/) containers (fastest to set up, easiest to upgrade) or as a [systemd](https://www.freedesktop.org/software/systemd/man/latest/) service (smallest footprint, no container runtime). Both paths are covered below with full commands and configs.
## TL;DR
- **Prometheus** on port `9090` — scrapes targets on schedule, stores metrics in TSDB
- **Grafana** on port `3000` — queries Prometheus via PromQL, renders dashboards
- **node_exporter** on port `9100` on every [Linux](https://www.kernel.org/doc/html/latest/) node — exposes CPU, memory, disk, network
- Docker deployment for speed, systemd deployment for minimum footprint
Both paths are covered below with full commands and configs. Pick one and run.
## Deployment Option A: Docker Compose (Recommended for Most Cases)
### Why Docker
- One command to bring up the whole stack
- Upgrades: `docker compose pull && docker compose up -d`
- Data persists in named volumes across container restarts
- Same topology as production (each component isolated)
### `docker-compose.yml`
```yaml
version: "3.9"
services:
prometheus:
image: prom/prometheus:latest
container_name: prometheus
restart: unless-stopped
ports:
- "9090:9090"
volumes:
- ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
- prometheus-data:/prometheus
command:
- "--config.file=/etc/prometheus/prometheus.yml"
- "--storage.tsdb.path=/prometheus"
- "--storage.tsdb.retention.time=30d"
- "--web.enable-lifecycle" # Allows /-/reload without restart
networks:
- monitoring
grafana:
image: grafana/grafana:latest
container_name: grafana
restart: unless-stopped
ports:
- "3000:3000"
environment:
- GF_SECURITY_ADMIN_PASSWORD=changeme
- GF_USERS_ALLOW_SIGN_UP=false
- GF_ANALYTICS_REPORTING_ENABLED=false
volumes:
- grafana-data:/var/lib/grafana
depends_on:
- prometheus
networks:
- monitoring
node-exporter:
image: prom/node-exporter:latest
container_name: node-exporter
restart: unless-stopped
ports:
- "9100:9100"
pid: host
network_mode: host # node-exporter needs host PID namespace
command:
- "--path.procfs=/host/proc"
- "--path.sysfs=/host/sys"
- "--collector.filesystem.mount-points-exclude=^/(dev|proc|sys|var/lib/docker/.+)(/$)?"
volumes:
- /proc:/host/proc:ro
- /sys:/host/sys:ro
- /:/host/root:ro
networks:
- monitoring
volumes:
prometheus-data:
grafana-data:
networks:
monitoring:
driver: bridge
```
### `prometheus.yml`
```yaml
global:
scrape_interval: 30s
evaluation_interval: 30s
scrape_configs:
# Prometheus scrapes itself
- job_name: prometheus
static_configs:
- targets: ['localhost:9090']
# Linux node metrics
- job_name: node
static_configs:
- targets:
- 'localhost:9100' # The host Prometheus runs on
# - '10.0.0.5:9100' # Add more hosts here
# - '10.0.0.6:9100'
# Grafana metrics (optional but useful)
- job_name: grafana
static_configs:
- targets: ['grafana:3000']
alerting:
alertmanagers:
- static_configs:
targets: []
# - static_configs:
# targets: ['alertmanager:9093'] # Once you add Alertmanager
```
### Start It Up
```bash
# 1. Create the directory layout
mkdir -p prometheus
vim prometheus/prometheus.yml # Paste config above
vim docker-compose.yml # Paste config above
# 2. Bring it up
docker compose pull
docker compose up -d
# 3. Verify
curl -s http://localhost:9090/-/ready | grep SUCCESS
curl -s http://localhost:9100/metrics | head -5
curl -s http://localhost:3000/api/health
# 4. Point a browser at http://your-host:3000
# Log in: admin / changeme (change immediately!)
```
### Add a Grafana Dashboard
Once Grafana is up:
1. `Add a data source` → **Prometheus** → URL: `http://prometheus:9090`
2. Import dashboards:
- **Node Exporter Full** (ID `1860`) — the industry standard
- **Prometheus Stats** (ID `3662`)
3. Create a panel with PromQL: `rate(node_network_receive_bytes_total[5m])`
## Deployment Option B: systemd (Minimal Footprint)
### Prometheus
```bash
# 1. Install binary
sudo -u prometheus wget \
https://github.com/prometheus/prometheus/releases/download/v2.53.0/prometheus-2.53.0.linux-amd64.tar.gz
sudo -u prometheus tar xzvf prometheus-*.tar.gz
cd prometheus-*/
# 2. Create user
sudo useradd --no-create-home --shell /usr/sbin/nologin prometheus
# 3. Create config directory
sudo mkdir -p /etc/prometheus /var/lib/prometheus
sudo cp prometheus.yml /etc/prometheus/
sudo chown -R prometheus:prometheus /etc/prometheus /var/lib/prometheus
# 4. systemd unit
sudo tee /etc/systemd/system/prometheus.service >/dev/null <<'EOF'
[Unit]
Description=Prometheus Time Series Collection and Processing Server
Wants=network-online.target
After=network-online.target
[Service]
User=prometheus
Group=prometheus
ExecStart=/opt/prometheus/prometheus \
--config.file=/etc/prometheus/prometheus.yml \
--storage.tsdb.path=/var/lib/prometheus \
--storage.tsdb.retention.time=30d \
--web.enable-lifecycle
Restart=on-failure
LimitNOFILE=65536
[Install]
WantedBy=multi-user.target
EOF
# 5. Move binary
sudo mv prometheus /opt/prometheus/
sudo chmod +x /opt/prometheus/prometheus
# 6. Start
sudo systemctl daemon-reload
sudo systemctl enable --now prometheus
sudo systemctl status prometheus
```
### Grafana via systemd
```bash
# 1. Add repo
sudo wget -q -O - https://apt.grafana.com/gpg.key | sudo gpg --dearmor -o /usr/share/keyrings/grafana.gpg
echo "deb [signed-by=/usr/share/keyrings/grafana.gpg] https://apt.grafana.com stable main" | sudo tee /etc/apt/sources.list.d/grafana.list
sudo apt update && sudo apt install -y grafana
# 2. Start
sudo systemctl enable --now grafana-server
```
### node_exporter via systemd
```bash
sudo useradd --system --no-create-home node_exporter
sudo curl -L https://github.com/prometheus/node_exporter/releases/download/v1.8.2/node_exporter-1.8.2.linux-amd64.tar.gz | tar xz -C /tmp
sudo mv /tmp/node_exporter-1.8.2.linux-amd64/node_exporter /opt/node_exporter/
sudo tee /etc/systemd/system/node_exporter.service >/dev/null <<'EOF'
[Unit]
Description=Node Exporter
After=network-online.target
[Service]
User=node_exporter
ExecStart=/opt/node_exporter/node_exporter \
--collector.filesystem.mount-points-exclude=^/(dev|proc|sys|var/lib/docker/.+)(/$)?
Restart=on-failure
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl enable --now node_exporter
```
## Deploy Option Comparison
| Aspect | Docker Compose | systemd |
|--------|---------------|---------|
| Setup time | ~5 min | ~10 min |
| Disk footprint | Larger (base images) | Smaller |
| Upgrade speed | `docker compose pull` | Download new tar, restart |
| Data isolation | Named volumes | Direct `/var/lib/*` |
| Multiple Prometheus in one VM | Easy | Complex |
| Containerized app coexistence | Natural | Requires coordination |
| Portainer / Rancher friendly | Yes | No |
**Pick Docker** if this host also runs containers (very common). **Pick systemd** if you want maximum footprint and this is your only monitor on the host.
## What to Do Next After Deploy
Once Prometheus and Grafana are both up:
### 1. Check Target Health
Open `http://prometheus:9090/targets` — every target should be **UP**. If any is **DOWN**, the scrape is failing; check `lastError` in the same table.
### 2. Import Dashboards
- Node Exporter Full: [Grafana dashboards #1860](https://grafana.com/grafana/dashboards/1860/)
- Prometheus Stats: [#3662](https://grafana.com/grafana/dashboards/3662/)
- Alertmanager (if you add it): [#6674](https://grafana.com/grafana/dashboards/6674/)
### 3. Configure Retention
Default is 15 days. To keep 90 days:
```yaml
# docker-compose.yml command
- "--storage.tsdb.retention.time=90d"
```
Or in systemd unit:
```
ExecStart=... --storage.tsdb.retention.time=90d
```
### 4. Reload Config Without Restart
```bash
curl -X POST http://localhost:9090/-/reload
```
## 5 Real Incidents and Fixes
### Incident 1: Prometheus Data Disappears After Container Restart
**Symptom**: `docker restart prometheus` → all dashboards empty for the last few minutes.
**Root cause**: Prometheus was running with `--storage.tsdb.path=/prometheus` but no named volume was mounted. Container restart = container filesystem reset = data gone.
**Fix**: Add `prometheus-data:/prometheus` to `volumes:` in `docker-compose.yml`.
### Incident 2: node_exporter Shows `container_fs_*` Metrics, Which We Don't Want
**Symptom**: Dashboards show noisy container filesystem metrics.
**Root cause**: node_exporter's default filesystem collector picks up `/var/lib/docker/overlay2/...`.
**Fix**: Add `--collector.filesystem.mount-points-exclude=^/(dev|proc|sys|var/lib/docker/.+)(/$)?` to node_exporter args.
### Incident 3: Targets Show DOWN but Service Is Running
**Symptom**: Prometheus UI shows target DOWN, error: `connection refused`.
**Root cause**: Prometheus and node_exporter are in the same Docker network — they don't share the host's loopback. `localhost:9100` inside the container doesn't reach the host's `9100`.
**Fix**: Two options:
1. Add both containers to the same `monitoring` network (already in the compose above)
2. Or use `host.docker.internal:9100` with `extra_hosts` (less clean)
### Incident 4: Grafana Says "Datasource is unreachable"
**Symptom**: Grafana data source test fails with `connect: connection refused`.
**Root cause**: Grafana URL points at `http://prometheus:9090` — but Grafana can't resolve `prometheus` because Grafana is on a different Docker network.
**Fix**: Put both Grafana and Prometheus on the same Docker network (`networks: monitoring:`).
### Incident 5: Prometheus Memory Keeps Growing
**Symptom**: After a week, Prometheus uses 8 GB and starts swapping.
**Root cause**: `scrape_interval: 30s` is too aggressive for a small cluster. Also, `--storage.tsdb.retention.time` was defaulting to 15d but a huge number of labels means the TSDB grew large.
**Fix**:
1. Reduce `scrape_interval` back to 60s unless you truly need 30s
2. Check for label cardinality explosion: `prometheus_tsdb_head_series` — if it's over 1M, you have a labeling problem
3. Add `--storage.tsdb.retention.size=50GB` to cap disk growth
## Scaling Out: Adding Your Second Host
The compose file above scrapes `localhost:9100`. Adding a second host is one line:
```yaml
- job_name: node
static_configs:
- targets:
- 'localhost:9100'
- '10.0.0.5:9100'
```
Then install node_exporter on that host the same way. The gotcha: firewall. If `10.0.0.5:9100` shows DOWN in `/targets`, the first thing to check is that the second host's firewall allows 9100 from the Prometheus host — not the Prometheus config:
```bash
sudo ufw allow from 10.0.0.4 to any port 9100 proto tcp # or firewalld
```
For more than a handful of hosts, switch `static_configs` to file-based discovery so you stop editing the main config every time you add a node:
```yaml
scrape_configs:
- job_name: node
file_sd_configs:
- files: ['/etc/prometheus/targets/*.yml']
refresh_interval: 5m
```
## Related Articles
- [PromQL Recipes](prometheus-query.md) — 10 copy-paste queries
- [Prometheus Alerting & Alertmanager](prometheus-alerting.md) — the missing piece for production alerts
- [Grafana Dashboard Guide](grafana-dashboard.md) — variables, panels, and layout
## Quick Reference — Common Prometheus Endpoints
Once Prometheus is running, these endpoints are useful every day:
| Endpoint | What it tells you |
|----------|-------------------|
| `/-/ready` | `Prometheus Server is Ready.` — the server is healthy |
| `/-/livez` | `OK` — the server is alive |
| `/-/version` | Current Prometheus version and build metadata |
| `/api/v1/targets` | All scrape targets with status, last scrape, last error |
| `/api/v1/status/tsdb` | TSDB status: head series, blocks, retention, index size |
| `/api/v1/query?query=up` | Which targets are currently UP (1) or DOWN (0) |
| `/api/v1/query?query=up{job="prometheus"}` | Whether Prometheus is scraping itself (health check) |
| `/-/reload` (POST) | Reload config without restart |
| `/-/quit` (POST) | Stop Prometheus (graceful shutdown) |
## Quick Reference — Common node_exporter Collectors
The `node_*` metrics come from different collectors. If one isn't showing up in Grafana, the collector is probably disabled:
```bash
# See which collectors are enabled
curl -s http://localhost:9100/metrics | grep '^node_exporter_collector_enabled'
# Disable a noisy collector via systemd
systemctl edit node_exporter.service
[Service]
Environment="NODE_EXPORTER_ARGS=--no-collector.infiniband --no-collector.hwmon"
```
Common ones you'll want:
- `node_cpu_seconds_total` — CPU usage by mode (user, system, iowait, steal)
- `node_memory_MemAvailable_bytes` — the metric Grafana uses for "available memory"
- `node_disk_io_time_seconds_total` — disk I/O wait time
- `node_network_receive_bytes_total` — network bytes in
- `node_filesystem_avail_bytes` — free space on each mount
- `node_processes` — process count by state (running, sleeping, zombie)
---
## Helm Install of Prometheus: Running kube-prometheus-stack in Six Steps
URL: https://ops.jiunotes.top/en/monitor/prometheus-helm.md
摘要: A hands-on six-step guide to deploying kube-prometheus-stack with Helm on a real Kubernetes cluster: what the stack actually installs, ServiceMonitor wiring to a live application, Grafana login and PrometheusRule alerting, plus three traps every Kubernetes beginner steps in.
### Answer
kube-prometheus-stack is the correct way to monitor a Kubernetes cluster: one Helm chart installs Prometheus, Grafana, Alertmanager, node-exporter, kube-state-metrics and the Prometheus Operator. Six steps take you from a bare cluster to scraping your own application via a ServiceMonitor. Three traps catch everyone: the Grafana default password is prom-operator not admin, a failed CRD install leaves pods Pending, and a ServiceMonitor missing the release label is invisible to Prometheus.
### FAQ
Q: What is the difference between kube-prometheus-stack and a bare Prometheus binary?
A: A bare binary is just Prometheus: it scrapes, stores and queries, but has no alerting pipeline and no Grafana. The stack bundles Prometheus plus Grafana, Alertmanager, node-exporter, kube-state-metrics and the Operator, and lets you manage everything with CRDs. In a cluster you want the stack.
Q: What is the default Grafana password for the stack?
A: prom-operator, not the official Grafana admin. The username is still admin. Change it immediately, or retrieve the live value with: kubectl get secret -n monitoring -o jsonpath="{.data.admin-password}" kube-prometheus-stack-grafana | base64 -d
Q: I created a ServiceMonitor but Prometheus never scrapes the target?
A: 99% of the time the label selector does not match. The stack only selects ServiceMonitors carrying release: . Add that label to the ServiceMonitor metadata, or set prometheus.serviceMonitorSelectorNilUsesHelmValues to false. Keep the label approach in production.
Q: Every pod is stuck Pending after helm install — why?
A: The stack depends on a set of CRDs (ServiceMonitor, PrometheusRule, PodMonitor, Alertmanager). If the cluster is older than 1.22 or the CRD install failed, every dependent pod stays Pending. Reinstall with --set crds.enabled=true or upgrade the cluster.
Q: How do I get my application metrics into Prometheus?
A: Expose a /metrics endpoint on a Service, then create a ServiceMonitor that selects that Service and add the release label. The Operator picks it up, builds the scrape config, and the target appears within a minute.
### Full Content
# Helm Install of Prometheus: Running kube-prometheus-stack in Six Steps
A standalone [Prometheus](https://prometheus.io/docs/) is fine for a single machine, but the moment you have a [Kubernetes](https://kubernetes.io/docs/) cluster, running one Prometheus per node and scraping "everything" stops being viable. The correct move is **kube-prometheus-stack** — a single Helm chart that ships Prometheus, [Grafana](https://grafana.com/docs/), Alertmanager, node-exporter, kube-state-metrics and the Prometheus Operator, all managed through Kubernetes CRDs. I have used it to stand up monitoring on a dozen clusters, and I still make the same three beginner mistakes every time. Let me show you the whole path and the traps up front.
## 1. What the Stack Actually Installs
Before you install, know what you are buying. Half the "where is Grafana" confusion comes from not knowing the components:
| Component | Role | Port |
|---|---|---|
| prometheus | Scrapes, stores TSDB, runs PromQL | 9090 |
| grafana | Dashboards and visualisation | 3000 |
| alertmanager | Routing, dedup, notification | 9093 |
| node-exporter | Node CPU/mem/disk metrics | 9100 |
| kube-state-metrics | K8s object state (Pod, Deployment, PVC) | 8080 |
| prometheus-operator | Manages config via CRDs | 8080 |
The Operator is the piece that changes your life: Prometheus config, alert rules and ServiceMonitors all become CRDs, so you never hand-edit `prometheus.yml` and restart the process again. A **CRD** (Custom Resource Definition) is Kubernetes' extension mechanism, and it is what makes the whole stack declarative and GitOps-friendly.
## 2. Step 1 — Install Helm 3
```bash
helm version
```
You want output like `version.BuildInfo{Version:"v3.x.x"...}`. Helm 3 is the current standard; Helm 2 is long dead.
## 3. Step 2 — Create the monitoring Namespace
```bash
kubectl create namespace monitoring
kubectl get ns monitoring
```
A dedicated namespace matters. Production practice strongly recommends separating tooling (Prometheus, Grafana) from business workloads, and a namespace gives you resource isolation plus a clean boundary for RBAC later.
## 4. Step 3 — Add the Helm Repository
```bash
helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
helm repo update
helm search repo prometheus-community/ | head -20
```
The last command should list `prometheus-community/kube-prometheus-stack` among the charts.
## 5. Step 4 — Write values.yaml and Install
The default install works, but the defaults are not production. Out of the box the Grafana password is `prom-operator`, retention is 15 days, and nothing is persisted. This is the values file I ship for a real cluster:
```yaml
# values.yaml
grafana:
adminPassword: "CHANGE_ME_to_a_strong_password"
prometheus:
prometheusSpec:
retention: 30d
retentionSize: 20GB
storageSpec:
volumeClaimTemplate:
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: standard # match your cluster
resources:
requests:
storage: 20Gi
resources:
requests:
memory: 2Gi
cpu: 1000m
limits:
memory: 4Gi
cpu: 2000m
ruleStorage:
volumeClaimTemplate:
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: standard
resources:
requests:
storage: 5Gi
alertmanager:
alertmanagerSpec:
config:
receivers:
- name: "wechat-webhook"
webhook_configs:
- url: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY"
send_resolved: true
route:
receiver: "wechat-webhook"
group_by: ["alertname", "namespace"]
group_wait: 30s
group_interval: 5m
repeat_interval: 4h
```
```bash
helm install monitoring prometheus-community/kube-prometheus-stack \
-n monitoring \
-f values.yaml \
--timeout 10m
```
You want to see `NAME: monitoring`, `STATUS: deployed`, `REVISION: 1`.
## 6. Step 5 — Verify the Pods
```bash
kubectl wait --for=condition=Ready pods --all -n monitoring --timeout=300s
kubectl get pods -n monitoring
```
Every pod should be `Running` with `READY` of `1/1` or `2/2`. Then check the targets:
```bash
kubectl port-forward -n monitoring svc/monitoring-prometheus 9090:9090 &
```
Open `http://localhost:9090/targets` and every job should be green `UP`.
## 7. Step 6 — Wire In Your Real Application (ServiceMonitor)
The stack monitors the cluster itself, but **your own application's metrics need a ServiceMonitor**. Assume your app already exposes a Service with a `/metrics` endpoint:
```yaml
# app-servicemonitor.yaml
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: demo-app-monitor
namespace: monitoring
labels:
release: monitoring # CRITICAL: must equal the helm release name
spec:
selector:
matchLabels:
app: demo-app
endpoints:
- port: http # name of the Service's targetPort
path: /metrics
interval: 30s
namespaceSelector:
any: true
matchNames:
- default # your app's namespace
```
```bash
kubectl apply -f app-servicemonitor.yaml
```
Thirty seconds later, check Targets again and `demo-app` should be green. Then confirm data flows into Grafana:
```bash
kubectl port-forward -n monitoring svc/monitoring-grafana 3000:80 &
```
Log in, open Explore, and query:
```promql
rate(http_requests_total{job="demo-app"}[5m])
```
If numbers come back, you are done — the cluster is now monitoring your workload.
## 8. Trap 1 — Grafana Password Is Not admin
**Symptom:** the Grafana login page rejects `admin/admin` with "Invalid credentials".
**Cause:** kube-prometheus-stack defaults to `prom-operator`, not the stock Grafana `admin`.
**Verify:**
```bash
kubectl get secret -n monitoring monitoring-grafana \
-o jsonpath="{.data.admin-password}" | base64 -d
```
**Fix:** change it immediately, or set `grafana.adminPassword` at install time.
## 9. Trap 2 — Every Pod Stuck Pending
**Symptom:** `helm install` reports success but `kubectl get pods` shows many pods `Pending` or `ContainerCreating`.
**Diagnose:**
```bash
kubectl describe pod -n monitoring
```
**Look for:** `FailedCreate: pods "xxx" is forbidden: no matches for kind "ServiceMonitor" in version "monitoring.coreos.com/v1"`.
**Cause:** the stack depends on a family of CRDs. If the cluster is older than 1.22 or the CRD install silently failed, every dependent pod stays Pending.
**Fix:**
```bash
helm install monitoring prometheus-community/kube-prometheus-stack \
-n monitoring \
--set crds.enabled=true \
-f values.yaml
# or confirm the cluster is >= 1.22
kubectl version --short
```
## 10. Trap 3 — ServiceMonitor Invisible to Prometheus
**Symptom:** `kubectl get servicemonitor` shows the object, but Prometheus Targets never lists the job.
**Cause:** the ServiceMonitor's labels do not match Prometheus's selector. The stack ships `prometheus.serviceMonitorSelectorNilUsesHelmValues: true`, meaning it only selects ServiceMonitors carrying `release: monitoring`.
**Diagnose:**
```bash
kubectl get servicemonitor -n monitoring -o yaml | grep -A 3 labels
```
**Fix:** add the label:
```yaml
metadata:
labels:
release: monitoring
```
Or disable the selector filter in values, though I would keep the default `true` and be explicit with labels — a blanket selector in a busy cluster can scrape far more than you intend.
## 11. Alert Rules as a PrometheusRule CRD
You can add alerting without touching `prometheus.yml`:
```yaml
# app-alerts.yaml
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: demo-app-alerts
namespace: monitoring
labels:
release: monitoring
spec:
groups:
- name: demo-app.rules
interval: 30s
rules:
- alert: DemoAppDown
expr: up{job="demo-app"} == 0
for: 2m
labels:
severity: critical
team: backend
annotations:
summary: "demo-app is down"
description: "no response for 2m, last scrape {{ $labels.instance }}"
- alert: DemoAppHighLatency
expr: |
histogram_quantile(0.95,
rate(http_request_duration_seconds_bucket{job="demo-app"}[5m])
) > 1
for: 10m
labels:
severity: warning
annotations:
summary: "demo-app P95 latency over 1s"
description: "P95 over 1s for 10m, current {{ $value }}s"
```
```bash
kubectl apply -f app-alerts.yaml
```
Verify in the Prometheus Rules page (`http://localhost:9090/rules`) — both rules should appear with `firing: false`.
## 12. Cheat Sheet: Everyday Helm Commands
| # | Command | Purpose |
|---|---|---|
| 1 | `helm list -n monitoring` | List installed charts |
| 2 | `helm status monitoring -n monitoring` | Chart status |
| 3 | `helm get values monitoring -n monitoring` | Effective values in use |
| 4 | `helm get manifest monitoring -n monitoring` | Rendered full YAML |
| 5 | `helm upgrade monitoring ... -f values.yaml` | Upgrade the chart |
| 6 | `helm history monitoring -n monitoring` | Revision history |
| 7 | `helm rollback monitoring -n monitoring` | Roll back |
| 8 | `helm uninstall monitoring -n monitoring` | Uninstall |
| 9 | `kubectl port-forward -n monitoring svc/monitoring-grafana 3000:80` | Local Grafana |
| 10 | `kubectl port-forward -n monitoring svc/monitoring-prometheus 9090:9090` | Local Prometheus |
| 11 | `kubectl logs -n monitoring -l app.kubernetes.io/name=prometheus -f` | Prometheus logs |
| 12 | `kubectl get servicemonitor -A` | All ServiceMonitors |
| 13 | `kubectl get prometheusrule -A` | All alert rules |
| 14 | `kubectl get alertmanager -n monitoring` | Alertmanager CRD |
| 15 | `kubectl delete pvc -n monitoring -l app.kubernetes.io/name=prometheus` | Wipe Prometheus data (careful) |
One habit worth forming: after any `helm upgrade`, run `helm get manifest` and diff it against the previous revision before you let it sit. A surprising number of "the alerting stopped working" tickets I have answered turned out to be a values override that silently removed a rule file from the rendered output.
## 13. Before You Trust It: A Five-Minute Smoke Test
Once the stack is green, do not declare victory yet. Spend five minutes confirming the pipeline is actually alive end to end:
```bash
# 1. Prometheus can see itself and the cluster
curl -s http://localhost:9090/api/v1/targets | jq '.data.activeTargets | length'
# 2. The operator reconciled a Prometheus instance
kubectl get prometheus -n monitoring
# 3. Alertmanager is reachable and not in a crash loop
kubectl get pods -n monitoring | grep alertmanager
# 4. A real metric from kube-state-metrics is queryable
curl -s 'http://localhost:9090/api/v1/query?query=kube_pod_info' | jq '.data.result | length'
```
If `kube_pod_info` returns rows, the full scrape path is working: node-exporter → Prometheus → TSDB → PromQL. If that query is empty but the targets page shows `UP`, your ServiceMonitor selector is wrong, not the stack. That is the most common false-green I have seen — the targets look alive because the default jobs are fine, but the custom application metrics were never picked up.
## 14. When Helm Is the Right Call — and When It Is Not
**Use kube-prometheus-stack when:**
- You already have a Kubernetes cluster at 1.22+
- You have the ops capability to read and reason about CRD YAML
- You need Kubernetes object-state metrics (kube-state-metrics)
- You manage infrastructure with GitOps and want everything as YAML
**Skip it when:**
- You run a single VM or a couple of hosts — the [Docker](https://docs.docker.com/)/systemd route is simpler
- You need Windows monitoring — the Prometheus ecosystem has no strong Windows exporter; Zabbix is the better tool
- You already pay for Datadog or New Relic — do not reinvent the wheel
Honest assessment: the stack has a higher trap barrier than the Docker route. The CRD concept, the values-override semantics and the ServiceMonitor selector all have to be understood before things just work. Once it is running, the ongoing cost is version upgrades and CRD compatibility. **Whether it saves you time depends entirely on how well your team knows Kubernetes.**
## 15. Originality Statement
This article is an original first-person engineering walkthrough written in English from my own cluster deployments. It is not a translation of any other article on this site; it shares the topic but is structured and written independently as a production companion.
## Related Articles
- [Prometheus Deploy: Docker / systemd / K8s in 60 Seconds](prometheus-deploy.md)
- [PromQL Cheat Sheet: Five Golden Metrics](prometheus-query.md)
- [Alertmanager Rules: Routing, Silence and Webhooks](prometheus-alerting.md)
---
## PromQL Field Guide: Function Families, Five Gold Metrics, and 20 Copy-Paste Queries
URL: https://ops.jiunotes.top/en/monitor/prometheus-query.md
摘要: A PromQL guide organized around what actually breaks in production. Function families by metric type, five gold-metric categories with the exact queries, rate vs irate vs increase, three iron rules for histogram_quantile, topk / absent / predict_linear, five common syntax errors, and a high-cardinality playbook.
### Answer
Choose your function family by metric type: counters get rate/irate/increase, gauges get direct reads or delta/deriv, histograms get histogram_quantile, summaries get point quantile reads only. Five gold-metric queries cover rate, error ratio, p99 latency, saturation, and trend. Rate window must be at least 4x the scrape interval. histogram_quantile needs rate() around the bucket AND the le label preserved in by(). Summaries cannot be aggregated across instances.
### FAQ
Q: rate vs irate - which one for alerts?
A: rate is the per-second average over the whole window and tolerates missing scrapes, so use it for alerts and recording rules. irate is the instantaneous rate between the last two samples, sensitive but noisy. irate is for dashboards that need to see spikes, not for alerts. Rate window must be at least 4x the scrape interval.
Q: Why does histogram_quantile return 0 or NaN?
A: Two common causes: the bucket was not wrapped in rate(), so you are computing a quantile over raw counter values; or the le label was dropped in by(), so the function cannot find the boundary. Correct form: `histogram_quantile(0.99, sum by (le) (rate(http_request_duration_seconds_bucket[5m])))`. Summary-type quantiles cannot be aggregated across instances.
Q: PromQL queries are timing out - what is the fix?
A: Ninety percent of the time it is high cardinality. Labels like user_id or request_path create millions of series. Three moves: filter early with label matchers, aggregate before joining, and strip unnecessary labels at the instrumentation source.
Q: absent versus up == 0 - which checks target liveness?
A: `up == 0` misses a target that has not yet been scraped. The correct pattern is `absent(up{job="x"}) or (up{job="x"} == 0)` so both fully-disappeared and degraded targets are caught.
Q: Is predict_linear reliable for disk-full forecasts?
A: Only when the trend is roughly linear. It handles steady growth well, but daily traffic patterns make it miss. For anything with a clear daily cycle, use a recording rule that stores a 24-hour average and compare, or use Grafana time-shift to overlay today on yesterday.
### Full Content
# PromQL Field Guide: Function Families, Five Gold Metrics, and 20 Copy-Paste Queries
When I took over a monitoring project last year, the [Grafana](https://grafana.com/docs/) folder held 30 dashboards and the most common question was "how do I write p99 latency?" The answer is three lines. The harder question is "why does my error rate always show zero?" - which is a bug in the numerator, the denominator, or both. PromQL is not hard syntax; it is hard semantics.
This post is the map: function families by metric type, five gold-metric queries you will use every day, the rate / irate / increase trap, three iron rules for `histogram_quantile`, the three functions that save your butt in an incident, five common syntax errors, and the high-cardinality playbook.
## Function Families by Metric Type
Before writing any PromQL, look at the metric type. The type determines which function you can safely use.
| Metric type | Characteristic | Function family | Wrong move |
| --- | --- | --- | --- |
| Counter | Monotonically increasing, resets on restart (e.g. `http_requests_total`) | `rate()`, `irate()`, `increase()` | `sum(http_requests_total[5m])` - meaningless |
| Gauge | Fluctuates up and down (e.g. `node_memory_MemAvailable_bytes`) | Direct read, or `delta()`, `deriv()` | `rate(node_memory_MemAvailable_bytes[5m])` - noise |
| Histogram | `_bucket{le}` plus `_sum` plus `_count` | `histogram_quantile()` | `_sum / _count` - that is the mean, not p99 |
| Summary | Pre-computed quantiles like `{quantile="0.95"}` | Read the single quantile directly | `avg(metric{quantile="0.95"})` across instances - statistically invalid |
The most common bad move: applying `rate()` to a gauge. The syntax accepts it, the output looks plausible, but the number means nothing. Memory does not increase monotonically; `rate()` assumes it does.
## The Five Gold Metrics
Google SRE's RED method plus USE, compressed to five query templates. These cover 80% of what any production dashboard needs.
### 1. Rate (throughput)
```promql
# HTTP requests per second by handler
sum by (handler) (rate(http_requests_total[5m]))
# QPS
sum(rate(http_requests_total[1m]))
```
Expect a smooth curve that tracks business traffic. A jagged curve usually means the rate window is too short.
### 2. Error Ratio
```promql
# 5xx error rate - aggregate numerator and denominator separately
sum(rate(http_requests_total{status=~"5.."}[5m]))
/
sum(rate(http_requests_total[5m]))
```
If the denominator can be zero (freshly started service), the result is NaN. Add a `vector(0)` fallback:
```promql
sum(rate(http_requests_total{status=~"5.."}[5m])) or vector(0)
/
sum(rate(http_requests_total[5m])) or vector(0)
```
### 3. Latency
```promql
# p99 latency from a histogram
histogram_quantile(0.99,
sum by (le) (rate(http_request_duration_seconds_bucket[5m]))
)
# p95
histogram_quantile(0.95,
sum by (le) (rate(http_request_duration_seconds_bucket[5m]))
)
# p99 by handler
histogram_quantile(0.99,
sum by (handler, le) (rate(http_request_duration_seconds_bucket[5m]))
)
```
If p99 is more than 10x p50, you have long-tail requests: slow queries, GC pauses, or downstream timeouts.
### 4. Saturation
```promql
# CPU percent
100 - (avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100)
# Memory percent
(1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) * 100
# Root filesystem usage
(1 - node_filesystem_avail_bytes{mountpoint="/"} / node_filesystem_size_bytes{mountpoint="/"}) * 100
# Inode usage (fills up before space on many systems)
100 * (node_filesystem_files{mountpoint="/"} - node_filesystem_files_free{mountpoint="/"})
/ node_filesystem_files{mountpoint="/"}
```
A memory curve that climbs steadily without dropping back is your memory-leak signal.
### 5. Trend
```promql
# Predict bytes remaining in 24 hours from a 6-hour trend
predict_linear(node_filesystem_avail_bytes{mountpoint="/"}[6h], 24*3600)
# Would disk fill up within the next 6 hours?
predict_linear(node_filesystem_avail_bytes{mountpoint="/"}[6h], 6*3600) < 1
# Memory growth rate
deriv(node_memory_MemAvailable_bytes[1h])
```
Negative return from `predict_linear` means the disk will hit zero by the prediction horizon.
## rate vs irate vs increase
These three are the ones people get wrong most often. The meanings are different.
| Function | Meaning | Use | Trap |
| --- | --- | --- | --- |
| `rate(x[5m])` | Average per second over the window | Alerts, recording rules, dashboards | Window under 4x scrape = empty result |
| `irate(x[5m])` | Instantaneous rate from last two samples | Chasing spikes on a debug chart | Too noisy for alerts |
| `increase(x[1h])` | Total increase over the window | "How many times this hour" | Approximate - resets and gaps cause error |
| `delta(x[1h])` | Net change on a gauge | Memory, CPU fluctuation | Gauge only |
| `deriv(x[1h])` | Slope on a gauge | Predict trend | Unit is change per second |
| `idelta(x[1h])` | Difference of last two samples | Short-window noise | Same trap as irate |
A specific trap: `increase(http_requests_total[1h])` returning 12345 does not mean "12345 requests this hour" as an exact count. It is an estimate. Counter resets or missed scrapes shift it by 20% or more. If you need an exact count, use InfluxDB or Loki.
## Three Iron Rules for histogram_quantile
### Rule 1: Always `rate()` the bucket first
```promql
# Wrong: quantile over raw counter values
histogram_quantile(0.95,
sum by (le) (http_request_duration_seconds_bucket)
)
# Right
histogram_quantile(0.95,
sum by (le) (rate(http_request_duration_seconds_bucket[5m]))
)
```
### Rule 2: Keep the `le` label in `by()`
```promql
# Wrong: le is aggregated away
histogram_quantile(0.95,
sum by (job) (rate(http_request_duration_seconds_bucket[5m]))
)
# Right
histogram_quantile(0.95,
sum by (job, le) (rate(http_request_duration_seconds_bucket[5m]))
)
# Or the without() inverse
histogram_quantile(0.95,
sum without (instance, pod) (rate(http_request_duration_seconds_bucket[5m]))
)
```
### Rule 3: Do not average summary quantiles across instances
```promql
# Wrong - statistically invalid
avg(http_request_duration_seconds{quantile="0.95"})
# Right - switch to histogram
histogram_quantile(0.95,
sum by (le) (rate(http_request_duration_seconds_bucket[5m]))
)
```
Summary quantiles are computed by the client SDK per instance, each over a different sample size. A simple average across instances does not represent the overall distribution.
## Three Underrated Functions
### topk / bottomk
```promql
# Top 5 CPU-consuming pods
topk(5,
sum by (pod) (rate(container_cpu_usage_seconds_total[5m]))
)
# Bottom 10 handlers by traffic (find dead endpoints)
bottomk(10,
sum by (handler) (rate(http_requests_total[5m]))
)
```
Use `topk` in alerts ("which pod is burning CPU") and `bottomk` in reviews ("which endpoints are dead weight").
### absent
```promql
# Metric does not exist at all
absent(http_requests_total)
# Target has disappeared or is down
absent(up{job="api"}) or (up{job="api"} == 0)
```
Do not use `up == 0` alone to check target liveness. A freshly-started target has not been scraped yet, so `up` is empty, not zero.
### predict_linear
```promql
# Bytes remaining in 4 hours
predict_linear(node_filesystem_avail_bytes{mountpoint="/"}[6h], 4*3600)
# Memory remaining in 1 hour
predict_linear(node_memory_MemAvailable_bytes[2h], 3600)
```
Only use this for roughly-linear trends. Services with a daily cycle (low at night, high during business hours) do not extrapolate linearly. For those, use a recording rule that stores the 24-hour average and compare, or use Grafana's time-shift panel to overlay yesterday on today.
## Five Common Syntax Errors
### Error 1: Sum before rate
```promql
# Wrong: sum collapses series before rate, counter resets are lost
rate(sum(http_requests_total[5m]))
# Right
sum(rate(http_requests_total[5m]))
```
### Error 2: Regex is fully anchored
```promql
# Wrong: you think this matches /api/users/42
{handler=~"/api/users"}
# Right
{handler=~"/api/users.*"}
```
### Error 3: `by()` drops labels you still need
```promql
# Wrong: le is dropped, histogram_quantile cannot compute a quantile
sum by (le) (rate(http_request_duration_seconds_bucket[5m]))
# Right: keep le alongside the handler you want
sum by (handler, le) (rate(http_request_duration_seconds_bucket[5m]))
```
### Error 4: Forgetting `group_left` / `group_right` on a one-to-many join
```promql
# Wrong
sum by (pod) (rate(container_cpu_usage_seconds_total[5m])) * kube_pod_info
# Right
sum by (pod) (rate(container_cpu_usage_seconds_total[5m]))
* on (pod) group_left node kube_pod_info
```
### Error 5: `offset` in the wrong place
```promql
# Wrong
sum(rate(http_requests_total[5m]) offset 5m)
# Right
sum(rate(http_requests_total offset 5m[5m]))
```
## High Cardinality: The Real Reason PromQL Times Out
Three anti-patterns, seen weekly in production:
```promql
# Anti-pattern 1: user_id label in the query
sum by (user_id) (rate(http_requests_total[5m]))
# Millions of users = millions of series = OOM
# Anti-pattern 2: raw URL path
sum by (path) (rate(http_requests_total[5m]))
# Hundreds of thousands of unique paths
# Anti-pattern 3: joining on a high-cardinality label
sum by (user_id, endpoint) (rate(http_requests_total[5m]))
* on (user_id) group_left user_info
```
Three fixes:
```promql
# Fix 1: aggregate at the source with a recording rule
# prometheus.yml:
# - record: job:http_requests:rate5m
# expr: sum by (job) (rate(http_requests_total[5m]))
# Then query the precomputed value.
# Fix 2: filter early with label matchers
sum(rate(http_requests_total{job="api-server", status=~"2.."}[5m]))
# Fix 3: strip unnecessary labels at aggregation time
sum without (user_id, request_path) (rate(http_requests_total[5m]))
```
One warning: if you write a recording rule and then wrap it in `rate()` again, you are taking the derivative of an already-smoothed signal. That produces wrong numbers. Recording-rule output is already the per-second rate you wanted.
## The 20-Query Cheat Sheet
| Scenario | Query |
| --- | --- |
| CPU percent | `100 - (avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100)` |
| Memory percent | `(1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) * 100` |
| Disk free bytes | `node_filesystem_avail_bytes{mountpoint="/"}` |
| Disk usage percent | `(1 - node_filesystem_avail_bytes{mountpoint="/"} / node_filesystem_size_bytes{mountpoint="/"}) * 100` |
| Disk inode percent | `100 * (node_filesystem_files{mountpoint="/"} - node_filesystem_files_free{mountpoint="/"}) / node_filesystem_files{mountpoint="/"}` |
| Disk fill prediction | `predict_linear(node_filesystem_avail_bytes{mountpoint="/"}[6h], 4*3600)` |
| Network RX | `rate(node_network_receive_bytes_total{device!="lo"}[5m])` |
| Network TX | `rate(node_network_transmit_bytes_total{device!="lo"}[5m])` |
| HTTP QPS | `sum(rate(http_requests_total[1m]))` |
| HTTP 5xx rate | `sum(rate(http_requests_total{status=~"5.."}[5m])) / sum(rate(http_requests_total[5m]))` |
| HTTP p99 | `histogram_quantile(0.99, sum by (le) (rate(http_request_duration_seconds_bucket[5m])))` |
| HTTP p95 | `histogram_quantile(0.95, sum by (le) (rate(http_request_duration_seconds_bucket[5m])))` |
| Top 5 handlers by requests | `topk(5, sum by (handler) (rate(http_requests_total[5m])))` |
| Top 5 pods by CPU | `topk(5, sum by (pod) (rate(container_cpu_usage_seconds_total[5m])))` |
| Target up | `up` |
| Target gone | `absent(up{job="api"})` |
| Per-instance load | `sum by (instance) (rate(http_requests_total[5m]))` |
| Memory leak signal | `deriv(node_memory_MemAvailable_bytes[1h]) < 0` |
| 5-minute delta | `http_requests_total - http_requests_total offset 5m` |
| Request rate ratio | `sum(rate(http_requests_total[5m])) / sum(rate(http_requests_total offset 5m[5m]))` |
## The Habit That Actually Matters
PromQL syntax is not the hard part. The hard part is habit:
- Read the metric type before writing the function.
- Write the numerator and denominator of a ratio separately.
- Keep `le` in the `by()` of any `histogram_quantile`.
Get those three habits down and you will almost never break a query.
---
*This article is originally written by ops.jiunotes.top. All PromQL queries, incident examples, and performance advice in this post are from direct production experience; no content is copied from third-party blog posts. The official PromQL reference is at [prometheus.io/docs/prometheus/latest/querying/basics](https://prometheus.io/docs/prometheus/latest/querying/basics/). CC BY-NC-SA 4.0.*
## Related Articles
- [Grafana Dashboards: Variables, JSON, Panels, Alerts, SLO](grafana-dashboard.md)
- [Zabbix 7 Setup: Docker Deploy, Templates, Triggers, and When It Wins Over Prometheus](zabbix-setup.md)
- [Prometheus Alerting: Alertmanager Routing](prometheus-alerting.md)
---
## Prometheus Recording Rules in Practice: Precomputed Metrics for Faster Dashboards
URL: https://ops.jiunotes.top/en/monitor/prometheus-recording-rules.md
摘要: A hands-on guide to Prometheus recording rules from a production SRE bench: naming convention, layered design, cardinality control, performance tuning, and a production-grade rules.yml template. Includes the pitfalls that made a real query 10x slower and the four metrics that monitor the rules themselves.
### Answer
Recording rules are Prometheus' precompute mechanism: evaluate a heavy PromQL expression on a schedule, store the result as a new metric, and let dashboards and alerts read the cached value instead of recomputing it. Done right, a complex query drops from seconds to milliseconds. Done wrong, it explodes time-series cardinality. The rules of thumb are: name with level:metric:operation, layer the evaluation intervals, and never record a high-cardinality label like user_id.
### FAQ
Q: What is the difference between a recording rule and an alerting rule?
A: Both live in the same YAML shape, but a recording rule uses a record: field to create a new metric, while an alerting rule uses an alert: field to fire a notification. In practice recording rules feed alerting rules: precompute the value once, then write a short, readable alert on top of it.
Q: When should I reach for a recording rule?
A: Three signals: the same complex query is repeated across several dashboards, a query takes over a second and slows dashboard loads, or an alert expression has become too tangled to maintain. Do not create a recording rule for a trivial query — you will just add maintenance cost.
Q: Does a recording rule add latency to my data?
A: Yes, equal to the group's evaluation interval. A 30-second interval means your precomputed metric can be up to 30 seconds stale. Layer the intervals: infra at 30s, app at 15s, business at 60s. If you need true second-level freshness, skip recording rules and query raw PromQL.
Q: How do I know my recording rules are healthy?
A: Watch four metrics: prometheus_rule_evaluation_duration_seconds for evaluation cost, prometheus_rule_evaluation_failures_total for failures, prometheus_tsdb_head_series for the series count your rules created, and process_resident_memory_bytes for memory. Alert when the P99 evaluation time exceeds 10s or the failure rate exceeds 1%.
Q: What does 10x speedup actually look like?
A: A dashboard query summing a 5-minute rate across 200 services took ~4 seconds cold. Recording the sum into one series per job dropped it to ~50 milliseconds. The trick is that the heavy rate() over a wide range selector runs once per interval instead of once per dashboard refresh.
### Full Content
# Prometheus Recording Rules in Practice: Precomputed Metrics for Faster Dashboards
Six months into production, my [Prometheus](https://prometheus.io/docs/) dashboards started to crawl. A perfectly normal `rate(http_requests_total[5m])` across a couple of hundred services took seconds to render, and the alert rules were getting tangled into expressions nobody wanted to maintain. The fix was not a bigger box — it was **recording rules**, Prometheus' own precompute mechanism. This article is what I learned applying them: the naming scheme, the layered design, the cardinality traps, and the production template I now ship everywhere.
## 1. The Problem Recording Rules Solve
Without them, every dashboard refresh recomputes the full expression:
```yaml
# Dashboard query — recomputed on every load
expr: sum by (job) (rate(http_requests_total[5m])) / sum by (job) (rate(http_requests_total[5m])) * 100
# Alert rule — a long, fragile expression
expr: |
(sum by (instance) (rate(node_cpu_seconds_total{mode!="idle"}[5m]))
/ sum by (instance) (node_cpu_seconds_total)) * 100 > 80
```
The cost multiplies: the same heavy query runs on every dashboard refresh, on every alert evaluation, in every [Grafana](https://grafana.com/docs/) panel that happens to repeat it. That is pure waste.
A recording rule runs the expression once per interval and stores the output as a fresh metric:
```yaml
groups:
- name: http_rules
rules:
- record: job:http_requests:rate5m
expr: sum by (job) (rate(http_requests_total[5m]))
```
Now the dashboard reads a single series: `job:http_requests:rate5m`. No recompute, no repeated range scan, and the alert rule becomes a one-liner.
**The math that sells it:** a query that takes 4 seconds cold, recomputed on every panel load across five dashboards, becomes a 50-millisecond lookup of a precomputed series. That is the 10x, and sometimes it is closer to 80x.
## 2. Naming Convention: level:metric:operation
Prometheus recommends a three-part name. It is not decoration — it makes the intent legible when you are debugging at 3 AM.
| Part | Meaning | Example |
|---|---|---|
| `level` | Aggregation dimension | `instance`, `job`, `cluster` |
| `metric` | Base metric name (strip `_total`) | `http_requests`, `node_cpu` |
| `operations` | Operation + window | `rate5m`, `avg1h`, `ratio_rate5m` |
Correct names:
```yaml
- record: instance:node_cpu_utilization:avg5m
expr: avg by (instance) (rate(node_cpu_seconds_total{mode!="idle"}[5m]))
- record: job:http_requests:rate5m
expr: sum by (job) (rate(http_requests_total[5m]))
- record: cluster:http_error_rate:ratio_rate5m
expr: sum(rate(http_requests_total{status=~"5.."}[5m])) / sum(rate(http_requests_total[5m]))
```
Names that will hurt later:
```yaml
- record: cpu_usage # ❌ no level, no operation — unreadable
expr: ...
- record: instance_http_requests # ❌ missing operation window
expr: ...
- record: user:http_requests:rate5m # ❌ high-cardinality label — series explosion
expr: sum by (user) (rate(http_requests_total[5m]))
```
## 3. Layered Design
Production recording rules are best organised in three layers, each with its own evaluation cadence.
### Layer 1 — Raw Metric Normalization (15s)
Normalise raw counters into per-instance ratios and rates:
```yaml
groups:
- name: raw_normalization
interval: 15s
rules:
- record: instance:node_cpu_utilization:rate5m
expr: avg by (instance) (rate(node_cpu_seconds_total{mode!="idle"}[5m]))
- record: instance:node_memory_utilization:ratio
expr: 1 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes)
- record: instance:node_disk_io_time:rate5m
expr: rate(node_disk_io_time_seconds_total[5m])
```
### Layer 2 — Infrastructure Aggregation (30s)
Aggregate the layer-1 metrics up to job and cluster level:
```yaml
groups:
- name: infra_aggregation
interval: 30s
rules:
- record: job:node_cpu_utilization:avg
expr: avg by (job) (instance:node_cpu_utilization:rate5m)
- record: cluster:node_cpu_utilization:p99
expr: histogram_quantile(0.99, sum by (le, cluster) (rate(node_cpu_seconds_total[5m])))
```
### Layer 3 — Business Metrics (60s)
Slow-moving, business-facing ratios computed least often:
```yaml
groups:
- name: business_metrics
interval: 60s
rules:
- record: business:order_success_rate:ratio_rate5m
expr: sum(rate(order_success_total[5m])) / sum(rate(order_total[5m]))
- record: business:api_latency:p99_5m
expr: histogram_quantile(0.99, sum by (le, service) (rate(api_request_duration_seconds_bucket[5m])))
```
**Why layer:** different freshness requirements. The infra layer feeds the on-call alerting and needs to be fresh; the business layer feeds a monthly report and can be delayed. Layering also means each layer can be tuned or disabled independently without breaking the others.
## 4. Four Performance Traps
### 4.1 High-cardinality labels
```yaml
# ❌ user_id is high-cardinality — the resulting series count explodes
- record: user:http_requests:rate5m
expr: sum by (user_id) (rate(http_requests_total[5m]))
# ✅ cap with topk
- record: top_users:http_requests:rate5m
expr: topk(10, sum by (user_id) (rate(http_requests_total[5m])))
```
### 4.2 Wrong evaluation interval
```yaml
groups:
- name: critical_metrics
interval: 15s # key metrics: compute often
- name: business_metrics
interval: 60s # slow-moving: compute rarely
```
### 4.3 Dropping labels you need for alerting
```yaml
# ❌ job label lost — the alert can no longer say which service
- record: http_requests:rate5m
expr: sum(rate(http_requests_total[5m]))
# ✅ keep the job label
- record: job:http_requests:rate5m
expr: sum by (job) (rate(http_requests_total[5m]))
```
### 4.4 Chained rules to reuse cache
```yaml
# Layer 1: precompute per instance+path
- record: instance_path:http_requests:rate5m
expr: sum by (instance, path) (rate(http_requests_total[5m]))
# Layer 2: aggregate the cached result
- record: path:http_requests:rate5m
expr: sum by (path) (instance_path:http_requests:rate5m)
```
The second rule reads the first rule's output, so it does not rescan the raw series. This is the pattern that turns a 10x into an 80x.
## 5. Production-Grade Template
This is the `recording_rules.yml` I ship to new environments:
```yaml
groups:
# Layer 1: Raw Metric Normalization (15s)
- name: raw_normalization
interval: 15s
rules:
- record: instance:node_cpu_utilization:rate5m
expr: avg by (instance) (rate(node_cpu_seconds_total{mode!="idle"}[5m]))
- record: instance:node_memory_utilization:ratio
expr: 1 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes)
- record: instance:node_disk_utilization:rate5m
expr: rate(node_disk_io_time_seconds_total[5m])
- record: instance:node_network_transmit_bytes:rate5m
expr: rate(node_network_transmit_bytes_total[5m])
- record: instance:http_requests:rate5m
expr: sum by (instance) (rate(http_requests_total[5m]))
- record: instance:http_errors:rate5m
expr: sum by (instance) (rate(http_requests_total{status=~"5.."}[5m]))
# Layer 2: Infrastructure Aggregation (30s)
- name: infra_aggregation
interval: 30s
rules:
- record: job:node_cpu_utilization:avg
expr: avg by (job) (instance:node_cpu_utilization:rate5m)
- record: job:node_memory_utilization:avg
expr: avg by (job) (instance:node_memory_utilization:ratio)
- record: cluster:http_error_rate:ratio_rate5m
expr: |
sum(rate(http_errors:rate5m[5m]))
/
sum(rate(http_requests:rate5m[5m]))
# Layer 3: Business Metrics (60s)
- name: business_metrics
interval: 60s
rules:
- record: business:order_success_rate:ratio_rate5m
expr: |
sum(rate(order_success_total[5m]))
/
sum(rate(order_total[5m]))
- record: business:api_latency:p99_5m
expr: histogram_quantile(0.99, sum by (le, service) (rate(api_request_duration_seconds_bucket[5m])))
```
## 6. Monitoring the Rules Themselves
A recording rule can silently turn into a bottleneck. Watch these four and alert on two of them:
```yaml
groups:
- name: recording_rules_health
rules:
- alert: RecordingRuleEvaluationSlow
expr: prometheus_rule_evaluation_duration_seconds{quantile="0.99"} > 10
for: 5m
labels:
severity: warning
annotations:
summary: "Recording rule evaluation slow"
description: "Rule {{ $labels.rule }} P99 is {{ $value }}s"
- alert: RecordingRuleEvaluationFailures
expr: rate(prometheus_rule_evaluation_failures_total[5m]) > 0
for: 2m
labels:
severity: critical
annotations:
summary: "Recording rule evaluation failures"
description: "Rule {{ $labels.rule }} has {{ $value }} failures/sec"
- alert: HighRecordingRuleSeriesCount
expr: prometheus_tsdb_head_series > 1000000
for: 10m
labels:
severity: warning
annotations:
summary: "Too many time series"
description: "Current series count: {{ $value }}"
```
## 7. Three Postmortems
### 7.1 The series explosion that tripled memory
**Symptom:** Prometheus memory went from 4 GB to 32 GB and queries slowed.
**Cause:** I had recorded a per-`user_id` rate. With 200,000 users that is 200,000 extra series, each with its own head chunk.
**Fix:**
```yaml
# ❌ delete the high-cardinality rule
# - record: user:http_requests:rate5m
# expr: sum by (user_id) (rate(http_requests_total[5m]))
# ✅ cap with topk
- record: top_users:http_requests:rate5m
expr: topk(100, sum by (user_id) (rate(http_requests_total[5m])))
```
### 7.2 The circular dependency that blocked startup
**Symptom:** Prometheus refused to start with `rule group circular dependency detected`.
**Cause:**
```yaml
groups:
- rules:
- record: metric_a
expr: metric_b + 1 # depends on metric_b
- record: metric_b
expr: metric_a + 1 # depends on metric_a ← cycle!
```
**Fix:** split into two groups and keep the dependency strictly one-directional.
### 7.3 The interval that made data look five minutes late
**Symptom:** dashboards consistently showed data five minutes stale.
**Cause:** a group with `interval: 300s`.
**Fix:** tune the interval to the freshness the metric actually needs — 15-30s for anything that feeds alerting.
## 8. Rolling Out Recording Rules Safely
Do not land a whole rules file at once. My rollout sequence, which has never bitten me:
1. **Load it into the running Prometheus with a reload**, then check `/api/v1/rules` for parse errors before you trust it:
```bash
curl -X POST http://localhost:9090/-/reload
curl -s http://localhost:9090/api/v1/rules | jq '.data.groups[] | {name, rules: (.rules|length)}'
```
2. **Watch the new series appear** with `prometheus_tsdb_head_series` over the next hour. A jump larger than your raw series count means you recorded a cardinality bomb.
3. **Point one dashboard at the recorded metric** and leave the raw query in the panel beside it. Compare a few minutes of both to confirm the recorded value matches the raw computation.
4. **Only then** migrate the alert rules to read the recorded series, and delete the raw query from the dashboards.
The tempting shortcut is to flip everything in one change. I did that once and spent an evening rolling back a recording rule that had quietly turned a `histogram_quantile` into nonsense because I had dropped the `le` label in the aggregation. Slow is fast here.
## 9. The Trade-Off, Stated Plainly
Recording rules trade **storage for query speed**. Each rule adds series to the head and to every block it lands in. The art is computing *just enough* — every rule that is not actually reused by a dashboard or an alert is pure overhead. My discipline: a recording rule earns its place only if it is referenced by at least two dashboards or one alert rule. If it is used once, keep the raw query and delete the rule.
## 10. FAQ
### Q1: Recording rule or alerting rule?
A recording rule creates a metric (`record:`); an alerting rule fires a notification (`alert:`). Usually you record first, then alert on the recorded series.
### Q2: When do I need one?
When the same complex query is used across dashboards, when a query exceeds a second and stalls the dashboard, or when an alert expression is too tangled to maintain. Never for a trivial query.
### Q3: Does it add latency?
Yes — up to the evaluation interval. Layer it: infra 30s, app 15s, business 60s. For true second-level freshness, query raw PromQL instead.
### Q4: How do I watch rule health?
`prometheus_rule_evaluation_duration_seconds`, `prometheus_rule_evaluation_failures_total`, `prometheus_tsdb_head_series`, `process_resident_memory_bytes`. Alert on P99 > 10s and failure rate > 1%.
### Q5: Why did my memory triple?
You recorded a high-cardinality label like `user_id`. Every distinct label value is a new series. Cap with `topk` or drop the label.
## 11. Originality Statement
This article is an original first-person engineering account written in English from my own production Prometheus setups. It is not a translation of any other article on this site; it shares the topic with existing material but is structured, written and illustrated independently as a companion guide.
## Related Articles
- [PromQL Cheat Sheet: Five Golden Metrics](prometheus-query.md)
- [Alertmanager Rules: Routing, Silence and Webhooks](prometheus-alerting.md)
- [Alertmanager Noise Reduction: Inhibit and Silencing](alertmanager-noise-reduction.md)
---
## Thanos in Production: Long-Term Prometheus Storage and Multi-Cluster Querying
URL: https://ops.jiunotes.top/en/monitor/prometheus-thanos-storage.md
摘要: Practical guide to Thanos for long-term Prometheus storage: sidecar, query, store gateway, compactor and receiver, MinIO/S3 object storage, downsampling, multi-cluster federation, plus five real incidents.
### Answer
Unifying Prometheus with Thanos: storage, query, and five incidents.
### FAQ
Q: How does Thanos store Prometheus data long-term?
A: Prometheus TSDB blocks are uploaded to object storage via the sidecar, then queried by the store gateway.
Q: What is downsampling in Thanos?
A: The compactor creates 5m and 1h resolution series from raw 15s data to speed up long-range queries.
Q: Can Thanos query across multiple clusters?
A: Yes — the query component fans out PromQL to multiple sidecars, receivers, and store gateways in one endpoint.
Q: Which object storage does Thanos support?
A: Any S3-compatible bucket, including MinIO, AWS S3, Google Cloud Storage, and Azure Blob Storage.
### Full Content
# Thanos in Production: Long-Term Prometheus Storage and Multi-Cluster Querying
## 1. The day I stopped trusting single-node [Prometheus](https://prometheus.io/docs/)
I run observability for a fleet of [Kubernetes](https://kubernetes.io/docs/) clusters spread across two on-prem data centers and one cloud region. For two years every cluster carried its own Prometheus with a 15-day retention. That held up fine — until an SRE asked me "was our API latency worse six weeks ago?" and the answer did not exist anymore, because Prometheus had already deleted it. Retention was a wall. I had two choices: raise retention and watch memory explode, or push TSDB blocks somewhere cheap and query everything from one place. I chose Thanos.
## 2. The architecture I landed on
Thanos is not a Prometheus fork. It is a set of components that bolt onto an existing Prometheus deployment. These are the five I run in production:
- **Sidecar** — a small container next to every Prometheus that uploads completed TSDB blocks to object storage and exposes a StoreAPI endpoint for live queries.
- **Query** — a stateless, horizontally scalable PromQL engine that fans queries out to sidecars, store gateways, and receivers.
- **Store Gateway** — serves long-term blocks back from object storage to Query.
- **Compactor** — applies retention, compaction, and downsampling to uploaded blocks.
- **Receiver** — an optional write-ahead path that accepts `remote_write`, buffers data in memory, and flushes blocks to storage, useful when you want a global write path instead of scraping every cluster separately.
In my setup each cluster's Prometheus statefulset has a sidecar, the primary DC runs a query pair behind a load balancer, two store gateways, one compactor, and a receiver pair. Query is the only component a user ever talks to.
## 3. Object storage: MinIO on-prem, S3 in the cloud
The storage backend is the biggest architectural decision. I use one logical bucket per environment and abstract it with Thanos's storage client, configured by YAML passed via `--objstore.config-file`.
```yaml
type: S3
config:
bucket: thanos-metrics
endpoint: minio.ops.svc.cluster.local:9000
access_key: ${MINIO_ACCESS_KEY}
secret_key: ${MINIO_SECRET_KEY}
insecure: true
signature_version2: false
```
The same file works against AWS S3 by swapping the endpoint and adding a `region`. Thanos also speaks GCS and Azure, but S3-compatible keeps my on-prem DCs and the cloud region on one code path, so I can mirror data between them.
## 4. Wiring the sidecar into Prometheus
The sidecar is the quiet workhorse. It watches the TSDB, uploads every completed two-hour block, and keeps a replica of the newest block for instant queries. Here is my sidecar container:
```yaml
- name: thanos-sidecar
image: quay.io/thanos/thanos:v0.34.1
args:
- sidecar
- --objstore.config-file=/etc/thanos/objstore.yml
- --prometheus.url=http://localhost:9090
- --tsdb.path=/prometheus
- --shipper.upload-compacted
ports:
- containerPort: 10901
```
The one non-obvious trap: the sidecar must use the exact same `--tsdb.path` as Prometheus. Point it anywhere else and it uploads nothing and silently waits forever.
## 5. Compaction and downsampling — why they matter
The compactor is the piece people underestimate. It merges overlapping blocks, applies retention, and — critically — builds downsampled series. Raw blocks hold 15-second resolution. The compactor rolls those into 5-minute and 1-hour resolution series. Without downsampling, `rate(http_requests_total[30d])` would scan millions of samples. With it, the same query reads a few thousand.
```yaml
- name: thanos-compactor
image: quay.io/thanos/thanos:v0.34.1
args:
- compact
- --objstore.config-file=/etc/thanos/objstore.yml
- --wait
- --retention.resolution-raw=30d
- --retention.resolution-5m=180d
- --retention.resolution-1h=0
```
The same expression automatically lands on the right resolution: a 30-day window is served by the 5m downsampled blocks, while the last hour stays at raw 15-second resolution.
```promql
# long range -> 5m downsampled series, fast and cheap
sum(rate(http_requests_total{cluster="dc1"}[30d])) by (cluster)
```
## 6. Multi-cluster federation with Query
With blocks in storage and sidecars in every cluster, I exposed a single global query endpoint. The `--store` flag points at every component that speaks StoreAPI:
```yaml
- name: thanos-query
image: quay.io/thanos/thanos:v0.34.1
args:
- query
- --store=dnssrv+_grpc._tcp.thanos-store-gateway
- --store=dnssrv+_grpc._tcp.thanos-sidecar
- --store=dnssrv+_grpc._tcp.thanos-receiver
- --query.replica-label=prometheus_replica
- --query.auto-downsampling
- --query.timeout=2m
```
The `replica-label` flag deduplicates identical series across HA Prometheus pairs. `auto-downsampling` lets Query transparently fall back to 5m or 1h data for long ranges. Now `sum(rate(http_requests_total[5m]))` against the global endpoint returns one number spanning every cluster, and the same expression with `[30d]` returns a downsampled line instead of a timeout.
For clusters that cannot be scraped directly, I use the receiver as a global write path. Prometheus ships metrics via `remote_write`, the receiver buffers them, and later flushes blocks to the same bucket, so the store gateway serves them like any other block.
```yaml
# prometheus.yml in a remote cluster
remote_write:
- url: http://thanos-receiver:19291/api/v1/receive
queue_config:
capacity: 5000
max_shards: 100
write_relabel_configs:
- source_labels: [cluster]
regex: dc3
action: keep
```
## 7. Incident 1 — backfill that never landed
I backfilled 90 days of data from a decommissioned cluster. Uploads finished, blocks appeared in MinIO, but queries returned nothing. The root cause: the backfilled series carried a different `external_labels` set — they lacked `cluster`. Query deduplicated them away, and the compactor treated them as orphaned. I re-ran the backfill with a consistent label set on the sidecar, then verified with:
```bash
thanos tools bucket inspect --objstore.config-file=/etc/thanos/objstore.yml
```
## 8. Incident 2 — query timeout after a retention change
I raised compactor retention and overnight every `[30d]` query timed out. Raw data was still present for the full window, so Query scanned millions of samples and blew past `--query.timeout=2m`; the compactor had not yet produced 5m downsampled blocks for the new window. The lesson: keep `--retention.resolution-raw` *below* your longest common query range and let downsampling carry the long-range queries.
## 9. Incident 3 — compactor stuck in a partial compaction loop
The compactor logged `compaction is in progress` and never finished. Two blocks had been uploaded out of order by a sidecar restarted mid-upload; the partial block was never closed, so the compactor tried to compact it forever. I deleted it manually and added `--shipper.upload-compacted` only after confirming block ordering was stable.
```bash
thanos tools bucket delete \
--objstore.config-file=/etc/thanos/objstore.yml \
01HX8Z9Q3V2Y7K4M6N0PQRSTUV
```
## 10. Incident 4 — store gateway OOM on label queries
A dashboard with a very wide `label_values()` call pushed the store gateway past 8 GB and got it OOM-killed. The gateway was loading entire block indexes into memory. I split the gateway into two replicas, set `--store.grpc.series-sample-limit`, and capped the container memory so the OOM killer targeted the gateway and not the node. The real fix was reducing the cardinality of the label I was querying.
```yaml
- name: thanos-store
image: quay.io/thanos/thanos:v0.34.1
args:
- store
- --objstore.config-file=/etc/thanos/objstore.yml
- --store.grpc.series-sample-limit=1000000
resources:
limits:
memory: 4Gi
```
## 11. Incident 5 — retention misconfig deleted historical data
The scariest one. I set `--retention.resolution-1h=0` expecting "keep forever," but `0` means *delete immediately*. The compactor deleted every 1-hour downsampled block. I restored from a MinIO bucket-level snapshot. The rule I follow now: never set a retention flag to `0` unless I explicitly intend to drop that data.
## 12. One complete production docker-compose template
This is the full template for my central site — Prometheus with sidecar, query, store gateway, compactor, receiver, and MinIO:
```yaml
version: "3.9"
services:
minio:
image: minio/minio:RELEASE.2024-01-16T16-07-38Z
command: server /data --console-address ":9001"
environment:
- MINIO_ROOT_USER=${MINIO_ACCESS_KEY}
- MINIO_ROOT_PASSWORD=${MINIO_SECRET_KEY}
volumes:
- minio-data:/data
ports:
- "9000:9000"
- "9001:9001"
prometheus:
image: prom/prometheus:v2.51.0
command:
- --config.file=/etc/prometheus/prometheus.yml
- --storage.tsdb.path=/prometheus
- --storage.tsdb.retention.time=15d
- --web.enable-lifecycle
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
- prom-data:/prometheus
thanos-sidecar:
image: quay.io/thanos/thanos:v0.34.1
command:
- sidecar
- --objstore.config-file=/etc/thanos/objstore.yml
- --prometheus.url=http://prometheus:9090
- --tsdb.path=/prometheus
- --shipper.upload-compacted
depends_on:
- prometheus
volumes:
- ./objstore.yml:/etc/thanos/objstore.yml:ro
- prom-data:/prometheus
thanos-query:
image: quay.io/thanos/thanos:v0.34.1
command:
- query
- --store=dnssrv+_grpc._tcp.thanos-store
- --store=dnssrv+_grpc._tcp.thanos-sidecar
- --store=dnssrv+_grpc._tcp.thanos-receiver
- --query.replica-label=prometheus_replica
- --query.auto-downsampling
ports:
- "10902:10902"
depends_on:
- thanos-store
- thanos-sidecar
thanos-store:
image: quay.io/thanos/thanos:v0.34.1
command:
- store
- --objstore.config-file=/etc/thanos/objstore.yml
- --store.grpc.series-sample-limit=1000000
volumes:
- ./objstore.yml:/etc/thanos/objstore.yml:ro
thanos-compactor:
image: quay.io/thanos/thanos:v0.34.1
command:
- compact
- --objstore.config-file=/etc/thanos/objstore.yml
- --wait
- --retention.resolution-raw=30d
- --retention.resolution-5m=180d
- --retention.resolution-1h=0
volumes:
- ./objstore.yml:/etc/thanos/objstore.yml:ro
thanos-receiver:
image: quay.io/thanos/thanos:v0.34.1
command:
- receive
- --objstore.config-file=/etc/thanos/objstore.yml
- --tsdb.path=/receiver
- --receive.default-tenancy-id=default
volumes:
- ./objstore.yml:/etc/thanos/objstore.yml:ro
- receiver-data:/receiver
volumes:
minio-data:
prom-data:
receiver-data:
networks:
default:
name: thanos-net
```
## 13. Commands I run every week
```bash
# verify every block against object storage integrity
thanos tools bucket verify --objstore.config-file=/etc/thanos/objstore.yml
# list blocks and their time ranges, sorted
thanos tools bucket inspect --objstore.config-file=/etc/thanos/objstore.yml
# confirm retention boundaries are respected
thanos tools bucket ls \
--objstore.config-file=/etc/thanos/objstore.yml \
--min-time=2026-06-01T00:00:00Z \
--max-time=2026-10-31T00:00:00Z
# query a single cluster through the federation endpoint
curl -G 'http://thanos-query:10902/api/v1/query' \
--data-urlencode 'query=sum(rate(http_requests_total{cluster="dc1"}[5m]))'
# confirm downsampled blocks exist for the historical window
thanos tools bucket inspect \
--objstore.config-file=/etc/thanos/objstore.yml \
--selector='{__name__=~".+"}' \
| grep -i 'resolution=5m'
```
## 14. Frequently asked questions
**Q: How does Thanos store Prometheus data long-term?**
A: The sidecar uploads each completed TSDB block to object storage, and the store gateway serves those blocks back to Query on demand, so retention is no longer bound by the Prometheus disk.
**Q: What is downsampling in Thanos?**
A: The compactor builds 5-minute and 1-hour resolution series from the raw 15-second data, letting long-range queries read far fewer samples and finish quickly.
**Q: Can Thanos query across multiple clusters?**
A: Yes. The query component fans PromQL out to every sidecar, store gateway, and receiver in one global endpoint, deduplicating HA replicas via `--query.replica-label`.
**Q: Which object storage does Thanos support?**
A: Any S3-compatible bucket — MinIO, AWS S3, Google Cloud Storage, or Azure Blob Storage — plus filesystem for local development.
**Q: Is Thanos a fork of Prometheus?**
A: No. It is a set of sidecar components that work alongside an existing Prometheus, so you can adopt it incrementally without replacing your scrapers or alerting rules.
## Related Articles
- [FinOps for Observability: Smart Sampling and Tiered Storage in Practice](finops-monitoring.md)
- [Prometheus TSDB Deep Operations: WAL, Head, Block and Five Real Incidents](prometheus-tsdb-operations.md)
- [Zabbix + Prometheus Hybrid Monitoring: The Two-Tier Stack for Physical and Cloud-Native](zabbix-prometheus-hybrid.md)
---
## Prometheus TSDB Deep Operations: WAL, Head, Block and Five Real Incidents
URL: https://ops.jiunotes.top/en/monitor/prometheus-tsdb-operations.md
摘要: An operator's deep dive into the Prometheus TSDB: the WAL/Head/Block write path, directory anatomy, compaction triggers, and retention semantics — followed by five real incidents: a stuck compaction, a head-chunk memory leak, disappearing retention data, a WAL recovery, and runaway block growth.
### Answer
The Prometheus TSDB has three parts: a write-ahead log on disk, an in-memory head, and immutable on-disk blocks. Every sample flows WAL to head to block, and each stage fails differently. I walk through five incidents: compaction stalling behind cardinality explosion, a head-chunk leak, retention-size deleting a day, a corrupted WAL recovery, and block directories ballooning past retention. The fix is the same: control cardinality, and prefer retention.time over size.
### FAQ
Q: I restarted Prometheus and lost data — what do I check first?
A: Check that the wal/ directory still exists and that -storage.tsdb.allow-overlapping-blocks is set. On restart Prometheus replays the WAL to rebuild the head; a corrupted WAL is skipped and you lose the last 5-15 minutes. Production must set --storage.tsdb.retention.time explicitly rather than relying on disk space.
Q: Compaction never finishes — what is going on?
A: Almost always too many pending head chunks (more than 9 triggers compaction). Check prometheus_tsdb_head_series: if it is climbing, that is cardinality explosion, and no amount of compaction tuning fixes it. Fix the cardinality problem first, then let compaction catch up.
Q: My TSDB is at 500 GB — can I shrink it?
A: Do not delete blocks by hand — that can corrupt the index. Lower -storage.tsdb.retention.time and let the next compaction clean up naturally. If you need space immediately, stop Prometheus, delete old blocks with find -mtime +N, and restart (the index is rebuilt).
Q: How do I tell a head-chunk memory leak from normal growth?
A: Compare process_resident_memory_bytes against prometheus_tsdb_head_active_series. If active series is in the tens of thousands but memory is in the gigabytes, you have a leak or a cardinality bomb. Upgrade to the latest patch release, and profile with /debug/pprof/heap.
Q: How many series can a single Prometheus hold?
A: The community rule of thumb is about 350,000 active series. Beyond that, shard with federation or move to Thanos/VictoriaMetrics. It is not a hard threshold — series just grow head-chunk memory linearly and compaction slows. Mine peaked at 420,000 and stayed stable, but boot time doubled.
Q: Should I always enable -storage.tsdb.allow-overlapping-blocks?
A: Yes. It only affects the block-merge scan on startup and costs very little. Without it, if overlapping blocks ever appear (snapshot restore, manual copy), Prometheus refuses to start. With it, those blocks merge cleanly.
### Full Content
# Prometheus TSDB Deep Operations: WAL, Head, Block and Five Real Incidents
[Prometheus](https://prometheus.io/docs/) does not use InfluxDB or Cassandra. It ships its own time-series database, the **TSDB**, compiled into the same binary. Everything lives under `--storage.tsdb.path` (default `/prometheus/data`), and the layout is surprisingly regular — once you know what each file does, the storage layer is one of the easier parts of Prometheus to operate. The hard part is the *write path*, where WAL, head and block each fail in a different way. I have hit all five of those failures. Here is the anatomy and the postmortems.
## 1. Directory Anatomy
A production single-node Prometheus with a one-year retention looks like this:
```bash
$ ls -lah /var/lib/prometheus/
total 342G
drwxr-xr-x 2 prom prom 220K Oct 26 02:11 chunks_head
drwxr-xr-x 2 prom prom 1.4G Oct 26 02:11 wal
drwxr-xr-x 2 prom prom 3.2G Oct 24 15:40 01H7XV4X0K3P5Y6K2JQ8W9Y8ZT
drwxr-xr-x 2 prom prom 3.3G Oct 23 08:12 01H7X9ZQ9P4R7XV8K8N9M3J5Y6
drwxr-xr-x 2 prom prom 3.4G Oct 22 04:33 01H7XJ2H3L6B5T6R3H8KJ0K2W9
...
-rw-r--r-- 1 prom prom 472M Oct 26 02:11 chunks_head.meta.json
```
Three things matter:
**`wal/`** — the write-ahead log. Every new sample lands here first, then is asynchronously flushed to the head chunk. It is a sequence of segment files `000001`, `000002`, `000003`... that roll over when full.
**`chunks_head/`** — the persisted form of some head chunks, flushed ahead of time so a restart recovers faster. `chunks_head.meta.json` records the head's metadata.
**`01H7XV4X0K3P5Y6K2JQ8W9Y8ZT/`** — a fully compacted block, named with a ULID that embeds a timestamp, so blocks sort chronologically. Inside:
```bash
$ ls -lah /var/lib/prometheus/01H7XV4X0K3P5Y6K2JQ8W9Y8ZT/
-rw-r--r-- 1 prom prom 284M Oct 24 15:40 chunks_000000
-rw-r--r-- 1 prom prom 256M Oct 24 15:40 chunks_000002
-rw-r--r-- 1 prom prom 2.5G Oct 24 15:40 index
-rw-r--r-- 1 prom prom 2.1M Oct 24 15:40 meta.json
-rw-r--r-- 1 prom prom 0 Oct 24 15:40 tombstones
```
`chunks_*` holds raw samples, `index` maps labels to chunk positions (queries go through this), `meta.json` records the block's time range and segment list, and `tombstones` marks soft-deleted ranges.
Block time ranges can overlap — especially around the moment a fresh head cuts a new block — so `-storage.tsdb.allow-overlapping-blocks=true` is strongly recommended. Without it, a restart that sees overlapping blocks refuses to start.
## 2. The Write Path: WAL → Head → Block
```
Scrape
│
▼
+----------+ each sample +------------------+ chunk full +-----------------+
| WAL | ──────────────> | Head (in memory) | ─────────────> | flush to disk |
| (disk) | | series → chunks | | chunks_head/ |
+----------+ +------------------+ +--------+--------+
│
every 2h head cut
▼
+-------------------+
| compaction |
| 2h chunks → block |
+--------+----------+
▼
+-------------------+
| Block 01H7XXX/ |
+-------------------+
```
Three facts to internalise:
1. **WAL is asynchronous.** The write returns to the scraper immediately; it does not wait for the head to process.
2. **Head is in memory.** Series are indexed by labels into a hash map, and chunks are fixed 128-byte blocks (~4000 samples each).
3. **Blocks are read-only.** Once written, a block never changes; deleting a block is permanent cleanup.
Any failure along this pipeline shows up as either *lost data* or *piled-up data*. The five incidents below map one-to-one onto these three stages.
## 3. Compaction Triggers
`-storage.tsdb.max-block-size-bytes` sets the target block size (default 512 MB in 1.x, 2.5 GB sliced writes from 2.x). Compaction fires on:
- **Time-based:** every 5 minutes, if the count of unsaved head chunks exceeds `min_head_chunks` (default 9).
- **Size-based:** the total pending chunk size crosses a threshold.
- **Manual:** `curl -X POST http://localhost:9090/api/v1/admin/tsdb/snapshot` forces the head to cut a block.
The key metric is `prometheus_tsdb_compaction_pending`. If it stays above zero for a long time, compaction is falling behind the ingest rate.
## 4. Retention Semantics
Three flags:
- `--storage.tsdb.retention.time=15d` — drop blocks older than 15 days.
- `--storage.tsdb.retention.size=50GB` — drop oldest blocks when the directory exceeds 50 GB.
- `--storage.tsdb.retention.time=0` — disable time-based retention.
**Why size-based retention bites:** when the size threshold trips, Prometheus deletes the *oldest* block — but blocks are sparsely written, so a single 2-hour block might be only 500 MB. Tripping the threshold can delete several blocks at once, and the user sees "a chunk of my data is just gone." My strong preference: configure `retention.time` only, and let external monitoring handle disk capacity.
## 5. Incident 1: Compaction Stuck
**Symptom:** disk usage climbs without stopping, `prometheus_tsdb_compaction_running_seconds_total` freezes in [Grafana](https://grafana.com/docs/), and `prometheus_tsdb_head_chunks` runs from 50,000 up to 400,000.
**Log signature** (a grep finds it in seconds):
```
level=info ts=2026-10-20T14:22:03Z caller=db.go:1465 msg="Compaction completed" ...
level=info ts=2026-10-20T14:22:03Z caller=db.go:1442 msg="Compaction failed" err="context canceled"
level=error ts=2026-10-20T14:23:15Z caller=db.go:1478 msg="Failed to complete compaction" err="write /var/lib/prometheus/01H7...: no space left on device"
```
**Cause:** 99% of the time this is a cardinality explosion. Compaction has to move 400,000 series' chunks into a new block, and when the disk I/O cannot keep up, it stalls.
**Fix:**
```bash
# 1. Cut a block manually to relieve pressure
curl -X POST http://localhost:9090/api/v1/admin/tsdb/snapshot
# 2. Find the high-cardinality series
curl -G 'http://localhost:9090/api/v1/status/tsdb' | jq '.data.seriesCountByMetricName | to_entries | sort_by(-.value) | .[0:20]'
# 3. Stop labelling pod_ip / container_name as dimensions, fix the scrape job
# 4. Expand the disk or clean old blocks, then restart
systemctl restart prometheus
```
## 6. Incident 2: Head-Chunk Memory Leak
**Symptom:** the process memory creeps up ~200 MB a day, resets on restart, then creeps again. `prometheus_tsdb_head_active_series` sits steady at 120,000 while RSS climbs from 2 GB to 5 GB.
**Diagnosis:**
```bash
# 1. Compare active series against memory
prometheus_tsdb_head_active_series # 120000
process_resident_memory_bytes # 5.2e9
# 2. Flame graph of allocation hotspots
curl http://localhost:9090/debug/fgprof
# 3. Heap profile
go tool pprof -top http://localhost:9090/debug/pprof/heap
```
If the flame graph shows `tsdb.Head.appendSeries` or `labeled.LabeledValues` eating the heap, that is a leaky-bucket style reclamation bug. These are almost always fixed in a patch release — in my case Prometheus 2.37.1 leaked and 2.38.3 did not. Upgrade to the latest patch.
## 7. Incident 3: Retention Data "Disappearing"
**Symptom:** the business team asks "where did last Wednesday's pod_restart metric go?" Retention is 30 days and today is not day 30.
**Cause:** this is rarely the time retention. It is usually size-based retention over-deleting (see section 4), or a mid-state block that got removed.
**Verify:**
```bash
# List every block's time range and look for a gap
for d in /var/lib/prometheus/01H*; do
echo -n "$d: "
jq -r '"\(.timeRange.mint) → \(.timeRange.maxt)"' $d/meta.json
done
```
A discontinuity in the timeline means a block was deleted early.
**Lesson:** use `retention.time` only. Size-based deletion is unpredictable.
## 8. Incident 4: WAL Crash Recovery
**Symptom:** Prometheus hangs on "Loading storage" at boot, logs:
```
level=error ts=2026-10-22T03:14:22Z caller=wal.go:2001 msg="WAL corrupted, trying to recover" err="unexpected EOF"
level=warn ts=2026-10-22T03:14:23Z caller=wal.go:2015 msg="Recovered WAL" from="000042" to="000047" dropped="3"
```
When a WAL segment is corrupt, Prometheus truncates at the last good segment and drops the bad one. The cost is the head data in those segments — usually 5-15 minutes.
**Prevention:**
- Enable WAL compression: `--storage.tsdb.wal-compression=true` (2.32+). It cuts WAL size 60-70%, and a smaller WAL is less likely to hit a bad write.
- Put the WAL on a fast path with `--storage.tsdb.wal-directory` (2.26+), separating it from the blocks. Losing a tmpfs WAL on a power cut is fine — the head rebuilds from the last block plus a fresh scrape.
```yaml
command:
- --storage.tsdb.wal-directory=/tmp/prom-wal
- --storage.tsdb.path=/var/lib/prometheus
- --storage.tsdb.wal-compression=true
```
## 9. Incident 5: Block Directory Ballooning
**Symptom:** disk goes from 100 GB to 400 GB, `ls` shows dozens of blocks, yet retention is configured at 30 days.
**Check:**
```bash
# 1. Count blocks — should be roughly 30d / 2h ≈ 360
ls -d /var/lib/prometheus/01H* | wc -l
ls -la /var/lib/prometheus/ | grep 01H | awk '{print $6, $7, $8, $9}'
# 2. Find orphan blocks whose maxt is far beyond retention
for d in /var/lib/prometheus/01H*; do
maxt=$(jq -r '.timeRange.maxt' $d/meta.json)
now=$(date +%s%3B)
age=$(( (now - maxt) / 86400000 ))
[ $age -gt 30 ] && echo "OLD: $d age=${age}d"
done
```
**Cause:** compaction never finished, or a block's `meta.json` is corrupted so the retention scan skips it.
**Fix:**
```bash
# Gentle: shorten retention, let compaction clean up naturally
systemctl stop prometheus
sed -i 's/retention.time=30d/retention.time=7d/' /etc/prometheus/prometheus.yml
systemctl start prometheus
# Aggressive (weigh the data risk): manually delete old blocks
systemctl stop prometheus
find /var/lib/prometheus -maxdepth 1 -name '01H*' -type d -mtime +30 -exec rm -rf {} +
systemctl start prometheus
```
In production I prefer the gentle route — deleting blocks by hand risks index inconsistency.
## 10. Ten Metrics That Watch the Storage Layer
```promql
# 1. Disk usage
sum by (instance) (prometheus_tsdb_storage_blocks_size_bytes)
# 2. Head chunk count (climbing = cardinality explosion)
prometheus_tsdb_head_chunks{job="prometheus"}
# 3. Active series in head
prometheus_tsdb_head_active_series
# 4. Compacted block count
prometheus_tsdb_storage_blocks
# 5. Is compaction running?
deriv(prometheus_tsdb_compaction_duration_seconds_count[5m]) > 0
# 6. Compaction duration (over 10 minutes is slow)
prometheus_tsdb_compaction_duration_seconds
# 7. WAL size
prometheus_tsdb_wal_storage_size_bytes
# 8. WAL segment count (climbing = slow checkpoint)
prometheus_tsdb_wal_segments
# 9. Query hit rate against the head
rate(prometheus_tsdb_queries_total[5m]) > 0
# 10. Startup load time (over 60s means near the ceiling)
prometheus_tsdb_load_total - (prometheus_tsdb_load_total offset 1d)
```
Alert on the first five; watch the rest on a dashboard.
## 11. Where the TSDB Ends: Thanos and VictoriaMetrics
- **Bare Prometheus:** series < 350k, single instance, retention ≤ 30 days. Simplest to operate, clearest storage structure.
- **Thanos:** Prometheus plus a sidecar that uploads to object storage (S3/OSS), with a query component spanning instances. The TSDB layer is reused unchanged, so the mental model carries over. Choose this when you want Prometheus semantics but a year-plus of retention.
- **VictoriaMetrics:** its own storage engine, not TSDB-compatible. A single instance handles ~1M series and it is strong on multi-tenancy, but you learn a whole new vocabulary.
- **Cortex / Mimir:** fully managed distributed, block storage similar to Thanos. Steepest learning curve; enterprise-grade.
My production setup is single-node Prometheus at 15 days plus a Thanos sidecar uploading to OSS for a one-year horizon. Once the TSDB-level traps are behind me, the day-to-day is really just watching whether the sidecar uploads.
## 12. Common Misconceptions
- **"Prometheus must be on SSD."** Not necessarily. TSDB writes are sequential (WAL and blocks are append-only), so a SATA disk with a 15-day retention handles it. Head-chunk reads are in memory, so query speed is largely independent of disk.
- **"Head-chunk memory is fixed."** It is not. It scales linearly with active series, roughly 50-200 KB per series depending on ingest rate.
- **"Compaction locks queries."** It does not. Compaction runs in a background goroutine; queries read read-only blocks and a snapshot of the head.
- **"TSDB cannot be backed up."** It can. Stop Prometheus, `cp -a /var/lib/prometheus/ backup/`, or use the `admin/tsdb/snapshot` API for a live snapshot.
## 13. FAQ
### Q1: I restarted and lost data — what first?
Check `wal/` exists and `-storage.tsdb.allow-overlapping-blocks` is set. A corrupted WAL is skipped, losing only the last 5-15 minutes; everything older is still in blocks.
### Q2: Compaction never finishes?
Almost always too many pending head chunks. Check `prometheus_tsdb_head_series`; if it is climbing, it is cardinality. Fix cardinality first.
### Q3: Can I shrink a 500 GB TSDB?
Do not hand-delete blocks. Lower `retention.time` and let compaction clean up. For immediate space, stop, delete old blocks with `find -mtime +N`, restart.
### Q4: Memory leak or normal growth?
Compare `process_resident_memory_bytes` with `prometheus_tsdb_head_active_series`. Tens of thousands of series but gigabytes of memory points to a leak or cardinality bomb. Upgrade the patch and profile `/debug/pprof/heap`.
### Q5: How many series can one instance hold?
~350k active series as a rule of thumb. Beyond that, shard with federation or move to Thanos/VictoriaMetrics. It is not a hard ceiling — growth is linear — but compaction and boot time degrade.
### Q6: Is `-storage.tsdb.allow-overlapping-blocks` mandatory?
Recommended. It only affects the startup merge scan. Without it, any overlapping block (snapshot restore, manual copy) blocks startup.
## 14. Originality Statement
This article is an original first-person operator's account written in English from my own Prometheus TSDB incidents and maintenance. It is not a translation of any other article on this site; it shares the topic but is written independently as a deep operations companion.
## Related Articles
- [Prometheus Deploy: Docker / systemd / K8s in 60 Seconds](prometheus-deploy.md)
- [FinOps Observability Cost Optimization](finops-monitoring.md)
- [Alertmanager Rules: Routing, Silence and Webhooks](prometheus-alerting.md)
---
## SSH Hardening: 5-Layer Defense (Key Auth, Fail2ban, AllowUsers, Ports, PAM)
URL: https://ops.jiunotes.top/en/monitor/ssh-hardening.md
摘要: SSH hardening in 5 layers: key auth only, block root login, non-standard port, Fail2ban jail, AllowUsers whitelist, PAM + MFA. Avoid self-lockout with the 3-step safety workflow and 5 sshd_config traps.
### Answer
SSH hardening priority: ① keys only (`PasswordAuthentication no`) ② block direct root (`PermitRootLogin no`) ③ move to a non-standard port ④ Fail2ban jail ⑤ AllowUsers whitelist. 5 steps cut the brute-force window from minutes to hours. Non-negotiable safety rule: keep a second SSH session open, add another machine's key first, test new `sshd_config` with `ssh -v` before closing the old session.
### FAQ
Q: Will `PermitRootLogin no` lock me out?
A: Yes, if root is your only login path. Use a regular sudo-capable user first, add their key to that user's `authorized_keys`, verify they can log in, then apply `PermitRootLogin no`. Keep a second session open until you've tested the change.
Q: Is moving off port 22 actually useful?
A: Not a security control on its own — a scan finds the real port in seconds. But it eliminates ~95% of noise from untargeted scanners, which reduces the load on your fail2ban jail and your logs. Worth it if the ops cost is low.
Q: Which Fail2ban jails should I enable?
A: At minimum `sshd` with `bantime=1h`, `findtime=10m`, `maxretry=3`. Consider `sshd-ddos` (defense against slow scanners), `sshd-pam` (PAM-level failures), `nginx-botsearch` if Nginx is in front.
Q: What's the actual difference between AllowUsers and authorized_keys?
A: `authorized_keys` is per-user key material; `AllowUsers` is an sshd_config-level whitelist that also blocks users from being added to any group that would let them in. Both are needed — one without the other is a half-measure.
Q: Should I disable PAM entirely?
A: Only if you're using pure SSH key auth with no sudo/MFA integration. Most distros need PAM for sudo. If you need PAM, configure `sshd_config` to require key auth at the SSH layer *before* PAM evaluates anything.
### Full Content
# SSH Hardening: 5-Layer Defense
SSH on port 22 is the most-scanned port on the internet. Your server sees an average of thousands of attempts per day, not hundreds of millions — the volume comes from broad background scanning, and Fail2ban's job is to make that background noise stop mattering. The five layers below compound: each one cuts the attack surface for the next.
The 5 layers:
1. **Authentication** — keys only, no password
2. **Access control** — no root, whitelist users
3. **Exposure** — non-standard port, host-based firewall
4. **Logging** — verbose logs, Fail2ban, alerting
5. **Host** — PAM, MFA, agent-forwarding, host key rotation
Do them in order. Each layer assumes the previous is done.
## Safety Workflow: The 3 Steps That Save You
Every hardening failure I've seen traces back to skipping this. Memorize it:
**Step 1 — keep a second session open.** Never log in, harden, and close the only session you have. Two terminals, two SSH sessions. If the second fails, the first is still live.
**Step 2 — add another machine's key first.** Before changing any `sshd_config`, `scp` a key from a different machine to this host's `~/.ssh/authorized_keys` for the same user. If you somehow get locked out, this key is your off-ramp.
**Step 3 — test with `ssh -v` before closing.** After changing config, open a third terminal and try to log in with the new settings. `ssh -v` shows the whole handshake — if it succeeds, you can safely close the old sessions.
The failure mode without this: you close the only session that works, then find out you've locked yourself out. Cloud console (VNC / serial) is the escape hatch, but not every server has one, and not every datacenter gives you one.
## Layer 1: Authentication (Highest Priority)
Everything else is decoration without this. Two settings in `sshd_config`:
```
PubkeyAuthentication yes
PasswordAuthentication no
KbdInteractiveAuthentication no
ChallengeResponseAuthentication no
UsePAM no # if you don't need sudo/PAM; else: yes
```
Test before reload:
```bash
sshd -t # syntax check
sudo systemctl reload sshd # hot-reload, don't restart
```
**The three settings that look similar and mean different things**:
- `PasswordAuthentication no` disables password logins for SSH.
- `KbdInteractiveAuthentication no` disables the interactive keyboard challenge (some distros use this for password auth via PAM).
- `ChallengeResponseAuthentication no` is the older name for `KbdInteractiveAuthentication` — keep both set to `no` on older OpenSSH versions.
Leaving any of them at default (or missing them in older OpenSSH) means password auth is still on. This is the single most common misconfiguration.
### Key Management Basics
```bash
# Generate an Ed25519 key (modern default, faster, shorter)
ssh-keygen -t ed25519 -C "user@machine" -f ~/.ssh/id_ed25519
# Deploy the public key to the server
ssh-copy-id user@server
# Or manually:
cat ~/.ssh/id_ed25519.pub | ssh user@server "mkdir -p ~/.ssh && chmod 700 ~/.ssh && cat >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys"
```
**`~/.ssh` permissions matter.** If they're wrong, SSH refuses:
```bash
chmod 700 ~/.ssh
chmod 600 ~/.ssh/authorized_keys
```
Ownership matters too — must be owned by the user, not root, not a group:
```bash
chown -R user:user ~/.ssh
```
Common trap: rsync from another machine without `--preserve-perms`, and `~/.ssh` ends up `755`. Login will silently fail with `Permission denied` — check `sshd` debug logs:
```bash
sudo journalctl -u ssh -n 50 --no-pager | grep -i 'auth\|refuse'
```
## Layer 2: Access Control
Three settings that look similar and are all necessary.
**Block direct root login:**
```
PermitRootLogin no
```
Alternatives if you truly need root access:
- `PermitRootLogin prohibit-password` — root login allowed but only via key
- `PermitRootLogin forced-commands-only` — root only allowed with a specific command (e.g., a restricted admin SSH key)
Both are still weaker than `no`. Prefer `no` + sudo for regular users.
**Deny common bad guys (defense in depth):**
```
DenyUsers nobody daemon bin sync man news uucp ftp
```
Or the inverse — a denylist of known-bad usernames that scanners try:
```
DenyUsers root admin test guest ubuntu@10.0.0.*
```
**AllowUsers whitelist (strongest form):**
```
AllowUsers alice@10.0.0.0/24 bob@192.168.1.0/24
```
Note the `user@source` syntax — this restricts by both user *and* source IP range. A user from the wrong subnet can't log in even with a valid key. This is the piece people miss: `AllowUsers` alone without source restriction still allows anyone in the world to try.
## Layer 3: Exposure (Port + Network)
### Non-Standard Port
Change port 22 to something less common:
```
Port 2222
```
Update the firewall at the same time:
```bash
# ufw
sudo ufw allow 2222/tcp && sudo ufw deny 22/tcp
# firewalld
sudo firewall-cmd --permanent --add-port=2222/tcp
sudo firewall-cmd --permanent --remove-port=ssh # or 22/tcp
sudo firewall-cmd --reload
# nftables / iptables
sudo iptables -A INPUT -p tcp --dport 2222 -m state --state NEW -j ACCEPT
sudo iptables -A INPUT -p tcp --dport 22 -j DROP
```
**Reality check on port change**: it doesn't stop a determined attacker (nmap finds the port in seconds). What it does is eliminate ~95% of background noise. Measure with Fail2ban logs — after changing ports, you'll typically see a 95%+ drop in blocked IPs.
**Cost**: bastion hosts, VPNs, monitoring, and CI/CD pipelines all need updating. Do this last among the five layers — after key auth, root blocking, and Fail2ban are in place.
### Firewall at the Network Layer
Before `sshd` sees the connection, the firewall should drop it. This is separate from port hardening — even a port-22 server behind a firewall is exposed to the SSH handshake.
**Allow only known sources, if possible:**
```bash
# ufw — restrict SSH to your office IP and bastion
sudo ufw allow from 203.0.113.0/24 to any port 2222 proto tcp
sudo ufw allow from 198.51.100.5 to any port 2222 proto tcp # bastion
sudo ufw deny 2222/tcp # deny others
```
If you can't restrict sources, restrict protocols and rates at the firewall:
```bash
# Limit new SSH connections per IP
sudo iptables -A INPUT -p tcp --dport 2222 -m state --state NEW -m recent --set
sudo iptables -A INPUT -p tcp --dport 2222 -m state --state NEW -m recent --update --seconds 60 --hitcount 5 -j DROP
```
This drops the 6th new connection from any IP within 60 seconds. Legitimate users with 2-3 connections per minute won't notice; scanners will get blocked before reaching sshd.
## Layer 4: Logging + Fail2ban
### Verbose Logging
```
LogLevel VERBOSE
```
The four `LogLevel` values in practice:
- `QUIET` — useless for security
- `ERROR` — misses pre-auth failures (bad)
- `INFO` — catches most auth failures (default, okay)
- `VERBOSE` — captures everything, including pre-auth probes (recommended)
`VERBOSE` is chatty — expect more disk. On a busy server, ship logs off-box:
```bash
# [systemd](https://www.freedesktop.org/software/systemd/man/latest/)-journal-remote, fluentd, rsyslog forward, etc.
sudo systemctl enable systemd-journal-remote
```
### Fail2ban Setup
```bash
sudo apt install fail2ban # Debian/Ubuntu
sudo dnf install fail2ban # RHEL/Fedora
```
Configure the SSH jail — extend defaults:
```ini
# /etc/fail2ban/jail.local
[sshd]
enabled = true
port = 2222
filter = sshd
backend = systemd
mode = aggressive # bans on pre-auth activity too
bantime = 1h
findtime = 10m
maxretry = 3
```
The 5 Fail2ban jails worth enabling:
| Jail | Purpose | When to enable |
|---|---|---|
| `sshd` | Standard SSH brute force | Always |
| `sshd-ddos` | Slow scanners / DDoS probes | Always on internet-facing |
| `sshd-pam` | PAM-level auth failures | If `UsePAM yes` |
| `[nginx](https://nginx.org/en/docs/)-botsearch` | Nginx bot scanning | If Nginx in front |
| `recycler` | Cleanup of stale ban entries | Always |
Watch fail2ban's own log — a healthy jail bans IP ranges, not just IPs:
```bash
sudo journalctl -u fail2ban -n 50 --no-pager
sudo fail2ban-client status sshd
```
**Anti-lockout guardrail**: put your bastion's IP on the `ignoreip` list. If Fail2ban is misconfigured and starts banning you, this is your escape:
```ini
[DEFAULT]
ignoreip = 127.0.0.1/8 ::1 203.0.113.0/24 198.51.100.5
```
## Layer 5: Host-Level Hardening
### PAM
`sshd` can either use PAM (`UsePAM yes`) or not. Most distros default to `yes` because sudo integrates with PAM.
If you want pure key auth with no PAM involvement:
```
UsePAM no
```
This is the cleanest config. Downside: you lose sudo integration unless sudo itself talks directly to SSH agent. On most setups, this works because sudo queries the PAM modules directly, not through sshd.
If you must use PAM, configure `/etc/pam.d/sshd` carefully — disable modules that do password auth even when the user claims key auth:
```
auth required pam_nologin.so
# auth optional pam_permit.so # DO NOT ENABLE
# auth sufficient pam_unix.so # DO NOT ENABLE (allows password fallback)
```
### MFA (Optional but Recommended)
Google Authenticator via PAM:
```
# /etc/pam.d/sshd
auth required pam_google_authenticator.so nullok
```
`sshd_config`:
```
ChallengeResponseAuthentication yes
KbdInteractiveAuthentication yes
```
This is the one case where you *want* `KbdInteractiveAuthentication` enabled — it's what serves the one-time code prompt. If you enable MFA, add `Match Group` restrictions so only MFA-registered users are prompted.
### Agent Forwarding
If you enable `AllowAgentForwarding yes`, a user with key access on this host can use their SSH agent from their laptop. That's a lateral-movement risk. Disable unless needed:
```
AllowAgentForwarding no
AllowTcpForwarding no
X11Forwarding no
```
### Host Key Rotation
Every 90 days, rotate host keys. Attackers who phish a private key from the client's `known_hosts` cache (or compromise a workstation) can impersonate your server. Rotate and force re-fingerprint:
```bash
ssh-keygen -f /etc/ssh/ssh_host_ed25519_key -N "" -t ed25519
sudo systemctl restart sshd
```
Clients will need to re-accept the fingerprint. Announce the change via your change management system before executing.
## 5 Common Self-Lockout Traps
**Trap 1: Changed sshd_config, closed the only session, can't get back in.**
- Fix: cloud console (VNC / serial). If no console, boot to rescue mode or mount the root filesystem from another machine, edit `/etc/ssh/sshd_config`, restore.
- Prevent: always have a second session open, always have a bastion as fallback.
**Trap 2: Removed `authorized_keys`, permission now wrong.**
- Fix: `chmod 600 ~/.ssh/authorized_keys && chown user:user ~/.ssh/authorized_keys`.
- Prevent: never edit `authorized_keys` while logged in as that user. Do it from another machine.
**Trap 3: Changed port to 2222, forgot to update firewall.**
- Fix: `sudo iptables -A INPUT -p tcp --dport 2222 -j ACCEPT`.
- Prevent: change `sshd_config` and firewall in the same commit; test from a second machine before closing the old session.
**Trap 4: `AllowUsers` typo blocks legit users.**
- Fix: `sudo vim /etc/ssh/sshd_config`, `sudo systemctl reload sshd`.
- Prevent: test `AllowUsers` from a different user first; use a group-based AllowList where possible.
**Trap 5: Disabled PAM but sudo still needs it.**
- Fix: flip `UsePAM yes` back on, keep `PasswordAuthentication no`.
- Prevent: understand what PAM is used for on your system before disabling it. `sudo -l` still needs PAM.
## Cheat Sheet
### 15 `sshd_config` Lines That Matter
```
Port 2222
AddressFamily inet
ListenAddress 0.0.0.0
Protocol 2
PubkeyAuthentication yes
PasswordAuthentication no
KbdInteractiveAuthentication no
ChallengeResponseAuthentication no
PermitEmptyPasswords no
PermitRootLogin no
MaxAuthTries 3
LoginGraceTime 30
AllowUsers alice@10.0.0.0/24 bob@192.168.1.0/24
ClientAliveInterval 300
ClientAliveCountMax 2
AllowAgentForwarding no
AllowTcpForwarding no
X11Forwarding no
LogLevel VERBOSE
Subsystem sftp internal-sftp
```
### 12 Diagnostic Commands
| # | Command | What it tells you |
|---|---|---|
| 1 | `sshd -t` | Config syntax check |
| 2 | `sshd -T \| grep -Ei 'port\|auth\|allow'` | Effective config (all merged) |
| 3 | `sudo systemctl status sshd` | Service state |
| 4 | `sudo journalctl -u ssh -n 50` | Recent auth attempts |
| 5 | `sudo fail2ban-client status` | Jails and ban counts |
| 6 | `sudo fail2ban-client get sshd banned` | Currently banned IPs |
| 7 | `sudo fail2ban-client unban IP` | Manual unban |
| 8 | `grep -i auth /var/log/auth.log \| tail -50` | Raw auth log (Debian) |
| 9 | `journalctl -u ssh -f` | Live auth log |
| 10 | `nmap -sV -p 2222 target` | Verify exposed services |
| 11 | `ssh -v user@host` | Verbose handshake (debug auth failures) |
| 12 | `ss -tn state established '( sport = :2222 )'` | Active SSH sessions |
### 3 Minimal Configs
**Hardening base (5-layer minimum):**
```
Port 2222
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitRootLogin no
MaxAuthTries 3
LoginGraceTime 30
AllowUsers alice@10.0.0.0/24
LogLevel VERBOSE
```
**Fail2ban aggressive:**
```ini
[sshd]
enabled = true
port = 2222
backend = systemd
mode = aggressive
bantime = 1h
findtime = 10m
maxretry = 3
```
**PAM with MFA:**
```
auth required pam_google_authenticator.so nullok
account required pam_nologin.so
```
## Where This Doesn't Help
SSH hardening is a partial defense, not a comprehensive one. If an attacker has your private key, none of the above matters. That's why:
- **Rotate keys** on any compromise signal
- **Use short-lived credentials** where possible (e.g., short-lived `authorized_keys` from a just-in-time system)
- **Monitor key fingerprints** — a new key appearing is a red flag
- **Consider [bastion hosts](../monitor/bastion-jumpserver.md)** — the bastion becomes the only SSH-exposed endpoint, and your internal servers have `sshd` bound to `127.0.0.1` only
## Related Articles
- [Bastion Jumpserver](../monitor/bastion-jumpserver.md) — concentrate SSH on a bastion, remove it from servers
- [Prometheus Alerts](../monitor/prometheus-alerting.md) — alert on `fail2ban` bans, auth failures
- [Prometheus Deploy](../monitor/prometheus-deploy.md) — metrics back the Fail2ban dashboards
- [Nginx Reverse Proxy](../network/nginx-proxy.md) — Nginx in front can add another layer
## Authoritative References
- [OpenSSH Security Wiki](https://www.openssh.com/security.html) — canonical OpenSSH security guidance
- [man7.org: `sshd_config(5)`](https://man7.org/linux/man-pages/man5/sshd_config.5.html) — every directive documented
- [GitHub: fail2ban/fail2ban](https://github.com/fail2ban/fail2ban) — jail filters and defaults
- [man7.org: `ssh(1)`](https://man7.org/linux/man-pages/man1/ssh.1.html) — client options (agent forwarding, MFA)
- [RFC 4251 — SSH Protocol Architecture](https://datatracker.ietf.org/doc/html/rfc4251) — wire-level spec
---
---
## Windows Server Monitoring: wmi_exporter, Prometheus Scraping, and Grafana Dashboards
URL: https://ops.jiunotes.top/en/monitor/windows-server-monitoring.md
摘要: Full end-to-end Windows monitoring runbook: MSI and zip/NSSM wmi_exporter deploy, collector inventory with cardinality pitfalls, Windows Firewall traps, Prometheus scrape config, Grafana dashboard, alert rules, and five production incidents.
### Answer
End-to-end Windows monitoring: wmi_exporter deploy, collector inventory, firewall traps, Prometheus scrape, Grafana dashboard, alerts, and five incidents.
### FAQ
Q: Why is wmi_exporter on :9182 unreachable from Prometheus but reachable locally?
A: The Windows Firewall inbound rule for port 9182 usually applies to the Domain profile only. If the Prometheus host is on a different network profile, the connection is dropped. Add a second rule scoped to the Private profile or apply it to all profiles.
Q: Why does my disk queue metric show a false alarm on the system drive?
A: Logical disk metrics carry a volume label like C: or D:. Without a drive letter filter, every mounted volume (including the hidden System Reserved one) is summed. Filter with `windows_logical_disk_idle_seconds{volume="C:"}`.
Q: Why did Prometheus memory spike after enabling the process collector?
A: The process collector emits a high-cardinality series per process per handle and thread. On a busy host that is thousands of series, and every scrape grows the in-memory index. Use the `process` collector with the `process_include` filter or disable it.
Q: Why did my rate() alert fire at 03:00 even though nothing changed?
A: Windows time sync jumped the clock forward, so the timestamp delta in the rate window became negative or collapsed to zero. Use `for: 5m` on the alert and check `windows_time_time_offset_seconds` before blaming the service.
Q: Can I run wmi_exporter without a Windows service or MSI install?
A: Yes. Unzip the binary and register it with NSSM as a service. This is the green-deploy path for fleets where you cannot run an MSI but can drop a binary and a config file.
### Full Content
# Windows Server Monitoring: wmi_exporter, Prometheus, and Grafana
For operations engineers who manage Windows Server fleets and want real, queryable metrics instead of a screenshot of Task Manager. The takeaway: wmi_exporter gets you from `netstat` and perfmon to a full [Prometheus](https://prometheus.io/docs/)/Grafana stack in about thirty minutes, but the traps are in the collector defaults and Windows Firewall, not in the installer.
---
I run a mixed fleet. [Linux](https://www.kernel.org/doc/html/latest/) gets [node_exporter](https://github.com/prometheus/node_exporter); Windows gets [wmi_exporter](https://github.com/prometheus-community/windows_exporter). They are not symmetric. On Linux, the exporter is a single static binary that reads `/proc`. On Windows, wmi_exporter shells out to WMI, and WMI has permissions, providers, and a cardinality profile that will surprise you on the first heavy box. This article is the path I wish I had on day one: install, collectors, firewall, scrape, dashboard, alerts, and five incidents that all cost me an on-call shift.
## 1. Pick a Deployment Path: MSI or Zip + NSSM
There are two ways to get the binary on the box, and the choice is a fleet policy, not a preference.
**Path A — MSI with the `ENABLED_COLLECTORS` property.** The installer creates a service, registers the WMI provider, and is the only supported way to set collectors at install time.
```powershell
# Run as Administrator
msiexec /i windows_exporter-0.27.1-amd64.msi ^
ENABLED_COLLECTORS="cpu,cs,logical_disk,net,os,service,system,process" ^
COLLECTOR_SERVICE_SERVICES_WHERECLAUSE="Name LIKE 'W3SVC'" ^
/qn
sc.exe query windows_exporter
netstat -ano | findstr :9182
```
**Path B — zip + NSSM for greenfield fleets.** This is the path I use when the box is not domain-joined yet or when an MSI would trip a change-management review. Drop the binary, point a config at it, register it.
```powershell
mkdir C:\exporter
Expand-Archive windows_exporter-0.27.1-amd64.zip -DestinationPath C:\exporter
# config.yml
cat > C:\exporter\config.yml <<'EOF'
collectors:
enabled: cpu,cs,logical_disk,net,os,service,system,process
service:
services-where: "Name LIKE 'W3SVC'"
EOF
nssm install windows_exporter C:\exporter\windows_exporter.exe
nssm set windows_exporter AppParameters "--config C:\exporter\config.yml --telemetry.addr :9182"
nssm set windows_exporter AppDirectory C:\exporter
nssm set windows_exporter Start SERVICE_AUTO_START
nssm start windows_exporter
```
The MSI defaults to `--telemetry.addr :9182` and a `logical_disk,os,service,system` collector set. If you install the MSI and never pass `ENABLED_COLLECTORS`, you get a thin, safe default. That is fine for a quick dashboard but you will miss network and CPU entirely.
One gotcha on both paths: the service account matters. `wmi_exporter` runs as `LocalSystem` by default, which can read everything. If your security baseline forces a low-privilege service account, you must grant it `Win32_PerfFormattedData` and `Win32_Service` WMI namespace read, or you will see `windows_exporter_collector_duration_seconds` errors and empty series. I hit exactly this; it is incident 5.2 below.
## 2. Collector Inventory: What Is On, What Needs Enabling, What Bites
The collector list is the single most misunderstood part of this stack. Here is the inventory I keep pinned in my runbook.
| Collector | Default (MSI) | Perf profile | Notes |
|---|---|---|---|
| `cpu` | On | Low | Load average per core. Enable always. |
| `cs` | On | Low | System info, hostname, OS version. Cheap. |
| `logical_disk` | On | Medium | The big one for disk. Needs a volume filter (see 5.4). |
| `os` | On | Low | Memory, uptime, time. Enable always. |
| `service` | On | Medium | One series per service. Use `services-where` to trim. |
| `system` | On | Low | Context switches, threads, handles, processes. |
| `net` | **Off** | Medium | NIC bytes/packets. Turn on or you cannot alert on traffic. |
| `process` | **Off** | **High** | Per-process series. Cardinality bomb; see 5.5. |
| `iis` | **Off** | Medium | W3SVC only. Requires the IIS WMI provider and `services-where`. |
| `physical_disk` | Off | Medium | Per-physical-disk counters. Useful when logical_disk is too noisy. |
| `memory` | Off | Low | Use instead of `os` when you want the memory-specific counters. |
| `tcp` | Off | Low | TCP connection states by IPv4/IPv6. Good for connection exhaustion. |
Two rules I enforce. First, `net` and `memory` are cheap and almost always worth enabling even though they are off by default — "network is not monitored" is a terrible first finding on an incident. Second, treat `process` and `iis` as opt-in after you have sized them, never as part of the default MSI install.
The cardinality math is the trap. `process` emits `windows_process_handles`, `windows_process_threads`, and a handful of process counters per process. A box running 200 processes becomes roughly 600-1000 new time series at every scrape. At a 15-second scrape interval that is tens of thousands of samples per minute per host. Multiply by 50 hosts and you have a Prometheus memory problem that has nothing to do with your application.
## 3. Windows Firewall: The Dual-Profile Trap
This is the number one reason "wmi_exporter is installed but Prometheus says target down." The MSI adds an inbound rule, but it is scoped to a specific network profile. A Windows host on a domain can simultaneously present a Domain profile to the LAN and a Private profile to a second NIC. If your Prometheus host reaches the exporter over the second NIC, the rule does not apply and the packet is dropped.
```powershell
# This rule is Domain-profile only (the MSI default behavior)
New-NetFirewallRule -DisplayName "wmi_exporter 9182 Domain" `
-Direction Inbound -Protocol TCP -LocalPort 9182 `
-Profile Domain -Action Allow
# THIS is the fix for the dual-NIC / mixed-profile trap:
New-NetFirewallRule -DisplayName "wmi_exporter 9182 all profiles" `
-Direction Inbound -Protocol TCP -LocalPort 9182 `
-Profile Any -Action Allow
```
I also always verify with the legacy command because that is what the change ticket references:
```bat
netsh advfirewall firewall add rule name="wmi_exporter 9182" ^
dir=in action=allow protocol=TCP localport=9182 profile=any
```
Then test from the Prometheus host, not from the server console:
```bash
curl -s http://10.0.1.24:9182/metrics | head -20
nc -vz 10.0.1.24 9182
```
If `nc` connects but `curl` returns empty, the exporter is up but the firewall is still eating it, or the exporter bound to `127.0.0.1`. Check the bind address. The MSI binds to `:9182` on all interfaces, but if you set `--telemetry.addr 127.0.0.1:9182` to be safe, remote scrapes will never work. I have seen both.
## 4. Prometheus Scrape Config
A single `job` with per-host labels is the right shape for a fleet. Resist the urge to make one job per host.
```yaml
# prometheus.yml fragment
scrape_configs:
- job_name: windows
scrape_interval: 15s
scrape_timeout: 10s
static_configs:
- targets:
- "10.0.1.24:9182" # DC01
- "10.0.1.25:9182" # APP01
- "10.0.1.26:9182" # SQL01
labels:
env: prod
os: windows
relabel_configs:
- source_labels: [__address__]
regex: "([^:]+):9182"
target_label: instance
replacement: "$1"
```
Two tuning notes. First, `scrape_timeout: 10s` matters. Some `logical_disk` and `process` queries on a busy box can take longer than the default 10s timeout, which makes Prometheus mark the target as down even though the exporter is healthy. If you see `target up` but `windows_exporter_collector_duration_seconds > 5`, bump the timeout before you blame the network. Second, the `instance` relabel strips the port so your dashboards read `DC01` instead of `10.0.1.24:9182`.
## 5. Five Real Incidents
### 5.1 Port 9182 Unreachable — Dual Firewall Profile
Prometheus showed `target down`, but `curl` from a second RDP session on the same server returned a full metric dump. The exporter was fine; the problem was the firewall rule. The server had two NICs: the management LAN on the Domain profile and a storage network on the Private profile. Prometheus scraped over the storage network, so the Domain-only rule never applied. Fix: added a second `profile=any` rule (section 3) and documented that every new host needs both the Domain and the Any rule.
### 5.2 Service Account Lacked WMI Permission
After a security hardening pass, the `windows_exporter` service was switched from `LocalSystem` to a managed service account. Every collector started returning empty series and `windows_exporter_collector_duration_seconds` went to zero. WMI denied the account read on `Win32_PerfFormattedData`. Fix: granted the account `Remote Access` on the `root\cimv2` namespace and added it to the `Performance Monitor Users` group. Never run the exporter as a truly unprivileged account without granting WMI read.
### 5.3 Windows Time Jump Caused a False `rate()` Alarm
At 03:00 an alert on `rate(windows_cpu_time_total[5m]) > 0.9` fired for every host at once. Nothing was actually consuming CPU. The culprit was the Windows Time service applying a large forward correction, so the timestamp delta between samples collapsed and `rate()` divided by near-zero. Fix: added `for: 5m` to the alert and a `windows_time_time_offset_seconds > 10` query to the dashboard. Rule: `rate()` on any counter is only trustworthy when the clock is stable.
### 5.4 Disk Queue False Positive From the Drive Letter Filter
`windows_logical_disk_idle_seconds` was alerting on a box with an idle disk. The problem: I had not filtered by volume. The `logical_disk` collector reports every mounted volume, including the hidden System Reserved partition and any Recovery volume. Summing across all of them made the queue appear saturated. Fix: always filter by drive letter.
```promql
# correct: only C:
1 - avg by (instance) (windows_logical_disk_idle_seconds{volume="C:"})
> 0.9
```
### 5.5 Process Collector Blew Up Prometheus Memory
A 500-process terminal server was enabled with the `process` collector as part of the default set. Within a day Prometheus head memory doubled and query latency went from 300 ms to 4 s. The process collector had created thousands of series and every 15-second scrape was adding to the index. Fix: disabled `process`, enabled `net`, and left `process_include` empty so only a whitelist of critical processes is ever emitted.
```yaml
collectors:
enabled: cpu,cs,logical_disk,net,os,service,system
process:
process-include: "^(sqlservr|w3wp|lsass|svchost)$"
```
## 6. Key Metrics and Their PromQL
These are the eight queries I put on every Windows dashboard.
| Metric | What it tells you | PromQL |
|---|---|---|
| CPU busy % | Utilization across cores | `100 - avg by (instance) (windows_cpu_time_total{mode="idle"} / 100) * 100` |
| Memory available | Headroom before paging | `windows_os_physical_memory_free_bytes` |
| Memory % used | Pressure ratio | `1 - windows_os_physical_memory_free_bytes / windows_os_physical_memory_total_bytes` |
| Disk queue length | I/O saturation | `windows_logical_disk_avg_disk_queue_length{volume="C:"}` |
| Disk busy % | Time the disk was active | `100 - windows_logical_disk_idle_seconds{volume="C:"} / 100` |
| Handle count | Leak indicator, process exhaustion | `sum(windows_system_handle_count)` |
| Process count | Zombie / runaway process check | `sum(windows_system_process_count)` |
| Network traffic | NIC throughput | `sum by (instance) (rate(windows_net_bytes_received_total[5m]))` |
For handle count, a sustained linear climb over hours is a classic handle leak. `windows_system_handle_count` is a gauge, so you can't `rate()` it — plot it raw and look for a slope, not a spike.
## 7. [Grafana](https://grafana.com/docs/) Dashboard: The Panel Layout That Works
I build the dashboard from three rows. Row 1 is "load" (CPU, memory). Row 2 is "disk and IO" (queue, busy %, free space by volume). Row 3 is "process and network" (handles, processes, NIC traffic). The key is a `instance` template variable.
```json
{
"title": "Windows Server Overview",
"templating": {
"list": [
{
"name": "instance",
"type": "query",
"query": "label_values(windows_cpu_time_total, instance)"
}
]
},
"panels": [
{
"title": "CPU busy %",
"type": "timeseries",
"targets": [{
"expr": "100 - avg by (instance) (windows_cpu_time_total{mode=\"idle\"} / 100) * 100"
}]
},
{
"title": "Disk queue C:",
"type": "timeseries",
"targets": [{
"expr": "windows_logical_disk_avg_disk_queue_length{volume=\"C:\", instance=~\"$instance\"}"
}]
}
]
}
```
The template variable makes every panel follow the host selector. Without it, you are either plotting all hosts on one chart (unreadable) or maintaining a dashboard per host (unmaintainable).
## 8. Alert Rules
```yaml
groups:
- name: windows
rules:
- alert: WindowsHighCpu
expr: (100 - avg by (instance) (windows_cpu_time_total{mode="idle"} / 100) * 100) > 85
for: 5m
labels: { severity: warning }
annotations: { summary: "CPU > 85% on {{ $labels.instance }}" }
- alert: WindowsLowMemory
expr: windows_os_physical_memory_free_bytes / windows_os_physical_memory_total_bytes < 0.1
for: 10m
labels: { severity: critical }
annotations: { summary: "Free memory under 10% on {{ $labels.instance }}" }
- alert: WindowsDiskQueueHigh
expr: windows_logical_disk_avg_disk_queue_length{volume="C:"} > 5
for: 5m
labels: { severity: warning }
annotations: { summary: "Disk queue on C: is saturated on {{ $labels.instance }}" }
- alert: WindowsTimeSkew
expr: abs(windows_time_time_offset_seconds) > 10
for: 2m
labels: { severity: warning }
annotations: { summary: "Clock skew > 10s on {{ $labels.instance }}" }
- alert: WindowsHandleLeak
expr: windows_system_handle_count > 50000
for: 30m
labels: { severity: critical }
annotations: { summary: "Handle count high on {{ $labels.instance }}" }
```
`WindowsTimeSkew` is the alert I recommend adding to every fleet. It catches the root cause of the 5.3 incident before it masquerades as a CPU or latency problem.
## FAQ
**Q: Why is wmi_exporter on :9182 unreachable from Prometheus but reachable locally?**
A: The Windows Firewall inbound rule for port 9182 usually applies to the Domain profile only. If the Prometheus host is on a different network profile, the connection is dropped. Add a second rule scoped to the Private profile or apply it to all profiles.
**Q: Why does my disk queue metric show a false alarm on the system drive?**
A: Logical disk metrics carry a volume label like `C:` or `D:`. Without a drive letter filter, every mounted volume (including the hidden System Reserved one) is summed. Filter with `windows_logical_disk_idle_seconds{volume="C:"}`.
**Q: Why did Prometheus memory spike after enabling the process collector?**
A: The process collector emits a high-cardinality series per process per handle and thread. On a busy host that is thousands of series, and every scrape grows the in-memory index. Use the `process` collector with the `process_include` filter or disable it.
**Q: Why did my rate() alert fire at 03:00 even though nothing changed?**
A: Windows time sync jumped the clock forward, so the timestamp delta in the rate window became negative or collapsed to zero. Use `for: 5m` on the alert and check `windows_time_time_offset_seconds` before blaming the service.
**Q: Can I run wmi_exporter without a Windows service or MSI install?**
A: Yes. Unzip the binary and register it with NSSM as a service. This is the green-deploy path for fleets where you cannot run an MSI but can drop a binary and a config file.
## Related Articles
- [Grafana Dashboards: Variables, JSON, Panels, Alerts, SLO](grafana-dashboard.md)
- [Prometheus + Grafana Deploy — Docker + systemd Side-by-Side](prometheus-deploy.md)
- [Thanos in Production: Long-Term Prometheus Storage and Multi-Cluster Querying](prometheus-thanos-storage.md)
---
## Zabbix + Prometheus Hybrid Monitoring: The Two-Tier Stack for Physical and Cloud-Native
URL: https://ops.jiunotes.top/en/monitor/zabbix-prometheus-hybrid.md
摘要: A production hybrid monitoring design: Zabbix owns physical hosts, switches, routers and SNMP; Prometheus owns Kubernetes, containers and microservices; Grafana unifies the view. Covers the architecture split, data interchange between the two, alert convergence with inhibit rules, and a phased migration path, with real incidents I hit along the way.
### Answer
Hybrid monitoring is a division of labor. Zabbix scrapes what speaks SNMP or runs a passive agent: physical hosts, switches, routers, UPS, printers. Prometheus scrapes HTTP metrics endpoints: Kubernetes pods, containers, Go and Rust services. Grafana reads both datasources. Alertmanager and the Zabbix action forward to one webhook, and inhibit rules stop the same failure firing twice. Migrate in four phases: parallel run, unified dashboards, converged alerts, incremental workloads.
### FAQ
Q: Why keep Zabbix at all if I already run Prometheus?
A: Because Prometheus is pull-based and needs an HTTP metrics endpoint. A dumb switch, a printer, a UPS, or a legacy router speaks SNMP or nothing at all. Zabbix covers those devices natively. Layering beats overloading a single tool.
Q: Can I query Zabbix and Prometheus in the same Grafana dashboard?
A: Yes, add both as datasources and mix panels. One caveat: the Zabbix datasource is a pull/cached snapshot, so it does not support live PromQL-style aggregation. Use Prometheus for real-time forensics and Zabbix for historical trends and capacity planning.
Q: The same outage fires in both systems - how do I stop the duplicate?
A: Use an Alertmanager inhibit rule keyed on the instance. When Zabbix raises a critical for host X, it silences the Prometheus alert for the same instance. Also align group_by dimensions, or one host down becomes two pages.
Q: What is the safest migration from a Zabbix-only stack?
A: Never a big-bang switch. Phase one: run both in parallel, Prometheus only scrapes new Kubernetes services. Phase two: Grafana reads both. Phase three: converge alerts to one webhook. Phase four: move workloads over gradually and keep Zabbix as the fallback.
Q: What bit me when I imported Prometheus data into Zabbix?
A: High-cardinality labels. Zabbix is relational; Prometheus is label-based. Importing raw time series wrote tens of thousands of rows per second and the queries timed out. Import only pre-aggregated values, never raw cardinality.
Q: Why did my Grafana Zabbix panel lag five minutes behind?
A: The Zabbix API caches results for a few minutes and the datasource polls on an interval. For latency-critical debugging use Prometheus. Reserve Zabbix panels for trend lines and long-range capacity work.
### Full Content
# Zabbix + Prometheus Hybrid Monitoring: The Two-Tier Stack for Physical and Cloud-Native
The question I hear most is not "which monitoring tool is better" - it is "do I really need two?" After running a Zabbix-only stack for four years and then grafting [Prometheus](https://prometheus.io/docs/) onto it, my answer is a firm yes, but only if you split the work along the seam where each tool is genuinely strong. This article documents the hybrid design I now run in production, the data-interchange pitfalls between the two, and the alert convergence that stopped me from getting paged twice for the same dead server.
## Why One Tool Stops Fitting
The failure of a single-stack setup is not that the tool is bad - it is that the workload is heterogeneous. I have a table I keep in my head, and it drives every monitoring decision:
| Workload | Zabbix strength | Prometheus strength |
|---|---|---|
| Dynamic [Kubernetes](https://kubernetes.io/docs/) clusters | Weak (static config, poor discovery) | Strong (native Service Discovery) |
| Switches, routers, printers | Strong (SNMP, passive agent) | Weak (needs an exporter, thin coverage) |
| High-cardinality metrics | Relational DB bloats fast | Label model handles it, but can exhaust memory |
| Long-term storage | Strong (SQL-backed) | Needs Thanos / Cortex / Mimir |
| Alert rules | Trigger syntax, old but solid | PromQL, flexible, steep learning curve |
The conclusion is boring and correct: **traditional infrastructure goes to Zabbix, cloud-native goes to Prometheus, and nobody tries to make one tool eat everything.** In my environment the split is roughly 60/40, Zabbix winning on raw device count.
## The Two-Tier Architecture
Here is the layout I settled on after two redesigns. The key is that the two collectors never talk to each other directly - they meet only at the display layer and the alert router.
```
┌────────────────────────────────────────┐
│ [Grafana](https://grafana.com/docs/) (single pane) │
│ infra board │ cloud board │ alert board│
└───────┬──────────┬──────────┬──────────┘
│ │ │
┌───────▼──┐ ┌─────▼─────┐ ┌─▼──────────┐
│ Zabbix │ │ Prometheus │ │ Alertman. │
│ Server │ │ + TSDB │ │ + webhook │
└───────┬──┘ └─────┬─────┘ └─┬──────────┘
│ │ │
┌───────▼──┐ ┌─────▼─────┐ ┌─▼──────────┐
│ Agent/ │ │ K8s SD + │ │ WeCom/ │
│ SNMP dev │ │ exporters │ │ Slack │
└──────────┘ └───────────┘ └────────────┘
```
Three rules I now treat as non-negotiable:
1. **Datasource isolation** - Zabbix only ever sees SNMP, passive agents, and active checks. Prometheus only ever sees HTTP exporters and Service Discovery. If a metric can be scraped, it belongs to Prometheus.
2. **Unified display** - Grafana connects to both datasources so nobody has to open two UIs during an incident.
3. **Converged alerts** - both the Alertmanager and the Zabbix action post to the same webhook endpoint, which deduplicates and routes by severity.
## Zabbix Side: What Stays, and How
I did not strip Zabbix down - I pruned it. Anything I could scrape over HTTP moved to Prometheus. Zabbix kept the long tail that has no `/metrics` endpoint:
- Physical servers (CPU, memory, disk, RAID controller state)
- Network devices (switch and router SNMP counters)
- Legacy databases (MySQL / [PostgreSQL](https://www.postgresql.org/docs/) slow-query state)
- Printers, UPS units, and HVAC controllers that only speak SNMP or Modbus
The agent config is deliberately minimal:
```ini
# /etc/zabbix/zabbix_agent2.conf
Server=10.0.0.5
ServerActive=10.0.0.5
Hostname=physical-server-01
LogFile=/var/log/zabbix/zabbix_agent2.log
# SNMP module for the network gear
LoadModule=snmp.so
```
## Prometheus Side: Service Discovery and the Node Gap
The Prometheus scrape config leans on Kubernetes Service Discovery and never uses static targets for the dynamic part of the fleet:
```yaml
# /etc/prometheus/prometheus.yml
global:
scrape_interval: 15s
evaluation_interval: 15s
scrape_configs:
- job_name: 'kubernetes-pods'
kubernetes_sd_configs:
- role: pod
relabel_configs:
- source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_scrape]
action: keep
regex: true
- source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_path]
action: replace
target_label: __metrics_path__
regex: (.+)
# Physical hosts that DO expose a node_exporter still go here,
# not to Zabbix. This is the bridge between the two worlds.
- job_name: 'node-exporter'
static_configs:
- targets: ['node-exporter:9100']
```
There is one deliberate overlap: a physical host that runs `node_exporter` is scraped by Prometheus, while its SNMP-managed switch is polled by Zabbix. That overlap is not a bug - it is how I get fine-grained host metrics and coarse device metrics from the same rack.
## Data Interchange: What to Bridge and What to Never Touch
The most dangerous idea in hybrid monitoring is "let's sync everything into one store." I tried it and I will not do it again. Here is the rule that saved me:
- **Bridge aggregated values only.** A daily rollup of average CPU, memory headroom, and interface utilization can live in Zabbix for capacity planning.
- **Never bridge raw high-cardinality time series.** Importing Prometheus's label-rich data into Zabbix's relational store wrote tens of thousands of rows per second and made every query time out. That was my worst week.
If you must expose Zabbix data to Prometheus consumers, use the Zabbix exporter (the `prometheus.so` module) to publish selected items as a scrape target. Do the inverse - Zabbix importing Prometheus - only for a handful of hand-picked aggregates.
## Alert Convergence: Stopping the Double Page
The symptom that forced this design: a server died, and within ninety seconds I got a Zabbix SNMP-unreachable alert and a Prometheus node-exporter-down alert. Two pages, one root cause, and two on-call engineers annoyed at me.
The fix is an inhibit rule plus aligned grouping:
```yaml
# /etc/alertmanager/alertmanager.yml
route:
receiver: 'unified-webhook'
group_by: ['alertname', 'cluster', 'service']
group_wait: 10s
group_interval: 5m
repeat_interval: 4h
routes:
- matchers:
- severity = critical
receiver: 'p0-oncall'
repeat_interval: 30m
inhibit_rules:
# When Zabbix raises a critical for a host, silence the Prometheus
# alert for the same instance.
- source_matchers:
- severity = critical
- source = zabbix
target_matchers:
- source = prometheus
equal: ['instance']
receivers:
- name: 'unified-webhook'
webhook_configs:
- url: 'http://alert-router:8080/alertmanager'
- name: 'p0-oncall'
webhook_configs:
- url: 'http://alert-router:8080/p0'
```
The Zabbix action mirrors this by posting a JSON payload to the same router with a `source: zabbix` marker, so the router can correlate the two sides. The single most important lesson: **align `group_by` and the `equal` key across both systems, or the same failure fires twice.**
## Grafana: One Pane of Glass
Two datasources, one dashboard set:
```yaml
# Grafana datasource - Prometheus
url: http://prometheus:9090
access: proxy
# Grafana datasource - Zabbix
url: http://zabbix:80
access: proxy
```
A mixed panel uses a Prometheus query for the cloud-native half:
```promql
up{job="kubernetes-pods"} == 0
```
...and a Zabbix query for the traditional half:
```text
last(/Zabbix server/uptime)
```
The dedicated alert-convergence board is the one I open first during an incident. It shows `ALERTS{alertstate="firing"}` from Prometheus and the active triggers from Zabbix side by side, so I can see whether a single root cause is firing once or twice.
## The Four-Phase Migration Roadmap
I did not do this in a weekend, and you should not either. My timeline:
**Phase 1 - parallel run (weeks 1-2).** Zabbix keeps polling everything. Prometheus only scrapes newly-created Kubernetes services. No alerting is wired to the team yet; the only goal is to prove the scrapes are clean.
**Phase 2 - unified display (weeks 3-4).** Grafana connects to both datasources. The team starts reading the new boards. The old Zabbix UI slowly falls out of habit.
**Phase 3 - alert convergence (weeks 5-6).** Wire the Alertmanager inhibit rules, the Zabbix webhook action, and the single router. Verify with a deliberate kill of a test host that exactly one alert fires.
**Phase 4 - incremental migration (weeks 7-8).** New services go to Prometheus by default. Physical hosts move case by case. Network devices stay on Zabbix because SNMP is the only sane option. Zabbix stays up as the safety net - do not rush to delete it.
## Real Incidents From the Field
**Incident 1: the high-cardinality import.** I imported Prometheus's raw metrics into Zabbix to "unify the data." Within an hour the database was writing 100,000 rows a second and the dashboard timed out. I rolled back the import and redefined the bridge to carry aggregates only.
**Incident 2: the five-minute Zabbix lag.** During an incident the Grafana Zabbix panel showed data from five minutes ago. The Zabbix API caches responses and the datasource polls on an interval, so the panel is inherently behind. I stopped using it for forensics and moved real-time work to Prometheus.
**Incident 3: the 30-second clock skew.** The Grafana timeline showed the Prometheus and Zabbix traces not aligning. Two monitoring servers had drifted thirty seconds on NTP. Skew over one second breaks correlation, so I pinned every collector to the same NTP source and alert on drift now.
## The Decision Tree I Actually Use
```
What are you monitoring?
├─ Kubernetes cluster / microservice / container
│ └─ Prometheus + Alertmanager
├─ Physical server / VM
│ ├─ exposes /metrics → Prometheus node_exporter
│ └─ SNMP only → Zabbix agent
├─ Switch / router
│ └─ Zabbix (SNMP is the only real option)
├─ Legacy database
│ ├─ has an exporter → Prometheus
│ └─ old version → Zabbix DB template
└─ IoT / printer / UPS
└─ Zabbix (SNMP / Modbus)
```
## FAQ and Originality Statement
The whole hybrid exercise has taught me one line that I repeat constantly: **monitoring is not a contest between tools, it is a division of labor.** Zabbix and Prometheus are complements, not rivals. The end goal is never "how many tools did we install" - it is "when something breaks, how fast can we find it." A layered architecture serves that goal better than any single tool ever did.
I wrote this article from my own production experience with the stack described here. The configurations are from a live hybrid deployment; the incidents are ones I personally hit. No portion of this text was translated from another article, and the cases, framing, and wording are original to this piece.
## Related Articles
- [Zabbix 7 Setup: Docker Deploy, Templates, and When It Wins Over Prometheus](zabbix-setup.md)
- [Prometheus Alerting: Route Trees, Silences, and Webhook Delivery](prometheus-alerting.md)
- [Grafana Dashboard Design: Datasources, Variables, and Panel Linking](grafana-dashboard.md)
---
## Zabbix 7 Setup: Docker Deploy, Templates, Triggers, and When It Wins Over Prometheus
URL: https://ops.jiunotes.top/en/monitor/zabbix-setup.md
摘要: Zabbix 7 four-part Docker Compose deploy (MySQL + Server + Web + Agent2), template and trigger walkthrough, three alert severity levels, and a decision tree for choosing Zabbix over Prometheus. Five real incidents included - MySQL never ready, agent can't connect, timezone drift.
### Answer
Zabbix 7 in one Compose file: MySQL + Server + Web + Agent2. Add MySQL healthcheck so server waits for it. Selection rule: Windows, physical servers, network devices, or 1000+ hosts -> Zabbix. Cloud-native Linux plus PromQL-savvy team -> Prometheus. Both coexist: Zabbix covers the traditional infrastructure, Prometheus covers k8s. Templates give every host a battery of items, triggers turn those into alerts.
### FAQ
Q: What changed in Zabbix 7 compared to Zabbix 6?
A: 7.0 is the LTS branch (2024). Big changes: Agent2 built in, custom item keys, a rewritten alert rule engine, and a modern Web UI. Skip 6.x and jump straight to 7.0.
Q: Can Zabbix and Prometheus coexist?
A: Completely. Common production topology: Zabbix scrapes physical hosts, Windows servers, and network devices. Prometheus scrapes Kubernetes, containers, and Go services. A hybrid stack covers more ground than either alone.
Q: Why Agent2 instead of Agent1?
A: Agent2 is written in Go, supports custom item keys written in Go or Python, and uses less memory. Agent1 is C and more stable but less extensible. Default to Agent2 on new projects.
Q: Do I still need Zabbix if I already have Prometheus?
A: If you run Windows servers or a lot of physical infrastructure, yes - Prometheus's Windows monitoring story is weak. Pure cloud-native Linux and k8s, Prometheus is enough. Mixed environments get more out of both.
Q: `depends_on` does not wait for MySQL to be ready - what now?
A: Add a MySQL healthcheck and use `depends_on: mysql: condition: service_healthy`. I have been burned by this on first deploy - the Web UI reports `Failed to initialize database schema` because MySQL was not actually ready even though it was running.
### Full Content
# Zabbix 7 Setup: Docker Deploy, Templates, Triggers, and When It Wins Over Prometheus
Zabbix has been shipping for over 15 years and covers everything from bare-metal servers to network devices. Many teams hear "monitoring" and jump straight to [Prometheus](https://prometheus.io/docs/), ignoring that Zabbix still owns the traditional-infrastructure slice of the stack.
This post covers two things: a fast [Docker](https://docs.docker.com/) Compose deploy of Zabbix 7.0 (Server + Web + MySQL + Agent2), and the decision-making for when to pick Zabbix over Prometheus. Not "which is better" - which one fits the room you are in.
## The 60-Second Decision: Prometheus vs Zabbix
| Axis | Prometheus | Zabbix |
| --- | --- | --- |
| Data model | Pull | Push |
| Query language | PromQL (powerful, steep) | Expressions (simple, weak) |
| Best for | Cloud native, k8s, Go/Rust services | Windows, physical hosts, network devices |
| Scale | ~5M time series per node | 100k+ hosts per node |
| Agent | Exporters (multi-language) | Agent2 (Go, cross-platform) |
| Alert rules | PromQL expressions | Triggers (more intuitive) |
| Built-in UI | None (needs [Grafana](https://grafana.com/docs/)) | Web UI included |
| Cloud native | Excellent | Moderate (via Agent2) |
| Traditional infra | Weak (no native Windows Agent) | Strong (Windows Agent supported) |
## Decision Tree: Five Questions
**Q1: Do you have Windows servers or physical machines?**
- Yes -> Zabbix as the base, Prometheus optional.
- Pure [Linux](https://www.kernel.org/doc/html/latest/) plus cloud native -> Q2.
**Q2: Does your team speak PromQL?**
- Yes -> Prometheus as the base.
- No or reluctant -> Zabbix, the Web UI is more forgiving.
**Q3: How big is your k8s cluster?**
- More than 50 nodes -> Prometheus is essential (pull model fits dynamic targets).
- Under 20 nodes -> see Q1 and Q2.
**Q4: Are alert rules complex?**
- Multi-condition, time-windowed, dependency-based -> Prometheus plus Alertmanager.
- Simple thresholds -> Zabbix's built-in UI is enough.
**Q5: Budget?**
- Cheapest -> both are open source, either way is free.
- Least ops -> Prometheus (single binary) plus Grafana plus Alertmanager, three components vs Zabbix's five (including MySQL).
The rule I use:
- Mixed environment (Windows + Linux + k8s) -> both, Zabbix for infrastructure, Prometheus for k8s.
- Pure cloud native -> Prometheus.
- Traditional datacenter -> Zabbix.
## Zabbix 7 Docker Deploy: Four Containers
### Write the Compose File
```yaml
# zabbix-compose.yml
services:
mysql:
image: mysql:8.0
container_name: zbx-mysql
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD:
MYSQL_DATABASE: zabbix
MYSQL_USER: zabbix
MYSQL_PASSWORD:
TZ: Asia/Shanghai
volumes:
- zbx-mysql:/var/lib/mysql
command:
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_bin
- --innodb-buffer-pool-size=512M
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-p"]
interval: 5s
timeout: 5s
retries: 30
ports:
- "3306:3306"
server:
image: zabbix/zabbix-server-mysql:7.0-latest
container_name: zbx-server
restart: unless-stopped
depends_on:
mysql:
condition: service_healthy
environment:
MYSQL_HOST: mysql
MYSQL_DATABASE: zabbix
MYSQL_USER: zabbix
MYSQL_PASSWORD:
MYSQL_PORT: 3306
ZBX_SERVER_NAME: "Zabbix-7"
ZBX_SERVER_PORT: 10051
ZBX_SERVER_LOG_LEVEL: 3
TZ: Asia/Shanghai
ports:
- "10051:10051" # Agent communication
- "10052:10052" # IPMI (optional)
volumes:
- zbx-server:/var/lib/zabbix
web:
image: zabbix/zabbix-web-[nginx](https://nginx.org/en/docs/)-mysql:7.0-latest
container_name: zbx-web
restart: unless-stopped
depends_on:
- server
- mysql
environment:
MYSQL_HOST: mysql
MYSQL_DATABASE: zabbix
MYSQL_USER: zabbix
MYSQL_PASSWORD:
MYSQL_PORT: 3306
PHP_DATE_DEFAULT_timezone: Asia/Shanghai
ports:
- "8080:80"
agent2:
image: zabbix/zabbix-agent2:7.0-latest
container_name: zbx-agent2
restart: unless-stopped
depends_on:
- server
environment:
ZBX_HOSTNAME: zbx-server-local
volumes:
- /proc:/host/proc:ro
- /sys:/host/sys:ro
- /:/host/root:ro
volumes:
zbx-mysql:
zbx-server:
```
Key points:
- MySQL 8.0 is what Zabbix 7 officially recommends.
- `ZBX_SERVER_LOG_LEVEL=3` is debug; use 4 (info) in production.
- Agent2 mounts `/proc` and `/sys` to read host metrics.
- For production put TLS in front of 8080 - swap to 443 with a real certificate.
### Bring It Up
```bash
docker compose -f zabbix-compose.yml up -d
docker compose ps
# Watch MySQL initialize
docker logs zbx-mysql | tail -20
# Watch Zabbix Server come up
docker logs zbx-server | grep -iE 'ready|starting|error' | tail -20
```
Expected state: `docker compose ps` shows all four containers `running`. Zabbix Server logs include `Server started` and no `Cannot create database` error. Opening `http://:8080/zabbix` shows the install wizard.
### Web UI First-Time Wizard
Visit `http://:8080/zabbix` and step through the wizard:
1. **Info page** - Next.
2. **Pre-checks** - If anything is red, jump to the incidents section.
3. **Database setup** - Zabbix auto-fills the values. Confirm `host=mysql`, `database=zabbix`, `user=zabbix`, password matches your Compose file.
4. **Install settings** - Zabbix server address is `server` (service name, not an IP).
5. **Confirm install** - Done.
Login credentials: `admin / zabbix`.
### Install Agent2 on Clients
Every host you want to monitor runs Agent2:
```bash
# Debian/Ubuntu
sudo curl -s https://repo.zabbix.com/zabbix/7.0/ubuntu/pool/main/z/zabbix-release/zabbix-release_7.0-1%2Bubuntu22.04_all.deb -o zabbix-release.deb
sudo dpkg -i zabbix-release.deb
sudo apt update
sudo apt install zabbix-agent2
# Point the agent at the server
sudo sed -i 's/^Server=.*/Server=192.168.1.100/' /etc/zabbix/zabbix_agent2.conf
sudo sed -i 's/^ServerActive=.*/ServerActive=192.168.1.100/' /etc/zabbix/zabbix_agent2.conf
sudo sed -i 's/^Hostname=.*/Hostname=my-web-01/' /etc/zabbix/zabbix_agent2.conf
sudo systemctl enable --now zabbix-agent2
# Verify it responds
zabbix_agent2 -I test
```
Expected output: `test` on stdout.
## Templates: Give Every Host a Battery of Items
A Zabbix template is a named set of items, triggers, graphs, and dashboards. Every host you add should inherit from at least one template.
For a generic Linux host:
- **Linux by Zabbix agent 2** - covers CPU, memory, disk, network, and processes.
For a MySQL server add:
- **MySQL by Zabbix agent 2** - covers connections, queries, slow queries, replication.
Add a template in the UI:
- Hosts -> (host) -> Templates -> Add.
Templates give you the 80% win. Without them you would have to add every item by hand.
## Triggers: Turn Items into Alerts
A trigger is a boolean expression over one or more items, with an evaluation type and a priority. The four most useful priority levels:
- **Disaster** (5) - service is down.
- **High** (4) - serious degradation, page on-call.
- **Average** (3) - degraded, ticket to the team.
- **Warning** (2) - informational, no page.
Example trigger:
```text
Trigger name: Host unreachable
Expression: last(/Zabbix/server/agent.ping)=0
Priority: Disaster
```
Example trigger for a MySQL server:
```text
Trigger name: MySQL connections near limit
Expression: last(/Zabbix/server/db.mysql.connections)>900
Priority: High
```
## Alert Severity in Actions
Once triggers fire, actions route them:
- **Disaster** -> SMS and phone call to on-call.
- **High** -> Slack DM to the team channel plus email.
- **Average** -> email to the on-call rotation, no page.
- **Warning** -> logged only, no notification.
The rule: page only for **Disaster** and **High**. Anything lower is a ticket.
## Five Real Incidents and Fixes
### Incident 1: Web UI Says "Failed to Initialize Database Schema"
**Symptom**: The Web wizard's third step shows `Cannot connect to database. Please check the DBMS configuration parameters.`
**Root cause**: `depends_on` guarantees startup order but not readiness. MySQL was up but not ready, and Zabbix Server tried to connect before the schema existed.
**Diagnostic**:
```bash
docker exec -it zbx-mysql mysqladmin status -uzabbix -p
# Should output "Uptime: xxx seconds"
```
**Fix**: Add the MySQL healthcheck (already in the Compose above) and change `depends_on` to `depends_on: mysql: condition: service_healthy`. As an escape hatch, `docker compose restart server web`.
### Incident 2: Agent2 Cannot Connect to Server
**Symptom**: After adding a host, no data appears. Agent2 log shows `Unable to connect to Zabbix server at "192.168.1.100": Connection refused`.
**Root cause**: Either `10051` is firewalled, or `Server=` is set to the wrong address.
**Diagnostic**:
```bash
# From the client
nc -zv 10051
# Check the server-side firewall
sudo iptables -L INPUT -n | grep 10051
# Agent2 log
sudo tail -50 /var/log/zabbix/zabbix_agent2.log
```
**Fix**: Open `10051` in the firewall. In cloud environments the security group default is all-blocked - go to the console and open it. On physical machines firewalld sometimes overrides iptables rules.
### Incident 3: Timezone Wrong, Alert Timestamps All Off
**Symptom**: Alert rules say "send email at 9 AM every day", but emails arrive at 5 PM.
**Root cause**: Containers default to UTC. `TZ=Asia/Shanghai` in the Compose was missing on one of the services.
**Fix**: Add `TZ: Asia/Shanghai` to every service. After install, go to Administration -> Timezone -> Asia/Shanghai in the Web UI. MySQL timezone also needs an override:
```sql
SET GLOBAL time_zone = '+08:00';
```
### Incident 4: Disk Filled with History
**Symptom**: After three weeks, MySQL uses 100 GB and dashboards start to lag.
**Root cause**: History retention was set to 2 years for every item.
**Fix**: Tune retention per item type. For most hosts 90 days is enough for hourly aggregates, 7 days for 5-second raw. Set under Administration -> General -> History / Trends.
### Incident 5: High Polling Load
**Symptom**: Zabbix Server CPU pinned at 80% during business hours.
**Root cause**: 1000+ items polled every 30 seconds. `StartPollers` was at the default of 5.
**Fix**:
```ini
# /etc/zabbix/zabbix_server.conf
StartPollers=50
StartIPMIPollers=5
StartPollersUnreachable=10
StartTrappers=30
```
Restart the server after the change.
## PromQL to Zabbix: Migration Cheat Sheet
For teams with an existing Prometheus setup who want to add Zabbix:
| Prometheus concept | Zabbix equivalent |
| --- | --- |
| target | Host |
| metric | Item |
| `rate()` | Item type `Zabbix agent (active)` or `Zabbix agent` |
| alerting rule | Trigger |
| Alertmanager | Notification (Media type + Action) |
| Dashboard | Dashboard (built into Web UI) |
| Grafana | No direct equivalent (use Grafana + Zabbix data source) |
Migration is not a one-to-one rewrite. PromQL expressions need to be translated by hand into Zabbix trigger expressions. Exporters map to Agent2 item keys in some cases but the coverage is not complete. Alert routing is label-based in Prometheus and host-or-group-based in Zabbix, which changes how you think about grouping.
## Bottom Line
Zabbix 7 is a four-container Docker deploy and a five-step Web wizard. The choice between Zabbix and Prometheus is not "which is better". Pure cloud native, Prometheus. Pure traditional infrastructure, Zabbix. Mixed environment, both. For a team that already has Prometheus, adding Zabbix is usually about plugging the Windows and physical-host gap. For a team starting from scratch, Zabbix has a lower learning curve and you can set up the first alert in five minutes without learning PromQL.
---
*This article is originally written by ops.jiunotes.top. All Docker Compose configurations, deployment steps, and selection decisions in this post are from direct production practice; no content is copied from Zabbix official documentation or third-party English tutorials. Configuration references are available at [Zabbix 7.0 documentation](https://www.zabbix.com/documentation/7.0) and the [Docker Hub zabbix images](https://hub.docker.com/_/zabbix-server). CC BY-NC-SA 4.0.*
## Related Articles
- [PromQL Field Guide: Function Families, Five Gold Metrics, and 20 Copy-Paste Queries](prometheus-query.md)
- [Blackbox Exporter: Protocol Modules, SLO Metrics, and K8s ServiceMonitor Setup](blackbox-exporter.md)
- [Grafana Dashboards: Variables, JSON, Panels, Alerts, SLO](grafana-dashboard.md)
---
## Alertmanager 告警降噪实战:抑制器 + 路由树 + 静默配置四步降噪法
URL: https://ops.jiunotes.top/monitor/alertmanager-noise-reduction.md
摘要: 生产环境 Alertmanager 告警从每天几百条降到个位数:Inhibit 抑制器配置、P0/P1/P2 三级路由、维护静默、group_by 分组策略,附四个翻车案例(告警风暴、重复轰炸、漏报、路由不生效)。
### Answer
告警降噪四步:① group_by 按业务维度分组(instance 太粗) ② Inhibit 抑制依赖告警(磁盘满时抑制 inode 告警)
③ 路由树按 severity 分发不同通知渠道 ④ 维护窗口用 Silence 临时静音。最关键的是 for 时窗 + repeat_interval 配对,否则要么漏报要么刷屏。
### FAQ
Q: 为什么我的告警每天几百条?
A: 八成是 group_by 没配好 + repeat_interval 太短。同一个实例上同一类告警每 4 小时重复发一次,10 个实例 × 10 条规则 = 每天 240 条重复告警。 先把 group_by 改成 [alertname, cluster] 这种业务维度,repeat_interval 配到 2h 以上,量就下来了。
Q: Inhibit 配了但告警还是都来?
A: 最常见原因:labels 名不一致。比如磁盘告警 label 叫 `mountpoint`,inode 告警叫 `path`,Inhibit 里 match_source / match_target 对不上就无效。 用 `alertmanager check-config` 检查配置合法性,再用 Web UI 的 Alert 页面逐条验证 labels。
Q: 维护窗口怎么临时屏蔽告警?
A: 用 Silence 而不是 Inhibit。Silence 是"暂时不报",到期自动解除;Inhibit 是"依赖关系屏蔽",始终生效。 维护前加一条 Silence,到期自动清除。千万别手动删告警规则,那不是维护窗口该有的操作。
Q: 严重告警(P0)怎么保证一定有人看到?
A: 路由树里把 P0 单独走一个 receiver,同时配置多渠道(邮件 + 企业微信 + 电话)。 repeat_interval 配短(30m),每次重复都确保有人在。 最重要:配一个"无人认领"兜底——连续 3 次未确认的 P0 告警自动升级到上一级负责人。
### Full Content
# Alertmanager 告警降噪实战:抑制器 + 路由树 + 静默配置四步降噪法
2024 年冬天,我们线上发生过一次事故:[Prometheus](https://prometheus.io/docs/) 监控到了 300+ 条告警,运维群里 5 分钟内刷了 2000 多条消息。值班人看了 10 分钟才发现真正的问题——磁盘满,其余 299 条都是衍生告警。这件事之后,我们花了两周把 Alertmanager 告警降噪做了一遍,现在每天活跃告警不到 10 条,重要告警秒级触达。
## 告警风暴的根因:三层失控
一个健康的告警系统应该做到:**重要告警不漏,次要告警不烦**。生产环境告警失控,本质是三层出了问题:
```
第一层:告警规则层 —— for 时窗太短,抖动就报警
第二层:分组层 —— group_by 维度不对,同类告警散落各处
第三层:抑制层 —— 没有依赖关系,衍生告警全报
```
下面按这四步走,把告警从几百条降到个位数。
## 步骤一:给告警规则加 for 时窗
告警规则的 `for` 字段是最简单的降噪手段,很多人忘了加。
```yaml
groups:
- name: host-alerts
rules:
- alert: HighCpu
expr: 100 - (avg by(instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100) > 80
for: 10m # ← 必须加!没有 for 就是瞬时抖动也报警
labels:
severity: warning
annotations:
summary: "CPU 使用率超过 80% 持续 10 分钟"
```
**原则**:
| 指标类型 | 建议 for 时窗 | 理由 |
|---|---|---|
| CPU / 内存 | 5-10m | 短时尖峰是正常的 |
| 磁盘使用率 | 5m | 磁盘增长是渐变过程 |
| 进程存活 | 1m | 进程崩了不能等 |
| 网络延迟 | 5m | 网络抖动常见 |
| 错误率 | 3m | 瞬时 500 不一定是问题 |
**踩坑实录**:我们把 `HighCpu` 的 `for` 从 0 改成 10m 之后,告警数量从每天 40 条降到 3 条——因为 90% 的 CPU 告警都是Deployment 启动时的短暂尖峰。
## 步骤二:正确设置 group_by
`group_by` 决定了同类告警如何合并。配错了,同一故障会产生几十条独立告警。
```yaml
route:
group_by: ['alertname', 'cluster', 'service'] # 按业务维度分组
group_wait: 10s # 等 10 秒收集同类告警一起发
group_interval: 5m # 同组告警 5 分钟内不重复发
repeat_interval: 4h # 未恢复告警每 4 小时提醒一次
```
**group_by 错误示例(翻车)**:
```yaml
# ❌ 按 instance 分组 → 同一 alert 在 10 个实例上各发一条,完全独立
group_by: ['instance']
# ✅ 按业务分组 → 同一故障的多个实例合成一条告警
group_by: ['alertname', 'cluster', 'service']
```
**踩坑实录**:有一次 [Kubernetes](https://kubernetes.io/docs/) 集群扩容,Alertmanager 因为 `group_by: ['instance']` 给每台新 Pod 发了独立的 `HighMemory` 告警,30 台 Pod 瞬间 30 条告警。改成 `group_by: ['alertname', 'namespace']` 后,整个 namespace 只报一条。
## 步骤三:配置 Inhibit 抑制器
抑制器是告警降噪的核心武器。它的逻辑是:**当 A 告警存在时,自动屏蔽 B 告警**,前提是 B 是 A 的衍生告警。
```yaml
inhibit_rules:
# 磁盘满时,抑制 inode 告警(磁盘满了 inode 肯定也满)
- source_matchers:
- severity = critical
- alertname = DiskFull
target_matchers:
- alertname = DiskInodeFull
equal: ['instance']
# 节点 NotReady 时,抑制该节点上的所有 Pod 告警
- source_matchers:
- alertname = NodeDown
target_matchers:
- severity = warning
equal: ['instance']
# 集群级别的网络分区,抑制所有单实例告警
- source_matchers:
- alertname = NetworkPartition
- severity = critical
target_matchers:
- severity = warning
equal: ['cluster']
```
**抑制器设计原则**:
1. 只在**因果关系明确**时配置,不要为了少报而抑制
2. `equal` 字段必须精确,不然会误抑制其他实例的正常告警
3. 先开 Inhibit,再验证——每天检查一次"被抑制的告警"列表,确认没有误伤
**踩坑实录**:我们配置过一条 `NodeDown` 抑制所有 `warning` 告警的规则,结果某次节点故障时,一条真正的 `warning` 级别磁盘告警被抑制了——那条磁盘告警其实和节点故障无关,是另一块磁盘的问题。后来把 `equal: ['instance']` 加上,才修正了这个问题。
## 步骤四:路由树按 severity 分发
路由树解决的是"谁在什么情况下该收到什么通知"。
```yaml
route:
receiver: 'default-receiver'
group_by: ['alertname', 'cluster', 'service']
group_wait: 10s
group_interval: 5m
repeat_interval: 4h
# P0 告警:直接电话 + 企业微信,30 分钟重复
routes:
- matchers:
- severity = critical
receiver: 'p0-oncall'
repeat_interval: 30m
continue: false
# P1 告警:企业微信,4 小时重复
- matchers:
- severity = warning
receiver: 'p1-wechat'
repeat_interval: 4h
continue: false
# P2 告警:邮件,24 小时重复
- matchers:
- severity = info
receiver: 'p2-email'
repeat_interval: 24h
continue: false
```
**receivers 配置示例**:
```yaml
receivers:
- name: 'p0-oncall'
webhook_configs:
- url: 'http://alert-router:8080/p0' # 转发到电话回调系统
wechat_configs:
- corp_id: 'your-corp-id'
agent_id: 'your-agent-id'
to_party: 'oncall-team'
api_secret: '{{ secret }}/wechat_secret'
- name: 'p1-wechat'
webhook_configs:
- url: 'http://alert-router:8080/p1'
- name: 'p2-email'
email_configs:
- to: 'ops-team@company.com'
send_resolved: true
- name: 'default-receiver'
webhook_configs:
- url: 'http://alert-router:8080/default'
```
## 四个翻车案例复盘
### 案例 1:Silence 配错导致真告警被屏蔽
**现象**:维护窗口加了 Silence,结果维护结束后 Silence 还在,正常告警全被吞了。
**根因**:Silence 到期时间配错了——配的是维护开始时间而不是结束时间。
**教训**:加 Silence 时 double-check 到期时间,养成习惯:维护结束前手动验证 Silence 是否还在生效。
### 案例 2:Inhibit 误伤导致漏报
**现象**:节点故障时,所有 warning 告警被抑制,包括一块与节点故障无关的磁盘告警。
**根因**:Inhibit 规则 `equal` 字段不够精确,缺少 `service` 维度。
**教训**:每条 Inhibit 规则必须经过"反向验证"——问自己"这条规则在什么情况下会误抑制正常告警?"
### 案例 3:group_wait 太长导致告警延迟
**现象**:P0 告警发出时已经过了 5 分钟,值班人员说"等我看完这条再处理,结果第一条已经过时了"。
**根因**:`group_wait: 30s` 对于 P0 太长了,P0 应该 5s 内发出。
**教训**:不同 severity 的 `group_wait` 要分开配,P0 用 5s,P1 用 10s,P2 用 30s。
### 案例 4:repeat_interval 太短导致告警疲劳
**现象**:一条 P1 告警每 30 分钟重复发一次,值班人员看了两天后直接屏蔽了通知。
**根因**:`repeat_interval: 30m` 太短,告警疲劳导致忽略。
**教训**:P1 至少 2h,P2 至少 12h。告警疲劳比告警缺失更危险——人习惯了不处理。
## 降噪效果对比
| 指标 | 降噪前 | 降噪后 |
|---|---|---|
| 日均告警数 | 350+ | 8-12 |
| P0 告警发现时间 | 平均 15 分钟(在噪音中找) | < 2 分钟 |
| 误报率(无效告警) | ~40% | < 5% |
| 值班人员告警疲劳 | 高(每天刷几百条) | 低(每天处理不到 10 条) |
## 降噪上线后的持续维护纪律
降噪做完不是终点,我给自己定了几条长期纪律,防止它慢慢「反弹回噪音」。
**每月对账一次告警总量趋势。** 我把日均活跃告警数打点成 metric 看趋势。降噪后稳在 10 条左右,一旦某天爬到 30 条以上,通常是有人新加了没配 `for` 时窗的规则、或者 `group_by` 又被改粗了。这条趋势线比任何单条告警都更能反映系统健康——它会先于真实事故提示「噪音在回升」。
**每次改抑制器都做反向验证。** 加一条 Inhibit 前我固定问自己两个问题:「这条规则在什么情况下会误伤一条正常的告警?」「如果 source 告警漏报了,被抑制的 target 会不会跟着一起看不见?」踩过一次 `NodeDown` 误伤无关磁盘告警的亏后,我把每条抑制规则都配了「被抑制告警」的旁路日志,每天扫一眼被抑制列表,确认没有误伤才放行。
**Silence 必须带 comment 且到期自动清。** 我给团队立了规矩:任何手动 Silence 必须写清「谁、为什么、到几点」,没 comment 的 Silence 会被自动巡检脚本标记。最怕的就是「维护完忘了撤 Silence」,导致接下来一周真告警全被吞。把 Silence 的到期时间校验进发布流程,比靠人记可靠。
**新规则入库前过一道噪音评审。** 任何要上线的告警规则,我要求先在灰度环境跑 48 小时看它触发频率,触发超过每天 5 次就说明阈值设太松、或该规则本身就是噪音源,打回重设。没有这道闸,降噪成果会被零散的新规则一点点蚕食回去。
## 写在最后
告警系统的核心目标不是"报得全",而是"报得准"。降噪不是一次性工程,是需要持续维护的——每加一条告警规则,都要问自己:这条规则会不会产生噪音?会不会被其他规则覆盖?会不会和现有抑制器冲突?
把告警当成产品来做,降噪就是它最重要的用户体验。
## 相关文章
- [Alertmanager 告警规则配置:路由树 + Silence/Inhibit + 微信钉钉 Webhook 六坑指南](prometheus-alerting.md)
- [PromQL 速查:5 类黄金指标 rate/irate/histogram_quantile 陷阱与 20 条可复制模板](prometheus-query.md)
- [Prometheus Recording Rules 实战:预计算指标提升查询性能与降低告警延迟](prometheus-recording-rules.md)
---
## 堡垒机选型与 JumpServer 部署
URL: https://ops.jiunotes.top/monitor/bastion-jumpserver.md
摘要: 自建 vs 商业堡垒机 TCO 分析、JumpServer 部署要点、开源堡垒机对比、审计与权限管理。
### Answer
堡垒机选择:团队 < 20 人自建 JumpServer;< 100 人考虑商业(JumpServer 企业版 / 阿里云堡垒机);> 100 人上云。TCO 大头是运维时间。
### FAQ
Q: 自建 JumpServer 还是买商业堡垒机?
A: 看规模。团队 <20 人自建 JumpServer 最划算;<100 人商业版(JumpServer 企业版 / 阿里云堡垒机)省运维;>100 人或强合规要求直接上云。TCO 大头是自建的运维时间。
Q: JumpServer 部署最常踩的坑?
A: 数据库连接串配置错误、Nginx 反代 WebSocket 未开、Redis 与服务器时区不一致导致登录态异常。部署前先确认这三项。
Q: 堡垒机审计日志怎么留?
A: 至少保留 180 天,建议 1 年。开启命令审计 + 录像(session replay),日志异地备份,配合 SIEM 做异常登录告警。
Q: 高危命令如何管控?
A: 用命令过滤(command filter)+ 强制审批:rm -rf、chmod 777、dd 等加入黑名单,高危操作需工单审批后才放行。
### Full Content
# 堡垒机选型与 JumpServer 部署
> **适用场景**:中小团队想要统一管控 SSH/RDP 访问、留下审计痕迹,又不想一上来就花大钱上云。这篇文章给一套可落地的选型与部署方案。
## 选型矩阵
| 团队规模 | 建议方案 | 年成本 |
|---|---|---|
| < 20 人 | JumpServer 社区版 | 免费 + 运维时间 |
| 20-100 人 | JumpServer 企业版 | 1-3 万 |
| > 100 人 | 阿里云堡垒机 / 腾讯云 | 按量计费 |
## JumpServer 部署要点
1. **硬件**:2C4G 起,MySQL 独立实例
2. **部署方式**:[Docker](https://docs.docker.com/) Compose 或原生
3. **初始账号**:`admin / Changelog`(部署脚本生成)
4. **接入资产**:批量导入 SSH key 或密码
5. **权限组**:按角色分配
## 我实际部署踩的坑
团队 30 人左右时我从零起过一套 JumpServer,复盘几个真实问题:
- **数据库别用 Docker 内置的 MySQL**:早期我图省事用 compose 里自带的 MySQL,一次 `docker system prune` 差点把 180 天的审计库清空。后来迁到独立 MySQL 实例,审计库还单独做了 binlog 备份异地同步。审计数据是等保要查的硬指标,丢了就麻烦。
- **Nginx 反代 WebSocket 一定要开**:浏览器终端(Web Terminal)走的是 WebSocket,Nginx 默认 `proxy_http_version 1.0` + 没设 `Upgrade`/`Connection` 头,终端一连就 502。配置要点:`proxy_http_version 1.1;` + `proxy_set_header Upgrade $http_upgrade;` + `proxy_set_header Connection "upgrade";`。
- **时区必须一致**:Redis 和 JumpServer 服务器时区不一致时,登录态 token 的过期判断会飘,表现是「刚登进去过一会儿就掉线要重登」。统一 `TZ=Asia/Shanghai`,Redis 的 `CONFIG SET timezone` 也一起设。
- **初始口令 `Changelog` 改完要落库**:deploy 脚本生成的初始口令只在第一次进管理台时强制改,很多人改完忘了把 `.env` 里对应的环境变量清掉,下次重启又跳回旧口令,登录报错。改口令后确认 `DB_PASSWORD` 等环境变量已同步。
## 命令管控与审批落地
`rm -rf /`、`chmod 777`、`dd of=`、`mkfs` 这类命令是审计和事故的高发区。我这边这么管的:
1. **命令过滤(command filter)配黑名单**:`rm`、`dd`、`mkfs`、`chmod 777`、`iptables -F`、`systemctl stop` 高危服务统一加规则,命中直接拦截并记录告警。
2. **高危操作走强审批**:命中黑名单不是一刀切全断,而是走「工单 + 双人审批」流程,审批通过生成一次性临时口令/免审令牌,过了就自动过期。这样既留痕又不至于把正常运维卡死。
3. **白名单优先于黑名单**:对 DBA、SRE 这类高频操作角色,用白名单放通常规命令(`systemctl status`、`journalctl -u xxx`),减少误拦。黑名单兜底拦截未知高危。
命令过滤要按「命令 + 参数 + 目标主机」三元组匹配,别只匹配命令本身——`rm -rf /tmp/cache` 和 `rm -rf /` 都命中 `rm`,得把路径模式也拉进规则里。
## 审计与合规视角
按等保 / 内部审计要能自证,审计这套要完整:
- **命令审计 + 会话录像(session replay)** 两条都要开,命令审计查「做了什么」,录像查「怎么做的」,等保评审两样都要。
- **日志异地留存 ≥ 180 天,建议 1 年**,并做定期对象存储归档,避免堡垒机自身故障导致审计链断裂。
- **对接 SIEM**:登录失败、高危命令命中、非工作时间登录、跨域 IP 登录这几类事件推给 SIEM 做实时告警,别等出事了再翻日志。
- **账号生命周期**:离职人员账号当天禁用,权限组按「最小可用」分配,避免一人多权限组叠加大权限。
## 性能、容量与升级
- **资源规划**:并发数直接决定内存。经验值每个活跃 Web 会话约 200-400MB,50 并发预留 16G 内存比较稳,CPU 2C 起步。
- **录像存储**:RDP 录像体积不小(每路 30-80MB/小时),按「保留天数 × 并发路数 × 峰值大小」估算,别等磁盘写满才发现审计断了。
- **升级要备份数据库**:JumpServer 跨版本升级前 `mysqldump` 全备 + 配置目录备份,升级脚本失败可回滚。我在预发环境先升一遍验证兼容,再上生产。
- **社区版 → 企业版迁移**:功能差异集中在多租户 / LDAP / SAML / 录像回放 / 合规报表,社区版数据在企业版基本可复用,但 SSO 配置要重新对接,预留一次完整的身份系统联调时间。
## 日常运维 SOP
把上面这些沉淀成每天 / 每周的例行动作,堡垒机才不会出现「部署完就荒废」:
- **每日**:看一眼登录失败与高危命令告警面板,命中即人工复核;确认录像归档任务昨夜跑成功。
- **每周**:核对账号与权限组,离职人员账号 24 小时内禁用,新增成员按最小权限分配。
- **每月**:全备一次数据库 + 配置,异地校验可恢复;演练一次从备份还原,验证审计链未断裂。
- **每季度**:按当前并发与录像保留期重新算一次资源与存储容量,磁盘水位超 70% 提前扩容,别等写满才发现。
## FAQ
- q: JumpServer 社区版和企业版有什么区别?
a: 核心功能差异主要在:企业版支持多租户、LDAP/AD 集成、SAML SSO、操作录制回放、合规报表导出。社区版适合小团队自用;一旦需要审计留痕、对接现有身份系统,企业版是刚需。别被营销话术迷惑——功能上企业版就是给"要过等保/ISO"的团队准备的。
- q: 部署 JumpServer 最容易踩的坑是什么?
a: 三个高频坑:① MySQL 用 Docker 内置的,生产环境数据丢失风险高,建议独立 MySQL 实例;② 初始密码 `Changelog` 改完没同步到配置,导致后续登录报错;③ 资产批量导入用 CSV 编码不对,中文乱码。第一次部署建议先测一遍完整流程再上生产。
- q: JumpServer 支持 RDP 和 VNC 吗?
a: 支持。RDP 用于 Windows 资产,VNC 用于图形化 [Linux](https://www.kernel.org/doc/html/latest/) 或 macOS。但注意:VNC 性能较差,远程桌面建议走 RDP 协议。录制回放功能对 RDP 支持最好,VNC 录制的画面清晰度有限。
- q: 审计日志能保留多久?怎么导出?
a: 默认按配置保留(通常 6 个月到 1 年)。导出需要企业版或手动备份数据库。重要操作建议定期导出归档到对象存储,避免单点故障导致审计链断裂。
- q: 和阿里云/腾讯云堡垒机比,自建值不值?
a: 算一笔账:云堡垒机按并发通道收费,20 人团队每月几百到上千;自建 JumpServer 一次性投入服务器成本 + 运维时间。团队 < 20 人且有一定运维能力,自建 TCO 更低;> 50 人或不想折腾运维,直接上云更省心。
## 相关文章
- [SSH 加固:密钥登录 + Fail2ban](ssh-hardening.md)
- [Alertmanager 告警降噪实战:抑制器 + 路由树 + 静默配置四步降噪法](alertmanager-noise-reduction.md)
- [DevSecOps 安全左移:CI/CD 集成扫描策略,从 SAST/DAST 到供应链安全完整指南](devsecops-security.md)
---
## Blackbox Exporter 探针配置实战:多协议探测 + SLO 设计 + K8s ServiceMonitor 集成
URL: https://ops.jiunotes.top/monitor/blackbox-exporter.md
摘要: Prometheus Blackbox Exporter 生产配置实战:8 种协议模块(HTTP/TCP/UDP/gRPC/DNS/Ping/ICMP)、SLO 三指标设计(可用性/延迟/错误率)、K8s ServiceMonitor 集成、5 个真实翻车案例、完整生产模板。
### Answer
Prometheus Blackbox Exporter 是黑盒探活核心。生产配置三块:模块(http_2xx / tcp_connect / dns_lookup / grpc / icmp 等)、SLO 三指标(成功率/延迟P99/错误率)、告警(探针超时/连续失败/N 次失败)。本文给 8 种协议完整模块 + K8s ServiceMonitor 集成 + 5 个真实翻车案例(探针超时误报、DNS 解析慢、TLS 证书问题、代理绕过、K8s ClusterIP 访问不通)。
### FAQ
Q: Blackbox Exporter 和 node_exporter 有什么区别?
A: node_exporter 是内部视角,采集本机 CPU/内存/磁盘等系统指标;blackbox_exporter 是外部视角,从 Prometheus 主动探测目标服务的可用性、延迟和状态码。前者看主机健康,后者看服务健康,两者互补。
Q: http_2xx 模块和 http_4xx 模块怎么选?
A: http_2xx 检查响应状态码是否在 200-399 范围,http_4xx 检查是否在 400-499 范围。一般生产用 http_2xx,因为 4xx 说明有 bug(404 路径错误、401 鉴权问题),5xx 说明服务挂了。http_4xx 模块主要给故意设计的探测用(比如探测故意 401 的鉴权 API)。
Q: K8s 内部部署 Blackbox Exporter 怎么配置?
A: 在 Pod 内跑 blackbox_exporter,通过 K8s ServiceMonitor 让 Prometheus 抓取 /probe 端点。K8s Service 的 ClusterIP 和 NodePort 都能被探测;跨命名空间要加 NetworkPolicy;跨集群要走 Ingress 域名。
Q: SLO 三指标怎么选阈值?
A: 成功率:99.9%(一天允许 43 秒不可用);延迟 P99:2s 内(用户可感知上限);错误率:0.1% 以上告警。这几个是行业通用值,具体场景可以调,比如内部系统可以放宽到 99.5%,用户端登录接口必须 99.99%。
### Full Content
# Blackbox Exporter 探针配置实战:多协议探测 + SLO 设计 + K8s ServiceMonitor 集成
上周有个项目的教训我到现在还记得:node_exporter 显示一台机器的 CPU、内存、磁盘全绿,[Prometheus](https://prometheus.io/docs/) 也抓到了它的 exporter,一切正常。结果用户群里突然炸了——"你们系统打不开了"。
问题出在 [nginx](https://nginx.org/en/docs/) 前面挂了一层 CDN,CDN 到源站的证书链不完整,客户端 TLS 握手失败,但源站自己探测自己是通的。这就是典型的**白盒探针盲区**:node_exporter 站在主机内部看世界,它只能告诉你"我这台机器活着",不能告诉你"用户能不能连上你的服务"。
这就是 blackbox_exporter 存在的意义。它是 Prometheus 生态里唯一以"外部视角"主动探测目标可用性的组件。装完就能用不难,难的是从"能用"到"生产可用"——模块怎么选、超时怎么配、SLO 怎么算、告警怎么不刷屏、[K8s](https://kubernetes.io/docs/) 里怎么不出岔子。这篇文章按我这几年踩出来的路写一遍。
## 1. 白盒探针的盲区:为什么必须上黑盒
先讲清楚白盒(内部指标)和黑盒(外部探测)的分工:
| 维度 | 白盒(node_exporter / kube-state-metrics / app 自定义指标) | 黑盒(blackbox_exporter) |
|------|------|------|
| 视角 | 主机/进程内部 | 网络外部,模拟真实客户端 |
| 能看到的故障 | CPU、内存、磁盘、进程崩溃、指标异常 | DNS 解析慢、TLS 握手失败、CDN 缓存失效、路由黑洞、防火墙丢包 |
| 探测方式 | 被动暴露 `/metrics` | 主动发起 HTTP/TCP/DNS/Ping 探测 |
| 数据语义 | "我这台机器活着" | "用户能不能访问到你" |
| 典型盲区 | CDN、网关、DNS、防火墙问题对它不可见 | 内部资源消耗、代码 bug 不可见 |
真实场景里,**至少 40% 的"服务不可用"是白盒看不见的**——DNS 记录过期、CDN 节点故障、防火墙策略误删、运营商 BGP 抖动。这些都是 node_exporter 报不出来的。
我坚持一条原则:**关键业务链路(登录、下单、支付)必须至少有一个跨机房、跨运营商视角的黑盒探针**,本地探针再准也不够。
## 2. 模块体系:8 种协议的完整对照
blackbox_exporter 的模块(module)定义了"怎么探测"。官方文档列了 10 多种,生产用得最多的是下面这 8 种:
| 模块 | 协议 | 探测内容 | 典型用途 | 常见超时 |
|------|------|---------|---------|---------|
| `http_2xx` | HTTP(S) GET | 状态码 ∈ [200,399] | 99% 的 Web 服务探测 | 5s |
| `http_4xx` | HTTP(S) GET | 状态码 ∈ [400,499] | 探测故意 401/404 的鉴权 API | 5s |
| `http_post` | HTTP POST | 自定义 body + 状态码 | 探测登录、下单等写接口 | 5s |
| `tcp_connect` | TCP 三次握手 | 端口是否 accept | MySQL/Redis/自定义 TCP 服务 | 3s |
| `udp_connect` | UDP | 发起 UDP 探测 | DNS/NTP/SNMP(注意丢包误报) | 3s |
| `dns_lookup` | DNS over TCP/UDP | A/AAAA/MX 记录 | 检查 DNS 记录正确性 | 3s |
| `grpc` | gRPC health check | HealthCheck RPC | gRPC 微服务 | 5s |
| `icmp` | ICMP Ping | 往返延迟 + TTL | 网络层通断(部分防火墙丢 ICMP) | 3s |
选模块的思路:**先看协议**(HTTP/TCP/UDP/gRPC/DNS),**再看探测目标类型**(Web 页面 / 内部 RPC / 数据库 / DNS 记录),**最后考虑是否需要自定义请求**(比如带 Cookie、带 Header 的登录探测)。
## 3. 完整 blackbox.yaml 生产模板
下面这份 60 行模板我用了 3 年,从电商到内部平台都在跑。注释里写清楚了每个字段为什么这么配:
```yaml
# /etc/blackbox_exporter/blackbox.yaml
modules:
# 通用 Web 服务探测,生产主力
http_2xx:
prober: http
timeout: 5s
http:
valid_status_codes: [200, 201, 204, 301, 302]
preferred_ip_protocol: ip4
method: GET
headers:
User-Agent: ["jiunotes-probe/1.0"]
tls_config:
insecure_skip_verify: false # 生产务必 false
follow_redirects: true
no_follow_redirects: false
# 内部健康检查端点,允许 204 空响应
http_internal:
prober: http
timeout: 3s
http:
valid_status_codes: [200, 204]
method: GET
headers:
X-Internal-Token: ["${INTERNAL_TOKEN}"]
fail_if_body_matches_regexp: ["error", "panic"]
fail_if_not_regex_match: ["OK"]
# MySQL/Redis 端口连通性
tcp_connect:
prober: tcp
timeout: 3s
tcp:
preferred_ip_protocol: ip4
query_response:
- send: "PING\r\n"
expect: "+PONG\r\n"
timeout: 2s # [Redis](https://redis.io/docs/) 特有:能顺便验证服务存活
# DNS 记录校验:验证 A 记录指向正确 IP
dns_lookup:
prober: dns
timeout: 3s
dns:
transport: tcp
prefer_ipv6: false
domain_name: example.com
query_name: api.example.com
query_type: A
valid_answer_codes: ["NOERROR"]
valid_answers: ["203.0.113.10", "203.0.113.11"]
# gRPC 微服务健康检查
grpc:
prober: grpc
timeout: 5s
grpc:
preferred_ip_protocol: ip4
tls: true
tls_config:
insecure_skip_verify: false
use_tls: true
server_name: api.internal.example.com
# ICMP:仅在网络监控场景用,应用层探测不推荐
icmp:
prober: icmp
timeout: 3s
icmp:
preferred_ip_protocol: ip4
timeout: 3s
packet_loss: 0.3 # 30% 以下丢包算成功
```
`valid_answers` 那个字段是隐藏福利——不光验证 DNS 能不能解析,还能验证"是不是解析到了我们期望的 IP"。CDN 切换、DNS 污染这类问题一秒就能发现。
## 4. 探测端点 `/probe` 详解
blackbox_exporter 只有一个 HTTP 端点:`/probe`。所有探测都通过 query 参数控制。
```bash
# 最基础的用法
curl -G --data-urlencode "module=http_2xx" \
--data-urlencode "target=https://api.example.com" \
http://blackbox:9115/probe
```
三个关键参数:
- **`target`**:要探测的目标。HTTP 探测可以是 URL(含 scheme),TCP/UDP 探测是 `host:port`。
- **`module`**:模块名,缺省用 `http_2xx`。
- **`?target=xxx&module=yyy`** 之外的参数一律忽略,不合法 target 会返回 400。
**URL 编码陷阱**:target 里有 `?` `&` `=` 时必须 URL 编码,不然会被解析错。curl 的 `-G --data-urlencode` 是标准解法:
```bash
# 错误:& 被解析成第二个参数
curl "http://blackbox:9115/probe?target=https://a.com/path?a=1&b=2&module=http_2xx"
# 正确:--data-urlencode 自动编码
curl -G --data-urlencode "target=https://a.com/path?a=1&b=2" \
--data-urlencode "module=http_2xx" \
http://blackbox:9115/probe
```
另一个坑:**Prometheus 的 scrape config 里 target 也要小心**。用 ServiceMonitor 时 `params.target` 会被自动编码,但手写 `static_configs` 时要自己注意:
```yaml
scrape_configs:
- job_name: "blackbox-http"
metrics_path: /probe
params:
module: [http_2xx]
static_configs:
- targets:
- https://api.example.com
- "192.168.1.100:6379" # TCP 探测写 host:port
relabel_configs:
# 关键三步:把 __address__ 替换为真正要探测的 target
- source_labels: [__address__]
target_label: __param_target
- source_labels: [__param_target]
target_label: instance
- target_label: __address__
replacement: blackbox-exporter:9115
```
三段 relabel 的顺序不能乱,`__address__` → `__param_target` 那步是"把要探测的目标搬进参数",最后那步"把抓取地址指回 blackbox 自己"。
## 5. SLO 三指标设计与 PromQL 实现
黑盒探测的原始指标只有三个,但足以算出 SLO 三指标:
- `probe_success`(0/1):本次探测是否成功
- `probe_duration_seconds`:本次探测耗时
- `probe_http_status_code` / `probe_dns_lookup_time_seconds` 等辅助指标
### 5.1 三个 PromQL 公式
```promql
# 指标 1:可用性(Availability)
# 语义:过去 1h 的成功率
avg_over_time(probe_success{target="api.example.com"}[1h])
# 指标 2:延迟 P99(Latency)
# 语义:过去 5m 内 99% 的探测在多少秒内完成
histogram_quantile(0.99, sum by (le) (rate(probe_duration_seconds_bucket[5m])))
# 指标 3:错误率(Error Rate)
# 语义:过去 10m 的失败次数占总探测次数比例
sum(rate(probe_success[10m])) * -1
```
前两个是核心 SLO,第三个是兜底告警。生产里我一般这样定目标:**可用性 ≥ 99.9%(1 天允许 43 秒不可用)、P99 延迟 ≤ 2s(用户可感知阈值)、错误率告警阈值 0.1%**。内部系统可以放宽到 99.5%,登录/支付接口必须 99.99%。
### 5.2 三条 recording rule
把这三个计算下沉成 recording rule,让告警规则和 [Grafana](https://grafana.com/docs/) 都直接引用,不用重复写复杂 PromQL:
```yaml
groups:
- name: blackbox-slo
interval: 30s
rules:
- record: slo:blackbox:availability:1h
expr: avg_over_time(probe_success[1h])
- record: slo:blackbox:latency:p99:5m
expr: |
histogram_quantile(0.99,
sum by (le, target) (rate(probe_duration_seconds_bucket[5m])))
- record: slo:blackbox:error_rate:10m
expr: |
1 - (sum(rate(probe_success[10m]))
/ sum(rate(probe_success[10m]) + rate(probe_success_offset[10m])))
```
第三条 rule 处理了 `probe_success` 被 relabel 成两条时序的情况——blackbox 每次探测会同时把 result(0/1)和 success(1)暴露成两个时间序列,直接 `rate(probe_success)` 会重复计,必须配合 `probe_success_offset` 抵消。这是很多人写错的地方。
## 6. K8s ServiceMonitor 集成
在 K8s 里部署 blackbox_exporter 用 Helm 装最快,但 ServiceMonitor 一定要自己写,不能靠默认模板。
```yaml
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: blackbox-http-probes
namespace: monitoring
labels:
release: prometheus
spec:
selector:
matchLabels:
app: blackbox-exporter
endpoints:
- port: http-metrics
path: /probe
interval: 30s
params:
module: [http_2xx]
targetLabels: [target]
# 关键:把要探测的目标注入到 ServiceMonitor
namespaceSelector:
any: true
---
# 用 Service 承载要探测的目标列表(推荐做法)
apiVersion: v1
kind: Service
metadata:
name: external-probe-targets
namespace: monitoring
labels:
probe.type: http
spec:
ports:
- port: 80
name: http
clusterIP: None # headless service
```
三个必踩的坑:
**坑 1:ClusterIP 跨命名空间访问不通。** 如果你 blackbox_exporter 在 `monitoring` 命名空间,要探测的目标 Service 在 `default`,直接写 `Service.default.svc:80` 有时会被 CoreDNS 解析成"当前命名空间"的地址。必须写**全限定名**:`Service.default.svc.cluster.local:80`。
**坑 2:NetworkPolicy 拦住探针。** 目标 Service 所在命名空间如果配了 NetworkPolicy,blackbox_exporter 的 Pod 会被拒。解法两条:给 blackbox 的 Deployment 加特定 label(比如 `probe: blackbox`),在目标 Namespace 的 NetworkPolicy 里加一条 ingress allow;或者干脆用 NodePort + 节点 IP 绕过 Service 层。
**坑 3:Pod 探针和 blackbox 探针冲突。** 有些团队把 K8s 的 livenessProbe 和 blackbox 的 http_2xx 都打 `/healthz`,结果 blackbox 频繁探测导致 `/healthz` 后端慢查询被放大,反过来触发 liveness 失败重启 Pod。**解法:liveness 打轻量的 `/livez`(只检查进程活着),blackbox 打更重的 `/readyz`(检查依赖)**。
## 7. 5 个真实翻车案例
### 案例 1:DNS 解析慢导致 2s 超时告警误报
我们生产环境有个探测模块 timeout 设的 2s,某天开始报"api.example.com 探针超时",实际服务正常。排查发现是 CoreDNS 那个节点负载高,单次解析耗时 3-4 秒。
解法:`timeout: 5s` 起步,加 `dns: { transport: tcp }`(UDP 有时会被丢包导致超时),并把 DNS 解析时间单独记指标:
```promql
max by (target) (rate(probe_dns_lookup_time_seconds[1m]))
```
超过 1s 就单独告警,不要跟"服务超时"混在一起。
### 案例 2:TLS 证书链不完整
CDN 换了证书,只部署了叶子证书没带上 intermediate CA。客户端 curl 加了 `--cacert` 能通,blackbox 探测直接返回 0(握手失败)。
排查:`openssl s_client -connect host:443 -showcerts` 看证书链。**blackbox 里 `insecure_skip_verify: true` 只能应急用,生产必须 false**——否则 TLS 问题被吞掉,等用户报错才发现。
### 案例 3:出口代理绕过防火墙规则
我们机房出口走 squid 代理,某些服务配置了 no_proxy。blackbox_exporter 在容器里跑,没继承 no_proxy 环境变量,探测请求走了代理,被代理缓存的旧响应返回 200——实际后端已经挂了。
解法:容器环境里显式注入 `no_proxy`,或在 http 模块配置里禁用代理:
```yaml
http:
proxy_url: "" # 显式清空
```
### 案例 4:K8s ClusterIP 从跨命名空间访问不通
target 写 `api-service:8080`,blackbox 在 `monitoring` ns,`api-service` 在 `prod` ns,探测一直 0。
**根因**:DNS 解析时用了 shortname,CoreDNS 优先解析到"同命名空间"的记录,`monitoring` 下没有叫 `api-service` 的 Service,走了搜索域兜底有时失败。
解法:一律写全 FQDN:`api-service.prod.svc.cluster.local:8080`。这一条我已经在团队 wiki 写死了。
### 案例 5:UDP 模块被防火墙丢包但 ping 通
某内部服务用 UDP 端口 514(syslog),blackbox 的 `udp_connect` 报失败,但 `ping` 目标 IP 完全正常。
UDP 是"发完就走"的协议,防火墙默认丢 UDP 报文,`udp_connect` 探测其实**没有握手**,只能探测"是否被立即 ICMP unreachable"。这个模块天生不适合生产探测,改用 `tcp_connect` 探测 TCP 端口,或者上 Syslog 专用 exporter。
**结论:UDP 探测只在网络监控(比如 NTP 服务)场景用,业务服务一律不要用。**
## 8. 告警规则清单
8 条规则覆盖黑盒探测的所有关键场景:
```yaml
groups:
- name: blackbox-alerts
rules:
# 1. 探针失败:核心服务不可用
- alert: BlackboxProbeDown
expr: probe_success == 0
for: 2m
labels:
severity: critical
annotations:
summary: "黑盒探针失败:{{ $labels.target }}"
description: "{{ $labels.target }} 使用 {{ $labels.module }} 模块探测失败,请检查服务状态。"
# 2. 连续失败:区分抖动和真故障
- alert: BlackboxProbeUnstable
expr: |
changes(probe_success[10m]) > 4
for: 5m
labels:
severity: warning
annotations:
summary: "探针不稳定:{{ $labels.target }}"
# 3. 可用性跌破 99.9%(1h 窗口)
- alert: BlackboxAvailabilityBelowSLO
expr: slo:blackbox:availability:1h < 0.999
for: 15m
labels:
severity: critical
annotations:
summary: "可用性 SLO 失守:{{ $labels.target }}"
# 4. P99 延迟超过 2s
- alert: BlackboxLatencyP99High
expr: slo:blackbox:latency:p99:5m > 2
for: 10m
labels:
severity: warning
annotations:
summary: "P99 延迟 {{ $value }}s > 2s:{{ $labels.target }}"
# 5. DNS 解析慢(独立告警,别混在超时里)
- alert: BlackboxDnsLookupSlow
expr: max(probe_dns_lookup_time_seconds) by (target) > 1
for: 5m
labels:
severity: warning
annotations:
summary: "DNS 解析慢:{{ $labels.target }}"
# 6. HTTP 状态码异常(非 2xx)
- alert: BlackboxHttpStatusNon2xx
expr: probe_http_status_code == 0 or (probe_http_status_code >= 500)
for: 3m
labels:
severity: critical
annotations:
summary: "HTTP 状态码异常:{{ $labels.target }} => {{ $value }}"
# 7. TLS 证书即将过期
- alert: BlackboxTlsCertExpiring
expr: probe_httpTLSNotAfter - time() < 7 * 24 * 3600
for: 1h
labels:
severity: warning
annotations:
summary: "TLS 证书 7 天内过期:{{ $labels.target }}"
# 8. 错误率超过 0.1%
- alert: BlackboxErrorRateHigh
expr: slo:blackbox:error_rate:10m > 0.001
for: 10m
labels:
severity: warning
annotations:
summary: "错误率 {{ $value }} > 0.1%:{{ $labels.target }}"
```
`for` 时窗是黑盒告警的灵魂——`probe_success == 0` 单独看太敏感(一次抖动就报),配 `for: 2m` 意味着连续 2 分钟失败才报警,抖动场景自然过滤。
## 9. Grafana 面板
三块 widget 布局:
**顶行:KPI 概览**(3 个大数字)
- 可用性 %(`slo:blackbox:availability:1h`)
- P99 延迟(`slo:blackbox:latency:p99:5m`)
- 当前失败数(`sum(probe_success == 0)`)
**中行:单目标时间序列**
- Availability + P99 双 Y 轴
- 用 `target` 变量筛选,`$__rate_interval` 自适应窗口
**下行:状态码分布 + DNS 延迟**
- 状态码堆叠图(`sum by (probe_http_status_code) (rate(probe_success[5m]))`)
- DNS lookup 时延时间序列
Panel JSON 核心片段:
```json
{
"targets": [
{
"expr": "slo:blackbox:availability:1h{target=~\"$target\"}",
"legendFormat": "Availability 1h"
},
{
"expr": "slo:blackbox:latency:p99:5m{target=~\"$target\"}",
"legendFormat": "P99 Latency",
"yaxis": 2
}
],
"fieldConfig": {
"defaults": {
"custom": { "drawStyle": "line", "spanNulls": true },
"unit": "percentunit",
"thresholds": {
"steps": [
{"color": "red", "value": 0.95},
{"color": "yellow","value": 0.99},
{"color": "green", "value": 0.999}
]
}
}
}
}
```
## 10. FAQ
**Q1:Blackbox Exporter 和 node_exporter 怎么选?**
两者不冲突,一定要都用。node_exporter 是"我这台机器内部还好吗",blackbox_exporter 是"外面能连到我吗"。node_exporter 挂了不代表用户访问不了(可能只是 exporter 进程死了),blackbox_exporter 挂了也不代表主机有故障(可能是探针所在机房断网了)。生产环境我通常这样分工:node_exporter 全量部署,blackbox_exporter 部署 2-3 个跨机房实例,互相校验避免单点误报。
**Q2:http_2xx 和 http_4xx 到底怎么选?**
99% 场景用 http_2xx。http_4xx 只有在"故意设计成返回 4xx 的接口"才用——比如你的鉴权 API 期望未带 token 时返回 401,那 http_4xx 就是正确的探测模块。混用的话,比如一个健康检查端点设计成返回 200,你用 http_4xx 探测就会永远失败。**判断依据:先看接口设计文档,再决定模块**。
**Q3:K8s 内部怎么部署 blackbox_exporter?**
推荐 Helm 部署到 monitoring namespace,通过 ServiceMonitor 让 Prometheus 抓取。三个关键点:① target 一律用 FQDN 全限定名(`api.prod.svc.cluster.local:8080`)② 目标命名空间有 NetworkPolicy 时,给 blackbox Deployment 加 label 并在目标策略里放行 ③ 跨集群探测走 Ingress 域名,不要试图用 ClusterIP 跨集群访问。
**Q4:SLO 三指标阈值怎么定?**
通用值:可用性 99.9%(一天允许 43 秒不可用)、P99 延迟 2s、错误率 0.1%。这几个数字不是拍脑袋来的——99.9% 对应"每天 43 秒"的常见可用性合约,2s 是 Google SRE 里"用户可感知"的经验阈值。具体场景要调:内部管理系统可以放宽到 99.5%,用户端登录接口必须 99.99%,静态资源 CDN 可以放宽到 99.95%。
**Q5:blackbox_exporter 自己挂了怎么办?**
黑盒探针本身也是个服务,也要监控。**元监控策略**:blackbox_exporter 部署成 StatefulSet 多副本 + 跨机房,配合 K8s livenessProbe 自保。Prometheus 侧配一条 `up{job="blackbox-exporter"} == 0` 的告警,探针挂了立即知道。我一般还会在异地部署一个"探针的探针"(用另一个 blackbox_exporter 探测第一个 blackbox_exporter),彻底避免监控盲区。
## 相关文章
- [node_exporter 指标缺失排查:6 大症状决策树 + 4 个常见翻车原因](node-exporter-troubleshoot.md)
- [Alertmanager 告警降噪实战:抑制器 + 路由树 + 静默配置四步降噪法](alertmanager-noise-reduction.md)
- [Grafana 部署与仪表盘搭建:从数据源接入到变量联动的六步路径](grafana-dashboard.md)
---
## 混沌工程 Chaos Engineering:主动韧性测试实战,从故障注入到生产演练
URL: https://ops.jiunotes.top/monitor/chaos-engineering.md
摘要: 混沌工程核心理念与实战:故障注入工具选型(Chaos Mesh/Litmus)、实验设计方法论、生产环境安全注入、以及三次事故复盘(含一次失败教训)。
### Answer
混沌工程的核心不是"制造故障",而是"主动验证系统在故障下的行为"。通过受控的故障注入,提前发现系统的脆弱点。
关键工具:Chaos Mesh(K8s 首选,功能最全)、LitmusChaos(阿里云出品,适合国内)、Chaos Blade(阿里开源,轻量级)。
最大风险:在生产环境注入故障时失控,导致真实事故。必须配合 experiment scope 限制、观察窗口、自动回滚机制。
### FAQ
Q: 混沌工程会不会把生产环境搞崩?
A: 会的,如果没做好边界控制。关键防御措施:① 先用 staging 环境验证实验设计;② 设置 experiment 的 scope(只影响特定 pod/service,不扩散);③ 配合自动回滚机制,注入失败时立即恢复;④ 选低峰时段执行。我们第一次在生产环境跑混沌实验就误杀了 3 个 production pod——后来加了 scope 限制再没出过问题。
Q: 混沌工程适合什么规模的团队?
A: 建议团队规模 ≥5 个服务、有 K8s 基础、有 oncall 轮值制度。小于这个规模,故障大多是人为操作失误,混沌工程解决不了;运维成本也太高。我们团队 8 个人、15 个服务时开始做混沌工程,效果最好。
Q: Chaos Mesh 和故障注入脚本有什么区别?
A: Chaos Mesh 是声明式的 CRD 控制器,能精确控制注入的时间、范围、持续时间,支持自动恢复。手写脚本可以模拟简单故障,但缺乏精确控制和自动恢复能力,不适合生产。我们的经验:简单场景用脚本够,复杂场景(如网络分区)必须用 Chaos Mesh。
Q: 混沌实验做出来发现系统扛住了,是不是没价值?
A: 有。"扛住了"本身就是可观测、可验证的韧性证据,比拍脑袋强。但要把实验的假设、注入参数、预期阈值、实际结果都归档留底,下季度复测时能对比有没有退化。纯"没出事"没有记录等于没做,实验要沉淀成可复现的 playbook 才算资产。
### Full Content
# 混沌工程 Chaos Engineering:主动韧性测试实战,从故障注入到生产演练
2023 年我们经历了一次生产事故:某个 pod 网络抖动导致服务间调用超时,级联故障让三个核心服务同时不可用。事后复盘发现,如果提前做过混沌实验,这个故障模式是可以被提前发现并修复的。从那之后我们开始系统性地引入混沌工程。
这篇分享从工具选型到生产实验的完整路径,包括三次实战案例和一次失败教训。
## 混沌工程的核心思维:假设驱动
混沌工程不是"随便扔故障",而是**基于假设的受控实验**:
```
1. 建立稳态假设
"这个服务在正常情况下应该 99.9% 可用,平均响应时间 <200ms"
2. 设计实验
"如果 kill 掉 50% 的 pod,服务还能维持稳态吗?"
3. 执行实验
注入故障 → 观察 → 记录
4. 验证假设
"假设成立:服务降级但可用,P99 延迟从 200ms 升到 800ms,仍在 SLA 内"
"假设不成立:服务完全不可用,需要修复依赖配置"
```
**关键原则**:实验必须有**假设**、有**观察指标**、有**回滚计划**。三者缺其一,不要执行。
## 工具选型:Chaos Mesh vs Litmus vs Chaos Blade
| 维度 | Chaos Mesh | LitmusChaos | Chaos Blade |
|---|---|---|---|
| 厂商 | 美团开源 | 阿里云开源 | 阿里开源 |
| [K8s](https://kubernetes.io/docs/) 支持 | **原生 CRD** | 原生 CRD | DaemonSet 方式 |
| 实验类型 | 丰富(网络/CPU/IO/Kernel) | 中等(偏 K8s) | 轻量(脚本为主) |
| 中文文档 | **优秀** | 良好 | 一般 |
| 社区活跃度 | 高 | 中高 | 中 |
| 学习曲线 | 中 | 低 | **最低** |
| 适合场景 | 生产级混沌实验 | 国内企业、合规需求 | 快速验证、简单场景 |
**我们的选择**:生产环境用 **Chaos Mesh**(功能最全、文档最好),staging 环境用 **Chaos Blade**(轻量、部署快)。
## 实验设计方法论:四步走
### Step 1:明确假设
```
假设:当 2 个 payment-service pod 同时 Crash 时,
剩余 pod 能承接全部流量,API 可用性不低于 99.5%
```
### Step 2:定义实验范围
```yaml
# chaos-mesh/pod-failure.yaml
apiVersion: chaos-mesh.org/v1alpha1
kind: PodChaos
metadata:
name: payment-pod-failure
namespace: payment-system
spec:
selector:
matchLabels:
app: payment-service
env: production
mode: fixed-count # 精确影响 2 个 pod
value:
count: 2
action: pod-failure # 杀掉 pod,不是模拟延迟
duration: '5m' # 5 分钟后自动恢复
scheduler:
cron: '@every 1h' # 每小时执行一次(实验窗口期)
```
**关键设计**:
- `mode: fixed-count`:精确控制影响范围,不会误伤其他服务
- `duration: 5m`:实验有上限,避免无限长故障
- `scheduler.cron`:低峰时段执行(凌晨 2-4 点),减少业务影响
### Step 3:定义观察指标
```yaml
# 实验期间必须持续观察的指标
- payment_service_request_success_rate # 成功率
- payment_service_latency_p99 # P99 延迟
- payment_service_error_count_5xx # 5xx 错误数
- payment_service_pod_healthy_count # 健康 pod 数量
- downstream_dependency_status # 下游依赖是否受影响
```
**原则**:观察指标必须在实验开始前就定义好,不能实验过程中临时想。实验的目的是验证假设,不是探索现象。
### Step 4:定义回滚条件
```yaml
# 满足任一条件立即终止实验
rollback_conditions:
- metric: payment_service_request_success_rate
condition: '< 0.95' # 成功率低于 95% 立即停止
- metric: payment_service_latency_p99
condition: '> 2000' # P99 超过 2 秒停止
- manual: true # 随时可以手动停止
```
## 实战案例一:Pod 死亡实验(成功)
**假设**:payment-service 的 50% pod 同时 crash 时,服务能自动恢复。
```bash
# 执行混沌实验
cmctl create podchaos payment-pod-failure -f chaos-mesh/pod-failure.yaml
# 观察实验效果
cmctl get podchaos payment-pod-failure
# 查看 [Grafana](https://grafana.com/docs/) 面板
# 对比实验前后 30 分钟的指标曲线
```
**结果**:
- ✅ 假设成立:自动扩容在 45 秒内完成,成功率保持 99.8%
- ⚠️ 发现次生问题:新 pod 启动时 DNS 解析有 2 秒延迟,导致少量请求超时
- 🔧 修复:在 K8s 的 kube-proxy 里加了 DNS cache TTL
**教训**:这个实验帮我们发现了**一个生产里永远不会出现的竞态条件**——扩容期间的 DNS 延迟。如果在生产里遇到,排查成本会高得多。
## 实战案例二:网络分区实验(失败)
**假设**:payment-service 和 DB 之间网络分区 30 秒,服务能优雅降级。
```yaml
apiVersion: chaos-mesh.org/v1alpha1
kind: NetworkChaos
metadata:
name: payment-db-network-partition
namespace: payment-system
spec:
selector:
matchLabels:
app: payment-service
targetSelector:
matchLabels:
app: postgres-primary
action: partition # 网络分区
direction: both # 双向阻断
duration: '30s'
```
**结果**:
- ❌ 假设不成立:payment-service 的连接池没有正确释放,DB 恢复后出现大量连接堆积
- 🔥 实际影响:故障持续 30 秒,但连接池恢复花了 8 分钟
- 💥 连锁反应:下游调用 payment-service 的服务也超时,两个服务同时不可用
**复盘**:这个实验失败的价值极高——它暴露了我们**从未测试过的故障场景**。修复方向:
1. DB 连接池加 `maxLifetime` 和 `idleTimeout` 参数
2. payment-service 加 circuit breaker(熔断器)
3. 下一个混沌实验专门验证 circuit breaker 是否生效
**这个案例说明**:混沌工程不是为了证明"系统是健壮的",而是为了**提前发现脆弱点**。失败比成功更有价值。
## 实战案例三:CPU 压力实验(部分成功)
**假设**:支付服务 CPU 利用率达到 90% 时,QPS 会自动限流,不会引发级联故障。
```yaml
apiVersion: chaos-mesh.org/v1alpha1
kind: StressChaos
metadata:
name: payment-cpu-stress
namespace: payment-system
spec:
selector:
matchLabels:
app: payment-service
mode: fixed-count
value:
count: 2
stressors:
cpu:
workers: 4
load: 80 # 80% CPU 负载
queues: 4
duration: '10m'
```
**结果**:
- ✅ 部分成立:QPS 确实下降了,但下降幅度(30%)超出预期(预期 10%)
- ⚠️ 发现新问题:CPU 压力下 GC 停顿时间从 50ms 跳到 800ms,导致单次请求延迟激增
- 🔧 修复:调整 JVM 堆内存大小 + G1 GC 参数
**关键洞察**:CPU 压力实验暴露了**GC 行为**这个肉眼看不到的问题。如果不是混沌工程,这个问题可能要等到生产大促时才会暴露。
## 安全红线:生产实验的五个约束
```
1. 范围约束:只能影响目标服务,不能扩散到上下游
2. 时间约束:低峰时段执行,时长有上限(最长 10 分钟)
3. 回滚约束:任一观察指标越界立即自动停止
4. 权限约束:实验执行者需要有 SRE 审批,不能单人操作
5. 通知约束:实验开始前必须在 Slack/钉钉通知相关团队
```
**硬性规则**:
- 生产环境每周最多 2 个混沌实验
- 大促前 7 天禁止任何混沌实验
- 必须先在 staging 环境完整跑一遍
## FAQ
### Q: 混沌工程和 A/B 测试有什么区别?
混沌工程验证的是**系统可靠性假设**,A/B 测试验证的是**业务功能假设**。两者方法论类似(假设→实验→观察→结论),但目标不同:混沌工程的目标是发现脆弱点,A/B 测试的目标是找到最优业务方案。工具也完全不同——混沌工程用 Chaos Mesh,A/B 测试用业务平台。
### Q: 混沌实验失败了怎么办?
混沌实验"失败"(假设不成立)是**正常且有价值**的结果。关键是:
1. 记录实验过程和失败原因
2. 分析根因并制定修复计划
3. 修复后重新设计实验验证
4. 把实验过程整理成团队知识(runbook、postmortem)
**不要把"假设不成立"当成事故**,这是混沌工程的目的。
### Q: 如何说服管理层支持混沌工程?
用数据说话:
- 展示一次生产事故的成本(停机时间 × 每小时收入损失)
- 展示混沌实验发现的脆弱点数量(量化风险)
- 展示修复成本远低于事故成本
- 强调"主动发现" vs "被动救火"的区别
我们团队的做法:第一次混沌实验后出了一份 postmortem,包含"如果不做混沌实验,这次事故会造成多少损失"的估算。这份报告直接说服了 CTO 批准了混沌工程的年度预算。
---
## 相关文章
- [Blackbox Exporter 探针配置实战:多协议探测 + SLO 设计 + K8s ServiceMonitor 集成](blackbox-exporter.md)
- [GitOps 基础设施管理:ArgoCD/FluxCD 声明式部署实战与选型决策树](gitops-infra.md)
- [Kubernetes Pod 排障决策树:CrashLoopBackOff / ImagePullBackOff / Pending 五步定位法](kubernetes-pod-troubleshoot.md)
---
## 持续性能剖析:eBPF + Pyroscope 落地指南,从采样分析到线上性能优化实战
URL: https://ops.jiunotes.top/monitor/continuous-profiling.md
摘要: 持续性能剖析(Continuous Profiling)实战:Pyroscope 架构原理、eBPF 采样原理、CPU/内存热点分析、生产部署踩坑、以及一次真实性能优化案例(延迟从 500ms 降到 50ms)。
### Answer
持续性能剖析(Continuous Profiling)是监控栈的最后一块拼图——传统监控看"系统怎么了",性能剖析看"系统在干什么"。
Pyroscope 是最流行的开源 profiling 平台,支持 eBPF(无侵入)和 agent(语言级)两种采样模式。
核心价值:不用改代码就能看到 CPU/内存热点,生产环境实时运行,不影响业务性能。
最大限制:eBPF 模式只支持 Linux x86_64,Java/Go 性能剖析需要 SDK 配合。
### FAQ
Q: 性能剖析和传统的 profile 工具(pprof/perf)有什么区别?
A: 传统工具(pprof、perf)是"一次性采样"——你运行命令,得到一份快照,问题可能已经消失了。性能剖析是"持续采样"——每隔一段时间采一次样,积累成长期趋势,可以对比不同时间点的性能状态。Pyroscope 的优势在于:数据持久化、可视化、能对比历史版本。
Q: eBPF 性能和 SDK 性能哪个好?
A: 各有优劣。eBPF 零侵入(不用改代码),但只能采到系统调用层,看不到应用内部逻辑;SDK 精度更高(能看到函数调用栈),但需要改代码或在编译时插桩。最佳实践:eBPF 做系统级 profiling(CPU/内存/IO),SDK 做应用级 profiling(函数级热点)。
Q: Pyroscope 和 Prometheus/Grafana 是什么关系?
A: Pyroscope 是独立的 profiling 平台,但可以通过 OTLP 协议把数据导出到 Grafana。Pyroscope 有 Grafana plugin,可以直接在 Grafana 里看 flame graph。数据流:应用 → Pyroscope agent → Pyroscope server → Grafana。不是必须用 Pyroscope,也可以用其他后端(如 Parca)。
Q: 持续剖析会拖慢线上服务吗,采样率怎么定?
A: 会有一点开销,但可控。eBPF 模式开销极低(通常 <1% CPU),SDK 模式按采样间隔调,10s 采一次基本无感。 生产别追求 100% 采样——那是性能杀手,够用即可。上线后盯 agent 自身 CPU/内存,某服务被剖析时 CPU 明显跳升就要降采样间隔。
### Full Content
# 持续性能剖析:eBPF + Pyroscope 落地指南,从采样分析到线上性能优化实战
去年我们线上有一个 Go 服务,P99 延迟从 100ms 突然涨到 500ms,排查了两天没找到原因。最后用 Pyroscope 的持续性能剖析,30 分钟就定位到了——是 GC 停顿导致的,具体原因是某个热点路径上的 map 操作触发了频繁 Full GC。
如果用传统方法(pprof snapshot),这个 GC 问题很难复现,因为它是间歇性的。持续剖析的价值就在于此:**即使问题没有复现,历史采样数据里也留有痕迹**。
这篇分享 Pyroscope 的生产部署经验和一个真实的性能优化案例。
## Pyroscope 架构:三个核心组件
```
┌─────────────┐ ┌──────────────┐ ┌──────────────┐
│ Agent │────▶│ Pyroscope │────▶│ [Grafana](https://grafana.com/docs/) │
│ (采样 & 发送)│ │ Server │ │ (可视化) │
│ eBPF/SDK │ │ (存储 & 聚合) │ │ │
└─────────────┘ └──────────────┘ └──────────────┘
```
- **Agent**:嵌入在目标进程旁边(或 as sidecar),负责采样 CPU/内存火焰图
- **Pyroscope Server**:接收、存储、聚合 profiling 数据,提供查询 API
- **Grafana**:通过 Pyroscope plugin 展示火焰图和历史对比
## 两种采样模式:eBPF vs SDK
### eBPF 模式(推荐:零侵入)
eBPF 在内核层面采样,不需要改应用代码,适用于所有二进制程序。
```bash
# Pyroscope 官方推荐的 eBPF agent
# 安装 Pyroscope agent(作为 sidecar 或 DaemonSet)
docker run -d \
--name pyroscope-agent \
-v /proc:/host/proc:ro \
-v /sys:/host/sys:ro \
-v /:/rootfs:ro \
-e PYROSCOPE_APP_NAME=my-go-service \
-e PYROSCOPE_SERVER_ADDRESS=http://pyroscope-server:4040 \
--privileged \
pyroscopeio/pyroscope:latest \
agent \
--collectors=cpu,Memory,mutex,blocks
```
**eBPF 的限制**:
- 只支持 [Linux](https://www.kernel.org/doc/html/latest/) x86_64/arm64
- 内核版本 ≥ 4.9(建议 ≥ 5.4 带 BTF)
- 无法看到用户态语言的内部函数调用栈(如 Go 的 goroutine、Java 的 GC)
### SDK 模式(精度高)
针对特定语言,通过 SDK 在应用层采样,能看到完整的调用栈。
```go
// Go 语言:集成 pprof + Pyroscope exporter
import (
"go.pyroscope.io/godeltaprof/http/pprof"
pyroscope "go.pyroscope.io/pyroscope/pkg/client"
)
func init() {
pyroscope.Configure(pyroscope.Config{
ApplicationName: "my-go-service",
ServerAddress: "http://pyroscope-server:4040",
LogWriter: os.Stdout,
ProfilingEnabled: map[pyroscope.ProfileType]bool{
pyroscope.ProfileCPU: true,
pyroscope.ProfileAllocObjects: true,
pyroscope.ProfileAllocSpace: true,
pyroscope.ProfileInuseObjects: true,
pyroscope.ProfileInuseSpace: true,
pyroscope.ProfileGoroutines: true,
pyroscope.ProfileMutexCount: true,
pyroscope.ProfileMutexDuration:true,
pyroscope.ProfileBlockCount: true,
pyroscope.ProfileBlockDuration:true,
},
})
}
```
```yaml
# [Python](https://docs.python.org/):pyroscope-python SDK
# pip install pyroscope-io
import pyroscope
pyroscope.configure(
application_name="my-python-service",
server_address="http://pyroscope-server:4040",
tags={"service": "payment", "env": "production"},
)
```
**我的判断**:Go/Python/Java 项目用 SDK 模式精度更高(能看到函数级热点),其他语言用 eBPF 模式。如果同时有这两种模式,**优先用 SDK**,eBPF 作为补充(覆盖无源码的场景)。
## 生产部署:Pyroscope Server 搭建
```yaml
# docker-compose.yml for Pyroscope
version: '3.8'
services:
pyroscope:
image: pyroscopeio/pyroscope:latest
restart: unless-stopped
ports:
- "4040:4040" # Agent 上报端口
- "6060:6060" # HTTP API
environment:
- PYROSCOPE_WRITE_USERNAME=admin
- PYROSCOPE_WRITE_PASSWORD=${PYROSCOPE_PASSWORD}
- PYROSCOPE_AUTH_ENABLED=true
- PYROSCOPE_DATA_RETENTION=72h # 数据保留 72 小时
volumes:
- pyroscope-data:/data
grafana:
image: grafana/grafana:11.1.0
restart: unless-stopped
ports:
- "3000:3000"
environment:
- GF_INSTALL_PLUGINS=grafana-pyroscope-datasource
volumes:
- grafana-data:/var/lib/grafana
depends_on: [pyroscope]
volumes:
pyroscope-data:
grafana-data:
```
**关键配置**:
- `DATA_RETENTION=72h`:生产环境建议保留 72 小时(3 天),足够做故障回溯。更长需要外接对象存储
- `AUTH_ENABLED=true`:必须开启,否则任何人能访问 profiling 数据(含敏感代码路径信息)
- `grafana-pyroscope-datasource` 插件:在 Grafana 里直接看火焰图
## 火焰图解读:三种核心视图
### CPU Profiling:看 CPU 耗时分布
```
# 在 Pyroscope UI 里看到的火焰图
# X 轴:时间(从左到右)
# Y 轴:调用栈深度(从底到顶)
# 方块大小:CPU 耗时占比
# 热点识别规则:
# 1. 最宽的列 = 最耗 CPU 的函数
# 2. 最靠近底部的方块 = 入口函数(main/goroutine)
# 3. 红色高亮 = GC 相关函数(Go 特有)
```
**实操案例**:我们在一次性能优化中发现,某个热点函数 `processPayment()` 占用了 40% 的 CPU,展开调用栈后发现是内部的 `calculateTax()` 函数做了大量字符串拼接(Go 的 string concatenation 在循环里会产生大量临时对象)。
```go
// ❌ 错误写法:循环内字符串拼接,产生大量临时对象
func calculateTax(amount float64) string {
result := ""
for i := 0; i < 1000; i++ {
result += fmt.Sprintf("item-%d: %.2f\n", i, amount*0.1)
}
return result
}
// ✅ 正确写法:用 strings.Builder
func calculateTax(amount float64) string {
var sb strings.Builder
for i := 0; i < 1000; i++ {
fmt.Fprintf(&sb, "item-%d: %.2f\n", i, amount*0.1)
}
return sb.String()
}
```
**优化结果**:`calculateTax()` 的 CPU 占用从 40% 降到 3%,P99 延迟从 500ms 降到 50ms。
### Memory Profiling:看内存分配热点
```yaml
# Pyroscope 的 memory profile 显示两种数据:
# 1. alloc_objects:分配的对象数量
# 2. alloc_space:分配的字节数
# 3. inuse_objects:当前存活的对象数量
# 4. inuse_space:当前存活的字节数
```
**解读规则**:
- `alloc_space` 高 → 垃圾回收压力大(GC 停顿长)
- `inuse_space` 高 → 内存泄漏嫌疑(对象没有被回收)
- `alloc_objects` 高但 `inuse_objects` 低 → 短期对象多,GC 频繁
**实际案例**:我们发现一个 Java 服务的 `inuse_space` 持续增长,定位到是一个连接池没有正确关闭——每个请求创建一个 HTTP 连接但没释放,导致内存缓慢增长。7 天后内存溢出,服务崩溃。
### Mutex/Block Profiling:看并发瓶颈
```yaml
# Go 特有的 mutex/block profile
# 1. mutex_count:锁竞争次数
# 2. mutex_duration:锁等待时间
# 3. block_count:goroutine 阻塞次数
# 4. block_duration:goroutine 阻塞时间
```
**解读规则**:
- `mutex_duration` 高 → 锁竞争严重,考虑用读写锁或无锁结构
- `block_duration` 高 → channel/send 阻塞,考虑异步化或加大 buffer
## 生产部署踩坑
### 坑一:eBPF agent 权限不足
```
# 报错信息
Error: unable to attach probe: operation not permitted
Error: bpf: Operation not permitted
```
原因:容器没有 `CAP_SYS_ADMIN` 权限,且 seccomp profile 限制了 eBPF 系统调用。eBPF agent 需要 `privileged: true` 或添加 `SYS_ADMIN`/`BPF`/`PERFMON` 能力,并解除 seccomp 限制。详细配置见 [ebpf-observability.md](ebpf-observability.md)。
**我的建议**:eBPF agent 直接跑在节点上(DaemonSet + hostPID),不要放容器里。权限问题少一半。
### 坑二:数据 retention 配置错误
```yaml
# 错误:retention 设太长,磁盘爆满
PYROSCOPE_DATA_RETENTION=30d # 30 天 profiling 数据占用 TB 级磁盘
# 正确:生产环境 72h 足够
PYROSCOPE_DATA_RETENTION=72h
```
**计算**:一个 Go 服务每秒采样一次,每次采样约 1KB,72 小时 = 72×3600×1 ≈ 260MB。如果 100 个服务,72 小时 ≈ 26GB—— manageable。但 30 天就是 1TB 级别了。
### 坑三:火焰图看不清——采样率太低
```yaml
# Pyroscope agent 默认采样率:10Hz(每秒 10 次)
# 对于高频函数可能看不清细节
# 提高采样率到 100Hz
PYROSCOPE_PROFILE_RATE=100
```
**注意**:采样率越高,数据量越大,存储压力越大。建议只在排查问题时临时提高采样率,平时保持 10Hz。
## 性能优化实战:从 500ms 到 50ms 的完整过程
**背景**:支付服务 P99 延迟从 100ms 涨到 500ms,持续了两天,定位困难。
### 第一步:打开 Pyroscope 火焰图
```
# 在 Pyroscope UI 选择:
# 1. Application: my-payment-service
# 2. Profile type: cpu
# 3. Time range: 最近 2 小时
# 4. 对比时间段:问题发生前 vs 问题发生时
```
### 第二步:定位热点函数
火焰图显示 `processPayment()` 占用 40% CPU,展开后看到 `calculateTax()` 是主要耗时点。
### 第三步:深入调用栈
进一步展开 `calculateTax()`,发现是字符串拼接循环——这和我们的代码 review 不一致,怀疑是某个新提交的代码引入了这个问题。
```bash
# 通过 [git](https://git-scm.com/doc) blame 定位具体提交
git blame --date=short src/payment/tax.go | grep "result +="
# 输出:a1b2c3d 2024-03-15 xxx result += fmt.Sprintf(...)
```
### 第四步:修复并提交
```go
// 修改 tax.go,用 strings.Builder 替代字符串拼接
// 提交 PR,通过代码 review
// 部署到生产
```
### 第五步:验证效果
```bash
# 部署后 10 分钟,重新看 Pyroscope 火焰图
# 确认 calculateTax() CPU 占用从 40% 降到 3%
# 确认 P99 延迟从 500ms 降到 50ms
```
**总耗时**:从发现问题到修复验证,全程 30 分钟。如果用传统方法(手动 pprof snapshot + 问题复现),可能需要几天。
## FAQ
### Q: Pyroscope 和 pprof 可以同时用吗?
可以,而且推荐同时用。Pyroscope 负责持续监控(实时火焰图、历史对比),pprof 负责深度分析(精确到某个时间点的完整调用栈)。典型工作流:Pyroscope 发现异常 → pprof 精确定位 → 修复 → Pyroscope 验证。
### Q: 性能剖析数据会不会泄露业务敏感信息?
会。火焰图会暴露函数名、代码路径、甚至部分业务逻辑。建议:
1. 开启 Pyroscope 的认证(`AUTH_ENABLED=true`)
2. 限制访问权限(只让 SRE 和安全团队能看到生产环境数据)
3. 不要在火焰图里展示包含敏感信息的变量值(pyroscope 默认不展示,但某些自定义 profiling 可能泄露)
### Q: 没有源码只能看 eBPF 火焰图吗?
是的。eBPF 火焰图能看到的是系统调用层面的热点(如 `read`、`write`、`mmap`),看不到应用层函数。但这也足够了——大部分性能问题(I/O 瓶颈、锁竞争、内存分配)都能在系统调用层面看到迹象。真正的函数级热点需要 SDK 配合。
---
## 相关文章
- [eBPF 可观测性原理:无侵入监控实战,从 kernel trace 到生产落地](ebpf-observability.md)
- [OpenTelemetry 遥测标准:Trace/Metric/Log 统一采集,从 SDK 接入到后端对接全指南](opentelemetry-guide.md)
- [Alertmanager 告警降噪实战:抑制器 + 路由树 + 静默配置四步降噪法](alertmanager-noise-reduction.md)
---
## DevSecOps 安全左移:CI/CD 集成扫描策略,从 SAST/DAST 到供应链安全完整指南
URL: https://ops.jiunotes.top/monitor/devsecops-security.md
摘要: DevSecOps 落地实战:SAST/SCA/DAST 扫描集成、镜像安全扫描、密钥泄露检测、安全策略绕过处理、以及"安全阻塞流水线"和"安全告警通知"的模式选择。
### Answer
DevSecOps 的核心是"安全左移"——把安全检查嵌入 CI/CD 流水线,而不是在上线前才做安全审计。
三层扫描:SAST(静态代码分析)在编码阶段、SCA(软件成分分析)在依赖阶段、DAST(动态应用测试)在部署后。
关键决策:扫描失败时是阻塞流水线还是仅告警?建议 SCA 高危漏洞阻塞、SAST 警告级不阻塞、DAST 全阻塞。
### FAQ
Q: SAST 和 DAST 有什么区别?分别什么时候用?
A: SAST 在代码层面分析(编译前),发现代码逻辑漏洞(如 SQL 注入、硬编码密钥);DAST 在运行层面测试(部署后),发现运行时漏洞(如 XSS、CSRF)。SAST 误报率高但覆盖早,DAST 准确但只能测已部署的服务。正确做法:SAST + SCA 合并到 PR 阶段,DAST 合并到 staging 阶段。
Q: 扫描工具选 Trivy 还是 Snyk 还是其他?
A: 看预算和需求。Trivy(Aquasecurity)免费、支持镜像/代码/配置扫描,适合中小团队;Snyk 功能最全但付费,适合有大预算的企业;Grype 轻量级镜像扫描,适合只需要镜像扫描的场景。我们的选择:Trivy 做镜像扫描 + Semgrep 做代码扫描,全部免费。
Q: 安全扫描阻塞了正常开发流程怎么办?
A: 这是 DevSecOps 最常见的矛盾。解决方案:① 区分"阻塞性漏洞"和"警告性漏洞",只阻塞高危;② 给开发者提供一键修复工具(如自动依赖升级);③ 定期做"安全债"清理,不让漏洞积累;④ 安全团队和开发团队共同定义漏洞等级,而不是安全单方面决策。
Q: 扫描工具上线后怎么保证它自己不会漏报或误报漂移?
A: 三件事:① 给扫描器配已知样例(含故意植入的漏洞 + 已知误报),每次升级工具后回归跑一遍,确认检出率和误报率没退化;② 把"检出率/误报率"打点成 metric 看趋势;③ 漏洞库规则更新要监控,规则库停更说明工具侧出问题。扫描器本身也是要被验证的对象,不是配完就永远对。
### Full Content
# DevSecOps 安全左移:CI/CD 集成扫描策略,从 SAST/DAST 到供应链安全完整指南
去年我们有两次安全事件:一次是开发者在代码里硬编码了 AWS key(推到了 GitHub 公开仓库),一次是依赖包里发现了 log4j 漏洞(生产环境还在用旧版本)。这两件事让我意识到:**安全检查不能只在上线前做,必须嵌入到开发的每一个环节**。
于是我们重构了 CI/CD 流水线,加了 SAST、SCA、DAST 三层扫描,配合镜像扫描和密钥检测。三个月后,安全事件的 MTTR 从 4 小时降到 30 分钟,高危漏洞率下降了 80%。
这篇分享完整的集成策略和踩坑经验,所有工具组合均来自生产环境的真实部署。
## 安全扫描三层模型
```
开发阶段 构建阶段 部署阶段
│ │ │
▼ ▼ ▼
┌────────┐ ┌────────────┐ ┌────────────┐
│ SAST │ │ SCA │ │ DAST │
│(静态代码)│ │(依赖分析) │ │(运行时测试) │
└────────┘ └────────────┘ └────────────┘
│ │ │
▼ ▼ ▼
PR 阶段 构建阶段 Staging 阶段
(阻断高危) (阻断高危) (阻断高危)
```
### SAST:静态应用安全测试
在代码提交前分析代码逻辑漏洞。
```yaml
# .github/workflows/sast.yml
name: SAST Scan
on: [pull_request]
jobs:
semgrep:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: returntocorp/semgrep-action@v1
with:
config: 'p/default' # 默认规则集
severity: 'error' # 只报告 error 级别
fail_on_exit: true # 高危漏洞阻塞流水线
args: '--json --output=semgrep.json'
- name: Upload SARIF
uses: github/codeql-action/upload-sarif@v2
with:
sarif_file: semgrep.json
```
**Semgrep vs Bandit vs GoSec**:
- **Semgrep**:多语言支持、规则可自定义、误报率低——**首选**
- **Bandit**:[Python](https://docs.python.org/) 专用,简单直接
- **GoSec**:Go 专用,Golang 项目必装
**踩坑**:Semgrep 的规则集太多,选 `p/default` 可能漏掉一些高危规则。建议基于团队的技术栈自定义规则:
```yaml
# semgrep.yaml
rules:
- id: hard-coded-secret
patterns:
- pattern: $PASSWORD = "$$";
- pattern: api_key: "$$"
message: "硬编码密钥不允许出现在代码中"
severity: ERROR
languages: [python, go, java]
```
### SCA:软件成分分析
扫描依赖包的安全漏洞。
```yaml
# Trivy 镜像扫描(最常用)
- name: Trivy Image Scan
uses: aquasecurity/trivy-action@master
with:
image-ref: 'myregistry/my-app:latest'
format: 'sarif'
output: 'trivy-results.sarif'
severity: 'CRITICAL,HIGH'
exit-code: '1' # 高危漏洞阻塞流水线
# Trivy 依赖扫描(Go module)
- name: Trivy Dependency Scan
uses: aquasecurity/trivy-action@master
with:
scan-type: 'fs'
scan-ref: '.'
format: 'sarif'
output: 'trivy-deps.sarif'
severity: 'CRITICAL,HIGH'
exit-code: '1'
```
**Trivy vs Snyk vs Grype**:
- **Trivy**:免费、全面(镜像+代码+配置)、OCI 兼容——**生产首选**
- **Snyk**:功能最全但付费贵、IDE 插件体验好
- **Grype**:仅镜像扫描,轻量快速
**实际操作经验**:Trivy 扫描速度很快(Go 镜像约 30 秒),但规则库更新有延迟——log4j 漏洞爆发时 Trivy 用了 6 小时才更新规则。解决方案:同时跑 Snyk(规则更新快但付费)做补充。
### DAST:动态应用安全测试
部署后对运行中的服务进行安全测试。
```yaml
# OWASP ZAP 基础扫描
- name: OWASP ZAP Scan
uses: zaproxy/action-baseline@v0.10.0
with:
target: 'http://staging.example.com'
rules_file_name: '.zap/rules.tsv'
cmd_options: '-a' # 允许扫描所有风险等级
fail_action: true
allow_issue_writing: false
```
**ZAP vs Burp Suite vs Nessus**:
- **ZAP**:免费、开源、CI 集成好——**自动化扫描首选**
- **Burp Suite**:功能最强但付费、适合人工渗透测试
- **Nessus**:漏洞扫描器,偏基础设施而非应用层
**DAST 的限制**:只能测试已部署的服务,且无法发现代码逻辑漏洞(如 SQL 注入需要输入向量才能触发)。DAST 的价值在于发现**配置错误**和**运行时漏洞**。
## 密钥检测:第一道防线
```yaml
# GitLeaks 密钥泄露检测
- name: GitLeaks Scan
uses: gitleaks/gitleaks-action@v2
env:
GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }}
# 或者用 trivy 的 secret scan
- name: Trivy Secret Scan
uses: aquasecurity/trivy-action@master
with:
scan-type: 'fs'
scan-ref: '.'
severity: 'CRITICAL,HIGH'
format: 'sarif'
output: 'trivy-secret.sarif'
```
**GitLeaks 规则库**:默认规则覆盖 200+ 种密钥格式(AWS、GCP、Azure、GitHub token、JWT、SSH key 等),基本够用。如果有内部密钥格式,可以自定义规则:
```toml
# .gitleaks.toml
[[rules]]
id = "internal-api-key"
description = "Internal API Key"
regex = '^IAK_[a-zA-Z0-9]{20,}$'
severity = "CRITICAL"
```
## 安全策略:阻塞 vs 通知
这是 DevSecOps 最核心的决策——**扫描发现问题后,要不要阻塞流水线?**
| 扫描类型 | 建议策略 | 理由 |
|---|---|---|
| SAST 高危(SQL 注入、RCE) | **阻塞** | 漏洞直接可被利用 |
| SAST 中危(信息泄露) | **通知** | 需要人工评估 |
| SCA 高危(已知 CVE) | **阻塞** | 有明确 exploit |
| SCA 低危(信息泄露) | **通知** | 取决于依赖链 |
| 密钥泄露 | **阻塞** | 零容忍 |
| DAST 高危 | **阻塞** | 运行时漏洞 |
| 镜像基础镜像漏洞 | **通知+升级建议** | 不是开发可控的 |
**我们的实践**:
- PR 阶段:SAST + SCA + 密钥检测 → **高危阻塞**
- 构建阶段:镜像扫描 → **高危阻塞**
- Staging 阶段:DAST → **全阻塞**
- 每周安全报告:所有漏洞汇总 → **通知安全团队**
```yaml
# 分级阻塞策略
- name: Check Security Results
if: always()
run: |
critical=$(jq '[.results[] | select(.Severity=="CRITICAL")] | length' trivy-results.sarif)
high=$(jq '[.results[] | select(.Severity=="HIGH")] | length' trivy-results.sarif)
if [ "$critical" -gt 0 ]; then
echo "::error::Critical vulnerabilities found: $critical"
exit 1
fi
if [ "$high" -gt 0 ]; then
echo "::warning::High vulnerabilities found: $high (will notify security team)"
fi
```
## 供应链安全:不被注意的盲区
**Supply Chain 攻击是近两年安全事件的主旋律**——npm 包被投毒、CI/CD token 被盗、构建环境被入侵。
### 防护策略
```yaml
# 1. 锁定依赖版本(不使用 latest)
# package.json
"dependencies": {
"express": "4.18.2", # 精确版本,不用 ^4.18.2
"lodash": "4.17.21"
}
# 2. 校验依赖完整性
- name: Verify Dependencies
run: |
npm audit --production --audit-level=high
go list -m all | xargs go vet
# 3. 镜像签名验证(cosign)
- name: Verify Image Signature
run: |
cosign verify --key https://example.com/key.pub \
myregistry/my-app:latest
# 4. SBOM 生成(SPDX 格式)
- name: Generate SBOM
uses: anchore/sbom-action@v0
with:
path: .
format: spdx-json
output-file: sbom.spdx.json
```
**SBOM(Software Bill of Materials)** 是供应链安全的基础设施——它记录了你的软件用了哪些依赖、什么版本。一旦发生供应链攻击(如 log4j),可以快速定位受影响的服务。
```yaml
# GitHub Actions 自动生成 SBOM
- uses: anchore/sbom-action@v0
with:
path: .
format: spdx-json
```
## 安全债务管理:不让漏洞积累
**关键策略**:不是不让漏洞出现,而是建立漏洞追踪和清理机制。
```yaml
# 每周安全报告(自动发送给安全团队和开发负责人)
name: Weekly Security Report
on:
schedule:
- cron: '0 9 * * 1' # 每周一早上 9 点
jobs:
security-report:
runs-on: ubuntu-latest
steps:
- name: Collect Vulnerabilities
run: |
# 从所有仓库收集高危漏洞
gh api repos/${{ github.repository }}/security-advisories \
--jq '.[] | select(.severity=="high")'
- name: Send Report
run: |
curl -X POST ${{ secrets.SLACK_WEBHOOK }} \
-d '{"text":"Weekly Security Report: ${{ steps.collect.outputs.vulns }} critical vulnerabilities"}'
```
**安全债务看板**:用 GitHub Issues 或 Jira 跟踪每个漏洞的修复进度,设置 SLA:
- Critical:24 小时内修复
- High:72 小时内修复
- Medium:7 天内修复
- Low:下一个迭代处理
## 扫描体系上线后的巡检与防退化纪律
安全扫描器本身也是要被持续验证的对象,我给自己定了几条纪律,防止它「配完就永远对」。
**每次升级扫描器都跑回归样例集。** 我维护了一组固定样例:故意植入已知漏洞的 bad 用例 + 一批已确认的误报 good 用例。扫描器升级或规则库更新后,先跑一遍这套回归,比对检出率和误报率有没有漂移。某次 Trivy 规则库大更新后误报率从 5% 跳到 20%,靠回归在上线前就拦住了,没有直接污染开发体验。没有这道回归,规则库一变,你的整个门禁标准就悄悄变了。
**漏洞库停更要报警。** 我给 Trivy/Semgrep 的漏洞库拉取做了监控,连续 48 小时没拉到新规则就告警。规则库静默停更意味着新出的高危 CVE 你的门禁根本防不住,而这往往是最危险的时刻(log4j 那种爆发式场景)。监控规则库活性比监控扫描器本身更能抓住「防线哑了」的盲区。
**安全债按 SLA 强制收敛,不是只记录。** 我固定每周拉一次全仓库高危漏洞清单,按 Critical 24h / High 72h 的 SLA 算逾期项。逾期超阈值的自动 @ 到对应负责人并在周报里点名。安全债最坏的状态不是有漏洞,而是「记了但没人认领、慢慢烂掉」。让看板带收敛趋势(本周新增 vs 本周关闭),数字在涨就是失血。
## FAQ
### Q: 安全扫描太慢拖慢 CI/CD 怎么办?
分层处理:
1. **快速扫描放 PR 阶段**(密钥检测、简单 SAST,<30 秒)
2. **完整扫描放 nightly build**(全量 SAST、DAST,10-30 分钟)
3. **增量扫描放构建阶段**(只扫描变更的文件)
我们的经验:把完整扫描放到 nightly build,PR 阶段只做快速扫描,整体 CI 时间从 15 分钟增加到 18 分钟(可接受)。
### Q: 开发者抱怨安全扫描阻断正常开发怎么办?
1. **明确告知阻断原因**:不要只说"Build Failed",要给出具体的漏洞信息和修复建议
2. **提供一键修复**:自动依赖升级、自动密钥替换
3. **建立安全沙箱**:允许开发者在本地跑扫描工具,提前发现问题
4. **定期培训**:让开发者理解"为什么这个漏洞危险",而不是"为什么我的 PR 被拒"
### Q: 安全扫描结果误报率高怎么处理?
```yaml
# 建立误报豁免机制
- name: Suppress False Positives
run: |
# 把已确认的误报加入 ignore 列表
echo '{"ignore":[{"id":"semgrep-123","reason":"已确认误报:这是测试代码"}]}' \
> .semgrepignore
```
关键原则:**不是消除误报,而是管理误报**。建立一个共享的误报知识库,让全团队都能看到哪些扫描结果是误报,避免重复判断。
---
## 相关文章
- [堡垒机选型与 JumpServer 部署](bastion-jumpserver.md)
- [GitOps 基础设施管理:ArgoCD/FluxCD 声明式部署实战与选型决策树](gitops-infra.md)
- [SSH 加固:密钥登录 + Fail2ban](ssh-hardening.md)
---
## 分布式追踪采样实战:从头采样到尾采样,把成本压下来又不丢异常
URL: https://ops.jiunotes.top/monitor/distributed-tracing-span-sampling.md
摘要: 分布式追踪采样怎么做才不丢关键信息又控住成本?本文讲清头采样与尾采样的取舍、概率采样/限流采样/自适应采样/父级采样策略,附 OpenTelemetry Collector tail_sampling 配置与常见坑。
### Answer
追踪采样分头采样(采集时定)和尾采样(看完全程再定)。高流量用尾采样保住异常与慢
链路、用概率+限流控总量;OTel Collector 的 tail_sampling 处理器按延迟/错误/频次决策,
配内存与等待窗口防丢。
### FAQ
Q: 头采样和尾采样到底差在哪,为什么大家都推尾采样?
A: 头采样在 span 一产生就决定是否保留,简单、零延迟、不占内存,但它"没看到结局就做了决定"——一条最终报错的链路可能在采样点被丢掉了。尾采样把 span 攒到请求结束(或更久),根据这条链路最终是否出错、是否够慢、是否罕见来做决定,能保住真正有价值的异常链路。代价是要缓冲一批 span、占用内存、引入决策延迟。所以高价值但高流量的系统多用尾采样,低流量或边缘采集点用头采样更划算。
Q: 尾采样要等多久才拍板?等太短会不会漏掉慢请求?
A: 关键参数是"等待窗口"(OTel 里是 `wait_for`)。它决定 Collector 收到第一个 span 后最多等多久才对整条链路做决策。窗口太短,跨服务的长链路还没到齐就被迫决策,慢请求会被误判为"普通"而丢掉;窗口太长,内存里堆的 span 越多、延迟越大。经验值从 2 秒起步,跨多服务或长事务的系统调到 5-10 秒,同时配合 `num_traces` 上限防止内存爆。这是个"内存 vs 保真度"的权衡,按你的链路长度实测调。
Q: 概率采样设多少合适?会不会把偶发的错误采样没了?
A: 纯概率采样(如 1%)对平稳流量很省,但偶发的错误链路有概率被整个丢掉,而这恰恰是最该看的。正确做法是在尾采样里叠规则:对"错误链路"和"超过阈值的高延迟链路"强制 100% 保留,对普通成功链路才走概率。这样整体采样率可能只有几个点,但异常链路几乎不丢。也可以按服务重要度分桶:核心支付链路高采样、健康检查类低采样。
Q: 父级采样(parent-based)是什么意思,什么时候用?
A: 父级采样是一种"跟随上游决定"的策略:如果上游已经决定采这条链路,下游服务就跟着采,保证一条链路要么整条都在、要么整条都不在,避免出现"父 span 不在、子 span 孤零零"的残缺链路。它常和头采样搭配:服务收到一个已被上游标记为采样的请求时,自己不再独立抛硬币。在微服务里基本是必开的,否则追踪图谱会碎片化。
Q: 采样之后,我怎么确认没把关键错误采丢了?
A: 三个办法:第一,在尾采样规则里把"错误/高延迟"设为强制保留并单独计数,监控这两条规则的命中率;第二,对极少数核心业务(如支付、登录)做 100% 采样或独立高采样通道,不依赖通用采样;第三,周期性抽几条已知故障做"能否在追踪系统里查到"的探针验证。别只信采样率数字,要看"关键链路的可查到率"。
### Full Content
# 分布式追踪采样实战:从头采样到尾采样,把成本压下来又不丢异常
分布式追踪一旦铺开,第一个让你肉疼的不是接入难度,而是成本。每来一个请求就生成几十条甚至上百条 span,全量上报到后端,存储和流量费用按月跳着涨。更要命的是,绝大多数 span 是健康请求的正常路径,你其实并不需要它们全部。采样就是回答一个问题:**在海量 span 里,留下哪些才既看得清系统、又不被账单压垮**。
但采样最怕两件事:一是把偶发的报错链路采没了,出事时追踪里查无此链;二是采得支离破碎,父 span 在、子 span 不在,图谱拼不起来。这篇文章把头采样、尾采样和各种采样策略讲透,并给你一套能直接落地的取舍原则。
## 1. 为什么必须采样,不能全量
先算笔账。一个中等流量服务,QPS 2000,每请求平均 30 条 span,一天就是约 52 亿条 span。全量存下来,光存储就要按 TB 计,查询也慢。而其中 95% 以上是正常的、重复的、没分析价值的路径。追踪的本质目的是"在异常时定位问题",不是"给每根健康请求建档"。
所以行业共识是:**对高流量系统,采样不是可选项,是必选项**。难点在于怎么采得"聪明"——把采样率压到个位数百分比,同时让错误、慢、异常的链路几乎不丢。
## 2. 头采样:在起点就决定
头采样(head-based sampling)在 span 被创建的那一刻就决定这条链路采不采,通常是抛个硬币(概率采样)或看固定规则。它的好处很直接:
- 零缓冲、零额外内存,决定立刻生效。
- 不引入决策延迟,对延迟敏感的服务友好。
- 实现简单,SDK 里一个 `sampler` 配置就完事。
坏处也明显:它"没看到结局"。一条最终 500 错误的链路,在采样点可能恰好被概率丢掉;一条跨十个服务的慢链路,头采样无法预知它后面会变慢。所以头采样适合流量低、或者只关心"有没有"而非"具体哪条错了"的场景。
## 3. 尾采样:看完全程再拍板
尾采样(tail-based sampling)反过来:先把一条链路相关的 span 收齐,等请求跑完(甚至等多久看配置),再根据"最终表现"决定保留与否。它可以基于这些信号决策:
- 这条链路最终报错了吗(HTTP 5xx、gRPC error、异常)?
- 耗时是否超过某阈值(P99 之外)?
- 这是不是罕见路径(低频接口、特定用户)?
- 是否触发了自定义业务规则(如金额大于某值)?
于是你能做到"错误和慢链路全留、正常链路按比例抽",在极低总采样率下保住最大价值。代价是要有一层组件把 span 缓冲起来、占用内存、引入一点点决策延迟。OpenTelemetry Collector 的 `tail_sampling` 处理器就是干这个的。
## 4. 常见采样策略拆解
单一策略不够用,实战里是组合拳:
- **概率采样**:每条链路按固定比例(如 1%)保留。简单、流量平稳时够用,但会随机丢掉偶发错误。
- **限流采样(rate-limited)**:每秒最多保留 N 条,超出丢弃。控制总量上限很有效,适合防突发。
- **自适应采样**:根据当前流量和错误率动态调采样率,错误率高时自动调高。需要额外实现或平台支持。
- **父级采样**:下游跟随上游的采样决定,保证链路完整性(见 FAQ)。
- **基于属性的采样**:按服务名、接口、用户标签等做不同采样率,核心业务高、旁路低。
生产环境通常是"尾采样 + 错误/慢强制保留 + 普通链路概率/限流"的组合。
## 5. OpenTelemetry Collector 尾采样配置
下面是 `tail_sampling` 处理器的骨架,逻辑是:先看是否错误、是否够慢,命中就 100% 保留;否则走概率采样把总量压住:
```
processors:
tail_sampling:
decision_wait: 10s # 等整条链路到齐的窗口
num_traces: 50000 # 内存里最多攒多少条链路
policies:
- name: errors
type: status_code
status_code:
status_codes: [ERROR]
- name: slow
type: latency
latency:
threshold_ms: 500
- name: probabilistic
type: probabilistic
probabilistic:
sampling_percentage: 5
```
注意顺序:前面的 policy 命中即保留,未命中的才落到概率策略。再配一个 `batch` 处理器和导出器即可。这套配置下,错误和慢链路几乎不丢,整体采样率只有约 5%,成本直接砍掉九成多。
## 6. 内存与等待窗口的权衡
尾采样的命门是内存。它在决策前要把 span 缓存在 Collector 里,所以两个参数要盯紧:
- `decision_wait`:等太久,内存堆得多、延迟大;等太短,长链路没到齐就被迫决策,慢请求被误丢。从 2 秒起,跨多服务调到 5-10 秒。
- `num_traces`:并发缓存的链路数上限,防内存爆。超了会触发淘汰,可能丢还在等待的链路。
经验法则:链路平均跨度越长、服务越多,`decision_wait` 越大;Collector 内存越小,`num_traces` 越要收。上线后监控 Collector 的内存和"被淘汰未决策链路数",这是尾采样最该看的指标。
## 7. 父级采样保证链路完整
微服务里如果每个服务独立抛硬币采样,会出现"父 span 没采、子 span 采了"的残缺链路,追踪图谱全是断点。父级采样让下游跟随上游决定:上游说采,下游就采;上游说不采,下游也不采。在 OTel 里默认开启,和头采样组合使用时尤其重要。别为了"更细粒度"把它关掉,否则你会得到一堆拼不起来的碎片。
## 8. 怎么验证采样没丢关键链路
采样率数字好看不等于没丢东西。建议三条验证线:
- 监控尾采样各 policy 的命中率,特别是 `errors` / `slow` 的命中,确认异常链路确实进了保留池。
- 对核心业务(支付、登录)走独立 100% 采样通道,不依赖通用采样,确保这部分永远可查。
- 周期性用已知故障做探针:故意触发一次错误,确认能在追踪后端查到完整链路。
把"关键链路可查到率"当成 SLO 来盯,比盯"总采样率"更有意义。
## 9. 上线后的巡检与防退化纪律
采样配置也会悄悄退化:一次 Collector 配置回滚可能让尾采样变头采样、一次内存超限可能让 `num_traces` 被调小丢链路。建立纪律:
- **每周**:看尾采样各 policy 命中率趋势,错误链路保留率应稳定接近 100%;若骤降,查是不是规则被改。
- **每周**:核对 Collector 内存与"淘汰未决策链路数",内存持续高位说明 `decision_wait`/`num_traces` 该调或该扩容。
- **每次变更前**:在测试环境用已知错误链路验证"采到率",再上生产;变更后立刻复核。
- **红线**:禁止"为省成本把错误链路也纳入概率采样"。错误和慢链路必须强制保留,这是采样配置不可动的底线。
- **每次扩服务**:新接入的服务默认继承统一采样策略,别让某个服务"裸奔"全量上报拉高成本。
## FAQ
头采样和尾采样到底差在哪,为什么大家都推尾采样?
头采样在 span 一产生就决定是否保留,简单、零延迟、不占内存,但它"没看到结局就做了决定"——一条最终报错的链路可能在采样点被丢掉了。尾采样把 span 攒到请求结束(或更久),根据这条链路最终是否出错、是否够慢、是否罕见来做决定,能保住真正有价值的异常链路。代价是要缓冲一批 span、占用内存、引入决策延迟。所以高价值但高流量的系统多用尾采样,低流量或边缘采集点用头采样更划算。
尾采样要等多久才拍板?等太短会不会漏掉慢请求?
关键参数是"等待窗口"(OTel 里是 `wait_for`)。它决定 Collector 收到第一个 span 后最多等多久才对整条链路做决策。窗口太短,跨服务的长链路还没到齐就被迫决策,慢请求会被误判为"普通"而丢掉;窗口太长,内存里堆的 span 越多、延迟越大。经验值从 2 秒起步,跨多服务或长事务的系统调到 5-10 秒,同时配合 `num_traces` 上限防止内存爆。这是个"内存 vs 保真度"的权衡,按你的链路长度实测调。
概率采样设多少合适?会不会把偶发的错误采样没了?
纯概率采样(如 1%)对平稳流量很省,但偶发的错误链路有概率被整个丢掉,而这恰恰是最该看的。正确做法是在尾采样里叠规则:对"错误链路"和"超过阈值的高延迟链路"强制 100% 保留,对普通成功链路才走概率。这样整体采样率可能只有几个点,但异常链路几乎不丢。也可以按服务重要度分桶:核心支付链路高采样、健康检查类低采样。
父级采样(parent-based)是什么意思,什么时候用?
父级采样是一种"跟随上游决定"的策略:如果上游已经决定采这条链路,下游服务就跟着采,保证一条链路要么整条都在、要么整条都不在,避免出现"父 span 不在、子 span 孤零零"的残缺链路。它常和头采样搭配:服务收到一个已被上游标记为采样的请求时,自己不再独立抛硬币。在微服务里基本是必开的,否则追踪图谱会碎片化。
采样之后,我怎么确认没把关键错误采丢了?
三个办法:第一,在尾采样规则里把"错误/高延迟"设为强制保留并单独计数,监控这两条规则的命中率;第二,对极少数核心业务(如支付、登录)做 100% 采样或独立高采样通道,不依赖通用采样;第三,周期性抽几条已知故障做"能否在追踪系统里查到"的探针验证。别只信采样率数字,要看"关键链路的可查到率"。
## 相关文章
- [OpenTelemetry 实战指南:从 SDK 到 Collector](opentelemetry-guide.md)
- [持续性能剖析:把火焰图接进可观测体系](continuous-profiling.md)
- [eBPF 可观测性:内核级采集不改造应用](ebpf-observability.md)
---
## eBPF 可观测性原理:无侵入监控实战,从 kernel trace 到生产落地
URL: https://ops.jiunotes.top/monitor/ebpf-observability.md
摘要: eBPF 原理与实战:kprobe/uprobe/ftrace 三种 attach 方式、常见性能影响、生产部署踩坑(内核版本限制、权限、BTF 缺失)、以及三个真实案例复盘。
### Answer
eBPF 是在 Linux 内核里跑用户态程序的技术,通过 kprobe/uprobe/ftrace 三种 attach 方式采集指标,零侵入、低开销。
核心工具链:BCC(Python 脚本)、bpftrace(一命令一行)、Cilium/Telegraf(生产级)。
关键限制:内核 4.9+(现代发行版都满足)、需要 BTF 元数据(5.4+)、CAP_SYS_ADMIN 权限(容器里用 hostPID+privileged 或 seccomp unconfined)。
最大坑:误以为 eBPF 零开销——高频 tracepoint 会烧 CPU,要配合 sampling 使用。
### FAQ
Q: eBPF 和传统的 strace/ltrace 有什么区别?
A: strace 是系统调用级别的 trace,性能开销极大,不适合生产。eBPF 在内核里直接运行用户态编写的小程序,开销比 strace 低 1-2 个数量级,而且可以持久运行、实时上报指标,不是事后回放。
Q: eBPF 需要什么权限?容器里能用吗?
A: 需要 CAP_SYS_ADMIN 加载 eBPF 程序。普通容器默认没有这个权限。解决方案:① 用 privileged 容器(安全风险高);② 配置 seccomp unconfined + SYS_ADMIN 能力;③ 用 Cilium 等工具做白名单加载。生产建议直接跑在节点上,不走容器。
Q: eBPF 程序写起来很复杂吗?有没有现成工具?
A: 写 C 再编译最灵活但门槛高。日常用 BCC 的 Python 脚本(如 tcplife、execsnoop)或 bpftrace 一行命令就够了。生产场景推荐 Telegraf with eBPF plugin 或 Cilium Hubble,开箱即用。
Q: kprobe 和 tracepoint 选哪个?哪个更稳定?
A: 我第一次用 kprobe 挂到 `do_mkdir` 上,结果内核升级到 5.15 后函数名改成 `vfs_mkdir`,脚本直接报错。 tracepoint 是内核预先定义好的钩子,接口不会因为内部实现改动而变,稳定性好得多。 原则:能用 tracepoint 就优先用,只有 tracepoint 覆盖不到的场景(比如跟踪特定内核函数的参数)才用 kprobe。 查可用 tracepoint 用 `sudo bpftrace -l 'tracepoint:*'`,按类别过滤用 `tracepoint:syscalls:*`。
Q: 生产环境 eBPF 监控要不要做采样?怎么控制开销?
A: 要。我第一次在压测环境跑 `bpftrace -e 'tracepoint:syscalls:sys_enter_openat { printf("%s\n", args->filename); }'`, CPU 直接飙到 40%,因为每个 open 系统调用都打了日志。 正确姿势是聚合而不是打印:`@[comm] = count();` 用 map 累加,定时 `print(@)` 输出。 或者用 LRU map 限制条目数,避免 map 溢出导致 verifier 拒绝加载。 生产部署建议:只采集关键 tracepoint(网络、磁盘 I/O),系统调用级别的高频事件默认关掉或大幅降采样。
### Full Content
# eBPF 可观测性原理:无侵入监控实战,从 kernel trace 到生产落地
去年给公司线上 k8s 集群加可观测性时,遇到了一个诡异问题:**Java 服务偶发性 GC 停顿超过 500ms,[Prometheus](https://prometheus.io/docs/) 抓不到,[Grafana](https://grafana.com/docs/) 看不到根因**。最后用 eBPF 的 `funcslower` 抓到了——是某个 JNI 调用阻塞了 JVM 线程,但 Prometheus 的 GC metric 根本覆盖不到这个场景。
这个问题如果用传统方式排查,至少要装 APM、改代码、重启服务。eBPF 的好处是**零侵入**——不需要改代码、不需要重启、不需要插桩,直接在内核层面看发生了什么。
## eBPF 是什么:一句话解释清楚
eBPF(extended Berkeley Packet Filter)原本是用来过滤网络包的内核功能。后来被扩展成一个可以在内核里安全执行用户态编写的小程序的框架。
**核心思想**:在 [Linux](https://www.kernel.org/doc/html/latest/) 内核里跑"安全沙箱",允许用户态程序在内核上下文里执行,但经过验证器(verifier)检查,确保不会崩溃内核或访问非法内存。
```
用户态程序(BPF 字节码)
↓ ebpf verifier 检查安全
内核态执行(挂载到钩子点)
↓ 产出数据
ring buffer / perf event / map
↓ 用户态读取
监控工具(BCC / bpftrace / 自研 agent)
```
**关键点**:verifier 是核心——它保证你的 eBPF 程序不会无限循环、不会访问内核未授权内存、不会导致内核崩溃。如果 verifier 过不了,程序根本加载不进去。
## 三种 Attach 方式:kprobe / uprobe / tracepoint
这是 eBPF 监控的三大入口,选对类型决定你能看到什么。
### kprobe:内核函数级别
挂在内核函数入口或出口,能看到任何内核函数的调用。
```bash
# bpftrace 一行命令:跟踪 mkdir 系统调用的延迟分布
bpftrace -e 'kprobe:do_mkdir { @start[tid] = nsecs; }
kretprobe:do_mkdir /@start[tid]/ {
@latency = lshift(nsecs - @start[tid], 0);
delete(@start[tid]);
}'
# 输出示例
# @latency:
# [1000, 2000) 3
# [2000, 4000) 15
# [4000, 8000) 8
```
**kprobe 的限制**:kernel symbol 可能被 kabi 改名,跨内核版本兼容性差。比如 `do_mkdir` 在 5.15+ 变成了 ` vfs_mkdir`,脚本要跟着改。
### uprobe:用户空间函数级别
挂在用户态进程的函数上,能看到应用程序内部行为。
```bash
# 挂到 Java 进程的某个函数(需要 PID)
bpftrace -e 'uprobe:/usr/bin/java:_ZNK6google7protobuf7Message10InitializationErrorStringEv {
printf("Java function called by pid %d\n", pid);
}'
# 跟踪 [Python](https://docs.python.org/) 函数的执行时间
bpftrace -e 'uprobe:/usr/bin/python3.11:main { @start[pid] = nsecs; }
uretprobe:/usr/bin/python3.11:main /@start[pid]/ {
printf("python3 pid %d took %lld ns\n", pid, nsecs - @start[pid]);
delete(@start[pid]);
}'
```
**uprobe 的限制**:需要知道目标二进制路径,且函数符号可能 stripped。生产环境常用 `readelf` 或 `nm` 先确认符号名。
### tracepoint:预定义内核事件
tracepoint 是内核预先定义好的钩子,比 kprobe 稳定(不会因为内部实现改变而失效)。
```bash
# 列出所有网络相关的 tracepoint
sudo bpftrace -l 'tracepoint:*net*' | head -20
# 跟踪 TCP 连接建立
sudo bpftrace -e 'tracepoint:syscalls:sys_exit_connect {
@estab[tid] = args->err;
}'
```
**决策树**:
- 想看系统调用 → `tracepoint:syscalls` 优先,不行再 kprobe
- 想看内核函数 → kprobe
- 想看用户态函数 → uprobe
- 不确定用哪种 → 先用 `bpftrace -l` 列出可用的 tracepoint,再决定
## 生产级工具链:BCC / bpftrace / Telegraf / Cilium
### BCC:Python 脚本集合
BCC 提供几十种现成的监控脚本,是入门 eBPF 最快的路径。
```bash
# 安装 BCC(Ubuntu 22.04)
sudo apt install bpfcc-tools linux-headers-$(uname -r)
# 一键查看所有可用脚本
bcu list | head -30
# 经典脚本:tcplife - 展示 TCP 连接的生存时间和字节数
sudo tcplife -l
# 经典脚本:execsnoop - 跟踪新进程启动
sudo execsnoop -T
# 经典脚本:biosnoop - 块设备 I/O 延迟
sudo biosnoop -T
```
**个人判断**:BCC 脚本适合快速排查问题,但生产监控不建议直接用 BCC 脚本——它们是调试工具,不是监控系统。生产环境应该用 Telegraf 或自建 agent。
### bpftrace:一行命令的瑞士军刀
bpftrace 语法类似 awk + C,学习曲线比 BCC 脚本陡,但灵活性高得多。
```bash
# 安装
sudo apt install bpftrace
# 监控 open 系统调用的文件路径分布
sudo bpftrace -e 'tracepoint:syscalls:sys_exit_openat {
printf("%s %s\n", comm, str(args->filename));
}'
# 监控 TCP 重传(网络问题的神器)
sudo bpftrace -e 'kprobe:tcp_retransmit_skb {
@retrans[key(arg0, arg0->sk)] = count();
}'
```
**实操经验**:`bpftrace -e 'hist(args->size)'` 可以画出 open 系统调用参数大小的分布——这是我发现一个大文件拷贝导致磁盘 I/O 尖峰的快捷方式。
### Telegraf with eBPF:生产级方案
Telegraf 有官方 eBPF plugin,支持开箱即用的系统指标采集。
```toml
# telegraf.conf
[[outputs.prometheus_client]]
listen = ":9273"
[[inputs.ebpf]]
programs = [
{ name = "syscall", paths = ["tracepoint:syscalls:sys_exit_read", "tracepoint:syscalls:sys_exit_write"] },
{ name = "network", paths = ["tracepoint:net:net_dev_rx"] },
]
```
```bash
telegraf --config telegraf.conf --test
```
**我的建议**:Telegraf eBPF plugin 适合基础指标(system call 计数、网络统计),但复杂场景(如追踪特定应用的调用链)还是 bpftrace 更灵活。
### Cilium Hubble:[K8s](https://kubernetes.io/docs/) 网络可观测性的天花板
Cilium 自带 Hubble,用 eBPF 做 K8s 网络层的完全可观测性——流量矩阵、DNS 查询、连接状态,都不需要改业务代码。
```bash
# 查看 Hubble 拓扑
hubble observe --follow
# 查看 DNS 查询
hubble observe --type dns
# 查看连接状态变化
hubble events --type connection
```
**Hubble 的核心价值**:在 K8s 里看东西南北向流量,不需要 service mesh,不需要 sidecar,eBPF 直接搞定。如果你的集群用了 Cilium,这是必装组件。
## 性能影响:eBPF 不是零开销
很多人误以为 eBPF 零开销,这是个误区。**高频 tracepoint 会烧 CPU**,必须配合采样策略。
### 三个真实踩坑案例
**案例一:采样率设太高,CPU 飙到 40%**
```bash
# ❌ 错误:每个系统调用都打印,CPU 直接爆
sudo bpftrace -e 'tracepoint:syscalls:sys_enter_openat {
printf("%s open %s\n", comm, str(args->filename));
}'
# ✅ 正确:每 100 次采样一次
sudo bpftrace -e 'tracepoint:syscalls:sys_enter_openat {
@[comm] = count();
if (@[comm] % 100 == 0) {
printf("%s open %s\n", comm, str(args->filename));
}
}'
```
**教训**:生产环境不要直接跑 `bpftrace -e 'print(...)'`,一定要配采样或聚合。
**案例二:map 溢出导致程序被 verifier 拒绝**
```bash
# ❌ 错误:map 大小设太小,频繁报错
bpftrace -e 'kprobe:do_sys_open {
@a[tid] = args->dfd; # 每个 tid 一条,map 爆了
}'
# ✅ 正确:用 LRU map 限制条目数
bpftrace -e 'kprobe:do_sys_open {
@a[lru] = args->dfd; # 自动淘汰最旧条目
}'
```
**教训**:eBPF map 有大小限制(通常 4MB),高频场景要用 LRU hash 或 percpu array。
**案例三:BTF 缺失导致 kfunc 加载失败**
```
# 报错信息
Error: failed to apply BTF map: Invalid argument
Error: no BTF found for kernel
```
```bash
# 检查内核是否支持 BTF
ls /sys/kernel/btf/vmlinux && echo "BTF OK" || echo "BTF missing"
# 修复(Ubuntu 22.04 通常已有)
sudo apt install linux-modules-extra-$(uname -r)
sudo modprobe btf
```
**教训**:某些定制内核(如云厂商的优化内核)会去掉 BTF 支持,此时只能用 tracepoint,不能用 kprobe/kfunc。生产前务必先检查。
## 权限与部署:容器里怎么用 eBPF
eBPF 加载需要 `CAP_SYS_ADMIN`,这在容器里默认没有。三种方案:
```yaml
# 方案一:privileged 容器(最简单但最危险)
securityContext:
privileged: true
# 方案二:只加需要的能力(推荐)
securityContext:
capabilities:
add: ["SYS_ADMIN", "BPF", "PERFMON"]
drop: ["ALL"]
seccompProfile:
type: Unconfined
# 方案三:hostNetwork + hostPID(Cilium Hubble 推荐)
hostNetwork: true
hostPID: true
```
**我的判断**:能跑在节点上就别放容器里。eBPF 的核心优势是"看到容器看不到的东西",如果把 eBPF 本身关在容器里,反而丧失了最大价值。
## FAQ
### Q:eBPF 能监控什么?有什么限制?
能监控的范围非常广:系统调用、网络包、函数调用、磁盘 I/O、内存分配、信号、定时器……几乎内核做的任何事。
**主要限制**:
- 内核版本 ≥ 4.9(XDP 等高级功能需要 4.18+)
- 需要 BTF(5.4+ 默认开启,旧内核需要手动安装)
- 不能监控已经被内核移除的功能(如某些旧的 tracepoint)
- XDP 等高级功能需要网卡驱动支持
### Q:eBPF 和 APM 工具(如 Jaeger、SkyWalking)有什么区别?
APM 工具需要在代码里插桩(SDK、agent),要改代码或至少部署 sidecar。eBPF 完全零侵入,内核层面就能看到所有进程的系统调用和网络行为。
**适用场景**:APM 适合追踪业务逻辑链路(请求从哪个入口来、走了哪些服务);eBPF 适合系统层诊断(为什么慢、哪里卡了、资源瓶颈在哪)。两者互补,不冲突。
### Q:eBPF 程序出问题了会搞挂内核吗?
不会。eBPF verifier 在加载前会静态分析程序,确保:
- 程序会在有限步数内终止(无无限循环)
- 不会访问未授权内存
- 不会修改不属于自己的数据结构
- map 操作在边界内
如果 verifier 过不了,程序根本加载不进去。历史上出现过极少数 verifier bypass 漏洞(CVE-2021-3490 等),但都有补丁修复。生产环境保持内核更新即可。
---
## 相关文章
- [Linux 监控 CPU:top 一行 5 个百分比字段排障决策树](linux-cpu-monitor.md)
- [Linux 内存监控实战与 OOM 排障:free/vmstat/ps 字段解读 + cgroup v2 内存限制决策树](linux-memory-monitor.md)
- [node_exporter 指标缺失排查:6 大症状决策树 + 4 个常见翻车原因](node-exporter-troubleshoot.md)
---
## FinOps 可观测性成本优化:智能采样与分层存储实战指南
URL: https://ops.jiunotes.top/monitor/finops-monitoring.md
摘要: FinOps 可观测性落地:智能采样降成本、冷热分层存储、高基数指标治理、成本归因到团队/服务,附真实数据对比和采坑复盘。
### Answer
FinOps 可观测性的核心是"花得值"——通过智能采样把高频低价值指标降采样,通过冷热分层让热数据快查、冷数据便宜存,通过标签治理减少无效 cardinality。
最大成本杀手不是查询而是存储:高基数指标和全量日志是最烧钱的两类数据。
实战经验:引入智能采样后监控成本平均下降 60-80%,但要把采样率和告警灵敏度之间的 tradeoff 讲清楚。
### FAQ
Q: 智能采样会影响告警灵敏度吗?
A: 会影响,但可以接受。关键原则:告警相关的指标不降采样,只有非告警类指标(如调试用的高频 metric)才降采样。实际测试中,把 scrape_interval 从 15s 调到 60s 对 CPU/内存类指标影响可忽略,但对瞬态错误(如偶尔的 5xx)可能漏报——这类指标保持 15s。
Q: 热数据多久算热?分层阈值怎么定?
A: 没有标准答案,根据查询模式定。我们的经验:7 天内是热数据(每天查询),7-30 天是温数据(周报/月报),30 天以上是冷数据(合规审计)。热数据用 SSD 本地存储,温数据用 HDD 对象存储,冷数据用 S3/GCS 归档。
Q: FinOps 和传统成本优化有什么区别?
A: 传统成本优化是"砍预算",FinOps 是"优化每一块钱的投入产出比"。前者关注"花了多少",后者关注"花得值不值"。比如砍掉某个指标的采集是传统做法,评估这个指标带来的业务价值再决定要不要保留是 FinOps 做法。
Q: 采样率和保留期调整后怎么确认没有悄悄漏报告警?
A: 调整前后要跑一次「告警回放」:拿过去 7 天真实数据,用新策略重新评估所有告警规则,比对触发次数和触发时间有没有漂移。重点核对 P0 告警的触发时间——如果降采样导致某条 P0 告警晚了 30 秒触发,就要把该指标单独锁回 15s。降采样不是无脑降,每降一档都要回测告警灵敏度。
### Full Content
# FinOps 可观测性成本优化:智能采样与分层存储实战指南
去年我们监控栈的成本达到了月均 $8,000(主要是 [Prometheus](https://prometheus.io/docs/) 集群的存储 + Thanos 的对象存储),CFO 找我谈了一次话后,我开始认真做 FinOps 了。六个月内成本降到了 $1,800/月,降幅 77%,而且告警准确率没有下降。
这篇分享整个过程和关键决策,所有内容来自我们在 Prometheus + Thanos + VictoriaMetrics 栈上的真实部署经验。
## 成本拆解:钱花在哪了
```
月度成本分析(改造前)
─────────────────────────────────────────────
Prometheus TSDB 存储(热数据 30 天) $3,200
Thanos Query / Sidecar 运维 $1,800
S3 对象存储(冷数据归档) $1,500
外部监控服务(Datadog 免费版升级) $1,200
─────────────────────────────────────────────
合计 $7,700/月
```
**最大成本项不是查询**,而是**存储**。每增加一个活跃的时间序列,Prometheus 大约消耗 200MB 基线内存,加上磁盘存储。100 万活跃 series × 30 天 × ~$0.03/GB/月 ≈ 可观的数字。
## 第一刀:智能采样——降采样比例最高 80%
### 指标分级
不是所有指标都值得 15 秒采样一次。我们把指标分成四级:
| 级别 | 采样间隔 | 典型指标 | 理由 |
|---|---|---|---|
| **P0**(必保) | 15s | `up`、告警相关 metric、核心业务指标 | 告警依赖,不能丢 |
| **P1**(重要) | 30s | CPU、内存、磁盘、网络 | 趋势分析够用 |
| **P2**(一般) | 60s | HTTP 请求计数、错误计数 | 降采样后趋势仍清晰 |
| **P3**(可选) | 5min | 调试用的高频 metric、debug 指标 | 不影响核心告警 |
### 实现方式
**Prometheus 原生方式**(按 job 粒度控制):
```yaml
scrape_configs:
- job_name: 'critical'
scrape_interval: 15s
static_configs:
- targets: ['api-server:8080']
- job_name: 'routine'
scrape_interval: 30s
static_configs:
- targets: ['web-server:8080']
- job_name: 'debug'
scrape_interval: 5m
metrics_path: '/debug/metrics'
static_configs:
- targets: ['internal-tools:9090']
```
**VictoriaMetrics 方式**(支持更细粒度的 retention):
```yaml
# VictoriaMetrics 的 vmagent 配置
global:
scrapeInterval: 15s
remoteWriteUrls:
- http://vminsert:8480/insert/prometheus/api/v1/write
scrape_configs:
- job_name: 'critical'
metrics_path: /metrics
scrape_interval: 15s
- job_name: 'routine'
metrics_path: /metrics
scrape_interval: 30s
# VictoriaMetrics 独有的 retention_override
# 热数据保留 7 天,温数据 30 天,冷数据 1 年
_scrape_interval: 30s
```
**踩坑**:降采样后 `rate()` 计算窗口要对齐。如果 scrape_interval 是 30s,rate 窗口至少要是 4×30s=2min,否则会漏采样点导致 rate 计算不准确。
### 效果数据
改造后指标数量从 120 万降到 35 万,存储成本从 $4,700 降到 $1,400/月,降幅 **70%**。告警漏报率从 0.02% 升到 0.05%——还在可接受范围内。
## 第二刀:分层存储——冷热数据各归各位
### 分层策略
```
时间轴
─────────────────────────────────────────────────────────▶
0d 7d 30d 365d
│ │ │ │
▼ ▼ ▼ ▼
┌──────────┬────────────┬────────────────────┐
│ 热数据 │ 温数据 │ 冷数据 │
│ (SSD本地) │ (HDD本地) │ (S3/GCS归档) │
│ <7天 │ 7-30天 │ >30天 │
└──────────┴────────────┴────────────────────┘
```
**实现**:Thanos compact + object storage,或 VictoriaMetrics 的 retention 策略。
```yaml
# VictoriaMetrics 单节点方案(最简单)
vmstorage:
retentionPeriod: 1y # 总保留期
# 通过 query 参数控制查询范围
# 热查询:/api/v1/query?query=up&start=-7d
# 温查询:/api/v1/query?query=up&start=-30d
# 冷查询:/api/v1/query?query=up&start=-1y
```
```yaml
# Thanos 方案(更复杂但功能更强)
# Thanos compact 自动把 Prometheus TSDB 块压缩并推到 S3
compactor:
data-dir: /var/thanos/compact
retentions:
- 7d # 热数据(Prometheus 本地保留)
- 30d # 温数据(S3 保留)
- 1y # 冷数据(S3 归档)
```
**关键选择**:VictoriaMetrics 方案更简单(单二进制),Thanos 方案更灵活(兼容现有 Prometheus 生态)。我们选了 VictoriaMetrics,因为团队规模小、维护成本优先。
### 成本对比
| 方案 | 热数据成本 | 冷数据成本 | 总成本/月 | 复杂度 |
|---|---|---|---|---|
| 全 Prometheus 本地 | $4,700 | — | $4,700 | 低 |
| Thanos + S3 | $2,800 | $600 | $3,400 | 中 |
| VictoriaMetrics | $1,200 | $300 | $1,500 | 低 |
## 第三刀:高基数治理——清理无效 cardinality
### 诊断高基数指标
```promql
# 统计每个 __name__ 的 series 数量
topk(20, count by (__name__) ({__name__=~".+"}))
# 找出高基数标签
topk(10, count by (__name__, some_label) ({__name__=~".+"}))
```
### 常见的高基数来源
```yaml
# ❌ 危险:request_id 是高基数标签(每个请求唯一)
metric:
name: http_requests_total
labels:
- request_id # 几百万 unique value → 几百万 series
# ✅ 正确:去掉高基数标签,改用聚合
metric:
name: http_requests_total
labels:
- service_name # 几十个 value → 几十条 series
- endpoint # 几百个 value → 几百条 series
```
**实操经验**:我们清理了三个高基数指标后,活跃 series 从 120 万降到 65 万,直接省了 $1,500/月存储成本。
### recording rule 预聚合
对于需要高粒度但又不能丢的指标,用 recording rule 在采集端就聚合:
```yaml
# prometheus.yml
rule_files:
- aggregation-rules.yml
# aggregation-rules.yml
groups:
- name: http-aggregation
rules:
- record: job:http_requests:rate5m
expr: sum by (job, status) (rate(http_requests_total[5m]))
# 原始指标 http_requests_total{user_id} 被丢弃
# 只保留聚合后的 job:http_requests:rate5m
```
## 成本归因:谁在用监控资源
```yaml
# VictoriaMetrics 支持标签级别的成本归因
# 在 VM 仪表盘中加一个 cost 面板:
# sum by (team) (rate(vm_rows{team=~".+"}[1h]))
# × $0.03 / row / month
```
**我们的实践**:给每个服务打上 `team` 和 `env` 标签,然后在 [Grafana](https://grafana.com/docs/) 里做一个"成本透视"面板——每个团队能看到自己服务消耗了多少监控资源。这个做法让 teams 开始主动优化自己的指标,成本意识明显提升。
## 常见误区
**误区一:"少采集点指标就能省钱"**
错。省钱的关键是**减少 cardinality** 而不是减少指标数。1000 个指标 × 10 个 series = 1 万 series,和 10 个指标 × 1000 个 series 的成本一样。把标签精简到最小必要集合才是省钱之道。
**误区二:"对象存储就是便宜的"**
S3/GCS 确实比 SSD 便宜,但读取冷数据时的延迟和高昂的 GET 请求费用可能超出预期。我们的经验:**查询频率 <1 次/周的指标才值得归档到对象存储**,否则直接延长本地保留期更划算。
**误区三:"FinOps 是一次性项目"**
不是。监控成本会随业务增长自然上升,需要**季度审查**指标卡、采样策略和保留策略。我们每季度做一次"指标审计"——问每个 team "这个指标还在用吗?"
## 降本上线后的巡检与防反弹纪律
成本降下来不是终点,我给自己定了几条长期纪律,防止它随业务增长悄悄反弹回去。
**每季度做一次「指标审计 + 告警回放」。** 降本最大的隐患是「降完当掉链子没人管」,某次新服务接入后活跃 series 悄悄涨了 40%。我固定每季度跑两件事:一是拉全量 `count by (__name__)` 排一遍新晋高基数指标,清掉没人用的;二是用新采样策略对过去 7 天真实数据做告警回放,比对新旧 P0 告警触发次数。回放是降本的护栏——只要发现某条告警因为降采样晚触发或漏触发,就把那个指标单独锁回 15s,别为了省钱牺牲告警灵敏度。
**给存储水位设提前告警,别等爆。** 我把热/温/冷各层的存储用量都打点,在 80% 水位就告警(不是 95% 才响)。冷层对象存储的 GET 请求费用容易被忽视,我专门监控「冷数据被频繁查询」的指标,某服务如果查冷数据很频繁,说明它的数据其实该升回温层,硬留在冷层反而因高频 GET 更贵。降本要盯着「哪类数据被误放在贵层」,不是只看总账单。
**成本归因面板要落到团队,才有持续动力。** 我把按 team 维度的成本面板做进 Grafana 并配月环比,每个 team 能看到自己这月花了多少、比上月涨还是跌。纯靠运维单方省,撑不过两三个季度——业务一扩张又涨回去。让花钱的团队看到账单并负责优化,降本才能变成常态而不是项目。
## FAQ
### Q: 降采样后还能做实时告警吗?
可以,但需要分层设计。P0 级别指标保持 15s 采样,用于告警;P1-P3 降采样后只用于 Dashboard 和历史查询,不参与告警规则。告警规则和采集策略要严格分离——不要把同一个指标既用于告警又用于降采样。
### Q: 历史数据查询变慢怎么办?
分层存储后,查 30 天前的数据需要从对象存储读取,延迟从毫秒级变成秒级甚至分钟级。解决方案:
1. 用 `start` 参数限制查询范围,避免全表扫描
2. 对热点历史数据用 Thanos query-frontend 缓存
3. 查询冷数据时接受秒级延迟,不要期望即时响应
### Q: 哪些指标绝对不能降采样?
- `up`(目标存活状态)—— 15s 是底线,更低也没有意义
- 告警规则依赖的指标—— 降采样会导致告警延迟或漏报
- 计费相关的指标—— 精确计数很重要
- 短生命周期事件(如函数执行)—— 降采样后采样不到
**判断标准**:问自己"如果这个指标丢了 10 秒数据,会发生什么严重问题?"如果答案是"会",就不降采样。
---
## 相关文章
- [Prometheus TSDB 深度运维:WAL/Head/Block 结构与 5 个真实故障](prometheus-tsdb-operations.md)
- [Zabbix + Prometheus 混合监控架构:传统基础设施 + 云原生服务的双栈方案](zabbix-prometheus-hybrid.md)
- [Alertmanager 告警降噪实战:抑制器 + 路由树 + 静默配置四步降噪法](alertmanager-noise-reduction.md)
---
## GitOps 基础设施管理:ArgoCD/FluxCD 声明式部署实战与选型决策树
URL: https://ops.jiunotes.top/monitor/gitops-infra.md
摘要: GitOps 核心原理、ArgoCD vs FluxCD 选型对比、多集群管理、Rollback 策略、以及应用级回滚 vs 基础设施级回滚的实战经验。
### Answer
GitOps 的核心是"代码即基础设施"——所有资源定义在 Git 仓库,ArgoCD/FluxCD 等 controller 持续同步集群状态到 Git 描述的状态。
ArgoCD 适合 UI 依赖强、多环境管理的场景;FluxCD 适合 CLI 优先、CI/CD 深度集成的团队。
关键陷阱:直接 `kubectl apply` 绕过 Git 会导致漂移,GitOps 工具检测到 drift 后会自动修复,但可能覆盖掉你的手动变更。
### FAQ
Q: ArgoCD 和 FluxCD 选哪个?
A: 没有绝对答案。ArgoCD 可视化强、UI 体验好,适合运维主导的团队;FluxCD 轻量、Git native、与 GitHub Actions 集成更顺滑,适合开发主导的团队。我们的判断:小团队 (<50 人) 用 FluxCD,大团队或多集群用 ArgoCD。
Q: GitOps 和传统 CI/CD 有什么区别?
A: 传统 CI/CD 是 push 模型——构建后主动推送到集群;GitOps 是 pull 模型——集群上的 controller 主动拉取 Git 状态并同步。GitOps 的好处是声明式、可审计、可回滚——所有变更都在 Git 里有记录。
Q: 直接 kubectl 改集群会破坏 GitOps 吗?
A: 会。ArgoCD/FluxCD 检测到 drift 后会强制将集群状态重置为 Git 中的期望状态,你刚才的改动就丢了。正确做法:所有变更都走 Git PR,经过 review 后再 merge。这是 GitOps 的文化约束,不是技术限制。
Q: GitOps 上线之后怎么持续巡检和防漂移?
A: 盯三条。一,定期跑一次「集群实际状态 vs Git 期望状态」的漂移扫描(ArgoCD 用 argocd app get --show-diff、FluxCD 用 flux reconcile),非零漂移必须有人认领原因。二,把 controller 自身的健康(reconcile 成功率、git pull 时延、controller Pod 重启次数)挂进监控,controller 自己挂了 GitOps 会静默失效。三,prune 误删前强制 dry-run 预览删除列表,生产环境配好保护性 ignoreDifferences。
### Full Content
# GitOps 基础设施管理:ArgoCD/FluxCD 声明式部署实战与选型决策树
2023 年我们团队从 Jenkins pipeline 切换到 ArgoCD,过程不算痛苦,但有几个踩坑点值得记下来——尤其是要处理好"手动变更"和"GitOps 流程"之间的摩擦。
## GitOps 核心概念:一句话讲清楚
GitOps = Git 作为单一事实源 + 自动同步机制。
```
Git 仓库(source of truth)
↓ 开发者 push manifest
Controller(ArgoCD/FluxCD)
↓ 持续 reconcile
[Kubernetes](https://kubernetes.io/docs/) 集群(desired state → actual state)
```
**三个关键原则**:
1. **所有变更走 Git**——禁止直接 kubectl 操作生产集群
2. **自动同步**——Controller 持续比对并修复 drift
3. **版本可追溯**——每次变更都有 commit hash,可回滚到任意历史版本
## ArgoCD vs FluxCD:选型决策树
| 维度 | ArgoCD | FluxCD |
|---|---|---|
| 架构 | Go 编写,单二进制 | Go 编写,模块化 |
| 界面 | **Web UI(强项)** | CLI 为主,Web UI 较弱 |
| 同步模式 | pull(controller 主动拉) | pull(同 ArgoCD) |
| 多集群 | 原生支持,Web UI 可视化 | 支持,需配置多个 source |
| 与 GitHub 集成 | 通过 Webhook | **原生支持 GitHub App** |
| 学习曲线 | 中(概念多但 UI 友好) | 低(简单直接) |
| 资源占用 | 较高(UI + 数据库) | 较低(无状态) |
| 适合团队 | 大团队、多环境、UI 偏好 | 小团队、CLI 偏好、GitHub 深度用户 |
**选型建议**:
- 团队有专职运维 → **ArgoCD**(UI 降低运维负担)
- 团队是纯开发导向、GitHub 重度用户 → **FluxCD**(CI/CD 集成更顺滑)
- 同时管 K8s + [Terraform](https://developer.hashicorp.com/terraform/docs) → **ArgoCD**(支持跨平台资源)
- 最小化运维成本 → **FluxCD**(轻量、少依赖)
## ArgoCD 最小可用配置
```yaml
# app-of-apps 模式:用一个 Application 管理所有子应用
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: platform-apps
namespace: argocd
spec:
project: default
source:
repoURL: https://github.com/myorg/platform-infra.git
targetRevision: main
path: apps
destination:
server: https://kubernetes.default.svc
namespace: argocd
syncPolicy:
automated:
prune: true # 删除 Git 里不存在的资源
selfHeal: true # 自动修复 drift
syncOptions:
- CreateNamespace=true
```
```yaml
# apps/monitoring.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: monitoring
namespace: argocd
spec:
project: default
source:
repoURL: https://github.com/myorg/platform-infra.git
targetRevision: main
path: overlays/monitoring
destination:
server: https://kubernetes.default.svc
namespace: monitoring
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
```
**关键配置解析**:
- `prune: true`:Git 里删了资源,集群上也会跟着删——**危险但必要**
- `selfHeal: true`:有人手动改了集群,Controller 会自动改回来——**防止 drift 累积**
- `targetRevision: main`:指向具体分支,建议**不要指向 main,指向 release tag 或特定 branch**
## FluxCD 最小可用配置
```yaml
# fluxctl install --namespace=flux-system | kubectl apply -f -
# 上面的命令会自动安装 FluxCD
# 定义 Git 源
apiVersion: source.toolkit.fluxcd.io/v1beta2
kind: GitRepository
metadata:
name: platform-infra
namespace: flux-system
spec:
interval: 1m0s # 每 1 分钟检查一次变化
url: https://github.com/myorg/platform-infra.git
ref:
branch: main
secretRef:
name: github-token # 需要 Git token
---
# 定义 K8s 目标
apiVersion: kustomize.toolkit.fluxcd.io/v1beta2
kind: Kustomization
metadata:
name: monitoring
namespace: flux-system
spec:
interval: 5m0s
sourceRef:
kind: GitRepository
name: platform-infra
path: ./overlays/monitoring
prune: true
wait: true
healthCheck:
- apiVersion: v1
kind: Namespace
name: monitoring
```
**ArgoCD vs FluxCD 实际使用感受**:
- ArgoCD 的 UI 是真实生产力工具——可以直接在 Web 上看每个资源的 diff、手动 sync、看事件日志
- FluxCD 的 CLI 体验好但调试麻烦——出问题要看 `kubectl get events -n flux-system` 和 flux 的 reconcile log
## 多集群管理:一个 Git 管所有集群
```yaml
# clusters/prod/kustomization.yaml
apiVersion: kustomize.toolkit.fluxcd.io/v1beta2
kind: Kustomization
metadata:
name: prod-apps
namespace: flux-system
spec:
interval: 5m
sourceRef:
kind: GitRepository
name: platform-infra
path: ./clusters/prod
prune: true
wait: true
targetNamespace: apps-prod
---
# clusters/staging/kustomization.yaml(同上结构,不同 path)
```
**ArgoCD 的多集群方式**:通过 `argocd cluster add ` 把多个集群注册到同一个 ArgoCD 实例,然后在 UI 里为每个集群创建独立的 Application。
**踩坑**:多集群时 **namespace 隔离要显式配置**——两个集群的同一个 namespace 会冲突。建议在 Git 里用 `namespace: prod-apps` vs `namespace: staging-apps` 做区分。
## Rollback 策略:三种回滚方式
### 方式一:Git revert(推荐)
```bash
# 找到出问题的 commit
git log --oneline -10
# revert 那个 commit
git revert abc1234
# push 后 Controller 自动同步回滚
git push origin main
```
**优点**:可审计、可 review、有历史记录。**这是生产环境的首选方式**。
### 方式二:ArgoCD UI 回滚
在 ArgoCD UI 里选择 "Rollback" → 选历史版本 → "Sync"。
**优点**:快速、可视化。**缺点**:绕过了 Git,没有 commit 记录,不适合审计要求高的场景。
### 方式三:FluxCD image update rollback
```bash
# 查看当前镜像版本
flux get images -n flux-system
# 回滚到指定版本
flux create image update my-app \
--image=myregistry/my-app \
--target-value=1.2.3 \
--export | kubectl apply -f -
```
**适用场景**:只在镜像更新场景下用,其他场景不适用。
## 常见问题排障
**问题一:应用一直处于 `OutOfSync` 状态**
原因:集群状态和 Git 定义不一致,但 `selfHeal` 没有启用或 sync policy 配置错误。
排查:
```bash
# ArgoCD
argocd app get --refresh
# FluxCD
flux get kustomizations
```
修复:手动 sync 一次,确认 Git 定义是正确的,再让 Controller 自动修复。
**问题二:Prune 误删了生产资源**
原因:`prune: true` + 开发者忘记从 Git 里删除旧资源。
修复:
```bash
# 立即恢复被删除的资源
kubectl apply -f
# 然后配置保护性 prune
# 在 ArgoCD 的 Application 里加:
# spec:
# ignoreDifferences:
# - jsonPointers:
# - /spec/replicas
```
**教训**:prune 是双刃剑,生产环境建议先用 `--dry-run` 确认删除列表,再正式执行。
**问题三:ArgoCD 登录 401**
```bash
# 获取 admin 密码
kubectl -n argocd get secret argocd-initial-admin-secret \
-o jsonpath="{.data.password}" | base64 -d
# 如果是 K8s 内置认证问题,检查 argocd-cm ConfigMap
kubectl get configmap argocd-cm -n argocd -o yaml
```
## FAQ
### Q: GitOps 适合微服务架构吗?
适合,而且微服务越多越需要 GitOps。微服务的部署复杂度呈指数增长——20 个服务手动部署是噩梦,但用 GitOps 就是 20 个 YAML 文件的事。关键是要有**分层管理**:基础组件(monitoring、logging)一层,业务服务一层,配置和环境差异通过 overlay 管理。
### Q: 和 Helm/Kustomize 是什么关系?
GitOps 是**流程**(怎么管变更),Helm/Kustomize 是**工具**(怎么渲染模板)。两者正交:
- ArgoCD/FluxCD 可以用 Helm chart 作为 source
- ArgoCD/FluxCD 可以用 Kustomize overlay 管理多环境
- 也可以直接用裸 YAML(不推荐,维护成本高)
**我们的实践**:基础组件用 Helm(成熟稳定),业务应用用 Kustomize overlay(轻量灵活),GitOps controller 负责同步。
### Q: GitOps 能解决所有部署问题吗?
不能。GitOps 解决的是**声明式部署和版本管理**问题,但不解决:
- 数据库迁移(需要顺序执行,不适合自动同步)
- 灰度发布(需要逐步 rollout,要配合argo-rollouts 或 Flagger)
- 紧急 hotfix(可以直接改集群,但之后必须补回到 Git)
**建议**:把 GitOps 当作基础设施管理的基础设施,而不是全部。复杂部署流程还是要配合专门的工具(如 argo-rollouts)。
## 上线后的巡检与防漂移纪律
前面讲的是「怎么装、怎么回滚」,真正拉开差距的是上线后这套持续盯防。我们这套 ArgoCD 管着十几个集群跑了一年多,靠下面几条纪律把「GitOps 自己先失效」这种尴尬挡在外面。
### 把 controller 自身挂进监控
最反直觉但最重要:GitOps 工具本身也要被监控,否则它静默挂掉时,你以为集群状态是被 Git 保证的,其实早就不 reconcile 了。我们给 ArgoCD/FluxCD 本身配了三类告警:① controller Pod 重启次数增量(`restartCount` 上涨说明 OOM 或 panic);② git pull 时延,某次拉取超过 5 分钟没完成就报;③ reconcile 失败率(ArgoCD 的 `argocd_app_sync_last_result`、FluxCD 的 `fluxcd_kustomization_last_succeeded_time` 超过 10 分钟没刷新)。这三条把「GitOps 不灵了」从「发现集群状态不对」前移到「controller 异动时就告警」。
### 漂移扫描常态化
`selfHeal` 会默默把手动改动改回来,但如果 controller 没跟上、或有人把某个字段加进了 `ignoreDifferences`,漂移就会悄悄累积。我们每周跑一次全量漂移扫描:ArgoCD 侧对每个 app 跑 `argocd app get --show-diff`,FluxCD 侧跑 `flux reconcile kustomization`,把**任何非零漂移**都记下来并指派认领——漂移出现本身就是「有人绕过 Git 改了集群」的信号,不查根因就会反复被自动改回去、反复出问题。
### 紧急 hotfix 的「先改后补」纪律
翻车最多人踩的是:生产着火时直接 `kubectl` 改了集群救急,完事忘了补回 Git,结果下一个 reconcile 周期改动被冲掉。我们的铁律是**热修必须双写**:一边 `kubectl` 救急,一边**同一时间**在 Git 里提一个 hotfix PR 并 fast-merge,让「集群实际状态」和「Git 期望状态」保持一致。事后必须复盘「这次热修有没有进 Git」,没进 Git 的热修等于没做,下个 reconcile 就回滚了。
### 巡检清单(每周)
- **controller 健康**:重启次数、git pull 时延、reconcile 成功率三条曲线。
- **漂移扫描**:全量 `--show-diff`,非零漂移逐条认领。
- **热修核对**:本周所有 `kubectl` 手动改动,逐一确认已补回 Git。
- **prune 演练**:抽查一次 `--dry-run` 的删除列表,确认保护性 `ignoreDifferences` 还在生效。
---
## 相关文章
- [Blackbox Exporter 探针配置实战:多协议探测 + SLO 设计 + K8s ServiceMonitor 集成](blackbox-exporter.md)
- [混沌工程 Chaos Engineering:主动韧性测试实战,从故障注入到生产演练](chaos-engineering.md)
- [DevSecOps 安全左移:CI/CD 集成扫描策略,从 SAST/DAST 到供应链安全完整指南](devsecops-security.md)
---
## Grafana 部署与仪表盘搭建:从数据源接入到变量联动的六步路径
URL: https://ops.jiunotes.top/monitor/grafana-dashboard.md
摘要: Grafana + Prometheus 部署六步走:Docker Compose 一起跑、admin/admin 首登改密、Prometheus 数据源配置、变量联动下拉、JSON 导入导出、告警联动。原创,含 3 个真实踩坑案例。
### Answer
Grafana 部署六步:Docker Compose(Grafana + Prometheus 一起跑)→ 首登 admin/admin 改密 → 配 Prometheus 数据源 → 建第一个 Dashboard → 变量联动($instance 下拉)→ JSON 导入导出。最容易翻车的是 Prometheus 数据源 URL 用 9090 不是 9090/api/v1,以及变量查询里 metric 名要加花括号。
### FAQ
Q: Grafana 默认账号密码?
A: admin / admin,首次登录强制改密。如果不改,每次容器重启都用 admin/admin,容易被扫。生产环境建议用 Grafana 内置的 service account 或 reverse proxy auth 做集成认证。
Q: Grafana 和 Prometheus 一起部署还是分开?
A: 小团队用 Docker Compose 一起部署最简单;K8s 集群里 Grafana 用 Helm 单独装,Prometheus 用 kube-prometheus-stack。分开部署的好处是独立扩缩容,代价是多一套配置。
Q: 变量查询怎么写?
A: Prometheus 数据源 → Variables → Type=Query → Query 写 `label_values({__name__="node_cpu_seconds_total"}, instance)`。metric 名要加花括号避免歧义,Prometheus 版本差异下不加可能查询失败。
Q: Dashboard 怎么批量复用给同事 / 多环境?
A: 导出 JSON Model 进 Git 做版本管理,多环境用 Grafana 的 library panel(共享面板)+ 环境变量注入,避免每套环境手抄一遍 JSON。生产推荐配 provisioning 目录自动加载,重启不丢。
### Full Content
# Grafana 部署与仪表盘搭建:从数据源接入到变量联动的六步路径
[Grafana](https://grafana.com/docs/) 是 [Prometheus](https://prometheus.io/docs/) 生态里最实用的可视化层,但装完 Grafana 只是开始——怎么把数据源接进来、怎么让仪表盘自动切换节点、怎么导出给同事复用,才是真正的活。这篇讲从零到能用的六步。
## 60 秒决策:三种装法谁该选谁
| 装法 | 适合场景 | 优点 | 坑 |
|---|---|---|---|
| **[Docker](https://docs.docker.com/) Compose** | 个人 / 小团队 | 简单、迁移方便 | 需要单独管卷 |
| **[K8s](https://kubernetes.io/docs/) + Helm** | 已有 K8s 集群 | 与集群服务对齐 | Helm Chart 版本更新慢 |
| **[systemd](https://www.freedesktop.org/software/systemd/man/latest/)** | 原生 [Linux](https://www.kernel.org/doc/html/latest/) 极简 | 无依赖 | 升级麻烦、要 root |
生产建议 Docker Compose 或 K8s + Helm。本文以 Docker Compose 为主讲。
## Step 1:Docker Compose 一起跑
```yaml
# docker-compose.yml
services:
prometheus:
image: prom/prometheus:v2.53.0
restart: unless-stopped
ports: ["9090:9090"]
volumes:
- ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
- prom-data:/prometheus
command:
- --storage.tsdb.retention.time=15d
- --web.enable-lifecycle
grafana:
image: grafana/grafana:11.1.0
restart: unless-stopped
ports: ["3000:3000"]
environment:
- GF_SECURITY_ADMIN_USER=admin
- GF_SECURITY_ADMIN_PASSWORD=ChangeMe_2024
- GF_USERS_ALLOW_SIGN_UP=false
volumes:
- grafana-data:/var/lib/grafana
- ./grafana/provisioning:/etc/grafana/provisioning:ro
depends_on: [prometheus]
volumes:
prom-data:
grafana-data:
```
```bash
mkdir -p ~/grafana/{prometheus,grafana/provisioning/datasources}
cd ~/grafana && docker compose up -d
```
**你应该看到什么**:两个服务 `running`,`curl http://localhost:3000/` 返回 200。
## Step 2:首次初始化 + 改密码
浏览器打开 `http://:3000`:
```
Welcome to Grafana 11.1.0
Username: admin
Password: admin
```
**关键动作**:改密码。不改的话每次容器重启 Grafana 都用 admin/admin,容易被扫。
```bash
# 也可以改 compose 里的环境变量
sed -i 's/ChangeMe_2024/Your_New_Password/' docker-compose.yml
docker compose up -d --no-deps grafana
```
## Step 3:配 Prometheus 数据源
**方法一:UI 手动配**
Grafana → Connect → Data Sources → Add → Prometheus → URL: `http://prometheus:9090` → **Save & Test**。
**方法二:文件 provision(推荐生产用)**
```yaml
# grafana/provisioning/datasources/prometheus.yml
apiVersion: 1
datasources:
- name: Prometheus
type: prometheus
access: proxy
url: http://prometheus:9090
isDefault: true
jsonData:
httpMethod: POST
manageAlerts: true
```
```bash
# 重启让 provision 生效
docker compose restart grafana
```
**踩坑记录**:URL 只写 `http://prometheus:9090`,不要加 `/api/v1`,也不要在末尾加斜杠。加了会 404 或返回 status codes 解析失败。
## Step 4:建第一个 Dashboard
Grafana → Explore → 添加 Panel → 写 PromQL → 保存。
```promql
# 每个节点 CPU 使用率
100 - (avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100)
# 内存使用率
100 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes * 100)
# 根分区磁盘使用率
100 - (node_filesystem_avail_bytes{mountpoint="/"} / node_filesystem_size_bytes{mountpoint="/"} * 100)
```
**你应该看到什么**:三条曲线,每个 instance 一条线。
## Step 5:变量联动(最有用的一步)
变量让一个 Dashboard 复用给多个节点。
- **变量名**:`instance`
- **类型**:Query
- **Prometheus 查询**:`label_values({__name__="node_cpu_seconds_total"}, instance)`
```yaml
# Dashboard JSON 中的变量片段
"templating": {
"list": [
{
"name": "instance",
"type": "query",
"datasource": "Prometheus",
"query": {
"query": "label_values({__name__=\"node_cpu_seconds_total\"}, instance)",
"refId": "PrometheusVariableQueryEditor-VariableQuery"
},
"multi": true,
"includeAll": true,
"allValue": ".*"
}
]
}
```
```promql
# Panel 里用变量筛选
100 - (avg by (instance) (rate(node_cpu_seconds_total{mode="idle", instance=~"$instance"}[5m])) * 100)
```
**关键坑**:metric 名要加花括号 `{}`。老版教程里省略花括号在 Prometheus 10.x 之前能用,但 11.x 更严格的语法解析下容易失败。这一点我也不完全确定所有 Prometheus 版本都强制要求,实测 2.53 不加能跑,但加上更稳。
## Step 6:JSON 导入导出
Dashboard 右上角菜单 → JSON Model → 复制/粘贴。
```bash
# 用 Grafana HTTP API 批量导入
curl -X POST http://localhost:3000/api/dashboards/db \
-H "Content-Type: application/json" \
-u admin: \
-d @dashboard.json
```
**你应该看到什么**:`{"folderId":1,"id":2,"slug":"node-overview","status":"success","url":"/d/2/node-overview","version":1}`。
## 3 个真实踩坑案例
**案例一:Prometheus 数据源 URL 拼错**
现象:Save & Test 报 `status codes should not be parsed as json` 或 `404`。
根因:URL 加了 `/api/v1` 或末尾多了斜杠。
修复:URL 只写 `http://prometheus:9090`。
**案例二:变量查询返回空**
现象:Dashboard 里变量下拉框空的,无法选。
根因:变量查询里 metric 名没加花括号,或 metric 名写错。
```yaml
# 错(Prometheus 11.x 可能失败)
"query": "label_values(node_cpu_seconds_total, instance)"
# 对(metric 加花括号)
"query": "label_values({__name__=\"node_cpu_seconds_total\"}, instance)"
```
**案例三:Dashboard JSON 导入 401**
现象:`curl` 导入 JSON 报 401 Unauthorized。
根因:Grafana 版本 10+ 默认禁用 API Token 匿名导入,要用 service account token 或 basic auth。
```bash
# 用 basic auth 替代
curl -u admin: -X POST http://localhost:3000/api/dashboards/db \
-H "Content-Type: application/json" \
-d @dashboard.json
```
**这里最容易翻车**:Grafana 版本升级会改 API 行为,10.x 和 11.x 的鉴权差异挺大。这一点我也不完全确定所有插件都跟上了新版本鉴权,实测有些老插件要降级。
## Dashboard 版本管理与 provisioning
手动导出的 JSON 进 Git 是底线,但团队大了还得靠 provisioning 自动化。要点:
- **provisioning 目录挂进去**:compose 里已把 `./grafana/provisioning` 挂到 `/etc/grafana/provisioning`,把 dashboard JSON 放进 `provisioning/dashboards/` 配一个 `dashboards.yml`(specify `path` + `provider`),容器重启 Grafana 自动加载,不丢。
- **JSON 进 Git 做 code review**:改 dashboard 走 PR,指标口径、告警阈值、变量定义都能被 review。谁改了 CPU 告警阈值一目了然,避免「某人悄悄把告警阈值放宽导致漏报」。
- **多环境复用**:用 Grafana library panel(共享面板)+ 环境变量注入(`GF_INSTALL_PLUGINS`、datasource name),同一套面板在生产 / 预发各挂各的数据源,不用每套环境手抄 JSON。
## 告警联动与「只看面板」的坑
仪表盘是给人看的,告警才是真兜底。两条经验:
- **面板上的阈值 ≠ 告警**:别把「我看面板红没红」当监控。面板挂了没人看,告警才推人。同一套指标既画进面板、也配进 Alertmanager 规则,口径一致。
- **面板要能下钻**:大盘用 `topk(10, ...)` 看最差的 10 个 instance,点进变量下拉选单节点看细节。变量联动(Step 5)就是为这个下钻服务的——没有变量的大盘只能看整体,排障寸步难行。
## 性能:几百条线的大盘别乱画
- **`by (instance)` 聚合控制线数**:一台面板几百条线时浏览器卡死,先 `topk` / `bottomk` 限 10 条。
- **查询窗口别开太宽**:`[30d]` 这种长窗口直接打爆 Prometheus 查询,仪表盘看 24h 足够,历史留给长查询单独面板。
- **`rate` 窗口匹配采集间隔**:默认 15s 采集用 `[5m]` 或 `[2m]`,窗口开成 `[1m]` 在抖动大时会出锯齿假峰。
- **refresh 频率别太密**:大盘 30s 刷新够用,10s 频繁刷新对 Prometheus 是实打实的查询压力。
## 日常巡检与容量规划
把上面这些沉淀成例行动作,仪表盘才长期好用:
- **每周看一次「最坏 10 个节点」**:大盘顶部的 `topk(10, 100 - idle%)` 面板扫一眼,长期居前的 instance 重点看是不是漏配了清理脚本、磁盘是不是在涨。
- **每季度重算容量**:看 `node_filesystem_size_bytes` 与增长趋势(`predict_linear`),提前预警磁盘写满;Grafana 自身的 `tsdb` 查询压力随面板数线性涨,面板数翻几倍前先把刷新间隔和查询窗口收紧。
- **仪表盘 JSON 也做备份**:provisioning 目录进 Git 是备份的一种,再定期 `curl /api/dashboards` 全量导出 JSON 归档到对象存储,防 provisioning 配置改坏导致面板全丢。
- **告警与面板口径对齐**:每季度核对一遍「面板上的阈值」和「Alertmanager 规则」是否一致,漂移了就统一改,避免「面板绿着、告警其实早该响了」。
## 多数据源混用(Prometheus + Zabbix)
基础设施走 Zabbix、服务走 Prometheus 的团队很常见,Grafana 可以把两套数据源挂一起,但有几个坑:
- **数据源要能分选**:同一套变量里 `instance` 标签可能两个数据源都有但含义不同,建变量时绑定到指定数据源(`datasource: Prometheus`),别让它跨源取。
- **时区与采集间隔不一致**:Zabbix 默认 60s 粒度、Prometheus 15s,画在同一面板时线会「一大一小」跳,要么分开放面板、要么 `rollup` 对齐粒度。
- **告警分开配**:Grafana 原生告警只管 Prometheus 系数据源,Zabbix 的告警留在 Zabbix 内部,两套告警口径要在值班 SOP 里写清楚「哪个告警看哪套面板」,别混。
## 收尾
Grafana 部署本身不难,难的是"从能用到好用"——数据源稳定、变量联动、JSON 复用。六步走下来,你的团队应该能有一个"每人一个 dashboard,共享一套变量"的可用状态。
**我的判断**:Docker Compose 一起跑最省心,但生产环境还是推荐 K8s + Helm。原因很简单——Grafana 10+ 的鉴权、企业授权、插件管理在 K8s 里更可控。小团队先用 Compose,规模上来再切。
---
## 相关文章
- [Prometheus 部署实战:Docker / systemd / K8s 三种方案 60 秒选型](prometheus-deploy.md)
- [PromQL 速查:5 类黄金指标 rate/irate/histogram_quantile 陷阱与 20 条可复制模板](prometheus-query.md)
- [Zabbix + Prometheus 混合监控架构:传统基础设施 + 云原生服务的双栈方案](zabbix-prometheus-hybrid.md)
---
## 监控与安全 · Prometheus / Grafana / SSH 加固 / 堡垒机(8 篇)
URL: https://ops.jiunotes.top/monitor.md
摘要: 从 Prometheus 部署,到 PromQL 查询、Alertmanager 通知链、Grafana 面板、Zabbix Agent、SSH 加固、堡垒机选型,一条链路给你讲完。
### Full Content
# 让机器先喊救命,别等你收到电话
监控的尽头是**能被处理的告警**,不是漂亮的仪表盘;安全的尽头是**能被复盘的动作**,不是厚厚的加固手册。这里写:Prometheus alertmanager 怎么把"CPU 高"翻译成"哪台机器、什么进程、下一步"、SSH 加固怎么改到不改坏自己、堡垒机选型怎么算 TCO。
## 内容导航(8 篇)
| Slug | 一句话价值 |
|---|---|
| **[prometheus-deploy](prometheus-deploy.md)** | Docker + systemd 两种装法对比,5 分钟能跑 ⭐ |
| [prometheus-query](prometheus-query.md) | 10 个常用 PromQL 模板,直接抄 |
| [prometheus-alerting](prometheus-alerting.md) | alertmanager 路由树 + Silence vs Inhibit |
| [grafana-dashboard](grafana-dashboard.md) | 数据源 / 变量 / 常用 Panel 三件套 |
| [zabbix-setup](zabbix-setup.md) | Agent 主动 vs 被动,一条配置跑通 |
| [ssh-hardening](ssh-hardening.md) | 加固 Top 8,含"如何不把自己锁外面" |
| [bastion-jumpserver](bastion-jumpserver.md) | 自建 vs 商业,JumpServer 部署要点 |
| [distributed-tracing-span-sampling](distributed-tracing-span-sampling.md) | 头采样 vs 尾采样,错误/慢链路强制保留压成本 |
## 首篇旗舰
**[Prometheus 部署:Docker + systemd](prometheus-deploy.md)** ⭐ 首发旗舰
从 Docker Compose 到 systemd,Prometheus + node_exporter + 简单 Alertmanager 一条链路跑通。附内存占用与保留策略的实测数字。
## 相关标签
prometheuspromqlgrafanazabbixsshhardeningjumpserveralertmanager
---
[浏览全部监控文章 →](../../archive.md)
---
## Kubernetes Pod 排障决策树:CrashLoopBackOff / ImagePullBackOff / Pending 五步定位法
URL: https://ops.jiunotes.top/monitor/kubernetes-pod-troubleshoot.md
摘要: K8s 生产环境 Pod 常见问题排障:CrashLoopBackOff 根因分析、ImagePullBackOff 镜像拉取失败、Pending 调度失败、OOMKilled 内存超限、Service 不可达。附 kubectl 诊断命令速查表和六个翻车案例。
### Answer
K8s Pod 排障核心是"分层定位":先看 Events 看调度问题,再看 Logs 看应用崩溃,最后看 Describe 看资源限制。
五个最常见状态:CrashLoopBackOff(应用崩了)、ImagePullBackOff(镜像拉不到)、Pending(调度不上)、OOMKilled(内存超了)、ContainerCreating(启动中卡住)。
kubectl describe pod 和 kubectl logs --previous 是最快定位工具。
### FAQ
Q: Pod 一直 CrashLoopBackOff 怎么办?
a: 三步:① `kubectl logs --previous` 看上一次崩溃的日志;② `kubectl describe pod` 看 Events 里有没有 OOMKilled 或 Exit Code;③ 检查环境变量、ConfigMap、Secret 是否配置正确。
最常见原因:应用启动命令写错、缺少必要环境变量、资源 limit 设太小导致 OOM。
A:
Q: ImagePullBackOff 和 ErrImagePull 有什么区别?
a: ImagePullBackOff 是"重试拉取中",ErrImagePull 是"最后一次拉取失败"。
前者通常是网络问题或镜像仓库限流(Docker Hub 未登录每分钟 100 次),后者通常是镜像名/标签写错或私有仓库认证失败。
修复:检查 image name 拼写,私仓加 imagePullSecrets,国内用镜像加速器。
A:
Q: Pod 一直 Pending 怎么排查?
a: `kubectl describe pod` 看 Events,常见原因三选一:① 资源不足(Insufficient cpu/memory)→ 扩节点或减 request;
② NodeSelector/Affinity 不匹配 → 检查 label;③ PVC 未绑定 → 检查 StorageClass 和 PV 是否存在。
A:
Q: OOMKilled 后怎么调内存 limit?
a: 先看应用实际峰值:`kubectl top pod` 看当前 usage,然后 limit 设成峰值的 1.5 倍。
Java 应用特别注意:Xmx 要小于 container limit,否则 JVM 还没启动就被 OOM Kill 了。
长期方案:Profile 内存泄漏,而不是盲目加 limit。
A:
Q: kubectl 命令怎么快速诊断 Pod 问题?
a: 五步法:① `kubectl get pods` 看状态;② `kubectl describe pod ` 看 Events;
③ `kubectl logs ` 看应用日志;④ `kubectl logs --previous` 看上一次崩溃日志;
⑤ `kubectl exec -it -- sh` 进容器调试。这五步覆盖 90% 的问题。
A:
### Full Content
# Kubernetes Pod 排障决策树:CrashLoopBackOff / ImagePullBackOff / Pending 五步定位法
生产环境凌晨 2 点收到 [K8s](https://kubernetes.io/docs/) Pod CrashLoopBackOff 告警,这种场景每个运维都经历过。问题不是"怎么重启",而是"为什么崩"。这篇把 K8s Pod 最常见的五种故障状态拆解清楚,给你一个五步定位法,下次遇到不再慌。
## 五种最常见 Pod 状态
| 状态 | 含义 | 最常见原因 |
|---|---|---|
| `CrashLoopBackOff` | 容器反复崩溃重启 | 应用代码异常、环境变量缺失、资源不足 OOM |
| `ImagePullBackOff` | 拉取镜像重试中 | 镜像名错误、私有仓库认证失败、网络问题 |
| `Pending` | 调度器无法分配节点 | 资源不足、NodeSelector 不匹配、PVC 未绑定 |
| `OOMKilled` | 内存超限被杀 | limit 设太小、内存泄漏 |
| `ContainerCreating` | 启动中卡住 | 挂载卷权限、镜像拉取慢、Init Container 失败 |
下面按这个顺序排查。
## 第一步:看 Events —— 调度问题先看这里
```bash
kubectl describe pod -n
```
重点看底部的 **Events** 区域:
```
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Warning Failed 2m default-scheduler 0/3 nodes are available: 3 Insufficient memory.
Warning FailedMount 1m mounter Unable to attach or mount volumes: pvc-xxx not found
```
**常见 Events 解读**:
- `Insufficient memory/cpu` → 节点资源不够,扩集群或减小 request
- `FailedScheduling` → NodeSelector/Affinity 不匹配,检查 label
- `FailedMount` → PVC 未绑定,检查 StorageClass 和 PV
- `Back-off pulling image` → 镜像拉取失败,检查网络和认证
## 第二步:看 Logs —— 应用崩溃先看日志
```bash
# 当前运行容器的日志
kubectl logs -n
# 上一次崩溃容器的日志(CrashLoopBackOff 必备)
kubectl logs -n --previous
# 多容器 Pod 指定容器
kubectl logs -c -n
```
**CrashLoopBackOff 常见日志特征**:
```
# 环境变量缺失
Error: DATABASE_URL not found
at Object. (/app/server.js:12:11)
# 端口占用
Error: listen EADDRINUSE: address already in use 0.0.0.0:8080
# 权限问题
Error: EACCES: permission denied, open '/data/app.log'
# 依赖服务连不上
Error: connect ECONNREFUSED 10.0.0.5:5432
```
## 第三步:看 Describe —— 资源限制和配置
```bash
kubectl describe pod -n
```
除了 Events,重点看:
```yaml
Limits:
cpu: 500m
memory: 256Mi
Requests:
cpu: 250m
memory: 128Mi
Status: OOMKilled # ← 这里显示崩溃原因
Exit Code: 137 # ← 137 = SIGKILL = OOM
```
**Exit Code 对照表**:
| Code | 含义 | 排查方向 |
|---|---|---|
| 0 | 正常退出 | 可能是 readiness probe 失败 |
| 1 | 应用异常 | 检查日志 |
| 137 | OOMKilled | 增大 memory limit |
| 143 | SIGTERM | 正常终止,检查 Grace Period |
## 第四步:进容器 —— 手动调试
```bash
# 进入容器 shell
kubectl exec -it -n -- sh
# 或者指定命令测试
kubectl run debug --rm -it --restart=Never --image=busybox -- sh
```
**容器内常用调试命令**:
```bash
# 测试网络连通性
curl -v http://backend:8080/health
# 检查环境变量
env | grep DATABASE
# 检查文件权限
ls -la /data
# 查看进程
ps aux
# 查看内存使用
free -h
```
## 第五步:查资源 —— 集群层面问题
```bash
# 查看节点资源
kubectl top nodes
# 查看 Pod 资源使用
kubectl top pods -n
# 查看资源配额
kubectl describe quota -n
# 查看 Namespace 限制
kubectl describe namespace
```
## 五个实战场景
### 场景 A:CrashLoopBackOff —— 环境变量缺失
```bash
# 现象
kubectl get pods
NAME READY STATUS RESTARTS AGE
backend-xyz123 0/1 CrashLoopBackOff 5 10m
# 排查
kubectl logs backend-xyz123 --previous
# 输出:Error: DATABASE_URL not found
# 修复
kubectl set env deployment/backend DATABASE_URL=postgres://...
# 或者更新 ConfigMap/Secret 后 rollout restart
kubectl rollout restart deployment/backend
```
### 场景 B:ImagePullBackOff —— 私有仓库认证
```bash
# 现象
Events:
Warning Failed 1m kubelet Failed to pull image "registry.example.com/app:v1.0": unauthorized: authentication required
# 修复
kubectl create secret docker-registry reg-cred \
--docker-server=registry.example.com \
--docker-username=user \
--docker-password=pass \
--docker-email=email@example.com
# 更新 Deployment
kubectl patch deployment backend -p '{"spec":{"template":{"spec":{"imagePullSecrets":[{"name":"reg-cred"}]}}}}'
```
### 场景 C:Pending —— 资源不足
```bash
# 现象
Events:
Warning FailedScheduling 2m default-scheduler 0/3 nodes are available: 3 Insufficient memory.
# 修复方案一:减小 request
kubectl patch deployment backend -p '{"spec":{"template":{"spec":{"containers":[{"name":"backend","resources":{"requests":{"memory":"128Mi"}}]}}}}}'
# 修复方案二:扩容节点
kubectl scale nodes 3 → 5
# 修复方案三:清理不用的 Pod
kubectl delete pods --field-selector status.phase!=Running
```
### 场景 D:OOMKilled —— 内存超限
```bash
# 现象
kubectl describe pod
Last State: Terminated
Reason: OOMKilled
Exit Code: 137
# 短期修复:增大 limit
kubectl set resources deployment/backend -c backend --limits=memory=512Mi
# 长期修复:Profile 内存泄漏
# Java: jmap -heap
# [Python](https://docs.python.org/): import tracemalloc
```
### 场景 E:Service 不可达
```bash
# 现象
kubectl logs backend
# Error: connect ECONNREFUSED 10.96.0.10:5432
# 排查 Service
kubectl get svc
kubectl describe svc postgres
kubectl get endpoints postgres
# 常见问题:
# 1. Service 没有 Endpoints(Pod 没 Ready)
# 2. Selector 不匹配
# 3. Namespace 不同
# 修复
kubectl get pods -l app=postgres
kubectl describe pod postgres-xxx # 看 Readiness Probe
```
## 翻车案例复盘
### 案例 1:Resource Quota 限制导致 Pod 起不来
**现象**:Deployment 正常,但 Pod 一直 Pending。
**根因**:Namespace 有 Resource Quota,memory request 总和超过配额。
**修复**:
```bash
kubectl describe quota -n production
# 看到 memory request: 10Gi, used: 9.8Gi
kubectl edit quota main-quota -n production
# 调大配额或减小 Pod request
```
### 案例 2:Init Container 失败导致主容器不启动
**现象**:Pod 一直处于 ContainerCreating,日志无输出。
**根因**:Init Container 执行失败,主容器不会启动。
**排查**:
```bash
kubectl logs -c init-migrate-db
# 看到数据库连接失败
```
**修复**:修复 init container 的配置或依赖服务。
### 案例 3:liveness probe 过于激进导致 Pod 反复重启
**现象**:Pod 每 30 秒重启一次,但应用正常运行。
**根因**:liveness probe 的 timeout 设太短,应用偶尔慢响应就被判不健康。
**修复**:
```yaml
livenessProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 60 # 给应用启动时间
periodSeconds: 30
timeoutSeconds: 10 # 放宽超时
failureThreshold: 3 # 允许 3 次失败
```
### 案例 4:ConfigMap 更新后 Pod 不生效
**现象**:更新 ConfigMap 后,Pod 还是用旧配置。
**根因**:ConfigMap 挂载是只读的,需要重启 Pod 才能加载新配置。
**修复**:
```bash
kubectl rollout restart deployment/backend
```
### 案例 5:PVC 绑定失败导致 Pod Pending
**现象**:Pod 一直 Pending,Events 显示 `storageclass "fast-ssd" not found`。
**根因**:StorageClass 名称写错或删除了。
**修复**:
```bash
kubectl get storageclass
# 确认正确的 StorageClass 名称
kubectl patch pvc data-pvc -p '{"spec":{"storageClassName":"standard"}}'
```
### 案例 6:Node Affinity 导致 Pod 无法调度
**现象**:只有 2 个节点符合 label,但需要 3 个 Pod。
**根因**:
```yaml
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: disktype
operator: In
values:
- ssd
```
**修复**:增加带 `disktype=ssd` label 的节点,或改用 `preferredDuringScheduling`。
## 诊断命令速查表
| 问题 | 命令 |
|---|---|
| 看 Pod 状态 | `kubectl get pods -A` |
| 看具体 Pod 详情 | `kubectl describe pod -n ` |
| 看应用日志 | `kubectl logs -n ` |
| 看上次崩溃日志 | `kubectl logs --previous -n ` |
| 进容器调试 | `kubectl exec -it -- sh` |
| 看节点资源 | `kubectl top nodes` |
| 看 Pod 资源 | `kubectl top pods -n ` |
| 重启 Deployment | `kubectl rollout restart deployment/` |
| 回滚版本 | `kubectl rollout undo deployment/` |
## 生产环境的巡检与发布纪律
前面讲的是「出事了怎么查」,真正拉开差距的是「平时怎么盯、怎么发」。我们集群规模上到 20 多节点后,靠人肉 `kubectl get pods` 早就管不过来了,沉淀出下面这套节奏。
### 把 Pod 异常变成可观测指标
不要等告警来了才翻 `describe`。我们自建一个采集器,定期跑 `kubectl get pods -A -o json`,把每个 Pod 的 `status.phase`、`restartCount`、`containerStatuses.lastState.terminated.reason` 抽出来打点成 Prometheus 指标。三条最值钱的告警:① 任一命名空间 `restartCount` 一小时内增量 > 5;② 出现 `reason: OOMKilled` 直接 P1;③ Pending 超过 10 分钟。这三条把「凌晨 2 点被动收告警」变成「异动时主动收到」,MTTR 明显缩短。
### 发布纪律:金丝雀 + 回滚开关常备
任何 Deployment 变更都走「先 1 个副本金丝雀、观察 10 分钟指标无异常再全量」的节奏。回滚开关必须**常备且验证过**——我们每月做一次「发布后立即 `kubectl rollout undo`」的演练,确认 undo 真的能拉回上一版镜像,而不是因为 `revisionHistoryLimit` 设太小说明没有历史 revision 可回。这个坑很隐蔽:`revisionHistoryLimit` 默认 10 没问题,但有人为了省空间设成 1,出事就啥都回不去了。
### 容量巡检:request 别设虚
翻车案例里大量 Pending 的根因是「request 设得太保守占满了集群,但实际 usage 远低于 request」。我们每月跑一次 `kubectl top pods` 对比 `spec.resources.requests`,算每个 Pod 的 request/usage 比,长期低于 30% 的就下调 request,给集群腾出真实容量。反过来 usage 长期贴着 request 的,说明 request 设得刚好或偏低,优先给这类 Pod 加余量,别等 OOM 才发现。
### 节点层留观
Pod 排障只盯 Pod 不够,节点本身异常(磁盘只读、kubelet 失联、某节点 OOM)会引发一批 Pod 连锁 CrashLoop。我们给每个节点配了 `node_exporter` + 一条「节点 Ready=False 或 kubelet 心跳丢失」的告警,节点级问题提前于 Pod 级暴露。
## 写在最后
K8s Pod 排障的核心思路是**分层定位**:
1. **调度层**:Pending → 看 Events → 资源/节点/存储问题
2. **启动层**:CrashLoop → 看日志 → 应用配置问题
3. **运行层**:OOM/超时 → 看资源 → 限制造型问题
4. **网络层**:Service 不可达 → 看 Endpoints → 选择器问题
记住五个命令:`get`、`describe`、`logs`、`exec`、`top`。这五个命令覆盖 90% 的排障场景。
**最重要的是**:不要只看报错就重启,先理解"为什么崩"。重启只能解决临时问题,找到根因才能防止复发。
## 相关文章
- [Alertmanager 告警降噪实战:抑制器 + 路由树 + 静默配置四步降噪法](alertmanager-noise-reduction.md)
- [堡垒机选型与 JumpServer 部署](bastion-jumpserver.md)
- [Blackbox Exporter 探针配置实战:多协议探测 + SLO 设计 + K8s ServiceMonitor 集成](blackbox-exporter.md)
---
## Linux 监控 CPU:top 一行 5 个百分比字段排障决策树
URL: https://ops.jiunotes.top/monitor/linux-cpu-monitor.md
摘要: Linux CPU 监控深度解析:top 一行 %usr/%sys/%iowait/%steal/%irq 五个字段的排障决策树,mpstat/pidstat/vmstat/sar 工具链组合使用,4 个真实排障案例,含 NUMA 绑核和 CFS 配额两个隐性 CPU 问题。
### Answer
Linux CPU 排障从 top 一行 5 个字段开始:%usr 高 → 代码优化或扩容;%sys 高 → 系统调用/审计/内核;%iowait 高 → 磁盘瓶颈不是 CPU;%steal 高 → 虚拟化层被抢;%irq/%soft 高 → 网络/中断。
然后按 top → mpstat 看单核 → pidstat 看进程 → iostat 看磁盘 → perf 看热点函数 五层递进。
隐性坑:容器 CFS 配额导致 %idle 高但业务卡;NUMA 绑核导致跨节点访存慢。
### FAQ
Q: %iowait 高就一定说明 CPU 忙吗?
A: 不是。%iowait 是"CPU 空闲但等待磁盘 I/O 完成"的时间百分比。真正的问题是磁盘,不是 CPU。高 %iowait 结合 iostat 的 %util 和 await 看磁盘,扩容 CPU 没用。
Q: top 里 %Cpu(s) 平均值 40%,为什么接口还是慢?
A: 平均值会掩盖单核热点。用 mpstat -P ALL 看每个核心,如果有某个核 100% 而其他核空闲,可能是线程绑核(NUMA)或 Java/GC 单线程问题。另外看 CFS 容器配额:CFS quota 限流会让业务线程在 CPU 空闲时被强行暂停。
Q: %steal 高说明什么?
A: 虚拟化场景下"被 hypervisor 抢走的时间"。云主机邻居吵闹(noisy neighbor)或宿主机超卖会拉高这个值。物理机不该有 %steal。这个字段是判断"云主机是不是共享超卖"最直接的信号。
Q: top 的 %CPU 和 pidstat 的 %CPU 是同一个意思吗?
A: 数值算法接近但含义不同。top 的 %CPU 是进程占用整机 CPU 的百分比(多核场景下单进程最多 100%×N 核);pidstat 默认也是这个口径,但加 -I 参数会除以 CPU 总数,返回 0-100 范围的归一化百分比。跨机器对比要统一口径。
### Full Content
# Linux 监控 CPU:top 一行 5 个百分比字段排障决策树
CPU 满了,是**最容易被误诊的性能问题**。90% 的人看到 `%Cpu(s): 100% us` 就冲去重启进程,看到 `%iowait 高` 就换块更快的 SSD,看到 `%sys 高` 就以为是系统调用太多要优化代码——结果方向完全错了。
写这篇的时候,我复盘了自己踩过的三个坑:
- 生产环境 Java 应用 CPU 100%,重启了 5 次,最后是 GC 单线程导致的
- 一台机器 `%iowait 40%` 但业务慢,扩容到 16 核还是慢——磁盘是瓶颈不是 CPU
- [K8s](https://kubernetes.io/docs/) 集群里业务线程被 CFS quota 限流,top 显示 `%idle 95%`,业务却在超时无响应
## 入口:top 一行的 5 个字段
CPU 排障的第一站永远是 `top`。不用学什么工具,先看第一屏顶部的这一行:
```bash
top -bn1 | head -5
```
**你应该看到什么**:
```
%Cpu(s): 12.3 us, 2.1 sy, 0.0 ni, 84.5 id, 0.5 wa, 0.0 hi, 0.4 si, 0.0 st
```
这一行有 5 个关键字段,**每个字段高都是完全不同的问题**:
| 字段 | 含义 | 高了说明什么 |
|---|---|---|
| **%us / %usr** | 用户态 CPU 时间 | 应用代码在烧 CPU |
| **%sy / %system** | 内核态 CPU 时间 | 系统调用多 / 内核驱动忙 / 审计钩子 |
| **%wa / %iowait** | 等待 I/O 完成 | **磁盘瓶颈**,不是 CPU |
| **%hi** | 硬件中断 | 网卡/磁盘中断处理 |
| **%si** | 软件中断 | 网络协议栈处理 |
| **%st** | 被 hypervisor 偷走 | 云主机超卖/邻居吵闹 |
| **%ni** | nice 进程 | 人为降优先级,通常可忽略 |
| **%idle** | 空闲 | 越接近 100% 越健康 |
**注意一个反直觉的事实**:`%iowait` 高**不代表** CPU 忙。它是"CPU 空闲但用户在等磁盘"的时间百分比。看到 `%iowait 40%` 该去查磁盘,别去扩 CPU。
## 排障决策树
拿到 top 第一行,按下面这张决策树走:
```
top 显示 CPU 使用率高
├─ %usr 高(>70%)
│ ├─ 单进程占大头?→ pidstat -u 定位进程 → strace/perf 看热点
│ └─ 多个进程都有?→ 应用架构问题,考虑水平扩容
├─ %sys 高(>30%)
│ ├─ 网络中断多?→ mpstat 看 %irq/%soft
│ ├─ 系统调用多?→ strace -c -p 看调用频次
│ └─ 审计/安全软件?→ auditctl -l / 卸载安全 agent
├─ %iowait 高(>20%)
│ ├─ %util 高?→ 磁盘打满,换更快的盘或优化 IO
│ ├─ await 高但 %util 不高?→ 网络存储/挂载问题
│ └─ 频繁 fsync?→ 应用层写策略问题(WAL/事务日志)
├─ %irq + %soft 高(>15%)
│ ├─ 网卡中断?→ ethtool -n 看队列均衡
│ └─ 中断绑核?→ irqbalance 或手动绑多核
└─ %steal 高(>5%)
└─ 云主机被邻居影响 → 换实例规格或联系云厂商
```
## 场景一:%usr 高 → 找进程
`%usr` 高是"应用代码在吃 CPU"。第一步是**定位是哪个进程、哪个线程**。
```bash
# 1. 看 top 里按 CPU 排序
top -bn1 | sort -k 9 -n -r | head -10
# 2. 用 pidstat 精确定位(sysstat 包)
pidstat -u 1 5 # 每 1 秒刷新,共 5 次
# 3. 如果怀疑是线程问题(Java/Go 常见)
pidstat -u -t 1 3 # -t 展开到线程级
```
**你应该看到什么**:
- pidstat 输出里 `%CPU` 列最高的进程
- 加 `-t` 后能看到 `%CPU` 列的线程(TID),一个进程可能有单线程 100% 而其他线程 0%
**真实案例 1:Java 应用 CPU 100% 重启无效**
一台 Java 应用服务器,top 显示 100% us,重启无效。用 `pidstat -u -t 1 3` 看到只有一个 TID(`GC Thread #2`)持续 100%,其他线程 0%。
排查路径:`top -H -p ` 看线程名 → 定位到 GC 线程 → 打开 GC 日志发现 Full GC 频繁 → 根因是堆内存 OOM 前兆。
修复:加 JVM 参数 `-XX:+HeapDumpOnOutOfMemoryError`,扩大 Xmx,优化代码对象持有。
**教训**:进程级 `%usr` 是平均,线程级才是真相。Java 尤其如此——单线程 GC 就能吃满整个核。
## 场景二:%sys 高 → 找系统调用和内核开销
`%sys` 高不是"代码写不好",通常是三种情况:**网络中断风暴、系统调用频繁、审计/安全钩子**。
```bash
# 1. 看是硬中断还是软中断
mpstat 1 5 # 看 %irq 和 %soft 分布
# 2. 如果 %sys 高但 %irq/%soft 不高,抓系统调用
strace -c -p -f # -c 汇总,-f 跟随 fork
# 按回车停止,看耗时最多的 syscall
# 3. 看是哪些 syscall 在烧
perf top -p # 看内核函数热点
```
**你应该看到什么**:
- `mpstat` 里 `%irq` 或 `%soft` 明显高于其他行 → 中断是主因
- `strace -c` 输出里某个 syscall(比如 `futex`、`select`、`epoll_wait`)耗时最多
**真实案例 2:%sys 40% 是安全 agent 的锅**
一台服务器 `%sys` 长期 40%,业务方抱怨启动慢。排查发现:
- `strace -c -p <业务pid>` 里 `futex` 占了 80%
- `auditctl -l` 里审计规则有 200 多条,包括"监控所有 /var/log/*"、"监控所有网络连接"
- 卸载安全 agent 的 audit 规则后,`%sys` 降到 5%
**教训**:%sys 高优先查**审计和安全软件**,而不是应用代码。安全 agent 是"看不见的 CPU 杀手"。
## 场景三:%iowait 高 → 找磁盘
**反直觉**:`%iowait` 高是磁盘问题,不是 CPU 问题。但 90% 的人第一反应是扩容 CPU。
```bash
# 1. 用 iostat 看每个磁盘的状态
iostat -xz 1 5
# 2. 关注三个字段
# %util = 磁盘忙碌率(>90% 说明磁盘打满)
# await = 平均 IO 等待时间(毫秒)
# avgqu-sz = 平均队列长度
```
**你应该看到什么**:
- `%util` 接近 100% → 磁盘被打满,扩容 CPU 没用
- `await` > 100ms → 单次 IO 慢,可能是磁盘老化或网络存储抖动
- `avgqu-sz` > 8 → IO 排队严重
**真实案例 3:%iowait 40% 扩容 16 核依然慢**
一台 MySQL 服务器 `%iowait 40%`,业务方要求扩容。扩容到 16 核,依然慢。用 `iostat -xz` 看到:
- `sda %util = 98%`(磁盘打满)
- `sda await = 250ms`(单次 IO 250 毫秒)
- `sda avgqu-sz = 42`(IO 队列长度 42)
根因是 MySQL `innodb_log_file_size` 太小 + WAL 频繁刷盘,撑爆单块 SSD。
修复:
- 拆双 SSD,把 innodb_log 分到独立盘
- 或改 `innodb_flush_log_at_trx_commit=2`(可容忍少量数据丢失)
**教训**:`%iowait` 高先看 `iostat %util`。**磁盘是瓶颈时,扩容 CPU 是浪费钱**。
## 场景四:%irq/%soft 高 → 找网络中断
网络密集型机器(网关、代理、K8s 节点)经常出现 `%soft` 高。
```bash
# 1. 看具体是哪些中断
cat /proc/interrupts | head -20
# 2. 看中断在各核的分布
watch -n 1 "cat /proc/interrupts | grep eth0"
# 3. 看网卡多队列是否生效
ethtool -l eth0 # 看 Number of combined RX queues
```
**你应该看到什么**:
- `/proc/interrupts` 里某个 IRQ 编号(比如 `123:`)在某一个 CPU 上计数持续增长
- `ethtool -l` 里 "Combined" 显示 1 而不是 CPU 数 → 网卡没开多队列
**修复**:
- 打开网卡多队列:`ethtool -L eth0 combined 8`
- 或让 irqbalance 自动平衡:`systemctl status irqbalance`
## 场景五:%steal 高 → 找虚拟化层
**这个字段是判断"云主机是不是超卖"最直接的信号**。
```bash
# 看 %steal 长期分布
sar -u -P ALL 60 30 # 每 60 秒采一次,共 30 次
```
**你应该看到什么**:
- 物理机上 `%steal` 应该恒为 0
- 云主机上偶发 `%steal 2-5%` 是正常的
- 长期 `%steal > 10%` → 邻居吵闹或宿主机超卖
**修复路径**:
1. 换独享实例规格(阿里云独享 vs 共享)
2. 或者接受性能抖动,把业务容忍度调高
**真实案例 4:一台云主机 CPU 慢但 %idle 95%**
业务方反馈"云主机突然变慢",但 `top` 显示 `%idle 95%`。用 `mpstat -P ALL 1 5` 发现:
- `%steal` 长期 15-20%
- `%idle` 85-90%(看似空闲)
这是典型的**云主机邻居吵闹**:宿主机其他虚拟机把 CPU 偷走了,你的实例被限流。
修复:联系云厂商升级到独享实例,或换实例。
## 隐性坑 1:容器 CFS quota 限流
**这是 K8s 集群最容易踩的坑**。`top` 显示 `%idle 90%`,但业务超时无响应。
```bash
# 1. 看容器是否被 CFS 限流
cat /sys/fs/cgroup/cpu/cpu.stat # cgroup v1
# 或 cgroup v2
cat /sys/fs/cgroup/cpu.max
# 2. 看是否被 throttle
grep throttled /sys/fs/cgroup/cpu.stat
# nr_throttled: 累计被限流次数
# throttled_time: 累计被限流时间(纳秒)
```
**你应该看到什么**:
- `nr_throttled` 持续增加 → 业务被 CFS quota 限流
- `throttled_time` 每秒增量 > 10ms → 业务明显受影响
**修复**:
- 调大 `cpu.cfs_quota_us`(比如从 50000 提到 200000)
- 或去掉 CPU limit,只留 requests
**教训**:K8s 里看 CPU 一定要看 cgroup 的 throttle 数据,**top 里的 %idle 会骗人**。
## 隐性坑 2:NUMA 绑核导致跨节点访存
大内存多核机器(64 核 + 256G 内存)的隐性性能杀手。
```bash
# 1. 看进程绑在哪个 NUMA 节点
taskset -pc
# 2. 看进程的 NUMA 分布
numastat -p
# 3. 看 NUMA 节点间迁移次数
numastat --all | grep migrate
```
**你应该看到什么**:
- `numastat -p` 里 numa_hit 和 numa_miss 差距大 → 跨节点访存多
- `numastat --all` 里 numamigrate 计数持续增长 → 线程在节点间乱跑
**修复**:用 `taskset` 或 `numactl` 把进程绑到单一 NUMA 节点:
```bash
numactl --cpunodebind=0 --membind=0 java -jar app.jar
```
## 5 层递进工具链
CPU 排障的正确姿势是**由粗到细 5 层递进**:
| 层级 | 命令 | 用途 |
|---|---|---|
| 1 | `top` | 看整体 %us/%sy/%wa |
| 2 | `mpstat -P ALL` | 看单核分布(找热点核)|
| 3 | `pidstat -u -t` | 定位到具体进程和线程 |
| 4 | `iostat -xz` / `perf top` | 找磁盘或内核热点函数 |
| 5 | `perf record` + `perf report` | 生成火焰图,定位具体代码行 |
**不要跳过中间层**:直接 perf 一个 1000 进程的机器会输出爆炸。先用 top 定方向,再用 mpstat/pidstat 缩范围,最后 perf 定根因。
## 12 条速查命令
| # | 命令 | 用途 |
|---|---|---|
| 1 | `top -bn1` | 快速看一屏(非交互模式)|
| 2 | `top -H -p ` | 看进程内线程 CPU |
| 3 | `mpstat -P ALL 1 5` | 每核分布 |
| 4 | `pidstat -u 1 5` | 进程 CPU |
| 5 | `pidstat -u -t 1 3` | 线程 CPU(Java/Go 用)|
| 6 | `iostat -xz 1 5` | 磁盘状态 |
| 7 | `sar -u 1 5` | 历史 CPU 采样 |
| 8 | `sar -u -f /var/log/sa/sa$(date +\%d)` | 查历史某一天的 CPU |
| 9 | `perf top -p ` | 进程热点函数 |
| 10 | `numastat -p ` | NUMA 分布 |
| 11 | `cat /sys/fs/cgroup/cpu.stat` | 容器 CFS throttle |
| 12 | `cat /proc/interrupts` | 中断分布 |
## 收尾:什么时候不是 CPU 问题
CPU 排障最容易犯的错误是**把不是 CPU 的问题当 CPU 问题**。以下场景看起来像 CPU 慢,其实不是:
1. **`%iowait` 高**:磁盘问题,扩 CPU 没用
2. **`%steal` 高**:虚拟化层问题,找云厂商
3. **top 显示 `%idle 90%` 但业务慢**:可能是内存不足(换页)、GC、CFS 限流、NUMA 绑核错
4. **单核 100% 其他核空闲**:不是 CPU 容量问题,是线程调度问题
**排障铁律**:先分类(%us/%sy/%wa/%irq/%st)再定位(进程/线程/函数),**不要一上来就重启**。重启能解决的只是"重启能解决"的问题——生产环境里 90% 的性能问题重启没用。
**这一点我也不完全确定**:不同发行版(RHEL/Debian/Alpine)的 mpstat 版本对某些字段计算方式略有差异,同一份输出在不同系统里可能差 1-2 个百分点。跨机器对比时留 5% 的余量比较稳。
---
## 相关文章
- [Linux 内存监控实战与 OOM 排障:free/vmstat/ps 字段解读 + cgroup v2 内存限制决策树](linux-memory-monitor.md)
- [eBPF 可观测性原理:无侵入监控实战,从 kernel trace 到生产落地](ebpf-observability.md)
- [node_exporter 指标缺失排查:6 大症状决策树 + 4 个常见翻车原因](node-exporter-troubleshoot.md)
---
## Linux 磁盘 IO 监控实战:6 症状决策树 + iostat/iotop 判读 + 5 个真实事故
URL: https://ops.jiunotes.top/monitor/linux-disk-io-monitor.md
摘要: 磁盘 IO 瓶颈是最易误诊的问题:load 飙高 CPU 却闲,八成是 iowait 作怪。本文给 vmstat/iostat/iotop/pidstat/blktrace 完整判读,6 症状决策树,cgroup v2 blkio 限流,HDD/SSD/NVMe 阈值矩阵,加 5 个真实事故。
### Answer
磁盘 IO 瓶颈常伪装成"load 高 CPU 闲"。先 vmstat 看 b/wa,再 iostat 判 %util/await,iotop 揪元凶,别被 NVMe 假饱和骗了。
### FAQ
Q: load 很高但 CPU 使用率很低,为什么?
A: 八成是 iowait 在作怪。进程在等磁盘 I/O 完成,CPU 处于空闲但被计入 load。用 vmstat 看 wa 列非零、b 列有阻塞进程,再 iostat 查 %util/await,方向就清楚了。
Q: iostat 的 %util 100% 但业务不慢,正常吗?
A: NVMe 或 SSD 可能出现这种情况:设备队列持续有请求所以 %util 100%,但实际带宽/延迟都很低。判读要结合 rMB/s 和 await,NVMe 别只看 %util,要对比它的额定带宽。
Q: iowait 高但 iostat %util 不高,怎么解释?
A: 通常是多队列设备、单盘多队列并发,或 NFS/网络文件系统。%util 是按设备聚合的,并发请求多时单个请求等待长但设备没满。用 iotop/pidstat 定位进程,或用 blktrace 看真实队列深度。
Q: D 状态进程太多,kill 不掉怎么办?
A: D 状态是内核不可中断睡眠,等 I/O 完成,普通 SIGKILL 无效。先看 wchan 停在哪个函数(io_schedule/blk_mq/submit_bio 是 IO 层)。若盘坏了只能重启;若可恢复,先停写源再等它自己完成。
Q: rsync 备份经常打满磁盘,怎么限速?
A: 用 ionice -c3 给它 best-effort 空闲级,或 rsync --bwlimit 限带宽。更彻底的是把备份错峰到业务低峰,并检查 rsync 是否每次全量扫描而非增量,避免无谓的读放大。
Q: swap 抖动(kswapd0 占 CPU 顶)怎么处理?
A: 内存不足导致内核换页线程疯狂工作。临时调 vm.swappiness 降低换页倾向,但根治是加内存或限制进程内存(cgroup memory.max)。查看 /proc/pressure/memory 的 psi 值判断真实压力。
Q: 机械盘 await 200ms 了,是不是必须换盘?
A: 机械盘 await 超 100ms 已算慢,200ms 基本是寻道风暴或盘接近损坏。先确认是否随机小 IO 打满(用 iostat 看 r/s 和 r_await),若持续高建议换 SSD 或 NVMe,机械盘别用于高并发随机 IO。
Q: 数据目录和日志目录要不要分盘?
A: 要。随机读(数据)和顺序写(日志)混在一个机械盘会互相干扰寻道。分盘后随机 IO 不再打乱顺序写,日志的 fsync 延迟会明显下降。数据库和容器镜像层分盘效果最明显。
### Full Content
# Linux 磁盘 IO 监控实战:6 症状决策树 + iostat/iotop 判读 + 5 个真实事故
我干运维第十个年头,遇到最多的"疑难杂症"不是 CPU 满、不是内存泄漏,而是**磁盘 IO**。
原因很反直觉:磁盘出问题,最先报警的往往不是磁盘监控,而是 `load average` 飙到几十、业务接口超时、数据库连接池打满。你盯着 CPU 看,`%us` 才 5%,`%idle` 还有 60%——**CPU 一点都不忙,可系统就是卡得要死**。这种"load 高 CPU 闲"的经典现场,我第一年就踩过,还以为是机器中毒了,重装了两遍系统。
后来才明白:load 统计的是**可运行 + 不可中断睡眠**的进程数。当进程卡在磁盘 I/O 上进入 D 状态(不可中断睡眠),它照样计入 load,但 CPU 是空的。这就是"load 高 CPU 闲"的真相——**八成是 iowait(IO wait)在作怪,不是 CPU 满,不是病毒**。
这篇我把自己十年的 IO 排障经验完整写出来:从 `vmstat` 60 秒定性,到 `iostat -x` 逐字段判读,到 `iotop`/`pidstat` 揪出元凶进程,再到 `blktrace` 下探块设备层,最后是 cgroup v2 的 blkio 限流。全文给出一张 **6 症状决策树**,一张 **HDD/SSD/NVMe 阈值矩阵**,外加 5 个我亲手处理过的真实事故。所有命令都可直接复制,阈值都写到具体数字。
### 这篇适合谁
- **运维/后端/数据库工程师**:需要自己动手排 IO 瓶颈的
- **SRE/平台工程师**:容器场景被邻居进程拖垮 IO 的
- **刚入行的同学**:想建立"IO 瓶颈 vs CPU 瓶颈"正确判断的
### 工具依赖速查
本文用到的主工具与安装方式(各发行版):
```bash
# RHEL/CentOS/Rocky
yum install -y sysstat iotop blktrace
# Debian/Ubuntu
apt install -y sysstat iotop blktrace
# 检查是否已装
which iostat iotop pidstat blktrace
```
注意 `sysstat` 包提供 `iostat`、`pidstat`、`sar`、`mpstat` 四件套,装一次全有。`iotop` 和 `blktrace` 是独立包,别漏装。
## 一、为什么 IO 瓶颈是运维最易误判的问题
先讲清楚"load 高 CPU 闲"这个经典陷阱的成因,后面所有排查都从这出发。
[Linux](https://www.kernel.org/doc/html/latest/) 的 load average 统计的是**运行队列里可运行进程 + 不可中断(D 状态)睡眠进程**的数量。关键就在"不可中断睡眠"这五个字:
- 进程等 CPU 时,状态是 R(running),计入 load,CPU 也在忙——**正常对应关系**
- 进程等磁盘 I/O 时,状态是 D(uninterruptible sleep),**照样计入 load,但 CPU 是空的**
所以你看到 load 20、CPU 5%,不是 CPU 不够,是有 20 个进程在排队等磁盘。我把它叫"**影子瓶颈**":磁盘是那堵墙,CPU 是无辜的旁观者。
### 最常见的 5 个误判方向
| 误判 | 错误操作 | 真实原因 |
|---|---|---|
| 以为是 CPU 满 | 加 CPU、扩核 | 是 iowait,磁盘慢 |
| 以为是病毒/挖矿 | 杀进程、重装系统 | D 状态进程在等 IO |
| 以为是内存泄漏 | 加大内存 | 页缓存回写(flush)压盘 |
| 以为是应用 bug | 重启应用 | 是其他进程抢 IO |
| 以为是网卡 | 查网络抓包 | 是磁盘随机读风暴 |
这 5 个误判我都犯过至少一次。**排障第一原则:先分清是 CPU、内存还是 IO,再往下查。** 分清之后,IO 内部的排查其实非常模式化。
### load 的三个组成部分,别被平均数骗
`load average` 是"1 分钟 / 5 分钟 / 15 分钟"三个数,很多人只盯它。但它把三种完全不同的事混在一起:
| 计入 load 的进程 | 状态 | 对应问题 |
|---|---|---|
| 可运行进程(等 CPU) | R | CPU 不足 |
| 不可中断睡眠进程(等 IO) | D | **磁盘 IO 瓶颈** |
| 不可中断睡眠进程(等锁/驱动) | D | 内核死锁、盘挂、驱动 bug |
所以**load 高本身没意义,要看"组成 load 的是 R 还是 D"**。判断方法:
```bash
# 看 R 和 D 进程分别多少个
ps -eo state | awk '$1=="R"{r++} $1=="D"{d++} END{print "R="r" D="d}'
```
```text
R=4 D=18
```
- `R=4 D=18`:load 主要是 18 个 D 状态进程在等 IO → **IO 瓶颈**(本文主题)
- `R=40 D=0`:load 全是可运行进程在等 CPU → **CPU 瓶颈**,去看 cpu-monitor
- `R=4 D=18` 但 `iostat %util` 不高 → 可能是 NFS/多队列,见症状 5
**记住**:`load` 是"症状",`R/D 分布`才是"病因"。看到 load 高,先别慌,花 3 秒跑上面这条命令分清是 R 还是 D,方向就定了。
### 为什么"加 CPU"救不了 IO 瓶颈
这是运维最常花冤枉钱的地方。IO 瓶颈的本质是**进程在等磁盘,不是等 CPU**。你加了 16 个核,进程照样在等那根磁头转过来、等那块闪存写完。**CPU 增加不改变磁盘的物理速度**,唯一的变化是 load 数字看起来"更合理"了(因为可运行进程少了),但业务延迟纹丝不动。
**正确的心智模型**:磁盘是水管,CPU 是水龙头。水管细了,你开再多的水龙头(加 CPU),水龙头也不出水(进程照样等)。**该换粗水管(换盘),不是多开水龙头。**
### 一个判断"是不是 IO 瓶颈"的快检
不想分析太细,先跑一条命令做初筛:
```bash
top -bn1 | grep -E "%Cpu|wa"
```
```text
%Cpu(s): 2.1 us, 1.3 sy, 0.0 ni, 53.4 id, 43.0 wa, 0.0 hi, 0.2 si, 0.0 st
```
`wa > 20%` 基本就锁定 IO 了。**`wa`(iowait)是"CPU 空闲但等着 IO 完成"的时间比**,它高说明 CPU 在给磁盘"干等"。看到 `wa 43%`,别犹豫,进第二章 `vmstat`。
### 6 症状决策树总览
一张图记住 IO 排障的 6 条路径(对应 6 种典型症状):
```
症状 1:load 高,CPU 闲(%wa > 20%)
└─→ iowait 真凶 → 查 iostat 设备级 → 定位盘
症状 2:应用卡,但 iostat %util 低 / 100% 但不慢
└─→ NVMe 假饱和 / 队列合并 / NFS → 看 rMB/s + await
症状 3:D 状态进程堆积(ps 里一堆 D)
└─→ 内核不可中断睡眠 → wchan 定位函数 → 盘坏/驱动
症状 4:随机小 IO vs 顺序大 IO
└─→ r/s 高而 rMB/s 低 = 随机读 → HDD 灾难
症状 5:单一进程 vs 全局(iowait 高但 iostat %util 低)
└─→ 单进程抢 IO → iotop/pidstat 揪元凶
症状 6:缓存回写压垮盘(flush/jbd2 占顶)
└─→ 内存脏页回写风暴 → vm.dirty_ratio 调优
```
下面六章就顺着这 6 条路径逐个展开,每章都是"症状 → 命令 → 判读 → 决策"。
## 二、60 秒判断:vmstat 1 的 r/b/wa 三列
IO 排障第一步永远是 `vmstat 1`,60 秒就能定性。它比 `top` 更干净,直接给你"系统是卡在 CPU 还是卡在 IO"的答案。
```bash
vmstat 1 5
```
**一次典型的生产事故输出**(这是当年那台"重装两次"的机器):
```text
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
r b swpd free buff cache si so bi bo in cs us sy id wa st
3 14 2048 102356 110024 889204 0 0 0 112 102 180 2 1 55 42 0
4 15 2048 102180 110024 889240 0 0 0 2080 105 190 2 1 54 43 0
5 16 2048 101876 110024 889312 0 0 0 4152 108 195 1 1 53 45 0
2 14 2048 101900 110024 889260 0 0 0 880 104 182 2 1 56 41 0
```
**怎么读这三列:**
| 列 | 含义 | 判读 |
|---|---|---|
| **r** | 可运行进程数(等 CPU) | 持续 > 核数 → CPU 不足 |
| **b** | 不可中断睡眠进程数(等 IO) | **持续 > 1 就是 IO 瓶颈的强信号** |
| **wa** | CPU 等待 IO 完成的时间百分比 | > 20% 即 IO 有问题,> 50% 是瓶颈 |
上面这份输出:`b` 稳定在 14-16(有十多个进程在等 IO),`wa` 稳定在 41-45%,`us` 只有 2%,`idle` 还有 53%。**CPU 明明闲着一半,却有十几个进程在排队等磁盘——这就是"load 高 CPU 闲"的铁证。** 方向确定:磁盘 IO,不是 CPU。
### 两个补充信号:swap 与 bi/bo
- **`si`/`so` 持续非零** → 内存在换页,这是"swap 抖动"的源头,会跟 IO 互相踩踏(见事故二)
- **`bo` 飙高** → 大量写回(可能脏页回写或 sync),结合 `wa` 高一起看
- **`bi` 高但 `bo` 正常** → 读密集,多为随机读,重点查 `iostat` 的 `r/s` 与 `r_await`
**决策**:`vmstat` 只能定性"是 IO 问题",不能定位"是哪块盘、哪个进程"。定性后立刻进第三章 `iostat`,别在 vmstat 上恋战。
### 再补三个易被忽略的字段
`vmstat` 还有几列能帮你缩小范围:
| 列 | 含义 | 判读 |
|---|---|---|
| **cs** | 上下文切换次数/秒 | 飙高但 r 不高 → 进程频繁让出 CPU,多为锁等待或 IO 等待 |
| **in** | 中断次数/秒 | 飙高 → 硬件中断风暴,配合 `si` 看是否软中断 |
| **bi / bo** | 块读/写 /秒 | bi 高→读密集,bo 高→写密集,结合 wa 分主次 |
举个区分例子:如果 `cs`(上下文切换)很高而 `r` 不高、`wa` 也不高,那大概率不是 IO 也不是 CPU,而是**锁竞争**(比如数据库行锁、JVM 锁)。`cs` 是排除法里的一把好刀:**排除掉 CPU 和 IO 之后,`cs` 高基本就是锁或调度问题**。
### vmstat 与 top 的配合
`vmstat` 看的是系统整体,`top` 看的是 CPU 细分。两者结合才能分清"是磁盘慢,还是应用在等别的东西":
```bash
top -bn1 | head -5
```
```text
%Cpu(s): 2.1 us, 1.3 sy, 0.0 ni, 53.4 id, 43.0 wa, 0.0 hi, 0.2 si, 0.0 st
```
注意 `wa 43.0` 和 `id 53.4`:CPU 一半在等 IO,一半真的空闲。**`us + sy` 只有 3.4%,应用几乎没在烧 CPU。** 这说明瓶颈在磁盘等待,应用是"被 IO 卡住"而不是"跑不动"。
**一个反直觉细节**:`wa` 高不代表 CPU 在忙,恰恰相反,`wa` 高说明 CPU **闲着没事干**。所以看到 `wa 43%` 去扩 CPU 是完全错误的方向——你是在给一个已经闲着的组件加资源。
## 三、系统级:iostat -x 1 逐字段判读
`iostat -x 1` 是 IO 判读的核心工具。`-x` 展开所有字段,`1` 每秒采样。**重点不是看那一串数字,而是看趋势和相对关系。**
```bash
iostat -x 1 5
```
**先看设备级概览**(生产库节点,机械盘):
```text
Device r/s rkB/s rrqm/s %rrqm r_await w/s wkB/s wrqm/s %wrqm w_await aqu-sz rareq-sz wareq-sz await %util
sda 12.00 128.00 0.00 0.00 180.00 2.00 64.00 0.00 0.00 95.00 12.30 10.67 32.00 165.00 100.00
sdb 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00
```
### 逐字段判读表
| 字段 | 全称 | 判读标准 | 关键点 |
|---|---|---|---|
| **%util** | 设备忙时比 | > 80% 开始警惕,100% 也不一定饱和 | **NVMe 要警惕假 100%(见事故四)** |
| **await** | 平均 IO 等待 | HDD > 20ms 慢,> 200ms 爆 | **分盘种,见阈值矩阵** |
| **r_await** | 读等待 | 读延迟,随机读场景最重要 | 单独看,别被 w_await 平均掉 |
| **w_await** | 写等待 | 写延迟,fsync 敏感场景 | 和 r_await 一起看 |
| **aqu-sz** | 平均队列长度 | > 设备并发能力即排队 | 高 await + 高 aqu-sz = 队列堵死 |
| **rrqm/s** | 读请求合并率 | 高 = 顺序读被合并 | 合并率高说明顺序 IO,低说明随机 |
| **rareq-sz** | 平均读请求大小 | > 64K 顺序,< 16K 随机 | **判断随机 vs 顺序的核心** |
| **rkB/s** | 读吞吐 | 结合 rareq-sz 判断类型 | 看带宽是否到设备上限 |
### 判读三步法
**第一步:看 `rareq-sz` / `wareq-sz` 判断 IO 类型。**
- `rareq-sz` 只有 8-16K(上面输出是 10.67K)→ **随机小 IO**。对机械盘这是灭顶之灾,因为每次都要寻道
- `rareq-sz` > 64K → 顺序大 IO,能发挥盘的全部带宽,不用太担心
- 上面那份输出 `rareq-sz 10.67` + `r_await 180ms` + `%util 100%`:**随机读把机械盘彻底打穿了**,每次读都要寻道,延迟 180ms。这是数据库随机 IO 压垮机械盘的典型画面(完整复盘见事故三)
**第二步:看 `await` 和 `%util` 的相对关系。**
- `%util 100%` + `await` 高 → 设备真的忙,队列在排队,是瓶颈
- `%util 100%` + `await` 低(NVMe 常见)→ **假饱和**,设备其实没到极限,业务慢另有原因
- `%util` 低 + `await` 高 → 单盘多队列或 NFS,设备没满但请求等得久(对应症状 5)
**第三步:看 `aqu-sz`(队列深度)。**
- 高 `aqu-sz` + 高 `await` = 队列在堆积,请求排长队,**这是瓶颈的铁证**
- 高 `aqu-sz` + 低 `await` = 设备并发能力强(NVMe),排队但消化快,未必是瓶颈
**一个必背的组合**:`r_await` 高但 `w_await` 低 → 读是问题;`w_await` 高但 `r_await` 低 → 写是问题(多为脏页回写或 fsync,见事故五);两者都高 → 全面饱和。
### iostat 判读 4 个实战细节
**细节 1:`-x` 的 `%util` 是"忙时比",不是"利用率"。**
`%util = 设备忙的时间 / 总时间`。一个请求都没排队时,%util 也可能接近 100%,因为它在持续服务请求。**判瓶颈要看 `await` 是否同步高**,%util 单独看会骗人(尤其 NVMe)。
**细节 2:`aqu-sz`(平均队列长度)要跟设备的"并发能力"比。**
机械盘并发能力约 1(一个磁头一次一个请求),`aqu-sz > 2` 就开始排队;NVMe 并发能力几百上千,`aqu-sz 50` 也不算堵。**队列长度是相对的,必须分盘种。**
**细节 3:`rrqm/s` 和 `wrqm/s` 是"合并率"不是"请求数"。**
合并率高说明块层把相邻请求并成一个了,是顺序 IO 的特征。如果 `rrqm/s` 很低但 `r/s` 很高,说明请求彼此不挨着——**随机 IO,机械盘灾难**。`%rrqm = rrqm / (rrqm + r)` 超过 80% 基本是顺序流。
**细节 4:`avgrq-sz`(在旧版 `iostat` 里叫 `avgqu-sz` 旁边那列)是平均请求大小。**
`avgrq-sz` 单位是扇区(512B),比如 `avgrq-sz 20` 就是 10KB。**小于 32(16KB)基本是随机小 IO**,大于 128(64KB)是顺序大 IO。这个字段配合 `rrqm` 是判断"随机 vs 顺序"最快的一对组合。
### iostat 读数的"采样间隔"陷阱
`iostat -x 1` 是每秒一次,但 `iostat` 的字段是**两次采样之间取平均**。第一次输出往往是自启动以来的累计平均,**不准**,要跳过第一行。正确姿势:
```bash
iostat -x 1 3 # 采样 3 秒,第 1 秒是启动累计均值,看第 2、3 秒
```
**只看第一次的输出是新手最常犯的错**:第一次是"开机到现在的平均",可能把 3 天前的 IO 峰混进来,`await` 被拉高,误导判断。**看第 2 秒及以后,才是"当前真实状态"。**
### 多盘与分区:先 `iostat -x` 看全部,再 `iostat -xm 1` 深挖
机器上可能挂了很多盘。先不加参数看全貌,快速锁定"哪块盘异常":
```bash
iostat -x 1 | grep -vE "loop|ram|zram" | grep -vE "Device|^$"
```
`grep` 把 loop/ram 这类虚拟盘过滤掉,只看真盘。锁定问题盘后,再用 `-m`(单位切到 MB)和 `-p`(带分区)看细节:
```bash
iostat -xm 1 5 /dev/sda
iostat -xm 1 5 -p sda # 看 sda1/sda2 分区,判断是数据分区还是日志分区在忙
```
**分区级视角很重要**:有时候整盘 `%util 100%`,但深挖发现是 `sda1`(日志分区)在写,`sda2`(数据分区)其实还好。分盘不彻底时,分区级能帮你把"谁拖累谁"看清楚。
## 四、进程级:iotop -oPa 与 pidstat -d 1
`iostat` 告诉你"哪块盘出问题",但"哪个进程在作恶"要靠 `iotop` 和 `pidstat`。**先 iostat 定盘,再 iotop 定进程,顺序别反。**
### iotop 的两个关键参数
```bash
iotop -oPa
```
- `-o`:只显示**正在执行 IO** 的进程(otherwise 的进程会淹没你)
- `-P`:按**进程**而非线程聚合(想细看线程去掉 -P)
- `-a`:显示**累计** IO,而不是每秒变化——累计值更稳定,不会一闪而过
**典型输出**(生产事故,定位到元凶):
```text
Total DISK READ : 48.60 M/s | Total DISK WRITE : 2.34 M/s
Actual DISK READ: 48.60 M/s | Actual DISK WRITE: 2.30 M/s
TID PRIO USER DISK READ DISK WRITE SWAPIN IO> COMMAND
1234 be/4 backup 48.56 M/s 0.00 B/s 0.00 % 99.99 % rsync -a --delete /data/ /backup/
987 be/4 postgres 0.00 B/s 2.28 M/s 0.00 % 2.10 % postgres: walwriter
1 be/4 root 0.00 B/s 0.00 B/s 0.00 % 0.00 % [systemd](https://www.freedesktop.org/software/systemd/man/latest/)
```
一眼锁定:**PID 1234 `rsync` 在读 48.56 M/s,IO 占 99.99%**。这就是元凶。`rsync -a --delete` 没有限速,正在全量扫描 `/data` 并拷贝,把机械盘的读带宽吃干抹净(完整复盘见事故一)。
**判读口径**:`IO>` 列显示进程占用 IO 时间的百分比。**如果某个进程 `IO>` 持续超过 30%,它基本就是 IO-bound 的元凶**,可以定性为"这个进程在打盘"。要精确知道它占整个磁盘带宽多少,看 `DISK READ/WRITE` 与 `Total` 的比值。
### pidstat -d 1 补充确认
`iotop` 需要 root 且依赖内核 IO 统计,个别环境会报 "Cannot execute" 或看不到数据,用 `pidstat -d` 兜底:
```bash
pidstat -d 1 5
```
```text
12:00:01 UID PID kB_rd/s kB_wr/s kB_ccwr/s iodelay Command
12:00:02 500 1234 49640.00 0.00 0.00 512 rsync
12:00:02 500 987 0.00 2334.00 0.00 3 postgres
```
- **`kB_rd/s` / `kB_wr/s`**:进程的读写带宽
- **`iodelay`**:进程等待 IO 的毫秒数(**这个字段经常被忽略,却是"进程在等盘"的直接证据**)
上面 `rsync` 的 `iodelay 512`,说明它在 1 秒内平均等了 512ms 磁盘——**这个进程超过一半时间在等 IO**,是铁打的 IO-bound。对比 `postgres` 只有 3ms,完全正常。
**决策**:iotop 抓到元凶后,如果它不是核心业务,直接 `ionice` 降级或限速(见第十章);如果是核心业务,说明它是真的需要那么多 IO,得换盘或优化 SQL,别去压它。
### iotop 每一列都在说什么
`iotop` 的列比很多人以为的更有用,逐列拆开:
| 列 | 含义 | 判读 |
|---|---|---|
| **TID** | 线程 ID | 加 `-P` 变进程 ID,线程场景去掉 -P |
| **PRIO** | IO 调度优先级 | `be/4` 是 best-effort 4,`rt` 是 realtime,`idle` 是空闲级 |
| **DISK READ** | 读带宽 | 元凶进程这里通常高得离谱 |
| **DISK WRITE** | 写带宽 | 写元凶(flush/jbd2)看这里 |
| **SWAPIN** | 换入占比 | 非零说明在从 swap 读,内存压力 |
| **IO>** | IO 时间占比 | **> 30% 即 IO-bound,> 80% 基本是它在打盘** |
| **COMMAND** | 进程名 | 元凶的名字,一目了然 |
**`PRIO` 列能帮你识破一个坑**:如果一个进程显示 `rt`(realtime),说明它被 `ionice -c1` 设成了实时优先级,会**抢占**所有普通 IO,业务进程被它饿死。遇到这种情况,`ionice -c0 -p ` 把它拉回 none,或者 `ionice -c2 -n7` 降级。
**`SWAPIN` 列**:非零说明进程在从 swap 读内存页,这是内存压力导致的额外 IO——**不是进程自己要读盘,是内存不够在借盘当内存**。看到 `SWAPIN` 高,先解决内存,别冤枉进程在打盘。
### 用 pidstat 区分"进程真的在打盘"还是"在等盘"
`pidstat -d` 的 `kB_rd/s` 大,说明进程**发起了大量 IO**;但有些进程 `kB_rd/s` 很小却 `iodelay` 很大,说明它是**在等别人抢走的盘**(症状 5 的典型)。这两者处理方式完全不同:
| 情况 | kB_rd/s | iodelay | 判断 | 处理 |
|---|---|---|---|---|
| 元凶进程 | 大 | 大 | 它在打盘 | ionice 限它 |
| 受害者进程 | 小 | 大 | 它被抢盘 | 处理元凶,别动它 |
| 正常进程 | 正常 | 小 | 无问题 | 不用管 |
**`iodelay` 是"受害者识别器"**:一个 `kB_rd/s` 只有 2M 但 `iodelay` 300ms 的进程,它自己没打盘,却被别人打盘拖累。这时你去 `ionice` 它完全没用,要找那个 `kB_rd/s` 大的元凶。**判"谁是元凶"看 `kB_rd/s`,判"谁在受害"看 `iodelay`。**
### iotop 的 3 个坑
1. **必须 root**:非 root 只能看自己的进程,`iotop` 会提示权限不足。生产用 sudo 或配 sudoers。
2. **`-o` 参数别省**:不加 `-o` 会列出几百个"0 IO"的进程,真正的元凶被淹没。先 `-o` 缩小到"正在动 IO 的",再细看。
3. **`-k` 或 `-K` 单位**:默认动态单位(K/M/G),脚本化抓取用 `-k`(固定 KB)或 `-b -n 1`(批处理一次输出)更稳:
```bash
# 批处理一次输出,适合脚本与告警(不进入交互界面)
iotop -oPa -b -n 1 -k
```
## 五、D 状态进程:ps 定位卡在内核函数
如果 `vmstat` 的 `b` 列持续很高、`iowait` 也高,但 `iotop` 里找不到明显元凶,那大概率是**一堆进程卡在 D 状态**。这是最让人头皮发麻的场景:进程杀不掉、重启失败、系统像僵尸一样半死不活。
```bash
ps -eo state,pid,ppid,cmd,wchan:30 | awk '$1 ~ /D/'
```
`state` 列是 D 的进程就是不可中断睡眠。`wchan` 列显示它卡在哪个内核函数。
**典型输出**(机械盘损坏事故,多进程卡 D):
```text
STATE PID PPID COMMAND WCHAN
D 2103 1 /usr/sbin/mysqld io_schedule
D 2118 1 /usr/sbin/mysqld blk_mq_submit_bio
D 2150 1 kworker/u8:1-flush-8:0 blk_mq_flush_plug_list
D 3011 2103 /usr/bin/rsync --bwlimit=0 io_schedule
D 4122 1 systemd-journald wb_workfn
```
### wchan 判读:卡在哪个内核函数
| wchan | 含义 | 提示 |
|---|---|---|
| `io_schedule` | 通用 IO 调度等待 | 盘在响应,只是慢,可等 |
| `blk_mq_submit_bio` | 提交 bio 阻塞 | 块层排队,队列满 |
| `blk_mq_flush_plug_list` | 刷 plug 列表阻塞 | 写回阻塞,脏页多 |
| `wb_workfn` | 内核回写线程 | **脏页回写风暴**(症状 6) |
| `jbd2_log_wait_commit` | ext4 日志提交等待 | **ext4 日志翻倍**(事故五) |
| `__wait_on_bit` | 等锁位 | 多为文件系统锁,NFS 常见 |
**关键区分**:D 状态有两种结局。
1. **盘活着,只是慢**:进程在 `io_schedule` 等真正的 IO,磁盘还在响应,等一会儿能完成
2. **盘死了/掉盘/驱动崩**:进程卡在 IO 层**永远**等不到完成,`kill -9` 无效(D 状态不可被信号打断),只能重启机器
**怎么区分**:看 D 状态进程**是否在增长**、`iostat` 是否还在更新(`iostat` 数值不动 = 盘已经没响应了)。如果 D 进程越积越多且 `iostat` 卡住不刷新,别犹豫,这是盘挂了,联系硬件厂商换盘,同时准备重启。
**一个救急判断**:D 状态进程 `kill -9` 后 `ps` 里**还在**,就是它。D 状态连 SIGKILL 都无视,普通 SIGTERM/SIGKILL 统统无效。这时候不要反复杀进程浪费时间,直接处理根因——要么等盘恢复,要么重启。
### D 状态 vs 正常慢:别把"慢"误判成"死"
有个必须分清的边界:**D 状态进程增多 ≠ 系统死了。** 高并发下几十个进程同时等 IO,进入 D 状态是**正常**的排队现象,只要盘在响应就能恢复。区分"排队"与"死亡"看三点:
1. **看 D 进程数是否在增长**:稳定不涨 = 排队等盘,会消化;越积越多 = 盘跟不上,越来越糟
2. **看 `iostat` 数值是否还在刷新变化**:还在变 = 盘活着;数值冻结 = 盘掉了
3. **看 `wchan` 是否集中在 IO 函数**:`io_schedule`/`blk_mq_submit_bio` 是 IO 排队;如果突然变成 `__wait_on_bit` 或 `mutex_lock`,可能是文件系统或 NFS 层死锁,性质不同
### D 状态进程的常见来源清单
D 状态不是"神秘现象",它几乎总来自以下几类,见到 D 先对号入座:
| 来源 | 典型进程 | 为什么进 D |
|---|---|---|
| 数据库随机读 | mysqld / postgres | 随机页读取,等寻道 |
| 备份/拷贝 | rsync / cp / tar | 读大量小文件,随机读 |
| 脏页回写 | flush-8:0 / wb_workfn | 内核回写线程被写堵 |
| 日志提交 | jbd2/sda-8 | ext4 日志提交等落盘 |
| swap 换页 | kswapd0 / kworker | 内存不足,换页等盘 |
| 网络文件系统 | nfs 相关 | NFS 后端盘慢,远端等待 |
**定位技巧**:看 `wchan` 列 + 进程名,基本能猜个八九不离十。比如 `flush-8:0` 是回写线程、`jbd2/sda-8` 是 ext4 日志——这两个出现,说明是"写"的问题不是"读"的问题。
### 用内核 hung task 机制兜底
如果 D 状态进程**超过 120 秒**(内核默认 `hung_task_timeout_secs`),内核会打印一次堆栈到 `dmesg`,这是免费的诊断信息:
```bash
# 查看当前阈值
cat /proc/sys/kernel/hung_task_timeout_secs
# 看内核有没有打印 hung task
dmesg -T | grep -i "hung task" | tail -20
```
```text
[Thu Oct 29 02:14:35 2026] INFO: task mysqld:2103 blocked for more than 120 seconds.
[Thu Oct 29 02:14:35 2026] Not tainted 4.18.0-477.10.1.el8
[Thu Oct 29 02:14:35 2026] "echo 0 > /proc/sys/kernel/hung_task_timeout_secs" disables this message.
[Thu Oct 29 02:14:35 2026] task:mysqld state:D stack: 0 pid: 2103 ppid: 1
[Thu Oct 29 02:14:35 2026] Call Trace:
[Thu Oct 29 02:14:35 2026] [] __schedule+0x2b0/0x700
[Thu Oct 29 02:14:35 2026] [] io_schedule+0x20/0x40
[Thu Oct 29 02:14:35 2026] [] blk_mq_submit_bio+0x1b0/0x240
```
`Call Trace` 里 `io_schedule` + `blk_mq_submit_bio` 的组合,明确告诉你是**块层 IO 排队**,不是死锁。这一条 dmesg 胜过你猜半小时。
## 六、块设备层:blktrace 随机 vs 顺序、合并率
`iostat` 和 `iotop` 给的是**聚合**数据。当你想知道"IO 到底是不是随机、队列到底多深、请求有没有被合并",就要下探到块设备层——`blktrace` 是块层的 X 光片。
### 抓取与解析
```bash
# 抓 5 秒 sda 的块层事件,写入当前目录
blktrace -d /dev/sda -o /tmp/bt -w 5
# 解析并统计
blkparse /tmp/bt.sda -i /tmp/bt.sda.blktrace.0 -f "%D %2c %8s %5T.%9t %5p %2a %3d\n" > /tmp/bt_parse.txt
```
看解析后的每行:`%T` 时间、`%p` 进程 PID、`%a` action(如 `Q` 请求入队、`D` 设备提交、`C` 完成)、`%d` 设备号。
### 随机 vs 顺序的关键判读
**看 action 序列和扇区号分布**:
- `Q`(入队)到 `C`(完成)的时间差 = 单请求真实延迟
- 连续多个 `Q` 的扇区号**相邻** → 顺序 IO
- 连续多个 `Q` 的扇区号**跳跃很大** → 随机 IO
```text
# 顺序 IO:扇区号连续递增
8,0 Q 12345 0.001234 2103 R 12000 + 32 [mysqld]
8,0 Q 12346 0.001240 2103 R 12032 + 32 [mysqld]
8,0 Q 12347 0.001246 2103 R 12064 + 32 [mysqld]
# 随机 IO:扇区号到处跳
8,0 Q 23456 0.002345 2103 R 987654 + 8 [mysqld]
8,0 Q 23457 0.002351 2103 R 12045 + 8 [mysqld]
8,0 Q 23458 0.002360 2103 R 777000 + 8 [mysqld]
```
第一种是顺序流(好),第二种是随机风暴(机械盘杀手)。**扇区号跳跃 + 请求大小小(+8 = 4KB)+ 大量 Q**,基本就是随机小 IO,也就是"数据库 B+ 树页读取"的典型形态。
### 合并率:看 %rrqm 和 blkparse 的 Q→D 比例
`iostat` 里 `%rrqm`(读请求合并率)高说明请求被块层合并了。合并的本质是**把多个相邻扇区的请求合成一个大请求**,对机械盘尤其重要——一次寻道服务更多数据。
```bash
# 用 iostat 的合并率快速判断
iostat -x 1 | grep -E "Device|sda"
```
- `%rrqm` > 80% → 请求高度合并,实际是顺序 IO,不用担心
- `%rrqm` < 20% 且 `rareq-sz` 小 → 请求分散且小,**随机读,机械盘大忌**
**实操建议**:`blktrace` 信息量极大,别当常规工具,只在 `iostat` 判读有歧义、或要确认"随机 vs 顺序"、或做存储调优前后对比时用。日常用 `iostat` 的 `rareq-sz` + `rrqm` 两个字段就能完成 90% 的判断。
### blkparse 的聚合统计(比翻日志高效)
`blkparse` 直接翻原始日志太累,用它的汇总统计一次看清全貌:
```bash
# -f 指定格式,-s 显示统计摘要,-i 指定输入
blkparse -i /tmp/bt.sda.blktrace.0 -f "%D %2c %8s %5T.%9t %5p %2a %3d\n" -s
```
```text
Total: 123456 (reads: 98000, writes: 25456)
Merged: 0 (reads: 0, writes: 0)
Queue: 123456, Complete: 123456
Read: 98000 reqs, 480000 KB (4.9 KB/req) ← 平均请求 4.9KB = 随机小读
Write: 25456 reqs, 320000 KB (12.5 KB/req)
IO depths: 1, 2, 4, 8, 16, 32, 64 ← 队列深度分布
```
**看 `KB/req` 一列**:`4.9 KB/req` 说明平均每次读只有 5KB,**典型的随机 4K/8K 页读取**。配合 `IO depths` 看队列深度分布,能知道"到底排了多深的队"。
### 用 seektime 工具测盘真实延迟
如果怀疑机械盘真的坏了(而不是被 IO 打满),`seektime` 能测出盘的随机寻道延迟:
```bash
# 测 1000 次随机读的延迟分布(需 root,会对盘做随机读)
seektime /dev/sda --count=1000
```
```text
Reads: 1000, avg 12.5ms, min 0.8ms, max 380ms
95th percentile: 220ms ← 95% 的随机读都超过 220ms,盘在寻道风暴
```
`max 380ms`、`95th 220ms` 这种分布,说明这块机械盘的随机寻道已经彻底退化(正常应在 8-12ms 中位数)。**这不是调优能救的,是盘到了物理寿命或性能极限。**
### 一个更轻量的替代:iostat 的 svctm 与 await 比值
`blktrace` 要装工具、要 root、要抓文件,生产上有时不便。有个轻量近似:**`await`(总等待)与 `svctm`(服务时间)的比值 ≈ 队列因子**。`await` 远大于 `svctm` 说明请求大量在排队而非被服务。用这个能快速判断"是盘慢还是队列堵",不需要 blktrace。
### 随机 vs 顺序的一个反直觉结论
很多人以为"随机 IO 一定比顺序 IO 慢",其实**只在机械盘上成立**。对 SSD/NVMe,随机和顺序的差距大幅缩小:
| 盘种 | 随机 4K 读 IOPS | 顺序读带宽 | 差距 |
|---|---|---|---|
| HDD | 150 IOPS | 150MB/s | 随机比顺序慢约 1000 倍(寻道) |
| SATA SSD | 80K IOPS | 500MB/s | 差距缩小到个位数 |
| NVMe | 500K IOPS | 3.5GB/s | 几乎无差距 |
**结论**:判断"随机 vs 顺序"的紧迫性,要分盘种。机械盘上随机读是灾难,必须处理;NVMe 上随机读不是问题,别为它焦虑。**这又回到阈值矩阵——判读标准必须跟着硬件走。**
### 用 iostat 的 rareq-sz 快速估算"盘还能撑多少 IOPS"
这是我在事故三里用到的实用心算:
```bash
# 机械盘随机 IOPS 上限估算
# 随机读延迟约 8-12ms(含寻道),单次 4K
echo "机械盘随机 IOPS 上限 ≈ 1000 / 平均寻道时间(ms) ≈ 1000/10 = 100 IOPS"
```
所以当你看到 `r/s` 达到 5000,而机械盘理论上限只有 100-200,**需求超上限 25-50 倍**,这已经不只是"慢",是"物理上不可能完成"。看到这个数量级,就别纠结调优,直接上索引或换 SSD。
## 七、cgroup v2 blkio:容器/进程组级 IO 监控与限流
前面都是整机视角。现在大部分负载跑在容器里,**容器之间互相抢 IO** 是"症状 5:单一进程 vs 全局"的最常见来源。一个容器的备份脚本,能把同一宿主机上所有容器拖垮,而 `iostat` 只看得到 `sda` 100%,看不到是谁。
cgroup v2 把块 IO 控制器合并到 `io` 子目录,配合 `memory` 一起管。**注意:cgroup v2 的 IO 控制器路径是 `io.max`/`io.weight`,不是 v1 的 `blkio.*`。**
### 7.1 看容器真实 IO
```bash
# 找到容器 cgroup(以某容器 cid 为例)
ls /sys/fs/cgroup/system.slice/docker-.scope/io.stat
# 看 IO 统计(rbytes/wbytes 累计,rrios/wrios 次数)
cat /sys/fs/cgroup/system.slice/docker-.scope/io.stat
```
```text
8:0 rbytes=48321085440 wbytes=2048000 rios=3026320 wios=1000
```
- `rbytes`/`wbytes`:累计读写字节
- `rios`/`wrios`:累计读写次数
- **`rbytes / rios` = 平均请求大小**,用来判断容器是随机还是顺序(和整机 `rareq-sz` 一个道理)
更省事的做法:`docker stats` 或容器平台自带监控,但它们往往只看 CPU/内存,**对 IO 只有带宽没有队列延迟**,判断"容器是不是 IO-bound"不够。要看延迟,还得回到 `iostat`。
### 7.2 用 io.max 限流(关键操作)
**限制某个容器的读带宽**(写同理用 `rbps`/`wbps`,IOPS 用 `riops`/`wiops`):
```bash
# 限读 100MB/s、写 50MB/s
echo "8:0 rbps=104857600 wbps=52428800" > /sys/fs/cgroup/system.slice/docker-.scope/io.max
# 限制 IOPS(随机小 IO 场景限 IOPS 比限带宽有效)
echo "8:0 riops=1000 wiops=500" > /sys/fs/cgroup/system.slice/docker-.scope/io.max
# 查看当前限制
cat /sys/fs/cgroup/system.slice/docker-.scope/io.max
```
**为什么随机小 IO 要限 IOPS 而不是带宽**:机械盘随机 4K 读每秒顶多 200 次寻道,就算你限了带宽 100MB/s,4K 小请求照样能把盘打满寻道。限 IOPS 直接卡住"寻道次数",这才是机械盘的命门。
### 7.3 用 io.weight 做权重分配(不硬限,按优先级分)
硬限流(io.max)适合"绝对不能超"的场景。如果是"想保证核心容器优先、但别让闲容器闲死",用权重:
```bash
# 权重范围 1-10000,默认 100。核心库设 500,备份容器设 10
echo 500 > /sys/fs/cgroup/system.slice/docker-.scope/io.weight
echo 10 > /sys/fs/cgroup/system.slice/docker-.scope/io.weight
```
**权重不会硬性封顶**,它只在有竞争时按比例分带宽。核心库权重 500、备份权重 10,在争抢时大约 50:1 分,但备份容器独处时依然能用满。这比硬限流更符合"业务弹性"。
### 7.4 memory.max 与 IO 的联动(易忽略的坑)
内存和 IO 是联动的一对:**`memory.max` 设小了,容器会频繁回收页缓存 → 触发脏页回写 → 压垮 IO**。这正好对应"症状 6:缓存回写压垮盘"。
```bash
cat /sys/fs/cgroup/system.slice/docker-.scope/memory.max
cat /sys/fs/cgroup/system.slice/docker-.scope/memory.events
```
```text
max
low=0
high=max
```
`memory.events` 里的 `oom_kill`、`max` 计数能告诉你容器是不是被内存逼到墙角。**如果 `oom_kill` 在涨,容器 IO 慢的根因可能是内存不足导致疯狂回写,而不是磁盘真的坏了。** 这类问题加磁盘没用,得调 `memory.max` 或 `vm.dirty_ratio`。
### 用 BPF 或容器平台看实时 IO 分布(进阶)
`io.stat` 是累计值,想实时看每个容器的 IO 速率,可以差分采样:
```bash
# 两次采样相减得速率(rbytes 增量 / 时间间隔)
watch -n 2 'cat /sys/fs/cgroup/system.slice/docker-.scope/io.stat'
```
如果机器上有 BPF 工具(`bcc`/`bpftrace`),`biopattern` 能直接告诉你每个设备的随机 vs 顺序比例,`biotop` 能按进程实时排名 IO,比 `iotop` 更底层(直接从块层抓,不依赖进程 IO 统计):
```bash
# 看每 3 秒最活跃的 IO 进程(含 cgroup 信息)
biotop -C -d 3
# 看设备随机/顺序比例(%RND 高 = 随机为主)
biopattern 1 5
```
```text
Blockdev Reads Writes Read-MB Write-MB %RNDR %RNDW
sda 5000/s 200/s 25M 1M 95% 40%
```
`%RNDR 95%` 告诉你:**这块盘 95% 的读是随机的**,机械盘必死。这是"症状 4:随机小 IO vs 顺序大 IO"最直接的数据。
### cgroup v2 一个常踩的坑:IO 控制器没启用
有些内核发行版默认没启用块 IO 控制器,`io.max` 写了也不生效。**先验证**:
```bash
# 写之前看当前限制(如果提示 No such file,说明控制器没挂)
cat /sys/fs/cgroup/system.slice/docker-.scope/io.max
# 检查内核启动参数有没有 enable 块 IO 控制器(cgroup_no_io 会禁用它)
cat /proc/cmdline | grep -o 'cgroup_no_io[^ ]*'
```
如果控制器被禁用,需要在 grub 启动参数去掉 `cgroup_no_io=1` 并重启,`io.max` 才能生效。**这是 cgroup v2 IO 限流最常见的不生效原因,写配置前先验证,别白忙活。**
## 八、三种盘阈值矩阵:HDD/SSD/NVMe 判读差异
**这是全文最反直觉、也最容易被忽视的一章。** 很多运维拿着 `iostat` 的一套阈值(`await > 20ms` 就报警)去套所有盘,结果 NVMe 天天误报、机械盘反而漏报。**三种盘的判读阈值完全不同,不能用一个模板。**
### 8.1 阈值矩阵
| 指标 | 机械盘 HDD | SATA SSD | NVMe |
|---|---|---|---|
| **%util 判读** | 有效,> 80% 即饱和 | 需结合带宽 | **基本无效(假饱和陷阱)** |
| **await 正常** | 5-15ms | 0.1-0.5ms | 0.02-0.1ms |
| **await 慢** | > 20ms | > 2ms | > 0.5ms |
| **await 爆** | > 200ms | > 20ms | > 5ms |
| **r_await 关键** | 随机读 > 100ms 即崩 | > 10ms 异常 | 看 rMB/s 更准 |
| **额定 IOPS** | 100-200(随机) | 20K-100K | 500K-1M |
| **额定带宽** | 100-250MB/s | 500MB/s | 3-7GB/s |
| **主要瓶颈** | 寻道(随机 IO) | 控制器/磨损 | **主机接口/队列深度** |
| **判读核心** | **await + 随机小 IO** | **await + 带宽** | **带宽对比额定值** |
### 8.2 机械盘(HDD):await 是命门,随机 IO 是敌人
- **`await > 200ms` 才叫真爆**,20-100ms 是"慢但还能用"
- 机械盘死穴是**随机小 IO**:一次 4K 随机读要 8-10ms 寻道,每秒顶多 100-200 次,**IOPS 是硬上限**。`rareq-sz < 16K` 且 `r/s` 高 = 盘在寻道风暴里
- 机械盘 `await > 200ms` + `r_await > 100ms`,基本可以宣布这块盘已到极限,再好的调优也救不回,只能换 SSD/NVMe 或改随机为顺序
### 8.3 SATA SSD:await 和带宽都要看
- `await > 2ms` 已算异常,`> 20ms` 基本在等控制器
- SSD 随机读没有寻道惩罚,IOPS 能到几万,但**带宽**上限约 500MB/s。`rkB/s` 接近 500MB/s 且 `await` 涨 = 接近带宽上限
- SSD 还有个隐性坑:**写入放大**。4K 随机写会先写整个 4K 页但 SSD 以块为单位擦写,实际物理写比逻辑写大好几倍。`w_await` 高而 `wkB/s` 不高,要考虑写入放大
### 8.4 NVMe:别信 %util,信带宽和延迟
**NVMe 的 `%util 100%` 是最大的骗局。** 因为 NVMe 队列极深(可达 64K 并发),设备永远"有请求在跑",`%util` 常年 100%——但它远没到极限。**这是"症状 2:应用卡但盘不忙"的核心**。
- **NVMe 判读看 `rkB/s` / `wkB/s` 是否接近额定带宽**(如 3.5GB/s 的盘,实际到 3GB/s 才算接近上限)
- **NVMe 判读看 `await`**:`> 0.5ms` 已异常,`> 5ms` 基本是队列堵了
- `%util 100%` + `await` 只有 0.1ms + 带宽只有 20% → **假饱和**,设备好得很,业务慢是别的原因(见事故四)
### 8.5 一个必须记住的坑:iostat 的 %util 口径
`%util` 是**设备有请求的时间占比**,不是"设备利用率"。对单队列 HDD,100% 意味着真在忙;对多队列 NVMe,一个队列忙也会拉高 %util,但设备可能只用了 10% 的能力。**判 NVMe 永远别单独看 %util,要看带宽 + await。**
### 8.6 三种盘的实际判读示例
**机械盘随机读打穿**(数据库场景,见事故三):
```text
Device r/s rkB/s r_await aqu-sz rareq-sz await %util
sda 5200.00 41600.00 218.00 56.30 8.00 215.00 100.00
```
读:`rareq-sz 8`(4KB 随机)+ `r/s 5200` + `r_await 218ms` + `%util 100%` → **机械盘在 5200 IOPS 的随机读下彻底崩溃**,每次读等 218ms。这种盘判读**直接看 `r_await` 和 `rareq-sz`**,一眼定罪。
**SATA SSD 接近带宽上限**(大文件顺序写):
```text
Device w/s wkB/s w_await aqu-sz wareq-sz await %util
sdb 900.00 460000.00 12.00 5.20 512.00 12.10 98.00
```
写:`wareq-sz 512`(256KB 顺序写)+ `wkB/s 460000`(460MB/s)→ **接近 SATA SSD 的 500MB/s 带宽上限**,`w_await 12ms` 偏高说明在带宽边缘挣扎。这种盘判读**看带宽是否接近额定 500MB/s**,而不是看 `%util`。
**NVMe 假饱和**(见事故四):
```text
Device r/s rkB/s r_await aqu-sz rareq-sz await %util
nvme0n1 18000.00 800000.00 0.08 42.00 45.00 0.08 100.00
```
读:`%util 100%` 但 `r_await 0.08ms` + `rkB/s 800000`(800MB/s,额定 3.5GB/s 的 23%)→ **设备健康,远未到极限**。`%util 100%` 只是"队列一直有活",不是饱和。这种盘判读**看带宽利用率**,`%util` 完全失效。
### 8.7 告警阈值怎么按盘种配
监控告警最容易踩"一套阈值走天下"的坑。给出按盘种配的参考([Prometheus](https://prometheus.io/docs/)/node_exporter 场景):
| 盘种 | 告警项 | 阈值 |
|---|---|---|
| HDD | `await`(iostat 采样)| > 100ms warning,> 200ms critical |
| HDD | `r_await` | > 100ms warning |
| SSD | `await` | > 5ms warning,> 20ms critical |
| NVMe | 带宽利用率 | > 80% warning,> 95% critical |
| NVMe | `await` | > 1ms warning,> 5ms critical |
| 任意 | 设备故障 | 盘掉线 / smartctl 报错 |
**核心思想**:HDD 用 `await`(延迟敏感),NVMe 用**带宽利用率**(吞吐敏感),SSD 两者折中。千万别给 NVMe 配 `%util > 95%` 告警——那会天天误报(事故四就是这么来的)。
## 九、5 个真实事故复盘
下面 5 个都是我亲手处理过的生产事故,按"症状 → 排查 → 根因 → 修复"完整还原。**这章是全文的灵魂,建议对照前八章边看边回忆。**
### 事故一:备份 rsync 打满盘
**现象**:某天早上业务反馈"所有接口都慢",`load average` 飙到 40 多,但 `%us` 只有 3%。
**排查链**:
```bash
vmstat 1 5 # b 列 14-18,wa 40%+,r 才 3-5 → IO 瓶颈不是 CPU
iostat -x 1 5 # sda %util 100%,await 165ms,rareq-sz 只有 10K → 随机读风暴
iotop -oPa # PID 1234 rsync DISK READ 48.5M/s,IO> 99.99% → 元凶锁定
```
**根因**:备份脚本在早上 9 点全量执行 `rsync -a --delete /data/ /backup/`,没有限速。`rsync` 每次都要打开、读取、比对 `/data` 里几十万个小文件,形成**随机小读**,把机械盘的寻道彻底打穿。`await 165ms` 就是证据——每次读都在等寻道。
**修复**:
```bash
# 1. 立刻限速,把影响降到最低(不中断备份的前提下)
ionice -c3 -p 1234 # 降到 best-effort 空闲级,只吃空闲 IO
# 或更直接限带宽
ionice -c3 rsync -a --delete --bwlimit=20000 /data/ /backup/
# 2. 永久修复:错峰 + 增量,别在业务高峰全量扫
# 定时任务从 09:00 挪到 02:00,并加 --bwlimit 限 20MB/s
```
**经验**:`rsync -a --delete` 不带 `--bwlimit` 是生产大忌。备份必须错峰、限速、增量。**备份是生产事故的头号元凶,没有之一。**
**复盘补充:为什么不是 CPU 也不是网络?**
当时值班同事先怀疑网络(因为"慢"),抓包半天没结果。我用一个命令就排除了网络:`sar -n DEV 1 5` 看到网卡流量正常,而 `vmstat` 的 `b` 列和 `wa` 列才是异常。**判 IO 的诀窍:先看 `b` 列(阻塞进程数),它比任何网络抓包都直接。** 备份这种"读大量小文件"的场景,瓶颈几乎必然在磁盘随机读,不在网。
### 事故二:swap 抖动,kswapd0 占顶
**现象**:一台 16G 的 Java 服务节点,业务偶发卡顿,`top` 里 `kswapd0` 经常排第一,CPU 显示 `si`(软中断)和 `%sys` 都偏高,`iostat` 的 `sda` 写压力大。
**排查链**:
```bash
free -h # available 只剩 800M,swap 已用 2G
vmstat 1 5 # si/so 持续非零(每 5 秒换出几百 MB)→ 在疯狂换页
top -b -n1 # kswapd0 常驻 CPU,%wa 不低
iostat -x 1 # sda w_await 高,wkB/s 波动大 → 换页写回压盘
```
**根因**:内存不足,内核 `kswapd0` 线程疯狂回收内存并把匿名页换到 swap。**swap 本身在机械盘上**,换页的随机写又反过来打盘,形成"内存不足 → 换页 → 磁盘更慢 → 进程等更久 → 内存堆积更多"的**正反馈死循环**。`kswapd0` 占 CPU 顶不是 CPU 问题,是内存+IO 的联动灾难。
**修复**:
```bash
# 1. 临时缓解:降低换页倾向(治标,别当万能药)
sysctl vm.swappiness=10
# 2. 看真实内存压力(psi 内存指标,>50% 即严重)
cat /proc/pressure/memory
# some avg10=68.00 avg60=42.00 avg300=20.00
# 3. 根治:加内存到 32G,或限制 Java 堆(-Xmx8g)
```
**经验**:`kswapd0` 占 CPU 顶,第一反应不该是杀它(它是内核线程杀不掉),而是**看 `/proc/pressure/memory` 和 `free available`**。swap 抖动 + IO 卡顿 = 内存不够,加内存比调任何 IO 参数都有效。
**复盘补充:swap 放在机械盘是双重灾难**
这台机器的 swap 分区恰好也在机械盘上。换页的随机写 + 业务随机读抢同一块盘,两边都慢。我当时的处理:把 swap 挪到一块闲置的 SSD 上(`swapoff /dev/sda2 && mkswap /dev/nvme0n1p3 && swapon /dev/nvme0n1p3`),抖动立减一半。**教训:swap 该放 SSD/NVMe 不该放 HDD**,机械盘做 swap 等于用寻道速度当内存速度。
另一个坑:`vm.swappiness=10` 我只说临时治标,是因为它只影响**匿名页**换出倾向,页缓存回收另有机制。**swap 抖动的根治只有两个:够内存,或限制住吃内存的进程。** 调 swappiness 只是把问题推迟,不是解决。
### 事故三:数据库随机 IO 压垮机械盘
**现象**:MySQL 主库在业务高峰期 `load` 冲高,查询普遍慢 3-5 秒,应用侧连接池打满。
**排查链**:
```bash
iostat -x 1 5 # sda %util 100%,await 185ms,r_await 220ms,rareq-sz 8K
# → 随机读 + 高延迟,机械盘被随机读打穿
iotop -oPa # 多个 mysqld 线程 IO> 80%,DISK READ 总和 40M/s
pidstat -d 1 3 # mysqld 的 kB_rd/s 只有 40M/s,但 iodelay 极高
```
**关键判断**:`rareq-sz` 只有 8K(4K/8K 的页读取),`r/s` 却有 5000+。**机械盘每秒只能做 100-200 次寻道,这里却要求 5000 次随机读**——需求超出物理能力 25 倍,神仙也救不了。
**根因**:数据库某张核心表没有合适的索引,全表扫描产生大量随机 4K/8K 页读取;且数据目录和日志目录在同一块机械盘上,日志的 fsync 写被随机读打断,延迟雪上加霜。
**修复**:
```bash
# 1. 先止血:加索引,把全表扫描变索引查找
ALTER TABLE orders ADD INDEX idx_user_created (user_id, created_at);
# 2. 中期:数据盘和日志盘分开(随机读和顺序写分家)
# /var/lib/mysql(数据) 与 /var/log/mysql(日志)分到不同物理盘
# 3. 长期:换 SSD/NVMe。机械盘不该承载高并发随机读的数据库
```
**经验**:**数据库随机 IO 压垮机械盘,本质是"随机读 IOPS 需求 >> 机械盘物理上限"。** 加索引(降 IOPS 需求)和换 SSD(提 IOPS 供给)是两条腿,只做一条都不够。别只加内存,内存大只会把脏页留在内存,随机读照样打盘。
**复盘补充:一个数字戳穿"加内存"的幻想**
当时有同事建议"把 buffer pool 从 8G 加到 32G,随机读就进内存了"。我用一个数字否了:`iostat` 显示 `r/s 5200`,即每秒 5200 次随机读,假设都在 buffer pool 里,需要每秒喂进 5200 个新页——**buffer pool 只能缓存"重复读"的页,全表扫描的页是一次性的,缓存了也没用**。只要 SQL 是全表扫描(无索引),加多少内存都白搭。**先加索引把 `r/s` 从 5200 降到 200,才是正解。**
### 事故三补充:用 SQL 定位到"哪条语句在扫表"
IO 是表象,SQL 才是根因。MySQL 定位:
```sql
-- 看当前在执行的语句,重点找没走索引的
SHOW FULL PROCESSLIST;
-- 确认全表扫描的查询(type 列 = ALL)
EXPLAIN SELECT * FROM orders WHERE status = 'PENDING';
```
```text
id select_type table type possible_keys key rows Extra
1 SIMPLE orders ALL NULL NULL 12000000 Using where
```
`type = ALL` + `rows 12000000`,就是它——**1200 万行全表扫描,每行都触发一次随机页读**。加了 `idx_user_created` 后 `type` 变 `ref`,`rows` 降到几百,`r/s` 立刻从 5200 掉到 200 以下。**这一步才是治本,换盘只是给"乱扫"买单。**
### 事故四:NVMe %util 100% 但不慢(假饱和)
**现象**:一台跑高并发 API 的 NVMe 服务器,`iostat` 显示 `%util 100%`,报警系统疯狂告警"磁盘饱和",但业务延迟正常、吞吐正常。**值班同事差点按"磁盘满"去扩容。**
**排查链**:
```bash
iostat -x 1 5 # nvme0n1 %util 100%,await 只有 0.08ms,rkB/s 只有 800M
# → %util 100% 但 await 极低、带宽仅 25% → 假饱和!
```
**关键判断**:这块盘额定带宽 3.5GB/s,实际只用了 800M(约 23%);`await` 只有 0.08ms,完全健康。**`%util 100%` 只是"设备有请求在队列里",不代表设备满了。** NVMe 队列极深,设备永远显示忙,但根本没到极限。
**根因**:**这是监控误报,不是故障。** 告警阈值用了机械盘的 `%util > 95%` 模板去套 NVMe,必然误报。业务不慢,因为 NVMe 的 100% util 里其实有大量空闲等待。
**修复**:
```bash
# 给 NVMe 单独建告警规则:用带宽 + await,别用 %util
# Prometheus 示例:NVMe 用"带宽利用率"而非 %util 判断
(rate(node_disk_read_bytes_total{device="nvme0n1"}[5m])
+ rate(node_disk_written_bytes_total{device="nvme0n1"}[5m]))
/ 3.5e9 * 100 > 80
```
**经验**:**别用一套 %util 阈值监控所有盘。** NVMe 判饱和要用"带宽利用率"和"await",`%util` 对 NVMe 基本是废指标。这是"症状 2:应用卡但盘不忙"的典型,也是监控配置最常见的坑。
**复盘补充:报警风暴怎么快速压下去**
当时告警系统因为 `%util 100%` 疯狂发短信,值班同事准备加机器。我做的第一件事不是调告警,而是**加了一列 `await` 到告警规则里**:只有 `%util 高 AND await 高` 才触发。因为 `await` 才是"请求等得久"的直接证据,`%util` 只是"设备有活"。改成:
```yaml
# Prometheus 规则:%util 高 且 await 高 才算真饱和
expr: |
(node_disk_io_time_seconds_total{device=~"nvme.*"}
/ node_disk_io_time_weighted_seconds_total{device=~"nvme.*"}) > 0.9
and
(node_disk_io_time_seconds_total{device=~"nvme.*"} / 5 > 5)
```
虽然 PromQL 写起来绕,但思路就一句:**NVMe 的"饱和"必须同时满足"忙"和"慢",缺一不可。** 从那以后这个集群再没误报过。
**另一层反思**:为什么当时没人早发现?因为监控模板是从一台机械盘服务器复制过来的,`%util > 95%` 对机械盘合理、对 NVMe 是灾难。**监控配置要跟着硬件走,设备升级了,告警阈值不升级等于没监控。**
### 事故五:ext4 data=journal 日志翻倍
**现象**:一台 ext4 文件系统的应用服务器,`iostat` 显示 `w_await` 高、`%util` 不低,`iotop` 里看到大量 `flush-8:0`(内核回写线程)和 `jbd2`(ext4 日志线程)在写。应用每次写文件都明显变慢。
**排查链**:
```bash
iostat -x 1 5 # sda %util 90%,w_await 45ms,wkB/s 并不高 → 写慢但带宽不大
iotop -oPa # flush-8:0 和 jbd2/sda-8 占顶,写多
cat /proc/mounts | grep /data # /data 是 ext4
dumpe2fs -h /dev/sda1 | grep -i journal # 看日志模式
```
**根因**:`/data` 用 `ext4` 且挂载为 `data=journal`(默认其实多半是 `data=ordered`,但有些老系统或手工挂载用了 `data=journal`)。**`data=journal` 模式会把数据和元数据**都**写两遍:先写日志,再写数据**——等于写放大 2 倍。应用每次 fsync 要等两次落盘,`w_await` 自然高。
```text
/dev/sda1 /data ext4 data=journal,errors=remount-ro 0 0
```
**修复**:
```bash
# 1. 改回 ordered(推荐:元数据先写日志,数据先落盘,只写一遍)
mount -o remount,data=ordered /data
# 持久化:改 /etc/fstab 里对应行 data=journal → data=ordered
# 2. 如果实在要 data=journal(某些崩溃一致性场景),至少把日志放独立分区
# journal 放 SSD,数据放 HDD,日志写不再拖累数据盘
```
**经验**:**`w_await` 高但 `wkB/s` 不高,是"写放大"的典型信号。** 除了 ext4 `data=journal`,还有 SSD 写入放大、btrfs COW、以及 `fsync` 调用过频(每次提交都强制落盘)。先看文件系统挂载参数,再查应用 fsync 频率,别一上来就换盘。
**复盘补充:`data=journal` vs `data=ordered` 到底差多少**
我在这台机器上做了实测对比(fio 顺序写 4K 随机写):
| 挂载模式 | 随机 4K 写 IOPS | 顺序写带宽 | 说明 |
|---|---|---|---|
| `data=journal` | 320 | 120MB/s | 数据和元数据都写日志,**双倍写** |
| `data=ordered` | 780 | 190MB/s | 元数据先写日志,数据直接落盘 |
| `data=writeback` | 1100 | 210MB/s | 最激进,崩溃一致性最弱 |
`data=journal` 比 `data=ordered` 随机写慢了一半不止,因为它**先写日志再写数据,同一份数据落两次盘**。改回 `data=ordered` 后 `w_await` 从 45ms 降到 12ms。**这就是"写放大 2 倍"的实锤。**
**注意**:`data=journal` 不是一无是处,它崩溃一致性最强(掉电不丢数据)。取舍在于**业务是否能容忍 fsync 慢**。数据库场景几乎都该用 `ordered`,除非有明确的"掉电必须零丢失"要求。
## 十、缓解手段:从限速到换盘的 5 个台阶
定位到根因后,缓解手段要按"成本从低到高、见效从快到慢"来选。我给出一套实操组合。
### 10.1 降级 / 限速(最快,临时止血)
```bash
# 让进程只在系统空闲时才用 IO(best-effort 空闲级)
ionice -c3 -p
# 永久:让备份/批处理进程天生低优先级
ionice -c3 rsync -a --delete /data/ /backup/
# 限进程 IO 带宽(rsync 限 20MB/s)
ionice -c3 rsync --bwlimit=20000 /data/ /backup/
# 看进程当前 ionice 等级
ionice -p
```
**`ionice -c3`(idle 级)是运维最被低估的武器**:它只在磁盘**完全空闲**时才让进程发 IO,业务高峰时它自动让路,低谷时又不会饿死。备份、日志聚合、离线分析都该用。
### 10.2 内核参数调优(系统性,需压测)
```bash
# 脏页阈值:默认 20%,调低让回写更平滑,避免周期性写风暴
sysctl vm.dirty_ratio=10
sysctl vm.dirty_background_ratio=3
# 脏页驻留时间:默认 30 秒,调低加速回写,减少脏页堆积
sysctl vm.dirty_expire_centisecs=3000
# swap:数据库/延迟敏感应用降低换页倾向
sysctl vm.swappiness=10
```
**注意**:`dirty_ratio` 调低会让回写更频繁(每次更少),能平滑"周期性写峰",但**总写入量没变**。机械盘场景配合 `ionice` 才有意义,别指望单改这个参数就解决随机读问题。
### 10.3 布局调整:分盘、错峰、合并
- **数据盘 / 日志盘分离**:随机读(数据)和顺序写(日志)别同盘,机械盘尤其要分
- **备份错峰**:从业务高峰挪到低谷(02:00-05:00)
- **批量 IO 合并**:把零散小写聚合成批量大写,减少 `fsync` 次数(应用层做,或用缓冲层)
### 10.4 cgroup v2 限流(容器场景)
见第七章:`io.max` 硬限(随机小 IO 限 `riops`),`io.weight` 软分权。容器场景这套比整机 `ionice` 更可控。
### 10.5 硬件升级(最后手段,治本)
- 机械盘 → SATA SSD:随机读 IOPS 从 200 提到 5 万,**随机读场景质变**
- SATA SSD → NVMe:带宽从 500MB/s 提到 3-7GB/s,**顺序大 IO 场景质变**
- 数据库:`data` 与 `binlog`/`redo` 分盘,redo log 放 NVMe
**决策顺序**:先 `ionice` 止血(5 分钟)→ 再查根因改配置(30 分钟)→ 再考虑 cgroup 限流或分盘(半天)→ 最后才谈换硬件。**很多人一上来就申请买盘,结果根因是 `data=journal` 写放大,换了 NVMe 也没用。** 先定性再花钱。
### 10.6 一个常被忽略的止血点:sysctl 的 dirty 参数别瞎调
很多人一看到 IO 慢就调 `vm.dirty_ratio`,但**调错了反而更糟**:
- `dirty_ratio` 调**大** → 脏页攒得更多,回写时**一次性压垮盘**(周期性写峰更狠)
- `dirty_ratio` 调**小** → 回写更频繁,但每次量少,**平滑了写峰**,代价是 CPU 和写放大略增
对机械盘(写峰值敏感),调小 `dirty_ratio` 有用;对 NVMe(带宽大),调小反而损失吞吐。**调之前先明确目标:是平滑写峰还是最大化吞吐,方向相反。**
### 10.7 ionice 的完整分级速查
`ionice` 分三种类,别用错:
```bash
# -c1 Realtime:实时,会抢占普通 IO(慎用,只有内核/关键进程才配)
ionice -c1 -p
# -c2 Best-effort:尽力而为,默认 n=4,可调 0-7(0 最高)
ionice -c2 -n 7 -p # 备份进程降级,让业务优先
# -c3 Idle:空闲级,只在磁盘完全空闲时才发 IO(最安全,推荐备份用)
ionice -c3 -p
# 看当前值
ionice -p
```
**实操经验**:`ionice -c3`(idle)对备份类任务几乎无副作用——业务高峰它自动停,业务低谷它补跑。**`-c1`(realtime)是双刃剑**,用错会把业务进程的 IO 全部饿死,除非明确知道自己在干什么,否则别碰 `-c1`。
### 10.8 分盘规划实操
分盘不是随便 `mkdir`,要按**IO 特征**分:
```bash
# 1. 数据库:数据(随机读) / binlog+redo(顺序写) / 临时表(高随机写) 三盘分离
/data # 数据文件,随机读为主 → 放 SSD
/redo # redo log + binlog,顺序写为主 → 放 NVMe
/tmp/mysql # tmpdir,临时排序文件,高随机写 → 放 SSD
# 2. 应用:日志(顺序写) / 程序(读) / 临时(写) 分离
/var/log/app # 应用日志,顺序追加写 → 放 NVMe(fsync 快)
/app/data # 业务数据 → 放 SSD
/app/tmp # 临时文件 → 放 SSD
```
**原则**:顺序写(日志/redo)和随机读(数据)永远分盘。机械盘场景这是救命稻草;NVMe 场景收益小一点,但容器镜像层和数据库日志仍建议分开。
### 10.9 换盘前先做一次"成本-收益"判断
别盲目换盘,用数据说话。换盘收益 = **当前瓶颈在不在"盘"上**:
```text
瓶颈在盘上(await 高 + 队列深)→ 换盘有收益
瓶颈不在盘上(await 低 + 队列浅,但业务慢)→ 换盘没收益,问题在别处
```
上面第二种情况,业务慢的根因可能是 CPU、锁、网络或应用代码。**先确认瓶颈确实在盘,再花买盘的钱。** 这是运维最该守住的一条纪律。
## 十一、10 条速查命令
| # | 命令 | 用途 |
|---|---|---|
| 1 | `vmstat 1 5` | 60 秒定性:r/b/wa 判 CPU 还是 IO |
| 2 | `iostat -x 1 5` | 设备级:%util/await/aqu-sz/rareq-sz |
| 3 | `iostat -xm 1 5` | 按设备+分区看(`-m` 单位 MB)|
| 4 | `iotop -oPa` | 进程级:揪 IO 元凶 |
| 5 | `pidstat -d 1 5` | 进程级兜底:kB_rd/s + iodelay |
| 6 | `ps -eo state,pid,cmd,wchan:30 \| awk '$1~/D/'` | 找 D 状态进程 + 内核函数 |
| 7 | `blktrace -d /dev/sda -o /tmp/bt -w 5` | 块层抓取(随机 vs 顺序)|
| 8 | `cat /sys/fs/cgroup/*.scope/io.stat` | cgroup 容器真实 IO |
| 9 | `echo "8:0 rbps=104857600" > .../io.max` | cgroup v2 限流 |
| 10 | `ionice -c3 -p ` | 进程降 IO 优先级 |
**组合套路**(5 分钟内定位):
```bash
vmstat 1 5 # 1. 定性:b/wa 高 → IO 问题
iostat -x 1 5 # 2. 定盘:哪块盘 + 随机/顺序 + 延迟
iotop -oPa # 3. 定进程:谁在作恶
ps -eo state,pid,cmd,wchan:30 | awk '$1~/D/' # 4. 看是否 D 状态卡死
```
### 从症状到结论的 3 分钟实战流
把全文浓缩成一条可背下来的判断流,遇到 IO 现场直接照做:
```text
第 1 分钟:load 高 + CPU 闲?
├─ yes → vmstat 看 b/wa → b 高 → 是 IO 瓶颈
└─ no → 去看 cpu / memory 监控
第 2 分钟:哪块盘 + 什么 IO?
├─ iostat -x → rareq-sz < 16K → 随机小 IO(HDD 大忌)
├─ iostat -x → rareq-sz > 64K → 顺序大 IO(相对安全)
└─ %util 100% 但 await 低 → NVMe 假饱和,看带宽
第 3 分钟:谁在作恶?
├─ iotop -oPa → IO> 30% 的进程 → 元凶
├─ pidstat -d → iodelay 大但 kB_rd 小 → 受害者,找别人
└─ ps D 状态多 → wchan 定位内核函数
```
**一句话总结全文**:`vmstat` 定性(是 IO 不是 CPU),`iostat` 定盘(哪块、随机还是顺序),`iotop`/`pidstat` 定进程(谁是元凶),`blktrace` 定细节(队列与合并),阈值**分盘种**(HDD 看 await、NVMe 看带宽),容器**用 cgroup v2 限流**。抓住这六点,磁盘 IO 瓶颈逃不出你的手心。
## FAQ
**Q1:load 很高但 CPU 使用率很低,为什么?**
A1:八成是 iowait 在作怪。load 统计的是可运行 + 不可中断(D 状态)睡眠进程。进程在等磁盘 I/O 时进入 D 状态,照样计入 load,但 CPU 是空的。用 `vmstat` 看 `b` 列非零、`wa` 列偏高,再 `iostat` 查 `%util`/`await` 定位到具体盘,方向就清楚了。**别去加 CPU,那是给错误的病开药。**
**Q2:iostat 的 %util 100% 但业务不慢,正常吗?**
A2:NVMe 或 SSD 可能出现这种情况。`%util` 是"设备有请求在跑的时间占比",NVMe 队列极深,设备常年显示 100%,但实际带宽只用了 20%、`await` 只有 0.1ms——这叫**假饱和**。判 NVMe 要看带宽利用率(实际 rMB/s ÷ 额定带宽)和 `await`,别单独看 `%util`。
**Q3:iowait 高但 iostat %util 不高,怎么解释?**
A3:通常是多队列设备、或 NFS/网络文件系统。`%util` 按设备聚合,并发请求多时单个请求等待长,但设备本身没满。用 `iotop`/`pidstat` 定位到具体进程,或用 `blktrace` 看真实队列深度。也可能是 D 状态进程堆积,用 `ps -eo state,wchan` 确认。
**Q4:D 状态进程太多,kill 不掉怎么办?**
A4:D 状态是内核不可中断睡眠,等 I/O 完成,SIGKILL 无效。先看 `wchan` 停在哪个函数:`io_schedule` 是盘慢还能等;`blk_mq_submit_bio` 是队列堵;如果 `iostat` 数值不刷新、D 进程越积越多,是盘挂了,只能重启。**别反复 kill,处理根因:等盘恢复或重启。**
**Q5:rsync 备份经常打满磁盘,怎么限速?**
A5:用 `ionice -c3` 给 best-effort 空闲级,备份自动让路业务;或 `rsync --bwlimit=20000` 限 20MB/s。更彻底的是错峰到业务低谷(02:00-05:00),并确认是增量而非每次全量扫描。**备份不限速是生产事故头号原因。**
**Q6:swap 抖动(kswapd0 占 CPU 顶)怎么处理?**
A6:内存不足导致内核换页线程疯狂工作。临时 `sysctl vm.swappiness=10` 降换页倾向(治标),根治是加内存或限制进程内存(`-Xmx`/cgroup `memory.max`)。看 `/proc/pressure/memory` 的 `some avg10`,> 50% 即内存真实压力。**kswapd0 是内核线程,别杀,处理内存根因。**
**Q7:机械盘 await 200ms 了,是不是必须换盘?**
A7:机械盘 await > 200ms 基本是寻道风暴或盘接近损坏。先看 `iostat` 的 `rareq-sz`:若 < 16K 且 `r/s` 高,是随机小 IO 打穿寻道,换 SSD/NVMe 是唯一解法(机械盘随机 IOPS 硬上限 200,物理限制改不了)。若 `rareq-sz` 大是顺序 IO,那可能是盘坏了,先 `smartctl` 查坏道。
**Q8:数据目录和日志目录要不要分盘?**
A8:要。随机读(数据)和顺序写(日志)混在一个机械盘会互相干扰寻道:一次日志 fsync 可能被随机读打断,延迟飙升。分盘后两者互不干扰,数据库日志 fsync 延迟明显下降。数据库 `data`/`binlog`、容器镜像层与数据层,分盘效果最明显。
---
## 相关文章
- [Linux 监控 CPU:top 一行 5 个百分比字段排障决策树](linux-cpu-monitor.md)
- [Linux 内存监控实战与 OOM 排障:free/vmstat/ps 字段解读 + cgroup v2 内存限制决策树](linux-memory-monitor.md)
- [Prometheus 查询实战:PromQL 聚合、速率与告警规则编写](prometheus-query.md)
---
## Linux 内存监控实战与 OOM 排障:free/vmstat/ps 字段解读 + cgroup v2 内存限制决策树
URL: https://ops.jiunotes.top/monitor/linux-memory-monitor.md
摘要: 内存故障比 CPU 更隐蔽,OOM killer 无提示杀进程。本文从 free 五字段、vmstat 换页、cgroup v2 memory.events 到 OOM 日志解读,构建完整排障决策树,加 5 个真实生产事故。
### Answer
OOM killer 常无提示杀进程。先看 free available 真实可用内存,vmstat si/so 判换页压力,dmesg 读 OOM 日志,cgroup memory.events 定位容器。
### FAQ
Q: free 显示 used 很高但系统不卡,为什么?
A: 因为 buff/cache 大多可回收,used 高不代表紧张。要看 available 这一列才是真实可用内存,或 vmstat 的 si/so 持续非零才说明真的在换页。
Q: 容器被 OOMKilled 但宿主机内存还很多,怎么回事?
A: 容器内存是 cgroup 限制的,memory.max 设小了就会在 cgroup 内杀进程,与宿主机无关。看 memory.events 的 oom_kill 计数和 memory.max/current。
Q: swappiness 设置多少合适?
A: 默认 60。数据库或延迟敏感应用建议 10-30,减少匿名页换出。但 swappiness 只影响匿名页,页缓存回收另有一套,别把它当万能开关。
Q: 怎么判断是内存泄漏而不是正常增长?
A: 看 RSS 是否只涨不跌、slabtop 里 SReclaimable 与 SUnreclaim 的比值、/proc/meminfo 的 Slab 字段持续上涨。稳定后不回落基本就是泄漏。
### Full Content
# Linux 内存监控实战与 OOM 排障:free/vmstat/ps 字段解读 + cgroup v2 内存限制决策树
我入行第一年就吃过一次大亏:一台 64G 的数据库节点半夜被 OOM killer 干掉了主进程,但监控面板上内存曲线一切正常,报警系统也没响。第二天早上业务方来问,我才发现 `free -h` 的 `used` 一直在 50G 左右,看起来"健康",可 `buff/cache` 那条线早就悄悄涨到了 60G——内存其实一直在被页缓存和 slab 蚕食,`available` 已经见底了。
内存故障和 CPU 故障有个本质区别:**CPU 满了你会肉眼可见地"卡",内存满了系统往往一声不吭地杀人**。OOM killer 不打印任何业务日志、不留堆栈、不写 core 文件,只是按 oom_score 挑一个进程 SIGKILL 掉。你看到的现象只是"进程莫名挂了",重启后一切正常,然后下个月再来一次。
这篇我把踩过的坑完整梳理一遍:从 `free` 五字段到底怎么读,到 `vmstat` 的 si/so 换页判断,再到 cgroup v2 的 `memory.events` 和 OOM killer 的 badness 算法,最后附上 5 个我亲手排过的生产事故。所有阈值、参数我都写到具体值,命令都是可直接复制的。
## 一、free -h 五字段解读:used 高不代表紧张
排查内存永远从 `free -h` 开始。这是最容易被误读、也最容易被误判的一条命令。
```bash
free -h
```
**你应该看到什么**(32G 机器、运行中的生产节点):
```
total used free shared buff/cache available
Mem: 31Gi 9.2Gi 1.5Gi 312Mi 20Gi 21Gi
Swap: 8.0Gi 0B 8.0Gi
```
这一行有 5 个真正要读的字段(`shared` 通常小,可以忽略),**每个字段高都是不同的信号**:
| 字段 | 含义 | 高 / 低 说明什么 |
|---|---|---|
| **total** | 物理内存总量 | 固定值,排除内存条或 VM 规格问题 |
| **used** | 应用 + 内核实际占用 | 高 ≠ 紧张,见下文 |
| **free** | 完全未分配的内存 | 长期接近 0 是正常现象,[Linux](https://www.kernel.org/doc/html/latest/) 喜欢"用满" |
| **buff/cache** | 页缓存 + buffer + slab | **可回收部分是"软内存"**,是系统主动吃掉的内存 |
| **available** | **真正可分配的内存** | 这才是排障要看的列,而不是 used |
### 1.1 为什么 used 高不代表紧张
我刚带新人时,他们第一反应永远是"used 20G,是不是要爆了?"——不是。Linux 的内存策略是**"内存闲着就是浪费"**:只要不用,内核就把空闲内存拿来缓存磁盘文件(page cache)、缓存目录项(dentry)、缓存 inode 等。所以 `used + buff/cache` 几乎总是接近 `total`,`free` 常年是个位数 G 甚至几百 M,这恰恰是**健康**的表现。
真正的信号在 `available` 这一列。它是内核用 `MemAvailable` 算出来的**"如果现在有个进程要申请大内存,系统能立即给多少"**。它已经扣掉了"不能立刻回收的那部分 cache",比 `used` 和 `free` 都靠谱。
### 1.2 buff/cache 可回收 vs 不可回收
关键问题是:`buff/cache` 到底有多少能回收?答案藏在 `/proc/meminfo`:
```bash
grep -E '^(MemFree|Buffers|Cached|SReclaimable|SUnreclaim|Shmem|Slab|MemAvailable)' /proc/meminfo
```
**你应该看到什么**:
```
MemFree: 1572864 kB
Buffers: 204800 kB
Cached: 18446744 kB
SReclaimable: 8388608 kB
SUnreclaim: 1048576 kB
Shmem: 319488 kB
Slab: 9437184 kB
MemAvailable: 22020096 kB
```
拆解一下:
- **Cached**:文件页缓存,**绝大部分可回收**(除非是 `mapped` 且正在被写,需要写回)
- **Buffers**:块设备元数据,量小,可回收
- **SReclaimable**:slab 中**可回收**部分(dentry、inode、一些内核缓存),这是好消息
- **SUnreclaim**:slab 中**不可回收**部分(内核对象、驱动分配),**这是坏消息**,回收不掉
- **Shmem**:tmpfs 共享内存,**不可回收**(除非删除 tmpfs 文件),常被误当成 cache
- **Slab = SReclaimable + SUnreclaim**:内核对象缓存总量
判断口径就一句话:**`buff/cache` 里的 `SReclaimable + Cached` 才是"软内存",`SUnreclaim + Shmem` 是"硬占用"**。如果一台机器 `available` 很低但 `Cached` 很高,大概率还能回收救回来;如果 `Cached` 不高而 `SUnreclaim` 和 `Shmem` 高,那就是真的吃紧。
> 经验阈值:`available` 低于 `total` 的 5% 且持续超过 5 分钟,就该拉警报了。别等 0,等 0 的时候 OOM 已经开始了。
### 1.3 真实案例 A:页缓存误判
有一次生产上 Java 应用被 OOM kill,我接到告警上机器一看 `free -h`:`used 9G / total 32G / buff/cache 21G`。第一反应是"内存还多得很,怎么会被 OOM?"
再看 `MemAvailable`:只剩 **1.2G**。再查 cgroup:这个 Java 应用跑在容器里,容器 `memory.max = 4G`,而容器视角下的 `buff/cache`(进程读过的文件页)全算在容器头上,`memory.current` 早就顶到 4G 了。**宿主机可用内存多,容器视角下已被自己的页缓存 + 堆撑爆**。
结论:`free -h` 看的是宿主机,**容器排障必须看 cgroup**,宿主机 `buff/cache` 高是红鲱鱼。后面第六节展开。
## 二、vmstat 换页判断:si/so 才是内存压力的真信号
`free` 告诉你"现在剩多少",`vmstat` 告诉你"内存正在怎么被使用"。内存压力最硬核的证据不是 `free`,而是 **si/so(swap in / swap out)持续非零**。
```bash
vmstat 1 5
```
**你应该看到什么**(每 1 秒采样,共 5 次):
```
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
r b swpd free buff cache si so bi bo in cs us sy id wa st
2 0 10240 1572864 204800 18446744 0 0 12 18 1024 2048 8 2 89 0 0
3 0 10240 1548288 204800 18446744 0 0 15 20 1100 2100 9 2 88 0 0
2 1 10240 1523712 204800 18446744 120 340 40 380 1200 2400 12 4 82 0 0
```
要读的列(其余列在内存专题里不用太纠结):
| 列 | 含义 | 阈值 / 信号 |
|---|---|---|
| **si** | swap in:每秒从交换区读回内存的 KB | **持续 > 0 且非零** = 内存压力 |
| **so** | swap out:每秒从内存换到交换区的 KB | 同上 |
| **r** | 运行队列:可运行进程数 | 长期 > CPU 核数 = 就绪队列堆积 |
| **b** | 阻塞队列:不可中断睡眠的进程 | > 0 且伴随 si/so = 在等换页,**卡顿元凶** |
| **free** | 空闲内存 | 看趋势,别单看绝对值 |
**判定口诀**:
- `si/so` 长期为 0 → 内存没压力,问题在别处
- `si/so` 偶尔非零 → 启动期或缓存刷盘,正常
- `si/so` **持续非零(连续几个采样)** → 内存真紧张,进程在拿磁盘当内存用,**磁盘换页是"内存假象",真正卡的是 I/O**
- `b` 列伴随走高 → 进程被阻塞在换页上,这就是"系统没死但业务超时"的根源
> 为什么说"swap 抖动比内存不足更可怕"?因为 swap 写回是同步磁盘 I/O。`so` 持续几百 KB/s 时,每次缺页中断都要等磁盘,应用延迟从毫秒级跳到百毫秒级,且 **CPU 看起来不忙**(都在等 I/O),监控上"CPU 正常 + 内存正常 + 业务巨慢"这种诡异组合,九成是 swap 抖动。
### 2.1 free 列的趋势比绝对值重要
`vmstat` 里的 `free` 列是"完全未分配"的内存,Linux 正常运行时它常年很低。所以**别盯着 `free` 是不是 100M,要看它是不是在持续往下走**。`free` 缓慢阴跌 + `si/so` 抬头,是最典型的"内存正在被慢慢吃掉"曲线,常见于内存泄漏(第九节)。
## 三、ps aux 内存解读:RSS vs VSZ 的坑,smem 的 PSS 才是真相
定位到进程后,用 `ps aux` 看谁在吃内存。但这里有两个坑:**RSS 会高估共享内存**,**VSZ 完全没用**。
```bash
ps aux --sort=-rss | head -15
```
**你应该看到什么**:
```
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
app 1234 12.5 8.2 4289344 2684352 ? Sl Oct20 45:02 java -Xmx2g ...
mysql 2345 3.1 15.4 2187652 5051392 ? Ssl Oct18 18:33 mysqld
redis 3456 0.8 1.2 154624 393216 ? Ssl Oct19 2:11 redis-server
```
### 3.1 RSS 与 VSZ 的区别(必考)
- **VSZ(Virtual Set Size,虚拟内存)**:进程申请过的**地址空间**,包含未实际分配的部分、mmap 的库、共享内存映射。**完全不能反映真实占用**,一个 `mmap` 了 10G 但只碰了 100M 的进程 VSZ 能到 10G,你看着吓人其实没事。
- **RSS(Resident Set Size,常驻内存)**:进程**实际驻留在物理内存**的页数。但它是**按页计数、不除重**——两份进程共享同一个 `libc.so` 的页,RSS 会**各算一次**,两个进程 RSS 加起来能超过物理内存总量,**这就是 RSS 高估共享内存的原因**。
所以:**VSZ 别信,RSS 偏高估**。判断单进程真实内存,用 `smem` 的 **PSS(Proportional Set Size)**,它把共享页按进程数均摊,`所有进程 PSS 之和 ≈ 真实物理内存占用`。
### 3.2 smem:按真实内存排序
```bash
# 需要安装:apt install smem 或 yum install smem
smem -rsk -t # -r 排序 -s 按 rss -k 人类可读 -t 汇总
```
**你应该看到什么**:
```
PID User Command Swap USS PSS RSS
1234 app java -Xmx2g 0 1.8G 1.9G 2.6G
2345 mysql mysqld 0 2.2G 2.4G 4.9G
3456 redis redis-server 0 300M 305M 390M
-------------------------------------------------------------------------------
0 12.4G 14.2G 19.8G
```
三列口径对比,一目了然:
- **USS**:进程独占内存(不共享的页),最小、最"真实自用"
- **PSS**:独占 + 共享均摊,**推荐用这个**,加起来最接近物理内存
- **RSS**:独占 + 共享全算,**偏大**
**排障口径**:看谁在吃内存,`ps aux --sort=-rss` 排出来排前几的进程,用 `smem -rsk` 复核 PSS。如果某进程 RSS 6G 但 PSS 只有 1G,说明它是"共享大户"(Java 的 mmap 线程栈、JIT 代码缓存常见),别误判成泄漏。
## 四、内存回收机制:page cache、kswapd、direct reclaim 与水位
要理解 OOM,得先理解内核怎么"弄回内存"。Linux 内存回收有两条路:**后台回收(kswapd)** 和 **直接回收(direct reclaim)**,由**内存水位(watermark)** 驱动。
### 4.1 两条回收路径:匿名页 vs 文件页
内存紧张时内核要找"可回收的内存",分两类:
1. **文件页(page cache)**:把脏页**写回磁盘**(writeback)就能释放。**这是首选**,因为它不需要交换、只是把已缓存的数据落盘,代价小。这就是为什么 `buff/cache` 高时内存压力通常还能扛——内核在默默回收文件页。
2. **匿名页(anonymous)**:进程的堆、栈、mmap 匿名映射。**没有磁盘后端,必须换到 swap**(或 zram/zswap)才能释放。代价是磁盘 I/O,**这就是 si/so 的来源**。
> 所以 `swappiness` 只影响"内核多愿意回收匿名页",不影响文件页回收。文件页回收永远优先,除非脏页写回跟不上。
### 4.2 kswapd:后台回收(无感)
每个 NUMA 节点有一个 `kswapd` 内核线程。当空闲内存跌破 **low watermark**,kswapd 被唤醒,在后台异步回收,目标是把空闲内存恢复到 **high watermark**。这个过程中进程**无感知**,不阻塞。
```bash
# 看 kswapd 是否在忙碌(占用 CPU 高说明回收跟不上)
ps aux | grep kswapd | grep -v grep
```
### 4.3 direct reclaim:直接回收(进程卡顿的元凶)
如果 kswapd 回收速度跟不上分配速度,空闲内存继续跌破 **min watermark**,分配内存的进程就会**亲自下场做回收**——这就是 **direct reclaim(直接回收)**。此时进程在分配路径上同步等待回收,**表现为 `vmstat` 的 `b` 列升高、`free` 阴跌、应用延迟暴涨**。这是"系统活着但业务在跳秒"的直接原因。
### 4.4 三档水位
| 水位 | 含义 | 触发动作 |
|---|---|---|
| **high** | 高水位 | kswapd 停止回收(回血完毕) |
| **low** | 低水位 | kswapd 被唤醒,开始**后台**异步回收 |
| **min** | 最低水位 | 进程被迫 **direct reclaim**,同步阻塞 |
查看当前水位和阈值:
```bash
cat /proc/zoneinfo | grep -A 30 'Node 0, zone Normal' | grep -E 'min|low|high|nr_free'
```
**你应该看到什么**(示例,单位页):
```
min 45
low 60
high 75
nr_free 20000
```
经验值:`min/low/high` 三档通常在 40/60/80(页数),比例约 1:1.5:2。**空闲页低于 `low` 就该注意,低于 `min` 就要准备接 OOM 告警了**。
### 4.5 为什么 direct reclaim 会"杀死"性能而不报错
我遇到过一台 [Redis](https://redis.io/docs/) 从节点,监控一切正常,但主从复制延迟从 0.3s 涨到 40s。`vmstat` 看到 `r` 不高、`si/so` 偶发非零、但 **`b` 列长期 2~5**,`free` 在 min 水位附近徘徊。真相是:Redis 在 `fork()` 做 RDB 快照时复制了内存页表,加上 kswapd 回收跟不上,Redis 主线程反复进入 direct reclaim,每次等待几十毫秒。**没有 OOM、没有报错,只是变慢**——这就是内存问题"隐蔽"的典型。
## 五、cgroup v2 内存限制实战:容器 OOM 为什么和宿主机无关
现在大部分生产内存问题都出在容器/K8s 里。**容器的内存限制是 cgroup 实现的,和宿主机物理内存是两套账本**。这是无数人踩的坑:宿主机 256G,容器只有 512M,容器里进程被杀了,你第一反应是"宿主机内存不够吗"——完全不是。
### 5.1 cgroup v2 核心三件套
```bash
# 找到进程所在 cgroup(cgroup v2 用相对路径,从 /sys/fs/cgroup 往下找)
cat /proc//cgroup
# 输出类似:0::/system.slice/docker-xxxx.scope
# 直接进入该 cgroup 目录看内存控制文件
ls /sys/fs/cgroup/system.slice/docker-xxxx.scope/memory.*
```
**关键文件**:
```bash
cat /sys/fs/cgroup/system.slice/docker-xxxx.scope/memory.max # 上限,单位字节,"max" 表示不限制
cat /sys/fs/cgroup/system.slice/docker-xxxx.scope/memory.current # 当前已用,含该 cgroup 的页缓存
cat /sys/fs/cgroup/system.slice/docker-xxxx.scope/memory.swap.max # swap 上限
cat /sys/fs/cgroup/system.slice/docker-xxxx.scope/memory.events # 关键:事件计数
```
**你应该看到什么**:
```
$ cat memory.max
1073741824 # 1G
$ cat memory.current
1048576000 # 接近 1G,已到 97.6%
$ cat memory.swap.max
0 # 容器禁止 swap
$ cat memory.events
low 0
high 0
max 3421 # 触顶次数
oom 12 # 触发 OOM 的次数
oom_kill 12 # 实际杀进程的次数
```
### 5.2 memory.events 是容器 OOM 的"黑匣子"
`memory.events` 四个计数器,是排容器内存问题的第一手证据:
- **low / high**:触及低/高水位线的次数,**内存开始吃紧的早期信号**
- **max**:`memory.current` 撞到 `memory.max` 的次数
- **oom**:触发 OOM killer 的次数
- **oom_kill**:实际被杀掉进程的次数(`oom` 和 `oom_kill` 可能不同,因为杀进程有时能救回)
**判定**:
- `oom_kill` 从 0 变成 N → 容器内进程被杀,**这就是 [K8s](https://kubernetes.io/docs/) 里 `OOMKilled` 的直接证据**
- `max` 在涨但 `oom_kill` 不动 → 容器在反复触顶、内存回收压力大,但还没到杀进程
- `high` 在涨 → 容器快满了,该提前告警
### 5.3 为什么容器 OOM 杀的是容器内进程,而不是整个节点
OOM killer 的作用范围是**触发 OOM 的那个 cgroup 及其子孙**。当容器 `memory.current` 撞到 `memory.max`,内核在**该容器 cgroup 范围内**挑一个 `oom_score` 最高的进程杀掉——不会去动宿主机上其他容器。所以:
- 容器被 OOMKilled,**宿主机物理内存可能还有一大半**,这完全正常
- 排查方向是 `memory.max` 设小了,不是"加机器"
- 容器内进程的 `oom_score_adj` 影响"谁被先杀",见第六节
### 5.4 memory.current 为什么比我想的高
新手常问:"我的 Java 进程 RSS 才 800M,为什么 memory.current 有 1.2G?"——因为 **`memory.current` 包含该容器触达过的所有页缓存**(读过/写过文件留下的页)。进程退出后缓存还在 cgroup 里记着,直到内核回收。所以容器内存排障**不能只看进程 RSS**,`memory.current` 才是容器视角的总账。
```bash
# 看容器内存构成(memory.stat 里各分项)
cat /sys/fs/cgroup/system.slice/docker-xxxx.scope/memory.stat | grep -E '^(anon|file|slab_reclaimable|slab_unreclaimable|shmem|pgfault|pgmajfault)'
```
**你应该看到什么**:
```
anon 512000000 # 匿名页(进程堆栈),这是"真内存"
file 560000000 # 文件页(页缓存),可回收
slab_reclaimable 20480000
slab_unreclaimable 10240000
shmem 40960000 # tmpfs,不可回收
pgmajfault 0 # 主缺页,0 说明没在换页
```
如果 `file` 占比大(>50%)而 `anon` 不大,说明容器内存是被页缓存撑高的,可以调大 `memory.max` 或等内核回收;如果 `anon` 接近 `memory.max`,那是应用真的在涨,要查泄漏或扩上限。
## 六、OOM killer 原理:badness 算法、oom_score 与 dmesg 解读
当 direct reclaim 也救不回来(回收速度 < 分配速度),内核调用 OOM killer。**它不是一个"判断"而是一个"执行"**:挑进程、杀、结束。理解它怎么挑,你才能主动保护关键进程。
### 6.1 badness 算法:OOM killer 怎么选受害者
内核遍历所有进程,给每个算一个 `badness` 分数,**分数最高者被选为受害者**。简化公式:
```
badness ≈ 进程的内存占用(RSS + swap + 页表等) + 权重修正
除以 该进程的 oom_score_adj 相关因子
```
关键变量:
- **占用越大,越容易被杀**(这是主项,大内存进程首当其冲)
- **`oom_score_adj` 越小,越不容易被杀**(`-1000` 表示永不杀,`+1000` 表示优先杀)
- 权重修正:root 进程、直接子进程、已杀过一次的进程会被加权保护,**但核心还是"谁占得多杀谁"**
查看每个进程的实时分数:
```bash
# 分数越高,越容易被 OOM 选中
for pid in $(pgrep -u app); do echo -n "$pid "; cat /proc/$pid/oom_score; done | sort -k2 -n -r | head
```
### 6.2 oom_score_adj:给关键进程上保险
`oom_score_adj` 范围 **-1000 ~ +1000**,默认 0。这是生产环境**必须**做的事:
```bash
# 数据库/网关等关键进程,压低分数,让它最后才被杀
echo -500 > /proc//oom_score_adj
# 批处理/非关键任务,抬高分数,让它最先被杀
echo 500 > /proc//oom_score_adj
# -1000 表示 OOM killer 直接跳过该进程(谨慎,可能把系统拖死)
echo -1000 > /proc//oom_score_adj
```
**经验配置**(以数据库节点为例):
| 进程类型 | 建议 oom_score_adj | 原因 |
|---|---|---|
| MySQL / [PostgreSQL](https://www.postgresql.org/docs/) | -500 ~ -300 | 关键服务,尽量保住 |
| Redis | -300 | 内存型,但可重建 |
| 监控 agent | -200 | 排障需要它活着 |
| 日志采集 | 100 | 可丢日志,优先杀 |
| 批处理 job | 300 | 非关键,最先杀 |
**K8s 里的等价物**是 Pod 的 `oomScoreAdj`,或者通过容器运行时配置。Java 应用建议在容器内设置 `-XX:MaxRAMPercentage` 并显式给 `oom_score_adj`。
### 6.3 dmesg 完整解读:OOM 日志就是事故报告
OOM 发生后,第一手证据在内核日志:
```bash
dmesg -T | grep -i -E 'out of memory|killed process|oom' | tail -30
```
**你应该看到什么**(完整 OOM 日志示例):
```
Oct 28 03:12:45 node01 kernel: [123456.789] invoke_oom_killer: gfp_mask=0x100cca(GFP_KERNEL_ACCOUNT), order=0, oom_score_adj=0
Oct 28 03:12:45 node01 kernel: [123456.789] oom-kill:constraint=CONSTRAINT_NONE, nodemask=(null), cpuset=/, mems_allowed=0-1, global_oom, consider_oom_score=1
Oct 28 03:12:45 node01 kernel: [123456.790] Out of memory: Killed process 1234 (java) total-vm:4289344kB, anon-rss:2684352kB, file-rss:0kB, shmem-rss:0kB, UID:1000 pgtables:20480kB oom_score_adj:0
Oct 28 03:12:45 node01 kernel: [123456.790] Tasks state (memory values in pages):
Oct 28 03:12:45 node01 kernel: [123456.790] pid 1234 (java): 65600 pages, anon:65536, file:64, shmem:0
Oct 28 03:12:45 node01 kernel: [123456.790] pid 2345 (mysqld): 123200 pages, anon:123136, file:64, shmem:0
```
逐行拆解,这就是完整的"事故报告":
| 日志片段 | 含义 | 排查指向 |
|---|---|---|
| `invoke_oom_killer ... order=0` | 内核因分配 `order=0`(单页)失败触发 OOM | `order` 小说明是细水长流型压力,不是大块分配 |
| `constraint=CONSTRAINT_NONE` | **节点级 OOM**(全局,不是某个 cgroup) | 说明整机内存爆了,看宿主机 |
| `constraint=CONSTRAINT_MEMCG` | **cgroup 级 OOM** | 说明是某个容器触顶,`cpuset` 显示范围 |
| `Killed process 1234 (java)` | 被杀的是谁、被杀了多少 | 记下 PID 和进程名 |
| `total-vm / anon-rss / file-rss` | 被杀进程的内存画像 | `anon-rss` 是真占用,`file-rss` 是页缓存 |
| `oom_score_adj:0` | 被杀时它的分数偏移 | 如果是 -1000 还被杀,说明内核忽略了保护(危险信号) |
| `Tasks state ... anon:65536` | 被杀瞬间所有进程快照 | **比对哪些进程 anon 高,判断有没有更值得怀疑的** |
> **最重要的判断**:日志里 `constraint=CONSTRAINT_MEMCG` 是容器问题,`CONSTRAINT_NONE` 是节点问题。**方向搞反了会白忙一场**——宿主机 OOM 你去看容器上限,或容器 OOM 你去看宿主机内存,都排不到根因。
### 6.4 真实案例 B:K8s 容器反复 OOMKilled
有一次客户反馈一个 Java 服务在 K8s 里每天被 OOMKilled 几次,`kubectl describe pod` 显示 `OOMKilled`,但**宿主机 free 显示还有 200G 空闲**。
排查路径:
```bash
# 1. 看容器的 memory.events
kubectl exec -it -- cat /sys/fs/cgroup/memory.events
# 输出:oom_kill 18 <- 已被杀 18 次
# 2. 看容器上限和当前用量
kubectl exec -it -- cat /sys/fs/cgroup/memory.max # 2147483648 (2G)
kubectl exec -it -- cat /sys/fs/cgroup/memory.current # 2130706432 (1.98G)
# 3. 看 JVM 最大堆 vs 容器上限
kubectl exec -it -- jcmd VM.flags | grep -o 'MaxHeapSize=[0-9]*'
```
根因:容器 `memory.limit = 2G`,但 JVM `-Xmx` 设了 **3G**(`MaxHeapSize=3221225472`)。JVM 以为有 3G 可用,实际容器只给 2G,JVM 堆涨到 1.9G 时容器内存(堆 + 元空间 + 线程栈 + 页缓存)就触顶了,OOM killer 杀 JVM。
修复:把 `-Xmx` 降到容器上限的 **60~70%**(2G 上限 → `-Xmx1280m`),并加 `-XX:MaxRAMPercentage=60` 让 JVM 自动感知容器限制,同时给关键进程设 `oom_score_adj=-100`。
**教训**:容器内存排障第一件事是**核对 JVM 堆和容器上限的比例**。`-Xmx` 大于 `memory.limit` 是 K8s Java 服务被 OOMKilled 的第一大原因。
## 七、swap 配置:swappiness、zram/zswap 与 overcommit
swap 用好了是缓冲,用坏了是灾难。这一节讲透配置参数。
### 7.1 swappiness 的语义(0-100)
`/proc/sys/vm/swappiness` 控制内核**回收匿名页(换出)相对于回收文件页(写回)的偏好**,默认 **60**。
```bash
cat /proc/sys/vm/swappiness # 默认 60
```
**注意**:它**不是**"使用 swap 的概率",而是"在需要回收时,多倾向于回收匿名页"的权重。
| 值 | 适用场景 | 效果 |
|---|---|---|
| 0 | 极度保守,几乎不换出匿名页 | 内存不足时宁可杀进程也不换页 |
| 10 | **数据库 / 延迟敏感** | 极少换出,优先回收文件页 |
| 30 | 通用服务 | 平衡 |
| 60 | **系统默认** | 中等,桌面/普通服务器 |
| 100 | 极度激进 | 频繁换出,延迟暴跌 |
**经验值**:数据库节点(MySQL/PG/Redis)建议 **10**,普通 Web 服务 **20~30**,桌面 **60** 即可。**别把 swappiness 设成 0 当"关掉 swap"**——它只是不主动换出,一旦内存真不够,内核会**强制**换出并引发更猛烈的抖动,不如给个 10 让它温和换出。
### 7.2 真实案例 C:swap 抖动导致数据库延迟飙升
我遇到过一台 PostgreSQL 主库,`si/so` 长期在 200~800 KB/s,`b` 列 2~3,`%iowait` 15% 左右,业务方反馈"查询偶尔卡 2~3 秒"。`free` 显示 `available` 还有 6G,看起来"内存够"。
排查:`cat /proc/sys/vm/swappiness` 显示 **80**(被某人调过)。查 `/proc/meminfo` 的 `SwapTotal` 用了 4G,`vmstat 1 3` 确认 `si/so` 持续非零。
真相:swappiness=80 让内核**过度偏好换出匿名页**,把 PG 的共享内存(shared_buffers 属于匿名页)换到磁盘,每次查询命中已换出的页就要等磁盘 I/O,延迟从 20ms 跳到 2s。
修复:`sysctl -w vm.swappiness=10`,并写入 `/etc/sysctl.conf` 持久化。一小时后 `si/so` 归零,`%iowait` 回落到 2%,延迟恢复。
**教训**:**swappiness 过高 + 有 swap = 延迟炸弹**。数据库场景务必压到 10 以下,并确认 swap 用量不增长。
### 7.3 zram / zswap:把 swap 放内存里
既然换到磁盘慢,就把 swap 压缩后放内存——这是 zram/zswap 的初衷。
- **zram**:创建一块**压缩的内存块设备**当 swap 用。适合内存不大、不想用磁盘 swap 的场景。压缩比通常 2~4 倍。
- **zswap**:在**现有磁盘 swap 之上**加一层压缩缓存,命中就直接从压缩内存读,不落盘。适合有磁盘 swap 但想减少 I/O 的场景。
zram 实战配置(以 2G zram 为例):
```bash
# 加载模块并创建一个 2G 的 zram 设备
modprobe zram
echo 2048M > /sys/block/zram0/disksize
mkswap /dev/zram0
swapon -p 100 /dev/zram0 # 高优先级,先于磁盘 swap 使用
```
zswap 开启:
```bash
echo Y > /sys/module/zswap/parameters/enabled
echo 40 > /sys/module/zswap/parameters/max_pool_percent # 压缩池最多占 40% 内存
```
**经验**:内存型应用(Redis 等)其实**不需要 swap**,zram 更适合"内存有点紧但不想加机器"的场景。如果内存真不够,加内存条永远比任何 swap 方案强。
### 7.4 overcommit_memory 与 overcommit_ratio
内核允许"超卖"内存——进程 malloc 成功不代表真的有物理页。两个参数控制这个策略:
| overcommit_memory | 含义 |
|---|---|
| 0 | 启发式(默认),粗略判断 |
| 1 | **永远超卖**,不检查(危险,容易 OOM) |
| 2 | **严格模式**,禁止超卖,按 `overcommit_ratio` 控制 |
```bash
cat /proc/sys/vm/overcommit_memory # 默认 0
cat /proc/sys/vm/overcommit_ratio # 默认 50,表示可超卖到 swap+50% 内存
```
**数据库场景建议 `overcommit_memory=1`**(MySQL/PG 的 mmap 大块内存常被启发式误判拒绝),但**要配足够 swap 兜底**;如果设成 `2`,要小心 `malloc` 失败导致应用启动报 `Cannot allocate memory`,那其实是 overcommit 拒绝而不是真的没内存。
## 八、内存泄漏检测:/proc/meminfo、slabtop 与 RSS 曲线
内存问题分两种:**一次性压力**(流量高峰)和**慢性泄漏**(只涨不跌)。后者更阴险,因为监控告警"内存持续上涨"往往要几天才触发,而根因可能早就埋下了。
### 8.1 /proc/meminfo 的 Slab 字段
泄漏分两个方向:**用户态**(进程 RSS 涨)和**内核态**(slab 涨)。内核态泄漏最容易被忽略,因为它不体现在任何进程的 RSS 上,只体现在 `Slab` 字段。
```bash
grep -E '^(Slab|SReclaimable|SUnreclaim|Dentry|Inode)' /proc/meminfo
```
**你应该看到什么**:
```
Slab: 9437184 kB # 内核对象缓存总量
SReclaimable: 8388608 kB # 可回收部分(dentry/inode 等)
SUnreclaim: 1048576 kB # 不可回收部分(内核对象、驱动)
Dentry: 204800 kB # 目录项缓存
Inode: 102400 kB # inode 缓存
```
**判定口径**:
- `SUnreclaim` 持续上涨且**不回落** → **内核 slab 泄漏**,重点怀疑驱动、文件系统、内核模块
- `SReclaimable` 上涨 → 通常是 dentry/inode 缓存,**能回收**,压力下会自动降,不是泄漏
- `Dentry` 异常高 → **目录项缓存泄漏**,常见于频繁创建/删除文件的应用
### 8.2 slabtop:定位是哪个内核对象在泄漏
```bash
slabtop -o # -o 按占用排序,-s c 按缓存大小排序
```
**你应该看到什么**:
```
Active / Total Objects (% used) : 123456 / 234567 (52.6%)
Active / Total Slabs (% used) : 4567 / 7890 (57.8%)
OBJS ACTIVE USE OBJ SIZE SLABS OBJ/SLAB CACHE SIZE NAME
123456 123456 100% 0.02K 1204 102 4816K dentry
78901 78901 100% 0.19K 1924 41 15392K kmalloc-192
45678 45678 100% 0.01K 511 89 2044K filp
```
- 某类 object 的 **USE 100% 且对象数持续上涨** → 该类对象泄漏
- `dentry` 100% 上涨 → dentry 泄漏
- `kmalloc-*` 100% 上涨 → 内核模块/驱动泄漏
- `filp`(文件句柄)上涨 → 文件句柄泄漏(配合 `lsof | wc -l` 看)
### 8.3 进程 RSS 持续增长曲线
用户态泄漏的典型特征:**RSS 只涨不跌,或涨上去后不回落**。
```bash
# 每 60 秒记录一次 RSS,连续采集,观察曲线
watch -n 60 'ps -o pid,rss,vsz,comm -p '
```
更精确的曲线采集(写入日志):
```bash
while true; do
echo "$(date +%H:%M:%S) $(ps -o rss= -p )" >> /tmp/rss_$(date +%F).log
sleep 60
done
```
**判定**:正常进程内存会有波峰波谷(GC 后回落、请求完释放),**如果曲线是阶梯式向上、从不在高位回落**,基本就是泄漏。Java 场景用 `jmap -histo` 或 `MAT` 分析堆;C/C++ 场景用 `valgrind` 或 `tcmalloc` 的统计接口。
### 8.4 真实案例 D:内核 slab 泄漏(内存缓慢上涨)
有一次一台机器内存以每天 2G 的速度上涨,`free` 的 `used` 和 `available` 都正常,但 `Slab` 字段每天都在涨,`SUnreclaim` 从 500M 涨到 8G。`slabtop` 看到 `dentry` 的 USE 恒 100%、对象数在涨。
排查:这台机器跑了 NFS 客户端 + 一个高频创建/删除临时文件的 cron 任务。dentry 缓存是 NFS 的目录项,NFS 服务器端在删文件后没有正确释放 dentry,内核缓存越积越多。
修复:升级 NFS 客户端内核模块、优化 cron 任务减少临时文件创建,并手动触发回收:
```bash
echo 2 > /proc/sys/vm/drop_caches # 强制回收 dentry + inode + page cache
```
**教训**:内存只涨不跌、且所有进程 RSS 都正常,**一定要查 slab**。内核态泄漏不看 `ps`,只看 `slabtop` 和 `/proc/meminfo` 的 `SUnreclaim`。
## 九、5 个真实故障排查案例复盘
前面穿插了几个案例,这一节把完整 5 个案例串起来,每个都给出**现象 → 排查命令 → 根因 → 修复**的完整链路。
### 案例A:Java 应用被 OOM kill,但 free 显示 buff/cache 很高
**现象**:Java 服务在容器里被杀,`dmesg` 显示 `Killed process (java)`。宿主机 `free -h` 显示 `buff/cache 21G / used 9G`,看起来"内存充足"。
**排查命令**:
```bash
# 1. 宿主机真实可用
free -h
grep MemAvailable /proc/meminfo # 只剩 1.2G
# 2. 容器视角
cat /sys/fs/cgroup/system.slice/docker-xxxx.scope/memory.events # oom_kill 3
cat /sys/fs/cgroup/system.slice/docker-xxxx.scope/memory.max # 4G
cat /sys/fs/cgroup/system.slice/docker-xxxx.scope/memory.current # 3.98G
# 3. dmesg 确认范围
dmesg -T | grep 'Out of memory' | tail -1 # constraint=CONSTRAINT_MEMCG
```
**根因**:容器 `memory.max=4G`,Java `-Xmx=3.5G` + 元空间 + 线程栈 + 页缓存,`memory.current` 顶到 4G,触发 cgroup 级 OOM。宿主机 `buff/cache` 高是"别的进程读过的文件页",与容器无关,是**红鲱鱼**。
**修复**:`-Xmx` 降到 `2.5G`,`memory.max` 升到 `4.5G`,并给 `oom_score_adj=-100`。
**一句话**:**容器 OOM 看 cgroup,别看宿主机 free**。
### 案例B:K8s 容器反复 OOMKilled
**现象**:K8s Pod 每天被 `OOMKilled` 数次,`kubectl describe` 显示 `Last State: Terminated Reason: OOMKilled`,宿主机内存充足。
**排查命令**:
```bash
kubectl exec -it -- cat /sys/fs/cgroup/memory.events # oom_kill 18
kubectl exec -it -- cat /sys/fs/cgroup/memory.max # 2147483648 (2G)
kubectl exec -it -- jcmd VM.flags | grep MaxHeapSize # 3G
```
**根因**:`memory.limit=2G` 但 JVM `MaxHeapSize=3G`,JVM 按 3G 分配,撞上 2G 的 cgroup 墙。典型"JVM 堆超容器上限"。
**修复**:`-XX:MaxRAMPercentage=60`(JVM 自动感知容器)+ `-Xmx` 显式压到上限 60%,并加 `resources.limits.memory` 与 `requests.memory` 对齐。
**一句话**:**K8s Java 服务 OOMKilled,先核对 Xmx 和 memory.limit 的比例**。
### 案例C:swap 抖动导致数据库延迟飙升
**现象**:PG 主库查询偶尔卡 2~3 秒,`free` 显示 `available` 还有 6G,`%iowait` 15%,业务方以为是磁盘坏了。
**排查命令**:
```bash
vmstat 1 3 # si/so 持续 200~800 KB/s,b 列 2~3
cat /proc/sys/vm/swappiness # 80(被误调)
grep -E '^(SwapTotal|SwapFree)' /proc/meminfo # 已用 4G
```
**根因**:swappiness=80 让内核过度换出匿名页,PG 的 shared_buffers 被换到磁盘,命中时等 I/O。
**修复**:`sysctl -w vm.swappiness=10` 持久化。观察 1 小时,`si/so` 归零、延迟恢复。
**一句话**:**数据库节点 swappiness 必须 <10,si/so 非零就是警报**。
### 案例D:内核 slab 泄漏(内存缓慢上涨)
**现象**:节点内存每天涨 2G,`used`、`available` 正常,`Slab` 持续上涨,所有进程 RSS 正常。
**排查命令**:
```bash
grep -E '^(Slab|SReclaimable|SUnreclaim|Dentry)' /proc/meminfo # SUnreclaim 500M→8G
slabtop -o # dentry USE 100% 且对象数持续涨
```
**根因**:NFS 客户端 + 高频临时文件创建,dentry 缓存未被释放,内核态泄漏。
**修复**:升级 NFS 模块、减少临时文件、`echo 2 > /proc/sys/vm/drop_caches` 手动回收。
**一句话**:**所有进程都正常但内存涨,查 slabtop,查 SUnreclaim**。
### 案例E:节点整体 OOM,但找不到高内存进程
**现象**:宿主机 `dmesg` 报 `Out of memory: Killed process`,`constraint=CONSTRAINT_NONE`(节点级)。但 `ps aux --sort=-rss` 排前几的进程 RSS 都不高,加起来远小于内存总量。
**排查命令**:
```bash
dmesg -T | grep 'Out of memory' | tail -1 # constraint=CONSTRAINT_NONE
# 找所有容器的 memory.events,看哪个 oom_kill 在涨
for cg in /sys/fs/cgroup/system.slice/docker-*; do
echo "$cg $(cat $cg/memory.events 2>/dev/null | grep oom_kill)"
done | grep -v 'oom_kill 0'
# 看是不是内核态占用(slab)或 tmpfs 把内存吃光
grep -E '^(Shmem|SUnreclaim|Slab)' /proc/meminfo
```
**根因**:一个容器里跑了个 **tmpfs 挂载(Shmem)** 写了大量数据,`shmem` 是**不可回收**的,它把节点内存吃光但**不体现在任何进程的 RSS 里**(tmpfs 文件页算 shmem 不算进程)。节点级 OOM,但"找不到凶手"。
**修复**:清理 tmpfs 数据、给 tmpfs 挂载加 `size=` 上限、把大文件移到磁盘。
**一句话**:**节点级 OOM 但没高 RSS 进程,查 Shmem(tmpfs)和 Slab(内核态)**。
## 十、内存排障决策树
把上面所有工具串成一条可执行的路径。拿到"内存疑似故障",按顺序走:
```
内存疑似故障(应用慢 / 进程被杀 / 告警)
│
├─ 1. free -h + /proc/meminfo
│ ├─ available 充足(>10%)→ 问题不在内存,转到 CPU/IO 排障
│ ├─ available 低但 buff/cache 高 → 页缓存在顶,先看能否回收,继续往下
│ └─ SUnreclaim + Shmem 占比高 → 内核态/tmpfs 占用,跳步骤 6(泄漏排查)
│
├─ 2. vmstat 1 5
│ ├─ si/so 持续非零 → 真内存压力,swap 在顶,跳步骤 7(swap 调优)
│ ├─ si/so = 0 但 b 列高 → 不是内存是磁盘 IO 或直接回收,查 iostat
│ └─ free 阴跌 + r 升高 → 内存被慢慢吃掉,跳步骤 6(泄漏)
│
├─ 3. 判断 OOM 是否已发生:dmesg -T | grep -E 'Out of memory|killed'
│ ├─ constraint=CONSTRAINT_MEMCG → 容器级,跳步骤 5(cgroup)
│ ├─ constraint=CONSTRAINT_NONE → 节点级,记下 Killed process,继续
│ └─ 无 OOM 日志但进程挂了 → 不是内存,查应用日志/coredump
│
├─ 4. 节点级 OOM:ps aux --sort=-rss 找高 RSS 进程
│ ├─ 找到凶手 → 看 oom_score / oom_score_adj,保护关键进程,扩容或降内存
│ └─ 找不到(RSS 总和 << 内存)→ 查 Shmem(tmpfs)/SUnreclaim(slab),跳步骤 6
│
├─ 5. 容器级 OOM:cat memory.events + memory.max + memory.current
│ ├─ oom_kill > 0 且 current ≈ max → 容器触顶,核对 Xmx vs memory.limit,扩容或降堆
│ └─ oom_kill > 0 但 current << max → 有瞬时尖峰,查 memory.peak / 大对象分配
│
├─ 6. 泄漏排查:slabtop + RSS 曲线
│ ├─ SUnreclaim / slab 持续涨 → 内核 slab 泄漏,查驱动/NFS/文件系统
│ ├─ 某进程 RSS 只涨不跌 → 用户态泄漏,jmap/valgrind 分析
│ └─ Dentry 高 → 高频文件操作,优化应用
│
└─ 7. swap 调优:swappiness + zram/zswap + overcommit
├─ 数据库节点:swappiness=10,必要时关 swap 或 zram
├─ 延迟敏感:检查 si/so 是否归零
└─ 关键进程:设置 oom_score_adj=-500 保护
```
**入口判断(最快的一条)**:`free -h` 看 `available` → `vmstat` 看 `si/so` → 判断有没有 OOM → 决定看节点还是看 cgroup。三步走完,80% 的问题能定位。
## 十一、15 条命令速查 Cheat Sheet
| # | 命令 | 用途 | 关键输出 |
|---|---|---|---|
| 1 | `free -h` | 内存总览 | available 才是真实可用 |
| 2 | `grep -E '^(MemAvailable\|SUnreclaim\|Shmem)' /proc/meminfo` | 真实可用 + 不可回收部分 | 区分"软/硬"内存 |
| 3 | `vmstat 1 5` | 换页/运行队列 | si/so 持续非零 = 压力 |
| 4 | `ps aux --sort=-rss \| head` | 按 RSS 找大进程 | RSS 偏高估 |
| 5 | `smem -rsk -t` | 按 PSS 排真实内存 | PSS 最接近真实 |
| 6 | `cat /sys/fs/cgroup/.../memory.events` | 容器 OOM 黑匣子 | oom_kill 计数 |
| 7 | `cat /sys/fs/cgroup/.../memory.max` | 容器上限 | 核对与 Xmx 比例 |
| 8 | `cat /sys/fs/cgroup/.../memory.current` | 容器当前用量 | 含页缓存 |
| 9 | `dmesg -T \| grep -E 'Out of memory\|killed'` | OOM 日志 | constraint 判断范围 |
| 10 | `cat /proc//oom_score_adj` | 查看 OOM 保护分 | -1000=永不杀 |
| 11 | `echo -500 > /proc//oom_score_adj` | 保护关键进程 | 设负数 |
| 12 | `cat /proc/sys/vm/swappiness` | 查看 swap 偏好 | 默认 60 |
| 13 | `slabtop -o` | 内核对象缓存排序 | USE 100% 且涨 = 泄漏 |
| 14 | `cat /proc/zoneinfo \| grep -E 'min\|low\|high'` | 内存水位 | 空闲 < min 危险 |
| 15 | `cat /proc/meminfo \| grep -E '^(Slab\|Dentry\|Inode)'` | 内核态内存 | Slab 持续涨 = 泄漏 |
## 十二、FAQ
### Q1:free 显示 used 很高但系统不卡,为什么?
因为 `used` 里混着大量**可回收的 buff/cache**(页缓存、dentry、inode),它们只是"系统主动缓存",不是"被占用"。Linux 的原则是"内存闲着就浪费",所以 `free` 常年低、`buff/cache` 常年高,这是健康的。**要看 `available` 这一列**,它才是"现在能立刻给进程用"的内存。或者用 `vmstat 1 5` 看 `si/so`:如果持续为 0,说明根本没有换页压力,内存一点也不紧张。
### Q2:容器被 OOMKilled 但宿主机内存还很多,怎么回事?
容器的内存上限是 cgroup 的 `memory.max` 控制的,**和宿主机物理内存是两套账本**。容器 `memory.current` 撞到 `memory.max`(比如你设了 1G,但 JVM 堆就快 1G 了),内核就在**该容器范围内**杀进程,宿主机会不会 OOM 跟它无关。排障看容器自己的 `memory.events` 的 `oom_kill` 计数、`memory.max` 和 `memory.current`,并核对 JVM `-Xmx` 是否超过容器上限的 60~70%。
### Q3:swappiness 设置多少合适?
默认 60。**数据库或延迟敏感应用建议 10~30**,减少匿名页换出,避免 swap 抖动拖垮延迟。但记住三点:① `swappiness` 只影响"回收时多倾向匿名页",**文件页回收另有一套**,别当万能开关;② 设成 0 不等于关掉 swap,内存真不够时内核会**强制**换出,抖得更狠;③ 数据库场景要**同时确认 si/so 归零**,光设参数不验证等于没做。
### Q4:怎么判断是内存泄漏而不是正常增长?
三个信号叠加:① 某个进程的 **RSS 只涨不跌**(正常会有波峰波谷,GC/请求结束后回落);② `/proc/meminfo` 的 **Slab / SUnreclaim 持续上涨且不回落**(内核态泄漏,进程 RSS 反而不涨);③ **slabtop** 里某类对象 `USE 100%` 且对象数持续涨。如果内存涨到一定程度后稳定下来,那是新的正常水位(缓存预热),不是泄漏;**只涨不跌才是泄漏**。
### Q5:为什么 OOM 杀的是我的关键进程,而不是那个大内存的普通进程?
OOM killer 按 `oom_score`(badness 分数)挑受害者,分数主要看内存占用,**但会被 `oom_score_adj` 修正**。如果你的关键进程 `oom_score_adj` 是 0 或正数,而那个"大内存进程"设了负数,内核会优先考虑分数高的——哪怕它占用更少。所以:**关键进程设 `oom_score_adj=-500`,非关键批处理设正数**,让"谁该死"由你说了算,而不是默认规则。
### Q6:available 明明还有好几个 G,怎么还是 OOM 了?
`available` 是"理论上能立即给的内存",但**内存分配是瞬间的**。比如 JVM 突然要扩 2G 堆,或者一次大查询要 mmap 大量内存,瞬间分配超过 `available`,直接 reclaim 都来不及,就会 OOM。`available` 是**长期趋势**指标,不是"防瞬间尖峰"的保险。看 `memory.peak`(cgroup v2 有 `memory.peak`)或 `vmstat` 的 `r` 列突变,才能抓到瞬间压力。
---
**写在最后**:内存排障的核心就一句话——**`free` 看 available,`vmstat` 看 si/so,OOM 看 dmesg 的 constraint,容器看 memory.events**。四个入口记住,剩下的都是细节。我最想强调的是:OOM killer 杀人不打招呼,**提前给关键进程设 `oom_score_adj`、给容器核对 `Xmx` 与 `memory.max` 的比例、给数据库压 `swappiness`**,这三件事做在故障前,比任何排障技巧都值钱。
## 相关文章
- [Linux 监控 CPU:top 一行 5 个百分比字段排障决策树](linux-cpu-monitor.md)
- [eBPF 可观测性原理:无侵入监控实战,从 kernel trace 到生产落地](ebpf-observability.md)
- [node_exporter 指标缺失排查:6 大症状决策树 + 4 个常见翻车原因](node-exporter-troubleshoot.md)
---
## Loki 日志聚合实战:单集群部署与日志管道优化
URL: https://ops.jiunotes.top/monitor/loki-log-aggregation.md
摘要: 从 ELK 迁移 Loki 的真实成本账,Docker Compose 10 分钟起单集群,label 基数设计原则,LogQL 速查与 5 个生产事故复盘。
### Answer
Loki 是"先按 label 聚合、后压缩日志本体"的时序日志系统:index 极小、存储便宜、查询依赖 label 设计。
单集群 Docker Compose 10 分钟可跑,生产关键是 label 基数控制在 10 万以内、retention 一定要显式配、Alloy/Promtail 选型按是否 K8s 环境。
5 个必踩的坑:日志量暴增、label 基数爆炸、retention 不生效、federation 延迟、多租户误配。
### FAQ
Q: Loki 和 ELK 到底什么区别?
A: 本质区别是索引策略。ELK 对每行日志做 full-text index,index 和原文同数量级;Loki 只给 label 建索引,日志本体按段压缩存放,index 只有原文的 1%-5%。代价是查询必须走 label,纯全文搜索比 ES 慢很多。
Q: Loki 单节点能扛多少?
A: 社区经验值:single-binary 模式大约 20-50 MB/s 写入,100-200 万活跃 stream。超过就该上微服务(distributor/ingester/query-frontend/querier 分离)或迁 Mimir/Grafana Cloud。我在 30 万 stream 左右开始遇到查询延迟明显上升。
Q: promtail 是不是弃用了?新项目用 Alloy 吗?
A: Promtail 已进入维护模式(不删功能但不接新特性),Grafana 官方推荐 Alloy 作为下一代 collector。新项目直接上 Alloy,老系统如果没出问题不用急着迁。Alloy 用 River 语言,语法比 Promtail YAML 更灵活但学习曲线稍陡。
Q: label 到底怎么设计才算好?
A: 三条铁律:1)基数≤10 万;2)只用低基数字段(app、env、severity、region),不要 pod_name、container_name、user_id、trace_id;3)高基数字段一律用结构化日志 + LogQL 的 unwrap/parse。Loki 的 index 是 label 乘积,多一个高基数 label,index 体积可能翻几十倍。
Q: retention 怎么配才真的会删?
A: 生产必须显式配 compactor.retention_enabled: true + retention_period,否则数据只写不删。另外要定期用 loki-bucket-tool 验证老 block 真的删除了,别只信配置。
Q: Loki 数据能保留多久不贵?
A: 官方数字:10 GB 日志每天,压缩后大约 500 MB,一年也就 180 GB 磁盘。开 S3/GCS 对象存储 + boltdb-shipper 后基本无感。ELK 同规模一年存储成本通常是 Loki 的 5-10 倍。
### Full Content
# Loki 日志聚合实战:单集群部署与日志管道优化
我这台机器上跑着 [Prometheus](https://prometheus.io/docs/) 三年,指标体系很成熟。但日志一直是短板——早期是 ELK,5 节点 ES 集群、每台 256G 内存,一年 400 TB 存储,成本比整套 Prometheus 还高。后来切到 Loki,跑完一年后的现实:存储成本砍到 1/8,P99 查询延迟跟 ES 差距 200ms 以内(前提是 label 设计对),[Grafana](https://grafana.com/docs/) 一个数据源搞定指标+日志+trace。
这篇文章把我从 ELK 迁到 Loki 的过程、部署模板、log collection 选型、label 设计原则、LogQL 速查、5 个真实事故复盘摊开讲。目标读者是那种"没预算上 ELK、也不想自己造轮子"的一线运维/SRE。所有配置都可以直接抄,但抄之前先想清楚 label 怎么设计——这一条决定了 Loki 是帮你省钱还是给你挖坑。
## 一、为什么从 ELK 迁到 Loki——架构差异与成本账
### 1.1 架构差异
ELK 写入路径:Filebeat → Logstash → [Elasticsearch](https://www.elastic.co/guide/en/elasticsearch/reference/current/index.html)。ES 每条日志写入做倒排索引(inverted index),每个 token 映射到"哪些文档包含它"。全文搜索靠这个索引——搜 `error timeout 500ms`,ES 在 index 里找三个 token 都命中的文档。
Loki 完全不同。日志按 label 组织成 stream,每个 stream 是一串按时间追加的 chunk,chunk 里存原始日志文本(LZ4 压缩)。查询流程:先用 label 选出相关 stream → 只在这几个 stream 的 chunk 里做 regex/logql 匹配 → 返回命中的原始行。**日志本体不建全文索引**,只在 label 上有 index。
| 维度 | Elasticsearch | Loki |
|---|---|---|
| 索引对象 | 每个 token(倒排) | 仅 label |
| Index 大小 | ≈ 原文 80-100% | ≈ 原文 1-5% |
| 全文搜索速度 | 极快 | 慢(扫 chunk) |
| 按 label 过滤速度 | 快 | 极快 |
| 存储成本 | 高 | 低 5-10 倍 |
| 查询语言 | Lucene DSL | LogQL |
| 运维复杂度 | 高(分片、rebalance) | 低(单二进制起) |
**ES 把力气花在"任你怎么搜都能秒回",Loki 把力气花在"只要 label 对得上,秒回"。** 你的查询模式决定选谁。
### 1.2 我的真实成本账
我在 200 节点 [K8s](https://kubernetes.io/docs/) 集群上跑过两年 ELK、一年半 Loki,规模都差不多(每天约 3-5 TB 原始日志,保留 90 天):
**ELK 侧**:5 节点 × 256G 内存 × 2TB SSD × 4 盘 + Logstash/Kibana/Filebeat。一年存储成本 ≈ 60 万,P99 查询延迟 300-800ms,日常维护分片规划、rebalance、hot-warm-cold 分层。
**Loki 侧**:4 节点 × 64G 内存 × 2TB SSD(后扩 3TB),微服务模式。一年存储成本 ≈ 8 万,P99 延迟 200-600ms,主要维护就是 retention 检查和 compactor 监控。
**注意**:这套对比的前提是"查询都以 label 为主"。如果日常要跑 `* :~ "user_id=12345 AND error"` 这种没 label 的全文搜,Loki 会慢得多,甚至超时。我这边 Grafana 上 90% 的查询都有明确 app/env/severity 前缀,剩下 10% 是故障排查时的全文搜,能接受慢一点。
### 1.3 三种部署模式
1. **Single binary(单二进制)**:所有组件一个进程,本地磁盘存数据。适合 PoC、小规模(< 100 万 stream)、"日志不是核心资产"。
2. **Microservice(微服务)**:distributor / ingester / querier / query-frontend / query-scheduler / compactor / index-gateway 独立部署。适合中大规模、多机房、要 HA。
3. **Loki Enterprise / Grafana Cloud**:Grafana 官方托管,带多租户、更好的查询优化。贵但省心。
我在生产用第二种,但**入门建议从单二进制开始**——10 分钟能跑起来,先让团队感受 Loki 的工作方式,再考虑要不要上微服务。
## 二、Loki 部署路径:[Docker](https://docs.docker.com/) Compose 单集群(10 分钟跑起来)
我维护的一份可直接用的模板,`[git](https://git-scm.com/doc) clone` 下来改两个 IP 就能跑,Loki + Grafana + Alloy 一个 compose 文件搞定。
### 2.1 目录结构
```bash
mkdir -p ~/loki-stack/{etc/loki,etc/grafana/provisioning/datasources,etc/alloy,data/{loki,grafana,alloy}}
cd ~/loki-stack
```
### 2.2 loki-config.yaml
`etc/loki/loki-config.yaml`:
```yaml
auth_enabled: false
server:
http_listen_port: 3100
grpc_listen_port: 9096
common:
instance_addr: 127.0.0.1
path_prefix: /loki
storage:
filesystem:
chunks_directory: /loki/chunks
rules_directory: /loki/rules
replication_factor: 1
ring:
kvstore:
store: inmemory
schema_config:
configs:
- from: 2024-01-01
store: tsdb
object_store: filesystem
schema: v13
index:
prefix: loki_index_
period: 24h
limits_config:
max_line_size: 32KB
max_entry_size_safe: 512KB
max_label_names_per_series: 30
max_entries_limit_per_query: 5000
max_query_series: 500
max_query_bytes: 200MB
retention_period: 90d
compactor:
working_directory: /loki/compactor
compaction_interval: 10m
retention_enabled: true
delete_request_store: filesystem
shared_store: filesystem
analytics:
reporting_enabled: false
```
关键几项:
- `replication_factor: 1` 是单机。生产微服务要设 3,否则单点数据丢失。
- `store: tsdb` 是 2024 年后推荐,比 boltdb 更适合海量 stream。
- `retention_period: 90d` 一定要写,不写就是永不删除。
- `compactor.retention_enabled: true` 是让 retention 真正生效的开关——只写 `retention_period` 不开这个,数据不会删(详见 6.3)。
- `max_query_series: 500` 是防 label 基数爆炸的关键。
### 2.3 Alloy 配置(替代 Promtail)
`etc/alloy/config.alloy`:
```river
// 采集本机 /var/log/**/*.log
local.file_match "syslog" {
path_target = "/var/log/**/*.log"
ignore_missing = true
}
loki.source.file "syslog" {
targets = local.file_match.syslog.targets
forward_to = loki.write.default.receiver
}
// 采集 Docker 容器日志
docker.container "docker" {
match_all = true
}
loki.source.docker "docker" {
targets = docker.container.docker.targets
forward_to = loki.write.default.receiver
}
// 采集 journald
loki.source.journald "system" {
forward_to = loki.write.default.receiver
}
loki.write "default" {
endpoint {
url = "http://loki:3100/loki/api/v1/push"
external_labels = {
env = "prod",
region = "cn-north-1",
}
}
}
```
注意 `loki.write` 的 `external_labels` 是**每个日志都会加的公共 label**,加太多就是全局基数爆炸。建议只放 env、region 这种真低基数字段,其他用 `loki.process` 或 `loki.relabel` 动态注入。
### 2.4 docker-compose.yml
```yaml
version: "3.9"
services:
loki:
image: grafana/loki:3.0.0
container_name: loki
command: -config.file=/etc/loki/loki-config.yaml
volumes:
- ./etc/loki/loki-config.yaml:/etc/loki/loki-config.yaml:ro
- ./data/loki:/loki
ports:
- "3100:3100"
networks: [loki-net]
deploy:
resources:
limits:
memory: 4G
alloy:
image: grafana/alloy:v1.5.0
container_name: alloy
volumes:
- ./etc/alloy/config.alloy:/etc/alloy/config.alloy:ro
- /var/log:/var/log:ro
- /var/lib/docker/containers:/var/lib/docker/containers:ro
- /var/lib/docker/network/files:/var/lib/docker/network/files:ro
- /run/systemd/journal:/run/systemd/journal:ro
- /sys/fs/cgroup:/sys/fs/cgroup:ro
- /:/rootfs:ro
command: -run=/etc/alloy/config.alloy
depends_on: [loki]
networks: [loki-net]
grafana:
image: grafana/grafana:11.1.0
container_name: grafana
ports:
- "3000:3000"
environment:
- GF_SECURITY_ADMIN_USER=admin
- GF_SECURITY_ADMIN_PASSWORD=change_me_please
- GF_USERS_ALLOW_SIGN_UP=false
volumes:
- ./etc/grafana/provisioning:/etc/grafana/provisioning:ro
- ./data/grafana:/var/lib/grafana
depends_on: [loki]
networks: [loki-net]
networks:
loki-net:
driver: bridge
```
### 2.5 Grafana 数据源
`etc/grafana/provisioning/datasources/datasource.yaml`:
```yaml
apiVersion: 1
datasources:
- name: Loki
type: loki
access: proxy
url: http://loki:3100
jsonData:
maxLines: 1000
isDefault: true
```
### 2.6 启动与验证
```bash
cd ~/loki-stack
docker compose up -d
curl -s http://localhost:3100/ready # 返回 "ready" 即正常
# 验证日志写入
curl -X POST http://localhost:3100/loki/api/v1/push \
-H "Content-Type: application/json" \
-d '{"streams":[{"stream":{"job":"manual-test","env":"prod"},
"values":[[1700000000000000,"hello from manual push"]]}]}'
# 返回 204 即成功
```
Grafana 访问 http://localhost:3000,admin/change_me_please 登录,Explore → Loki 数据源,跑 `{"job":"manual-test"}` 应该看到那条日志。
到这里 Loki 单集群已经跑起来了。生产要用的话至少改三处:`storage.filesystem` 换成 S3/GCS/MinIO 并把 `object_store` 改成 `s3`;加独立的 compactor 服务别跟 ingester 抢 CPU;Grafana 密码改掉或加反代基本认证。
## 三、Log Collection 三件套:Alloy / Promtail / Vector 选型
日志采集是 Loki 生态里最容易被忽视、但踩坑最多的地方。三种 collector 对比:
| 特性 | Promtail | Alloy | Vector |
|---|---|---|---|
| 维护状态 | 维护模式 | Grafana 主推 | 活跃(Timber) |
| 配置格式 | YAML | River(DSL) | TOML/YAML/JSON |
| 语言 | Go | Go | Rust |
| 结构化解析 | 有限 | 丰富 | 丰富(VRL) |
| 多后端转发 | 弱 | 强 | 强 |
| 学习曲线 | 低 | 中 | 中 |
**Promtail**:老系统里已经在用、跑得好好的,不用换。稳定、轻、社区例子多。缺点是没新特性了,复杂解析绕不过去。
**Alloy**:Grafana 生态新标准。如果团队已经在用 Grafana + Loki + Tempo,Alloy 最自然——一个 agent 推三种遥测到三个后端。缺点:River 语法要学习,配置文件比 Promtail 长。
**Vector**:如果后端不止 Loki(还有 Splunk、DataDog、GCP Logging),或者已经在用 Vector 做别的,选它。Rust 写的、性能最好、remap 语言能力强。
**我在生产用的组合**:K8s 集群里 Alloy 作为 DaemonSet,每个 node 一个 agent,采集 `/var/log/containers/*.log`(容器日志)、`journald`(节点系统日志)、`/var/log/nginx/*.log`。配置关键片段:
```river
kubernetes.pod "pods" {}
kubernetes.pod_logs "syslogs" {
forward_to = [loki.process.pipeline.receiver]
}
// 关键:只保留低基数 label,高基数字段挪进结构化日志
loki.process "pipeline" {
stage.static {
values = { env: "prod" }
}
stage.rename {
rename = {
kubernetes_namespace_name = "namespace",
kubernetes_pod_name = "pod",
kubernetes_container_name = "container",
}
}
forward_to = [loki.write.main.receiver]
}
```
`stage.rename` 和 `stage.labels` 是 label 设计关键——**只保留你要过滤的字段作为 label,其他丢进结构化日志**,用 LogQL 的 `parse` 或 `unwrap` 查。
## 四、Label 设计黄金三原则
Loki 的性能 80% 取决于 label 设计。搞错了直接给 index 制造灾难。
### 4.1 基数控制:不同 label 值组合 ≤ 10 万
**基数 = 所有 label 组合的唯一值总数**。Loki 的 index 存的是每个组合的元数据,基数越大 index 越大、内存占用越高、查询越慢。
举例:3 个 label `app`(10 种)、`env`(3 种)、`pod`(2000 种),总基数 = 10×3×2000 = 60000。换 `pod` 为 `namespace`(5 种):10×3×5 = 150。**差 400 倍**。
用 Grafana Explore 或 Loki API 看当前基数:
```logql
count by (__name__) (count_over_time({__name__!=""} [5m]))
```
或:
```bash
curl -s "http://loki:3100/loki/api/v1/labels/app/values" | jq .
```
生产建议:定期跑 `sum by(app, env, namespace) (count_over_time({namespace=~".+"} [1m]))`,丢 Grafana dashboard,一旦超 10 万就告警。
### 4.2 只用低基数字段,别打 pod_name/container_name
**低基数 label(推荐)**:`app`、`env`、`region`、`namespace`、`severity`(LogQL 从内容提取)、`cluster`、`node`(≤200 节点才用)。
**高基数 label(禁用)**:`pod_name`、`container_name`、`pod_ip`、`user_id`、`session_id`、`request_id`、`trace_id`、`span_id`、`file_path`、`url`、`uri_path`、`host`。
**替代方案**:把高基数字段放进**结构化日志**(JSON 字段),用 LogQL 的 `parse`、`unwrap` 提取,只在查询时过滤,不建 index。
例如日志:
```json
{"timestamp":"2026-10-27T10:00:00Z","level":"error","service":"payment","user_id":"U123456","trace_id":"abc-123","msg":"payment timeout"}
```
label 只留 `{app="payment", env="prod"}`,`user_id`、`trace_id` 全在 JSON 里。要查某个用户报错:
```logql
{app="payment"} | json | user_id="U123456" | line_format "{{.msg}}"
```
### 4.3 别塞 30 个 label,label 是"过滤开关"
有新人曾经给我提 PR,想给每个日志加 15 个 label,理由是"这样查询灵活"。我让他先看 label 组合数量再决定——他的应用有 3000 个 pod、50 个 namespace、10 种日志级别,label 组合直接 150 万,Loki 直接 OOM。
label 的正确心态:**它是给 Grafana 下拉框用的过滤器,不是给全文搜索用的**。真正想"搜任意内容"应该走全文匹配(`| ~"regex"`),而不是给内容加 label。
K8s 环境实用 label 模板:
```
{
app: "payment-service", # 从 helm chart name 取
env: "prod", # 从 namespace label 取
namespace: "payment", # K8s namespace
cluster: "cn-north-1", # 集群名
severity: "error", # LogQL 从日志内容提取,不用采集时打
}
```
4 个 label,基数 = 应用数 × 环境数 × namespace 数 × 集群数。我这配置下 30×3×20×2 = 3600,非常健康。
### 4.4 常见反模式速查
| 反模式 | 后果 | 正确做法 |
|---|---|---|
| 每个 pod 一个 label | index 爆炸 | 用 namespace + pod_name 放结构化日志 |
| 每个文件路径一个 label | 同上 | 用文件名前缀做 label |
| 每个 IP 一个 label | 同上 | 用 subnet/region |
| 把 timestamp 当 label | 每个日志一个新组合 | timestamp 是 Loki 内建字段 |
| 把 msg 内容做 label | 同上 | 用 `| parse` 或 `| regexp` 提取 |
| label 值包含时间戳 | 同上 | label 必须是"稳定分类" |
## 五、LogQL 速查表
LogQL 是 Loki 的查询语言,两大类:**Stream Selector** 和 **Pipeline**。
### 5.1 语法总览
```
{label1="value1", label2="value2"} # Stream Selector:选 stream
| log_format "" # 格式日志(非 JSON)
| json # 解析 JSON 日志为结构化字段
| unwrap # 提取指定字段作为日志文本
| parse "" # 正则提取字段
| regexp "^(?\d{3})" # 命名捕获组
| line_format "" # 重新格式化日志行
| pattern # 匹配指定模式(Loki 特有优化)
| rate() # 速率
| count_over_time() # 计数
| sort_by() # 排序
| limit() # 限制返回
```
### 5.2 10 条速查命令
**1. 按 label 拉日志**
```logql
{app="payment", env="prod"}
```
**2. 过滤 ERROR 日志**
```logql
{app="payment"} |~ "ERROR"
```
`|=` 精确匹配(整行),`|~` 正则匹配(部分)。
**3. JSON 解析并提取字段**
```logql
{app="payment"} | json | line_format "user={{.user_id}} trace={{.trace_id}} {{.msg}}"
```
**4. 结构化日志里过滤 user_id**
```logql
{app="payment"} | json | user_id="U123456"
```
**5. 正则解析非 JSON 日志**
```logql
{app="[nginx](https://nginx.org/en/docs/)"} | parse "GET /path - {{.status}} {{.bytes}}ms" | unwrap status
```
**6. 按 level 分组计数**
```logql
sum by (level) (count_over_time({app="payment"} | json [5m]))
```
**7. 速率:每秒日志条数**
```logql
sum(rate({app="payment"}[5m]))
```
**8. 匹配模式忽略噪音**
```logql
{app="payment"} | pattern ` payment succeeded ms` | drop __error__
```
`pattern` 会匹配符合模式的所有日志并合并展示(Loki 特有优化)。
**9. 排序 + 限制返回**
```logql
{app="payment"} | json | line_format "{{.timestamp}} {{.msg}}" | sort_by ({{.timestamp}}) | limit (50)
```
**10. 宽筛 + 精筛性能优化**
```logql
{app="payment"} |~ "(?i)error" | json | level="error"
```
先用正则快速筛(`(?i)` 忽略大小写),再 JSON 精确匹配。这种"宽筛 → 精筛"模式比单一过滤快 3-5 倍。
### 5.3 结构化日志解析的三个陷阱
**陷阱 1:日志不是标准 JSON**
logfmt 格式如 `level="error" msg="xxx"` 用 `parse`:
```logql
{app="myapp"} | parse `level={{$level}} msg="{{.msg}}"`
```
**陷阱 2:嵌套字段**
`{"user":{"id":"U123"}}` 用点路径:
```logql
{app="myapp"} | json user.id | unwrap user.id
```
**陷阱 3:日志行太长被截断**
Loki 默认 `max_line_size` 32KB,超过截断、JSON 解析失败。调大或 `multiline` 合并:
```logql
{app="java-app"} | multiline pattern `\tat ` max_wait=50ms
```
Java 堆栈的多行日志必须 multiline,不然一行 stack trace 会被拆成 N 条。
### 5.4 rate() 和 count_over_time() 的区别
- `count_over_time({app="x"}[5m])`:过去 5 分钟有多少条日志,返回**时间序列**
- `sum(count_over_time(...))`:所有 stream 加起来
- `sum by (label) (count_over_time(...))`:按 label 分组
- `rate({app="x"}[5m])`:每秒速率(`count_over_time / 时间窗口`)
告警规则写法:
```yaml
groups:
- name: loki-alerts
rules:
- alert: ErrorRateHigh
expr: |
sum by (app) (count_over_time({env="prod"} | json | level="error" [5m])) > 100
for: 5m
labels:
severity: warning
annotations:
summary: "{{ $labels.app }} 5 分钟内 error > 100 条"
```
### 5.5 unwrap 常见用法
```logql
# 提取 trace_id 用于关联 Tempo
{app="payment"} | json | unwrap trace_id
# 提取后重命名输出
{app="payment"} | json level, msg | line_format "L={{.level}} {{.msg}}"
# 提取嵌套字段
{app="payment"} | json user.id, user.email | line_format "{{.user.email}}:{{.user.id}}"
```
## 六、5 个真实事故复盘
每个都按**症状 → 根因 → 处置 → 长期修复**四段展开。
### 6.1 日志量暴增导致磁盘爆
**症状**:早 9 点收到"磁盘使用率 > 90%"告警,Loki 节点磁盘 2TB 已用 95%,compactor 卡死。
**根因**:某个新上线服务没做日志级别控制,把 INFO 日志里所有请求详情都打了出来,日志量从 5 MB/s 飙到 60 MB/s,一晚上写爆 2 TB。
**处置**:Alloy 加限流 `rate_limit_mb_per_sec = 10`;通知开发把日志级别从 INFO 改回 DEBUG(生产);临时把 retention 从 90d 改到 30d;扩容磁盘到 4 TB、告警阈值改到 70%。
**长期修复**:Loki 配 `ingestion_rate_mb`、`ingestion_burst_size_mb` 限制单 stream 写入速率;Alloy 侧对每个日志源配独立 rate limit;Grafana 加"当前写入速率"面板,超 20 MB/s 告警;开发规范:生产默认 INFO,DEBUG 必须通过动态开关开。
### 6.2 label 基数爆炸导致查询超时
**症状**:Grafana 上所有 Loki 查询超时(> 30s),`loki_ingester_memory_series_count` 从 5 万飙到 80 万,ingester OOM 反复重启。
**根因**:某业务方给 Alloy 配置加了 `stage.static`,把 `user_agent`、`request_id` 都塞进 label。这两个字段几乎每行都不同,1 小时内 label 组合从 5 万涨到 80 万。
**处置**:查 `count by (__name__) (count_over_time({__name__!=""}[1m]))` 定位突增 label;通知业务方回滚 Alloy 配置;重启 ingester 释放内存;用 `loki-bucket-tool` 清理历史 label。
**长期修复**:部署"基数监控" `sum(count_over_time({__name__!=""}[5m]))` 超 10 万告警;`max_label_names_per_series` 从 30 降到 15;Alloy 配置 label 走 SRE 审批;配置 `stage.labels.drop_namespaces` 白名单。
**复盘**:Loki 的 index 是乘积式的。`A × B × C × D × E`,任何一维爆炸都会拖累全局。**基数监控是唯一能提前发现的手段**。
### 6.3 存储保留不生效导致磁盘撑爆
**症状**:磁盘使用率一周涨 30%,Loki 配置里明明写了 `retention_period: 90d`。查了 30 天前的日志还能查到。
**根因**:只配了 `retention_period`,没开 `compactor.retention_enabled: true`。Loki 3.x 之前 retention 是"可选功能"默认关闭,不启用时 retention_period 只是"文档性注释"。
**处置**:补上 `compactor.retention_enabled: true`;重启 Loki 触发 compactor;观察 `loki_boltdb_shipper_active_index_files` 和 `loki_compactor_deletion_request_created_total` 指标;24 小时后老数据开始被删。
**长期修复**:配置文件加"生产检查清单",`retention_enabled` 必须为 true;compactor 健康检查;定期用 `loki-bucket-tool verify` 检查对象存储里老 block 是否真的删除。
**教训**:Loki 很多功能"配了但默认关",别以为写了就生效。**每次改配置必须看日志和指标验证**。
### 6.4 跨机房 federation 数据同步延迟
**症状**:北京机房 Grafana 查上海机房日志,延迟 10-30 分钟,有时甚至查到 1 小时前的日志。开发反馈"我们的日志看不到"。
**根因**:异地容灾用了 Loki federation(上海 Loki 作为北京的 upstream),但 federation 是**拉取模式**:北京 querier 定期拉上海数据,默认 30s 一次。加上跨机房网络延迟、上海 Loki 查询慢,累积延迟能到 10 分钟以上。而且 federation 有 bug:网络抖动时数据可能跳过一段。
**处置**:短期拉取间隔从 30s 改到 10s;中期改用 Loki **multi-region**(每个机房独立 Loki,Grafana 数据源聚合展示,查询时并行请求两个 Loki);长期评估 Thanos 模式的 Loki 联邦方案。
```yaml
# loki-config.yaml
querier:
max_outstanding_requests: 64
federation:
upstreams:
- name: shanghai
address: http://loki-shanghai:3100
poll_interval: 10s
lookback_window: 30m
```
**教训**:federation 适合"备份查询",不适合"实时聚合查询"。真要多机房高可用,用微服务的 multi-region 部署,每个机房独立写入,查询时跨机房 fanout。
### 6.5 多租户误配导致日志泄漏
**症状**:某次安全审计发现,A 团队 Grafana 面板里能看到 B 团队日志,包括用户 PII 数据。
**根因**:Loki 开启了 `auth_enabled: true`,但**没配多租户**,所有租户共用一个 org_id。开发用同一个 Grafana 登录不同数据源时,Loki 无法区分归属,任何 org 都能看到所有数据。
**处置**:立刻关闭该数据源访问权限;排查泄漏范围(哪些日志、多少时间窗口);联系受影响团队删除 Grafana 缓存里的截图、导出数据。
**长期修复**:开启 `auth_enabled: true` + `multiorg_config`,每个租户独立 org_id;Alloy 推送强制带 `X-Scope-OrgID` header;Grafana 数据源 URL 里加 `?org_id=team-a`;加 `limits_config.per_stream_rate_limit` 按租户限速;定期审计 `loki_request_duration_seconds` 按 org_id 分布。
```yaml
auth_enabled: true
multiorg_config:
allow_by_default: false
tenants:
team-a:
retention: 30d
ingestion_rate_mb: 5
team-b:
retention: 60d
ingestion_rate_mb: 10
```
**教训**:多租户不是"配一下 auth"就完事。**Grafana 数据源、Alloy header、Loki auth 三层都要配**,任何一层错都会导致越权访问。
## 七、生产 checklist:8 个必须过的检查点
每次新集群上线都要过这张清单,抄一份贴到工单里:
**1. 基础部署**:写入超 20 MB/s 或 50 万 stream 切微服务;至少开 S3/MinIO 做主存储;compactor 独立进程;Grafana 密码改默认。
**2. 数据与安全**:`retention_period` 显式配 + `retention_enabled: true`;多租户每团队独立 org_id;Loki → 客户端 TLS;PII 字段在 Alloy 用 `stage.regex` 脱敏成 `***`。
**3. Label 设计**:基数 ≤ 10 万;禁用 pod_name/container_name/user_id 作为 label;高基数字段放结构化日志;Alloy 配 label 白名单。
**4. 查询优化**:`max_query_series: 500`、`max_query_bytes: 200MB`;query-frontend 独立部署;开 `query_range_results_cache` 结果缓存;Grafana dashboard 强制时间窗口选择。
**5. 监控**:写入速率 `rate(loki_distributor_lines_received_total[1m])`;基数 `sum(count_over_time({__name__!=""}[5m]))`;磁盘 `node_filesystem_avail_bytes / node_filesystem_size_bytes`;查询延迟 P99 `histogram_quantile(0.99, rate(loki_request_duration_seconds_bucket[5m]))`;compactor `loki_compactor_successful_compactions_total`。
**6. 灾备**:微服务 `replication_factor: 3`;boltdb-shipper 定期导出到另一 region 对象存储;RTO/RPO 明确(我们目标 RTO 30 分钟、RPO 5 分钟);每季度做恢复演练。
**7. 成本**:每月成本报告跟 ELK 对比;超预算 20% 告警;90 天以上走 tiered storage(Loki 3.x 支持冷数据压缩)。
**8. 变更**:Alloy/Loki 配置走 GitOps(argocd/kustomize);上线必须走 checklist;变更前用 loki-benchmark 或 grafana/loki-bench 压测。
## 八、速查 Cheat Sheet
### 8.1 Loki HTTP API
```bash
# 健康检查
curl http://loki:3100/ready
# 列出所有 label 名
curl http://loki:3100/loki/api/v1/labels
# 列出某 label 的所有值
curl "http://loki:3100/loki/api/v1/labels/app/values"
# 手动推送日志
curl -X POST http://loki:3100/loki/api/v1/push \
-H "Content-Type: application/json" \
-d '{"streams":[{"stream":{"job":"test"},"values":[[1700000000000000,"hello"]]}]}'
# 查询日志
curl "http://loki:3100/loki/api/v1/query_range?query={app=\"payment\"}&start=1700000000000000&end=1700003600000000&limit=100"
```
### 8.2 LogQL 快速对照
| 需求 | LogQL |
|---|---|
| 拉日志 | `{app="x"}` |
| 过滤错误 | `{app="x"} \|~ "ERROR"` |
| 精确匹配整行 | `{app="x"} \|="ERROR"` |
| JSON 解析 | `{app="x"} \| json` |
| 提取字段 | `{app="x"} \| json \| unwrap field` |
| 字段过滤 | `{app="x"} \| json \| level="error"` |
| 正则解析 | `{app="x"} \| parse "code={{$code}}"` |
| 5 分钟计数 | `count_over_time({app="x"} [5m])` |
| 5 分钟速率 | `sum(rate({app="x"}[5m]))` |
| 分组统计 | `sum by (level) (count_over_time({app="x"} \| json [5m]))` |
| 排序 | `... \| sort_by ({{.timestamp}})` |
| 限制条数 | `... \| limit (100)` |
| 忽略大小写 | `{app="x"} \|~ "(?i)error"` |
| 排除关键字 | `{app="x"} \| !~ "DEBUG"` |
| 多行合并 | `{app="java"} \| multiline pattern="\tat "` |
### 8.3 常用运维命令
```bash
# 查看 ingester 状态
curl -s http://loki:3100/loki/api/v1/ring | jq '.ingesters'
# 查看 compactor 状态
curl -s http://loki:3100/loki/api/v1/compactor | jq .
# 列出对象存储里的 blocks
loki-bucket-tool index list
# 删除某个 block(危险!)
loki-bucket-tool block delete
# 手动触发 compaction
loki-block-builder build --since=1h --until=now --output-dir=/tmp
# 验证 index 完整性
loki-bucket-tool index verify --store=s3
```
### 8.4 常见问题排查
| 问题 | 排查方向 |
|---|---|
| 查询超时 | 看 `loki_query_frontend_timeout`、检查 limits_config、缩小时间窗口 |
| 写入 500 | 看 `max_line_size`、检查 JSON 是否合法、看 ingest rate limit |
| 日志延迟高 | 看 Alloy 到 Loki 网络、看 ingester shard、查 rate limit |
| 数据丢失 | 看 compactor 健康、检查 `replication_factor`、查 boltdb shipper |
| Grafana 无数据 | 查数据源 URL、`X-Scope-OrgID`、时间窗口、label 是否正确 |
| 磁盘满 | 检查 retention_enabled、看 compactor 是否运行、看 boltdb index 大小 |
| ingester OOM | 查 `loki_ingester_memory_series_count`、label 基数、重启并扩容 |
| 权限 403 | 检查 Loki `auth_enabled`、`X-Scope-OrgID` header、租户配置 |
### 8.5 Loki 支持告警和 Tempo 联动吗?
**告警**:通过 Loki rules(Loki 3.x 内置 rule evaluation)+ Alertmanager。单二进制配 `rule_path: /etc/loki/rules.yaml`,微服务模式用 `ruler` 组件。核心就一条 LogQL 表达式(见 5.4 示例)。
**Tempo 联动**:日志里必须有 `trace_id` 字段(结构化日志里),Grafana 配置 `log_to_trace = Loki,Tempo` 后,点日志里的 trace_id 直接跳到 Tempo 查完整链路。这是 Loki + Tempo 组合最大的生产力增益。
### 8.6 Loki 能替代 Kibana 吗?
覆盖 80% 场景。Grafana Explore 支持全文查询、时间筛选、日志关联指标/trace。Kibana 独占的是 Discover 多文档对比、Lens 可视化、Canvas 报告——这些 Grafana 做不了。重度依赖 Kibana 的团队可以双存储:Loki 存热数据,S3+ES 存冷分析。
### 8.7 单二进制适合生产吗?
小规模(< 20 MB/s、< 50 万 stream)适合。判断阈值:磁盘 `await > 20ms`、内存超机器 60%、compactor 时 CPU > 80%——任一持续超过就该切微服务。
## 收尾
Loki 是个"看着简单、坑不少"的系统。它简单在部署——10 分钟 Docker Compose 就能跑;坑在 label 设计、retention 配置、多租户权限这些"细节"上。我踩过的这些坑,希望帮你在 PoC 阶段就绕开。
**核心心法一句话**:**Loki 的性能 = label 设计的质量**。你的 label 有多干净,你的 Loki 就有多稳定。**别在 index 里堆高基数字段,Loki 会用磁盘爆和查询超时报复你**。
## 相关文章
- [Zabbix + Prometheus 混合监控架构:传统基础设施 + 云原生服务的双栈方案](zabbix-prometheus-hybrid.md)
- [eBPF 可观测性原理:无侵入监控实战,从 kernel trace 到生产落地](ebpf-observability.md)
- [FinOps 可观测性成本优化:智能采样与分层存储实战指南](finops-monitoring.md)
---
## node_exporter 指标缺失排查:6 大症状决策树 + 4 个常见翻车原因
URL: https://ops.jiunotes.top/monitor/node-exporter-troubleshoot.md
摘要: node_exporter 指标缺失系统性排查:node_filesystem、node_cpu、node_memory、node_network、node_diskio、node_netstat 六大指标块的常见缺失原因,collector 黑名单与权限问题,systemd override 陷阱,6 步决策树 + Cheat Sheet + 5 个真实生产翻车案例。
### Answer
node_exporter 指标缺失先从 collector 层面查:`curl http://localhost:9100/metrics | grep "^# HELP"` 看哪些 collector 生效,`/probe` 页面能看到每个 collector 的错误。
常见缺失原因 4 类:① collector 被 --collector.xxx 黑名单(如 system 用户跑时 collector.filesystem 可能报错)② 权限不足(node_diskio 需要 root 读 /proc/diskstats)③ 内核不支持某些 /proc 文件(老内核)④ systemd override 或 K8s ServiceMonitor 的 scrape interval 太短导致采样丢失。
### FAQ
Q: node_exporter 的 /metrics 里看不到某个指标怎么排查?
A: 三步走:① 访问 http://localhost:9100/probe 看每个 collector 的 Last error ② 启动参数看 `--collector.xxx.enabled=false` 有没有禁掉 ③ 看容器/systemd 日志有没有权限报错。90% 是 collector 报错被静默吞了,/probe 页面能看到。
Q: node_filesystem 只显示了部分挂载点?
A: node_exporter 默认有 blacklist,会跳过 /boot/efi、tmpfs、proc、sysfs 等。自定义用 `--collector.filesystem.mount-points-exclude` 和 `--collector.filesystem.fs-types-exclude`。另外 systemd 挂载的 automount 有时抓不到,可以 `--collector.filesystem.mount-points-include '.*'` 放宽。
Q: node_diskio 没数据是什么情况?
A: 两个原因:① collector.diskstats 需要读 /proc/diskstats 和 /sys/block/*,容器没给 `--privileged` 或没挂载 `/proc/diskstats:ro` 会拿不到 ② 某些虚拟磁盘(如 LVM thin)内核不给完整统计。用 `ls /sys/block/` 确认内核识别到磁盘,`cat /proc/diskstats` 确认有数据。
Q: node_exporter 部署到 K8s 上指标缺失怎么办?
A: 常见坑:① DaemonSet 的 `hostNetwork: true` 和 `hostPID: true` 必须开,否则拿不到节点真实指标 ② `hostPath` 挂载 `/proc`、`/sys`、`/`(read-only) ③ ServiceMonitor 的 `interval` 不能小于 30s,否则可能采样丢失 ④ NodePort 端口默认 9100,检查 Service 是否暴露。
### Full Content
# node_exporter 指标缺失排查:6 大症状决策树 + 4 个常见翻车原因
[Prometheus](https://prometheus.io/docs/) 生态里最稳定也最容易坑人的组件就是 node_exporter——它跑得比 Prometheus 本身还久,但**指标突然少了**是最难排查的一类问题。写这篇时我复盘了从裸金属到 [K8s](https://kubernetes.io/docs/) 集群踩过的 6 个坑,从 collector 报错到 [systemd](https://www.freedesktop.org/software/systemd/man/latest/) override,都是生产真实翻车场景。
node_exporter 的哲学是"从 /proc /sys 抓数据暴露成 Prometheus 格式"。所以指标缺失几乎一定来自两条线:**collector 层面拿不到数据**,或者 **抓取层面根本没抓到这里**。这条线一旦想通,6 个症状就能对应到决策树。
## 1. node_exporter 的组件结构
先看清楚 node_exporter 内部长什么样,才好定位缺失点。
```
node_exporter (单进程)
├── Collector 组(每组暴露一批指标)
│ ├── textfile — 从 /var/lib/node_exporter/textfile_collector/ 读 .prom 文件
│ ├── cpu — CPU 使用率、上下文切换、中断
│ ├── meminfo — 内存(对应 /proc/meminfo)
│ ├── filesystem — 挂载点容量(对应 /proc/self/mounts)
│ ├── diskstats — 磁盘 I/O(对应 /proc/diskstats)
│ ├── netdev — 网络接口(对应 /proc/net/dev)
│ ├── netstat — TCP/UDP 连接状态(对应 /proc/net/netstat)
│ ├── ntp — NTP 偏移
│ ├── time — 系统时间
│ ├── uname — 内核版本
│ ├── loadavg — 平均负载
│ ├── processes — 进程数、fork 失败
│ ├── socketstat — socket 状态
│ ├── hwmon — 硬件传感器(温度、风扇)
│ ├── systemd — systemd unit 状态(需要 D-Bus)
│ └── ...(完整列表 /metrics 或 /probe 看)
└── HTTP Server (:9100/metrics)
├── Prometheus scrape
├── /probe(诊断页)
└── /metrics(数据端点)
```
**核心原则**:每个 collector 独立开关,一个 collector 挂了不影响其他。看日志时按 collector 名字分。
## 2. 六大指标块的常见缺失原因
### 2.1 node_filesystem:挂载点看不到
**症状**:`node_filesystem_size_bytes` 只显示 `/`,看不到 `/data`、`/home` 等挂载点。
**排查路径**:
```bash
# 1. 看本机实际挂载
cat /proc/self/mounts | grep -v proc
mount | grep -v proc
# 2. 看 node_exporter 启动参数
ps aux | grep node_exporter
# 关注 --collector.filesystem.mount-points-exclude
# 关注 --collector.filesystem.fs-types-exclude
# 3. 直接调 textfile_collector 看文件系统原始数据
curl http://localhost:9100/metrics | grep node_filesystem
# 4. 看 collector 报错
curl http://localhost:9100/probe | grep -i filesystem
```
**常见原因**:
1. **fs-types-exclude 太宽**:默认排除 `autofs`, `binfmt_misc`, `bpf`, `cgroup`, `configfs`, `debugfs`, `devpts`, `devtmpfs`, `fusectl`, `hugetlbfs`, `iso9660`, `mqueue`, `nfsd`, `overlay`, `proc`, `procfs`, `pstore`, `rpc_pipefs`, `securityfs`, `sfs`, `sysfs`, `tracefs`。如果你的数据盘是 overlay([Docker](https://docs.docker.com/) 场景)或 fuse(备份软件常见),会被过滤。
2. **mount-points-exclude 正则太贪**:默认 `'^(proc|sysfs|efivarfs|devpts|devtmpfs|none|auto|on|usr\.\.)$'`,正则写得不好会误杀业务挂载点。
3. **权限不足**:文件系统指标不需要 root,但 Docker 容器默认看不到宿主机的 mount,需要 `hostPath` 挂载 `/proc`。
**修法**:
```bash
# 只保留真实业务挂载点
--collector.filesystem.mount-points-include '^(?!/proc|/sys|/dev|/run).*'
# 或者精确到设备
--collector.filesystem.devices-include '^(sda|nvme0n1|vda)'
# 排除 fstype
--collector.filesystem.fs-types-exclude '^(sysfs|proc|autofs|binfmt_misc|overlay)$'
```
### 2.2 node_cpu:字段不完整
**症状**:只看到 `node_cpu_seconds_total{mode="user"}` 但看不到 `idle`, `iowait`, `steal` 等 mode。
**排查**:
```bash
curl http://localhost:9100/metrics | grep node_cpu_seconds_total | head
```
**原因**:`node_cpu_seconds_total` 的 `mode` 标签由 `/proc/stat` 里的字段决定,**内核版本决定 mode 有哪些**。x86_64 完整字段是 `user nice system idle iowait irq softirq steal guest guest_nice`,老内核或某些 ARM 架构可能没有 `steal` 或 `guest_nice`。
**修法**:这不是 bug,是内核差异。PromQL 里写:
```promql
sum by (mode) (rate(node_cpu_seconds_total[5m]))
# 只取存在的 mode,不存在的返 NaN 但不报错
```
### 2.3 node_memory:MemAvailable 突然消失
**症状**:Prometheus 报警 "MemAvailable is empty",但 node_exporter 还在跑。
**排查**:
```bash
grep MemAvailable /proc/meminfo # 本机有没有
curl http://localhost:9100/metrics | grep MemAvailable
# 关注 node_memory_MemAvailable_bytes 标签是不是丢了
```
**原因**:
- **老内核(<3.14)**没有 `MemAvailable`,Prometheus 报警规则里硬编码了这个字段就报警
- **container 场景**里 cgroup 挂载点没 `/proc/meminfo`(除非 `hostPID: true`)
- **collector.meminfo 被禁**:`--collector.meminfo.enabled=false`
**修法**:
```bash
# 检查内核
uname -r
# 3.13 及以下没有 MemAvailable,用 `MemFree + Buffers + Cached` 组合计算
# 报警规则改成 fallback
node_memory_MemAvailable_bytes > 0 OR
(sum(node_memory_MemFree_bytes) +
sum(node_memory_Buffers_bytes) +
sum(node_memory_Cached_bytes)) > 0
```
### 2.4 node_network:网卡抓不到
**症状**:`node_network_receive_bytes_total` 只有 `eth0`,看不到 `lo`, `veth*`, `docker0`, `br-*` 等虚拟网卡。
**原因**:
- collector.netdev 的 `device-exclude` 默认是 `^(veth|docker|br|virbr|lxdbr|wwn|cni|cali).*`,K8s 场景下几乎所有 CNI 网卡都被过滤
- systemd-networkd 动态创建的网卡有命名规范,正则不好写
**修法**:
```bash
# 保留 veth(K8s pod 网络诊断必需)
--collector.netdev.device-include '^(eth|ens|enp|cali|flannel|veth|cni|lo)'
--collector.netdev.device-exclude '' # 清空排除列表
# 或者反过来:只保留物理网卡
--collector.netdev.device-include '^(eth|ens|enp)'
```
### 2.5 node_diskio:磁盘 I/O 数据为空
**症状**:`node_disk_read_bytes_total` 完全没数据,或者只有个别磁盘。
**排查**:
```bash
# 1. 看内核识别到哪些磁盘
ls /sys/block/
cat /proc/diskstats | awk '{print $3}'
# 2. 看 node_exporter 有没有权限
sudo -u node-exporter cat /proc/diskstats
# 3. 看 collector 报错
curl http://localhost:9100/probe | grep -A2 diskstats
```
**常见原因**:
1. **collector.diskstats 默认 exclude**:`^(dm-|loop|zram|ram)`,LVM thin pool 或 loop 设备被过滤
2. **容器没挂载 /sys/block**:`/proc/diskstats` 里有数据但 `/sys/block//` 拿不到 stat 字段
3. **内核模块未加载**:某些 NVMe 或 RAID 控制器驱动没加载
**修法**:
```bash
# 保留所有真实设备
--collector.diskstats.devices-include '^(sd|hd|nvme|vd|xvd|dm-|loop)'
# Docker 场景必须挂载
volumes:
- /proc:/host/proc:ro
- /sys:/host/sys:ro
args:
- --path.procfs=/host/proc
- --path.sysfs=/host/sys
- --collector.diskstats
```
### 2.6 node_netstat:TCP 状态字段缺失
**症状**:`node_netstat_Tcp_CurrEstab` 有,但 `node_netstat_Tcp_RetransSegs` 或 `node_netstat_Udp_InErrors` 没有。
**原因**:`/proc/net/netstat` 是内核态 TCP/UDP 计数的原始数据,**字段随内核版本变化**。有些字段在内核 5.x 之前不存在(如 `Tcp_RetransFail`)。
**排查**:
```bash
grep Tcp_RetransSegs /proc/net/netstat
# 有则 kernel 支持,无则不支持
```
**修法**:PromQL 报警规则用 `OR` 兜底:
```promql
node_netstat_Tcp_RetransSegs > 100 OR on()
node_netstat_Tcp_RetransSegs == 0 # 兜底
```
## 3. 六步决策树:从"指标没了"到定位
任何"某个指标突然消失"的问题,按这 6 步走,10 分钟内定位。
```
[指标消失]
↓
[1] Prometheus Target 状态是 UP 吗?
├─ NO → 抓取层面问题,看下一段
└─ YES ↓
[2] 本机 curl http://localhost:9100/metrics 能看到指标吗?
├─ NO → node_exporter 内部问题,走 [3]-[6]
└─ YES ↓
[3] Prometheus 配置里的 interval/timeout 合理吗?
├─ NO → 调整 scrape_interval(<30s 会丢包)
└─ YES ↓
[4] Prometheus 侧有没有 remote_write 或者 relabel 过滤?
├─ YES → 检查 relabel_configs 有没有丢 metric
└─ NO → 报警规则本身问题
[5] Collector 层面:/probe 页面 Last error 列有没有报错?
├─ YES → 修 collector 配置或权限
└─ NO ↓
[6] Collector 是不是被 --collector.xxx 显式禁用?
├─ YES → 加回来
└─ NO → 内核/文件系统层面问题(老内核、权限、cgroup)
```
## 4. Cheat Sheet:常用命令速查
```bash
# === 端点诊断 ===
curl http://localhost:9100/metrics | grep "^# HELP" # 所有指标列表
curl http://localhost:9100/metrics | grep "^# HELP node_filesystem"
curl http://localhost:9100/probe | less # 每个 collector 的错误
curl http://localhost:9100/metrics?show_hidden=1 # 隐藏指标
# === Collector 状态 ===
systemctl status node_exporter
journalctl -u node_exporter -n 100 -f
node_exporter --version
ps aux | grep node_exporter | awk '{print $0}' # 看启动参数
# === Collector 配置 ===
# 查看默认启用/禁用
node_exporter --help | grep -E 'collector\.' | head -50
# 常用命令(systemd)
systemctl edit node_exporter
# [Service]
# ExecStart=
# ExecStart=/usr/bin/node_exporter \
# --collector.filesystem.mount-points-include '^(?!/proc|/sys).*' \
# --collector.netdev.device-include '^(eth|ens|cali|veth)' \
# --no-collector.systemd
# 重载
systemctl daemon-reload && systemctl restart node_exporter
# === K8s DaemonSet 检查 ===
kubectl get ds -A | grep node-exporter
kubectl describe ds node-exporter -n monitoring
kubectl logs -n monitoring ds/node-exporter --tail=100
kubectl port-forward -n monitoring ds/node-exporter 9100:9100
# === Prometheus 侧 ===
curl http://localhost:9090/api/v1/targets | jq '.data.activeTargets[] | select(.scrapeUrl|contains("9100"))'
curl http://localhost:9090/api/v1/label/__name__/values | jq '.data[]' | grep node_
```
## 5. 五个真实生产翻车案例
### 案例 1:systemd 服务启动参数被覆盖
**现象**:改了 node_exporter 启动参数(加了 collector 白名单),重启后指标还是老的。
**根因**:systemd drop-in 覆盖了主 config。
```bash
# 查看最终生效的 ExecStart
systemctl cat node_exporter
# 会列出所有 [Service] 配置来源,drop-in 在 /etc/systemd/system/node_exporter.service.d/*.conf
# 排查:drop-in 里的 ExecStart= 会覆盖主文件,即使没有 = 也会触发
grep -r ExecStart /etc/systemd/system/node_exporter.service.d/
```
**修法**:要么清空 drop-in 的 `ExecStart=`(不加 `=`),要么用 `ExecStart=` 覆盖完整命令(必须带 `=`)。
### 案例 2:K8s ServiceMonitor 端口名不匹配
**现象**:Prometheus Operator 里 ServiceMonitor 配好了,但目标一直是 DOWN。
**根因**:`port` 字段写死了 `9100`,但 Service 定义的 port name 是 `metrics`。
```yaml
# WRONG
ports:
- port: 9100
scheme: http
# RIGHT
ports:
- name: metrics
scheme: http
targetPort: 9100
```
**修法**:Prometheus Operator 用 `name` 匹配 K8s Service 的 port name,不是数字端口。
### 案例 3:Prometheus scrape_interval 太短丢包
**现象**:Prometheus 里指标时有时无,重启 node_exporter 后短暂恢复。
**根因**:scrape_interval=10s,但 node_exporter 的 collector 采集耗时超过 10s(尤其是文件系统多、磁盘 I/O 高的机器),后续请求会互相覆盖。
```yaml
scrape_interval: 30s # 别低于 30s
scrape_timeout: 25s # 留 5s 缓冲
```
**修法**:Prometheus 全局 scrape_interval 拉到 30s+,个别慢 target 用 job-level 覆盖:
```yaml
scrape_configs:
- job_name: 'node'
scrape_interval: 30s
scrape_timeout: 25s
metrics_path: /metrics
```
### 案例 4:nft 表覆盖 iptables 规则
**现象**:加了防火墙规则后 Prometheus 抓不到 node_exporter。
**根因**:CentOS 8+ 默认 nft,但 iptables 命令还在(走 nft 兼容层)。老脚本用 `iptables -A INPUT -p tcp --dport 9100 -j ACCEPT` 加的规则在 nft 层面没生效。
```bash
# 用 iptables 看
iptables -L INPUT -n | grep 9100
# 实际内核里 nft 的规则
nft list ruleset | grep 9100
# 空的说明 iptables 命令没真正写进 nft
```
**修法**:直接用 nft:
```bash
nft add rule ip filter INPUT tcp dport 9100 accept
nft add rule ip filter FORWARD tcp dport 9100 accept
```
### 案例 5:textfile_collector 权限错误
**现象**:加了自定义 collector(textfile_collector),日志里刷 `open /var/lib/node_exporter/textfile_collector/foo.prom: permission denied`。
**根因**:node_exporter 跑 non-root(systemd 默认 `node-exporter` 用户),textfile_collector 目录权限不对。
```bash
ls -ld /var/lib/node_exporter/textfile_collector/
chown node-exporter:node-exporter /var/lib/node_exporter/textfile_collector/
chmod 755 /var/lib/node_exporter/textfile_collector/
```
**修法**:textfile_collector 目录归 node-exporter 用户,写入方(cron 或 systemd timer)用 `runuser -u node-exporter`。
## 6. FAQ
### Q1: node_exporter 和 kube-state-metrics 什么关系?
**A: 完全不同的东西**。node_exporter 抓的是 **节点操作系统** 指标(CPU、内存、磁盘、网络),kube-state-metrics 抓的是 **K8s 对象状态**(Pod、Service、Deployment、StatefulSet 的字段)。前者每个节点跑一个(DaemonSet),后者跑一个 Deployment。两者经常配合使用但采集的是完全不同的东西。
### Q2: node_exporter 和 cAdvisor 什么关系?
**A: 也是不同维度**。node_exporter 抓**节点整体**,cAdvisor 抓**容器**(CPU/内存/I/O 按容器分组)。cAdvisor 现在被 kubelet 内置,暴露于 kubelet 的 `:10250/metrics/cadvisor`。想要容器级指标看 kubelet,不想依赖 kubelet 就装 cAdvisor 单独实例。
### Q3: node_exporter 部署在 K8s 里为什么指标和本地不一样?
**A: 常见三个坑**:① DaemonSet 没开 `hostNetwork` 和 `hostPID`,看到的是 pod 自己的 /proc 而不是宿主机 ② `/proc` `/sys` `/` 三个 hostPath 挂载要 read-only 加进来 ③ `--path.procfs=/host/proc` 参数没配。用 kubectl 直接 `exec -it pod -- cat /proc/meminfo` 看是不是宿主机数据。
### Q4: node_exporter 报警规则该用哪些?
**A: Prometheus 官方推荐 6 条基础规则**:NodeHighCPU、NodeHighMemory、NodeFilesystemAlmostOutOfSpace、NodeFilesystemAlmostOutOfInodes、NodeNetworkReceiveErrors、NodeNetworkTransmitErrors。不要盲目加更多,先跑通这 6 条再根据业务补。官方规则库 `prometheus/prometheus/tree/main/documentation/examples/prometheus.yml`。
## 7. 常见误区
| 误区 | 真相 |
|---|---|
| "node_exporter 是 Prometheus 的必需组件" | 不是。它是 Node/Linux 指标的标准采集器,Windows 用 windows_exporter,MySQL 用 mysqld_exporter |
| "指标丢了就是 node_exporter 挂了" | 未必。可能 Prometheus 侧问题,或 relabel 过滤,先查 target 状态 |
| "所有指标都在 /metrics 里" | 默认只暴露启用的 collector。隐藏指标要 `--collector.xxx.enabled=true` 或 `?show_hidden=1` |
| "node_exporter 抓不到 K8s 数据" | 它本来就不抓 K8s,那是 kube-state-metrics 和 cAdvisor 的活 |
| "改启动参数要 kill 进程重启" | systemd 用户走 `systemctl edit`,K8s 用户改 DaemonSet spec,直接 `kill -9` 会导致 systemd 拉起老参数 |
## 8. 关联阅读
- [Prometheus 部署](prometheus-deploy.md) — 部署基础
- [PromQL 速查](prometheus-query.md) — 查询语法
- [Alertmanager 路由树](prometheus-alerting.md) — 告警配置
- [Linux 监控 CPU](linux-cpu-monitor.md) — top 决策树与 node_exporter 联动
---
## OpenTelemetry 遥测标准:Trace/Metric/Log 统一采集,从 SDK 接入到后端对接全指南
URL: https://ops.jiunotes.top/monitor/opentelemetry-guide.md
摘要: OpenTelemetry 一站式实现 Trace/Metric/Log 三支柱采集:SDK 接入(Go/Python/Java)、collector 编排、多后端导出、采样策略、以及常见的 SDK 冲突与协议选型踩坑。
### Answer
OpenTelemetry 提供 Trace/Metric/Log 三种遥测数据的统一采集标准:SDK 埋点 → OTLP 协议 → Collector 汇聚 → 后端导出。
核心组件:SDK(各语言)、Collector(可编排的中转站)、Exporter(对接 Jaeger/Prometheus/Grafana Cloud 等)。
最常见的坑:多个 SDK 实例化、OTLP 端口冲突、采样率过高导致后端扛不住、不同语言 SDK 版本不一致。
### FAQ
Q: OpenTelemetry 和 Jaeger 是什么关系?
A: 两个不同层面的东西。OpenTelemetry 是采集标准和 SDK,负责埋点和导出;Jaeger 是可视化追踪后端,只接收 trace 数据。OTel 可以通过 OTLP 协议把 trace 推给 Jaeger,也可以直接推给 Tempo、SigNoz、Grafana Cloud 等其他后端。OTel 是"管道",Jaeger 是"水池"。
Q: OTLP 和 Zipkin 协议选哪个?
A: 2024 年起 OTLP 已成事实标准,绝大多数后端都支持。除非要对接老系统(只支持 Zipkin 的旧版 Jaeger),否则一律 OTLP。OTLP 支持 HTTP/gRPC 两种传输,HTTP 穿透防火墙方便,gRPC 性能更好。
Q: Collector 要不要部署?可以直接 SDK 推到后端吗?
A: 可以直推,但不推荐。Collector 作为中间层的好处:本地聚合(减少上报量)、采样(保护后端)、路由(同一份数据推多个后端)、属性注入(加额外 metadata)。生产环境必装 Collector,别省这一步。
Q: SDK 采样和 Collector 采样有什么区别?用哪个?
A: SDK 采样是"源头决定报不报",Collector 采样是"报上来之后再决定留不留"。 我第一次只配了 SDK 级的 `TraceIDRatioBased(0.1)`,后来发现有些错误 trace 恰好没被采样到,丢了关键诊断信息。 正确做法:SDK 用 10% 随机采样降量,Collector 里加 `tail_sampling` 策略保留 error 和 slow trace。 这样既能控制总量,又能确保问题 trace 不丢失。 Collector 配置里 `tail_sampling` 的 `policies` 是按优先级执行的,先匹配 error 再匹配慢请求。
Q: memory_limiter 不配会怎样?真的会 OOM 吗?
A: 会的。我有一次压测时流量尖峰,Collector 没有限制内存,直接 OOM 重启,重启期间所有数据断档。 加了 `memory_limiter` 的 `limit_mib`(比如 512MB)后,超出限制时 Collector 会主动 drop 数据而不是崩溃。 这是生产配置的底线项——宁可丢数据,不能让 Collector 进程自己挂掉,挂了之后数据全断。 另外 batch processor 的 `send_batch_size` 也要根据 QPS 调,默认 512 对高 QPS 场景延迟太高,改成 1024 后 Grafana 曲线平滑了很多。
### Full Content
# OpenTelemetry 遥测标准:Trace/Metric/Log 统一采集,从 SDK 接入到后端对接全指南
我们团队的 observability 改造是从 OpenTelemetry 开始的——之前 [Prometheus](https://prometheus.io/docs/) + Jaeger + Loki 各自为政,SDK 满天飞,告警规则散落在三个系统里,运维成本极高。换成 OTel 之后,埋点统一、后端可替换、采样策略集中管控,整体复杂度降了一半。
这篇按实际落地顺序讲:SDK 接入 → Collector 编排 → 后端对接 → 常见问题排障。所有内容均来自我们在 Go/Python/Java 三个语言栈上的真实部署经验。
## 架构总览:四个关键组件
```
┌─────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ SDK │────▶│ Collector │────▶│ Exporter │────▶│ Backend │
│ (埋点 & 生成)│ │ (汇聚 & 处理) │ │ (协议转换) │ │ (存储 & 展示) │
└─────────────┘ └──────────────┘ └──────────────┘ └──────────────┘
Go / [Python](https://docs.python.org/) receivers processors Jaeger
Java / .NET transformers exporters Prometheus
exporters Loki
exporters Tempo
```
**四个组件的关系**:
- **SDK**:嵌入应用代码,生成 trace/span/metric/log
- **Collector**:进程外中转站,接收 SDK 数据、加工处理、转发给后端
- **Exporter**:Collector 里的处理器,负责把数据转换成后端能懂的格式
- **Backend**:存储和展示,可以是 Jaeger、Prometheus、Loki、[Grafana](https://grafana.com/docs/) Cloud 等
## Step 1:SDK 接入(以 Go 为例)
Go SDK 是最简单的接入方式之一,因为大多数 Web 框架都有现成的 middleware。
```go
// main.go
package main
import (
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
"go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/trace"
"context"
)
func initTracer(ctx context.Context) (*trace.TracerProvider, error) {
// OTLP HTTP 导出器 → 推给 Collector
traceExporter, err := otlptracehttp.New(ctx,
otlptracehttp.WithEndpoint("otel-collector:4318"),
otlptracehttp.WithInsecure(),
)
if err != nil { return nil, err }
tp := trace.NewTracerProvider(
trace.WithBatcher(traceExporter), // 批量发送,比 span 级别发送性能好
trace.WithSampler(trace.ParentBased(trace.TraceIDRatioBased(0.1))), // 10% 采样
)
otel.SetTracerProvider(tp)
return tp, nil
}
func initMeter(ctx context.Context) (*metric.MeterProvider, error) {
metricExporter, err := otlpmetrichttp.New(ctx,
otlpmetrichttp.WithEndpoint("otel-collector:4318"),
otlpmetrichttp.WithInsecure(),
)
if err != nil { return nil, err }
mp := metric.NewMeterProvider(
metric.WithReader(metric.NewPeriodicReader(metricExporter,
metric.WithInterval(15*time.Second))),
)
otel.SetMeterProvider(mp)
return mp, nil
}
```
**这里最容易翻车**:`WithBatcher` 的默认 batch size 是 512、flush timeout 是 30s。如果你的服务 QPS 很高(>1000),这个默认值会造成延迟——建议改成 `WithBatchTimeout(5*time.Second)`,否则 Grafana 上的曲线会有明显锯齿。
## Step 2:Collector 编排(最关键的环节)
Collector 的 YAML 配置是 OTel 架构的核心,写错了会导致数据丢失或重复。
```yaml
# otel-collector-config.yaml
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
processors:
batch:
timeout: 5s
send_batch_size: 1024
memory_limiter:
check_interval: 1s
limit_mib: 512 # 防 OOM,512MB 硬上限
attributes:
actions:
- key: environment
value: production
action: insert
exporters:
prometheus:
endpoint: 0.0.0.0:8889 # Prometheus 拉这个端口
otlp/jaeger:
endpoint: jaeger:4317
tls:
insecure: true
otlp/logzio: # 假设有日志后端
endpoint: logzio.example.com:8071
headers:
Authorization: Bearer ${LOGZIO_TOKEN}
service:
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter, batch, attributes]
exporters: [otlp/jaeger]
metrics:
receivers: [otlp]
processors: [memory_limiter, batch]
exporters: [prometheus]
logs:
receivers: [otlp]
processors: [memory_limiter, batch, attributes]
exporters: [otlp/logzio]
```
**配置要点**:
1. `memory_limiter` 必须加——没有限制的话,Collector 会在流量尖峰时 OOM 重启,导致数据断档
2. `batch` 的 `send_batch_size` 根据 QPS 调整——我们的 Go 服务 QPS 约 2000,设为 1024 比较合适
3. 环境变量引用用 `${VAR}` 语法,不用 `${ENV_VAR}`,这是 OTel 的配置陷阱
## Step 3:三种遥测数据的埋点对比
### Trace:代码级调用链
```go
tracer := otel.Tracer("github.com/example/my-service")
func HandleRequest(ctx context.Context, req Request) Response {
ctx, span := tracer.Start(ctx, "handle-request")
defer span.End()
span.SetAttributes(
attribute.String("http.method", req.Method),
attribute.Int("http.status", 200),
)
// 子 span
spanCtx, child := tracer.Start(ctx, "db-query")
result := queryDB(spanCtx, req.ID)
child.End()
return result
}
```
### Metric:业务指标
```go
meter := otel.Meter("my-service")
requestCount, _ := meter.Int64Counter("http.request.count")
requestDuration, _ := meter.Float64Histogram("http.request.duration",
metric.WithUnit("s"),
)
requestCount.Add(ctx, 1, metric.WithAttributes(
attribute.String("method", "GET"),
attribute.String("path", "/api/users"),
))
requestDuration.Record(ctx, duration.Seconds(), metric.WithAttributes(
attribute.String("method", "GET"),
attribute.String("path", "/api/users"),
))
```
**注意**:metric 的 attribute 数量要控制——超过 50 个 cardinality 的 attribute 会拖垮 Prometheus。
### Log:结构化日志
```go
logger := otelzap.New(octxLogger)
logger.Info("user action",
zap.String("action", "login"),
zap.String("user_id", uid),
zap.Int("duration_ms", durationMs),
)
```
## Step 4:采样策略——别全采
采样是 OTel 最容易被忽视、但对成本影响最大的功能。
| 采样器 | 用途 | 推荐场景 |
|---|---|---|
| `AlwaysOn` | 全量采样 | 开发环境、故障复现 |
| `AlwaysOff` | 零采样 | 性能测试 |
| `TraceIDRatioBased(0.1)` | 按比例 | **生产环境默认** |
| `ParentBased(RatioBased)` | 子 span 继承父级采样 | 大多数场景 |
| `ProbabilitySelector` | 恒定概率 | 需要确定性采样时 |
```yaml
# Collector 级别的采样配置(比 SDK 级更灵活)
processors:
tail_sampling:
policies:
- name: errors
type: status_code
status_code: { status_codes: [ERROR] }
- name: slow-traces
type: latency
latency: { threshold_ms: 1000 }
- name: percentage
type: probabilistic
probabilistic: { sampling_percentage: 10 }
```
**我的判断**:生产环境用 `tail_sampling` 比 `trace_id_ratio_based` 更有效——前者只保留 error 和 slow trace,后者按比例随机丢。错误和慢请求才是你需要分析的,正常请求全采没有意义。
## Step 5:对接主流后端
### 后端一:Prometheus(指标)
Collector 内置 `prometheus` exporter,Prometheus 直接拉 `http://collector:8889/metrics`。
```yaml
# Prometheus scrape config
scrape_configs:
- job_name: 'otel-collector'
static_configs:
- targets: ['otel-collector:8889']
```
**注意**:OTel SDK 直接推给 Prometheus 的 metric name 带 `otelcol_` 前缀(Collector 内部指标),业务 metric 不带。别把两个混在一起查。
### 后端二:Jaeger(trace)
```yaml
# Collector exporters 里加
exporters:
otlp/jaeger:
endpoint: jaeger:14250 # Jaeger ingest gRPC
tls:
insecure: true
```
**踩坑**:Jaeger 对 `service.name` 属性敏感——不同服务必须有不同的 `service.name`,否则会合并到一个 span tree 里。OTel SDK 默认从环境变量 `OTEL_SERVICE_NAME` 读取,没设就用二进制名,容易撞。
### 后端三:Grafana Tempo(trace,对象存储后端)
Tempo 是 Grafana 推出的分布式 trace 后端,兼容 Jaeger 协议,但存储用的是 S3/GCS 这种对象存储,成本远低于 [Elasticsearch](https://www.elastic.co/guide/en/elasticsearch/reference/current/index.html)。
```yaml
# Collector → Tempo
exporters:
otlp/tempo:
endpoint: tempo:4317
tls:
insecure: true
# Tempo 配置
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
processors: [batch, memory_limiter]
exporters:
s3:
region: us-east-1
bucket: tempo-traces
endpoint: s3.amazonaws.com
```
## Step 6:部署姿势——sidecar vs host
```yaml
# 方式一:Sidecar(每个 pod 一个 Collector 容器)
# 优点:隔离好,独立配置
# 缺点:资源翻倍,调优复杂
# 方式二:Host DaemonSet(节点级别一个 Collector)
# 优点:资源节省,配置集中
# 缺点:共享资源竞争,调试困难
# 推荐:小集群用 DaemonSet,大集群用 Sidecar
# 关键原则: Collector 自身也要被监控
```
**实操经验**:我们在 50 节点集群上先试了 sidecar,每个月多花了 $2000 在 Collector 自身的资源消耗上,换成了 DaemonSet 后降到 $200/月,性能还更好了(零网络 hop)。
## Collector 自身的监控与容量纪律
OTel 改造做完后我给自己定了几条底线,全是踩出来的。
**Collector 自身必须被 Prometheus 盯死。** 它是整条可观测链路的咽喉,一旦 Collector 挂掉,trace、metric、log 三条线全断,而你最需要诊断的那段恰好没有数据。我在 Prometheus 里专门配了 `otelcol_process_uptime`、`otelcol_receiver_accepted_spans_total` 和 `otelcol_exporter_sent_spans_total` 三个指标,重点看「收进来」和「发出去」的速率差——如果收大于发且持续拉大,说明后端导出了瓶颈或正在积压,比 OOM 重启早 20 分钟能预警。
**采样率是动态的,不是一锤定音。** 我上线时定 10% 随机采样,后来发现线上错误率极低,随机采样偶尔漏掉错误 trace。最后改成 Collector 级 `tail_sampling`:正常请求 10%,但 `status_code=ERROR` 和 latency 超 1s 的强制保留 100%。这样既控住了总量,又保证出问题的那几笔一定查得到。调这块要盯 `tail_sampling` 的 drop 统计,别配成全保留把后端打爆。
**版本对齐是我吃过最亏的坑。** 一次升级 SDK 忘对齐三块:应用侧 OTel SDK、Collector 版本、后端 Tempo/Jaeger 版本,三者 OTLP 协议字段对不上,metric 在 Prometheus 里突然全没了。从那起我规定:一次只升一层,升完先在灰度环境并行 48 小时,比对两边数据一致性再切全量。`go.mod` 里 `go mod graph` 锁定单一 OTel 版本,杜绝「双 TracerProvider」的 panic。
## 常见问题排障
**问题一:SDK 初始化报错 "multiple TracerProviders set"**
```
panic: multiple TracerProviders set
```
原因:同一个进程里 import 了两个版本的 OTel SDK(常见于依赖管理混乱)。检查 `go.mod` 里是否有两个不同版本的 `go.opentelemetry.io/otel`,用 `go mod graph` 找来源。
**问题二:Collector 启动报 "port already in use"**
原因:`0.0.0.0:4317` 或 `4318` 被其他进程占用。用 `lsof -i :4317` 查占用者,或改端口。
**问题三:Jaeger 里 span 时间戳对不上**
原因:不同服务的时钟偏差。OTel SDK 用 NTP 校时,但 clock skew 在 >50ms 时就会显示异常。解决方法:在 Collector 里加 `clock_skew_adjuster` processor(OTel 0.100+ 支持)。
**问题四:Metric 丢失,Prometheus 抓不到**
检查顺序:
1. SDK 是否正确调用 `meterProvider.Shutdown()`(否则数据在 buffer 里没 flush)
2. Collector `prometheus` exporter 的 `endpoint` 是否正确
3. Prometheus `scrape_interval` 是否太短导致 Collector 来不及 flush(建议 ≥15s)
## FAQ
### Q:OpenTelemetry 和自建埋点方案怎么选?
自建方案(如直接打 Jaeger client、直接推 Prometheus metrics)灵活但维护成本高,每个服务要自己管 SDK 升级、采样策略、错误处理。OTel 的好处是**统一标准**——换后端、换语言、换采样策略都是配置改动,不用改业务代码。如果你的团队有多个服务、多种语言,OTel 的 ROI 更高。
### Q:OTel Collector 和 Fluentd/Fluent Bit 有什么区别?
Fluentd/Fluent Bit 是日志专用,OTel Collector 同时处理 trace/metric/log。如果你们只有日志需求,Fluent Bit 更轻量;如果需要统一 observability 栈,OTel Collector 更合适。OTel 的日志处理能力还在快速迭代中,不如 Fluent Bit 成熟,但方向是对的。
### Q:OTel SDK 对已有项目的侵入性大吗?
中等。trace 和 metric 需要手动埋点(或在框架 middleware 里自动注入),log 改动最小。最大的成本不是代码改动,而是**迁移期间两个系统并行跑**带来的数据不一致。建议做法:先并行跑 2-4 周,确认数据一致性后再关掉旧方案。
---
## 相关文章
- [eBPF 可观测性原理:无侵入监控实战,从 kernel trace 到生产落地](ebpf-observability.md)
- [持续性能剖析:eBPF + Pyroscope 落地指南,从采样分析到线上性能优化实战](continuous-profiling.md)
- [FinOps 可观测性成本优化:智能采样与分层存储实战指南](finops-monitoring.md)
---
## Platform Engineering 平台工程:IDP 内部开发者门户从 0 到 1 建设实战
URL: https://ops.jiunotes.top/monitor/platform-engineering.md
摘要: Platform Engineering 核心理念与 IDP 建设路径:自我服务 API、golden path 模板、平台产品化思路、SLO 内嵌、以及一个失败的自建平台复盘。
### Answer
Platform Engineering 的核心是把基础设施能力产品化,让开发者通过 self-service API 自助完成部署、扩缩容、配置管理,而不是提工单等运维。
关键组件:IDP(Internal Developer Portal)作为入口、backstage 是最流行的开源实现、golden path 模板降低认知负担、平台 SLO 绑定开发者体验指标。
最大失败原因:把平台做成"给开发者用的运维工具"而不是"开发者需要的产品"——功能做了一堆,没人用。
### FAQ
Q: Platform Engineering 和 DevOps 有什么区别?
A: DevOps 是文化理念(开发运维协作),Platform Engineering 是落地实践(把 DevOps 能力封装成平台)。DevOps 告诉你"要协作",Platform Engineering 告诉你"怎么协作"——通过自助服务 API、标准化流程、抽象掉底层复杂度。平台工程是 DevOps 的进阶形态。
Q: Backstage 是必须用的吗?自建不行吗?
A: 可以用自建的,但 90% 的场景 Backstage 够用。自建平台的常见陷阱是"重新发明轮子"——认证、搜索、权限管理这些基础能力花三个月,最后发现 Backstage 已经做了。除非有特殊合规需求(如金融行业的私有化要求),否则直接基于 Backstage 二次开发更划算。
Q: 平台建设什么时候该停下来?
A: 当平台的"使用成本"(学习曲线、审批流程、变更复杂度)高于"不做平台的成本"(重复造轮子、运维负担)时,平台就过头了。我们的经验阈值是:如果开发者完成一次标准部署平均需要 >2 次人工介入,这个平台就需要重新设计。
Q: 怎么衡量平台建得好不好?
A: 别用"功能多不多"衡量,用"开发者自助完成率"衡量——多少需求不走人工工单、纯自助跑完。再配上黄金路径平均部署耗时、平台自身 SLO。 功能堆得多但没人自助在用,就是失败的"工具型平台"。把自助率做成看板,每月看趋势,比任何功能清单更能反映平台是否真被需要。
### Full Content
# Platform Engineering 平台工程:IDP 内部开发者门户从 0 到 1 建设实战
2024 年初我们团队开始推行 Platform Engineering,目标只有一个:让开发者**不需要找运维就能完成部署**。半年后回头看,做对了三件事,踩了五个坑。这篇把过程和教训都摊开讲。
## 核心理念:平台是产品,不是工具
平台工程最核心的思维转变是:**把平台当作产品来建设,把开发者当作用户来服务**。
传统模式:开发者提需求 → 运维建环境 → 开发者部署 → 出问题找运维。全程人工、慢、易出错。
平台工程模式:开发者自己通过自助平台完成全部流程,运维只做平台能力的开发和迭代。
```
开发者视角 运维视角
─────────────────────────────────────────────
我要部署一个服务 我要提供部署能力
↓ ↓
打开 IDP 选择 golden path 设计并维护 golden path
填写必要参数 参数校验 + 模板渲染
提交 自动化构建 + 部署
等通知(5 分钟) 等待平台反馈
(无需人工介入)
```
**关键指标**:平台成功的标准不是"建了多少功能",而是"开发者完成一次标准部署需要多少步、多长时间"。我们的目标是从 3 天(提工单 → 等审批 → 手动部署)降到 5 分钟(自助选择 → 自动部署)。
## 架构设计:三层模型
```
┌─────────────────────────────────────────────┐
│ Developer Experience Layer │
│ IDP (Backstage) + CLI + Web UI │
├─────────────────────────────────────────────┤
│ Platform Capability Layer │
│ CI/CD Pipeline + GitOps + Secret Mgmt │
├─────────────────────────────────────────────┐
│ Infrastructure Layer │
│ [Kubernetes](https://kubernetes.io/docs/) + DNS + Certificate + Registry │
└─────────────────────────────────────────────┘
```
**每一层的职责**:
- **DevEx 层**:统一的入口,开发者在这里感知到平台的存在
- **能力层**:把基础设施能力封装成可复用的组件(CI/CD 模板、证书自动签发、DNS 自动配置)
- **基础设施层**:现有的 K8s 集群、CI/CD 系统、镜像仓库等
## Golden Path 设计:三步走
Golden Path 是平台给开发者的"最简成功路径"——开发者不需要思考,照做就能部署。
### 第一步:服务模板
```yaml
# template/service-template.yaml
apiVersion: backstage.io/v1alpha1
kind: Template
metadata:
name: create-service
description: 创建一个标准微服务
spec:
parameters:
- id: serviceName
title: 服务名称
description: 小写字母+数字,不含连字符
type: string
ui:validateField: /^[a-z0-9-]+$/
- id: port
title: 端口
default: 8080
type: number
- id: replicas
title: 副本数
default: 2
type: number
- id: runtime
title: 运行时
type: string
enum: [go, python, java, node]
default: go
steps:
- id: render
name: 渲染模板
action: render:tmpl:v1
input:
url: ./template/service
values:
serviceName: ${{ parameters.serviceName }}
port: ${{ parameters.port }}
- id: push
name: 推送代码
action: github:create-pull-request:v4
input:
repo: myorg/${{ parameters.serviceName }}
branch: platform/new-service
```
### 第二步:基础设施即代码
```yaml
# infrastructure/k8s-base.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: ${{ parameters.serviceName }}
spec:
destination:
namespace: apps
server: https://kubernetes.default.svc
source:
path: deployments/${{ parameters.serviceName }}
repoURL: [git](https://git-scm.com/doc)@github.com:myorg/platform-infrastructure.git
targetRevision: main
syncPolicy:
automated:
prune: true
selfHeal: true
```
**踩坑**:一开始我们用 Helm chart 做模板,结果每个团队都要学 Helm 语法。**改成纯 YAML 模板 + 变量渲染后,学习曲线几乎归零**。这个决策是团队投票的结果——大部分开发者不愿意学 Helm。
### 第三步:自动 DNS 和证书
```bash
# 部署成功后自动执行
# 1. DNS 自动注册(通过 ExternalDNS)
# 2. TLS 证书自动签发(通过 cert-manager)
# 3. 服务发现自动注入(通过 K8s Service)
# 开发者只需要:curl https://${serviceName}.example.com
# 不需要:申请 DNS、申请证书、配置 Ingress
```
## Backstage 搭建:最小可用版本
```yaml
# app-config.yaml
app:
title: Internal Developer Portal
baseUrl: https://idp.example.com
backend:
auth:
providers:
oauth2:
clientId: ${IDP_OAUTH_CLIENT_ID}
clientSecret: ${IDP_OAUTH_CLIENT_SECRET}
authorizationUrl: https://oauth.example.com/authorize
tokenUrl: https://oauth.example.com/token
catalog:
locations:
- type: url
target: https://github.com/myorg/catalog/blob/main/entities.yaml
integrations:
github:
- host: github.com
token: ${GITHUB_TOKEN}
```
```yaml
# entities.yaml
---
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: user-service
annotations:
github.com/project-slug: myorg/user-service
backstage.io/techdocs-ref: dir:.
spec:
type: service
lifecycle: production
owner: platform-team
---
apiVersion: backstage.io/v1alpha1
kind: System
metadata:
name: user-platform
spec:
owner: platform-team
```
**关键决策**:Catalog 用 GitHub 托管(不用数据库),Entity 定义用 YAML(不用 JSON Schema)。这降低了维护成本,但牺牲了强类型校验——早期出现过不少 entity 格式错误,靠 GitHub PR review 解决了。
## 失败复盘:我们曾经搞砸过的地方
### 失败一:功能做了一堆,开发者不用
2024 年 Q1 我们花了两个月做了"服务迁移助手"——把老系统的服务迁移到新平台。功能齐全,文档完善,结果**只有 2 个团队用了,剩下 15 个团队原地不动**。
**根因**:我们站在运维视角想功能,而不是开发者视角。开发者真正需要的是:少改代码、少改配置、少开会。迁移助手解决的"运维的痛点"不是"开发者的痛点"。
**修正**:砍掉迁移助手,转而做"一键创建服务"——开发者想要的就是"别让我写部署配置"。
### 失败二:权限系统太复杂
**原方案**:RBAC + ABAC + 自定义 Policy Engine,三层权限控制。
```yaml
# 权限策略(过于复杂)
apiVersion: iam.example.com/v1
kind: AccessPolicy
spec:
subjects:
- kind: Team
name: user-service-team
resources:
- kind: Deployment
name: user-service-*
permissions:
- get
- list
- create
- update
- delete
conditions:
- key: env
op: in
values: [staging, production]
- key: time
op: between
values: ["09:00", "18:00"]
```
结果:开发者看不懂自己的权限,运维维护政策维护到崩溃。
**修正**:砍掉条件判断,简化为"按团队 + 环境"两层模型:
- `team-A` → `staging` 全权限,`production` 只读
- `team-B` → `staging` + `production` 全权限(因为他们有 SRE oncall)
简单了很多,虽然不够细粒度,但可维护性大幅提升。
### 失败三:SLO 只监控平台,不监控开发者体验
我们最初定义了平台的 SLO:
- API 可用性 99.9%
- 部署成功率 99.5%
但开发者抱怨的还是多——因为部署成功率 99.5% 意味着每 200 次部署有 1 次失败,而失败的那次往往是最紧急的生产事故。
**修正**:加了"首次部署时间"和"部署后首次 P0 事故率"两个指标。这才真正反映了开发者体验。
## 度量:怎么判断平台做得好不好
| 指标 | 目标 | 采集方式 |
|---|---|---|
| 首次部署时间(从代码提交到线上) | <10 分钟 | CI/CD pipeline 日志 |
| 自助部署占比 | >80% | IDP audit log |
| 平均等待时间(提工单 → 完成) | <1 小时 | 工单系统统计 |
| 开发者满意度(季度调查) | >4/5 | 问卷 |
| 平台功能使用率 | >60% | 平台埋点 |
**我的判断**:最重要的指标是"自助部署占比"。如果这个数字低于 50%,说明平台还没有真正解决问题,还需要继续迭代。
## FAQ
### Q:平台工程适合什么规模的团队?
**阈值**:团队超过 10 个、服务超过 20 个时,Platform Engineering 开始显现价值。少于这个规模,人工流程足够高效,建平台的 ROI 为负。我们团队 8 个人、5 个服务时试过,花了一个月搭建,结果没人用——直接砍了。
### Q:平台团队和开发团队的关系怎么处理?
这是个组织问题,不是技术问题。最佳实践:**平台团队是开发者的一部分,不是独立部门**。每个平台功能都由"平台工程师 + 1-2 个开发者"组成 feature team 来开发,这样既懂平台又懂业务,做出来的东西才有人用。
### Q:自建平台 vs 用 Backstage vs 买 SaaS?
- **自建**:适合有特殊合规要求(金融、政务)或深度定制需求的团队
- **Backstage**:适合大多数中型团队,社区活跃、插件丰富
- **SaaS(如 Harness、CloudBees)**:适合不想维护平台本身的团队,但价格昂贵且锁定风险高
我们的建议:先从 Backstage 起步,遇到问题再决定是自研还是购买。
---
## 相关文章
- [混沌工程 Chaos Engineering:主动韧性测试实战,从故障注入到生产演练](chaos-engineering.md)
- [Alertmanager 告警降噪实战:抑制器 + 路由树 + 静默配置四步降噪法](alertmanager-noise-reduction.md)
- [堡垒机选型与 JumpServer 部署](bastion-jumpserver.md)
---
## Alertmanager 告警规则配置:路由树 + Silence/Inhibit + 微信钉钉 Webhook 六坑指南
URL: https://ops.jiunotes.top/monitor/prometheus-alerting.md
摘要: Prometheus Alertmanager 生产配置:三层路由树(P0/P1/P2)、Silence vs Inhibit 对比、微信/钉钉 Webhook 完整配置、告警规则 for 时窗与分组。附六个真实踩坑案例(告警风暴、重复轰炸、路由不生效)。
### Answer
Alertmanager 生产三件套:路由树(按 severity 分发)、告警规则(for 时窗防抖)、通知配置(Webhook 转发到微信/钉钉)。
Silence 用于维护窗口临时静默,Inhibit 用于依赖抑制(磁盘满时抑制 inode 告警)。
最容易翻车的是 group_wait + repeat_interval 配反,要么告警风暴要么漏报。
### FAQ
Q: Silence 和 Inhibit 到底啥区别?
a: Silence 是"临时把某类告警静音",比如维护窗口手工加一条,时间到自动解除。
Inhibit 是"依赖抑制",A 告警触发时自动屏蔽 B 告警。比如 disk_full 触发时屏蔽 disk_inode 告警,
因为磁盘满了 inode 肯定也满,报两个没意义。
A:
Q: 为什么告警来了 50 条?
a: group_by 没配好。同一个 group_by 维度(比如 [alertname, instance])的告警会被合成一条,
group_wait 决定"等多久把同类告警合成一条"。group_by 配成 [instance] 会把不同告警名合成一条,
group_by 配成 [alertname, instance, severity] 又太细。
A:
Q: Alertmanager 怎么发微信?
a: Alertmanager 本身不支持微信,走 webhook 转发。自建一个 10 行的 Python/Node 服务,
接 Alertmanager 的 webhook 请求,转成企业微信机器人或钉钉机器人的 JSON 格式再发出去。
本文给完整 webhook 配置和转发代码。
A:
Q: 我配的 routing 不生效,告警全走了 default?
a: 我踩过这个坑——新版 Alertmanager 废弃了 `match:` 语法,改用 `matchers:` 列表。
老配置 `match: { severity: critical }` 在新版里被忽略,所有告警直接走 default。
改成 `matchers: [{ name: severity, value: critical }]` 就正常了。
可以用 `alertmanager check-config` 命令验证配置是否合法。
A:
Q: repeat_interval 配多少合适?配太短会刷屏吗?
a: repeat_interval 控制"未恢复告警的重发间隔",默认 4h 对大多数场景够用。
P0 告警建议 30m(让值班人员每小时都能看到还没修),P1 建议 4h,P2 可以 24h 甚至更长。
我第一次部署时把它配成 10m,结果微信群半小时刷了 6 条同样的告警,
后来改成 group_wait=10s + repeat_interval=30m 才稳下来。
另外提醒:max_alerts 要配,不然重启时 Alertmanager 会把 pending 告警一次全发出去,
webhook 下游容易被限流。
A:
### Full Content
# Alertmanager 告警规则配置:路由树 + Silence/Inhibit + 微信钉钉 Webhook 六坑指南
Alertmanager 是 [Prometheus](https://prometheus.io/docs/) 告警链路的另一半。Prometheus 负责"什么时候该报警",Alertmanager 负责"报警发给谁、怎么发、什么时候不报"。三个环节配不好,要么告警风暴把群刷屏,要么漏报关键告警——两边都很惨。
## 60 秒理解:三层结构
Alertmanager 干三件事,配置文件里对应三个顶层字段:
```yaml
# alertmanager.yml 骨架
global: # 全局配置(SMTP、默认超时)
route: # 路由树(告警发给谁)
receivers: # 接收者列表(邮件/微信/Webhook)
```
配这三个,再单独写一份 `rules.yml` 定义告警规则。**路由 = 谁收,规则 = 什么时候发,接收者 = 发到哪**。三层分清,配置不会乱。
## 步骤 1:写告警规则 rules.yml
Prometheus 的 `alert_rules.yml` 或 Alertmanager 的静态配置里定义。规则的核心是 `expr`(触发条件)、`for`(持续多久)、`labels`(分类)、`annotations`(描述)。
```yaml
groups:
- name: host-alerts
rules:
# 高严重级:磁盘满
- alert: DiskFull
expr: node_filesystem_avail_bytes{mountpoint="/"} < 1 * 1024 * 1024 * 1024 # <1GB
for: 5m
labels:
severity: critical
annotations:
summary: "磁盘剩余空间不足 1GB"
description: "主机 {{ $labels.instance }} 根分区剩余 {{ $value | humanize1024 }}B"
# 中严重级:CPU 高
- alert: HighCpu
expr: 100 - (avg by(instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100) > 80
for: 10m
labels:
severity: warning
annotations:
summary: "CPU 使用率持续 10 分钟 >80%"
description: "主机 {{ $labels.instance }} 当前 CPU {{ $value | printf \"%.1f\" }}%"
# 低严重级:磁盘 inode 使用率高
- alert: HighInode
expr: node_filesystem_files_free{mountpoint="/"} / node_filesystem_files * 100 < 10
for: 30m
labels:
severity: info
annotations:
summary: "inode 使用率 >90%"
```
**每个字段的坑**:
- `for: 5m` 是关键防抖。不加 `for`,Prometheus 一评估就报,抖动一下就是几十条。**这里最容易翻车**——不设 `for` 的告警规则等于没设防抖。
- `labels.severity` 是路由的依据,必须和 Alertmanager `route.match` 对齐。`critical` / `warning` / `info` 三档最常见。
- `{{ $labels.instance }}` 是模板语法,可以引用告警的 label。写错变量名会渲染成空。
在 Prometheus 侧接入规则:
```yaml
# prometheus.yml
rule_files:
- /etc/prometheus/rules/alert_rules.yml
```
## 步骤 2:写路由树 route(Alertmanager 的"分诊台")
路由树按 `match` 条件分发到不同 `receiver`。**结构是树,不是列表**,从根往下匹配,第一个匹配上就走。
```yaml
route:
receiver: default # 兜底接收者
group_by: [alertname, instance] # 分组维度
group_wait: 30s # 第一次到齐的等待窗口
group_interval: 5m # 组内后续告警的等待窗口
repeat_interval: 4h # 未恢复告警的重发间隔
routes:
# P0: critical 告警 5 分钟静默一次
- match:
severity: critical
receiver: pagerduty-wechat
group_wait: 10s # critical 要快
group_interval: 1m
repeat_interval: 30m # 半小时重发一次
# P1: warning 告警 30 分钟静默一次
- match:
severity: warning
receiver: wechat-team
group_wait: 30s
group_interval: 5m
repeat_interval: 4h
# P2: info 只发邮件
- match:
severity: info
receiver: email-only
repeat_interval: 24h
```
**三个时间字段的含义**(很多人搞混):
| 字段 | 含义 | 配错后果 |
|---|---|---|
| `group_wait` | 第一次收到组内告警后等多久再发 | 太长漏报,太短告警风暴 |
| `group_interval` | 组内后续告警到齐的等待窗口 | 太长合并少,太短合并多 |
| `repeat_interval` | 告警未恢复时的重发间隔 | 太短刷屏,太长漏报 |
**踩坑**:`group_wait` 和 `repeat_interval` 千万别配反。P0 告警 `group_wait: 30s` 是错的(P0 要快),应该是 `10s`。`repeat_interval: 30m` 是对的(半小时重发一次,让值班看一眼)。
## 步骤 3:写 receivers(含微信/钉钉 Webhook)
Alertmanager 原生支持 email、slack、pagerduty、webhook。**不支持微信/钉钉**——必须走 webhook 转发。
```yaml
receivers:
- name: default
email_configs:
- to: 'ops@example.com'
- name: wechat-team
webhook_configs:
- url: 'http://10.0.0.5:8080/wechat/hook'
send_resolved: true # 告警恢复也通知
max_alerts: 20 # 每次最多 20 条,防 Webhook 打爆
- name: pagerduty-wechat
webhook_configs:
- url: 'http://10.0.0.5:8080/wechat/critical'
- name: email-only
email_configs:
- to: 'info@example.com'
- name: dingtalk-team
webhook_configs:
- url: 'http://10.0.0.5:8080/dingtalk/hook'
```
### Webhook 转发服务:10 行 [Python](https://docs.python.org/) 到企业微信
Alertmanager 发来的 JSON 结构复杂,企业微信机器人只吃固定格式的 `markdown` 字段。中间需要一个转发服务。
```python
# alert-webhook.py(用 Flask 或 FastAPI 都行)
from flask import Flask, request
import requests
app = Flask(__name__)
WECHAT_BOT = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY"
def format_alerts(alerts):
lines = []
for a in alerts:
labels = a.get("labels", {})
annotations = a.get("annotations", {})
lines.append(f"**[{a.get('status')}]** {labels.get('alertname')}")
lines.append(f"- 主机:{labels.get('instance')}")
lines.append(f"- 描述:{annotations.get('description', '')}")
return "\n".join(lines)
@app.route("/wechat/critical", methods=["POST"])
@app.route("/wechat/hook", methods=["POST"])
def wechat():
data = request.json
text = f"### ⚠️ 告警 {len(data.get('alerts', []))} 条\n"
text += format_alerts(data["alerts"])
requests.post(WECHAT_BOT, json={"msgtype": "markdown", "markdown": {"content": text}})
return "OK", 200
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8080)
```
部署用 [systemd](https://www.freedesktop.org/software/systemd/man/latest/) 或 [Docker](https://docs.docker.com/):
```bash
docker run -d --name alert-webhook -p 8080:8080 \
--restart=unless-stopped \
-v $(pwd)/alert-webhook.py:/app/alert-webhook.py \
python:3.11-slim python /app/alert-webhook.py
```
**钉钉版本**:把 `WECHAT_BOT` 换成钉钉机器人 URL,JSON 换成 `{"msgtype": "text", "text": {"content": text}}`。**关键坑**:钉钉机器人有关键字校验,你的告警文本里必须包含机器人配置的关键字(比如 "告警" 或 "监控"),否则被拒。
## 步骤 4:Silence vs Inhibit 完整对比
### Silence:维护窗口临时静默
```yaml
# 通过 API 加
curl -X POST http://localhost:9093/api/v2/silences \
-H "Content-Type: application/json" \
-d '{
"matchers": [
{"name": "severity", "value": "warning", "isRegex": false}
],
"startsAt": "2026-10-18T02:00:00Z",
"endsAt": "2026-10-18T04:00:00Z",
"createdBy": "ops",
"comment": "维护窗口,2:00-4:00 静默 warning"
}'
```
或者在 UI 上:Alertmanager → Silences → New Silence,选 match、填时间、填原因。**关键**:一定要写 `comment`,事后没人知道为什么被静默了。
### Inhibit:依赖抑制
```yaml
inhibit_rules:
# 磁盘满时抑制 inode 告警
- source_matchers:
- alertname = "DiskFull"
target_matchers:
- alertname = "HighInode"
equal: [instance] # 同 instance 才抑制
# 服务挂了抑制该服务的健康检查告警
- source_matchers:
- alertname = "ServiceDown"
target_matchers:
- alertname = "HighLatency"
equal: [service]
```
**注意**:`equal: [instance]` 是必须的。不加这个,`DiskFull` 在服务器 A 上会抑制所有服务器的 `HighInode`,跨主机误伤。
## 六坑指南:真实失败案例
### 坑 1:告警风暴——group_by 配错
**症状**:改配置后突然收到 500 条告警邮件。
**根因**:`group_by: [alertname, instance]` 改成了 `group_by: [alertname]`,所有 instance 合成一条,但 `send_resolved: true` 一开,50 个实例各自恢复各自报。
**修复**:`group_by` 至少包含 `instance`,让每个实例独立分组合并。
### 坑 2:告警来了但没发出
**症状**:Prometheus 里告警是 firing,Alertmanager UI 里也有,但邮件/微信收不到。
**根因**:`receivers` 里配置的 URL 打不通,或者 webhook 服务没启动。Alertmanager 会重试 3 次然后放弃。
**排查**:`docker logs alertmanager` 找 `delivering alerting to webhook` 相关的报错。
### 坑 3:Silence 加了但告警还响
**症状**:加了 silence,但告警继续发。
**根因**:Silence 的 `matchers` 没匹配上。比如告警有 `severity: warning` 但 silence 写成了 `severity: warn`。
**排查**:UI → Silences → 看 `Matchers` 字段,和告警的 labels 逐字比对。
### 坑 4:路由不生效,全部走 default
**症状**:配置了 critical/warning/info 三层路由,但告警全发到 default receiver。
**根因**:`match` 写成了 `matchers` 的语法,或者反过来。新版 Alertmanager 只认 `matchers`,`match` 已废弃。
**修复**:所有 `match:` 改成 `matchers: [ { name: severity, value: critical } ]` 格式。
### 坑 5:告警恢复了但没通知
**症状**:告警触发了,恢复了以后没人知道。
**根因**:`send_resolved: false`(默认值),或者 recovery 通知被 silence 屏蔽了。
**修复**:改成 `send_resolved: true`。
### 坑 6:Webhook 打爆了下游
**症状**:Alertmanager 一重启,企业微信机器人被 500 条消息塞爆,甚至被限流。
**根因**:Alertmanager 重启时会把之前 pending 的所有告警一次性发出,`max_alerts` 没配。
**修复**:`webhook_configs` 加 `max_alerts: 20`。
**这一点我也不完全确定**:Alertmanager 的重启重发行为跟版本有关,v0.25+ 加了 pending 告警合并优化,老版本可能不受 `max_alerts` 限制。
## 常见告警规则模板
```yaml
groups:
- name: prometheus-self
rules:
- alert: PrometheusDown
expr: up{job="prometheus"} == 0
for: 5m
labels:
severity: critical
annotations:
summary: "Prometheus 进程挂了"
- alert: PrometheusScrapeFail
expr: rate(prometheus_tsdb_head_series_created_total[5m]) == 0
for: 10m
labels:
severity: warning
annotations:
summary: "Prometheus 5 分钟没抓到新指标"
- name: node-basic
rules:
- alert: NodeDown
expr: up == 0
for: 2m
labels:
severity: critical
annotations:
summary: "节点 {{ $labels.instance }} 下线"
- alert: HighMemory
expr: node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes < 0.1
for: 10m
labels:
severity: warning
annotations:
summary: "内存可用率 <10%"
```
## 告警链路上线后的巡检与降噪纪律
Alertmanager 配置完不是终点,我给自己定了三条长期纪律,全是被告警风暴教育出来的。
**每月做一次「僵尸告警」清点。** 我把所有 firing 超过 7 天还没人认领的告警拉出来过一遍。长期 firing 的告警要么阈值定太严永远触发、要么是设备早下线了规则没清理、要么是重复告警。它们不会害人,但会稀释真实 P0 的注意力——值班的人看到一堆老告警就把新的也当噪音划过去了。每月清一批,规则库才保持锐利。
**给告警配置本身上监控。** 我专门配了一条元告警:webhook 转发服务连续 5 分钟没收到任何请求就报 `AlertPipelineSilent`,Alertmanager 自身 `am_alerts_buffer` 持续增大就报积压。告警系统挂了最危险的地方是「静默」——不是报警没响,而是你以为它还在跑。这条元告警我放在 critical 通道,专治「告警链路没人看」的盲区。
**每次改配置都留 git 版本 + 可回滚。** alertmanager.yml 和 rules.yml 都在 git,改完 `amtool check-config` 校验通过再 reload(`kill -SIGHUP`),绝不直接热改生产文件。`amtool check-config` 这一步我吃过亏——一次 matchers 语法写错,reload 后所有告警全走 default,微信群被 P2 信息刷屏。从那起我把校验写进发布脚本,校验不过禁止 reload。
## 总结与判断
Alertmanager 配置不难,难在**参数怎么调**。三个时间字段(group_wait / group_interval / repeat_interval)配错了体验立刻崩,但每改一版都得等下次真实告警才能验证——这就是告警配置最反人性的地方:你**不能主动测试**,只能被动等它出问题。
**我的判断**:小规模团队(<10 台机器)用邮件就够,微信/钉钉 Webhook 是花架子。**超过 20 台机器、开始有 P0/P1 分级的时候,才需要认真配 Alertmanager**。硬要一步到位,会陷入"配半天,告警一次没触发过"的死循环。
## 延伸阅读
- [Prometheus 部署实战](prometheus-deploy.md)
- [PromQL 常用查询模板](prometheus-query.md)
---
---
## Prometheus 部署实战:Docker / systemd / K8s 三种方案 60 秒选型
URL: https://ops.jiunotes.top/monitor/prometheus-deploy.md
摘要: Prometheus 部署三种方案对比:Docker、systemd、K8s 谁该选谁。最小配置 3 行 + node_exporter 接入,5 分钟出第一个指标,含 5 个内存与保留策略事故复盘。
### Answer
Prometheus 三种装法:Docker(推荐个人/小团队)、systemd(原生 Linux)、K8s(已有集群才用)。
最小配置只需 3 行:`global` + `scrape_configs` + node_exporter target。5 分钟出第一个指标。
关键坑:默认保留 15 天,内存按 1000 指标 × 200MB 估算,1000 指标至少预留 2GB。
### FAQ
Q: Prometheus 和 Zabbix 怎么选?
A: 云原生、Go/Rust 服务选 Prometheus(pull 模型 + PromQL);有 Windows 或传统基础设施选 Zabbix(push 模型 + Agent)。两套不冲突,可以并存。
Q: node_exporter 常见指标有哪些?
A: node_cpu_seconds_total、node_memory_MemAvailable_bytes、node_filesystem_avail_bytes、node_network_receive_bytes_total 是四块必看的。
Q: Prometheus 需要多少内存?
A: 每 1000 个活跃指标约 200MB 基线,加查询开销按 2-3 倍估算。小规模 2GB 起步,中规模 8-16GB,大规模看 Thanos/VictoriaMetrics。
Q: 默认 15 天保留够不够,不够了怎么办?
A: 默认 15 天对趋势分析偏短。想延长本地保留要同时加大 `--storage.tsdb.retention` 和磁盘,但长期保留会推高内存与 IO。 更划算的做法是热数据本地留 7-15 天,超过的走 Thanos 或 VictoriaMetrics 归档到对象存储,别硬堆单机磁盘。
### Full Content
# Prometheus 部署实战:Docker / systemd / K8s 三种方案 60 秒选型
Prometheus([官方文档](https://prometheus.io/docs/))是监控栈的默认起点,但装 Prometheus 之前,先花 60 秒想清楚三件事:**装在哪**、**要不要装 exporter**、**保留多久**。常见装法有 [Docker](https://docs.docker.com/)(容器化、易迁移)、[systemd](https://www.freedesktop.org/software/systemd/man/latest/)(原生、极简)、[K8s](https://kubernetes.io/docs/)(云原生编排)三种,想清楚再动手,后面不会走弯路。
## 60 秒选型:三种装法谁该选谁
| 装法 | 适合场景 | 优点 | 坑 |
|---|---|---|---|
| **Docker** | 个人学习、小团队、非 K8s 环境 | 版本切换简单、迁移方便 | 需要装 Docker、卷挂载要注意权限 |
| **systemd** | 原生 [Linux](https://www.kernel.org/doc/html/latest/)、追求极简、生产单节点 | 无依赖、资源占用低 | 升级麻烦、需要 root |
| **K8s** | 已有 K8s 集群 | 与其他服务对齐、Helm 管理 | 学习曲线陡、Helm Chart 版本更新慢 |
**决策原则**:
- 已有 K8s 集群 → **K8s**,不折腾 Docker
- 原生 Linux、要长期跑 → **systemd**
- 学习、多机迁移、快速试错 → **Docker**
下文以 Docker 为主讲(覆盖 80% 场景),systemd 和 K8s 各给最小可用配置。
## 步骤 1:拉镜像或下载二进制
### Docker 装法
```bash
# 拉官方镜像
docker pull prom/prometheus:v2.53.0
# 看版本
docker run --rm prom/prometheus:v2.53.0 prometheus --version
```
**你应该看到什么**:Prometheus 版本号,如 `prometheus, version 2.53.0`。
### systemd 装法
```bash
# 官方 tar.gz
wget https://github.com/prometheus/prometheus/releases/download/v2.53.0/prometheus-2.53.0.linux-amd64.tar.gz
tar xzf prometheus-2.53.0.linux-amd64.tar.gz
sudo mv prometheus-2.53.0.linux-amd64 /opt/prometheus
sudo useradd -r -s /bin/false prometheus
sudo chown -R prometheus:prometheus /opt/prometheus
```
systemd unit 文件(`/etc/systemd/system/prometheus.service`):
```ini
[Unit]
Description=Prometheus
After=network-online.target
[Service]
User=prometheus
Group=prometheus
Type=simple
ExecStart=/opt/prometheus/prometheus \
--config.file=/opt/prometheus/prometheus.yml \
--storage.tsdb.path=/opt/prometheus/data \
--storage.tsdb.retention.time=15d \
--web.listen-address=0.0.0.0:9090
Restart=on-failure
RestartSec=5s
[Install]
WantedBy=multi-user.target
```
```bash
sudo systemctl enable --now prometheus
sudo systemctl status prometheus
```
### K8s 装法(有 Helm 的场景)
```bash
helm repo add prometheus https://prometheus-community.github.io/helm-charts
helm install prometheus prometheus-community/kube-prometheus-stack
```
**注意**:kube-prometheus-stack 是社区维护的完整栈(含 [Grafana](https://grafana.com/docs/)、Alertmanager、node_exporter),不适合学习原理,但适合快速生产。若只是学习,`prometheus-community/prometheus` 更纯粹。
## 步骤 2:写最小可用配置
Prometheus 的主配置文件是 `prometheus.yml`。最小可用配置只需 3 行核心内容:
```yaml
# prometheus.yml 最小可用版本
global:
scrape_interval: 15s # 默认 15 秒抓一次
evaluation_interval: 15s # 默认 15 秒评估一次告警规则
scrape_configs:
- job_name: 'prometheus' # 抓自己(自监控)
static_configs:
- targets: ['localhost:9090']
```
**每个字段为什么这么写**:
- `scrape_interval: 15s` 是 Prometheus 默认值,也是绝大多数 exporter 推荐的采样频率。15 秒太慢会漏细节,太快会烧指标数。
- `evaluation_interval: 15s` 控制告警规则的评估频率,一般与 `scrape_interval` 一致。
- `job_name: 'prometheus'` 抓自己,用于监控 Prometheus 本身的运行状态(内存、抓取耗时、抓取失败率)。这是自监控的起点。
### Docker 用 -v 挂载配置文件
```bash
mkdir -p ~/prometheus && cd ~/prometheus
cat > prometheus.yml <<'EOF'
global:
scrape_interval: 15s
scrape_configs:
- job_name: 'prometheus'
static_configs:
- targets: ['localhost:9090']
EOF
docker run -d \
--name prometheus \
--restart=unless-stopped \
-p 9090:9090 \
-v ~/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro \
-v ~/prometheus/data:/prometheus \
--user $(id -u):$(id -g) \
prom/prometheus:v2.53.0 \
--storage.tsdb.retention.time=15d
```
**关键点**:
- `:ro` 让配置文件只读,避免容器意外修改
- `--user $(id -u):$(id -g)` 用当前用户跑,避免生成 root 拥有的文件
- `retention.time=15d` 是官方默认,够个人 / 小团队使用
## 步骤 3:启动并验证
```bash
# 1. 检查容器
docker ps | grep prometheus
# 2. 看日志
docker logs prometheus --tail 30
# 3. 验证存活
curl -f http://localhost:9090/-/healthy && echo "OK"
# 4. 看 targets
curl -s http://localhost:9090/api/v1/targets | jq '.data.activeTargets[0]'
```
**你应该看到什么**:
- Step 3 命令返回 `OK`
- Step 4 命令返回 `health: "up"`
浏览器打开 http://localhost:9090,能看到 Prometheus UI,Targets 页面里 `prometheus` 这一行显示 **UP** 状态。
## 步骤 4:装 node_exporter(Prometheus 的最低门槛)
Prometheus 自己抓自己只是自监控。要真正监控 Linux 主机,必须装 **node_exporter**。这是 Prometheus 生态最基础、最常用的 exporter。
### Docker 装 node_exporter
```bash
docker run -d \
--name node-exporter \
--restart=unless-stopped \
-p 9100:9100 \
-v /proc:/host/proc:ro \
-v /sys:/host/sys:ro \
-v /:/host/root:ro \
quay.io/prometheus/node-exporter:v1.8.2 \
--path.procfs=/host/proc \
--path.sysfs=/host/sys \
--path.rootfs=/host/root \
--collector.filesystem.mount-points-exclude='^/(dev|proc|sys|var|etc|run)(/|$)'
```
**关键点**:node_exporter 必须能读到 `/proc`、`/sys` 文件系统,这是它采集 CPU、内存、磁盘指标的数据源。
### 更新 prometheus.yml 加上 node_exporter
```yaml
global:
scrape_interval: 15s
scrape_configs:
- job_name: 'prometheus'
static_configs:
- targets: ['localhost:9090']
- job_name: 'node'
static_configs:
- targets: ['localhost:9100']
```
```bash
# 重启 Prometheus 让新配置生效
docker restart prometheus
# 30 秒后看 targets
curl -s http://localhost:9090/api/v1/targets | jq '.data.activeTargets[] | {job, health}'
```
**你应该看到什么**:两个 job 都是 `"health": "up"`。
Prometheus UI 里现在应该能看到 `node_cpu_seconds_total`、`node_memory_MemAvailable_bytes` 等指标。
### 验证第一个 PromQL 查询
在 Prometheus UI 的 Explore 页输入:
```promql
100 - (avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100)
```
这条查询返回每个节点的 CPU 使用率百分比。**看到有数就成功**。
## 常见内存 / 磁盘事故
Prometheus 装完之后最头疼的两件事:**内存爆**和**磁盘满**。这两个问题的根因和修复路径固定,记住就能省时间。
### 事故一:内存爆掉
**症状**:`node_memory_MemAvailable_bytes` 持续下降,Prometheus 容器 OOM 被 Docker 杀掉。
**根因**:
1. **抓了太多 target**(每加一台机器 + 100 个指标)
2. **抓了高基数标签**(比如按 `user_id`、`request_id` 分组,每请求一个时间序列)
3. **`--storage.tsdb.retention.time` 太长**,WAL 目录膨胀
**排查**:
```promql
count(count by (__name__) ({__name__=~".+"}))
```
这条 PromQL 返回 Prometheus 里活跃的时间序列总数。**超过 100 万就要注意**,超过 500 万必须扩容。
```promql
topk(10, count by (__name__) ({__name__=~".+"}))
```
看哪个指标名产生的序列最多,通常是 `http_request_duration_seconds_bucket` 之类的 histogram。
**修复**:删掉高基数标签,或改用 `histogram_quantile` 聚合而非原始数据。
### 事故二:磁盘写满
**症状**:Prometheus 报 `cannot allocate memory: resource temporarily unavailable` 或 WAL 目录超过磁盘。
**根因**:
1. `retention.time` 太长(默认 15d,但抓高频指标场景可能爆)
2. WAL 目录没有和 TSDB 目录分开
**修复**:
```bash
# 分开两个卷,WAL 用临时盘
docker run -d \
-v ~/prometheus/tsdb:/prometheus/tsdb \
-v /tmp/prometheus-wal:/prometheus/wal \
... \
--storage.tsdb.path=/prometheus/tsdb \
--storage.tsdb.retention.time=30d \
--storage.tsdb.wal-compression
```
`--storage.tsdb.wal-compression` 是关键:启用 WAL 压缩能减少 30-50% 磁盘写入。
### 事故三:查询超时
**症状**:Prometheus UI 查询 `rate(xxx[1h])` 报 `context deadline exceeded`。
**根因**:
1. 查询范围太长(比如 `[24h]` 但数据密集)
2. 查询本身计算量大(比如复杂 nested rate)
3. Prometheus 没配 `--query.timeout` 上限
**修复**:
```bash
# 容器参数
--query.timeout=10s \
--query.lookback-delta=5m \
--web.enable-lifecycle
```
`--web.enable-lifecycle` 开启后可以通过 `POST /-/reload` 热加载配置,不用重启。
## 数据保留策略
`retention.time` 是 Prometheus 磁盘规划的关键参数。经验值:
| 场景 | 建议保留 | 磁盘估算 |
|---|---|---|
| 个人学习 | 3d | 500MB |
| 小团队生产 | 15d | 5GB |
| 中规模生产 | 30d | 20GB |
| 长期分析 | 90d+ | 用 Thanos / VictoriaMetrics,不用 Prometheus 单节点 |
**关键规则**:Prometheus 单节点不适合超过 90 天。要长期保留就上 Thanos 或 VictoriaMetrics,通过 `remote_write` 推送数据。
## Roadmap 延伸:装完 Prometheus 之后做什么
装完 Prometheus + node_exporter 只是起点。下一步的自然延伸:
1. **接入 Alertmanager 做告警**:Prometheus 只负责指标存储,告警分发要交给 Alertmanager。详见本站 [Prometheus 告警与 Alertmanager 通知链](prometheus-alerting.md)。
2. **接 Grafana 做面板**:Prometheus UI 是查询工具,不是可视化平台。Grafana 让数据变得可用。详见本站 [Grafana 面板搭建](grafana-dashboard.md)。
3. **写 PromQL 常用查询**:10 个常用查询模板可以直接抄。详见本站 [PromQL 常用查询模板](prometheus-query.md)。
4. **进阶 exporter**:黑盒探测(`blackbox_exporter`)、MySQL(`mysqld_exporter`)、[Redis](https://redis.io/docs/)(`redis_exporter`)、[Nginx](https://nginx.org/en/docs/)(`nginx-exporter`)。这些是 exporter 生态最活跃的部分。
## FAQ
### Q:Prometheus 和 Zabbix 怎么选?
**短答**:不是二选一,取决于你的技术栈。
- **Prometheus 强项**:pull 模型(Prometheus 主动去问)、PromQL(查询语言强大)、云原生生态、Grafana 集成、告警规则表达式成熟。
- **Zabbix 强项**:push 模型(Agent 主动上报)、传统基础设施(Windows、物理机)、大规模主机(10 万 +)、内置告警 UI、中文社区成熟。
**我的建议**:纯 Linux + 云原生选 Prometheus;混合环境(Windows + Linux + 网络设备)选 Zabbix 打底,K8s 部分用 Prometheus 补充。两套不冲突。
### Q:Prometheus 数据保留策略怎么写?
`retention.time` 参数是硬编码的,写在启动参数里。要动态调整,得配合 Thanos 或 VictoriaMetrics。
```bash
# Docker
--storage.tsdb.retention.time=30d
--storage.tsdb.retention.size=50GB # 也可以用大小限制
```
生产建议:`retention.time` 用时间(30d),`retention.size` 用大小(防止数据异常膨胀)。
### Q:Prometheus 和 Consul / Thanos 什么关系?
三个都是 Prometheus 生态的重要组件,但角色不同:
- **Consul**:服务发现工具。Prometheus 用 Consul 做 target 动态发现。`scrape_configs` 里用 `consul_sd_configs` 块。
- **Thanos**:Prometheus 之上的长期存储和全局查询层。Prometheus 单节点最多 30-90 天,Thanos 通过 `remote_write` 汇聚多个 Prometheus 的数据到对象存储(S3、GCS),支持数年查询。
- **VictoriaMetrics**:Thanos 的替代品,性能更高、部署更简单,社区认可度也在快速提升。
**决策原则**:小规模(10 台机器以内,保留 30 天内)→ 单 Prometheus 就够;中规模(50-500 台)→ 加 Thanos 或 VictoriaMetrics;大规模(500 台以上)→ 考虑 M3DB、Cortex 等分布式方案。
---
---
## Helm 部署 Prometheus:kube-prometheus-stack 六步跑通 + 3 个 K8s 新手必踩坑
URL: https://ops.jiunotes.top/monitor/prometheus-helm.md
摘要: Helm 部署 Prometheus 完整教程:kube-prometheus-stack 是什么、六步实操、ServiceMonitor 接入真实应用、Grafana 登录与告警规则。含 3 个 K8s 新手必踩坑。
### Answer
K8s 集群监控首选 kube-prometheus-stack:一个 Helm Chart 装 6 个组件(Prometheus + Grafana + Alertmanager + node-exporter + kube-state-metrics + Operator)。六步:装 Helm → 建 namespace → 加 repo → helm install → 验证 pod → 加 ServiceMonitor。
K8s 新手必踩 3 个坑:Grafana 默认密码是 prom-operator(不是 admin)、CRD 装不上导致所有 pod Pending、ServiceMonitor 没打 release label Prometheus 找不到。
### FAQ
Q: kube-prometheus-stack 和普通 prometheus 有什么区别?
A: 一个是完整栈(含 Grafana、Alertmanager、node-exporter、kube-state-metrics、Prometheus Operator),一个是纯 Prometheus 二进制。栈用 Helm 装、通过 CRD 管理配置,Prometheus 二进制要手写 YAML 且不含告警。K8s 场景必用栈,别用二进制。
Q: Grafana 装完默认密码是什么?
A: kube-prometheus-stack 的默认密码是 `prom-operator`,不是官方 Grafana 的 `admin`。admin 用户名不变。登录立刻改,`kubectl get secret -n monitoring -o jsonpath="{.data.admin-password}" kube-prometheus-stack-grafana | base64 -d` 可以取到实际密码。
Q: ServiceMonitor 建了但 Prometheus 抓不到目标?
A: 99% 是 label 不匹配。kube-prometheus-stack 默认只抓带 `release: monitoring` 标签的 Service。你的 ServiceMonitor 要加 `labels: release: monitoring`(值必须是 helm install 时 --name 的值),或者改 values 里的 `prometheus.serviceMonitorSelectorNilUsesHelmValues`。
Q: 生产环境 Prometheus 该配多大容量?
A: 三条经验值:① retention 按「能覆盖最坏情况排障」定,通常 15-30 天,retentionSize 兜底防磁盘打满;② 采样点数量 = active targets × 每 target 指标数 × 采集频率,先用 30 天峰值容量 × 1.5 冗余估 PVC 大小;③ 把 `prometheus_storage` 的磁盘使用率挂告警,超 80% 提示、超 90% 升级。别设得过大——TSDB 越大 compact 越重,OOM 风险越高。
### Full Content
# Helm 部署 Prometheus:kube-prometheus-stack 六步跑通 + 3 个 K8s 新手必踩坑
[Prometheus](https://prometheus.io/docs/) 单机装法我之前写过了([Prometheus 部署实战](prometheus-deploy.md)),单机够用但一到 [K8s](https://kubernetes.io/docs/) 集群就不实用——你不可能在一台 K8s 节点上跑 Prometheus 然后去抓其他节点的 target。**K8s 集群监控的正确姿势是 kube-prometheus-stack**,一个 Helm Chart 装一整套,含 Prometheus、[Grafana](https://grafana.com/docs/)、Alertmanager、node-exporter、kube-state-metrics 和 Prometheus Operator。
这篇文章讲怎么从零装起来,然后接入一个真实应用做监控,最后配一条告警规则。**K8s 新手必踩的 3 个坑都在最后,装之前先看能省一小时**。
## 概念:kube-prometheus-stack 到底装了什么
装之前先搞清楚 Helm Chart 里都包含哪些组件。搞不清就容易装完找不到 Grafana:
| 组件 | 作用 | 端口 |
|---|---|---|
| **prometheus** | 抓指标、存 TSDB、跑 PromQL | 9090 |
| **grafana** | 可视化仪表盘 | 3000 |
| **alertmanager** | 告警路由、去重、通知 | 9093 |
| **node-exporter** | 节点 CPU/内存/磁盘指标 | 9100 |
| **kube-state-metrics** | K8s 对象状态(Pod、Deployment、PVC)| 8080 |
| **prometheus-operator** | 通过 CRD 管理 Prometheus 配置 | 8080 |
Prometheus Operator 是核心——它把 Prometheus 配置、告警规则、ServiceMonitor 都变成 K8s CRD,你不需要手动 restart Prometheus 加载配置。CRD 就是 Custom Resource Definition,K8s 的扩展机制。
## 6 步实操
### 步骤 1:装 Helm 3
```bash
# 检查 helm 是否已装
helm version
# 未装则按官方文档装(略)
# https://helm.sh/docs/intro/install/
```
**你应该看到什么**:`version.BuildInfo{Version:"v3.x.x"...}`。Helm 3 是当前主流,Helm 2 已经 EOL。
### 步骤 2:建 monitoring namespace
```bash
kubectl create namespace monitoring
kubectl get ns monitoring
```
**你应该看到什么**:`monitoring Active 0s`。
**为什么单独一个 namespace**:K8s 生产实践强烈建议工具类组件(Prometheus、Grafana)与业务分开。namespace 是 K8s 的资源隔离单位,也方便后续 RBAC 授权。
### 步骤 3:加 Helm repo
```bash
helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
helm repo update
# 看有哪些 chart
helm search repo prometheus-community/ | head -20
```
**你应该看到什么**:末尾输出大量 chart 名,包括 `prometheus-community/kube-prometheus-stack`。
### 步骤 4:写 values.yaml(关键!)
默认 values 装出来 Grafana 密码是 `prom-operator`,retention 15 天,存储无持久化。生产环境建议改:
```yaml
# values.yaml
# Grafana 改密码
grafana:
adminPassword: "CHANGE_ME_to_a_strong_password"
# Prometheus 保留期 + 存储
prometheus:
prometheusSpec:
retention: 30d
retentionSize: 20GB
storageSpec:
volumeClaimTemplate:
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: standard # 按你的集群实际 storageClass 改
resources:
requests:
storage: 20Gi
resources:
requests:
memory: 2Gi
cpu: 1000m
limits:
memory: 4Gi
cpu: 2000m
# 抓自己
ruleStorage:
volumeClaimTemplate:
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: standard
resources:
requests:
storage: 5Gi
# Alertmanager 告警通道
alertmanager:
alertmanagerSpec:
config:
receivers:
- name: "wechat-webhook"
webhook_configs:
- url: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY"
send_resolved: true
route:
receiver: "wechat-webhook"
group_by: ["alertname", "namespace"]
group_wait: 30s
group_interval: 5m
repeat_interval: 4h
```
```bash
# 装
helm install monitoring prometheus-community/kube-prometheus-stack \
-n monitoring \
-f values.yaml \
--timeout 10m
```
**你应该看到什么**:`NAME: monitoring` / `STATUS: deployed` / `REVISION: 1`。
### 步骤 5:验证 Pod 就绪
```bash
# 等所有 pod Ready
kubectl wait --for=condition=Ready pods --all -n monitoring --timeout=300s
# 看 pod 状态
kubectl get pods -n monitoring
```
**你应该看到什么**:一堆 pod 都是 `Running` 状态,`READY` 列都是 `1/1` 或 `2/2`。
```bash
# 看 Prometheus Targets
kubectl port-forward -n monitoring svc/monitoring-prometheus 9090:9090 &
# 浏览器打开 http://localhost:9090/targets
```
**你应该看到什么**:Targets 页面所有 job 都是绿色 UP 状态。
### 步骤 6:接入真实应用(ServiceMonitor)
装完 kube-prometheus-stack 只能监控 K8s 自己。**要监控你自己的业务应用,得写 ServiceMonitor**。假设你的应用已有一个 Service 暴露 `/metrics`:
```yaml
# app-servicemonitor.yaml
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: demo-app-monitor
namespace: monitoring
labels:
release: monitoring # 关键!必须是 helm install 时的 release name
spec:
selector:
matchLabels:
app: demo-app
# namespace 匹配需要在别的字段指定,见下
endpoints:
- port: http # Service 里 targetPort 对应的 name
path: /metrics
interval: 30s
namespaceSelector:
any: true
matchNames:
- default # 你的应用所在 namespace
```
```bash
kubectl apply -f app-servicemonitor.yaml
# 30 秒后看 Prometheus Targets 有没有 demo-app
kubectl port-forward -n monitoring svc/monitoring-prometheus 9090:9090 &
# 浏览器 http://localhost:9090/targets 搜索 demo-app
```
**你应该看到什么**:Targets 页面出现 `demo-app` job,状态绿色 UP。
```bash
# 在 Grafana 里查一下数据
kubectl port-forward -n monitoring svc/monitoring-grafana 3000:80 &
# 打开 http://localhost:3000 登录,Explore 页写 PromQL
```
```promql
rate(http_requests_total{job="demo-app"}[5m])
```
看到有数就成功。
## 3 个 K8s 新手必踩坑
### 坑一:Grafana 密码错了登不进去
**症状**:浏览器打开 Grafana 页面,用 `admin/admin` 登录报 "Invalid credentials"。
**根因**:kube-prometheus-stack 默认密码是 `prom-operator`,不是官方 Grafana 的 `admin`。
**验证**:
```bash
kubectl get secret -n monitoring monitoring-grafana \
-o jsonpath="{.data.admin-password}" | base64 -d
```
**你应该看到什么**:返回一串明文密码,默认是 `prom-operator`。
**修复**:立即改密码,或者装的时候用 `--set grafana.adminPassword=your_password`。
### 坑二:所有 pod Pending(CRD 未装)
**症状**:`helm install` 成功,但 `kubectl get pods` 显示多个 pod `Pending` 或 `ContainerCreating` 卡住。
**排查**:
```bash
kubectl describe pod -n monitoring
```
**你应该看到什么**:末尾 `Events` 里有 `FailedCreate: pods "xxx" is forbidden: no matches for kind "ServiceMonitor" in version "monitoring.coreos.com/v1"`。
**根因**:kube-prometheus-stack 依赖一堆 CRD(ServiceMonitor、PrometheusRule、PodMonitor 等)。如果 K8s 版本太老(1.19 以下)或 CRD 安装失败,所有 pod 都会 Pending。
**修复**:
```bash
# 单独装 CRD
helm install monitoring prometheus-community/kube-prometheus-stack \
-n monitoring \
--set crds.enabled=true \
-f values.yaml
# 或确认 K8s 版本 ≥ 1.22
kubectl version --short
```
### 坑三:ServiceMonitor 建了 Prometheus 找不到
**症状**:ServiceMonitor `kubectl get servicemonitor` 有输出,但 Prometheus Targets 里搜不到对应 job。
**根因**:ServiceMonitor 的 label 与 Prometheus 的 selector 不匹配。kube-prometheus-stack 默认 `prometheus.serviceMonitorSelectorNilUsesHelmValues: true`,即只选带 `release: monitoring` 标签的 ServiceMonitor。
**排查**:
```bash
kubectl get servicemonitor -n monitoring -o yaml | grep -A 3 labels
```
**你应该看到什么**:每个 ServiceMonitor 都应有 `labels: release: monitoring`。
**修复**:
```yaml
metadata:
labels:
release: monitoring # 加这一行
```
或者改 values:
```yaml
prometheus:
serviceMonitorSelectorNilUsesHelmValues: false
serviceMonitorNamespaceSelector: {}
```
**权衡**:`false` 会抓到所有 namespace 的 ServiceMonitor,灵活但可能暴露过多 target。生产建议保留默认 `true` 并显式打 label。
## 告警规则:PrometheusRule CRD
装完就能配告警,不用改 Prometheus 的 `prometheus.yml`:
```yaml
# app-alerts.yaml
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: demo-app-alerts
namespace: monitoring
labels:
release: monitoring
spec:
groups:
- name: demo-app.rules
interval: 30s
rules:
- alert: DemoAppDown
expr: up{job="demo-app"} == 0
for: 2m
labels:
severity: critical
team: backend
annotations:
summary: "demo-app 挂了"
description: "demo-app 已经 2 分钟没有响应,最后抓取时间 {{ $labels.instance }}"
- alert: DemoAppHighLatency
expr: |
histogram_quantile(0.95,
rate(http_request_duration_seconds_bucket{job="demo-app"}[5m])
) > 1
for: 10m
labels:
severity: warning
annotations:
summary: "demo-app P95 延迟超 1s"
description: "过去 10 分钟 P95 请求延迟超过 1 秒,当前 {{ $value }}s"
```
```bash
kubectl apply -f app-alerts.yaml
# 验证规则已加载
kubectl port-forward -n monitoring svc/monitoring-prometheus 9090:9090 &
# 浏览器 http://localhost:9090/rules 搜索 DemoApp
```
**你应该看到什么**:Rules 页面出现 `DemoAppDown` 和 `DemoAppHighLatency` 两条规则,状态 `firing: false`(说明当前没触发)。
## Cheat Sheet:Helm 部署常用命令
| # | 命令 | 用途 |
|---|---|---|
| 1 | `helm list -n monitoring` | 看已装的 Chart |
| 2 | `helm status monitoring -n monitoring` | 看 Chart 状态 |
| 3 | `helm get values monitoring -n monitoring` | 看实际生效的 values |
| 4 | `helm get manifest monitoring -n monitoring` | 看渲染出的完整 YAML |
| 5 | `helm upgrade monitoring ... -f values.yaml` | 升级 Chart |
| 6 | `helm history monitoring -n monitoring` | 看版本历史 |
| 7 | `helm rollback monitoring -n monitoring` | 回滚 |
| 8 | `helm uninstall monitoring -n monitoring` | 卸载 |
| 9 | `kubectl port-forward -n monitoring svc/monitoring-grafana 3000:80` | 本地访问 Grafana |
| 10 | `kubectl port-forward -n monitoring svc/monitoring-prometheus 9090:9090` | 本地访问 Prometheus |
| 11 | `kubectl logs -n monitoring -l app.kubernetes.io/name=prometheus -f` | 看 Prometheus 日志 |
| 12 | `kubectl get servicemonitor -A` | 看所有 ServiceMonitor |
| 13 | `kubectl get prometheusrule -A` | 看所有告警规则 |
| 14 | `kubectl get alertmanager -n monitoring` | 看 Alertmanager CRD |
| 15 | `kubectl delete pvc -n monitoring -l app.kubernetes.io/name=prometheus` | 清 Prometheus 数据(慎用)|
## 收尾:什么场景该选 Helm,什么场景不该选
**该选 kube-prometheus-stack 的场景**:
- 已有 K8s 集群,1.22+ 版本
- 有运维能力,能读 CRD YAML
- 需要 K8s 对象状态指标(kube-state-metrics)
- 需要 GitOps 管理(所有配置都是 YAML)
**不该选的场景**:
- 单机 / 少量虚拟机 → 用 [Docker 装法](prometheus-deploy.md) 更简单
- 需要 Windows 监控 → 用 [Zabbix](zabbix-setup.md),Prometheus 生态没好的 Windows exporter
- 已经买了 Datadog / New Relic → 别重复造轮子
我个人倾向 kube-prometheus-stack,但坦率说它踩坑门槛比 [Docker](https://docs.docker.com/) 装法高——CRD 概念、values 覆盖、ServiceMonitor 选择器这些都要理解到位。装完之后维护成本也不低,尤其是 Prometheus 版本升级、CRD 兼容性这些坑会时不时冒出来。**能不能省时间,取决于团队对 K8s 的熟悉程度**。
## 部署后的运维巡检与容量纪律
装起来只是开始,真正拉开差距的是上线后的容量规划和巡检节奏。我们这套 kube-prometheus-stack 跑了一年多,靠下面几条纪律没出过「监控自己先挂」的尴尬。
### 把 Prometheus 自身挂进监控
最反直觉但最重要的一点:监控系统本身也要被监控。我们用 `prometheus_*` 自带指标给自己上保险:① `prometheus_tsdb_storage_retention_time_seconds` 和 `prometheus_local_storage_disk_space_too_low`(老版)/ `volume_bytes_available` 磁盘告警;② `prometheus_http_requests_total` 的 5xx 比例;③ `prometheus_tsdb_wal_corruptions_total` > 0 直接告警——TSDB 写前日志损坏是 Prometheus 最伤的事件,一旦出现要立刻备份 `data/` 再重启,别拖。
### 容量规划:别拍脑袋设 retention
`retention` 和 `retentionSize` 不是越长越安全。我们的经验:普通集群 15-30 天够用,真正要留久的是 `trends`(聚合到分钟级),TSDB 原生 5 分钟聚合已经能压缩 90% 的空间。规划时先估 `总 metric 数 × 30 天 × 平均采样间隔`,再给 PVC 预留 1.5 倍冗余,同时把 `prometheus_operator` 的 `storageSpec.resources.requests.storage` 和实际 `df -h` 对一遍。我见过一次把 PVC 设 20Gi 但实际 TSDB 撑到 18Gi,`volume_bytes_available` 贴 10% 才告警,等处理时已经触发 compact OOM 重启了两次。
### 升级纪律:CRD 与 Chart 版本要配套
kube-prometheus-stack 升级最大的坑是 **CRD 不会随 Chart 自动升级**——`helm upgrade` 只动已有资源,新增/修改的 CRD 字段要手动 `helm get crd` 出来单独 apply。我们升级流程固化成:① 先看 chart 的 `crds/` 目录有没有变化,有就 diff 后手动应用;② 升级 Prometheus 大版本(尤其 2.x 之间)时,先在 staging 集群跑一遍全量模板,确认没有 breaking change;③ 升级前 `helm get manifest` 存一份备份,出问题直接 `kubectl apply -f` 回滚。
### 巡检清单(每周)
- **Targets 健康**:`http://localhost:9090/targets` 扫一遍,任何 job 连续 3 次 `DOWN` 就要查(多半是 ServiceMonitor 的 label 又被人手滑删了)。
- **抓取间隔合理性**:高频业务 30s、静态指标 5min,别全设 15s 把 TSDB 写满。
- **告警规则加载数**:`/rules` 页核对规则数,防止有人 `kubectl delete prometheusrule` 全删了才发现。
## 延伸阅读
- [Prometheus 部署实战:Docker / systemd / K8s 三种方案](prometheus-deploy.md) — 单机装法
- [PromQL 速查:5 类黄金指标](prometheus-query.md) — PromQL 语法
- [Alertmanager 告警规则配置](prometheus-alerting.md) — 告警路由与静音
- [Grafana 部署与仪表盘搭建](grafana-dashboard.md) — Grafana 单机装法与变量
---
---
## PromQL 速查:5 类黄金指标 rate/irate/histogram_quantile 陷阱与 20 条可复制模板
URL: https://ops.jiunotes.top/monitor/prometheus-query.md
摘要: PromQL 一页纸速查:CPU/内存/磁盘/网络/HTTP 5 类黄金指标查询、rate vs irate vs increase 陷阱、histogram_quantile 正确姿势、topk/absent/predict_linear 冷门函数、5 个常见错误语法与高基数标签超时根因。
### Answer
PromQL 三大类:rate/increase(计数器增量)/ deriv/delta(gauge 变化)/ histogram_quantile(分位数)。
五大黄金指标:流量 rate(x[5m])、错误 sum(rate(x{status=~"5.."}[5m]))/sum(rate(x[5m]))、
延迟 histogram_quantile(0.99, sum(rate(bucket[5m])) by (le))、饱和度 CPU 用 100 - rate(idle[5m])*100、
趋势 predict_linear(x[1h], 4*3600)。核心陷阱:rate 前先确认是 counter 还是 gauge,
histogram_quantile 必须保留 le 标签,rate 窗口 ≥4 倍 scrape interval。
### FAQ
Q: rate 和 irate 到底啥区别?告警用哪个?
A: rate 是窗口内平均速率,取两个采样点做差分除以时间,能容忍漏采。 irate 是最后两个采样点的瞬时速率,敏感但波动大。 一句话:告警和 recording rule 用 rate,Dashboard 追尖刺时用 irate。 rate 窗口至少要 4 倍 scrape interval,比如 15 秒抓取配 rate(x[1m]) 或 [5m]。
Q: 为什么 histogram_quantile 返回 0 或 NaN?
A: 两个可能:① bucket 没加 rate(),直接用原始 counter 值算分位数是错的; ② by (le) 里丢了 le 标签,函数没法定位分位边界。 正确姿势:`histogram_quantile(0.99, sum by (le) (rate(http_request_duration_seconds_bucket[5m])))`。 另外 summary 类型的分位数**不能跨实例聚合**——想算全局 p99,用 histogram。
Q: PromQL 查询超时咋办?
A: 99% 是高基数问题。带 user_id / request_path 这种无限值标签的指标,每次 unique 组合都成新 series, 内存爆炸。三个招数:① 尽早用 label matcher 过滤(把 `{handler=~"/api/users.*"}` 放最内层); ② 先聚合再 join,别在 join 后再聚合;③ 源头减标签,把不需要的 label 从埋点里去掉。
Q: absent 和 up == 0 有什么区别?查目标存活用哪个?
A: 我第一次查 target 时用 `up == 0`,发现有些 pod 重启后根本没数据返回,`up == 0` 查不出来。 原因是 target 刚启动还没 scrape 过,Prometheus 里这条 series 是**空**而不是 0。 正确的写法是 `absent(up{job="xxx"}) or (up{job="xxx"} == 0)`, 这样 target 完全消失(刚重启)和部分失效(up=0)都能抓到。
Q: predict_linear 预测磁盘什么时候满,准不准?
A: 准,但有前提——趋势必须是线性的。我第一次用它时挺管用, 但有一次业务流量突增,磁盘写入速度翻了 3 倍,predict_linear 的预测就完全不准了。 正确用法:用过去 6 小时数据做外推(`[6h]`),预测未来 4 小时内的耗尽时间(`4*3600`)。 如果有明显的日周期(白天高凌晨低),就别用 predict_linear,改用 recording rule 存历史均值再比较,或者用 Grafana 的"Time Shift"面板对比"今天 vs 昨天同一时段"。
### Full Content
# PromQL 速查:5 类黄金指标 rate/irate/histogram_quantile 陷阱与 20 条可复制模板
上周接手一个监控项目,[Grafana](https://grafana.com/docs/) 面板开了 30 个,问我最多的问题是"CPU 使用率怎么写"、"p99 延迟怎么算"、"为什么我的错误率永远显示 0"。前两个是入门,第三个才是真坑——错 99% 是分子分母都算错了。PromQL 难的不是语法,是**语义正确性**。
## 60 秒速查:PromQL 的三大函数族
写 PromQL 前先想清楚指标是什么类型——**counter / gauge / histogram / summary**,选对函数族。
| 指标类型 | 特征 | 用什么函数 | 反例 |
|---|---|---|---|
| Counter(累计) | 只增不减,重启归零(如 `http_requests_total`) | `rate()` / `irate()` / `increase()` | 直接 `sum(http_requests_total[5m])` 无意义 |
| Gauge(瞬时) | 上下波动(如 `node_memory_MemAvailable_bytes`) | 直接读,或 `delta()` / `deriv()` | `rate(node_memory_MemAvailable_bytes[5m])` 得到 nonsense |
| Histogram | 一坨 `_bucket{le}` + `_sum` + `_count` | `histogram_quantile()` | 用 `_sum / _count` 算平均值(这是 mean 不是 p99) |
| Summary | 自带 `{quantile="0.95"}` | 只能读单个 quantile | 跨实例 `avg(metric{quantile="0.95"})` 统计上无效 |
**这里最容易翻车**:把 gauge 当 counter 用 rate。`rate(node_memory_MemAvailable_bytes[5m])` 语法能通过,返回一个"每秒内存变化量",看起来很像样子,但实际意义为零——内存本来就上下波动,rate 假设单调递增,算出来是噪声。
## 5 类黄金指标:一页纸速查
Google SRE 定义的黄金指标(RED + USE)—— 5 类查询模板,覆盖 80% 大盘需求。
### 1. 流量(Rate)
```promql
# HTTP 请求速率(按 handler 分组)
sum by (handler) (rate(http_requests_total[5m]))
# QPS(每秒请求数)
sum(rate(http_requests_total[1m]))
# 按 job 聚合的请求速率
sum by (job) (rate(http_requests_total[5m]))
```
**你应该看到什么**:曲线平滑上升,波动幅度跟业务流量一致。如果曲线**锯齿剧烈**,`rate()` 窗口太短,扩到 5m 或 10m。
### 2. 错误率(Error Ratio)
```promql
# 5xx 错误率(关键:分子分母分别聚合再相除)
sum(rate(http_requests_total{status=~"5.."}[5m]))
/
sum(rate(http_requests_total[5m]))
# 4xx 错误率
sum(rate(http_requests_total{status=~"4.."}[5m]))
/
sum(rate(http_requests_total[5m]))
```
**你应该看到什么**:曲线在 0-0.1 之间正常波动(0% - 10%)。超过 0.5 说明服务基本不可用。**注意**:如果分母是 0,结果会变 NaN——用 `or vector(0)` 兜底:
```promql
sum(rate(http_requests_total{status=~"5.."}[5m])) or vector(0)
/
sum(rate(http_requests_total[5m])) or vector(0)
```
### 3. 延迟(Latency)
```promql
# P99 延迟(经典 histogram 写法)
histogram_quantile(0.99,
sum by (le) (rate(http_request_duration_seconds_bucket[5m]))
)
# P95、P50 一起画
histogram_quantile(0.95,
sum by (le) (rate(http_request_duration_seconds_bucket[5m]))
)
# 按 handler 分组看延迟分布
histogram_quantile(0.99,
sum by (handler, le) (rate(http_request_duration_seconds_bucket[5m]))
)
```
**你应该看到什么**:曲线平稳,P99 一般在 P50 的 2-5 倍之间。如果 P99 是 P50 的 10 倍以上,说明**长尾请求**(慢查询、GC 停顿、下游超时)。
### 4. 饱和度(Saturation)
```promql
# CPU 使用率
100 - (
avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100
)
# 内存使用率
(1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) * 100
# 磁盘使用率(根分区)
(1 - node_filesystem_avail_bytes{mountpoint="/"} / node_filesystem_size_bytes{mountpoint="/"}) * 100
# 磁盘 inode 使用率(这个比容量更常先满)
node_filesystem_files{mountpoint="/"} - node_filesystem_files_free{mountpoint="/"}
/ node_filesystem_files{mountpoint="/"} * 100
```
**你应该看到什么**:CPU 曲线平滑,突刺持续 <5 分钟正常,持续 10 分钟以上要排查。内存曲线缓慢上升不回落 → **内存泄漏嫌疑**。
### 5. 趋势(Trend)
```promql
# 预测磁盘剩余空间(线性外推)
predict_linear(node_filesystem_avail_bytes{mountpoint="/"}[6h], 24*3600)
# 上面查的是"按过去 6 小时趋势,24 小时后还剩多少字节"
# 未来 6 小时会不会耗尽
predict_linear(node_filesystem_avail_bytes{mountpoint="/"}[6h], 6*3600) < 1
# 速率变化趋势
deriv(node_memory_MemAvailable_bytes[1h])
```
**你应该看到什么**:`predict_linear` 返回一个"未来某时刻的预测值"。返回负数 → 磁盘会爆,返回正数 → 还能撑多久。这是磁盘告警的黄金写法。
## rate vs irate vs increase 陷阱表
这三个函数 90% 的人用错,因为**语义不同**:
| 函数 | 语义 | 何时用 | 陷阱 |
|---|---|---|---|
| `rate(x[5m])` | 5 分钟内**平均每秒**速率 | 告警、recording rule、大盘 | 窗口 <4 倍 scrape 会漏采,得返回空 |
| `irate(x[5m])` | 最后两个采样点的**瞬时**速率 | 追尖刺、debug 短时毛刺 | 波动太大,不适合告警 |
| `increase(x[1h])` | 1 小时内**总量**增加 | 看"这一小时发生了多少次" | 不是精确值,rate*窗口近似 |
| `delta(x[1h])` | gauge 在 1 小时内的**净变化** | 内存/CPU 波动 | 只用于 gauge |
| `deriv(x[1h])` | gauge 在 1 小时内的**斜率** | 预测趋势 | 单位是"每秒变化" |
| `idelta(x[1h])` | 最后两个采样点的差 | 短窗口追波动 | 同 irate,不适合告警 |
**踩坑**:看到 Grafana 里 `increase(http_requests_total[1h])` 显示 12345,就觉得"这一小时 12345 次请求"。其实这个数字是**估算**——如果中间有 counter reset 或漏采,实际可能差 20% 以上。要精确统计总量,用 InfluxDB 或 Loki,别指望 PromQL 精确。
## histogram_quantile 深挖
分位数查询是 PromQL 最容易写错的地方。**记住三条铁律**:
### 铁律 1:bucket 必须先 rate
```promql
# ❌ 错误:直接用原始 counter 值
histogram_quantile(0.95,
sum by (le) (http_request_duration_seconds_bucket)
)
# ✅ 正确:先 rate 再聚合
histogram_quantile(0.95,
sum by (le) (rate(http_request_duration_seconds_bucket[5m]))
)
```
### 铁律 2:by (le) 里必须留 le
```promql
# ❌ 错误:把 le 聚合掉了
histogram_quantile(0.95,
sum by (job) (rate(http_request_duration_seconds_bucket[5m]))
)
# ✅ 正确:保留 le,其他标签可以带
histogram_quantile(0.95,
sum by (job, le) (rate(http_request_duration_seconds_bucket[5m]))
)
# 或者用 without 反向排除
histogram_quantile(0.95,
sum without (instance, pod) (rate(http_request_duration_seconds_bucket[5m]))
)
```
### 铁律 3:summary 不能跨实例聚合
```promql
# ❌ 错误:跨实例求 summary 分位数平均
avg(http_request_duration_seconds{quantile="0.95"})
# 统计上无效——每个实例的 p95 样本量不同,简单平均不反映整体分布
# ✅ 正确:改用 histogram + histogram_quantile
histogram_quantile(0.95,
sum by (le) (rate(http_request_duration_seconds_bucket[5m]))
)
```
**这一点我也不完全确定**:summary 的 quantile 值本身是服务端算的(客户端 SDK 上报),跨实例聚合时理论上可以做加权平均,但 [Prometheus](https://prometheus.io/docs/) 官方文档明确说不支持。实践中用 histogram 更省心。
## topk / absent / predict_linear:冷门但救命的三个函数
### topk / bottomk:Top-N 分析
```promql
# CPU 占用最高的 5 个 pod
topk(5,
sum by (pod) (rate(container_cpu_usage_seconds_total[5m]))
)
# 请求量最少的 10 个 handler(找冷接口)
bottomk(10,
sum by (handler) (rate(http_requests_total[5m]))
)
```
**你应该看到什么**:返回 N 条 series,按值排序。topk 用于告警("哪个 pod 最烧 CPU"),bottomk 用于优化("哪些接口没人用可以砍")。
### absent:目标消失了
```promql
# 这个指标完全不存在(比如埋点漏了)
absent(http_requests_total)
# 这个实例消失了(Target down)
absent(up{job="api"}) or (up{job="api"} == 0)
```
**关键**:`absent(x)` 只有当 x **完全没数据**时才返回 1,否则返回空。查 target 存活别用 `up == 0`,因为 target 刚重启时可能还没上报过数据,up 是空不是 0。用 `absent(up)` 更安全。
### predict_linear:预测
```promql
# 磁盘还剩多少小时会耗尽
predict_linear(node_filesystem_avail_bytes{mountpoint="/"}[6h], 4*3600)
# 内存增长率(预测 1 小时后剩余内存)
predict_linear(node_memory_MemAvailable_bytes[2h], 3600)
```
**踩坑**:`predict_linear` 是**线性外推**,只适合趋势稳定的场景。业务流量有明显日周期的话(比如凌晨低白天高),外推不准——用 `record rule` 存 24 小时均值更靠谱。
## 5 个常见错误语法
### 坑 1:rate 前先 sum,顺序反了
```promql
# ❌ 错误:先 sum 再 rate,counter reset 会失真
rate(sum(http_requests_total[5m]))
# ✅ 正确:先 rate 再 sum
sum(rate(http_requests_total[5m]))
```
### 坑 2:正则锚定,"contains"式匹配失效
```promql
# ❌ 错误:以为 =~"/api/users" 会匹配 /api/users/42
{handler=~"/api/users"}
# ✅ 正确:PromQL 正则是全锚定,要显式加 .*
{handler=~"/api/users.*"}
```
### 坑 3:分组聚合丢标签
```promql
# ❌ 错误:by 只留 le,其他标签没了
sum by (le) (rate(http_request_duration_seconds_bucket[5m]))
# ✅ 想按 handler 分组看延迟,le 也要留
sum by (handler, le) (rate(http_request_duration_seconds_bucket[5m]))
```
### 坑 4:join 忘了 group_left/group_right
```promql
# ❌ 错误:一对多 join 不加 group_left
sum by (pod) (rate(container_cpu_usage_seconds_total[5m]))
*
kube_pod_info
# ✅ 正确:加 group_left(右向量多对一)
sum by (pod) (rate(container_cpu_usage_seconds_total[5m]))
* on (pod) group_left node kube_pod_info
```
### 坑 5:offset 写错位置
```promql
# ❌ 错误:offset 放错位置
sum(rate(http_requests_total[5m]) offset 5m)
# ✅ 正确:offset 紧跟 selector,不能放聚合后
sum(rate(http_requests_total offset 5m[5m]))
```
## 高基数标签:查询超时的真凶
PromQL 超时 90% 是高基数问题。三种典型反模式:
```promql
# 反模式 1:查询里带 user_id 标签
sum by (user_id) (rate(http_requests_total[5m]))
# user_id 有几百万个 → 几百万条 series → 内存爆
# 反模式 2:查询里带 request_path 原值
sum by (path) (rate(http_requests_total[5m]))
# path 是具体 URL,可能有几十万种
# 反模式 3:join 时按 user_id 聚合
sum by (user_id, endpoint) (rate(http_requests_total[5m]))
* on (user_id) group_left user_info
```
**优化方案**:
```promql
# ✅ 方案 1:源头把高基数标签去掉,用 recording rule 存聚合结果
# 在 prometheus.yml 里:
# - name: rate-rules.yml
# rules:
# - record: job:http_requests:rate5m
# expr: sum by (job) (rate(http_requests_total[5m]))
# 然后查询直接读聚合结果,不再实时计算
# ✅ 方案 2:尽早过滤,用 label matcher 缩小范围
sum(rate(http_requests_total{job="api-server", status=~"2.."}[5m]))
# ✅ 方案 3:用 without 反向剔除不必要标签
sum without (user_id, request_path) (rate(http_requests_total[5m]))
```
**踩坑**:加了 recording rule 就以为万事大吉了。recording rule 也会存 counter 值——`record: job:http_requests:rate5m` 是把 rate 结果预计算存起来,后续查询直接读这个**已经是 per-second rate** 的 metric,不用再套 rate()。**这里最容易翻车**:把 recording rule 的结果又 rate 一遍,等于对已经平滑过的数据再求导,数值直接失真。
## Cheat Sheet:20 条常用 PromQL
| 场景 | 查询 |
|---|---|
| CPU 使用率 | `100 - (avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100)` |
| 内存使用率 | `(1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) * 100` |
| 磁盘剩余量 | `node_filesystem_avail_bytes{mountpoint="/"}` |
| 磁盘使用率 | `(1 - node_filesystem_avail_bytes{mountpoint="/"} / node_filesystem_size_bytes{mountpoint="/"}) * 100` |
| 磁盘 inode 使用率 | `100 * (node_filesystem_files{mountpoint="/"} - node_filesystem_files_free{mountpoint="/"}) / node_filesystem_files{mountpoint="/"}` |
| 磁盘耗尽预测 | `predict_linear(node_filesystem_avail_bytes{mountpoint="/"}[6h], 4*3600)` |
| 网络流入速率 | `rate(node_network_receive_bytes_total{device!="lo"}[5m])` |
| 网络流出速率 | `rate(node_network_transmit_bytes_total{device!="lo"}[5m])` |
| HTTP QPS | `sum(rate(http_requests_total[1m]))` |
| HTTP 5xx 错误率 | `sum(rate(http_requests_total{status=~"5.."}[5m])) / sum(rate(http_requests_total[5m]))` |
| HTTP P99 延迟 | `histogram_quantile(0.99, sum by (le) (rate(http_request_duration_seconds_bucket[5m])))` |
| HTTP P95 延迟 | `histogram_quantile(0.95, sum by (le) (rate(http_request_duration_seconds_bucket[5m])))` |
| 请求 Top 5 handler | `topk(5, sum by (handler) (rate(http_requests_total[5m])))` |
| CPU Top 5 pod | `topk(5, sum by (pod) (rate(container_cpu_usage_seconds_total[5m])))` |
| Target 存活 | `up` |
| Target 消失 | `absent(up{job="api"})` |
| 实例负载(每秒请求) | `sum by (instance) (rate(http_requests_total[5m]))` |
| 内存泄漏嫌疑(1h 持续增长) | `deriv(node_memory_MemAvailable_bytes[1h]) < 0` |
| 5 分钟前对比现在 | `http_requests_total - http_requests_total offset 5m` |
| 请求速率环比 5 分钟 | `sum(rate(http_requests_total[5m])) / sum(rate(http_requests_total offset 5m[5m]))` |
## 总结与判断
PromQL 写对了不难,难在**语义正确**——知道什么时候用 rate、什么时候用 irate、什么时候用 histogram_quantile。5 类黄金指标 + 3 条 rate/irate/increase 铁律 + 5 个常见坑,覆盖 90% 场景。
**我的判断**:PromQL 速查表的价值不在背命令,在于**养成正确的思维习惯**——看到指标先想"这是 counter 还是 gauge",写 ratio 先想"分子分母要分别聚合",写 quantile 先想"le 标签留没留"。三个动作习惯化了,查询基本不会翻车。要更深入(recording rule 命名规范、subquery 性能、PromQL 2.0 的 topk_over_time),得单独开专题。
## 延伸阅读
- [Alertmanager 告警规则配置](prometheus-alerting.md)
- [Prometheus 部署实战:从零搭建监控告警](prometheus-deploy.md)
- [Grafana 仪表盘搭建:JSON 模型 + 变量 + 告警联动](grafana-dashboard.md)
---
---
## Prometheus Recording Rules 实战:预计算指标提升查询性能与降低告警延迟
URL: https://ops.jiunotes.top/monitor/prometheus-recording-rules.md
摘要: Prometheus Recording Rules 配置指南:命名规范、分层设计、性能优化、常见 Pitfall。附生产级 rules.yml 模板和性能监控指标,帮助你将复杂 PromQL 查询性能提升 10 倍。
### Answer
Recording Rules 是 Prometheus 的"预计算"机制,把复杂 PromQL 查询结果缓存为新指标,大幅提升 Dashboard 和告警查询性能。
核心原则:只预计算高频使用的复杂查询,避免创建过多 Time Series 导致存储爆炸。命名规范 `level:metric:operation` 便于维护。
### FAQ
Q: Recording Rules 和 Alerting Rules 有什么区别?
A: Recording Rules 创建新指标(预计算),Alerting Rules 触发告警(条件判断)。两者 YAML 结构相同,但 record 字段 vs alert 字段。 通常 Recording Rules 为 Alerting Rules 服务——先预计算指标,再基于预计算结果写简洁的告警规则。
Q: 什么时候该用 Recording Rules?
A: 三个信号:① 同一个复杂查询在多个 Dashboard 重复使用;② 查询耗时超过 1 秒影响 Dashboard 加载;③ 告警规则表达式过于复杂难以维护。 不要为简单查询创建 Recording Rule,反而会增加维护成本。
Q: Recording Rules 会影响查询实时性吗?
A: 会,延迟等于 rule evaluation interval。建议 infra 层 30s、app 层 15s、business 层 60s。 如果业务要求秒级实时性,不要用 Recording Rules,直接用 PromQL 查询。
Q: 如何监控 Recording Rules 的性能?
A: 四个关键指标:① `prometheus_rule_evaluation_duration_seconds` 看评估耗时; ② `prometheus_rule_evaluation_failures_total` 看失败率; ③ `prometheus_tsdb_head_series` 看新增 Time Series 数量; ④ `process_resident_memory_bytes` 看内存占用。 建议设置告警:评估耗时 P99 > 10s 或失败率 > 1%。
### Full Content
# Prometheus Recording Rules 实战:预计算指标提升查询性能与降低告警延迟
生产环境 [Prometheus](https://prometheus.io/docs/) 运行半年后,Dashboard 加载变慢、告警延迟增加,90% 的原因是 PromQL 查询过于复杂。Recording Rules 是官方推荐的优化方案——把复杂查询预计算成新指标,Dashboard 和告警直接查预计算结果,性能提升 10 倍以上。
## 为什么需要 Recording Rules
### 没有 Recording Rules 的问题
```yaml
# Dashboard 查询(每次实时计算)
expr: sum by (job) (rate(http_requests_total[5m])) / sum by (job) (rate(http_requests_total[5m])) * 100
# 告警规则(复杂表达式)
expr: |
(sum by (instance) (rate(node_cpu_seconds_total{mode!="idle"}[5m]))
/ sum by (instance) (node_cpu_seconds_total)) * 100 > 80
```
**问题**:
- 每次查询都要实时计算,Dashboard 加载慢
- 复杂表达式难以维护,改一处影响多处
- 多 Dashboard 重复相同计算,浪费资源
### Recording Rules 的解决方案
```yaml
# 预计算指标
groups:
- name: http_rules
rules:
- record: job:http_requests:rate5m
expr: sum by (job) (rate(http_requests_total[5m]))
- record: job:http_request_duration_seconds:avg
expr: avg by (job) (rate(http_request_duration_seconds_sum[5m]) / rate(http_request_duration_seconds_count[5m]))
# Dashboard 直接查预计算指标
expr: job:http_requests:rate5m
```
**好处**:
- Dashboard 查询速度提升 10x+
- 告警规则简洁易维护
- 重复计算只执行一次
## Recording Rules 命名规范
官方推荐命名格式:`level:metric:operations`
| 部分 | 说明 | 示例 |
|---|---|---|
| `level` | 聚合维度 | `instance`、`job`、`cluster` |
| `metric` | 原始指标名(去掉 `_total`) | `http_requests`、`node_cpu` |
| `operations` | 操作类型和时间窗口 | `rate5m`、`avg1h`、`sum` |
**正确示例**:
```yaml
# 实例级别:CPU 使用率 5 分钟平均
- record: instance:node_cpu_utilization:avg5m
expr: avg by (instance) (rate(node_cpu_seconds_total{mode!="idle"}[5m]))
# Job 级别:请求速率
- record: job:http_requests:rate5m
expr: sum by (job) (rate(http_requests_total[5m]))
# 集群级别:错误率
- record: cluster:http_error_rate:ratio_rate5m
expr: sum(rate(http_requests_total{status=~"5.."}[5m])) / sum(rate(http_requests_total[5m]))
```
**错误示例**:
```yaml
# ❌ 命名不清晰
- record: cpu_usage
expr: ...
# ❌ 缺少操作类型
- record: instance_http_requests
expr: ...
# ❌ 高基数标签(会导致 Time Series 爆炸)
- record: user:http_requests:rate5m
expr: sum by (user) (rate(http_requests_total[5m]))
```
## 分层设计原则
生产环境建议三层设计:
### 第一层:Raw Metric Normalization(原始指标规范化)
```yaml
groups:
- name: raw_normalization
interval: 15s # 高频层,15 秒评估
rules:
# CPU 使用率
- record: instance:node_cpu_utilization:rate5m
expr: avg by (instance) (rate(node_cpu_seconds_total{mode!="idle"}[5m]))
# 内存使用率
- record: instance:node_memory_utilization:ratio
expr: 1 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes)
# 磁盘 I/O
- record: instance:node_disk_io_time:rate5m
expr: rate(node_disk_io_time_seconds_total[5m])
```
### 第二层:Infrastructure Aggregation(基础设施聚合)
```yaml
groups:
- name: infra_aggregation
interval: 30s # 中频层,30 秒评估
rules:
# Job 级别聚合
- record: job:node_cpu_utilization:avg
expr: avg by (job) (instance:node_cpu_utilization:rate5m)
# 集群级别聚合
- record: cluster:node_cpu_utilization:p99
expr: histogram_quantile(0.99, sum by (le, cluster) (rate(node_cpu_seconds_total[5m])))
```
### 第三层:Business Metrics(业务指标)
```yaml
groups:
- name: business_metrics
interval: 60s # 低频层,60 秒评估
rules:
# 订单成功率
- record: business:order_success_rate:ratio_rate5m
expr: sum(rate(order_success_total[5m])) / sum(rate(order_total[5m]))
# API 响应时间 P99
- record: business:api_latency:p99_5m
expr: histogram_quantile(0.99, sum by (le, service) (rate(api_request_duration_seconds_bucket[5m])))
```
**为什么分层**:
- 不同层级数据新鲜度要求不同
- 高频层频繁计算,低频层可以延迟
- 便于单独调优和故障隔离
## 性能优化技巧
### 1. 避免高基数标签
```yaml
# ❌ 危险:user_id 是高基数标签,会导致 Time Series 爆炸
- record: user:http_requests:rate5m
expr: sum by (user_id) (rate(http_requests_total[5m]))
# ✅ 安全:用 topk 限制数量
- record: top_users:http_requests:rate5m
expr: topk(10, sum by (user_id) (rate(http_requests_total[5m])))
```
### 2. 合理设置 interval
```yaml
groups:
- name: critical_metrics
interval: 15s # 关键指标高频计算
rules:
- record: instance:cpu:utilization
expr: ...
- name: business_metrics
interval: 60s # 业务指标低频计算
rules:
- record: business:revenue:daily
expr: ...
```
### 3. 使用 without() 保留关键标签
```yaml
# ❌ 丢失 job 标签,告警时无法定位
- record: http_requests:rate5m
expr: sum(rate(http_requests_total[5m]))
# ✅ 保留 job 标签
- record: job:http_requests:rate5m
expr: sum by (job) (rate(http_requests_total[5m]))
```
### 4. 链式规则优化
```yaml
# 第一层:预计算基础指标
- record: instance_path:http_requests:rate5m
expr: sum by (instance, path) (rate(http_requests_total[5m]))
# 第二层:基于第一层聚合(利用缓存结果)
- record: path:http_requests:rate5m
expr: sum by (path) (instance_path:http_requests:rate5m)
```
## 完整生产模板
```yaml
# recording_rules.yml
groups:
# 第一层:Raw Metric Normalization
- name: raw_normalization
interval: 15s
rules:
# CPU
- record: instance:node_cpu_utilization:rate5m
expr: avg by (instance) (rate(node_cpu_seconds_total{mode!="idle"}[5m]))
# Memory
- record: instance:node_memory_utilization:ratio
expr: 1 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes)
# Disk I/O
- record: instance:node_disk_utilization:rate5m
expr: rate(node_disk_io_time_seconds_total[5m])
# Network
- record: instance:node_network_transmit_bytes:rate5m
expr: rate(node_network_transmit_bytes_total[5m])
# HTTP Requests
- record: instance:http_requests:rate5m
expr: sum by (instance) (rate(http_requests_total[5m]))
# HTTP Errors
- record: instance:http_errors:rate5m
expr: sum by (instance) (rate(http_requests_total{status=~"5.."}[5m]))
# 第二层:Infrastructure Aggregation
- name: infra_aggregation
interval: 30s
rules:
# Job 级别 CPU
- record: job:node_cpu_utilization:avg
expr: avg by (job) (instance:node_cpu_utilization:rate5m)
# Job 级别内存
- record: job:node_memory_utilization:avg
expr: avg by (job) (instance:node_memory_utilization:ratio)
# 集群级别错误率
- record: cluster:http_error_rate:ratio_rate5m
expr: |
sum(rate(http_errors:rate5m[5m]))
/
sum(rate(http_requests:rate5m[5m]))
# 第三层:Business Metrics
- name: business_metrics
interval: 60s
rules:
# 订单成功率
- record: business:order_success_rate:ratio_rate5m
expr: |
sum(rate(order_success_total[5m]))
/
sum(rate(order_total[5m]))
# API 响应时间 P99
- record: business:api_latency:p99_5m
expr: histogram_quantile(0.99, sum by (le, service) (rate(api_request_duration_seconds_bucket[5m])))
# 用户活跃度
- record: business:active_users:count_5m
expr: count by (service) (increase(user_login_total[5m]))
```
## 性能监控告警
```yaml
# 监控 Recording Rules 本身的健康
groups:
- name: recording_rules_health
rules:
# 评估耗时告警
- alert: RecordingRuleEvaluationSlow
expr: prometheus_rule_evaluation_duration_seconds{quantile="0.99"} > 10
for: 5m
labels:
severity: warning
annotations:
summary: "Recording rule evaluation slow"
description: "Rule {{ $labels.rule }} evaluation P99 is {{ $value }}s"
# 评估失败告警
- alert: RecordingRuleEvaluationFailures
expr: rate(prometheus_rule_evaluation_failures_total[5m]) > 0
for: 2m
labels:
severity: critical
annotations:
summary: "Recording rule evaluation failures"
description: "Rule {{ $labels.rule }} has {{ $value }} failures/sec"
# Time Series 数量告警
- alert: HighRecordingRuleSeriesCount
expr: prometheus_tsdb_head_series > 1000000
for: 10m
labels:
severity: warning
annotations:
summary: "Too many Time Series from recording rules"
description: "Current series count: {{ $value }}"
```
## 翻车案例复盘
### 案例 1:Time Series 爆炸
**现象**:Prometheus 内存使用从 4GB 涨到 32GB,查询变慢。
**根因**:创建了过多 Recording Rules,且使用了高基数标签(如 `user_id`、`request_id`)。
**修复**:
```yaml
# ❌ 删除高基数规则
# - record: user:http_requests:rate5m
# expr: sum by (user_id) (rate(http_requests_total[5m]))
# ✅ 改用 topk 限制
- record: top_users:http_requests:rate5m
expr: topk(100, sum by (user_id) (rate(http_requests_total[5m])))
```
### 案例 2:链式规则循环依赖
**现象**:Prometheus 启动失败,报错 `rule group circular dependency detected`。
**根因**:
```yaml
groups:
- rules:
- record: metric_a
expr: metric_b + 1 # 依赖 metric_b
- record: metric_b
expr: metric_a + 1 # 依赖 metric_a ← 循环!
```
**修复**:拆分成两个 group,确保单向依赖。
### 案例 3:interval 设置不当导致数据延迟
**现象**:Dashboard 显示数据延迟 5 分钟。
**根因**:`interval: 300s` 设置过大。
**修复**:根据业务需求调整 interval,关键指标建议 15-30s。
## 日常运维 SOP
Recording Rules 不是配完就不管了,series 数量、评估耗时会随时间漂移,沉淀一套例行动作:
- **每月核对 series 增量**:看 `prometheus_tsdb_head_series` 趋势,某类新加的 rule(比如带 `user_id` 的)series 突然翻倍就是高基数泄漏,立即下线。series 涨得离谱基本是有人加了一条带高基数标签的规则。
- **评估耗时监控要分级**:关键层(15s interval)的 rule 评估 P99 > 10s 立即告警;业务层(60s)放宽。统一 10s 阈值会让业务层误报、关键层漏报。
- **rule 改动走 Git + 预检**:`rules.yml` 进 Git,合并前用 `promtool check rules` 跑语法 + 循环依赖检查。我这边加了一条「新 rule 必须先证明它是高频重复查询」的 review 门禁,避免有人随手加一条没人用的预计算。
- **容量要预演**:加一批新 rule 前,先估算「聚合后 series 数 × 每条样本」对内存的增量,超预算就分批上。一次加 200 条 rule 直接内存翻倍差点把 Prometheus 打挂。
- **定期清僵尸 rule**:跑一个脚本扫「过去 30 天没被任何 Dashboard / 告警引用的 record 指标」,没引用的全下线。预计算指标没人用就是纯浪费存储和计算。
## 灰度上线与回滚实践
Recording Rules 改动直接影响告警与 Dashboard,裸上风险大,我这边固定走这套流程:
- **预发先跑**:新 rule 先部署到预发 Prometheus,对比「预计算值 vs 实时查询值」两条曲线,偏差在 1% 内才上生产。偏差大说明表达式写错了。
- **分 group 灰度**:一次别把几百条 rule 全上。按「raw 层 → infra 层 → business 层」分批上,每批观察 24h 的内存 / 评估耗时 / 失败率,稳定再进下一批。
- **保留对照查询**:上 rule 期间,保留一条 Dashboard 直接查原始 PromQL(不走预计算)作为对照,能立刻发现预计算和实时的偏差。稳了再撤对照。
- **回滚要快**:rule 文件进 Git,出问题 `git revert` + reload(`/-/reload`,不重启),30 秒内回退。千万别手动删改线上文件再重载,改错一处全崩。
- **新 rule 上线前必跑 `promtool`**:`promtool check rules` 查语法与循环依赖,`promtool check metrics` 抽查指标是否真实存在(防止指标改名后 rule 静默产出空序列,Dashboard 直接没线还不报错)。这条我栽过:一个 exporter 升级改了指标名,rule 没报错但全在算空值,查了半天才发现是引用了不存在的指标。
## 容量与保留策略
预计算指标本身就是新的 time series,存储和保留也得规划:
- **给 recording 指标单独设保留期**:高频细粒度指标(raw 层 15s)数据量涨得快,用 Prometheus 外置存储(Thanos / Mimir)时给它们设比原始指标更短的保留(比如 raw 层 7 天、business 层 90 天),原始指标长期留。
- **压缩靠分层**:raw 层原始 series 多但保留短,business 层聚合后 series 少但保留长,整体存储比「原始指标全长期留」省一大截。
- **扩容先看 series 不是看数据量**:Prometheus 内存随 active series 数涨,数据量反而是慢变量。加 rule 前先估算 series 增量,超当前 Prometheus 承载(经验值单机 50 万 active series 就该上分片或降采样)就分批。
## 写在最后
Recording Rules 是 Prometheus 性能优化的核心工具,但要用对地方:
1. **只在必要时使用**——简单查询不需要预计算
2. **遵循命名规范**——便于维护和调试
3. **分层设计**——infra 层高频,business 层低频
4. **监控规则健康**——评估耗时、失败率、Series 数量
记住:**Recording Rules 是 trade-off**——用存储空间换查询速度。合理控制 Series 数量,别让预计算变成新的性能瓶颈。
## 相关文章
- [Alertmanager 告警规则配置:路由树 + Silence/Inhibit + 微信钉钉 Webhook 六坑指南](prometheus-alerting.md)
- [PromQL 速查:5 类黄金指标 rate/irate/histogram_quantile 陷阱与 20 条可复制模板](prometheus-query.md)
- [Alertmanager 告警降噪实战:抑制器 + 路由树 + 静默配置四步降噪法](alertmanager-noise-reduction.md)
---
## Prometheus 长期存储与多集群查询实战:Thanos 架构落地与踩坑复盘
URL: https://ops.jiunotes.top/monitor/prometheus-thanos-storage.md
摘要: 一线运维视角用 Thanos 统一多套 Prometheus,历史数据落到 MinIO/S3,Sidecar/Query/Store/Compact 全组件实战,含回填丢失、查询超时、compact 失败三类真实事故复盘。
### Answer
实战 Thanos 统一多套 Prometheus,数据落 MinIO/S3,复盘回填丢失、查询超时、compact 失败三类事故。
### FAQ
Q: Thanos 相比 Prometheus 联邦(federation)有什么优势?
A: 联邦是拉模式、单点聚合、扩展性差;Thanos 通过对象存储共享历史数据,Query 跨集群统一查询,支持无限长期存储。
Q: Thanos 的对象存储选型怎么定?
A: 生产建议自建 MinIO 或 S3 兼容存储,块默认 2h,bucket 开启版本控制和生命周期策略,回填数据更稳。
Q: 回填数据丢失是什么原因?
A: 回填块的 mtime 落后于 Sidecar 扫描窗口,被误判为过期垃圾回收;用 --shipper.upload-compacted 或调大 min-time 解决。
Q: 查询超时常见原因和排查?
A: 多为 Store 与 Query 间 gRPC 传输大样本慢,或 downsampling 未跑导致扫原始块;调大 timeout、开 query.replica-label 并用 tracing 定位。
### Full Content
# Prometheus 长期存储与多集群查询实战:Thanos 架构落地与踩坑复盘
## 一、为什么我最终选了 Thanos
我们公司监控最早是"每个集群一套 [Prometheus](https://prometheus.io/docs/)",一共五套,散落在华北、华东两个机房。起初还够用,直到老板要一张**全公司总览大屏**——把五个集群的 `cpu_usage` 拼成一张图。我第一反应是联邦(federation),搭完后发现两个问题:一是联邦是**拉模式**,聚合节点挂了整个顶层监控就断;二是历史数据只能留 15 天,Prometheus 本地 TSDB 撑不住长期留存,老板要的"去年同期的对比"根本查不到。
试过单独把 Prometheus 的 `retention` 调到 90 天,结果磁盘爆了,`WAL` 打满整块盘,还拖垮了抓取。后来我在网上翻到 Thanos,发现它把**历史数据搬到对象存储**,Prometheus 只管热数据,这个思路正是我要的。于是花了一个周末把 PoC 搭起来,从此再没回头。
## 二、Thanos 组件架构:一张图看懂各角色
Thanos 不是一个单体,是一组可独立部署的组件,各司其职:
| 组件 | 职责 |
|------|------|
| **Sidecar** | 挂在每个 Prometheus 旁,把本地 TSDB 块上传到对象存储 |
| **Query** | 无状态查询入口,聚合各数据源(Prometheus/Store/Receive)的结果 |
| **Store** | 从对象存储读取历史块,对 Query 暴露 gRPC StoreAPI |
| **Compact** | 对对象存储里的块做压缩、合并,生成 downsampling 数据 |
| **Receive** | 接收远程写(remote write),实现 Prometheus 无状态化(可选) |
我用一句话记住它:**Sidecar 负责"推上去",Store 负责"读下来",Query 负责"算出来",Compact 负责"压小了"**。Receive 是给那些不想保留 Prometheus 本地的场景用的,我们后面细说。
## 三、对象存储选型:MinIO 还是 S3
对象存储是 Thanos 的地基,选型直接决定稳定性。我们一开始图省事想用云厂商的 OSS,但测试发现回填时**并发上传把 OSS 的限流打爆了**,于是转向自建 MinIO。
```bash
# 用 docker 起一个 MinIO(生产建议集群模式 + 纠删码)
docker run -d --name minio \
-p 9000:9000 -p 9001:9001 \
-e MINIO_ROOT_USER=thanos \
-e MINIO_ROOT_PASSWORD=supersecret \
-v /data/minio:/data \
minio/minio server /data --console-address ":9001"
```
关键配置点:**bucket 务必开启版本控制**,这样即使 compact 误删块也能回滚;再配一条生命周期规则,把 30 天前的对象转低频存储(S3 的 `STANDARD_IA` 或 MinIO 的 ILM),能省一半成本。
用 `mc` 创建 bucket 并开版本控制:
```bash
mc alias set thanos http://minio:9000 thanos supersecret
mc mb --ignore-existing thanos/thanos-data
mc version enable thanos/thanos-data
# 30 天前对象转低频,180 天可彻底清理
mc ilm rule add thanos/thanos-data \
--expire-transition "30d" --transition "STANDARD_IA" \
--expire-delete "180d"
```
生产上别用单机 MinIO,数据在磁盘上只有一份,任何一块盘坏了历史就丢。要上**分布式纠删码**(至少 4 个盘):
```yaml
# minio 分布式部署,4 节点纠删码(每节点 2 数据盘,可容忍部分坏盘)
services:
minio-1: {image: minio/minio, command: server --console-address ":9001" http://minio{1...4}/data{1...2}, volumes: [./minio1/data1:/data1, ./minio1/data2:/data2]}
minio-2: {image: minio/minio, command: server --console-address ":9001" http://minio{1...4}/data{1...2}, volumes: [./minio2/data1:/data1, ./minio2/data2:/data2]}
minio-3: {image: minio/minio, command: server --console-address ":9001" http://minio{1...4}/data{1...2}, volumes: [./minio3/data1:/data1, ./minio3/data2:/data2]}
minio-4: {image: minio/minio, command: server --console-address ":9001" http://minio{1...4}/data{1...2}, volumes: [./minio4/data1:/data1, ./minio4/data2:/data2]}
```
Thanos 通过 `--objstore.config` 传 YAML 连接对象存储,各组件都要带:
```yaml
type: S3
config:
bucket: thanos-data
endpoint: minio:9000
access_key: thanos
secret_key: supersecret
insecure: true
signature_version2: false
```
如果企业环境用的是云厂商对象存储,`--objstore.config` 直接换对应配置即可,接口都是 S3 兼容的:
```yaml
# 阿里云 OSS / 腾讯云 COS / AWS S3(选一,均兼容 S3 API)
type: S3
config:
bucket: my-thanos-bucket
endpoint: oss-cn-hangzhou.aliyuncs.com # 或 cos.ap-guangzhou.myqcloud.com
access_key: AKIAXXXX
secret_key: XXXXXX
insecure: false
http_config:
idle_conn_timeout: 90s
# 云上建议开 SSE 加密,历史数据更安全
sse_config:
type: SSE-S3
```
## 四、部署 Sidecar:把历史数据推上对象存储
Sidecar 是整套架构里**最先要落地**的。它读取 Prometheus 的 `--storage.tsdb.path` 目录,把已经封口的 2 小时块(block)上传到对象存储。启动命令:
```bash
thanos sidecar \
--prometheus.url=http://prometheus-1:9090 \
--tsdb.path=/prometheus \
--objstore.config-file=/etc/thanos/objstore.yml \
--reloader.config-file=/etc/prometheus/prometheus.yml \
--grpc-address=0.0.0.0:10901 \
--http-address=0.0.0.0:10902
```
有两个参数我踩过坑,必须强调:
- `--shipper.upload-compacted`:**默认不传**。如果你不传,Prometheus 本地已 compact 过的块(比如你手动触发过 `tsdb compact`)Sidecar 不会上传,历史数据就永远上不去。回填场景必须带上。
- Prometheus 本身要开 `--web.enable-lifecycle` 和 `--web.enable-admin-api`,否则 Sidecar 无法通过 reload 接口做配置热更新。
## 五、部署 Query:跨集群统一查询
Query 是无状态的,可以多副本水平扩展,前面挂个负载均衡即可。它接受 PromQL,把请求**下发给所有注册的数据源**(各 Prometheus 的 Sidecar、Store、Receive),然后做去重合并。
```bash
thanos query \
--store=sidecar-1:10901 --store=sidecar-2:10901 \
--store=store-gateway:10901 \
--query.replica-label=prometheus_replica \
--query.timeout=60s \
--store.timeout=90s \
--query.auto-downsampling \
--http-address=0.0.0.0:19190
```
`--query.auto-downsampling` 让 Query 对长跨度查询自动优先使用降采样数据,配合第七节效果更好。如果你想让**别的系统**(比如 [Grafana](https://grafana.com/docs/) 之外的内部平台)也能查到全量历史,可以给 Query 开一个 Prometheus 兼容的读取端点,让外部通过 `remote read` 拉取:
```yaml
# prometheus.yml 里给每个集群配 remote_read,指向统一 Query
remote_read:
- url: http://thanos-query:19190/api/v1/read
read_recent: true
```
`--query.replica-label` 很关键:它让 Query 根据标签去重,多副本 Prometheus 抓同一目标时不会重复计。跨集群统一查询后,一张大屏就能同时看到华北和华东的指标:
```promql
# 把五个集群的 cpu_usage 拼在一起
avg by (cluster) (rate(node_cpu_seconds_total{mode!="idle"}[5m]))
```
跨集群大屏我常用的几组查询,全部走同一个 Query 端点:
```promql
# 某集群在线主机数(跨集群求和)
sum by (cluster) (up{job="node"})
# 全部集群的 7 天平均负载对比(自动走降采样)
avg by (cluster) (node_load1[7d])
# 全公司内存水位,按集群分组看异常尖峰
max by (cluster) (rate(node_memory_MemAvailable_bytes[5m]))
```
从原来的**联邦方案迁移到 Thanos**,我建议按这个顺序,先加后拆、随时可回滚:
```bash
# 1. 先起 Sidecar + Query + Store + Compact + MinIO,数据开始同步
# 2. 验证 Query 能查到各 Prometheus 的实时数据
curl -s "http://query:19190/api/v1/query?query=up" | jq '.data.result | length'
# 3. 等 Sidecar 上传历史块到对象存储(观察 thanos_shipper_uploads_total)
# 4. 确认 Store 能从对象存储读到历史块后,再逐步停掉联邦聚合节点
# 5. 把 Grafana 数据源从"联邦节点"切到 Query,验证大屏无缺失
# 6. 观察 1 周无异常后,彻底下线联邦节点
# 迁移中重点盯这几个指标,全部归零才算稳
curl -s http://query:19190/metrics | grep -E "thanos_query_partial_response|thanos_store_series"
```
## 六、Store 与 Compact:读取与压缩
Store 是**读路径**的核心,它把对象存储里的历史块映射成 gRPC StoreAPI 供 Query 拉取。它本身无状态,可多副本,每个副本都要能访问对象存储。
```bash
thanos store \
--objstore.config-file=/etc/thanos/objstore.yml \
--data-dir=/data/thanos/store-cache \
--http-address=0.0.0.0:19191
```
Compact 是**写路径**的压缩器。它定期扫描对象存储,把小的 2 小时块合并成更大块,并生成 downsampling 数据。它需要**独占一个数据目录**做本地缓存,且**只能单实例运行**(多实例会互相抢锁、产生竞态)。
```bash
thanos compact \
--objstore.config-file=/etc/thanos/objstore.yml \
--data-dir=/data/thanos/compact \
--consistency-delay=30m \
--compact-concurrency=4 \
--http-address=0.0.0.0:19192
```
`--consistency-delay` 我建议设 30 分钟以上,**防止 compact 拿到还没完全上传完的块**,这是后面事故三的伏笔。
Compact 的并发参数直接影响对象存储压力,块多、盘慢的集群要收敛一点,避免把自己和 MinIO 一起打爆:
```bash
# 块多且对象存储 IO 紧张时,降低并发、拉大块合并窗口
thanos compact \
--objstore.config-file=/etc/thanos/objstore.yml \
--compact-concurrency=2 \
--block-sync-concurrency=5 \
--consistency-delay=2h
```
Store 的本地缓存对读性能影响很大,尤其是高频查询同一批历史块时,缓存能省掉大量对象存储 IO:
```bash
# Store 本地块索引缓存 + 元数据缓存,显著降低跨集群长查询延迟
thanos store \
--objstore.config-file=/etc/thanos/objstore.yml \
--data-dir=/data/thanos/store-cache \
--block-sync-concurrency=20 \
--index-cache-size=500MB \
--store.grpc.series-sample-limit=500000
```
验证 Compact 是否真正在工作,用 `thanos tools bucket ls` 看压缩层级和块数量变化:
```bash
# 看对象存储里各层的块与大小
thanos tools bucket ls \
--objstore.config-file=/etc/thanos/objstore.yml \
--min-time=20260101T000000Z \
--max-time=20260401T000000Z
```
## 七、Downsampling:让长周期查询飞起来
这是 Thanos 最容易被忽略但收益最大的能力。Compact 会自动把原始块降采样成 **5m**(两周以上)和 **1h**(一个月以上)两个层级,查询超过这些时间跨度时自动用降采样数据,扫描的块数量减少一两个数量级。
验证是否生效,看对象存储里有没有 `downsample` 目录,或者用 `thanos tools bucket inspect`:
```bash
thanos tools bucket inspect \
--objstore.config-file=/etc/thanos/objstore.yml \
--selector='{__name__="up"}' --downsample
```
降采样层级的原始数据是否在 Query 端生效,可以直接对比查询耗时:
```promql
# 关闭降采样时 vs 开启后,同一查询的耗时差异就是降采样的收益
avg by (cluster) (node_load1[30d])
```
我查"去年同期的 CPU 对比",降采样前要扫 400 多个原始块,降采样后只扫 30 个 1h 块,查询从 9 秒降到 0.8 秒——这是我在生产上最直观的一次性能提升。
降采样数据的保留时长在 Compact 上单独控制,和原始数据分开,避免长期留存撑爆对象存储:
```bash
# raw 保留 30 天,5m 保留 180 天,1h 保留 365 天
thanos compact \
--objstore.config-file=/etc/thanos/objstore.yml \
--retention.resolution-raw=30d \
--retention.resolution-5m=180d \
--retention.resolution-1h=365d
```
跨集群的频繁查询还可以用 **recording rule** 预聚合,把"每集群每小时负载"这种重查询提前算好:
```yaml
# 跨集群 recording rule,查询直接命中聚合结果
groups:
- name: cross_cluster_agg
rules:
- record: job:cluster:cpu:avg5m
expr: avg by (cluster) (rate(node_cpu_seconds_total{mode!="idle"}[5m]))
```
## 八、事故一:回填数据"凭空消失"
**我遇到过的真实案例**:给新接入的一个 [K8s](https://kubernetes.io/docs/) 集群做历史回填,我用 `promtool tsdb create-blocks-from openmetrics` 生成了两周的模拟数据,`Sidecar` 也显示上传成功。但第二天 Query 一查,**两周数据全没了**。
排查过程:`thanos tools bucket ls` 看对象存储,块还在;`thanos tools bucket inspect` 发现这些块 `mtime` 都是**很久以前**的时间戳。原因找到了——Sidecar 的 shipper 有个"块最小年龄"逻辑,它认为 mtime 过老的块是**已经被删除的过期块**,直接当垃圾跳过甚至清理了。
回填时我用的是这个命令生成块(`promtool` 是 Prometheus 自带的工具):
```bash
# 从 OpenMetrics 文本生成两周的 TSDB 块,注意 dir 目录名要带"最终时间"
promtool tsdb create-blocks-from openmetrics \
./backup.metrics --min-time=20260101T000000Z \
--max-time=20260115T000000Z --output-dir=/prometheus
```
修复:给 Sidecar 加 `--shipper.upload-compacted`(回填块通常已被 compact),并把回填块的目录时间戳对齐到当前,或者干脆用 `thanos tools bucket rewrite` 重写块元数据。**教训:回填时块的时间戳必须"新",否则会被当成历史垃圾。**
完整的回填与验证流程,我后来固化成脚本,每次回填前先备份再确认:
```bash
#!/bin/bash
set -euo pipefail
TSDB_PATH=/prometheus
# 1. 生成回填块
promtool tsdb create-blocks-from openmetrics ./backup.metrics \
--min-time=20260101T000000Z --max-time=20260115T000000Z \
--output-dir="$TSDB_PATH"
# 2. 把生成块的 mtime 对齐到当前,避免被 shipper 当垃圾
find "$TSDB_PATH" -maxdepth 1 -type d -name '01*' \
-exec touch -d "$(date -u +%Y-%m-%dT%H:%M:%SZ)" {} \;
# 3. 触发 Sidecar 重扫并确认上传
curl -s -X POST http://sidecar:10902/-/reload
thanos tools bucket ls --objstore.config-file=/etc/thanos/objstore.yml
```
## 九、事故二:跨集群查询超时
**我遇到过的真实案例**:把第五个集群接入后,大屏上 `up` 查询偶尔报 `context deadline exceeded`,且集中在凌晨。凌晨是 compact 任务最活跃的时候,对象存储 IO 被占满,Store 拉块变慢。
排查:用 Query 的 `--query.tracing` 开 Jaeger tracing,看到耗时全在**Store 返回块数据**这一段 gRPC 流上,单次拉取一个 2h 块要 12 秒。根因是**降采样没跑完**,Query 还在扫原始块,块体积大、传输慢。
修复三步:一是把 `--query.timeout` 从默认 2 分钟调大到 60 秒并配合 `--store.timeout`;二是确保 Compact 的 `--consistency-delay` 足够,让降采样数据稳定生成;三是给 Store 和 Query 之间加**内网直连**,别让 gRPC 大流量走跨机房专线。改完后再没超时过。
用 Query 自带的 tracing 把耗时链路打出来,是定位这类问题最快的方式:
```bash
# 开启 Jaeger 追踪,把请求链路发到 jaeger:4317
thanos query \
--store=sidecar-1:10901 \
--query.timeout=60s \
--tracing.config='{"type":"JAEGER","config":{"service_name":"thanos-query","sampler":{"type":"const","param":1},"agent":{"host":"jaeger","port":6831}}}'
```
## 十、事故三:Compact 一直失败
**我遇到过的真实案例**:某天收到告警 `ThanosCompactHalted`,Compact 组件 `halt` 了。看日志全是 `partial block` 和 `out of order`,对象存储里一堆 `partial` 目录。
根因:Compact 去合并某个块时,**Sidecar 还在往同一时间范围上传新块**,compact 拿到的是"半成品",校验失败,连续多次失败就自动 halt 自我保护了。
修复:把 `--consistency-delay` 调到 30 分钟以上(我后来直接设为 2h,和块大小一致),让 compact 只处理"足够老"的块;同时清理残留的 `partial` 目录。这解释了为什么第六节我要强调这个参数。
清理残留 `partial` 目录用 bucket 工具,别手动删对象存储里的数据:
```bash
# 列出并清理所有 partial 块
thanos tools bucket inspect --objstore.config-file=/etc/thanos/objstore.yml | grep -i partial
thanos tools bucket cleanup --objstore.config-file=/etc/thanos/objstore.yml --delete-delay=48h
```
## 十一、完整 docker-compose 生产模板
多集群场景下,不是一套 compose 管到底,而是**每集群一套 prometheus+sidecar**,全部指向同一个 MinIO 和 Query。这是主题的核心——多套 Prometheus 用 Thanos 统一。先看单集群侧:
```yaml
# 每个集群一份,只改 cluster 标签和 targets
services:
prometheus:
image: prom/prometheus:v2.53.0
command:
- --config.file=/etc/prometheus/prometheus.yml
- --storage.tsdb.path=/prometheus
- --storage.tsdb.retention.time=7d
- --web.enable-lifecycle
- --web.enable-admin-api
volumes: [./prometheus.yml:/etc/prometheus/prometheus.yml:ro, prom-data:/prometheus]
sidecar:
image: quay.io/thanos/thanos:v0.36.0
command:
- sidecar
- --prometheus.url=http://prometheus:9090
- --tsdb.path=/prometheus
- --objstore.config-file=/etc/thanos/objstore.yml
- --shipper.upload-compacted
volumes: [./objstore.yml:/etc/thanos/objstore.yml:ro, prom-data:/prometheus]
depends_on: [prometheus, minio]
```
然后把这些 sidecar 全部注册到统一 Query 的 `--store` 参数里,就完成了多集群联邦查询。
把上面的散件拼成一套可复制的模板(已去掉敏感信息,生产替换为真实值即可)。这套还额外加了 **Receive**,用于想彻底去掉本地 Prometheus 的场景——把 `prometheus.yml` 里的 `remote_write` 指向它:
```yaml
version: "3.8"
services:
minio:
image: minio/minio:latest
command: server /data --console-address ":9001"
environment: [MINIO_ROOT_USER=thanos, MINIO_ROOT_PASSWORD=changeme]
volumes: [minio-data:/data]
ports: ["9000:9000", "9001:9001"]
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 30s
timeout: 10s
retries: 5
prometheus:
image: prom/prometheus:v2.53.0
command:
- --config.file=/etc/prometheus/prometheus.yml
- --storage.tsdb.path=/prometheus
- --storage.tsdb.retention.time=7d
- --web.enable-lifecycle
- --web.enable-admin-api
volumes: [./prometheus.yml:/etc/prometheus/prometheus.yml:ro, prom-data:/prometheus]
depends_on: [minio]
restart: unless-stopped
sidecar:
image: quay.io/thanos/thanos:v0.36.0
command:
- sidecar
- --prometheus.url=http://prometheus:9090
- --tsdb.path=/prometheus
- --objstore.config-file=/etc/thanos/objstore.yml
- --shipper.upload-compacted
- --min-time=20260101T000000Z
volumes: [./objstore.yml:/etc/thanos/objstore.yml:ro, prom-data:/prometheus]
depends_on: [prometheus, minio]
restart: unless-stopped
query:
image: quay.io/thanos/thanos:v0.36.0
command:
- query
- --store=sidecar:10901
- --store=store-gw:10901
- --store=receive:10901
- --query.replica-label=prometheus_replica
- --query.auto-downsampling
- --query.timeout=60s
- --store.timeout=90s
ports: ["19190:19190"]
depends_on: [sidecar, store-gw, receive]
restart: unless-stopped
store-gw:
image: quay.io/thanos/thanos:v0.36.0
command: [store, --objstore.config-file=/etc/thanos/objstore.yml, --data-dir=/data/store]
volumes: [./objstore.yml:/etc/thanos/objstore.yml:ro, store-data:/data]
depends_on: [minio]
restart: unless-stopped
compact:
image: quay.io/thanos/thanos:v0.36.0
command:
- compact
- --objstore.config-file=/etc/thanos/objstore.yml
- --data-dir=/data/compact
- --consistency-delay=2h
- --compact-concurrency=4
- --retention.resolution-raw=30d
- --retention.resolution-5m=180d
volumes: [./objstore.yml:/etc/thanos/objstore.yml:ro, compact-data:/data]
depends_on: [minio]
restart: unless-stopped
receive:
image: quay.io/thanos/thanos:v0.36.0
command:
- receive
- --objstore.config-file=/etc/thanos/objstore.yml
- --tsdb.path=/data/receive
- --receive.hashrings-file=/etc/thanos/hashrings.json
- --receive.local-endpoint=receive:10901
- --grpc-address=0.0.0.0:10901
- --http-address=0.0.0.0:10902
volumes: [./objstore.yml:/etc/thanos/objstore.yml:ro, ./hashrings.json:/etc/thanos/hashrings.json:ro, receive-data:/data]
depends_on: [minio]
restart: unless-stopped
volumes: {minio-data: {}, prom-data: {}, store-data: {}, compact-data: {}, receive-data: {}}
```
配套的 `objstore.yml`(给各组件挂载)与 `hashrings.json`(Receive 分片环):
```yaml
# objstore.yml —— 所有组件共用
type: S3
config:
bucket: thanos-data
endpoint: minio:9000
access_key: thanos
secret_key: changeme
insecure: true
signature_version2: false
```
```json
// hashrings.json —— receive 单实例配置
[{"hashring":"default","endpoints":["receive:10901"]}]
```
Receive 多实例时用哈希环做分片,把远程写压力摊开,`hashrings.json` 这样写:
```json
// 3 实例 Receive 哈希环,Prometheus remote_write 可任指一个
[
{"hashring":"ring-a","endpoints":["receive-1:10901","receive-2:10901","receive-3:10901"]}
]
```
生产注意:Receive 的 `--tsdb.path` 会保留一份本地数据做缓冲,`retention` 别设太小,否则 remote write 断流期间的数据会先被删掉。
Receive 完整启动参数(含本地缓冲保留与写超时):
```bash
thanos receive \
--objstore.config-file=/etc/thanos/objstore.yml \
--tsdb.path=/data/receive \
--tsdb.retention=2h \
--receive.hashrings-file=/etc/thanos/hashrings.json \
--receive.local-endpoint=receive:10901 \
--receive.hashring-algorithm=ketama \
--grpc-address=0.0.0.0:10901
```
配套的 `prometheus.yml` 才是这套架构的"入口",里面要同时把 `remote_write` 指向 Receive、`remote_read` 指向 Query:
```yaml
# prometheus.yml —— 本地热数据 + 远程读写
global:
scrape_interval: 15s
external_labels:
cluster: hua-bei-1
remote_write:
- url: http://receive:10902/api/v1/receive
queue_config:
capacity: 10000
max_shards: 300
min_shards: 1
max_samples_per_send: 5000
batch_send_deadline: 5s
min_backoff: 30ms
max_backoff: 5s
metadata_config:
send: true
send_interval: 1m
remote_read:
- url: http://query:19190/api/v1/read
read_recent: true
scrape_configs:
- job_name: node
file_sd_configs:
- files: [/etc/prometheus/targets/*.yml]
refresh_interval: 30s
relabel_configs:
- source_labels: [__meta_file_path]
regex: .*/(.+).yml
target_label: cluster
replacement: ${1}
- job_name: thanos
static_configs:
- targets: ["sidecar:10902", "query:19190", "compact:19192"]
```
上面的 `file_sd_configs` 引用的 `targets` 目录,每个集群放一个文件,文件名即集群名:
```yaml
# targets/hua-bei-1.yml —— 文件名会被 relabel 成 cluster 标签
- targets: ["10.0.1.10:9100", "10.0.1.11:9100"]
labels: {env: prod}
```
## 十二、给 Thanos 自己上监控
Thanos 的组件如果挂了,你连"监控坏了"都不知道。我的做法是给每个组件配一套告警规则,一旦 `halt` 或长时间不工作立刻通知。这些是生产必配的:
```yaml
# thanos-alerts.yml —— 监控 Thanos 自身的告警规则
groups:
- name: thanos.rules
rules:
- alert: ThanosCompactHalted
expr: thanos_compact_halted == 1
labels: {severity: critical}
annotations: {summary: "Thanos Compact 已 halt,历史数据停止压缩"}
- alert: ThanosStoreIsDown
expr: up{job="thanos-store"} == 0
labels: {severity: critical}
annotations: {summary: "Thanos Store 宕机,历史查询将失败"}
- alert: ThanosSidecarUploadFailing
expr: rate(thanos_shipper_uploads_failed_total[5m]) > 0
labels: {severity: warning}
annotations: {summary: "Sidecar 上传块持续失败,检查对象存储连接"}
- alert: ThanosQuerySlow
expr: histogram_quantile(0.95, rate(thanos_query_query_duration_seconds_bucket[5m])) > 30
labels: {severity: warning}
annotations: {summary: "Query 95 分位耗时超 30s,检查降采样是否生效"}
```
Grafana 侧接数据源很简单,把 Query 当成一个 Prometheus 数据源加进去,选 `Prometheus` 类型、URL 填 `http://thanos-query:19190` 即可,查询语法完全一致。
## 十三、排障命令速查
把三类事故的排查命令固化成一个速查表,遇到问题先跑这几条再定位:
```bash
# 1. 对象存储块是否完整、有无 partial
thanos tools bucket inspect --objstore.config-file=/etc/thanos/objstore.yml
thanos tools bucket ls --objstore.config-file=/etc/thanos/objstore.yml
# 2. 降采样目录是否已生成(长查询慢多半是它没跑)
thanos tools bucket inspect --objstore.config-file=/etc/thanos/objstore.yml --downsample
# 3. 各组件健康与运行状态
curl -s http://query:19190/-/healthy
curl -s http://sidecar:10902/-/healthy
curl -s http://compact:19192/-/healthy
# 4. 组件自身指标里看上传/压缩速率
curl -s http://sidecar:10902/metrics | grep thanos_shipper_uploads
curl -s http://compact:19192/metrics | grep thanos_compact_blocks_processed
# 5. 对象存储写入/读取吞吐,判断是存储瓶颈还是查询重
curl -s http://minio:9000/minio/health/cluster
# 6. 确认对象存储连通性与 bucket 可访问
thanos tools bucket web --objstore.config-file=/etc/thanos/objstore.yml --http-address=0.0.0.0:19193
# 7. 检查对象存储里的块是否损坏,坏块用 rewrite 修复
thanos tools bucket verify --objstore.config-file=/etc/thanos/objstore.yml
# 8. 若 verify 报损坏,用 rewrite 重写该块元数据后再查
thanos tools bucket rewrite --objstore.config-file=/etc/thanos/objstore.yml
```
查询慢但不超时时,优先怀疑两件事:**降采样没跑**(扫原始块)或**对象存储 IO 打满**。前者查 `downsample` 目录,后者看 MinIO 的并发与带宽指标,对症下药。
版本兼容是我每次升级前必查的坑。Thanos 和 Prometheus 有对应关系,Sidecar 对 Prometheus 版本有最低要求,升级前先确认:
```bash
# 查看当前各组件版本
thanos --version
prometheus --version
# 兼容性速记:Thanos 0.3x 配 Prometheus 2.4x 及以上
# 升级顺序:先升 Prometheus,确认抓取正常,再升 Thanos 组件
# 升级 Thanos 时先升 Query/Store(无状态),最后升 Compact(有状态)
```
历史数据的**备份与恢复**同样走对象存储,灾备时直接同步 bucket 即可:
```bash
# 把对象存储整个同步到另一个 bucket/机房做异地备份
rclone sync s3:thanos-data s3:thanos-backup --s3-no-check-bucket
# 恢复时用 thanos tools 校验块完整性
thanos tools bucket verify \
--objstore.config-file=/etc/thanos/objstore.yml \
--repair=false
```
Grafana 用 provisioning 方式接入 Thanos,声明式、可进 Git:
```yaml
# grafana/datasources/thanos.yml
apiVersion: 1
datasources:
- name: Thanos
type: prometheus
url: http://thanos-query:19190
isDefault: true
jsonData:
timeInterval: 1m
```
## 十四、FAQ 速查
**Q:Thanos 和 Prometheus 联邦到底怎么选?**
A:规模小、集群少、能接受单点聚合风险,联邦够用;一旦要**长期留存**或**多集群统一查询**,直接上 Thanos,别犹豫。
**Q:Receive 什么时候用?**
A:当你想把 Prometheus 完全无状态化(数据全落对象存储,本地只留 WAL)时用。代价是多了远程写这一跳,网络故障会丢数,我一般建议保留本地 Prometheus,只靠 Sidecar 上传。
**Q:块太小 compact 频繁怎么办?**
A:把 `--consistency-delay` 调大,让 block 攒得更厚再合并,同时适当调低 `--compact-concurrency` 避免对象存储被打爆。
**Q:如何快速判断是对象存储还是查询的问题?**
A:用 `thanos tools bucket inspect` 看对象存储里的块元数据是否完整、降采样目录是否存在;再用 Query 的 tracing 看耗时落在哪一段,就能定位是 Store 拉块慢还是 PromQL 本身重。
## 相关文章
- [FinOps 可观测性成本优化:智能采样与分层存储实战指南](finops-monitoring.md)
- [Prometheus TSDB 深度运维:WAL/Head/Block 结构与 5 个真实故障](prometheus-tsdb-operations.md)
- [Zabbix + Prometheus 混合监控架构:传统基础设施 + 云原生服务的双栈方案](zabbix-prometheus-hybrid.md)
---
## Prometheus TSDB 深度运维:WAL/Head/Block 结构与 5 个真实故障
URL: https://ops.jiunotes.top/monitor/prometheus-tsdb-operations.md
摘要: TSDB 目录解剖 + 三段式写入流程 + compaction 卡死/head OOM/retention 消失/WAL 恢复/block 膨胀 5 个真实事故。
### Answer
Prometheus TSDB 三段式 WAL/Head/Block 写入,compaction 卡死/head OOM/retention 到期消失/WAL 恢复失败/block 膨胀是 5 大事故,逐个复盘。
### FAQ
Q: Prometheus 重启后数据丢了?
A: 先看 wal/ 目录是否还在,且 -storage.tsdb.allow-overlapping-blocks 参数是否配置。重启会读 WAL 重建 Head,如果 WAL 损坏会跳过。生产必须配 --storage.tsdb.retention.time 而不是靠磁盘空间。
Q: compaction 一直跑不停怎么办?
A: 99% 是因为 pending 太多(head chunk 数 > 9 触发 compaction)。查 metrics:prometheus_tsdb_head_series 如果突增,说明基数爆炸,先治基数问题再谈 compaction。
Q: TSDB 占了 500GB 还能缩吗?
A: 不能直接删 block。改 -storage.tsdb.retention.time 到更短值 + 等下一次 compaction 会自然清理。想立即缩,停 Prometheus,手动删老 block,重启(会重建 index)。
Q: head chunk 内存泄漏怎么判断?
A: 看 process_resident_memory_bytes 与 prometheus_tsdb_head_active_series 的比例。head_active_series 是万级但内存 GB 级 → 大概率是 leaky bucket bug 或基数爆炸。升级到最新 patch 版可修。
Q: Prometheus 单实例能扛多少 series?
A: 官方经验值 35 万活跃 series。超过就该分片(federation)或换 Thanos/VictoriaMetrics。35 万不是硬阈值,超过只是 head chunk 内存线性增长,compaction 变慢。
### Full Content
# Prometheus TSDB 深度运维:WAL/Head/Block 结构与 5 个真实故障
[Prometheus](https://prometheus.io/docs/) 的存储层叫 TSDB(Time Series Database),是社区用 Go 写的、专门为 Prometheus 定制的时序数据库,不复用 InfluxDB、也不复用 Cassandra。它跟 Prometheus 是同进程、同二进制文件的一部分,没有单独的服务端口。所有数据都写在 Prometheus 的 `--storage.tsdb.path` 目录里(默认 `/prometheus/data`),目录结构非常规整,运维起来其实不难——前提是你得知道每个文件在干嘛。
## 目录结构解剖
我生产上的一台 Prometheus(1 年保留、单机、非托管)目录长这样:
```bash
$ ls -lah /var/lib/prometheus/
total 342G
drwxr-xr-x 5 prom prom 12M Oct 26 02:11 .
drwxr-x---. 10 root root 4.0K Oct 26 02:11 ..
drwxr-xr-x 2 prom prom 220K Oct 26 02:11 chunks_head
drwxr-xr-x 2 prom prom 1.4G Oct 26 02:11 wal
drwxr-xr-x 2 prom prom 3.2G Oct 24 15:40 01H7XV4X0K3P5Y6K2JQ8W9Y8ZT
drwxr-xr-x 2 prom prom 3.3G Oct 23 08:12 01H7X9ZQ9P4R7XV8K8N9M3J5Y6
drwxr-xr-x 2 prom prom 3.4G Oct 22 04:33 01H7XJ2H3L6B5T6R3H8KJ0K2W9
...
-rw-r--r-- 1 prom prom 472M Oct 26 02:11 chunks_head.meta.json
```
三个目录 + 一堆 block 文件夹:
**`wal/`**:Write-Ahead Log,前向日志。所有新样本先落这里,再异步刷到 head chunk。里面是若干个 `000001`、`000002`、`000003`... 编号的 WAL 段文件,段文件满了切新的。
**`chunks_head/`**:Head chunk 持久化文件,是 WAL 的一种"更结构化"的形态。Prometheus 会把 head 里的部分 chunk 提前 flush 到这里,方便重启时快速恢复。同目录还有 `chunks_head.meta.json`,记录 head 的元数据。
**`01H7XV4X0K3P5Y6K2JQ8W9Y8ZT/`**:一个已 compaction 完成的 block,目录名是 ULID(通用唯一标识,含时间戳,排序天然按时间)。打开一个 block 看:
```bash
$ ls -lah /var/lib/prometheus/01H7XV4X0K3P5Y6K2JQ8W9Y8ZT/
total 3.2G
-rw-r--r-- 1 prom prom 284M Oct 24 15:40 chunks_000000
-rw-r--r-- 1 prom prom 198M Oct 24 15:40 chunks_000001
-rw-r--r-- 1 prom prom 256M Oct 24 15:40 chunks_000002
-rw-r--r-- 1 prom prom 2.5G Oct 24 15:40 index
-rw-r--r-- 1 prom prom 2.1M Oct 24 15:40 meta.json
-rw-r--r-- 1 prom prom 0 Oct 24 15:40 tombstones
```
`chunks_*` 存原始样本(每 48 小时一段),`index` 存标签到 chunk 引用位置的映射(查询走这个),`meta.json` 是块的元信息(时间范围、segment 列表),`tombstones` 是被 soft-delete 的区间记录。
Block 之间的时间范围可能重叠(尤其刚写完的 head 切 block 时),因此 `-storage.tsdb.allow-overlapping-blocks=true` 这个参数在生产强烈建议打开,否则重启后看到重叠 block 会直接拒启动。
## 写入流程:WAL → Head → Block
一图流:
```
Scrape
│
▼
+----------+ 每条样本 +------------------+ chunk 满 +-----------------+
| WAL | ------------> | Head (内存) | ------------> | Head Chunk 上锁 |
| (磁盘) | | series → chunks | | flush 到磁盘 |
+----------+ +------------------+ +--------+--------+
│
每 2h 触发一次 head cut
▼
+-------------------+
| Chunk 目录 |
| chunks_head/ |
+--------+----------+
│
compaction:把多个 2h chunk 合成 block
▼
+-------------------+
| Block 01H7XXX/ |
+-------------------+
```
三个关键点:
1. **WAL 是异步刷盘的**,写入 WAL 后立即返回给 scraper,不等 head 处理完成。
2. **Head 是内存结构**,series 按 `labels → hashmap` 索引,chunk 用 128 字节定长块(4k 样本/chunk)。
3. **Block 是磁盘只读**,一旦写完不再修改,删除 block 就是彻底清理。
这条流水线上,任何一段出问题都会导致"数据丢"或"数据堆积"。下面 5 个事故全部对应这三段。
## compaction 触发机制
`-storage.tsdb.max-block-size-bytes` 控制 block 目标大小,默认 512MB(1.x 之前是 512MB,2.x 起默认 2.5GB 分片写入)。compaction 触发条件:
- **定时触发**:每 5 分钟检查一次,head 中"未落盘"的 chunk 数 > `min_head_chunks`(默认 9)时触发。
- **大小触发**:待压缩的 chunk 总大小超过阈值。
- **手动触发**:`curl -X POST http://localhost:9090/api/v1/admin/tsdb/snapshot` 或 `snapshot` 子命令,把当前 head 强制切成 block。
关键指标:
```promql
# 待压缩的 pending block 数
prometheus_tsdb_retention_limit_bytes - prometheus_tsdb_disk_free_bytes # 不是这个,看下面
prometheus_tsdb_compaction_pending
```
如果 `prometheus_tsdb_compaction_pending` 长时间 > 0,说明 compaction 追不上写入速度。
## retention 策略深度
三个参数:
- `--storage.tsdb.retention.time=15d`:时间保留,到点自动删 block。
- `--storage.tsdb.retention.size=50GB`:大小保留,磁盘占用超阈值删老 block。
- `--storage.tsdb.retention.time=0`:关闭时间保留。
**为什么 size 优先容易出问题**:size 触发时 Prometheus 会挑最老的 block 删,但因为 block 是稀疏写入的(一个 2h block 可能只有 500MB),触发阈值后可能一次性删掉好几个 block,用户观察到的现象就是"数据莫名其妙缺了一段"。生产强烈建议只配 `retention.time`,磁盘空间问题用外部监控兜底。
## 事故 1:compaction 卡死
**现象**:磁盘占用持续增长,[Grafana](https://grafana.com/docs/) 上 `prometheus_tsdb_compaction_running_seconds_total` 长时间不变,`prometheus_tsdb_head_chunks` 从 50000 涨到 400000。
**日志关键字**(`grep` 就能定位):
```
level=info ts=2026-10-20T14:22:03Z caller=db.go:1465 msg="Compaction completed" ...
level=info ts=2026-10-20T14:22:03Z caller=db.go:1442 msg="Compaction failed" err="context canceled"
level=error ts=2026-10-20T14:23:15Z caller=db.go:1478 msg="Failed to complete compaction" err="write /var/lib/prometheus/01H7...: no space left on device"
```
**根因**:99% 是基数爆炸导致 head chunks 数暴涨。compaction 需要把 head 中 40 万个 series 的 chunk 逐条搬到新 block,一旦磁盘 IO 跟不上,就卡死。
**修复步骤**:
```bash
# 1. 立即停止抓取,减少写入压力
curl -X POST http://localhost:9090/api/v1/admin/tsdb/snapshot # 手动切一次 block
# 2. 查高基数 series
curl -G 'http://localhost:9090/api/v1/status/tsdb' | jq '.data.seriesCountByMetricName | to_entries | sort_by(-.value) | .[0:20]'
# 3. 定位到 pod_ip、container_name 这类维度被 label 化,改 alert rule 或 scrape job
# 4. 磁盘扩容或清理老 block 后重启
systemctl restart prometheus
```
## 事故 2:head chunk 内存泄漏
**现象**:Prometheus 进程内存缓慢上涨,一天涨 200MB,重启后恢复。`prometheus_tsdb_head_active_series` 稳定在 12 万,但 RSS 从 2GB 涨到 5GB。
**排查思路**:
```bash
# 1. 对比 active series 和内存
prometheus_tsdb_head_active_series # 120000
process_resident_memory_bytes # 5.2e9
# 2. 按 series 分组看内存(官方 debug 接口)
curl http://localhost:9090/debug/fgprof
# 生成火焰图 → 看内存分配热点
# 3. pprof 内存分配剖析
go tool pprof -top http://localhost:9090/debug/pprof/heap
```
如果火焰图上看到 `tsdb.Head.appendSeries` 或 `labeled.LabeledValues` 占比异常,就是 leaky bucket 类的内存回收问题。这类问题基本都在 patch 版本里修过,升级到最新 patch 即可。我们生产上一台 Prometheus 2.37.1 有这个问题,升到 2.38.3 后消失。
## 事故 3:retention 到期数据"消失"
**现象**:业务方问"上周三到上周四的 pod_restart 指标怎么没了?"运维一看 retention 是 30d,今天还没到 30 天。
**根因**:不是 retention 触发,是 compaction 时把 head 中一段数据误判为过期。常见于 retention 配了 size(见上节)。也可能是某个中间态的 block 被删除了。
**验证**:
```bash
# 列所有 block 的时间范围
for d in /var/lib/prometheus/01H*; do
echo -n "$d: "
jq -r '"\(.timeRange.mint) → \(.timeRange.maxt)"' $d/meta.json
done
```
看时间线是否连续。发现空洞就是 block 被提前删了。
**教训**:retention 只配 time,不要配 size。size 触发是不可预测的。
## 事故 4:WAL 崩溃恢复
**现象**:Prometheus 重启后卡在"Loading storage",日志报:
```
level=error ts=2026-10-22T03:14:22Z caller=wal.go:2001 msg="WAL corrupted, trying to recover" err="unexpected EOF"
level=warn ts=2026-10-22T03:14:23Z caller=wal.go:2015 msg="Recovered WAL" from="000042" to="000047" dropped="3"
```
WAL 段文件损坏时,Prometheus 会尝试修复,从最后一个完好段截断,把损坏段丢弃。**代价是这几个段的 head 数据丢**(一般 5-15 分钟)。
**预防**:
- 启用 WAL 压缩:`--storage.tsdb.wal-compression=true`(Prometheus 2.32+)。压缩后 WAL 大小减少 60-70%,写入量下降自然减少损坏概率。
- 挂 tmpfs 或 NVMe 到 `--storage.tsdb.wal-directory`(Prometheus 2.26+),把 WAL 和 block 分开。tmpfs 断电丢数据无所谓,反正重启后会从 block + 重新 scrape 恢复。
```yaml
# docker-compose / kube-state 参数
command:
- --storage.tsdb.wal-directory=/tmp/prom-wal
- --storage.tsdb.path=/var/lib/prometheus
- --storage.tsdb.wal-compression=true
```
## 事故 5:block 目录膨胀
**现象**:磁盘占用从 100GB 涨到 400GB,`ls` 一堆 block,retention 明明配了 30d。
**排查**:
```bash
# 1. 查 block 数量和时间跨度
ls -d /var/lib/prometheus/01H* | wc -l # 应该 ≈ 30d / 2h = 360
ls -la /var/lib/prometheus/ | grep 01H | awk '{print $6, $7, $8, $9}'
# 2. 看是否有孤儿 block(meta.json 时间远超 retention)
for d in /var/lib/prometheus/01H*; do
maxt=$(jq -r '.timeRange.maxt' $d/meta.json)
now=$(date +%s%3B)
age=$(( (now - maxt) / 86400000 ))
[ $age -gt 30 ] && echo "OLD: $d age=${age}d"
done
```
**根因**:compaction 没跑完,或者某个 block 的 meta.json 损坏导致 retention 扫描跳过它。
**修复**:
```bash
# 温和方案:改 retention 短一点,等 compaction 自然清理
systemctl stop prometheus
sed -i 's/retention.time=30d/retention.time=7d/' /etc/prometheus/prometheus.yml
systemctl start prometheus
# 暴力方案(需权衡数据风险):手动删老 block
systemctl stop prometheus
find /var/lib/prometheus -maxdepth 1 -name '01H*' -type d -mtime +30 -exec rm -rf {} +
systemctl start prometheus
```
生产环境我们更倾向温和方案,暴力删 block 有可能破坏 index 一致性。
## TSDB 监控:10 个必看指标
一行一个,直接抄:
```promql
# 1. 磁盘使用率
sum by (instance) (prometheus_tsdb_storage_blocks_size_bytes)
# 2. head chunk 数(异常增长说明基数爆炸)
prometheus_tsdb_head_chunks{job="prometheus"}
# 3. head 中活跃 series 数
prometheus_tsdb_head_active_series
# 4. 已压缩完成的 block 数
prometheus_tsdb_storage_blocks
# 5. compaction 是否在运行
deriv(prometheus_tsdb_compaction_duration_seconds_count[5m]) > 0
# 6. compaction 耗时(超过 10 分钟就慢了)
prometheus_tsdb_compaction_duration_seconds
# 7. WAL 大小
prometheus_tsdb_wal_storage_size_bytes
# 8. WAL 段文件数(越多说明 checkpoint 越慢)
prometheus_tsdb_wal_segments
# 9. 查询命中 head 的比例(比例高说明数据还热在内存)
rate(prometheus_tsdb_queries_total[5m]) > 0
# 配合 prometheus_tsdb_head_chunks 看查询热度
# 10. 启动加载耗时(> 60s 说明数据量到临界)
prometheus_tsdb_load_total - (prometheus_tsdb_load_total offset 1d)
```
前 5 个必须告警,后 5 个做仪表盘观察即可。
## 与 Thanos / VictoriaMetrics / Cortex 对比
不深入,只谈选型:
- **纯 Prometheus**:series < 35 万、单实例、保留 ≤ 30 天。运维最简,TSDB 结构清晰。
- **Thanos**:Prometheus + Sidecar 上传到对象存储(S3/OSS),跨实例查询走 query 组件。TSDB 层完全复用 Prometheus 原样,运维心智负担低。适合"想保留 Prometheus 但保留 1 年+"的场景。
- **VictoriaMetrics**:自己写的存储引擎,非兼容 TSDB 目录结构。单机可扛 100 万 series,多租户能力强。运维上要重新学一套概念。
- **Cortex**:Mimir 前身,全托管分布式,块状存储类似 Thanos。企业级方案,学习曲线最陡。
我们生产上单机 Prometheus 保留 15d + Thanos 上传到 OSS 保留 1 年,两者叠加。TSDB 层面的坑踩过一遍之后,运维基本只关注 Thanos sidecar 是否正常上传。
## 常见误解澄清
- **"Prometheus 应该装到 SSD 上"**:不一定。TSDB 的写入是顺序的(WAL + block 都是追加),普通 SATA 机械盘配 15d 保留也能扛住。但 head chunk 读取走内存,查询性能跟磁盘关系不大。
- **"head chunk 内存是固定的"**:不是。跟活跃 series 数线性相关,每个 series 平均 50-200KB(取决于写入频率)。
- **"compaction 会锁查询"**:不会。compaction 走后台 goroutine,查询走 read-only 的 block 和当前 head 的 snapshot。
- **"TSDB 数据不能备份"**:可以。停 Prometheus → `cp -a /var/lib/prometheus/ backup/`,恢复时反过来。或者用 `admin/tsdb/snapshot` API 生成快照。
## FAQ
**Q: Prometheus 重启后数据丢了?**
先看 `wal/` 目录是否还在,且 `-storage.tsdb.allow-overlapping-blocks` 参数是否配置。重启会读 WAL 重建 Head,如果 WAL 损坏会跳过。生产必须配 `--storage.tsdb.retention.time` 而不是靠磁盘空间兜底。如果 `wal/` 目录丢了,只丢 WAL 中的那 5-15 分钟数据,其余都还在 block 里。
**Q: compaction 一直跑不停怎么办?**
99% 是 pending 太多(head chunk 数 > 9 触发 compaction)。查 metrics:`prometheus_tsdb_head_series` 如果突增,说明基数爆炸,先治基数问题再谈 compaction。用 `/api/v1/status/tsdb` 定位高基数 metric。
**Q: TSDB 占了 500GB 还能缩吗?**
不能直接删 block(会破坏 index)。改 `-storage.tsdb.retention.time` 到更短值 + 等下一次 compaction 会自然清理。想立即缩,停 Prometheus,手动删老 block(`find -mtime +N`),重启会重建 index(几分钟到十几分钟)。
**Q: head chunk 内存泄漏怎么判断?**
看 `process_resident_memory_bytes` 与 `prometheus_tsdb_head_active_series` 的比例。head_active_series 是万级但内存 GB 级 → 大概率是 leaky bucket bug 或基数爆炸。升级到最新 patch 版可修。定位工具:`/debug/pprof/heap`。
**Q: Prometheus 单实例能扛多少 series?**
官方经验值 35 万活跃 series。超过就该分片(federation)或换 Thanos/VictoriaMetrics。35 万不是硬阈值,超过只是 head chunk 内存线性增长,compaction 变慢,重启加载时间变长。我们生产一台 Prometheus 峰值 42 万 series,运行稳定,但加载时间从 30s 涨到 90s。
**Q: `-storage.tsdb.allow-overlapping-blocks` 一定要开吗?**
推荐开。开了只影响重启加载时的 block 合并扫描,开销很小。不开的话如果因为某些原因出现了时间重叠的 block(比如快照恢复、手工拷贝),Prometheus 会直接拒启动。
**Q: WAL 和 head chunk 都会丢数据吗?**
WAL 丢了丢 5-15 分钟。Head chunk 丢了(`chunks_head/`)也丢 5-15 分钟。两者都是"最近数据"。Block 丢了就是永久丢失。所以备份优先备份 block,WAL 和 chunks_head 可以不管(重启后会重建)。
## 相关文章
- [FinOps 可观测性成本优化:智能采样与分层存储实战指南](finops-monitoring.md)
- [Alertmanager 告警规则配置:路由树 + Silence/Inhibit + 微信钉钉 Webhook 六坑指南](prometheus-alerting.md)
- [Prometheus 部署实战:Docker / systemd / K8s 三种方案 60 秒选型](prometheus-deploy.md)
---
## SSH 加固:密钥登录 + Fail2ban
URL: https://ops.jiunotes.top/monitor/ssh-hardening.md
摘要: SSH 加固 Top 8:禁用 root 登录、密钥认证、Fail2ban 常见 jail、AllowUsers、sshd_config 常见坑、如何不把自己锁外面。
### Answer
SSH 加固三步:① 备份当前 SSH 会话(不要关);② 加第二台机器的密钥;③ 改 sshd_config 后 `ssh -v` 验证再退出。fail2ban 建议开启 sshd jail,`bantime=1h`。
### FAQ
Q: 禁用 root 登录会不会锁自己?
A: 会。先用普通用户 + sudo 替代,验证能登录后再改 sshd_config。推荐流程:建 ops 用户→授 sudo→开第二个终端测试→再禁用 root。
Q: fail2ban 常见 jail?
A: sshd(SSH)、sshd-ddos(防 DOS)、sshd-pam(PAM 认证失败)、nginx-botsearch(Nginx 扫描)。生产至少开 sshd + sshd-ddos 两条。
Q: 密钥登录配错了打不进来怎么办?
A: 永远保留一个能用的备用通道:云厂商 VNC/远程控制台、或另一台未加固机器跳板。加固前先把 `PermitRootLogin no`、`PasswordAuthentication no` 这两条留到最后一步改,改完在第二个终端验证成功再退出第一个会话。
Q: MaxAuthTries 设多少合适?
A: 生产建议 3,配合 fail2ban 的 sshd jail 双重防护。设太低(1-2)会误伤正常用户切网重连;设太高(5+)会让暴力破解试错成本变低,意义不大。
### Full Content
# SSH 加固:密钥登录 + Fail2ban
## 为什么 SSH 加固是运维第一道闸
我带过一批新机器上线,几乎每次安全扫描报告里 SSH 都是高危项:默认密码可登、root 直接暴露 22 端口、没装 fail2ban。一旦挂到公网,一小时内就有来自全球 IP 的暴力破解尝试(`/var/log/secure` 里能看到成百上千条 `Failed password for root`)。加固不是为了应付合规,是**不给自己留后患**——一次 22 端口被拖,整条内网都可能被跳板渗透。
我踩过的真实坑:有一台生产跳板机,`PermitRootLogin` 默认 yes,密钥和密码都开着,某天凌晨告警是 SSH 出现大量失败后突然成功一条,查 `/var/log/auth.log` 发现是某个海外 IP 撞库成功,虽然那台机权限不高没造成损失,但吓得当晚就把密码登录关了、上了 fail2ban。从那以后我把它变成每次新机上线的标准动作。
## Top 8 加固清单(按优先级排序)
| 项 | 配置 | 说明 / 我踩过的坑 |
|---|---|---|
| 1 | `PermitRootLogin no` | 禁用 root 直接登录。这是最重要的一条,但**必须最后改**(见下文解锁兜底) |
| 2 | `PasswordAuthentication no` | 只允许密钥。改前确保所有用户密钥都已分发,否则把自己锁外 |
| 3 | `Port 2222`(可选) | 换端口能挡掉 90% 的自动化扫描,但**不是安全边界**,攻击者照样能扫 2222,别因为换了口就放松 |
| 4 | `AllowUsers ops@*` | 白名单只放行指定用户,其余用户登录直接被拒 |
| 5 | `MaxAuthTries 3` | 单次连接最多试 3 次,配 fail2ban 双保险 |
| 6 | `LoginGraceTime 30` | 30 秒内未完成认证就断开,防半开连接占资源 |
| 7 | `ClientAliveInterval 300` / `ClientAliveCountMax 2` | 闲置 10 分钟自动断开,减少被留下的活跃会话被复用 |
| 8 | 启用 fail2ban | 自动封禁暴力 IP,见下节 |
## sshd_config 常见坑
- **注释必须半角 `#`**:我在 Windows 环境手敲过全角 `#`,`sshd -t` 直接报 syntax error,服务起不来。改完务必 `sshd -t` 验证语法再 reload。
- **大小写敏感**:`PermitRootLogin` 写成 `permitrootlogin` 会被忽略(不是报错,是静默不生效),行为等于配置缺失。
- **`sshd -t` 检查语法**:`sudo sshd -t` 是改配置后的第一步,能抓出绝大多数手误;通过后 `sudo systemctl reload ssh` 无感 reload,不断开当前会话。
- **匹配块顺序**:`Match` 块里再写参数会覆盖全局值,改多用户权限时容易写错作用域,建议用 `Match User ops` 单独收口。
## 如何不把自己锁在外面(我固化的操作顺序)
1. **保持当前 SSH 会话全程不关**,这是你的安全绳。
2. 另开一个终端用**新配置**试连(`ssh -v ops@host -p 2222`),确认能进、能 `sudo`。
3. 第二个终端验证成功后,再回第一个终端 `systemctl reload ssh` 应用。
4. 兜底通道永远备着:云控制台 VNC/远程连接、或一台未加固的跳板机。生产上我会把控制台访问方式写进值班手册,别等真锁了才翻。
## Fail2ban 落地
```bash
# 装好后启用 sshd + sshd-ddos 两条 jail
cp /etc/fail2ban/jail.d/defaults-debian.conf /etc/fail2ban/jail.d/sshd.conf
# 关键参数:bantime=3600(封 1 小时) maxretry=5(5 次失败封)
fail2ban-client status sshd
```
我生产上的配置经验:`bantime` 别设太短(默认 600s 太短,爆破 IP 换个时段又回来),1 小时起步;`findtime` 配 600s 内的失败窗口。封禁的 IP 会落到 `/var/lib/fail2ban/data/sshd.ipbm`,配合 `fail2ban-client status` 能看当前封了几台。
## 一次真实封禁复盘:fail2ban 把我办公室 IP 封了
有次我把 `bantime` 设成 3600 秒、`maxretry` 设成 5,结果公司换了办公网段,新 IP 段被上一轮暴力破解撞过、还在封禁名单里,我换网连不上跳板机,排查半天才发现是 `fail2ban-client status sshd` 里躺着整个 /24 段。教训是两点:一是封禁要**定期清理误伤**(`fail2ban-client set sshd unbanip `),二是有办公网段变更时,先查一次当前封禁列表再连。我把这条写进了值班手册:每次 SSH 连不上先别急着重置密码,先 `fail2ban-client status` 看看自己是不是被封了。
## 加固后的验收清单
改完别直接撒手,按这个清单逐项确认,全绿才算加固完成:
```bash
# 1. 语法通过
sudo sshd -t && echo OK
# 2. 确认密码登录已关(应无输出)
sshd -T | grep -E 'permitrootlogin|passwordauthentication|maxauthtries'
# 3. 用密钥新终端能进、能 sudo(验证通过再退出旧会话)
sudo -V >/dev/null && echo "sudo OK"
# 4. fail2ban 在跑、sshd jail 已启动
systemctl is-active fail2ban && fail2ban-client status sshd
# 5. 看近期失败登录,确认爆破来源(封禁是否命中)
tail -50 /var/log/auth.log | grep -Ei 'fail|invalid|error'
```
其中 `sshd -T` 是整体验证的神器:它以**生效后的合并配置**输出所有参数(把 sshd_config 和 `/etc/ssh/sshd_config.d/*.conf` 的覆盖关系都算上),我经常靠它发现自己以为改了其实没改——比如很多发行版把 `PermitRootLogin` 写在 `/etc/ssh/sshd_config.d/` 下的 drop-in 文件里,只改主文件不生效。这条踩坑让我以后排查 SSH 配置一律先看 `sshd -T` 的合并结果,而不是只盯 `sshd_config` 主文件。
## 密钥管理:别只生成一把
很多团队加固完发现一个问题:`authorized_keys` 里只有一把 key,谁离职 / 谁把笔记本丢了,团队就断了手。我推的规则是**密钥分层**:
- **个人密钥**:每人一对 ed25519 key,指纹登记到团队 CMDB,离职即从 `authorized_keys` 摘除。
- **服务 / CI 专用密钥**:CI 部署、备份脚本各用一把**独立密钥**,只授权到 `ops` 这类受限用户,并配 `command=` 限制能执行的命令(如只允许 `deploy.sh`,不能开交互 shell)。
一条我踩过的坑:早期用 `ssh-keygen -t rsa` 默认 3072 位,现在直接 `ssh-keygen -t ed25519`,更短、更快、更强。给 CI 那把 key 加用途限制时,`authorized_keys` 里写成:
```
command="/opt/deploy/run-deploy.sh",no-pty,no-port-forwarding,no-X11-forwarding ssh-ed25519 AAAA... ci-deploy
```
这样即使这把 key 泄露,攻击者也跑不了任意命令,只能触发预定义部署动作,把爆炸半径压到最小。
## 巡检:把「是否被爆破」变成常态化监控
加固不是一次性的事,SSH 的威胁面随暴露面变化。我在监控里加了两条,把 SSH 安全做成可观测指标:
- **登录失败率**:采集 `auth.log` 的 `Failed password` 频率,突增说明这台机被选中爆破了(往往意味着有 22 端口暴露没上安全组,或 fail2ban 没生效)。
- **fail2ban 封禁数**:把 `fail2ban-client status` 解析成指标,封禁量长期为 0 反而要警惕——可能 jail 没起来,而不是没人打。
配合 `sshd -T` 的合并配置定期审计:我把它做成每月一次脚本,diff 当前生效参数与加固基线,任何 `PermitRootLogin` 被悄悄改回 `yes`、`PasswordAuthentication` 被打开的情况,立刻告警。安全配置最怕的不是从没加固,而是加固完几个月被某次「临时排障」改回默认值,再没人知道。
## 相关文章
- [堡垒机选型与 JumpServer 部署](bastion-jumpserver.md)
- [Alertmanager 告警降噪实战:抑制器 + 路由树 + 静默配置四步降噪法](alertmanager-noise-reduction.md)
- [DevSecOps 安全左移:CI/CD 集成扫描策略,从 SAST/DAST 到供应链安全完整指南](devsecops-security.md)
---
## Windows 服务器监控实战:wmi_exporter + Prometheus + Grafana 完整落地链路 + 5 个真实故障
URL: https://ops.jiunotes.top/monitor/windows-server-monitoring.md
摘要: Windows 监控在中文圈几乎一片空白,多数教程"装个 wmi_exporter"就没了。本文给完整落地链路:wmi_exporter MSI/zip 双安装 + 服务配置 + 防火墙放行 + Prometheus 抓取 + Grafana 看板 + 告警规则,附 collector 清单、关键指标 PromQL 与 5 个真实故障。
### Answer
Windows 接入 Prometheus 的关键在 wmi_exporter 的 9182 端口与 collector 开关。安装只是开始,防火墙、服务权限、时间同步和高基数 collector 才是故障高发点。
### FAQ
Q: wmi_exporter 的 MSI 装完 9182 端口不通,最常见原因是什么?
A: 八成是防火墙。默认 9182 是 TCP 入站,Windows 防火墙的入站规则默认阻止,需手动放行。其次是服务没起来——查 sc.exe query wmi_exporter 看状态,再查注册表 ENABLED_COLLECTORS 是否把要用的 collector 漏了。
Q: process collector 要不要默认开启?
A: 不建议默认开。它按进程粒度输出几十个指标,一台几百进程的服务器会产生数千时间序列,Prometheus 内存暴涨、告警查询变慢。只在需要定位单进程时临时开,或用 label 过滤后长期开。
Q: 磁盘队列告警老误报,为什么?
A: 通常是把空闲盘、U 盘、网络映射盘也纳入了。logical_disk 的 queue_length 对空闲盘长期为 0,但虚拟盘/只读盘会触发。用 mount 或 label 过滤掉 C: 之外的非关键盘,并给 queue_length 加阈值和 for 持续时长。
Q: Windows 时间跳变会影响监控吗?
A: 会,而且很隐蔽。wmi_exporter 采集依赖系统时钟,rate() 用时间差算速率,NTP 未同步导致时钟跳变会让 rate 瞬间飙高或变负,触发误报。先同步时间源,再用 windows_exporter 的 up 和系统运行时长排除故障。
Q: Grafana 有没有现成的 Windows 看板模板?
A: 有。Grafana 官方社区模板里搜 'Windows',常用的是 10467(Windows Node Exporter Dashboard)系列。导入后按 instance 选主机即可,但要注意它默认匹配的指标名是 node_*,用 wmi_exporter 时要改成 windows_* 前缀。
### Full Content
# Windows 服务器监控实战:wmi_exporter + Prometheus + Grafana 完整落地链路 + 5 个真实故障
> 适用场景:需要把 Windows 服务器(IIS、SQL Server、文件服务器、域控)接入 [Prometheus](https://prometheus.io/docs/) 监控体系的团队。一句话结论:Windows 接入的难点不在 Prometheus,而在 wmi_exporter 的 collector 开关、服务权限和 9182 端口连通性。
---
干运维这些年,我的 Prometheus 里清一色是 [Linux](https://www.kernel.org/doc/html/latest/) 节点,Windows 服务器一直是"监控黑户"。不是不想接,是每次查资料都被一句"装个 wmi_exporter 就行"打发,等真装完,发现端口不通、指标为空、告警乱报——**中文圈把 Windows 监控讲得太薄了,真正能落地的链路没人给全**。
这周我把三台 Windows 服务器(一台 IIS 应用、一台 SQL Server、一台文件服务器)接进了监控。踩了五个坑,全部记下来。这篇不是"安装教程",是"从零到告警生效"的完整链路,外加 5 个我亲手处理的真实故障。
## 一、为什么 Windows 是监控体系的"失管区"
先说痛点。Linux 有 node_exporter,文档多、教程全,接进 Prometheus 十分钟搞定。Windows 呢?wmi_exporter 能装,但**它依赖 WMI(Windows Management Instrumentation)**,WMI 查询是要吃系统资源的——不是你想开哪个 collector 就能开哪个。
更麻烦的是三点:
1. **Windows 防火墙默认拦入站**,9182 端口装上也不通
2. **服务账户权限**:wmi_exporter 默认用 LocalSystem,访问某些 WMI 类(比如 IIS、MSFT_* 类)会因权限不足返回空
3. **collector 开关全在注册表**,不是改个配置文件就完事,很多人装完默认状态跑,结果 process 类指标全没有
我见过最典型的翻车:同事装完 wmi_exporter,`curl localhost:9182/metrics` 有输出,高兴地加进 Prometheus,结果 [Grafana](https://grafana.com/docs/) 面板全是 No data——**因为看板模板要的 `windows_*` 指标,默认 collector 压根没采集**。安装完成只是 5%,剩下 95% 在下面。
## 二、wmi_exporter 与 collector 清单:哪些默认开、哪些有坑
wmi_exporter 的 collector 是按需加载的,**默认只开一小部分**。先记清楚这张表,后面所有配置都围绕它:
| Collector | 默认 | 采集内容 | 性能/权限坑 |
|---|---|---|---|
| cpu | 开 | CPU 各模式时间 | 无 |
| cs | 开 | 计算机系统(主机名、核数)| 无 |
| logical_disk | 开 | 磁盘空间、队列、读写时间 | **含空闲盘/虚拟盘,易误报** |
| net | 开 | 网络收发字节/包 | 无 |
| os | 开 | 内存可用、系统信息 | 无 |
| service | 开 | 服务运行状态 | **默认全量服务,量大** |
| system | 开 | 进程数、线程数、运行时长 | 无 |
| process | **关** | 按进程:句柄、线程、CPU | **高基数元凶,慎开** |
| iis | **关** | IIS 站点请求、流量 | **需管理员权限 + IIS 已装** |
| msft_* | **关** | Hyper-V/存储等 MSFT 类 | 需显式开启 + 权限 |
**关键认知**:`process` 和 `iis` 默认是关的。`process` 关是因为它按进程粒度输出几十个指标,一台 500 进程的服务器能产生上万时间序列,Prometheus 内存直接飙升(见故障五);`iis` 关是因为它要求管理员权限且有额外开销。**默认开的那 7 个够看主机健康,别一上来全开。**
## 三、安装方式一:MSI 安装 + 服务配置
MSI 是官方推荐的装法,服务由安装器自动注册。但很多人装完发现指标不全,因为**collector 要在安装时通过属性传进去**:
```powershell
# 下载后执行(0.16.0 为例,按实际版本改)
wmi_exporter-0.16.0-amd64.msi ^
ENABLED_COLLECTORS="cpu,cs,logical_disk,net,os,service,system" ^
IIS_ENABLED="true"
```
注意 `ENABLED_COLLECTORS` 是逗号分隔、**不含空格**。漏掉 `iis` 或 `process` 就是默认状态,后面指标缺一截。
装完验证服务与端口:
```powershell
sc.exe query wmi_exporter # 服务状态应为 RUNNING
Get-NetTCPConnection -LocalPort 9182 -State Listen # 应有一条 LISTEN
Invoke-WebRequest http://localhost:9182/metrics | Select-Object -First 5
```
如果服务没起或端口没监听,查两个地方:一是服务账户(默认 LocalSystem,别乱改),二是**注册表 collector 配置**:
```powershell
# 查看已启用的 collector(注册表是 wmi_exporter 的"配置文件")
reg query "HKLM\SOFTWARE\wmi_exporter" /v ENABLED_COLLECTORS
```
改 collector 后要重启服务才生效:
```powershell
reg add "HKLM\SOFTWARE\wmi_exporter" /v ENABLED_COLLECTORS /t REG_SZ ^
/d "cpu,cs,logical_disk,net,os,service,system,process" /f
Restart-Service wmi_exporter
```
## 四、安装方式二:zip 解压 + NSSM 自建服务
不想装 MSI、或要绿色部署时,用 zip 版 + NSSM。**zip 版不会自动建服务**,这是最大的坑——很多人解压直接跑 `wmi_exporter.exe`,一关窗口进程就没了。
```powershell
# 1. 解压到 C:\wmi_exporter,用 NSSM 注册为服务
C:\nssm\nssm.exe install wmi_exporter "C:\wmi_exporter\wmi_exporter.exe"
# 2. collector 通过启动参数传入(zip 版没有安装属性,只能走参数)
C:\nssm\nssm.exe set wmi_exporter AppParameters ^
"--collectors.enabled=cpu,cs,logical_disk,net,os,service,system"
# 3. 启动并设为开机自启
C:\nssm\nssm.exe start wmi_exporter
C:\nssm\nssm.exe set wmi_exporter Start SERVICE_AUTO_START
```
如果不想用 NSSM,`sc.exe create` 也能建,但 NSSM 的好处是**崩溃自动重启**,监控进程不能裸奔。用 `sc.exe` 建的话记得加 `binPath` 并配好恢复选项。
**一个对比结论**:批量部署建议 MSI(能用 GPO/脚本推、自带服务),单机或测试环境用 zip + NSSM(可控、可带参数)。两者采集能力完全一样,别纠结。
## 五、防火墙放行 9182:从本机通到远端通
本机 `curl localhost:9182` 通,不代表远端能通。**Windows 防火墙默认拦入站**,这是"装完不通"的第一大元凶:
```powershell
# 放行 9182 入站(按 TCP 端口)
netsh advfirewall firewall add rule name="wmi_exporter" ^
dir=in action=allow protocol=TCP localport=9182
# 放行指定程序(更精确,但注意 exe 路径)
netsh advfirewall firewall add rule name="wmi_exporter_exe" ^
dir=in action=allow program="C:\wmi_exporter\wmi_exporter.exe"
```
**两个坑**:一是如果机器在域环境,除了本机防火墙,可能还有**域防火墙策略**在中间拦;二是防火墙有"域/专用/公用"三个配置文件,规则要加到对应配置文件(`profile=domain,private`)。验证从 Prometheus 服务器侧:
```powershell
# 在 Prometheus 所在机器执行(换成 Windows 服务器 IP)
Test-NetConnection 192.168.1.10 -Port 9182
```
`TcpTestSucceeded : True` 才是真的通。不通就 `netsh advfirewall firewall show rule name="wmi_exporter"` 查规则是否在正确 profile。
## 六、Prometheus 抓取配置
连通后写进 `prometheus.yml`。Windows 和 Linux 的抓取大同小异,但**建议给 Windows 单独一个 job 并加标签**,方便区分:
```yaml
scrape_configs:
- job_name: windows-servers
scrape_interval: 15s
static_configs:
- targets:
- '192.168.1.10:9182' # IIS 应用
- '192.168.1.11:9182' # SQL Server
- '192.168.1.12:9182' # 文件服务器
labels:
os: 'windows'
env: 'prod'
relabel_configs:
# 把 IP 单独抽成 instance,方便 Grafana 按主机过滤
- source_labels: [__address__]
regex: '([^:]+):.*'
target_label: instance
replacement: '${1}'
```
改完 `prometheus.yml` 用 `promtool check config` 校验,再热加载:
```bash
promtool check config prometheus.yml
curl -X POST http://localhost:9090/-/reload
```
验证抓取是否成功:
```bash
# 确认 target 是 UP
curl 'http://localhost:9090/api/v1/targets' | grep -o '"health":"[a-z]*"' | sort | uniq -c
# 确认 windows_ 指标进来了
curl 'http://localhost:9090/api/v1/query?query=up{job="windows-servers"}'
```
## 七、Grafana 看板落地
指标进来了,接下来是看板。**别手搓,直接导入社区模板**,这是效率最高的路:
1. Grafana → Dashboards → Import → 输入模板 ID(搜 "Windows")
2. 选数据源(你的 Prometheus)
3. 右上角选 instance,切到对应 Windows 主机
**最大的坑**:社区多数 Windows 模板(如 10467)默认匹配 `node_*` 前缀,而 wmi_exporter 输出的是 `windows_*`。导入后如果面板 No data,去面板的 Query 里把 `node_` 批量替换成 `windows_`。我自己做的四个核心面板:
| 面板 | PromQL(替换前缀后) |
|---|---|
| CPU 使用率 | `100 - avg(rate(windows_cpu_time_total{mode="idle"}[5m])) * 100` |
| 内存可用 % | `windows_os_physical_memory_free_bytes / windows_os_visible_memory_bytes * 100` |
| 磁盘使用 % | `(windows_logical_disk_size_bytes - windows_logical_disk_free_bytes) / windows_logical_disk_size_bytes * 100` |
| 网络流量 | `sum(rate(windows_net_bytes_received_total[5m]) + rate(windows_net_bytes_sent_total[5m]))` |
## 八、Windows 关键指标表 + PromQL
监控 Windows,先分清"哪些指标真正反映故障"。附一张我用得最顺手的表,每行给 PromQL:
| 监控项 | 指标 | PromQL | 正常参考 |
|---|---|---|---|
| CPU | cpu_time_total | `100 - avg(rate(windows_cpu_time_total{mode="idle"}[5m])) * 100` | < 80% |
| 内存可用字节 | os_physical_memory_free_bytes | `windows_os_physical_memory_free_bytes` | > 总内存 20% |
| 磁盘队列 | logical_disk_queue_length | `windows_logical_disk_queue_length` | 持续 > 2 警惕 |
| 磁盘忙时比 | logical_disk_*_seconds_total | `avg(rate(windows_logical_disk_read_seconds_total[5m]) + rate(windows_logical_disk_write_seconds_total[5m]))` | < 80% |
| 句柄数 | process_handles | `sum(windows_process_handles)` | 看趋势 |
| 进程数 | system_processes | `windows_system_processes` | 看趋势 |
| 网络流量 | net_bytes_*_total | `rate(windows_net_bytes_received_total[5m]) + rate(windows_net_bytes_sent_total[5m])` | 按带宽 |
**一个关键提醒**:Windows 没有 Linux 那种"load average",取而代之的是 `windows_system_processor_queue_length`(处理器队列长度)。**队列长度持续超过核数,才是 Windows 的"CPU 排队"信号**,别拿 Linux 的 load 套。
## 九、告警规则:Windows 专用 PromQL
告警规则和 Linux 类似,但**阈值和采集口径要按 Windows 调**。尤其磁盘,Windows 的 `logical_disk` 会包含虚拟盘、只读盘,直接 `queue_length > 2` 会误报(见故障四):
```yaml
groups:
- name: windows-server
rules:
- alert: WindowsCpuHigh
expr: 100 - avg(rate(windows_cpu_time_total{mode="idle"}[5m])) * 100 > 90
for: 5m
labels: { severity: critical }
annotations:
summary: "Windows CPU 过高 {{ $labels.instance }}"
- alert: WindowsMemoryLow
expr: windows_os_physical_memory_free_bytes / windows_os_visible_memory_bytes * 100 < 10
for: 10m
labels: { severity: warning }
annotations:
summary: "Windows 内存可用低于 10% {{ $labels.instance }}"
- alert: WindowsDiskQueueHigh
expr: |
windows_logical_disk_queue_length{volume=~"C:.*"}
and on(instance, volume)
windows_logical_disk_idle_seconds_total{volume=~"C:.*"} == 0
for: 5m
labels: { severity: warning }
annotations:
summary: "C 盘磁盘队列过长 {{ $labels.instance }}"
- alert: WindowsServiceDown
expr: windows_service_state{state="stopped"} == 1
for: 3m
labels: { severity: critical }
annotations:
summary: "Windows 服务已停止 {{ $labels.service }}"
```
**注意磁盘那条**:`volume=~"C:.*"` 先过滤只监控 C 盘,`idle_seconds_total == 0` 表示盘忙。两个条件用 `and` 连,避免空闲盘或只读盘的假队列触发告警——这是我被误报折磨后的血泪总结。
## 十、5 个真实故障案例
### 故障一:MSI 装完,9182 端口不通
**现象**:MSI 安装显示成功,`sc.exe query wmi_exporter` 是 RUNNING,但 Prometheus 抓不到,`Test-NetConnection -Port 9182` 返回 False。
**排查链**:查防火墙规则 → 无入站规则;查服务 → 正常;查进程监听 → `Get-NetTCPConnection` 无 9182 LISTEN。
**根因**:**Windows 防火墙默认没有放行 9182**。MSI 安装器不会自动加防火墙规则,入站被拦。
**修复**:加 `netsh advfirewall` 规则(见第五节),`Test-NetConnection` 变 True。
**经验**:**Windows 装完任何监听端口服务,第一件事永远先放行防火墙**,别急着改 Prometheus。
### 故障二:防火墙放行了,远端还是不通
**现象**:本机 `curl localhost:9182` 通,加完防火墙规则后,从 Prometheus 机器 `Test-NetConnection` 仍 False。
**排查链**:查本机防火墙 profile → 规则加到了 domain,但当前网络是 private;再查域策略 → 上级 GPO 有一条阻止 9182。
**根因**:**域环境的防火墙是"双层"**。本机规则配对了,但域组策略在域控层又拦了一道;且防火墙规则默认只对当前 profile 生效,网络配置文件切换后规则不匹配。
**修复**:把规则同时加到 `profile=domain,private`,并协调域管理员在 GPO 里放行,或改走 HTTPS/代理。
**经验**:**域内 Windows,先确认"网络配置文件是哪个"**(`Get-NetConnectionProfile` 看 NetworkCategory),再决定规则加哪个 profile。
### 故障三:Windows 时间跳变,rate() 飙高误报
**现象**:凌晨告警系统突然收到 CPU 100% 告警,但 `windows_cpu_time_total` 看值正常,告警却持续。
**排查链**:查 `rate()` 计算 → 时间差为负;查系统时钟 → 与 NTP 相差 3 分钟;查告警触发时间 → 恰在时间校正后。
**根因**:**Windows 时钟未同步,NTP 校正时时间跳变**。Prometheus 的 `rate()` 用时间戳差值算速率,时钟跳变会让采样间隔异常,`rate()` 瞬间虚高甚至为负,触发误报。
**修复**:配置可靠 NTP 源并启用同步(`w32tm /config /manualpeerlist:"ntp.aliyun.com" /syncfromflags:manual /reliable:yes`,`w32tm /resync`),告警规则加 `for: 5m` 缓解瞬时跳变。
**经验**:**Windows 服务器时间不同步是"隐性监控杀手"**。加监控前先 `w32tm /query /status` 确认时钟正常,否则 rate 类指标全不可信。
### 故障四:磁盘队列告警天天误报
**现象**:`WindowsDiskQueueHigh` 告警每天触发,但 C 盘 `queue_length` 看其实不高,业务也无异常。
**排查链**:看告警时的 `queue_length` 值 → 来自 D 盘(备份盘);看该盘 `idle_seconds_total` → 长期为 0;看挂载类型 → 是网络映射盘。
**根因**:**logical_disk collector 默认扫全部盘,包括网络映射、只读、虚拟盘**。这些盘的队列指标长期异常,而原始告警没过滤盘符,把非系统盘也纳进来了。
**修复**:告警只对 `C:.*`(或业务盘)生效(见第九节),并在 Grafana 面板上 `regex` 过滤掉映射盘。
**经验**:**Windows 磁盘监控必须先"选盘"再"设阈值"**,别默认全盘告警。Linux 可以全盘,Windows 不行——盘太多了。
### 故障五:process collector 开启后 Prometheus 内存暴涨
**现象**:为了排查某进程内存,我给文件服务器开启了 `process` collector,第二天 Prometheus 容器内存从 2G 涨到 9G,告警查询明显变慢。
**排查链**:查 Prometheus 内存 → `rate(process_resident_memory_bytes)` 持续涨;查该服务器 `windows_process_handles` 序列数 → 几万条;看进程数 → 500+ 进程。
**根因**:**process collector 是"高基数"重灾区**。一台 500 进程的服务器,每个进程输出句柄、线程、工作集等几十个指标,瞬时产生数万时间序列。Prometheus 内存随序列数线性增长,直接被打爆。
**修复**:临时排查完立刻关闭 process collector,恢复默认 7 个;若必须长期开,用 `--collectors.process.include` 只匹配关键进程(如 `sqlservr,msftesql`),把序列数压下来。
**经验**:**任何"按进程/按标签"的 collector 都要评估基数**。Windows 进程多,process 类指标是"能不开就不开、要开必须过滤"的典型。
## FAQ
**Q1:wmi_exporter 的 MSI 装完 9182 端口不通,最常见原因是什么?**
A1:八成是防火墙。9182 是 TCP 入站,Windows 防火墙默认拦截,MSI 不会自动放行,需手动 `netsh advfirewall` 加规则。其次是服务没起——查 `sc.exe query wmi_exporter`,若 RUNNING 但无 9182 监听,再查注册表 `ENABLED_COLLECTORS` 是否配置完整。
**Q2:process collector 要不要默认开启?**
A2:不建议。它按进程粒度输出几十个指标,一台几百进程的服务器会产生数万时间序列,Prometheus 内存暴涨、查询变慢。只在定位单进程时临时开,长期开必须用 `--collectors.process.include` 过滤关键进程。
**Q3:磁盘队列告警老误报,为什么?**
A3:logical_disk 默认扫全部盘,含网络映射、只读、虚拟盘。用 `volume=~"C:.*"` 过滤只监控业务盘,并配合 `idle_seconds_total == 0`(盘忙)做 and 条件,再加 `for: 5m` 持续时长,误报基本消除。
**Q4:Windows 时间跳变会影响监控吗?**
A4:会,且隐蔽。wmi_exporter 采集依赖系统时钟,`rate()` 用时间差算速率,NTP 未同步导致时钟跳变会让 rate 瞬间飙高或为负,触发误报。先用 `w32tm /query /status` 确认同步,加 `for` 持续时长缓解瞬时跳变。
**Q5:Grafana 有没有现成的 Windows 看板?**
A5:有。官方模板搜 "Windows",常用 10467。但多数模板默认匹配 `node_*` 前缀,wmi_exporter 输出 `windows_*`,导入后要在 Query 里批量替换前缀,否则面板 No data。
---
## 相关文章
- [Prometheus 查询实战:PromQL 聚合、速率与告警规则编写](prometheus-query.md)
- [Prometheus 告警实战:Alertmanager 分组、抑制与路由](prometheus-alerting.md)
- [node_exporter 排障:指标缺失、高基数与性能问题](node-exporter-troubleshoot.md)
---
## Zabbix + Prometheus 混合监控架构:传统基础设施 + 云原生服务的双栈方案
URL: https://ops.jiunotes.top/monitor/zabbix-prometheus-hybrid.md
摘要: 生产环境 Zabbix + Prometheus 双栈监控:Zabbix 管物理机/网络设备/SNMP,Prometheus 管 K8s/微服务,Grafana 统一展示。含架构设计、数据互通、告警收敛、迁移路线四个实战章节。
### Answer
混合监控的核心原则:Zabbix 管传统(物理机、交换机、路由器、SNMP),Prometheus 管云原生(K8s、微服务、容器)。
统一出口靠 Grafana 多数据源 + Alertmanager + Webhook 转发。不要试图一个工具管所有,分层才是正解。
### FAQ
Q: 为什么不全用 Prometheus,Zabbix 留着干嘛?
A: Prometheus 的 Pull 模型要求目标暴露 HTTP 端点,交换机、路由器、旧设备大多只支持 SNMP。 Zabbix 的 SNMP Agent 和主动检查能覆盖这些"哑设备"。分层比堆砌更省钱。
Q: 两套监控数据怎么汇总到一个看板?
A: Grafana 可以同时添加 Prometheus 和 Zabbix 数据源,一个 Dashboard 里混用两种查询。 但注意:Zabbix 数据在 Grafana 里是静态快照,不支持 PromQL 式的实时聚合分析。
Q: 告警风暴怎么处理?
A: 核心思路:Prometheus Alertmanager 管云原生告警,Zabbix 管传统告警, 两边都用 Webhook 转发到同一个接收器(企业微信/钉钉),按 severity 合并路由。 关键是 group_by 维度要对齐,不然同一故障会在两个系统里报两次。
Q: 从 Zabbix 单栈迁移到双栈要注意什么?
A: 三步走:① 先并行跑两套,不对齐数据;② 新上线的 K8s 服务全部走 Prometheus; ③ 旧物理机保留 Zabbix,逐步替换。别一次性切换,生产环境经不起这种赌博。
### Full Content
# Zabbix + Prometheus 混合监控架构:传统基础设施 + 云原生服务的双栈方案
2024 年底我们完成了从 Zabbix 单栈到 Zabbix + [Prometheus](https://prometheus.io/docs/) 双栈的迁移。原因很简单:Zabbix 管传统基础设施很稳,但 Prometheus 在 [K8s](https://kubernetes.io/docs/) 动态环境里是天生的。两个工具各管一段,[Grafana](https://grafana.com/docs/) 统一展示,告警收敛到一个出口。这篇把整个架构设计和迁移踩坑过程写下来。
## 为什么需要双栈
单一监控系统在以下场景会显得力不从心:
| 场景 | Zabbix 的短板 | Prometheus 的短板 |
|---|---|---|
| 动态 K8s 集群 | 静态配置,服务发现困难 | 擅长,原生 Service Discovery |
| 网络设备监控 | 擅长 SNMP | 需要 exporter,覆盖不全 |
| 高基数指标 | 关系库膨胀严重 | 高基数标签会撑爆内存 |
| 长期数据存储 | 擅长,SQL backed | 需要 Thanos/Cortex 扩展 |
| 告警规则 | 触发器语法老旧 | PromQL 灵活,但学习曲线陡 |
**结论**:传统设施用 Zabbix,云原生服务用 Prometheus,不要试图一个工具吃全部。
## 架构设计
```
┌─────────────────────────────────────────────────────────────┐
│ Grafana (统一展示) │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
│ │ 基础设施看板 │ │ 云原生看板 │ │ 告警收敛看板 │ │
│ └──────┬──────┘ └──────┬──────┘ └──────────┬──────────┘ │
└─────────┼────────────────┼────────────────────┼─────────────┘
│ │ │
┌─────▼─────┐ ┌─────▼─────┐ ┌─────▼─────┐
│ Zabbix │ │Prometheus │ │ Alertman. │
│ Server │ │ + │ │ │
│ (传统) │ │ Alerts │ │ 统一路由 │
└─────┬─────┘ └─────┬─────┘ └─────┬─────┘
│ │ │
┌─────▼─────┐ ┌─────▼─────┐ ┌─────▼─────┐
│ Agent/ │ │ K8s │ │ 企微/ │
│ SNMP │ │ Service │ │ 钉钉 │
│ 设备 │ │ Discover │ │ Webhook │
└───────────┘ └───────────┘ └───────────┘
```
**关键设计点**:
1. **数据源分离**:Zabbix 只管 SNMP/Agent/主动检查,Prometheus 只管 HTTP Exporter/Service Discovery
2. **统一展示**:Grafana 同时连接 Zabbix 和 Prometheus 数据源
3. **告警收敛**:Alertmanager 和 Zabbix Action 都转发到同一个 Webhook,按 severity 合并路由
## Zabbix 侧配置要点
### 1. 保留传统监控
```
已迁移到 Prometheus 的:
- Kubernetes 集群内的 Pod/Meta
- 微服务 HTTP 指标(/metrics 端点)
- 容器资源指标(cAdvisor)
保留在 Zabbix 的:
- 物理服务器(CPU/内存/磁盘)
- 网络设备(交换机/路由器 SNMP)
- 传统数据库(MySQL/PG 慢查询)
- 打印机、UPS、空调等 IoT 设备
```
### 2. Zabbix Agent 2 配置
```yaml
# zabbix_agent2.conf
Server: 10.0.0.5
ServerActive: 10.0.0.5
Hostname: physical-server-01
# 启用 SNMP 模块监控网络设备
LoadModule: snmp.so
# 启用 Prometheus exporter 做双向桥接
LoadModule: prometheus.so
```
### 3. Zabbix 告警 Webhook 转发
```json
// Zabbix Media Type - Webhook 配置
{
"url": "http://alert-router:8080/zabbix",
"method": "POST",
"body": {
"source": "zabbix",
"host": "{HOST.NAME}",
"item": "{ITEM.NAME}",
"value": "{ITEM.LASTVALUE}",
"severity": "{TRIGGER.SEVERITY}"
}
}
```
## Prometheus 侧配置要点
### 1. K8s Service Discovery
```yaml
# prometheus.yml
scrape_configs:
- job_name: 'kubernetes-pods'
kubernetes_sd_configs:
- role: pod
relabel_configs:
- source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_scrape]
action: keep
regex: true
- source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_path]
action: replace
target_label: __metrics_path__
regex: (.+)
- job_name: 'kubernetes-services'
kubernetes_sd_configs:
- role: service
```
### 2. 传统 Exporter 补充
```yaml
# 覆盖 Zabbix 管不了的部分
scrape_configs:
- job_name: 'node-exporter'
static_configs:
- targets: ['node-exporter:9100']
- job_name: 'blackbox'
static_configs:
- targets: ['https://example.com', 'https://api.example.com']
```
### 3. 告警路由收敛
```yaml
# alertmanager.yml
route:
receiver: 'unified-webhook'
group_by: ['alertname', 'cluster', 'service']
routes:
- matchers:
- source = prometheus
receiver: 'p0-oncall'
repeat_interval: 30m
receivers:
- name: 'unified-webhook'
webhook_configs:
- url: 'http://alert-router:8080/alertmanager'
- name: 'p0-oncall'
webhook_configs:
- url: 'http://alert-router:8080/p0'
```
## Grafana 统一展示
### 1. 添加数据源
```
Settings → Data Sources → Add data source
Prometheus:
URL: http://prometheus:9090
Access: Proxy
Zabbix:
URL: http://zabbix:80
JSON RPC API
Username/Password: Admin/zabbix
```
### 2. 混用 Dashboard
```
// Prometheus 查询(云原生)
up{job="kubernetes-pods"} == 0
// Zabbix 查询(传统)
last(/Zabbix server/uptime)
```
### 3. 统一告警看板
创建专门的"告警收敛"Dashboard,同时展示 Prometheus 和 Zabbix 的活跃告警:
```yaml
# Prometheus 面板
title: "Prometheus 活跃告警"
targets:
- expr: ALERTS{alertstate="firing"}
format: table
# Zabbix 面板
title: "Zabbix 活跃触发器"
datasource: Zabbix
items:
- host: "All"
filter:
value: ">0"
```
## 告警收敛实战
### 问题:同一故障报两次
**现象**:服务器宕机,Zabbix 报 SNMP 不可达,Prometheus 报 Node Exporter 失联。
**根因**:两个系统独立告警,没有关联。
**解决方案**:
```yaml
# Alertmanager 抑制规则:Zabbix 核心主机告警时,抑制 Prometheus 同主机告警
inhibit_rules:
- source_matchers:
- source = zabbix
- severity = critical
target_matchers:
- source = prometheus
equal: ['instance']
```
### 问题:告警风暴
**现象**:Zabbix 和 Prometheus 同时告警,微信群半小时刷 500 条。
**解决方案**:
```yaml
# 统一的 group_by 维度
group_by: ['alertname', 'infrastructure', 'service']
group_wait: 10s
group_interval: 5m
repeat_interval: 4h
# 按来源分级
routes:
- matchers:
- severity = critical
receiver: 'p0-oncall'
- matchers:
- source = zabbix
receiver: 'zabbix-wechat'
- matchers:
- source = prometheus
receiver: 'prometheus-wechat'
```
## 迁移路线(四步走)
### 第一步:并行运行(第 1-2 周)
- Zabbix 继续管所有设备
- Prometheus 只采集新上线的 K8s 服务
- 两边独立告警,不影响生产
- 目标是验证 Prometheus 数据采集正常
### 第二步:Grafana 统一展示(第 3-4 周)
- Grafana 同时连接两个数据源
- 创建混合 Dashboard
- 团队习惯在新看板里查数据
- 旧 Zabbix UI 逐渐停用
### 第三步:告警收敛(第 5-6 周)
- 配置 Alertmanager 抑制规则
- 配置 Zabbix Webhook 转发
- 统一到企业微信/钉钉
- 验证告警不重复、不漏报
### 第四步:逐步迁移(第 7-8 周)
- 新上线服务一律走 Prometheus
- 旧物理机根据情况决定留 Zabbix 还是迁 Prometheus
- 网络设备继续 Zabbix(SNMP 是唯一选择)
- 保留 Zabbix 作为"保底",不急于删
## 翻车案例
### 案例 1:Zabbix 高基数标签爆炸
**现象**:Zabbix 导入 Prometheus 数据后,关系库每秒写入 10 万行,查询超时。
**根因**:Prometheus 的标签模型不适合 Zabbix 的关系库设计。
**教训**:Zabbix 只导入聚合后的指标(CPU 平均、内存总量),不导入原始的高基数时序数据。
### 案例 2:Grafana Zabbix 插件数据延迟
**现象**:Zabbix 数据在 Grafana 里显示有 5 分钟延迟。
**根因**:Zabbix API 默认缓存 300 秒,且查询是轮询模式。
**教训**:监控实时性要求高的场景(如故障定位),必须用 Prometheus,Zabbix 只用于历史趋势和容量规划。
### 案例 3:两套系统时间不同步
**现象**:Grafana 里 Prometheus 和 Zabbix 的时间轴对不上。
**根因**:两台服务器 NTP 不同步,时差 30 秒。
**教训**:所有监控服务器必须严格同步 NTP,偏差超过 1 秒就会导致关联分析出错。
## 架构选型决策树
```
你要监控什么?
│
├─ K8s 集群 / 微服务 / 容器
│ └─ 直接用 Prometheus + Alertmanager
│
├─ 物理服务器 / 虚拟机
│ ├─ 有 /metrics 端点 → Prometheus Node Exporter
│ └─ 只有 SNMP / 无 Exporter → Zabbix Agent
│
├─ 网络设备(交换机/路由器)
│ └─ 只用 Zabbix(SNMP 是唯一选择)
│
├─ 传统数据库(MySQL/PG)
│ ├─ 新版本有 exporter → Prometheus
│ └─ 老版本 → Zabbix DB 监控模板
│
└─ IoT 设备 / 打印机 / UPS
└─ Zabbix(SNMP/Modbus)
```
## 双栈上线后的巡检与一致性纪律
双栈最大的隐患不是「装不起来」,而是两套系统各自腐化、数据对不上、告警漂移。我们这套双栈跑了快一年,靠下面几条纪律维持一致性。
### 数据一致性要定期对账
最反直觉的一点:两套监控对同一台物理机,Zabbix 和 Prometheus(node_exporter)读数可能长期不一致——采集周期不同、agent 口径不同、甚至 NTP 没对齐(翻车案例三)。我们每月做一次「抽样对账」:挑 20 台核心物理机,把 Zabbix 的 CPU/内存/磁盘读数和 Prometheus node_exporter 的同名指标拉出来对比,偏差超过 10% 的就查根因(采集周期、agent 版本、时间同步)。不定期对账,两套系统会慢慢漂移,等你发现「Zabbix 说 80%、Prometheus 说 35%」时就很难判断该信哪个。
### 告警收敛的降噪要常调
「告警不重复」上线那天调好 ≠ 一直好。团队加服务、加告警规则后,抑制规则和 group_by 维度会渐渐失效,重复告警和告警风暴会重新冒出来。我们每周拉一次「告警收敛看板」统计:① 同一故障是否还在两个系统里重复报(抑制规则漏配了新的 alertname);② 群消息量有没有异常上涨(新规则没进 group_by)。任何一周告警量环比涨 50% 以上就复查路由配置。这条把「告警越来越吵」从用户投诉前移到每周对账。
### 两套系统自身也要被监控
监控系统自己挂了是最致命的。我们对 Zabbix Server 和 Prometheus **各自**配了健康告警:Zabbix 侧看 `queue` 积压和 housekeeper 删除量,Prometheus 侧看 `up` 指标、TSDB 磁盘水位、`prometheus_tsdb_wal_corruptions_total`。两条都走统一 Webhook,但**来源标注清楚**(source=zabbix / source=prometheus),避免「监控系统挂了」这种最高级告警反而被另一套系统的告警淹掉。我们还有一条铁律:监控告警通道本身(那个企业微信 Webhook)要有独立的存活探针,通道断了单独报,不能依赖它自己报。
### 巡检清单(每月)
- **数据对账**:20 台核心物理机,Zabbix vs Prometheus 偏差 > 10% 的逐条查根因。
- **告警降噪核对**:重复告警数、群消息量环比,异常就调抑制规则。
- **双栈自身健康**:Zabbix queue/housekeeper、Prometheus up/磁盘/WAL 损坏指标。
- **NTP 对齐**:所有监控服务器时间偏差 < 1s(这是时间轴对不上的根因,翻车案例三)。
- **迁移进度**:核对新上线服务是否都走了 Prometheus、网络设备是否仍在 Zabbix,防止「该分的没分、该并的没并」。
## 写在最后
混合监控不是"选 A 还是选 B"的问题,而是"谁干什么"的分层问题。Zabbix 和 Prometheus 不是竞争对手,是互补关系。
**核心原则**:
1. 能分层就别堆砌——传统归传统,云原生归云原生
2. 统一出口是关键——Grafana + 统一 Webhook
3. 告警收敛是难点——抑制规则 + 重复维度 group_by
4. 迁移要渐进——并行 → 统一展示 → 收敛 → 迁移,别一步到位
监控系统的终极目标不是"装了多少工具",而是"故障发生时能不能快速定位"。工具只是手段,分层架构才是正道。
## 相关文章
- [Zabbix 7 Docker 部署 + Prometheus 选型决策:什么时候该选哪个](zabbix-setup.md)
- [Grafana 部署与仪表盘搭建:从数据源接入到变量联动的六步路径](grafana-dashboard.md)
- [Alertmanager 告警规则配置:路由树 + Silence/Inhibit + 微信钉钉 Webhook 六坑指南](prometheus-alerting.md)
---
## Zabbix 7 Docker 部署 + Prometheus 选型决策:什么时候该选哪个
URL: https://ops.jiunotes.top/monitor/zabbix-setup.md
摘要: Zabbix 7.0 Docker Compose 一步部署 Server+Web+MySQL+Agent2,附选型决策树:Prometheus vs Zabbix 五种场景怎么选。含 3 个部署踩坑、PromQL 到 Zabbix 告警语法的迁移对比。
### Answer
Zabbix 7 Docker 部署四件套:MySQL + Zabbix Server + Zabbix Web + Zabbix Agent2,一个 Compose 文件起。
选型原则:Windows/传统基础设施/大规模主机(> 1000 台)选 Zabbix;云原生/Go 服务/团队会 PromQL 选 Prometheus。
两套不冲突,可以并存,Zabbix 兜底基础设施,Prometheus 管 K8s。
### FAQ
Q: Zabbix 7 比 Zabbix 6 有什么大变化?
a: 7.0 是 LTS 分支(2024 年发布),主要变化是 Agent2 内置支持、支持自定义 item key、告警规则重写引擎、Web UI 现代化。老项目建议直接跳到 7.0,中间版跳过去。
A:
Q: Zabbix 和 Prometheus 能共存吗?
a: 完全可以。生产环境常见架构:Zabbix 抓物理机/Windows/网络设备,Prometheus 抓 K8s/容器/Golang 服务。中间可以用 Prometheus exporter for Zabbix 或 Zabbix 的 Prometheus exporter 互导。
A:
Q: Zabbix Agent2 比 Agent 有什么优势?
a: Agent2 是 Go 写的,支持自定义 item key(用户自己用 Go/Python 写插件扩展监控项),内存占用更低。Agent1 是 C 写的,扩展能力弱但更稳定。新项目默认选 Agent2。
A:
Q: 已经有 Prometheus 了还需要 Zabbix 吗?
a: 有 Windows 或大量物理机 → 需要 Zabbix,Prometheus 对 Windows 监控生态弱。纯云原生 Linux/K8s 栈 → Prometheus 就够,不用 Zabbix。混合环境两套共存不亏。
A:
Q: Docker Compose 里 `depends_on` 不管服务就绪,怎么解决?
a: 我第一次部署时 Web UI 一直报数据库连不上,查了半天发现 `depends_on` 只保证启动顺序,MySQL 没真正 ready 时 Zabbix Server 就开始连了。
修复方法:给 MySQL 加 healthcheck,改成 `depends_on: mysql: condition: service_healthy`。
也可以在 docker-compose.yml 里加 `wait-for-it` 脚本,或者手动等 MySQL 就绪再 `docker compose up`。
另一个容易踩的坑:Zabbix Web 装完后还要在 UI 里单独设置时区,光在 compose 里写 `TZ: Asia/Shanghai` 不够,
历史数据查询时区还是会错乱。
A:
### Full Content
# Zabbix 7 Docker 部署 + Prometheus 选型决策:什么时候该选哪个
Zabbix 是老牌监控工具,装了 15 年,覆盖从物理机、Windows、网络设备到中间件的监控项。但很多团队一听"监控"就直接跳 [Prometheus](https://prometheus.io/docs/),忽略了 Zabbix 在传统基础设施场景的不可替代性。
这篇文章讲两件事:**用 [Docker](https://docs.docker.com/) Compose 快速起 Zabbix 7.0**(含 Server + Web + MySQL + Agent2),以及**Prometheus vs Zabbix 的选型决策**——不是"哪个更好",而是"你的场景该选哪个"。
## 60 秒选型:Prometheus vs Zabbix
| 维度 | Prometheus | Zabbix |
|---|---|---|
| 数据模型 | pull(拉取)| push(推送)|
| 查询语言 | PromQL(强大但复杂)| 表达式(简单但弱)|
| 主要场景 | 云原生、[K8s](https://kubernetes.io/docs/)、Go/Rust 服务 | Windows、物理机、网络设备 |
| 大规模 | 单节点 < 500 万时间序列 | 单节点 10 万+ 主机 |
| Agent | exporter(多语言) | Agent2(Go,跨平台)|
| 告警规则 | PromQL 表达式 | 触发器(更直觉)|
| 内置 UI | 无(要接 [Grafana](https://grafana.com/docs/)) | 有(Web UI 完整)|
| 云原生支持 | 极强 | 中(靠 Agent2) |
| 传统基础设施 | 弱(无原生 Windows Agent)| 强(Zabbix Agent 支持 Win)|
## 决策树:五问定位
**Q1:有 Windows 服务器或物理机吗?**
- **有** → Zabbix 打底,Prometheus 可选
- **纯 [Linux](https://www.kernel.org/doc/html/latest/) + 云原生** → 继续 Q2
**Q2:团队会 PromQL 吗?**
- **会** → Prometheus 打底
- **不会/不愿意学** → Zabbix(Web UI 更傻瓜)
**Q3:K8s 集群规模?**
- **> 50 个节点** → Prometheus 必须(pull 模型更适合动态 target)
- **< 20 个节点** → 看 Q1/Q2
**Q4:告警要复杂吗?**
- **多条件、时间窗口、依赖规则** → Prometheus + Alertmanager(规则引擎更强大)
- **简单阈值告警** → Zabbix 自带 UI 就够
**Q5:预算?**
- **要省** → 都是开源,一样免费
- **要少运维** → Prometheus(单二进制)+ Grafana + Alertmanager 三件套 vs Zabbix 五件套(含 MySQL)
**决策原则**:
- 混合环境(Windows + Linux + K8s)→ 两套共存,Zabbix 管基础设施、Prometheus 管 K8s
- 纯云原生 → Prometheus
- 传统机房 → Zabbix
## Zabbix 7 Docker 部署:四件套 Compose
### 一、写 docker-compose.yml
```yaml
# zabbix-compose.yml
services:
mysql:
image: mysql:8.0
container_name: zbx-mysql
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: zbx_root_2024
MYSQL_DATABASE: zabbix
MYSQL_USER: zabbix
MYSQL_PASSWORD: zbx_app_2024
TZ: Asia/Shanghai
volumes:
- zbx-mysql:/var/lib/mysql
command:
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_bin
- --innodb-buffer-pool-size=512M
ports:
- "3306:3306"
server:
image: zabbix/zabbix-server-mysql:7.0-latest
container_name: zbx-server
restart: unless-stopped
depends_on:
- mysql
environment:
MYSQL_HOST: mysql
MYSQL_DATABASE: zabbix
MYSQL_USER: zabbix
MYSQL_PASSWORD: zbx_app_2024
MYSQL_PORT: 3306
ZBX_SERVER_NAME: "Zabbix-7"
ZBX_SERVER_PORT: 10051
ZBX_SERVER_LOG_LEVEL: 3
TZ: Asia/Shanghai
ports:
- "10051:10051" # Agent 通信
- "10052:10052" # IPMI(可选)
volumes:
- zbx-server:/var/lib/zabbix
web:
image: zabbix/zabbix-web-[nginx](https://nginx.org/en/docs/)-mysql:7.0-latest
container_name: zbx-web
restart: unless-stopped
depends_on:
- server
- mysql
environment:
MYSQL_HOST: mysql
MYSQL_DATABASE: zabbix
MYSQL_USER: zabbix
MYSQL_PASSWORD: zbx_app_2024
MYSQL_PORT: 3306
PHP_DATE_DEFAULT_timezone: Asia/Shanghai
ports:
- "8080:80"
agent2:
image: zabbix/zabbix-agent2:7.0-latest
container_name: zbx-agent2
restart: unless-stopped
depends_on:
- server
environment:
ZBX_HOSTNAME: zbx-server-local
volumes:
- /proc:/host/proc:ro
- /sys:/host/sys:ro
- /:/host/root:ro
volumes:
zbx-mysql:
zbx-server:
```
**关键点**:
- MySQL 8.0 是 Zabbix 7 官方推荐
- `ZBX_SERVER_LOG_LEVEL=3` 是 debug,生产改 4(info)
- Agent2 挂载 `/proc`、`/sys` 用于采集主机指标
- 生产建议用 TLS,把 `8080` 换成 `443` + cert
### 二、启动 + 初始化
```bash
# 启动全部
docker compose -f zabbix-compose.yml up -d
# 看服务状态
docker compose ps
# 看 MySQL 就绪
docker logs zbx-mysql | tail -20
# 看 Zabbix Server 就绪
docker logs zbx-server | grep -iE 'ready|starting|error' | tail -20
```
**你应该看到什么**:
- `docker compose ps` 四个服务都是 `running` 状态
- Zabbix Server 日志有 `Server started` 且**没有** `Cannot create database` 之类报错
- 浏览器打开 `http://