基于 GitOps 自动化部署 Trino 实现对 Loki 日志与 NoSQL 元数据的联邦化性能诊断


一个 Trino 查询突然变慢,Query ID 是 20231027_083000_12345_abcde。现在,你需要做什么?传统的做法是SSH到 Trino coordinator 和多个 worker 节点上,用 grep 在海量日志文件中搜索这个ID,试图拼凑出执行计划、资源争用、或者潜在的GC停顿。这个过程不仅耗时,而且在动态伸缩的云原生环境中,节点来了又走,日志可能已经丢失。这是一个典型的、高压的生产环境问题,它暴露了可观测性的碎片化。

我们的团队也长期被这个问题困扰。我们拥有强大的 Trino 集群处理海量数据联邦查询,使用 Loki 收集所有容器日志,并通过 GitOps (ArgoCD) 管理着整个基础设施的声明式状态。每个工具都运行良好,但它们是孤立的。排查问题时,工程师需要在多个系统之间手动关联信息,效率极低。

痛点催生了构想:为什么不能用 Trino 自己来解决 Trino 的问题?Trino 的核心能力是联邦查询——跨越不同数据源进行统一查询。如果我们将 Loki 的日志流和记录着关键上下文的元数据存储(例如一个 NoSQL 键值数据库)都作为 Trino 的数据源,我们就可以用一条 SQL 来完成过去需要数小时手动操作才能完成的复杂诊断。

这个方案的核心在于构建一个闭环的、自动化的诊断平台。所有组件,包括 Trino 本身、它的数据源连接器、以及日志收集代理,都通过 GitOps 进行管理。这不仅保证了环境的一致性,也使得整个诊断体系的演进变得可追溯、可审计。

技术选型与架构决策

  1. Trino 作为查询引擎: 这是不二之选。我们用它来查询它自己的运行状态,利用其强大的连接器生态。
  2. Loki 作为日志数据源: Loki 的优势在于其基于标签的索引模型,对于云原生环境非常友好且成本可控。我们将通过 trino-loki 连接器将其接入 Trino。
  3. Redis 作为 NoSQL 元数据存储: 我们需要一个地方存储查询ID与“外部世界”的关联信息,比如触发这个查询的业务批次ID、Git commit hash、或者执行该查询的用户的团队信息。Redis 速度快、部署简单,作为键值存储非常适合这种高频写入、快速读取的场景。我们将使用 trino-redis 连接器。
  4. ArgoCD 与 Kustomize 作为 GitOps 实现: 我们使用 ArgoCD 来同步 Git 仓库中的声明式配置到 Kubernetes 集群。Kustomize 则用于管理不同环境(开发、预发、生产)的配置差异,尤其是 Trino 连接器的配置。

整体架构如下:

graph TD
    subgraph Git Repository
        direction LR
        A[ArgoCD App Manifests]
        B[Kustomize Overlays]
        C[Trino Connector Configs]
        D[Loki Configs]
    end

    subgraph Kubernetes Cluster
        direction TB
        F[ArgoCD Controller] --Syncs--> G
        subgraph Trino Ecosystem
            G[Trino Coordinator]
            H[Trino Workers]
            I[Loki Connector ConfigMap]
            J[Redis Connector ConfigMap]
        end
        subgraph Observability Stack
            K[Loki]
            L[Redis]
            M[Log Forwarder e.g., Promtail]
        end
        G --Federated Query--> K
        G --Federated Query--> L
        H --Writes Logs--> M --Sends Logs--> K
    end

    E[Developer/SRE] --kubectl apply or git push--> A
    E --Writes SQL--> G
    subgraph CI/CD Pipeline
        P[Event Listener] --Query Event--> Q[Metadata Injection]
    end
    G --Emits Event--> P
    Q --Writes Metadata--> L

    linkStyle 0 stroke:#ff9900,stroke-width:2px;
    linkStyle 1 stroke:#ff9900,stroke-width:2px;
    linkStyle 2 stroke:#ff9900,stroke-width:2px;
    linkStyle 3 stroke:#ff9900,stroke-width:2px;

