=== 一、现象 ===
- /logs 页「正在统计整个时间范围的日志量」24 小时查询转圈 5 分钟不出结果
- /analysis 页默认 1 小时也一直「加载中...」
- 期间 Loki 被 OOMKilled(2Gi) → Pod not Ready → Service 无端点 → 全站查询 502
=== 二、根因(实测数据) ===
同一 query {namespace="astralx", app="cex-ipublic-gateway"}:
24h count_over_time[15m] step=15m → 53.7s 后连接断开(算不完)
24h count_over_time[1h] step=1h → 60.1s 失败
24h index/stats(只读索引) → 1.3s → bytes 44.9GB / entries 3.33亿 / chunks 2474
24h index/volume_range step=1h → 111ms → 25 个点
1h index/volume_range step=5m → 115ms → 13 个点
原因:count_over_time 必须**下载并解码所有 chunk**;volume_range 只读 TSDB 索引里的
chunk 元数据(字节数),根本不碰日志内容。该 app 一天就有 3.33 亿条 / 44.9 GB。
=== 三、改造 ===
1. LokiHistogram 改用官方 /loki/api/v1/index/volume_range(带 step)
- 返回单位是「字节」,bucket 带 unit=bytes,前端格式化为 KB/MB/GB
- 老版本 Loki 没有该端点时自动回退 count_over_time(unit=entries)
2. 查询护栏(防止单条查询拖垮单实例 Loki)
- 统计范围上限 7 天,超出直接 400「统计范围过大(27.0 天),最多支持 7 天」
- 重查询单独 30s 超时(原来 120s)
- 全局并发重查询上限 2,超出立刻 429「请稍后重试」(不排队)
- /logs/metrics(analysis 页)共用同一套护栏
3. 前端
- 发起新查询前 abort 上一次请求(原来只用版本号丢弃结果,旧请求仍在后端跑)
- histogram/query 支持 signal;超时 40s/60s(原 125s)
- 错误不再统一显示「日志量统计失败」,按 429/400/502/超时给不同文案
- 时间线按 unit 格式化(字节 → GB),i18n 去掉「条」
4. Loki 侧官方优化(此前 limits_config 只有 retention_period)
- query_range: align_queries_with_step + cache_results + embedded_cache 100MB
- limits_config: max_query_parallelism 8(默认 32,单实例过高)
- 已用 `loki -verify-config` 校验通过(config is valid)
=== 四、实测(平台 API) ===
1 分钟 0.02s 2 桶
1 小时 0.05s 61 桶 3.8 GB
6 小时 0.04s 73 桶 12.3 GB
24 小时 0.04s 97 桶 41.8 GB ← 改造前 53.7s 失败
7 天 0.05s 37 桶 58.2 GB
27 天 0.01s 400「最多支持 7 天」
镜像:lghost/lokistack:latest + v0.1 = sha256:9fa1c4597533
410 lines
11 KiB
Go
410 lines
11 KiB
Go
// LokiDeployer — loki.yaml 配置生成:模板 + 对象存储/规则存储/ring 拓扑。
|
|
package services
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"text/template"
|
|
|
|
"lokistack/pkg/crypto"
|
|
)
|
|
|
|
const lokiConfigTemplate = `auth_enabled: {{ .AuthEnabled }}
|
|
|
|
common:
|
|
path_prefix: /var/lib/loki
|
|
{{- if .FilesystemStorage }}
|
|
storage:
|
|
filesystem:
|
|
chunks_directory: /var/lib/loki/chunks
|
|
rules_directory: /var/lib/loki/rules
|
|
{{- end }}
|
|
replication_factor: {{ .ReplicationFactor }}
|
|
ring:
|
|
instance_addr: {{ .RingInstanceAddr }}
|
|
kvstore:
|
|
store: {{ .RingKVStore }}
|
|
|
|
{{- if .StorageConfigBlock }}
|
|
|
|
storage_config:
|
|
{{ .StorageConfigBlock }}
|
|
{{- end }}
|
|
{{- if .MemberlistBlock }}
|
|
|
|
{{ .MemberlistBlock }}
|
|
{{- end }}
|
|
|
|
ingester:
|
|
wal:
|
|
dir: /var/lib/loki/wal
|
|
flush_on_shutdown: true
|
|
replay_memory_ceiling: 1GB
|
|
lifecycler:
|
|
ring:
|
|
kvstore:
|
|
store: {{ .RingKVStore }}
|
|
replication_factor: {{ .ReplicationFactor }}
|
|
{{- if .MultiReplica }}
|
|
autoforget_unhealthy: true
|
|
{{- end }}
|
|
|
|
compactor:
|
|
working_directory: /var/lib/loki/compactor
|
|
retention_enabled: true
|
|
delete_request_store: {{ .CompactorStoreType }}
|
|
|
|
limits_config:
|
|
retention_period: {{ .RetentionPeriod }}h
|
|
# 单实例 Loki 的并发护栏:默认 32 高得离谱,一条重查询就能把单进程打满
|
|
max_query_parallelism: 8
|
|
|
|
# ── 查询结果缓存(官方推荐;此前完全没配,重复查询要重算一遍)───────
|
|
# align_queries_with_step 让不同时间范围的同类查询对齐到同一 step,
|
|
# 显著提高缓存命中率;命中后同样的"日志量统计"直接从缓存返回。
|
|
query_range:
|
|
align_queries_with_step: true
|
|
cache_results: true
|
|
results_cache:
|
|
cache:
|
|
embedded_cache:
|
|
enabled: true
|
|
max_size_mb: 100
|
|
|
|
schema_config:
|
|
configs:
|
|
- from: "2024-01-01"
|
|
store: tsdb
|
|
object_store: {{ .ObjectStore }}
|
|
schema: v13
|
|
index:
|
|
prefix: tsdb_index_
|
|
period: 24h
|
|
|
|
ruler:
|
|
storage:
|
|
type: {{ .RulerStoreType }}
|
|
{{- if .RulerStorageBlock }}
|
|
{{ .RulerStorageBlock }}
|
|
{{- end }}
|
|
{{- if eq .RulerStoreType "local" }}
|
|
local:
|
|
directory: /var/lib/loki/rules
|
|
{{- end }}
|
|
rule_path: /var/lib/loki/rules-temp
|
|
ring:
|
|
kvstore:
|
|
store: {{ .RingKVStore }}
|
|
|
|
analytics:
|
|
reporting_enabled: false
|
|
`
|
|
|
|
type lokiConfigData struct {
|
|
AuthEnabled bool
|
|
ReplicationFactor int
|
|
RetentionPeriod int
|
|
StorageConfigBlock string
|
|
ObjectStore string
|
|
RulerStoreType string
|
|
CompactorStoreType string
|
|
RingInstanceAddr string // 单副本 127.0.0.1;多副本 0.0.0.0(须可被对端寻址)
|
|
RingKVStore string // 单副本 inmemory;多副本 memberlist
|
|
MemberlistBlock string // 多副本时非空,注入 memberlist 顶级配置
|
|
RulerStorageBlock string // 对象存储时非空,注入 ruler.storage.s3/gcs/azure 嵌套配置
|
|
MultiReplica bool
|
|
FilesystemStorage bool
|
|
}
|
|
|
|
func (d *LokiDeployer) generateLokiConfig() string {
|
|
retentionHours := d.deployment.RetentionDays * 24
|
|
if retentionHours <= 0 {
|
|
retentionHours = DefaultRetentionDays * 24
|
|
}
|
|
|
|
storageType := d.effectiveStorageType()
|
|
storageBlock := d.buildStorageConfigBlock()
|
|
objectStore := "filesystem"
|
|
rulerStore := "local"
|
|
compactorStore := "filesystem"
|
|
|
|
switch storageType {
|
|
case "s3", "oss", "minio", "cos", "obs":
|
|
objectStore = "s3"
|
|
rulerStore = "s3"
|
|
// 删除请求存本地盘,避免各云 S3 兼容实现差异导致 compactor 启动失败
|
|
compactorStore = "filesystem"
|
|
case "gcs":
|
|
objectStore = "gcs"
|
|
rulerStore = "gcs"
|
|
compactorStore = "gcs"
|
|
case "azure":
|
|
objectStore = "azure"
|
|
rulerStore = "azure"
|
|
compactorStore = "azure"
|
|
}
|
|
|
|
// Ring 拓扑:单副本用 inmemory + 127.0.0.1(无需跨实例通信);
|
|
// 多副本必须切 memberlist + 0.0.0.0,否则 ring 无法收敛、写入失败。
|
|
ringAddr := "127.0.0.1"
|
|
ringStore := "inmemory"
|
|
memberlistBlock := ""
|
|
if d.deployment.Replicas >= 2 {
|
|
ringAddr = "0.0.0.0"
|
|
ringStore = "memberlist"
|
|
ns := d.deployment.Namespace
|
|
if ns == "" {
|
|
ns = "log"
|
|
}
|
|
memberlistBlock = fmt.Sprintf(`memberlist:
|
|
join_members:
|
|
- loki-headless.%s.svc.cluster.local
|
|
bind_port: 7946
|
|
dead_node_reclaim_time: 30s
|
|
gossip_to_dead_nodes_time: 2m
|
|
left_ingesters_timeout: 5m`, ns)
|
|
}
|
|
|
|
data := lokiConfigData{
|
|
AuthEnabled: d.deployment.AuthEnabled,
|
|
ReplicationFactor: d.replicationFactor(),
|
|
RetentionPeriod: retentionHours,
|
|
StorageConfigBlock: storageBlock,
|
|
ObjectStore: objectStore,
|
|
RulerStoreType: rulerStore,
|
|
CompactorStoreType: compactorStore,
|
|
RingInstanceAddr: ringAddr,
|
|
RingKVStore: ringStore,
|
|
MemberlistBlock: memberlistBlock,
|
|
RulerStorageBlock: d.buildRulerStorageBlock(),
|
|
MultiReplica: d.deployment.Replicas >= 2,
|
|
FilesystemStorage: objectStore == "filesystem",
|
|
}
|
|
|
|
tmpl, err := template.New("loki-config").Parse(lokiConfigTemplate)
|
|
if err != nil {
|
|
return d.generateLokiConfigFallback(retentionHours)
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
if err := tmpl.Execute(&buf, data); err != nil {
|
|
return d.generateLokiConfigFallback(retentionHours)
|
|
}
|
|
return buf.String()
|
|
}
|
|
|
|
func (d *LokiDeployer) buildStorageConfigBlock() string {
|
|
if d.deployment.StorageConfig == nil {
|
|
return ""
|
|
}
|
|
|
|
sc := d.deployment.StorageConfig
|
|
accessKey, secretKey := d.storageCredentialValues()
|
|
switch sc.StorageType {
|
|
case "s3":
|
|
endpoint := sc.Endpoint
|
|
if endpoint == "" {
|
|
endpoint = fmt.Sprintf("s3.%s.amazonaws.com", sc.Region)
|
|
}
|
|
insecure := "false"
|
|
if sc.Insecure {
|
|
insecure = "true"
|
|
}
|
|
return fmt.Sprintf(` aws:
|
|
bucketnames: %s
|
|
endpoint: %s
|
|
region: %s
|
|
access_key_id: %s
|
|
secret_access_key: %s
|
|
insecure: %s
|
|
s3forcepathstyle: true`, sc.Bucket, endpoint, sc.Region, accessKey, secretKey, insecure)
|
|
case "oss", "cos", "obs":
|
|
insecure := "false"
|
|
if sc.Insecure {
|
|
insecure = "true"
|
|
}
|
|
// 云厂商 S3 兼容端点有两种写法:
|
|
// 区域端点 cos.ap-hongkong.myqcloud.com ← 正确
|
|
// 虚拟主机式 mybucket-1250000000.cos.<region>... ← 桶名已在 host 里
|
|
// Loki 的 S3 客户端会自己把 bucketnames 拼到 endpoint 前面(s3forcepathstyle=false
|
|
// 即虚拟主机风格),所以 endpoint 必须是区域端点。若用户在平台里填了带桶名的写法,
|
|
// 这里剥掉桶名前缀,否则会拼出 <bucket>.<bucket>.cos... 导致 TLS 证书校验失败、
|
|
// 日志写入失败(ruler/ingester 持续报 x509 证书不匹配)。
|
|
endpoint := stripBucketPrefix(sc.Endpoint, sc.Bucket)
|
|
return fmt.Sprintf(` aws:
|
|
bucketnames: %s
|
|
endpoint: %s
|
|
region: %s
|
|
access_key_id: %s
|
|
secret_access_key: %s
|
|
insecure: %s
|
|
s3forcepathstyle: false`, sc.Bucket, endpoint, sc.Region, accessKey, secretKey, insecure)
|
|
case "minio":
|
|
insecure := "false"
|
|
if sc.Insecure {
|
|
insecure = "true"
|
|
}
|
|
return fmt.Sprintf(` aws:
|
|
bucketnames: %s
|
|
endpoint: %s
|
|
access_key_id: %s
|
|
secret_access_key: %s
|
|
insecure: %s
|
|
s3forcepathstyle: true`, sc.Bucket, stripBucketPrefix(sc.Endpoint, sc.Bucket), accessKey, secretKey, insecure)
|
|
case "gcs":
|
|
return fmt.Sprintf(` gcs:
|
|
bucket_name: %s`, sc.Bucket)
|
|
case "azure":
|
|
return fmt.Sprintf(` azure:
|
|
container_name: %s
|
|
account_name: %s
|
|
account_key: %s`, sc.Bucket, accessKey, secretKey)
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// buildRulerStorageBlock 生成 ruler.storage 下与存储类型同名的嵌套配置
|
|
// (Loki 3.x 的 ruler 存储客户端不回退读取 storage_config,缺少此块会以空 bucket 启动失败)
|
|
func (d *LokiDeployer) buildRulerStorageBlock() string {
|
|
if d.deployment.StorageConfig == nil {
|
|
return ""
|
|
}
|
|
sc := d.deployment.StorageConfig
|
|
accessKey, secretKey := d.storageCredentialValues()
|
|
switch sc.StorageType {
|
|
case "s3":
|
|
endpoint := sc.Endpoint
|
|
if endpoint == "" {
|
|
endpoint = fmt.Sprintf("s3.%s.amazonaws.com", sc.Region)
|
|
}
|
|
insecure := "false"
|
|
if sc.Insecure {
|
|
insecure = "true"
|
|
}
|
|
return fmt.Sprintf(` s3:
|
|
bucketnames: %s
|
|
endpoint: %s
|
|
region: %s
|
|
access_key_id: %s
|
|
secret_access_key: %s
|
|
insecure: %s
|
|
s3forcepathstyle: true`, sc.Bucket, endpoint, sc.Region, accessKey, secretKey, insecure)
|
|
case "oss", "cos", "obs":
|
|
insecure := "false"
|
|
if sc.Insecure {
|
|
insecure = "true"
|
|
}
|
|
return fmt.Sprintf(` s3:
|
|
bucketnames: %s
|
|
endpoint: %s
|
|
region: %s
|
|
access_key_id: %s
|
|
secret_access_key: %s
|
|
insecure: %s
|
|
s3forcepathstyle: false`, sc.Bucket, sc.Endpoint, sc.Region, accessKey, secretKey, insecure)
|
|
case "minio":
|
|
insecure := "false"
|
|
if sc.Insecure {
|
|
insecure = "true"
|
|
}
|
|
return fmt.Sprintf(` s3:
|
|
bucketnames: %s
|
|
endpoint: %s
|
|
access_key_id: %s
|
|
secret_access_key: %s
|
|
insecure: %s
|
|
s3forcepathstyle: true`, sc.Bucket, sc.Endpoint, accessKey, secretKey, insecure)
|
|
case "gcs":
|
|
return fmt.Sprintf(` gcs:
|
|
bucket_name: %s`, sc.Bucket)
|
|
case "azure":
|
|
return fmt.Sprintf(` azure:
|
|
container_name: %s
|
|
account_name: %s
|
|
account_key: %s`, sc.Bucket, accessKey, secretKey)
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func (d *LokiDeployer) storageCredentialValues() (string, string) {
|
|
if d.deployment.StorageConfig == nil {
|
|
return "", ""
|
|
}
|
|
if d.deployment.Mode == "k8s-yaml" || d.deployment.Mode == "host-docker" {
|
|
return "${LOKI_STORAGE_ACCESS_KEY}", "${LOKI_STORAGE_SECRET_KEY}"
|
|
}
|
|
// host-docker 模式没有 Secret 注入,必须把库里 ENC: 密文解密后再写进配置
|
|
return crypto.DecryptField(d.deployment.StorageConfig.AccessKey),
|
|
crypto.DecryptField(d.deployment.StorageConfig.SecretKey)
|
|
}
|
|
|
|
// generateLokiConfigFallback 当模板解析失败时的降级配置
|
|
func (d *LokiDeployer) generateLokiConfigFallback(retentionHours int) string {
|
|
return fmt.Sprintf(`auth_enabled: %v
|
|
server:
|
|
http_listen_port: 3100
|
|
grpc_listen_port: 9095
|
|
http_server_read_timeout: 3m
|
|
http_server_write_timeout: 3m
|
|
log_level: info
|
|
common:
|
|
path_prefix: /var/lib/loki
|
|
storage:
|
|
filesystem:
|
|
chunks_directory: /var/lib/loki/chunks
|
|
rules_directory: /var/lib/loki/rules
|
|
replication_factor: %d
|
|
ring:
|
|
instance_addr: 127.0.0.1
|
|
kvstore:
|
|
store: inmemory
|
|
ingester:
|
|
wal:
|
|
enabled: true
|
|
dir: /var/lib/loki/wal
|
|
lifecycler:
|
|
ring:
|
|
kvstore:
|
|
store: inmemory
|
|
replication_factor: %d
|
|
final_sleep: 0s
|
|
chunk_idle_period: 30m
|
|
max_chunk_age: 2h
|
|
querier:
|
|
max_concurrent: 10
|
|
compactor:
|
|
working_directory: /var/lib/loki/compactor
|
|
retention_enabled: true
|
|
retention_delete_delay: 2h
|
|
limits_config:
|
|
retention_period: %dh
|
|
ingestion_rate_mb: 64
|
|
ingestion_burst_size_mb: 128
|
|
schema_config:
|
|
configs:
|
|
- from: "2024-01-01"
|
|
store: tsdb
|
|
object_store: filesystem
|
|
schema: v13
|
|
index:
|
|
prefix: tsdb_index_
|
|
period: 24h
|
|
analytics:
|
|
reporting_enabled: false
|
|
`, d.deployment.AuthEnabled, d.replicationFactor(), d.replicationFactor(), retentionHours)
|
|
}
|
|
|
|
// ==================== 辅助函数 ====================
|
|
|
|
func (d *LokiDeployer) replicationFactor() int {
|
|
if d.deployment.Replicas >= 3 {
|
|
return 3
|
|
}
|
|
if d.deployment.Replicas >= 2 {
|
|
return 2
|
|
}
|
|
return 1
|
|
}
|