在 Kubernetes 上采集网关日志
在 Kubernetes 上,可以通过两种方式采集 API7 网关的访问日志和错误日志:
- 从容器输出采集。 网关写入标准输出和标准错误,容器运行时将该数据流持久化到节点上,再由节点级采集器读取。这是默认方式,无需修改网关配置。
- 从 Pod 内的日志文件采集。 网关写入真实文件,通过卷将文件暴露出来,再由采集器读取文件。这需要修改网关配置,但每类日志各自独立成文件。
本文分别用 OpenTelemetry Collector 和 Filebeat 介绍这两种方式。
关于网关生成的日志类型以及控制这些日志的配置项,请参阅配置集中式日志记录。
选择采集方式
| 对比项 | 容器输出 | Pod 内的日志文件 |
|---|---|---|
| 网关配置 | 无需修改,这是默认方式。 | 需要重定向访问日志和错误日志、开启轮转、挂载卷。 |
| 访问日志与错误日志 | 交织在同一个数据流中,通过 stdout 或 stderr 标记区分。 | 各自独立的文件。 |
| 轮转与保留 | 由容器运行时和 kubelet 处理。 | 由网关的 log-rotate 插件处理。 |
kubectl logs | 能看到网关日志。 | 几乎看不到内容。 |
| 超长日志行 | 可能被运行时切分,再由采集器重组。 | 完整写入。 |
| 并发写入 | 单行日志超过管道缓冲区大小时,可能与另一个 worker 的写入交错。 | 每行作为一个整体写入文件。 |
建议优先使用容器输出方式,它的运维面最小,且两种采集器都能很好地处理。
何时改用日志文件
容器的标准输出是一个管道。在 Linux 上,对管道的写入只有在不超过 PIPE_BUF(4096 字节)时才是原子的。网关运行多个 worker 进程,它们共用同一个管道,因此当单行日志超过该限制时,另一个 worker 的写入可能被插入到这一行的中间。结果是一行被污染的日志同时包含两个不相关请求的片段,并且任何采集器都无法修复它,因为破坏发生在运行时读到数据之前。
出现以下情况时 可以考虑改用日志文件:
- 访问日志行经常超过 4096 字节,例如日志格式中包含较大的请求头、请求体或较多上游字段。
- 已经观察到日志条目中混入了不相关请求的片段。
- 需要让访问日志和错误日志分别落在不同文件中,而不是交织在同一个数据流里。
写文件也有代价:轮转需要自行负责,kubectl logs 对网关容器不再有用,采集器也需要通过卷才能读到文件。本文同时介绍两种方式,方便权衡。
从容器输出采集
Kubernetes 如何存放容器输出
容器运行时会把每个容器的输出写到节点文件系统上,路径中编码了 Pod 身份:
/var/log/pods/<namespace>_<pod-name>_<pod-uid>/<container-name>/<restart-count>.log
/var/log/containers/ 下的文件是指向上述文件的符号链接。
文件中的每一行都带有运行时前缀,包含时间戳、数据流名称,以及标识该行是否被切分的标志:
2026-08-20T11:31:29.123456789Z stdout F 172.18.0.1 - - [20/Aug/2026:11:31:29 +0000] "GET /anything HTTP/1.1" 200
使用 CRI 的容器运行时可能把一个超长的逻辑行切分成多个物理行,并把除最后一行以外的所有行标记为 P 而不是 F。例如 containerd 使用一个可配置的上限,默认值为 16 KiB;其它运行时实现和集群配置可能使用不同的阈值。下文介绍的两种采集器都会识别该格式、剥离其前缀并重组被切分的行。
在 containerd 的默认节点权限下,这些文件属主为 root、权限为 0640。请让采集器具备读取节点运行时日志的权限,通常意味着以 root 身份运行。如果使用其它运行时或安全策略,请自行确认节点上的权限设置。
使用 OpenTelemetry Collector 采集容器输出
以 DaemonSet 方式运行 Collector,并以只读方式挂载 /var/log/pods。一个 container 算子即可处理运行时格式:
extensions:
file_storage/checkpoints:
directory: /var/lib/otelcol/storage
receivers:
filelog/gateway:
include:
- /var/log/pods/<namespace>_<gateway-release-name>-*/gateway/*.log
exclude:
- /var/log/pods/*/otel-collector/*.log
start_at: end
include_file_path: true
storage: file_storage/checkpoints
operators:
- type: container
processors:
k8sattributes:
auth_type: serviceAccount
extract:
metadata:
- k8s.node.name
- k8s.deployment.name
pod_association:
- sources:
- from: resource_attribute
name: k8s.pod.uid
batch:
timeout: 2s
exporters:
otlp:
endpoint: <your-backend>:4317
service:
extensions: [file_storage/checkpoints]
pipelines:
logs:
receivers: [filelog/gateway]
processors: [k8sattributes, batch]
exporters: [otlp]
container 算子会自动识别 containerd、CRI-O 或 Docker 格式,剥离运行时前缀,重组被运行时切分的行,并从文件路径中提取资源属性:
| 属性 | 来源 |
|---|---|
k8s.pod.name、k8s.pod.uid、k8s.namespace.name | 文件路径 |
k8s.container.name、k8s.container.restart_count | 文件路径 |
log.iostream | 运行时前缀,取值为 stdout 或 stderr |
由于 Pod UID 来自路径,k8sattributes 处理器可以据此关联到运行中的 Pod,补充上面配置的节点名称和 Deployment 名称。
在 DaemonSet 中挂载宿主机路径:
volumeMounts:
- name: varlogpods
mountPath: /var/log/pods
readOnly: true
- name: checkpoints
mountPath: /var/lib/otelcol/storage
volumes:
- name: varlogpods
hostPath:
path: /var/log/pods
- name: checkpoints
hostPath:
path: /var/lib/otelcol
type: DirectoryOrCreate
检查点目录应使用 hostPath 卷而不是 emptyDir。file_storage 扩展记录了每个文件已读取到的位置,而 DaemonSet 的 Pod 在每次升级时都会被重建,emptyDir 会随之删除,导致 Collector 在每次重启后重新读取节点上的所有日志文件。
k8sattributes 处理器还需要读取用于补充属性的 Kubernetes 资源的权限。请把下列权限绑定到 Collector DaemonSet 使用的 ServiceAccount,并把 spec.template.spec.serviceAccountName 设为 otel-collector:
apiVersion: v1
kind: ServiceAccount
metadata:
name: otel-collector
namespace: <collector-namespace>
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: otel-collector-k8sattributes
rules:
- apiGroups: [""]
resources: ["pods", "namespaces"]
verbs: ["get", "list", "watch"]
- apiGroups: ["apps"]
resources: ["replicasets"]
verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: otel-collector-k8sattributes
subjects:
- kind: ServiceAccount
name: otel-collector
namespace: <collector-namespace>
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: otel-collector-k8sattributes
OpenTelemetry Collector 没有与 Filebeat autodiscover hints 等价的机制;后者可以根据注解为每个 Pod 应用解析规则。由于 include 模式是静态的,如需按工作负载解析,可以为每个工作负载配置一个接收器并使用范围更窄的 glob 模式,也可以在处理管道中根据 k8s.container.name 等属性进行分流。
使用 Filebeat 采集容器输出
Filebeat 读取的是同一批文件。使用 filestream 输入配合 container 解析器:
filebeat.inputs:
- type: filestream
id: api7-gateway-container
paths:
- /var/log/containers/<gateway-release-name>-*_<namespace>_gateway-*.log
prospector.scanner.symlinks: true
parsers:
- container:
stream: all
format: auto
processors:
- add_kubernetes_metadata:
host: ${NODE_NAME}
matchers:
- logs_path:
logs_path: "/var/log/containers/"
prospector.scanner.symlinks: true 是必需的。/var/log/containers/ 下的文件是符号链接,而 filestream 输入默认跳过符号链接,不加这一项该输入会静默采集不到任何内容。旧的 type: container 输入默认开启了该行为,因此从它迁移过来的配置会失效。
由于链接指向第二个目录,需要同时以只读方式挂载 /var/log/containers 和 /var/log/pods:
volumeMounts:
- name: varlogcontainers
mountPath: /var/log/containers
readOnly: true
- name: varlogpods
mountPath: /var/log/pods
readOnly: true
- name: data
mountPath: /usr/share/filebeat/data
volumes:
- name: varlogcontainers
hostPath:
path: /var/log/containers
- name: varlogpods
hostPath:
path: /var/log/pods
- name: data
hostPath:
path: /var/lib/filebeat-data
type: DirectoryOrCreate
出于与 OpenTelemetry Collector 需要持久化检查点目录同样的原因,注册表目录(/usr/share/filebeat/data)也应放在 hostPath 卷上。
Filebeat 需要读取 Pod 和命名空间才能解析元数据。请为它授予对 pods、namespaces、nodes 以及 apps API 组中 replicasets 的 get、watch 和 list 权限,并让容器以 root 身份运行,以便读取运行时日志文件。
在 Pod 内写入日志文件
配置网关日志文件
在官方容器镜像中,/usr/local/apisix/logs/access.log 和 /usr/local/apisix/logs/error.log 是指向 /dev/stdout 和 /dev/stderr 的符号链接。因此把配置改回这两个路径,日志仍然会写到容器输出。请改用其他目录,并在该目录上挂载卷:
logs:
enableAccessLog: true
accessLog: "/var/log/apisix/access.log"
accessLogFormatEscape: json
accessLogFormat: '{"time":"$time_iso8601","remote_addr":"$remote_addr","host":"$http_host","request":"$request","status":$status,"body_bytes_sent":$body_bytes_sent,"request_time":$request_time,"upstream_addr":"$upstream_addr","upstream_status":"$upstream_status","request_id":"$apisix_request_id"}'
errorLog: "/var/log/apisix/error.log"
errorLogLevel: "warn"
extraVolumes:
- name: api7-gateway-logs
emptyDir:
sizeLimit: 16Gi
extraVolumeMounts:
- name: api7-gateway-logs
mountPath: /var/log/apisix
不要把卷挂载到 /usr/local/apisix/logs 上,该目录同时存放网关的运行时文件,包括 nginx.pid 和 worker 事件套接字。
网关容器以 UID 636 运行。emptyDir 创建时权限较为宽松,但设置 fsGroup 可以让属主关系更明确:
apisix:
podSecurityContext:
fsGroup: 636
对可能为空的字段(例如 $upstream_status)加引号,这样在没有上游时该行仍然是合法 JSON。$status、$request_time 等数值字段可以不加引号。
四层访问日志不在轮转范围内。如果开启 logs.stream.enableAccessLog 并将其指向文件,该文件会无限增长。除非另有控制体积的方案,否则请保持关闭。
配置日志轮转
网关一旦开始写文件,容器运行时就不再轮转任何内容。没有轮转,卷会一直增长直到 Pod 被驱逐。请开启 log-rotate 插件,控制面下发给数据面的插件列表中已经包含该插件:
pluginAttrs:
log-rotate:
enable: true
interval: 3600
max_kept: 24
max_size: 268435456
enable_compression: false
该插件会重命名当前文件,并通知网关重新打开文件。轮转后的文件以时间戳作为文件名前缀:
2026-08-20_19-00-00__access.log
2026-08-20_19-00-00__error.log
请在采集器中排除该模式,避免把轮转后的文件当作新的采集源。两种采集器都会把被重命名的文件读到末尾后再释放,因此排除该模式不会丢失轮转前刚写入的条目。
请保持 enable_compression 关闭。开启压缩后,插件会在轮转后不久归档并删除原文件,这可能与尚未读完该文件的采集器发生竞争。
max_kept 对每种日志类型分别生效。按上面的取值,网关最多会保留约 12 GiB 的轮转后访问日志和错 误日志(2 × 24 × 256 MiB)。两个活动文件以及下一次容量检查之前写入的数据还需要额外空间,因此本示例使用 16 GiB 的卷。调整这些取值时,请按每种会被轮转的日志类型、活动文件和写入超额一并核算卷及其所在节点的容量。
选择采集器的部署位置
| 对比项 | 边车(Sidecar)采集器 | 节点级 DaemonSet |
|---|---|---|
| 采集器实例数 | 每个网关 Pod 一个 | 每个节点一个 |
| 卷 | Pod 内共享的 emptyDir | 同一个 emptyDir,从节点上读取 |
| 宿主机挂载 | 无 | 以只读方式挂载 /var/lib/kubelet |
| 资源开销 | 较高 | 较低 |
| 停机 | 在网关停止之后才退出,可以排空 | Pod 被删除时可能丢失末尾数据 |
emptyDir 并不局限在 Pod 自身的文件系统内,kubelet 会用节点上的一个目录来承载它:
/var/lib/kubelet/pods/<pod-uid>/volumes/kubernetes.io~empty-dir/<volume-name>/
正是这个路径让节点级采集 Pod 内文件成为可能,同时卷仍然绑定 Pod 生命周期,Pod 删除时 kubelet 会一并回收。
写入容器自身文件系统而非卷的文件,在节点上没有稳定路径。它位于容器可写层的快照目录下,该目录的标识符在容器重建时会变化,也无法通过 Pod 名称反查。任何采集器都无法发现它,所以通过卷暴露文件是必需的,而不是可选的。Filebeat 也受同样的限制。
使用边车 OpenTelemetry Collector 采集日志文件
API7 网关 Helm Chart 没有提供 extraContainers 配置项,但 Kubernetes 1.29 及以后的版本支持以 restartPolicy: Always 的 init 容器形式声明原生边车。在 Kubernetes 1.29 到 1.32 上,请确认集群管理员没有关闭 SidecarContainers 特性门控;在 Kubernetes 1.33 及以后的版本中,原生边车已 GA 且无法关闭。这类容器先于网关启动、后于网关停止,因此既能覆盖启动日志,也能覆盖停机时的排空。
extraVolumes:
- name: api7-gateway-logs
emptyDir:
sizeLimit: 16Gi
- name: otelcol-config
configMap:
name: gateway-otelcol-config
- name: otelcol-storage
emptyDir: {}
extraVolumeMounts:
- name: api7-gateway-logs
mountPath: /var/log/apisix
extraInitContainers:
- name: otel-collector
image: ghcr.io/open-telemetry/opentelemetry-collector-releases/opentelemetry-collector-contrib:0.160.0
restartPolicy: Always
args: ["--config=/etc/otelcol/config.yaml"]
env:
- name: K8S_NAMESPACE
valueFrom:
fieldRef:
fieldPath: metadata.namespace
- name: K8S_POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
- name: K8S_NODE_NAME
valueFrom:
fieldRef:
fieldPath: spec.nodeName
volumeMounts:
- name: api7-gateway-logs
mountPath: /var/log/apisix
readOnly: true
- name: otelcol-config
mountPath: /etc/otelcol
- name: otelcol-storage
mountPath: /var/lib/otelcol/storage
resources:
requests:
cpu: 50m
memory: 128Mi
limits:
memory: 512Mi
Collector 直接读取这两个文件,并通过 Downward API 获取 Pod 身份:
extensions:
file_storage/checkpoints:
directory: /var/lib/otelcol/storage
receivers:
filelog/access:
include: [/var/log/apisix/access.log]
exclude: [/var/log/apisix/*__*.log, /var/log/apisix/*.tar.gz]
start_at: beginning
include_file_path: true
storage: file_storage/checkpoints
operators:
- type: json_parser
timestamp:
parse_from: attributes.time
layout_type: gotime
layout: '2006-01-02T15:04:05Z07:00'
filelog/error:
include: [/var/log/apisix/error.log]
exclude: [/var/log/apisix/*__*.log, /var/log/apisix/*.tar.gz]
start_at: beginning
include_file_path: true
storage: file_storage/checkpoints
multiline:
line_start_pattern: '^\d{4}/\d{2}/\d{2} \d{2}:\d{2}:\d{2}'
operators:
- type: regex_parser
regex: '(?s)^(?P<ts>\d{4}/\d{2}/\d{2} \d{2}:\d{2}:\d{2}) \[(?P<sev>\w+)\] (?P<rest>.*)$'
timestamp:
parse_from: attributes.ts
layout_type: strptime
layout: '%Y/%m/%d %H:%M:%S'
location: UTC
severity:
parse_from: attributes.sev
mapping:
debug: debug
info: info
warn: [warn, notice]
error: [error, crit]
fatal: [alert, emerg]
processors:
resource:
attributes:
- key: k8s.namespace.name
value: ${env:K8S_NAMESPACE}
action: upsert
- key: k8s.pod.name
value: ${env:K8S_POD_NAME}
action: upsert
- key: k8s.node.name
value: ${env:K8S_NODE_NAME}
action: upsert
batch:
timeout: 2s
exporters:
otlp:
endpoint: <your-backend>:4317
service:
extensions: [file_storage/checkpoints]
pipelines:
logs:
receivers: [filelog/access, filelog/error]
processors: [resource, batch]
exporters: [otlp]
使用节点级 OpenTelemetry Collector 采集日志文件
若要从 DaemonSet 读取同一批文件,保留配置网关日志文件中的配置但不部署边车。在 Collector 中以只读方式挂载 /var/lib/kubelet,并把 include 匹配规则改为 kubelet 路径。请给卷取一个足够独特的名称,避免匹配到其他工作负载的 emptyDir。
receivers:
filelog/gateway-access:
include:
- /var/lib/kubelet/pods/*/volumes/kubernetes.io~empty-dir/api7-gateway-logs/access.log
exclude:
- /var/lib/kubelet/pods/*/volumes/kubernetes.io~empty-dir/api7-gateway-logs/*__*.log
start_at: beginning
include_file_path: true
storage: file_storage/checkpoints
operators:
- type: regex_parser
parse_from: 'attributes["log.file.path"]'
parse_to: attributes.k8s
regex: '^/var/lib/kubelet/pods/(?P<uid>[0-9a-f]{8}-[0-9a-f-]{27})/volumes/'
- type: move
from: attributes.k8s.uid
to: 'resource["k8s.pod.uid"]'
- type: remove
field: attributes.k8s
- type: json_parser
parse_from: body
timestamp:
parse_from: attributes.time
layout_type: gotime
layout: '2006-01-02T15:04:05Z07:00'
与容器输出示例一样,搭配 k8sattributes 处理器并在 pod_association 中使用 k8s.pod.uid,即可解析出 Pod 名称、命名空间、节点名称和 Deployment 名称。
请给 'attributes["log.file.path"]' 和 'resource["k8s.pod.uid"]' 这类字段引用加引号。YAML 流式映射中未加引号的方括号属于语法错误,而 Collector 报出的是 retrieved value (type=string) cannot be used as a Conf,并不指向真正的问题所在。
volumeMounts:
- name: varlibkubelet
mountPath: /var/lib/kubelet
readOnly: true
mountPropagation: HostToContainer
volumes:
- name: varlibkubelet
hostPath:
path: /var/lib/kubelet
/var/lib/kubelet 是 kubelet 的工作目录,可以通过它的 --root-dir 参数改到别处。挂载该目录意味着能看到节点上所有 Pod 的卷,而且 restricted Pod 安全标准完全禁止 hostPath 卷。存在这些约束时,请改用边车方式。
使用 Filebeat 采集日志文件
Filebeat 通过同样的卷挂载读取 kubelet 路径。使用 pod_uid 索引器和限定在 /var/lib/kubelet/pods/ 的 logs_path 匹配器来解析 Pod 元数据:
filebeat.inputs:
- type: filestream
id: api7-gateway-access
paths:
- /var/lib/kubelet/pods/*/volumes/kubernetes.io~empty-dir/api7-gateway-logs/access.log
prospector.scanner.exclude_files: ['__access\.log$', '\.tar\.gz$']
parsers:
- ndjson:
target: ""
add_error_key: true
processors:
- add_kubernetes_metadata:
host: ${NODE_NAME}
indexers:
- pod_uid: ~
matchers:
- logs_path:
logs_path: "/var/lib/kubelet/pods/"
resource_type: "pod"
- type: filestream
id: api7-gateway-error
paths:
- /var/lib/kubelet/pods/*/volumes/kubernetes.io~empty-dir/api7-gateway-logs/error.log
prospector.scanner.exclude_files: ['__error\.log$', '\.tar\.gz$']
parsers:
- multiline:
type: pattern
pattern: '^\d{4}/\d{2}/\d{2} \d{2}:\d{2}:\d{2}'
negate: true
match: after
processors:
- add_kubernetes_metadata:
host: ${NODE_NAME}
indexers:
- pod_uid: ~
matchers:
- logs_path:
logs_path: "/var/lib/kubelet/pods/"
resource_type: "pod"
ndjson 解析器在 target 为空时,会把 JSON 访问日志的字段提升到事件顶层。multiline 解析器会把 Lua 调用栈的后续行归并到起始行所在的条目中。
若要改为以边车方式运行 Filebeat,保持输入配置不变,把路径换成 /var/log/apisix/access.log 和 /var/log/apisix/error.log,并将 add_kubernetes_metadata 替换为通过 Downward API 填充的 add_fields。
add_kubernetes_metadata 的元数据监视器会延迟初始化。在元数据监视器完成同步之前 Filebeat 发出的事件不会带有 kubernetes.* 字段。这一点在首次启动且存在大量积压时最明显,因为 Filebeat 读取积压的速度快于元数据监视器就绪的速度。稳定运行后的事件不受影响。
验证日志采集
通过网关发送一个路径足够特殊的请求,便于后续检索:
curl "http://<gateway-address>:9080/collection-probe"
确认网关已经写入该条目。对于容器输出:
kubectl logs -n <namespace> <gateway-pod> -c gateway | grep collection-probe
对于文件输出:
kubectl exec -n <namespace> <gateway-pod> -c gateway -- grep collection-probe /var/log/apisix/access.log
然后在日志后端中检索同一字符串。如果条目到达了网关但没有到达后端,问题出在采集器;如果它根本没有到达网关,问题出在网关的日志配置。
故障排查
采集器提示没有匹配到文件。 请对照节点上的真实路径检查匹配规则。Pod 目录名在命名空间、Pod 名称和 UID 之间使用下划线分隔,而裸 Pod 没有自动生成的名称后缀。如果 Filebeat 读取的是 /var/log/containers/,请确认已设置 prospector.scanner.symlinks: true。
采集器启动了但没有任何输出。 请检查节点上运行时日志的权限,并确认采集器能够读取。在 containerd 的默认配置 下,/var/log/pods 下的文件权限为 0640、属主为 root,因此采集器通常需要以 root 身份运行。
时间戳不正确或回退成了摄入时间。 访问日志使用 $time_iso8601,它输出的 UTC 偏移量带冒号,例如 +08:00,而 strptime 的 %z 指令不接受这种形式,因此请改用 layout_type: gotime 配合 2006-01-02T15:04:05Z07:00。错误日志完全不带偏移量,其时间戳按配置的时区解释。如果给网关 Pod 设置了时区,请在 location 中填写相同的值,否则解析出的时间会发生偏移。
错误日志条目丢失了级别或时间戳。 Lua 调用栈跨越多行。这些行被合并之后,记录正文中包含换行符,而 Go 正则表达式中的 . 默认不匹配换行符。请在表达式中加上 (?s) 标志。
记录中没有 Kubernetes 元数据。 对于 OpenTelemetry Collector,请确认 pod_association 使用的属性确实由处理管道设置。对于 Filebeat,请检查受影响的事件是否是在启动期间、元数据监视器完成同步之前发出的。
卷被写满。 请确认已开启 log-rotate 插件。保留的轮转数据量可按「会被轮转的日志类型数 × max_kept × max_size」估算,再加上活动文件以及两次容量检查之间写入的余量。卷的 sizeLimit 和节点的可用存储都应大于该总量。同时注意四层访问日志不会被轮转。
后续步骤
- 配置集中式日志记录:日志类型、格式以及控制它们的配置项。
- 将 Kubernetes 错误日志发送到 Splunk:基于容器输出采集的 Splunk 专项指南。
- 在访问日志中包含消费者标签:在每行访问日志中加入消费者维度的元数据。
- 将访问日志发送到 Splunk:直接从路由推送访问日志,无需先落盘。