这里的关键点在于,不仅应用本身,连 Trino 的连接器配置 (I)(J) 也是通过 GitOps 管理的 ConfigMap。当我们需要添加一个新的数据源或调整现有连接器的参数时,我们修改的不再是服务器上的文件,而是 Git 仓库中的 YAML。

GitOps 驱动的配置管理实践

我们的 Git 仓库结构如下,清晰地分离了基础配置和环境特定配置:

trino-platform/
├── base/
│   ├── trino/
│   │   ├── coordinator-deployment.yaml
│   │   ├── worker-statefulset.yaml
│   │   ├── service.yaml
│   │   ├── configmap-trino-properties.yaml
│   │   └── configmap-connectors.yaml # 基础连接器配置占位
│   ├── loki/
│   │   └── ...
│   └── redis/
│       └── ...
│   └── kustomization.yaml
└── overlays/
    ├── production/
    │   ├── patch-coordinator-resources.yaml
    │   ├── patch-worker-replicas.yaml
    │   ├── configmap-loki-connector.yaml
    │   ├── configmap-redis-connector.yaml
    │   └── kustomization.yaml
    └── staging/
        └── ...

base/trino/configmap-connectors.yaml 中,我们可能只有一个空的占位符或通用配置。真正的配置在 overlays/production 中定义。

overlays/production/configmap-loki-connector.yaml 文件内容如下。这是声明式配置的核心。

# overlays/production/configmap-loki-connector.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: trino-connector-loki
  namespace: trino
data:
  # 此文件名 `loki.properties` 将作为 catalog 名称
  loki.properties: |
    connector.name=loki
    loki.uri=http://loki.monitoring.svc.cluster.local:3100
    # 在生产中,我们关心最近14天的数据
    loki.max-age=14d
    # 增加每次请求的行数,以提高大日志扫描的性能
    loki.page-size=5000
    # 生产环境API请求超时
    loki.client.read-timeout=5m

overlays/production/configmap-redis-connector.yaml 文件内容:

# overlays/production/configmap-redis-connector.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: trino-connector-redis
  namespace: trino
data:
  # Catalog 名称为 `redis_meta`
  redis_meta.properties: |
    connector.name=redis
    redis.nodes=redis-master.data.svc.cluster.local:6379
    # 我们将元数据存储在DB 1,与缓存数据隔离
    redis.database-index=1
    # 我们的元数据 Key 格式是 `trino:query:meta:{queryId}`
    redis.key-prefix=trino:query:meta:
    # 假设所有元数据都是 JSON 字符串
    redis.default-schema=default
    # 关键性能参数:避免扫描大Key空间
    redis.hide-internal-columns=true
    redis.key-delimiter=:

然后,overlays/production/kustomization.yaml 文件将这些配置组合起来:

# overlays/production/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: trino
resources:
  - ../../base

# 使用 strategic merge patch 来添加或覆盖配置
configMapGenerator:
  - name: trino-connector-loki
    behavior: merge
    files:
      - loki.properties=./configmap-loki-connector.yaml
  - name: trino-connector-redis
    behavior: merge
    files:
      - redis_meta.properties=./configmap-redis-connector.yaml

patches:
  - path: patch-coordinator-resources.yaml
    target:
      kind: Deployment
      name: trino-coordinator
  - path: patch-worker-replicas.yaml
    target:
      kind: StatefulSet
      name: trino-worker

Trino Coordinator 的 Deployment 需要挂载这些 ConfigMap/etc/trino/catalog 目录下。当我们将这些变更推送到 Git 主分支,ArgoCD 会自动检测到差异并应用到集群中,触发 Trino Coordinator 的滚动更新,新的 catalog 随即生效,整个过程无人值守。

关键环节:元数据注入

有了连接器,数据从哪里来?Loki 的日志由 Promtail 自动收集,但 Redis 中的元数据需要我们主动写入。一个常见的错误是让业务应用代码来做这件事,这会造成业务逻辑和运维逻辑的耦合。

