实现 MongoDB 驱动的 Trino 集群声明式 GitOps 部署自动化


管理多个 Trino 集群是一项繁琐且易错的工作。不同业务团队对计算资源、数据源连接器、JVM 参数的需求各不相同,导致配置文件急剧膨胀。传统的手动修改 Helm values.yaml 并执行 helm upgrade 的方式,不仅效率低下,而且缺乏审计、回滚和环境一致性的保障。当集群数量超过个位数时,这种模式的运维成本便无法接受。

我们的目标是构建一个平台,允许数据分析师或平台用户通过一个简单的接口来“声明”他们所需的 Trino 集群规格,后续的资源配置、部署、更新和销毁应完全自动化。这里的核心挑战在于,如何将动态的、来自用户输入的集群定义,转化为 Kubernetes 环境中静态的、由 GitOps 工具管理的声明式配置。

经过评估,我们确定了如下技术栈:

  • MongoDB: 作为动态集群配置的“单一事实来源”。其灵活的文档模型非常适合存储结构复杂的 Trino 集群定义,包括内嵌的连接器配置、资源请求等。
  • Ansible: 充当编排引擎。它将作为“翻译官”,定期从 MongoDB 拉取集群定义,利用其强大的模板能力(Jinja2)生成对应的 Kubernetes 清单和 Argo CD Application 定义。
  • Argo CD: 作为 GitOps 的核心执行者。它只关心 Git 仓库中的状态,并确保 Kubernetes 集群的状态与 Git 中定义的保持一致。
  • Trino on Kubernetes: 使用官方或社区维护的 Helm Chart 进行部署,这是目前在 K8s 上运行 Trino 的主流方式。

整个工作流的设计必须保证幂等性和声明式特性。无论 Ansible playbook 运行多少次,对于一个没有变化的 MongoDB 文档,其在 Git 仓库中生成的配置都应该是稳定不变的。

数据模型:在 MongoDB 中定义 Trino 集群

一切始于一个健壮的数据模型。我们需要在 MongoDB 的一个 collection(例如 trino_clusters)中定义一个文档结构,该结构必须足以描述一个完整的 Trino 集群。

一个真实项目中的文档结构可能如下所示。这里的关键在于将配置分层,并且允许定义一个动态的 connectors 数组,每个元素都直接对应一个 Trino 连接器配置文件。

{
    "_id": { "$oid": "653a1a7b8e1f4a1e9c5d8e1a" },
    "clusterId": "analytics-prod",
    "owner": "data_analytics_team",
    "enabled": true,
    "trinoVersion": "426",
    "clusterConfig": {
        "coordinator": {
            "replicas": 1,
            "resources": {
                "requests": { "cpu": "4", "memory": "16Gi" },
                "limits": { "cpu": "8", "memory": "24Gi" }
            },
            "jvm": {
                "maxHeapSize": "20G"
            }
        },
        "worker": {
            "replicas": 5,
            "resources": {
                "requests": { "cpu": "8", "memory": "32Gi" },
                "limits": { "cpu": "8", "memory": "32Gi" }
            },
            "jvm": {
                "maxHeapSize": "28G"
            }
        }
    },
    "properties": {
        "query": {
            "max-memory": "100TB",
            "max-memory-per-node": "26GB"
        },
        "node": {
            "environment": "production"
        }
    },
    "connectors": [
        {
            "name": "hive-prod",
            "properties": {
                "connector.name": "hive",
                "hive.metastore.uri": "thrift://hive-metastore.data.svc.cluster.local:9083",
                "hive.config.resources": "/etc/trino/hadoop/core-site.xml,/etc/trino/hadoop/hdfs-site.xml"
            }
        },
        {
            "name": "mysql-rds",
            "properties": {
                "connector.name": "mysql",
                "connection-url": "jdbc:mysql://prod-rds.ap-east-1.rds.amazonaws.com:3306",
                "connection-user": "trino_user",
                "connection-password": "{{ MYSQL_RDS_PASSWORD }}"
            }
        },
        {
            "name": "tpch",
            "properties": {
                "connector.name": "tpch"
            }
        }
    ],
    "lastUpdated": { "$date": "2023-10-27T00:00:00.000Z" }
}

