Elastic Stack、Loki 与云日志平台工具手册
那条明明打印过的日志去了哪里
凌晨的发布验证里,接口返回了 500,应用容器也确实打印过 payment timeout。值班同学打开日志平台,却得到三种互相矛盾的答案:Elastic Discover 里没有这个字段,Loki 按 trace_id 查询几分钟仍没结果,云控制台则提示没有权限。此时继续换关键字没有意义,先要判断事件停在了哪一层。
一条集中日志至少经历六次状态变化:应用生成事件,采集器发现文件或标准输出,处理管道解析与脱敏,本地队列批量发送,后端把它写入索引或对象存储,查询入口再依据时间、租户和权限返回结果。curl /ready 只证明进程活着;页面能打开也只证明 UI 活着。真正的验证必须给一条测试事件固定身份,并在每一层找证据。
下面统一使用不含真实用户数据的 JSONL 测试事件:
{"@timestamp":"<EVENT_TIMESTAMP>","service":"checkout","environment":"dev","level":"ERROR","trace_id":"TE-LOG-001","duration_ms":812,"message":"payment timeout"}在第一次启动容器前,机器需要 Docker Engine 与 Compose v2,Linux 容器至少准备 4 GiB 可用内存和足够的临时磁盘。Windows 开发机可使用 Docker Desktop 的 Linux 容器模式或 WSL 2。示例端口只绑定 127.0.0.1,而且只允许放合成日志;真实凭证、Cookie、Authorization、请求体、手机号和连接串必须先在应用或采集器侧删除,不能指望查询页面替你打码。
Elastic Stack:先让 data stream 真正落下一条事件
Elastic 的核心优势不是“能搜文本”,而是把日志解析成有类型的 document,并对字段做倒排索引、doc values 和聚合。代价也从这里产生:字段越多、分片越碎、保留越久,堆内存、磁盘和集群状态就越重。
开发环境可以用 Elasticsearch、Kibana 和 Logstash 组成一条完整链路。三个镜像必须锁定同一 Stack 版本;升级时也要先阅读目标版本的升级说明和插件兼容性,不要让 latest 在重启后替你完成一次不可逆迁移。新建空目录 elastic-log-lab,写入以下 compose.yaml:
services:
elasticsearch:
image: docker.elastic.co/elasticsearch/elasticsearch:9.4.2
environment:
discovery.type: single-node
xpack.security.enabled: "false"
ES_JAVA_OPTS: "-Xms1g -Xmx1g"
ports:
- "127.0.0.1:9200:9200"
volumes:
- es-data:/usr/share/elasticsearch/data
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://localhost:9200/_cluster/health >/dev/null"]
interval: 10s
timeout: 5s
retries: 30
kibana:
image: docker.elastic.co/kibana/kibana:9.4.2
environment:
ELASTICSEARCH_HOSTS: "http://elasticsearch:9200"
ports:
- "127.0.0.1:5601:5601"
depends_on:
elasticsearch:
condition: service_healthy
logstash:
image: docker.elastic.co/logstash/logstash:9.4.2
environment:
XPACK_MONITORING_ENABLED: "false"
volumes:
- ./pipeline:/usr/share/logstash/pipeline:ro
- ./logs:/logs:ro
- ls-data:/usr/share/logstash/data
depends_on:
elasticsearch:
condition: service_healthy
volumes:
es-data:
ls-data:这里故意关闭了认证,因为端口仅绑定回环地址,目标是观察数据模型而不是复制生产安全配置。只要端口需要跨主机访问,就应恢复 TLS 与认证,使用 CA 校验证书,并给采集器签发仅能写目标 data stream 的 API key;绝不能把这个 Compose 暴露到局域网或公网。
再创建 pipeline/log.conf。sincedb_path 保存文件读取位置,挂载的 ls-data 让容器重启后能续读;删除该卷会让 Logstash 忘记位置,是否重读取决于文件身份和插件状态。
input {
file {
id => "checkout-jsonl"
path => "/logs/*.jsonl"
start_position => "beginning"
sincedb_path => "/usr/share/logstash/data/checkout.sincedb"
codec => json
}
}
filter {
mutate {
add_field => {
"[data_stream][type]" => "logs"
"[data_stream][dataset]" => "checkout"
"[data_stream][namespace]" => "dev"
}
remove_field => ["@version", "host", "path", "log", "event", "tags"]
}
}
output {
elasticsearch {
hosts => ["http://elasticsearch:9200"]
data_stream => true
data_stream_auto_routing => true
}
stdout { codec => rubydebug }
}先创建空日志文件并启动服务:
mkdir -p pipeline logs
: > logs/app.jsonl
docker compose up -d
docker compose ps
curl -fsS http://localhost:9200/_cluster/health?prettydocker compose ps 预期显示三个服务运行,集群健康响应至少包含 status 与一个节点。单节点且模板配置了副本时出现 yellow 不等于主分片不可写,而是副本无处分配;开发环境可以接受,生产环境必须查清未分配副本。
模板决定字段和新分片的出生方式
data stream 是一个稳定写入名,实际数据在隐藏的 backing indices 中。它要求匹配的 index template 带有 data_stream 声明,每条事件也必须有 @timestamp。在采集器写入前创建模板,避免第一条脏数据抢先触发错误的动态 mapping:
curl -fsS -X PUT http://localhost:9200/_index_template/logs-checkout-template \
-H 'Content-Type: application/json' \
-d '{
"index_patterns": ["logs-checkout-*"],
"priority": 500,
"data_stream": {},
"template": {
"settings": {
"index.number_of_shards": 1,
"index.number_of_replicas": 0,
"mapping.total_fields.limit": 500
},
"mappings": {
"dynamic": "strict",
"properties": {
"@timestamp": {"type": "date"},
"service": {"type": "keyword"},
"environment": {"type": "keyword"},
"level": {"type": "keyword"},
"trace_id": {"type": "keyword", "index": true},
"duration_ms": {"type": "long"},
"message": {"type": "text"},
"data_stream": {
"properties": {
"type": {"type": "constant_keyword"},
"dataset": {"type": "constant_keyword"},
"namespace": {"type": "constant_keyword"}
}
},
"host": {"type": "object", "enabled": false},
"path": {"type": "keyword", "index": false},
"event": {"type": "object", "enabled": false}
}
}
}
}'成功响应是 {"acknowledged":true}。keyword 适合精确过滤与聚合,text 适合全文检索;把任意 JSON 都动态展开会产生 mapping explosion。dynamic: strict 会把未知字段变成明确写入失败,适合用测试暴露 schema 漂移,但团队必须给新增字段建立变更流程,否则一次正常发布也可能被拒绝。
现在追加测试事件:
NOW=$(date -u +%Y-%m-%dT%H:%M:%SZ)
printf '%s\n' "{\"@timestamp\":\"$NOW\",\"service\":\"checkout\",\"environment\":\"dev\",\"level\":\"ERROR\",\"trace_id\":\"TE-LOG-001\",\"duration_ms\":812,\"message\":\"payment timeout\"}" >> logs/app.jsonl
sleep 3
curl -fsS 'http://localhost:9200/logs-checkout-dev/_search?pretty' \
-H 'Content-Type: application/json' \
-d '{"query":{"term":{"trace_id":"TE-LOG-001"}}}'预期 hits.total.value 为 1,_source 中仍有原事件。若 data stream 不存在,先看 docker compose logs logstash:no matching index template found for data stream 指向模板名或 pattern,strict_dynamic_mapping_exception 指向字段契约,连接拒绝则继续看 Elasticsearch 健康与容器网络。不要先去 Kibana 改时间范围,因为此时证据已经表明事件尚未写入。
把 duration_ms 改成字符串可以稳定制造反例:
NOW=$(date -u +%Y-%m-%dT%H:%M:%SZ)
printf '%s\n' "{\"@timestamp\":\"$NOW\",\"service\":\"checkout\",\"environment\":\"dev\",\"level\":\"ERROR\",\"trace_id\":\"TE-LOG-BAD\",\"duration_ms\":\"slow\",\"message\":\"bad field type\"}" >> logs/app.jsonl
sleep 3
docker compose logs --since=1m logstash
curl -fsS 'http://localhost:9200/logs-checkout-dev/_count' \
-H 'Content-Type: application/json' \
-d '{"query":{"term":{"trace_id":"TE-LOG-BAD"}}}'预期 Logstash 出现无法把 slow 解析为 long 的 400 写入错误,计数为 0。这证明“采集器读到文件”与“后端接纳 document”是两件事。生产管道应把此类事件送往有保留上限的 dead-letter 路径或计数告警,修复解析后再受控重放;无限重试一个永久错误只会堵住队列并吃满磁盘。
类型错误只覆盖了已知字段。还要追加一个 schema 中从未声明的字段,直接验证 dynamic: strict 的拒绝语义:
NOW=$(date -u +%Y-%m-%dT%H:%M:%SZ)
printf '%s\n' "{\"@timestamp\":\"$NOW\",\"service\":\"checkout\",\"environment\":\"dev\",\"level\":\"ERROR\",\"trace_id\":\"TE-LOG-UNKNOWN\",\"duration_ms\":12,\"message\":\"unknown field\",\"release_channel\":\"canary\"}" >> logs/app.jsonl
sleep 3
docker compose logs --since=1m logstash
curl -fsS 'http://localhost:9200/logs-checkout-dev/_count' \
-H 'Content-Type: application/json' \
-d '{"query":{"term":{"trace_id":"TE-LOG-UNKNOWN"}}}'日志中应出现 strict_dynamic_mapping_exception,并指出 release_channel 不能动态加入,计数仍为 0。如果业务确实需要该字段,应先更新字段契约和模板,再 rollover 到新的 backing index 后发送;关闭 strict 或临时开启动态映射会把一次契约变更变成不可控的字段扩张。
分片、滚动和生命周期不是三个同义词
每个 backing index 被切成 primary shard,replica 是主分片的额外副本。分片是 Lucene 索引和资源调度单元,过大会拖慢恢复,过小则让每个 shard 的固定开销和集群状态压垮节点。按天建索引常常制造大量小分片;更稳妥的做法是让 data stream 按实际大小和时间滚动,并以采集字节、文档数、查询窗口、恢复时间目标和节点磁盘水位测算,而不是照抄“每天一个索引”。
curl -fsS 'http://localhost:9200/_data_stream/logs-checkout-dev?pretty'
curl -fsS 'http://localhost:9200/_cat/indices/.ds-logs-checkout-dev-*?v&expand_wildcards=all'
curl -fsS 'http://localhost:9200/_cat/shards/logs-checkout-dev?v'第一条命令能看到 write index 与 generation;后两条把 backing index、文档数、存储量和分片状态展开。查询延迟上升时,要把“命中了多少 shard、每个 shard 多大、是否在恢复、磁盘是否触发水位”连起来看,不能只调查询超时。
简单的“保留七天”可直接交给 data stream lifecycle:
curl -fsS -X PUT http://localhost:9200/_data_stream/logs-checkout-dev/_lifecycle \
-H 'Content-Type: application/json' \
-d '{"data_retention":"7d"}'
curl -fsS http://localhost:9200/_data_stream/logs-checkout-dev/_lifecycle?pretty它会自动管理 rollover,并在 backing index 不再是 write index 后按有效保留期删除。删除不是在事件刚满七天的那一秒发生。若需要 hot、warm、cold、frozen、searchable snapshot、force merge 等分层动作,再选择 ILM;不要同时让两套生命周期争夺同一 data stream。无论用哪套,保留都是成本与合规策略,不是备份。误删、集群损坏和跨区域灾难仍需要 snapshot repository、恢复权限和定期恢复演练。
Elastic 故障从第一证据向下走
出现“搜不到”时,先用固定 trace_id 分层定位。Logstash 没有读取记录,检查路径、文件权限、inode/轮转和 sincedb;Logstash 读到但持续重试,检查 mapping、认证、CA、429、磁盘水位和 data stream 权限;写入成功但查询为空,检查 @timestamp、时区、目标 data stream、Kibana data view 和字段是 keyword 还是 text。集群把索引切成只读时,先释放并确认磁盘空间、查水位与分片分配,再解除 block,不能只执行解锁 API 让磁盘继续写满。
迁移 mapping 时,更新模板只影响未来 backing index,不会自动改写旧数据。兼容变更可以更新模板后 rollover;不兼容类型变更应建立新 data stream,reindex 或双写验证查询与计数,再切换 data view。回滚也应切回旧写入目标,而不是删除已经承载新 mapping 的 backing index。
本地清理先停采集器,避免删除时仍有写入:
docker compose stop logstash
curl -fsS -X DELETE http://localhost:9200/_data_stream/logs-checkout-dev
curl -fsS -X DELETE http://localhost:9200/_index_template/logs-checkout-template
docker compose down -v前两条预期返回 acknowledged,最后一条删除本实验容器和具名卷。生产清理必须先确认 snapshot、法律保留和 owner 审批,绝不能把 down -v 当生产回滚。
Loki:查询的是 stream,正文住在 chunk 里
如果团队主要按服务、环境和集群缩小范围,再对日志正文做过滤,Loki 往往比“为每个字段建索引”更节省索引成本。它并非没有索引:租户 ID 与一组 labels 唯一确定 stream,索引记录 stream 到 chunk 的关联,日志正文压缩后写入 chunk。查询先用 label selector 找 stream,再读取候选 chunk 执行行过滤与解析。
这也解释了 Loki 最危险的误用:把 trace_id、订单号、用户 ID、完整 URL 或容器 UID 做 label。每个新值都可能生成新 stream,活跃 stream 数、内存、索引和小 chunk 会一起增长。service、environment、cluster、低基数 level 才更适合 label;请求身份留在正文或 structured metadata 中。
用 Loki、Alloy 和 Grafana 跑通文件采集
建立 loki-log-lab,其中包含 logs、alloy-data 和 grafana-provisioning/datasources 目录。compose.yaml 锁定 Loki 3.7.2、Alloy 1.17 和 Grafana 12.4 的版本线;补丁版本升级前仍要阅读 release notes,因为镜像内容、对象存储客户端和配置校验都可能变化。
services:
loki:
image: grafana/loki:3.7.2
command: ["-config.file=/etc/loki/loki.yaml"]
volumes:
- ./loki.yaml:/etc/loki/loki.yaml:ro
- loki-data:/loki
ports:
- "127.0.0.1:3100:3100"
alloy:
image: grafana/alloy:v1.17.0
command: ["run", "--server.http.listen-addr=0.0.0.0:12345", "--storage.path=/var/lib/alloy", "/etc/alloy/config.alloy"]
volumes:
- ./config.alloy:/etc/alloy/config.alloy:ro
- ./logs:/logs:ro
- alloy-data:/var/lib/alloy
ports:
- "127.0.0.1:12345:12345"
depends_on:
- loki
grafana:
image: grafana/grafana:12.4.0
environment:
GF_SECURITY_ADMIN_USER: admin
GF_SECURITY_ADMIN_PASSWORD: dev-only-change-me
volumes:
- ./grafana-provisioning:/etc/grafana/provisioning:ro
ports:
- "127.0.0.1:3000:3000"
depends_on:
- loki
volumes:
loki-data:
alloy-data:loki.yaml 使用单进程与本地文件系统,足以观察 TSDB index、chunk 和 compactor,但不具备主机故障容忍:
auth_enabled: false
server:
http_listen_port: 3100
common:
path_prefix: /loki
replication_factor: 1
ring:
kvstore:
store: inmemory
storage:
filesystem:
chunks_directory: /loki/chunks
rules_directory: /loki/rules
schema_config:
configs:
- from: <EVENT_DATE>
store: tsdb
object_store: filesystem
schema: v13
index:
prefix: index_
period: 24h
compactor:
working_directory: /loki/compactor
retention_enabled: true
delete_request_store: filesystem
limits_config:
retention_period: 168h
allow_structured_metadata: true新安装的 from 必须是过去日期,让当前写入立即匹配;已有集群增加 schema 时,新条目的 from 要安排在未来的 UTC 日界线。已经按新 schema 写出的数据不能靠删除配置回滚,只能追加另一个未来生效的 schema 条目。错误切换可能让跨边界数据变得不可读,因此变更前要备份对象存储、在影子租户验证,并观察切换两侧查询。
Alloy 的 config.alloy 负责发现文件、解析 JSON、保留低基数 label,并把 trace_id 作为 structured metadata:
local.file_match "checkout" {
path_targets = [{
"__path__" = "/logs/*.jsonl",
"service" = "checkout",
"environment" = "dev",
}]
}
loki.source.file "checkout" {
targets = local.file_match.checkout.targets
forward_to = [loki.process.checkout.receiver]
}
loki.process "checkout" {
stage.json {
expressions = {
level = "level",
trace_id = "trace_id",
timestamp = "@timestamp",
}
}
stage.labels {
values = { level = "" }
}
stage.structured_metadata {
values = { trace_id = "" }
}
stage.timestamp {
source = "timestamp"
format = "RFC3339"
}
forward_to = [loki.write.local.receiver]
}
loki.write "local" {
endpoint {
url = "http://loki:3100/loki/api/v1/push"
}
}Grafana 数据源文件 grafana-provisioning/datasources/loki.yaml 使用容器服务名而不是 localhost:
apiVersion: 1
datasources:
- name: Loki
type: loki
access: proxy
url: http://loki:3100
isDefault: true启动并写入测试事件:
mkdir -p logs alloy-data grafana-provisioning/datasources
: > logs/app.jsonl
docker compose up -d
curl -fsS http://localhost:3100/ready
NOW=$(date -u +%Y-%m-%dT%H:%M:%SZ)
printf '%s\n' "{\"@timestamp\":\"$NOW\",\"level\":\"ERROR\",\"trace_id\":\"TE-LK-001\",\"message\":\"payment timeout\"}" >> logs/app.jsonl
sleep 3
curl -fsS --get 'http://localhost:3100/loki/api/v1/query_range' \
--data-urlencode 'query={service="checkout",environment="dev"} |= "TE-LK-001"' \
--data-urlencode 'limit=20'readiness 预期返回 ready,查询响应的 status 为 success,data.result 中包含测试行。Grafana 打开 http://localhost:3000 后可在 Explore 使用同一条 LogQL。若 Alloy 容器里把写入地址配置成 localhost:3100,它访问的是 Alloy 自己而不是 Loki,这是容器环境最常见的连接误判。
用反例看见高基数为什么昂贵
下面直接向开发 Loki 写入 100 个不同 trace_id label。它只用于空实验环境:
for i in $(seq 1 100); do
ns=$(date +%s%N)
curl -fsS -X POST http://localhost:3100/loki/api/v1/push \
-H 'Content-Type: application/json' \
-d "{\"streams\":[{\"stream\":{\"service\":\"checkout-bad\",\"trace_id\":\"T-$i\"},\"values\":[[\"$ns\",\"bad cardinality experiment\"]]}]}" >/dev/null
done
curl -fsS --get 'http://localhost:3100/loki/api/v1/series' \
--data-urlencode 'match[]={service="checkout-bad"}'预期返回许多只在 trace_id 上不同的 series。相同 100 行若只有 service=checkout-good 一个 label 集合,就属于一个 stream,更容易形成饱满 chunk。stream 过多时,先停止制造高基数 label,修改采集配置,把该字段移到正文或 structured metadata;已经写入的旧 stream 不会因配置变更自动合并,只能等待保留清理或按治理流程删除。
从 400、429 和慢查询反推内部状态
Loki 的写入路径先由 Distributor 校验标签、租户、速率和行大小,再把 stream 路由给 Ingester;Ingester 在内存中追加并切 chunk,随后把 chunk 与 TSDB index 写到对象存储。missing_labels 是不可重试的 400,补至少一个有效 label;过旧或乱序事件要检查应用时间、采集延迟和允许窗口;429 则要区分租户写入速率、活跃 stream、单行大小或并发限制,盲目重试会扩大队列。
查询慢时先看 selector。{service="checkout",environment="prod"} |= "timeout" 先缩小 stream,再扫描正文;{environment=~".+"} | json | trace_id="..." 可能读取大量 chunk。查询前端拆分时间区间,Querier 从 index 找 chunk 并并行读取对象存储,因此对象存储延迟、过宽时间窗、小 chunk 和高基数都会出现在尾延迟与费用上。
生产 Loki 通常把读、写、backend 路径拆开并使用共享对象存储。规模较小时可先用 simple scalable;需要分别扩容 Distributor/Ingester、Query Frontend/Querier、Index Gateway/Compactor 时再采用微服务模式。拆组件不会自动带来可用性:ring、复制因子、zone-aware placement、WAL、对象存储版本与权限、网关限流、查询公平性和缓存都需要一起设计。
Loki 本身不带认证。auth_enabled: true 只启用多租户语义,X-Scope-OrgID 也不是身份证明;生产必须让可信网关完成用户或工作负载认证、授权并注入租户头,Loki、memberlist 和内部组件端口不得直接暴露公网。对象存储凭证优先使用 workload identity;Compactor 若负责删除,还需要删除对象权限,而查询组件不应顺便获得删除权。
保留由 Compactor 执行,不是 retention_period 写完就立即生效。应同时观察 Compactor 日志、retention 指标和对象数量趋势。文件系统模式不会根据剩余空间自动删到安全水位,断网时 Alloy 队列和 Loki WAL 也会把网络故障转成磁盘故障;容量预算必须包含峰值写入、压缩比、复制、WAL、缓存、对象请求费和查询出口流量。
回滚 Alloy 配置时保留 alloy-data,否则读取位置可能丢失并造成重采;旧采集器与新采集器不能长时间同时读同一文件。实验清理为:
docker compose stop alloy
docker compose down -v生产 schema 迁移不能执行这种“删卷回滚”。应先停新写入或切回旧租户,保留已写对象和旧 schema 配置,让旧数据仍可查询,再用未来日期追加修正条目。
云日志:买到的是平台责任,不是无限容量
云日志把后端可用性、升级和一部分扩缩容交给云厂商,但采集身份、数据地域、保留、查询扫描、导出链路和账单仍由使用方负责。AWS CloudWatch Logs、Azure Monitor Logs 与 Google Cloud Logging 的资源模型不同,不能把一份“云日志配置”改三个 endpoint 就视为迁移完成。
AWS CloudWatch Logs:log group 是治理边界
本地已经安装并配置 AWS CLI v2,调用身份至少需要创建测试 log group/stream、写入、查询、设置保留和删除测试资源的权限。真实应用应拆成更小的运行角色:写入角色只获得目标 ARN 上的写动作,查询角色只读,修改保留、KMS、订阅和删除由平台角色承担。
export AWS_REGION=ap-southeast-1
LAB_ID="loglab-$(date -u +%Y%m%dT%H%M%SZ)-$(openssl rand -hex 4)"
GROUP="/tool-lab/checkout/dev/$LAB_ID"
STREAM="manual-$LAB_ID"
TRACE_ID="TE-AWS-$LAB_ID"
CLOUD_TMP=$(mktemp -d "${TMPDIR:-/tmp}/cloud-log-lab.XXXXXXXX")
AWS_ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
EXISTING_GROUP=$(aws logs describe-log-groups --region "$AWS_REGION" \
--log-group-name-prefix "$GROUP" \
--query "logGroups[?logGroupName=='$GROUP'].logGroupName | [0]" --output text)
test "$EXISTING_GROUP" = "None" || { echo "refuse: log group already exists" >&2; exit 1; }
aws logs create-log-group --region "$AWS_REGION" --log-group-name "$GROUP"
aws logs tag-log-group --region "$AWS_REGION" --log-group-name "$GROUP" \
--tags "log-lab-owner=$LAB_ID"
aws logs put-retention-policy --region "$AWS_REGION" --log-group-name "$GROUP" --retention-in-days 7
aws logs create-log-stream --region "$AWS_REGION" --log-group-name "$GROUP" --log-stream-name "$STREAM"
NOW_MS=$(($(date +%s) * 1000))
jq -n --argjson timestamp "$NOW_MS" --arg trace_id "$TRACE_ID" \
'[{timestamp:$timestamp,message:({service:"checkout",trace_id:$trace_id,message:"payment timeout"}|tojson)}]' \
> "$CLOUD_TMP/aws-log-events.json"
aws logs put-log-events --region "$AWS_REGION" \
--log-group-name "$GROUP" --log-stream-name "$STREAM" \
--log-events "file://$CLOUD_TMP/aws-log-events.json"
aws logs filter-log-events --region "$AWS_REGION" \
--log-group-name "$GROUP" --filter-pattern "$TRACE_ID" --limit 20实验 ID 同时进入 group、stream、trace 与 group tag;创建前的精确查询保证不会接管同名资源。事件数组由 jq 写成 JSON 文件,再交给 AWS CLI 的 file:// 输入,避免 shell 引号把内层 JSON 拆坏。
写入响应可能仍展示 nextSequenceToken,但现行 PutLogEvents 已忽略 sequence token,不应再把单 stream 的发送器串行化在旧 token 协议上。查询预期在 events 中看到测试消息;刚写完短暂为空时先等待摄取延迟,再核对 region、account、group、时间戳与调用身份。AccessDeniedException 先用 aws sts get-caller-identity 确认实际身份,再看 IAM policy、permission boundary、SCP、KMS key policy 和资源 ARN,不能只给用户加管理员权限。
EC2、混合主机和容器可使用统一 CloudWatch Agent;旧 CloudWatch Logs agent 已弃用。EC2 instance profile 可先用托管的 CloudWatchAgentServerPolicy 跑通,再收敛成只允许目标 log group 的 logs:CreateLogStream、logs:DescribeLogStreams 与 logs:PutLogEvents。下面配置里的 retention_in_days 还需要 logs:PutRetentionPolicy;若保留期由基础设施流水线统一管理,就从 Agent 配置删除该字段并拒绝业务主机修改保留策略。CreateLogGroup 和修改保留不应长期留给每台业务主机。
Linux 主机安装 Agent 后,将下面配置保存为 /opt/aws/amazon-cloudwatch-agent/etc/amazon-cloudwatch-agent.json。固定 group、用实例 ID 区分 stream,避免每次进程重启创建新 stream:
{
"agent": {
"run_as_user": "cwagent"
},
"logs": {
"logs_collected": {
"files": {
"collect_list": [
{
"file_path": "/var/log/checkout/app.jsonl",
"log_group_name": "/tool-lab/checkout/dev",
"log_stream_name": "{instance_id}",
"timezone": "UTC",
"retention_in_days": 7
}
]
}
}
}
}sudo /opt/aws/amazon-cloudwatch-agent/bin/amazon-cloudwatch-agent-ctl \
-a fetch-config -m ec2 -s \
-c file:/opt/aws/amazon-cloudwatch-agent/etc/amazon-cloudwatch-agent.json
sudo /opt/aws/amazon-cloudwatch-agent/bin/amazon-cloudwatch-agent-ctl -m ec2 -a status状态预期为 running。cwagent 还必须能穿过目录并读取日志文件;为了省事把应用日志设成全员可读会泄露敏感数据,应使用专用组和最小 ACL。配置中的路径、group、stream 命名、时区和 multiline 起始规则共同决定采集结果。错误的 multiline 会把一条堆栈拆成多条计费事件,或把后续正常日志吞进一条超大事件。Agent 自身日志、目标文件权限和发送 API 错误是第一证据。
CloudWatch 的费用不只来自存储:摄取、Logs Insights 扫描、归档、数据保护、跨账号复制、订阅输出与目标服务都可能计费。每个 group 创建时立即设置保留,按 workload 与环境打成本标签,并用 usage 指标和预算观察“每天摄取字节、每次查询扫描字节、导出失败”。服务配额可能按区域、账户、API 和订阅类型限制,部署前从 Service Quotas 读取目标账户的实际值,不把文档默认值写死进容量设计。
订阅到 Kinesis、Firehose 或 Lambda 时,CloudWatch Logs 需要能够调用目标资源,目标也要有容量和失败处理。跨账号、跨区域和互联网出口会同时改变 IAM、延迟与费用。若唯一目的只是长期归档,优先评估直接导出到对象存储的批处理链路,不要让高成本实时订阅成为默认。
清理前重新读取调用账户、group 所有权 tag 和目标 stream。只有账户未切换、tag 与本次实验 ID 相同、stream 精确存在时才删除;这比只看一个看似熟悉的名称更能阻止误删:
test "$(aws sts get-caller-identity --query Account --output text)" = "$AWS_ACCOUNT_ID"
test "$(aws logs list-tags-log-group --region "$AWS_REGION" --log-group-name "$GROUP" \
--query 'tags."log-lab-owner"' --output text)" = "$LAB_ID"
test "$(aws logs describe-log-streams --region "$AWS_REGION" --log-group-name "$GROUP" \
--log-stream-name-prefix "$STREAM" \
--query "logStreams[?logStreamName=='$STREAM'].logStreamName | [0]" --output text)" = "$STREAM"
aws logs delete-log-group --region "$AWS_REGION" --log-group-name "$GROUP"
rm -f -- "$CLOUD_TMP/aws-log-events.json"
rmdir -- "$CLOUD_TMP"删除 log group 会删除其中事件,适合作为这个合成实验的清理,不适合作为配置回滚。生产回滚应先停新订阅或切回旧 Agent 配置,保留原 group 直到查询、归档和审计窗口结束。
Azure Monitor Logs:DCR 同时控制路由与变换
Azure 以 Log Analytics workspace 和 table 承载查询数据,Data Collection Rule(DCR)描述输入 stream、目标 table 与 transformKql。直接写入使用 Logs Ingestion API;虚拟机和混合主机通常使用 Azure Monitor Agent 与 DCR association。旧 Log Analytics agent 不应再成为新接入入口。
先用 Azure CLI 建一个开发 workspace。执行者除了资源创建权限,还必须具备 Microsoft.Authorization/roleAssignments/write;普通 Contributor 不能给发送者和查询者授权。查询数据还受 workspace/resource context、workspace access mode、Azure RBAC、table/row 条件共同影响。下面使用当前登录用户做隔离实验,因此该用户需要在实验资源组上拥有 Role Based Access Control Administrator、User Access Administrator、Owner 或包含等价动作的自定义角色。
az login
az account show --query '{subscription:id,tenant:tenantId,user:user.name}' -o json
LAB_ID="$(date -u +%Y%m%d%H%M%S)-$RANDOM"
RG="rg-log-lab-$LAB_ID"
LOCATION=eastus
WORKSPACE="law-log-lab-$LAB_ID"
AZ_TMP=$(mktemp -d "${TMPDIR:-/tmp}/azure-log-lab.XXXXXXXX")
az group create --name "$RG" --location "$LOCATION" \
--tags log-lab-owner="$LAB_ID" purpose=tool-efficiency-lab
az monitor log-analytics workspace create \
--resource-group "$RG" --workspace-name "$WORKSPACE" \
--location "$LOCATION" --retention-time 30
WORKSPACE_ID=$(az monitor log-analytics workspace show \
--resource-group "$RG" --workspace-name "$WORKSPACE" \
--query customerId -o tsv)
SUBSCRIPTION_ID=$(az account show --query id -o tsv)
WORKSPACE_RESOURCE_ID=$(az monitor log-analytics workspace show \
--resource-group "$RG" --workspace-name "$WORKSPACE" \
--query id -o tsv)成功后 WORKSPACE_ID 是查询 API 使用的 workspace GUID,不是 Azure Resource Manager resource ID。先创建自定义表:
az rest --method put \
--url "https://management.azure.com${WORKSPACE_RESOURCE_ID}/tables/Checkout_CL?api-version=2022-10-01" \
--body '{
"properties": {
"schema": {
"name": "Checkout_CL",
"columns": [
{"name":"TimeGenerated","type":"datetime"},
{"name":"Service","type":"string"},
{"name":"TraceId","type":"string"},
{"name":"Message","type":"string"}
]
},
"retentionInDays": 30,
"totalRetentionInDays": 30
}
}'再把下面内容保存为 $AZ_TMP/dcr.json。kind: Direct 使新 DCR 生成直连摄取 endpoint,data flow 将输入 stream 映射到自定义表:
{
"location": "eastus",
"kind": "Direct",
"properties": {
"streamDeclarations": {
"Custom-CheckoutRaw": {
"columns": [
{"name":"TimeGenerated","type":"datetime"},
{"name":"Service","type":"string"},
{"name":"TraceId","type":"string"},
{"name":"Message","type":"string"}
]
}
},
"destinations": {
"logAnalytics": [
{
"workspaceResourceId": "WORKSPACE_RESOURCE_ID",
"name": "checkoutWorkspace"
}
]
},
"dataFlows": [
{
"streams": ["Custom-CheckoutRaw"],
"destinations": ["checkoutWorkspace"],
"transformKql": "source",
"outputStream": "Custom-Checkout_CL"
}
]
}
}把文件中的 WORKSPACE_RESOURCE_ID 替换为上一步变量值后创建 DCR:
DCR="dcr-checkout-$LAB_ID"
DCR_RESOURCE_ID="/subscriptions/$SUBSCRIPTION_ID/resourceGroups/$RG/providers/Microsoft.Insights/dataCollectionRules/$DCR"
sed -i "s|WORKSPACE_RESOURCE_ID|$WORKSPACE_RESOURCE_ID|" "$AZ_TMP/dcr.json"
az rest --method put \
--url "https://management.azure.com${DCR_RESOURCE_ID}?api-version=2023-03-11" \
--body "@$AZ_TMP/dcr.json"
DCR_IMMUTABLE_ID=$(az rest \
--url "https://management.azure.com${DCR_RESOURCE_ID}?api-version=2023-03-11" \
--query properties.immutableId -o tsv)
DCR_ENDPOINT=$(az rest \
--url "https://management.azure.com${DCR_RESOURCE_ID}?api-version=2023-03-11" \
--query properties.endpoints.logsIngestion -o tsv)旧 DCR 不能原地补出这个 endpoint,需要新建替换。只有 Private Link 或旧 DCR 没有直连 endpoint 时才需要独立 DCE。
发送端使用 Entra ID 工作负载身份或服务主体,并在 DCR 资源上获得 Monitoring Metrics Publisher 角色;查询者再按 workspace、table 或 row 条件获得只读角色。生产应用使用 managed identity 或 federated credential,下面用当前 CLI 身份完成开发验证:
用当前 UTC 时间生成 $AZ_TMP/payload.json,避免事件落到默认查询窗口之外:
NOW=$(date -u +%Y-%m-%dT%H:%M:%SZ)
cat > "$AZ_TMP/payload.json" <<EOF
[
{
"TimeGenerated": "$NOW",
"Service": "checkout",
"TraceId": "TE-AZ-001",
"Message": "payment timeout"
}
]
EOF给当前身份分配测试发送权限并调用摄取端点:
PRINCIPAL_ID=$(az ad signed-in-user show --query id -o tsv)
az role assignment create \
--assignee-object-id "$PRINCIPAL_ID" --assignee-principal-type User \
--role "Monitoring Metrics Publisher" --scope "$DCR_RESOURCE_ID"
az role assignment create \
--assignee-object-id "$PRINCIPAL_ID" --assignee-principal-type User \
--role "Log Analytics Reader" --scope "$WORKSPACE_RESOURCE_ID"
TOKEN=$(az account get-access-token \
--scope https://monitor.azure.com/.default --query accessToken -o tsv)
curl -i -X POST \
"${DCR_ENDPOINT}/dataCollectionRules/${DCR_IMMUTABLE_ID}/streams/Custom-CheckoutRaw?api-version=2023-01-01" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
--data-binary "@$AZ_TMP/payload.json"请求 URL 由 DCR 的 logs ingestion endpoint、DCR immutable ID 与 stream name 组成。这里三个标识最容易混淆:ARM resource ID 用来授权和管理,immutable ID 进入摄取 URL,workspace GUID 用来查询。HTTP 204 表示 API 接纳请求,不等于查询马上可见;等待摄取后执行:
az monitor log-analytics query \
--workspace "$WORKSPACE_ID" \
--analytics-query 'Checkout_CL | where TraceId == "TE-AZ-001" | project TimeGenerated, Service, Message' \
--timespan PT1H -o table预期出现一行测试事件。401 先查 token audience 与过期时间,403 查身份是否有 DCR 数据发送权限,400 查 stream name、数组形状、字段类型与 transform,204 后长期无数据则查 DCR 目标 table、transformKql 是否过滤掉记录、workspace region 和 table 摄取状态。查询 403 不是摄取失败,它属于另一条读权限链。
DCR 的 transformation 可以删字段、改类型和路由,但也会成为静默丢数点。每次改动都应同时发送“应通过”和“应过滤”两条带固定 TraceId 的事件,记录摄取数量与延迟。表的 Analytics、Basic、Auxiliary 等 plan 会改变查询能力、保留和费用;不能为了便宜切 plan 后仍假设原有 KQL、告警与交互延迟不变。
Azure Monitor Logs 的主要成本通常是摄取与保留,查询、search job、restore、export、Sentinel 或 dedicated cluster 还可能增加费用。跨地域 DCR、Event Hub、Storage 导出和 Private Link 都会增加网络与平台成本。配额和 API 限制会随订阅、region、table plan 与合同变化,上线前应在目标订阅查看 Usage and estimated costs、服务限制与 DCR 指标。
测试完成后,先清除内存中的访问令牌,再核对订阅、资源组前缀和创建时写入的 owner tag。三项任一不匹配都停止删除:
unset TOKEN
test "$(az account show --query id -o tsv)" = "$SUBSCRIPTION_ID"
case "$RG" in rg-log-lab-*) ;; *) echo "refuse unexpected RG: $RG" >&2; exit 1;; esac
test "$(az group show --name "$RG" --query 'tags."log-lab-owner"' -o tsv)" = "$LAB_ID"
az group delete --name "$RG" --yes --no-wait
rm -r -- "$AZ_TMP"这是对独立实验资源的清理。生产回滚 DCR 时应恢复旧 association 或旧直写 endpoint,保留新旧表一段可核对窗口;删除 workspace 会同时影响查询、告警、Sentinel、导出和审计引用,不能作为普通回滚动作。
Google Cloud Logging:bucket、view 与 sink 分别承担存储、读取和出口
Cloud Logging 的日志条目进入 log bucket,view 控制 bucket 中哪些日志可见,sink 按过滤器把新日志路由到另一个 bucket、Cloud Storage、BigQuery 或 Pub/Sub。项目级 Editor 并不是合理的应用凭证:写入、查看私有日志、管理 bucket/view/sink 和使用 sink writer identity 应分别授权。
安装 Google Cloud CLI 后选择一个已启用结算的测试项目。用户交互可用 gcloud auth login,工作负载优先使用附加服务账号或 Workload Identity Federation,不下载长期 JSON key。
gcloud auth login
gcloud config set project PROJECT_ID
gcloud auth list
PROJECT_ID=$(gcloud config get-value project)
test -n "$PROJECT_ID" && test "$PROJECT_ID" != "(unset)"
LAB_ID="$(date -u +%Y%m%d%H%M%S)-$RANDOM"
LOG_ID="checkout-dev-$LAB_ID"
TRACE_ID="TE-GCP-$LAB_ID"
gcloud logging write "$LOG_ID" \
"{\"service\":\"checkout\",\"trace_id\":\"$TRACE_ID\",\"message\":\"payment timeout\"}" \
--payload-type=json --severity=ERROR --labels=log_lab_owner="$LAB_ID"
gcloud logging read \
"logName=\"projects/$PROJECT_ID/logs/$LOG_ID\" AND jsonPayload.trace_id=\"$TRACE_ID\" AND labels.log_lab_owner=\"$LAB_ID\"" \
--freshness=1h --limit=20 --format=json把 PROJECT_ID 替换为实际测试项目。写入成功通常没有业务输出;读取预期返回包含 jsonPayload、logName、owner label、resource 与时间戳的条目。空结果先检查活动账号、项目、logName URL 编码、freshness、resource scope 与权限。PERMISSION_DENIED 要区分缺少写入权限和缺少查看私有日志权限;授予更大角色会掩盖真正的最小权限缺口。
Ops Agent 适合 Compute Engine 主机采集,GKE 与托管服务还有各自的集成入口。Agent 配置应明确 receiver、processor 和 pipeline;结构化字段、排除规则与 multiline 在发送前就影响摄取字节。Agent 自身状态、诊断日志和 API 写入错误应纳入告警,不能只监控 Logs Explorer 是否偶尔有数据。
例如,附加到 Compute Engine 的服务账号只授予 roles/logging.logWriter,将下面内容放入 /etc/google-cloud-ops-agent/config.yaml,即可把 JSONL 文件交给 logging pipeline:
logging:
receivers:
checkout_json:
type: files
include_paths:
- /var/log/checkout/app.jsonl
record_log_file_path: true
processors:
parse_checkout_json:
type: parse_json
time_key: "@timestamp"
time_format: "%Y-%m-%dT%H:%M:%SZ"
service:
pipelines:
checkout_pipeline:
receivers: [checkout_json]
processors: [parse_checkout_json]sudo systemctl restart google-cloud-ops-agent
sudo systemctl status google-cloud-ops-agent --no-pager
sudo journalctl -u google-cloud-ops-agent --since "10 minutes ago" --no-pager状态预期为 active (running);解析或发送失败会进入 Agent 自身日志。开发者查询通常使用 roles/logging.viewer,读取 Data Access 等私有日志还需要更高的私有日志查看权限;bucket、view、sink 的管理角色不应授予应用服务账号。
Cloud Logging 对单条 LogEntry、请求大小、区域写入速率、读取调用、bucket、sink 和过滤器都有配额或固定限制。超出区域摄取配额可能返回 RESOURCE_EXHAUSTED;排除过滤发生在写 API 接收之后,不一定降低写入配额占用。容量评审要从目标项目的 Quotas 页面读取实际额度,并为突发流量设置采样、限流、缓冲和降级,而不是把 SDK 无限重试当保护。
成本控制要同时看摄取、超出默认保留的存储、Log Analytics、BigQuery/Storage/Pub/Sub 目标、跨区域网络与 sink 下游。出口 sink 创建后会得到 writer identity,必须给它目标资源的最小写权限;目标不可写或 Pub/Sub 超配额时,路由错误会作为平台日志出现。大量离线分析不应反复耗尽 entries.list,应提前用 sink 路由到适合批量分析的目标。
合成日志可删除,但删除日志名是破坏性操作。先确认当前项目、唯一日志名前缀和日志中的 owner label;不能把固定业务日志名放进这段命令:
test "$(gcloud config get-value project)" = "$PROJECT_ID"
case "$LOG_ID" in checkout-dev-*) ;; *) echo "refuse unexpected log: $LOG_ID" >&2; exit 1;; esac
test "$(gcloud logging read \
"logName=\"projects/$PROJECT_ID/logs/$LOG_ID\" AND labels.log_lab_owner=\"$LAB_ID\"" \
--freshness=1h --limit=1 --format='value(labels.log_lab_owner)')" = "$LAB_ID"
gcloud logging logs delete "$LOG_ID"生产迁移 bucket 或 sink 时,新规则只影响生效后的路由。应先双路输出固定样本、验证 writer identity 和下游数量,再切消费者;旧 bucket 按原保留期留存,不能假设新 sink 会自动搬迁历史数据。
为什么有时必须拆成独立日志平台
Elastic、Loki 和云日志不是三种皮肤相同的搜索框。Elastic 为字段建立丰富索引,适合复杂全文、聚合、安全分析和需要精细 mapping 的场景,但团队要承担 JVM、分片、生命周期、snapshot 与升级。Loki 把低基数标签作为索引主轴,适合和 Prometheus/Grafana 协同、主要按元数据缩小范围的日志,成本优势建立在 label 治理与对象存储之上。云日志最贴近云资源身份、审计、托管服务和原生告警,运维负担较小,却受厂商查询语言、配额、区域、出口和计费模型约束。
当业务日志、平台日志和安全审计的保留年限、读者、地域、加密密钥、查询方式或预算责任明显不同,就应拆成独立 data stream、tenant、workspace/bucket,必要时拆成独立平台。把安全审计塞进开发 Loki,只因它便宜,会失去不可抵赖权限和长期保留;把所有 debug 日志塞进高规格 Elastic,只因搜索灵活,会让分片与索引成本吞掉预算;把多云业务日志全部留在各云控制台,又会让一次跨云故障无法按统一 trace 关联。
拆平台也不能靠每个应用装三套采集器完成。更稳妥的边界是应用输出统一结构化事件,节点或集群只保留一条受治理的采集链,在路由层按数据分类发送到不同后端,并为每条出口设置独立缓冲、重试、脱敏和失败指标。这样某个后端限流时不会阻塞全部日志,也能明确哪些数据允许跨境或进入第三方 SaaS。
真正决定拆分的不是日均日志量,而是故障域和责任域:谁能读,谁能删,谁付费,谁值班,平台故障是否允许影响应用,数据是否必须留在某个地域,恢复时要找回哪个时间窗口。若这些问题的答案属于不同团队或法规,伪装成一个等价平台只会把冲突推迟到事故当天。
把日志链路变成长期可维护的产品
应用 owner 负责事件语义、字段版本和源头脱敏;平台 owner 负责采集器、队列、后端容量、生命周期、备份恢复和升级;安全 owner 管读写授权、审计、密钥与出口;成本 owner 管摄取、扫描、保留和跨区域账单。每个 Elastic data stream、Loki tenant、云 workspace/bucket 都应能找到 owner、数据级别、保留理由和退出日期。
上线门槛不应是“页面看到日志”,而是同一固定样本能证明:正常事件只写入一次,错误字段进入可观测的失败路径,采集器重启与文件轮转没有缺口,权限分别限制写、读、管理和删除,保留组件确实执行,恢复演练能找回指定时间窗,后端限流不会无限吃满节点磁盘。阈值必须来自峰值流量、SLO、基线压测和预算,不能跨团队复制一个万能数字。
采集器升级先在少量实例上保留旧位置状态,比较事件数、延迟、重复与丢弃,再扩大范围;Elastic mapping 变更通过新 backing index 或新 data stream 灰度;Loki schema 以未来 UTC 日期追加;云 DCR、sink 与订阅先双路验证。任何回滚都先停止新写入方向,再恢复旧配置,避免两套采集器同时读同一文件。数据卷、对象存储、旧 workspace 和旧索引只有在审计窗口与恢复验证结束后才能清理。
最后把容量看板与账单放在同一条叙事里:采集字节突然上涨,可能来自异常风暴、多行解析失败或新增大字段;Elastic shard 数上升可能来自 rollover 过频;Loki stream 数上升通常指向 label 基数;云日志费用跳涨可能来自摄取、查询扫描或出口。能从这些外部现象反推内部对象,集中日志才不只是一个搜索工具,而是一套可治理的工程系统。