我们选择的方案是利用 Trino 的 EventListener 机制。我们可以开发一个轻量级的插件,监听 Trino 的查询创建和完成事件 (queryCreated / queryCompleted)。

下面是一个简化的 EventListener 实现思路(伪代码),它会在查询创建时捕获元数据并写入 Redis。

// Simplified Java code for a Trino EventListener
import io.trino.spi.eventlistener.EventListener;
import io.trino.spi.eventlistener.QueryCreatedEvent;
import io.trino.spi.eventlistener.QueryCompletedEvent;
import redis.clients.jedis.Jedis;
import com.google.gson.Gson;
import java.util.Map;

public class RedisMetadataEventListener implements EventListener {

    private final Jedis jedis;
    private final Gson gson = new Gson();

    public RedisMetadataEventListener(Map<String, String> config) {
        String redisHost = config.getOrDefault("redis.host", "localhost");
        int redisPort = Integer.parseInt(config.getOrDefault("redis.port", "6379"));
        this.jedis = new Jedis(redisHost, redisPort);
        // Add authentication and other configs as needed
    }

    @Override
    public void queryCreated(QueryCreatedEvent event) {
        try {
            String queryId = event.getMetadata().getQueryId();
            String user = event.getContext().getUser();
            // X-Trino-Source 头部通常由客户端(如 DBeaver, Superset)设置
            String source = event.getContext().getSource().orElse("unknown");
            // 通过 session 属性传递业务上下文
            String businessBatchId = event.getContext().getSessionProperties().get("business_batch_id");
            
            Map<String, Object> metadata = Map.of(
                "queryId", queryId,
                "user", user,
                "source", source,
                "businessBatchId", businessBatchId,
                "createQueryTime", event.getCreateTime().toString(),
                "query", event.getMetadata().getQuery()
            );

            String redisKey = "trino:query:meta:" + queryId;
            String jsonPayload = gson.toJson(metadata);

            // 设置一个合理的过期时间,比如7天,防止Redis被撑爆
            // 在生产中,这里应该有重试和错误处理逻辑
            jedis.setex(redisKey, 60 * 60 * 24 * 7, jsonPayload);

        } catch (Exception e) {
            // 在真实项目中,这里需要接入监控和告警,而不仅仅是打印日志
            System.err.println("Failed to write query metadata to Redis: " + e.getMessage());
        }
    }

    @Override
    public void queryCompleted(QueryCompletedEvent event) {
        // 同样可以在这里更新查询的结束时间、状态等信息
    }
}

这个插件打包成 JAR 后,通过 GitOps 管理的 ConfigMapinitContainer 分发到 Trino 插件目录,并通过 Trino 的配置文件启用。这样,我们就建立了一个自动化管道,将每次查询的上下文信息持久化到了 Redis 中。

联邦化性能诊断实战

回到最初的问题,排查 Query ID 20231027_083000_12345_abcde。现在,我们不再需要 SSH。我们打开一个 SQL 客户端,连接到同一个 Trino 集群,执行以下查询:

-- This is a production-grade federated query for performance diagnostics.
-- It joins logs from Loki with query metadata from Redis to provide a holistic view.