这个模型有几个设计考量:

  1. clusterId: 既是 MongoDB 的业务主键,也将用作 Kubernetes 中的 namespace 和 Argo CD Application 的名称,保证全局唯一。
  2. enabled: 一个布尔标记,用于软删除或禁用集群。我们的 Ansible 脚本将只处理 enabled: true 的文档。
  3. connectors: 这是一个对象数组,每个对象包含连接器名称 (name) 和一个键值对的 properties。这种结构可以直接映射到 Trino 的 catalog 目录下的配置文件。
  4. 密码处理:注意 mysql-rds 连接器中的 connection-password。我们没有硬编码密码,而是使用了一个占位符 {{ MYSQL_RDS_PASSWORD }}。这暗示我们的自动化流程需要与某种 Secret 管理系统(如 HashiCorp Vault 或 Kubernetes Secrets)集成。在实际生成配置文件时,Ansible 会负责将这个占位符替换为真实的 secret 值。

Ansible 作为编排核心:从 MongoDB 到 Git

Ansible 的角色是周期性地执行一个 playbook,完成从读数据库到写 Git 的全过程。这个 playbook 通常由 CI/CD 工具(如 Jenkins、GitLab CI)或一个 Kubernetes CronJob 来调度执行。

项目结构

一个典型的 Ansible 项目结构如下:

.
├── ansible.cfg
├── inventory
├── playbook.yml
├── roles
│   └── trino_cluster_generator
│       ├── tasks
│       │   └── main.yml
│       └── templates
│           ├── argo-app.yaml.j2
│           ├── catalog
│           │   └── connector.properties.j2
│           └── values.yaml.j2
└── vars
    └── secrets.yml

核心 Playbook 逻辑 (playbook.yml)

---
- name: Generate and sync Trino cluster configurations for GitOps
  hosts: localhost
  connection: local
  gather_facts: false

  vars:
    gitops_repo_path: "/path/to/local/clone/of/gitops-repo"
    mongodb_uri: "mongodb://user:[email protected]:27017/trino_config_db"
    
  tasks:
    - name: Ensure GitOps repo path exists
      ansible.builtin.file:
        path: "{{ gitops_repo_path }}/trino-clusters"
        state: directory
        mode: '0755'

    - name: Fetch active Trino cluster definitions from MongoDB
      community.mongodb.mongodb_info:
        uri: "{{ mongodb_uri }}"
        database: "trino_config_db"
        collection: "trino_clusters"
        filter:
          enabled: true
      register: mongo_results
      # 在真实项目中,错误处理至关重要
      # 如果数据库连接失败,整个流程应该立即中止并告警
      failed_when: mongo_results.failed

    - name: Include the generation role for each cluster
      ansible.builtin.include_role:
        name: trino_cluster_generator
        tasks_from: main.yml
      loop: "{{ mongo_results.actions[0].documents }}"
      loop_control:
        loop_var: cluster_definition

    - name: Commit and push changes to GitOps repository
      block:
        - name: Check for changes in the git repository
          ansible.builtin.command: git status --porcelain
          args:
            chdir: "{{ gitops_repo_path }}"
          register: git_status
          changed_when: git_status.stdout != ""

        - name: Commit and push if there are changes
          when: git_status.changed
          block:
            - name: Add all changes
              ansible.builtin.command: git add .
              args:
                chdir: "{{ gitops_repo_path }}"

            - name: Commit changes
              ansible.builtin.command: "git commit -m 'Automated sync of Trino cluster definitions'"
              args:
                chdir: "{{ gitops_repo_path }}"

            - name: Push to remote
              ansible.builtin.command: git push origin main
              args:
                chdir: "{{ gitops_repo_path }}"
      rescue:
        - name: Git operation failed
          ansible.builtin.fail:
            msg: "Failed to commit or push to GitOps repo. Please check credentials and repository state."

核心生成逻辑 (roles/trino_cluster_generator/tasks/main.yml)

这个 role 是整个流程的核心,它接收 loop 传来的单个 cluster_definition 变量,并为其生成所有必需的文件。

---
# This task file is executed for each document from MongoDB.
# The 'cluster_definition' variable holds the current document.

- name: Define cluster-specific paths and variables
  ansible.builtin.set_fact:
    cluster_path: "{{ gitops_repo_path }}/trino-clusters/{{ cluster_definition.clusterId }}"
    # 这里的 secret 查找逻辑需要根据实际的 secret management 工具进行实现
    # 例如,使用 HashiCorp Vault lookup plugin
    # mysql_rds_password: "{{ lookup('hashi_vault', 'secret=kv/data/trino:mysql-rds token={{ vault_token }}') }}"

- name: Create directory for the cluster
  ansible.builtin.file:
    path: "{{ cluster_path }}"
    state: directory
    mode: '0755'

- name: Generate Helm values.yaml from template
  ansible.builtin.template:
    src: templates/values.yaml.j2
    dest: "{{ cluster_path }}/values.yaml"
    mode: '0644'

- name: Create catalog directory for connectors
  ansible.builtin.file:
    path: "{{ cluster_path }}/catalog"
    state: directory
    mode: '0755'

- name: Generate connector properties files
  ansible.builtin.template:
    src: templates/catalog/connector.properties.j2
    dest: "{{ cluster_path }}/catalog/{{ item.name }}.properties"
    mode: '0644'
  loop: "{{ cluster_definition.connectors }}"
  loop_control:
    loop_var: item
  # 关键点:当连接器需要密码时,这里的 item 变量包含了占位符
  # 模板文件内部需要有逻辑来处理这个占位符的替换

- name: Generate Argo CD Application manifest
  ansible.builtin.template:
    src: templates/argo-app.yaml.j2
    dest: "{{ gitops_repo_path }}/trino-applications/{{ cluster_definition.clusterId }}-app.yaml"
    mode: '0644'

Jinja2 模板的威力 (templates/values.yaml.j2)

这是将 MongoDB 文档翻译为 Trino Helm Chart 的 values.yaml 的关键。

# This file is auto-generated by Ansible. DO NOT EDIT MANUALLY.
# Cluster ID: {{ cluster_definition.clusterId }}
# Owner: {{ cluster_definition.owner }}

server:
  workers: {{ cluster_definition.clusterConfig.worker.replicas }}

coordinator:
  # ... 其他 coordinator 配置 ...
  resources:
    requests:
      cpu: "{{ cluster_definition.clusterConfig.coordinator.resources.requests.cpu }}"
      memory: "{{ cluster_definition.clusterConfig.coordinator.resources.requests.memory }}"
    limits:
      cpu: "{{ cluster_definition.clusterConfig.coordinator.resources.limits.cpu }}"
      memory: "{{ cluster_definition.clusterConfig.coordinator.resources.limits.memory }}"
  
  config:
    jvm:
      maxHeapSize: "{{ cluster_definition.clusterConfig.coordinator.jvm.maxHeapSize }}"
    
    properties:
      query.max-memory: "{{ cluster_definition.properties.query['max-memory'] }}"
      query.max-memory-per-node: "{{ cluster_definition.properties.query['max-memory-per-node'] }}"
      node.environment: "{{ cluster_definition.properties.node.environment }}"

worker:
  # ... 其他 worker 配置 ...
  resources:
    requests:
      cpu: "{{ cluster_definition.clusterConfig.worker.resources.requests.cpu }}"
      memory: "{{ cluster_definition.clusterConfig.worker.resources.requests.memory }}"
    limits:
      cpu: "{{ cluster_definition.clusterConfig.worker.resources.limits.cpu }}"
      memory: "{{ cluster_definition.clusterConfig.worker.resources.limits.memory }}"

  config:
    jvm:
      maxHeapSize: "{{ cluster_definition.clusterConfig.worker.jvm.maxHeapSize }}"

# 这是最巧妙的部分:将 catalog 配置文件作为额外的 volume mount 进去
# Helm chart 需要支持 `additionalVolumes` 和 `additionalVolumeMounts`
# 并且支持通过 `configMaps` 来创建 catalog 文件
additionalCatalogs:
{% for connector in cluster_definition.connectors %}
  {{ connector.name }}.properties: |
{% for key, value in connector.properties.items() %}
    {{ key }}={{ value }}
{% endfor %}
{% endfor %}

Argo CD Application 模板 (templates/argo-app.yaml.j2)

最后,我们需要为每个 Trino 集群生成一个 Argo CD Application 资源。这个资源告诉 Argo CD 去哪里找配置(Git repo),以及如何部署(Helm)。