WITH query_meta AS (
    -- Step 1: 从 Redis 中获取指定 QueryID 的上下文元数据。
    -- `redis_meta` 是我们在 GitOps 中定义的 catalog。
    -- `default.trino:query:meta` 对应 Redis 的 schema 和表 (key pattern)。
    SELECT
        CAST(json_extract(v, '$.queryId') AS VARCHAR) AS query_id,
        CAST(json_extract(v, '$.user') AS VARCHAR) AS user,
        CAST(json_extract(v, '$.source') AS VARCHAR) AS source,
        CAST(json_extract(v, '$.businessBatchId') AS VARCHAR) AS business_batch_id,
        from_iso8601_timestamp(CAST(json_extract(v, '$.createQueryTime') AS VARCHAR)) AS create_time
    FROM redis_meta.default."trino:query:meta"
    WHERE k = '20231027_083000_12345_abcde' -- 直接按 key 过滤,效率极高
),
trino_logs AS (
    -- Step 2: 从 Loki 中检索与该 QueryID 相关的所有日志。
    -- `loki` 是 catalog name. `default` 是 schema.
    -- `"{app=\"trino-coordinator\"} or {app=\"trino-worker\"}"` 是 Loki 的流选择器 (stream selector)。
    -- 我们只查询查询创建之后一小时内的日志,避免全表扫描。
    SELECT
        ts AS log_time,
        -- 从原始日志行中提取关键信息,这里假设日志是 JSON 格式
        json_extract_scalar(line, '$.thread') AS thread_name,
        json_extract_scalar(line, '$.level') AS log_level,
        json_extract_scalar(line, '$.message') AS message,
        -- 从标签中提取 pod 名称,用于定位问题节点
        map_extract_key(labels, 'pod') AS pod_name
    FROM loki.default."{app=\"trino-coordinator\"} or {app=\"trino-worker\"}"
    WHERE 
        line LIKE '%20231027_083000_12345_abcde%' -- 初步过滤,利用 Loki 的索引加速
        AND ts BETWEEN (SELECT create_time FROM query_meta) AND (SELECT create_time FROM query_meta) + interval '1' hour
)
-- Step 3: 将元数据和日志数据连接起来,并进行分析。
SELECT
    m.query_id,
    m.user,
    m.source,
    m.business_batch_id,
    l.pod_name,
    l.log_time,
    l.log_level,
    l.message
FROM query_meta m
LEFT JOIN trino_logs l ON 1=1 -- 简单连接,因为日志已按 QueryID 过滤
WHERE
    -- 过滤出我们关心的 WARN 或 ERROR 级别日志
    l.log_level IN ('WARN', 'ERROR')
    -- 或者查找特定的性能瓶颈信息,例如 GC 暂停或 Split 处理慢
    OR l.message LIKE '%Full GC%'
    OR l.message LIKE '%Split running for more than%'
ORDER BY l.log_time;

这条 SQL 查询的威力在于:

  1. 跨源关联: JOIN 操作将 Redis 中结构化的元数据与 Loki 中半结构化的日志数据无缝连接。
  2. 上下文丰富: 我们立刻就能知道这个慢查询是由哪个用户、通过哪个工具、为哪个业务批次提交的。
  3. 精准定位: 结果直接显示了问题发生在哪个 pod 上,以及具体的警告或错误信息。
  4. 效率提升: 原本数小时的人工排查,现在变成了数秒或数分钟的 SQL 查询。

局限性与未来展望

这套体系虽然强大,但也并非银弹。

首先,Redis 作为元数据存储,其持久性和容量有限。对于需要长期追溯和分析查询性能趋势的场景,将 EventListener 的数据写入到一个更持久的存储,如 ClickHouse 或数据湖(例如 Apache Iceberg 表)中会是更好的选择。Trino 同样可以连接这些数据源,查询逻辑保持不变。

其次,EventListener 本身是一个需要维护的组件,它增加了系统的复杂性。如果 Trino 社区未来能够提供基于 OpenTelemetry 的原生事件导出,那么我们可以用更标准化的方式来捕获这些元数据,减少定制化开发。

最后,目前的诊断还主要依赖日志。下一步的演进方向是将第三大可观测性支柱——指标(Metrics)也纳入联邦查询的范畴。通过 trino-prometheus 连接器,我们可以将 Trino worker 的 CPU、内存、GC次数等指标与日志、元数据关联起来,提供一个更加立体化的故障诊断视图。例如,我们可以查询出在慢查询发生的时间窗口内,哪些 worker 节点的 GC 次数出现了异常飙升。

这个基于 GitOps 和联邦查询的诊断平台,彻底改变了我们团队的运维模式。它将繁琐的手动任务转化为可复用、可版本化的 SQL 查询,将工程师从救火的泥潭中解放出来,使其能更专注于性能优化和架构演进本身。


  目录