# This file is auto-generated by Ansible. DO NOT EDIT MANUALLY.
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: trino-{{ cluster_definition.clusterId }}
  namespace: argocd
  # 添加 finalizer 确保在删除 Application 时,关联的 K8s 资源也会被清理
  finalizers:
    - resources-finalizer.argocd.argoproj.io
spec:
  project: default
  source:
    repoURL: 'https://github.com/your-org/gitops-repo.git'
    targetRevision: main
    path: 'trino-clusters/{{ cluster_definition.clusterId }}'
    helm:
      valueFiles:
        - values.yaml
  destination:
    server: 'https://kubernetes.default.svc'
    namespace: trino-{{ cluster_definition.clusterId }}
  syncPolicy:
    automated:
      prune: true
      selfHeal: true
    syncOptions:
      - CreateNamespace=true

这个 Application Manifest 定义了一个独立的 Trino 应用,它的所有配置都源自 Git 仓库中由 Ansible 生成的特定路径。CreateNamespace=true 确保每个集群都部署在自己的命名空间中,实现了良好的隔离。

整体架构与数据流

现在,我们可以用一个流程图来串联起所有组件。

graph TD
    A[用户/API 更新 MongoDB] -->|写入集群定义| B(MongoDB: trino_clusters collection)
    
    subgraph "CI/CD or K8s CronJob"
        C[Ansible Playbook 执行]
    end

    B -->|定时拉取| C
    
    subgraph "Ansible Orchestration"
        C --> D{为每个 enabled:true 的文档...}
        D --> E[使用 Jinja2 模板生成]
        E --> F[1. Helm values.yaml]
        E --> G[2. Connector.properties]
        E --> H[3. ArgoCD Application Manifest]
    end
    
    H --> I(GitOps Repo: /trino-applications)
    F & G --> J(GitOps Repo: /trino-clusters)
    
    J -->|git push| K[Git Remote Repository]
    I -->|git push| K

    subgraph "Argo CD Sync"
        L[Argo CD Controller]
    end
    
    K -->|Webhook 或轮询| L
    L -->|发现配置变更| M{应用 Application Manifest}
    M --> N[在 K8s 中创建/更新 Trino Namespace 和资源]

    subgraph "Kubernetes Cluster"
        N
    end

局限性与未来迭代方向

这套基于 Ansible 的 “翻译” 模式,虽然实现相对简单且功能强大,但在生产环境中也暴露出一些需要考量的点。

首先,Ansible 本身是无状态的。删除操作需要额外的逻辑。如果一个 MongoDB 文档的 enabled 字段从 true 变为 false,或者文档被直接删除,当前的 playbook 并不会自动清理 Git 仓库中对应的文件。需要实现一个 “清理” 逻辑,对比 MongoDB 中的 clusterId 列表和 Git 仓库中的目录列表,删除那些不再存在的集群配置。

其次,整个流程的触发是周期性的。从用户在 MongoDB 中修改配置,到最终在 Kubernetes 中生效,存在一个延迟(Ansible playbook 的执行周期 + Argo CD 的同步周期)。对于需要秒级响应的场景,可能需要引入事件驱动机制,例如使用 MongoDB Change Streams 来触发一个 webhook,直接调用 Ansible Runner。

最后,随着逻辑变得更复杂(例如,需要根据集群负载动态调整 worker 数量并写回 MongoDB),纯 Ansible 模式可能会变得笨重。一个更云原生的替代方案是开发一个自定义的 Kubernetes Operator。这个 Operator 可以直接 watch 一个 CRD (Custom Resource Definition),例如 kind: TrinoCluster,然后在其调谐循环(Reconcile Loop)中完成所有配置生成和应用逻辑。这种方式省去了 MongoDB 和 Ansible,将“单一事实来源”直接置于 Kubernetes API 之上,但开发和维护成本显著高于当前的实现。

尽管存在这些权衡,对于中等规模的平台工程团队而言,使用成熟的工具如 Ansible 和 MongoDB 组合来驱动 GitOps 流程,是在开发效率和系统健壮性之间取得的一个极佳平衡。它将复杂的 Trino 配置管理问题,分解为了一个清晰、可审计、且高度自动化的数据转换与同步任务。


  目录