使用 Terraform 与 containerd 构建 Kubernetes-less 的高可用 TiDB 集群


技术痛点:为何要绕开 Kubernetes?

在生产环境中部署 TiDB 集群,官方推荐的方式是使用 TiDB Operator on Kubernetes。这套方案成熟、稳定,提供了强大的自动化运维能力。但在某些特定场景下,这并非银弹。我们遇到的挑战是:在一个资源受限且对延迟极其敏感的核心交易后分析系统中,Kubernetes 的网络抽象层(如 CNI 插件和 Service 代理)引入了不可忽视的性能损耗。同时,整个团队对 Kubernetes 的运维复杂度有所顾虑,希望寻求一个更轻量、更贴近底层的容器化方案。

目标很明确:我们需要 TiDB 的分布式、高可用特性,也需要容器化带来的环境一致性与打包便利性,但要剥离 Kubernetes 的重量级编排层。我们需要的,是一个由基础设施即代码(IaC)完全驱动,直接运行在 containerd 上的、可预测、高性能的 TiDB 集群。这本质上是在构建一个“静态编排”的分布式系统,所有组件的生命周期和配置都通过代码严格定义,而非依赖一个动态的控制平面。

初步构想与技术选型决策

初步构想是使用 Terraform 来定义和管理整个集群的生命周期,包括计算实例、网络、存储以及其上的软件配置。计算实例上,我们选择直接使用 containerd 作为容器运行时,因为它轻量、稳定,并且是 Kubernetes 运行时的基石,社区成熟度高。

为什么是 Terraform 而不是 Ansible 或其他配置管理工具?

  1. 状态管理: Terraform 的核心优势在于其状态文件(tfstate),它精确记录了基础设施的实际状态,使得任何变更都可预测。对于一个复杂的 TiDB 集群拓扑,这一点至关重要。
  2. 声明式: 我们只需描述最终要达成的状态,Terraform 会计算出差异并执行。这与 Ansible 等过程式的工具在心智模型上有本质区别,更适合定义不可变基础设施。
  3. Provider 生态: 强大的云厂商 Provider 可以统一管理从 IaaS 资源(VPC、VM、EBS)到软件配置的全过程。

整个架构的核心思想是,用 Terraform 的声明式能力,取代 Kubernetes Operator 的命令式调谐循环。集群的拓扑、版本、配置变更,都通过提交 Git 仓库中的 Terraform 代码来驱动,实现彻底的 GitOps。

graph TD
    subgraph "IaC Management (Terraform)"
        A[main.tf] --> B{Cloud Provider API};
        B --> C[VPC & Subnets];
        B --> D[Security Groups];
        B --> E[EC2 Instances];
        B --> F[EBS Volumes];
    end

    subgraph "Node 1 (PD)"
        E1[EC2] -- Mounts --> F1[EBS];
        E1 -- Runs --> G1[containerd];
        G1 -- Manages --> H1[TiDB PD Container];
    end

    subgraph "Node 2 (PD)"
        E2[EC2] -- Mounts --> F2[EBS];
        E2 -- Runs --> G2[containerd];
        G2 -- Manages --> H2[TiDB PD Container];
    end

    subgraph "Node 3 (PD)"
        E3[EC2] -- Mounts --> F3[EBS];
        E3 -- Runs --> G3[containerd];
        G3 -- Manages --> H3[TiDB PD Container];
    end
    
    subgraph "Node 4 (TiKV)"
        E4[EC2] -- Mounts --> F4[EBS for Data];
        E4 -- Runs --> G4[containerd];
        G4 -- Manages --> H4[TiDB TiKV Container];
    end

    subgraph "Node 5 (TiKV)"
        E5[EC2] -- Mounts --> F5[EBS for Data];
        E5 -- Runs --> G5[containerd];
        G5 -- Manages --> H5[TiDB TiKV Container];
    end
    
    subgraph "Node 6 (TiKV)"
        E6[EC2] -- Mounts --> F6[EBS for Data];
        E6 -- Runs --> G6[containerd];
        G6 -- Manages --> H6[TiDB TiKV Container];
    end

    subgraph "Node 7 (TiDB Server)"
        E7[EC2] -- Runs --> G7[containerd];
        G7 -- Manages --> H7[TiDB Server Container];
    end
    
    subgraph "Node 8 (TiDB Server)"
        E8[EC2] -- Runs --> G8[containerd];
        G8 -- Manages --> H8[TiDB Server Container];
    end

    H1 <--> H2;
    H2 <--> H3;
    H4 <--> H1;
    H5 <--> H1;
    H6 <--> H1;
    H7 <--> H1;
    H8 <--> H1;
    H4 <--> H5;
    H5 <--> H6;
    H7 <--> H4;
    H7 <--> H5;
    H7 <--> H6;
    
    User[Client Application] --> LB[Load Balancer];
    LB --> H7;
    LB --> H8;

上图清晰地展示了架构:Terraform 负责创建所有底层资源。每个 TiDB 组件作为一个独立的 containerd 容器运行在专用的 EC2 实例上。PD 和 TiKV 节点挂载独立的 EBS 卷以保证数据持久化。组件间的服务发现通过 Terraform 生成的静态配置实现。

步骤化实现:代码是最好的文档

我们将整个构建过程分为三层:基础设施层、节点配置层、服务启动层。

1. 基础设施层:使用 Terraform 定义集群骨架

这一层负责网络、安全组、EC2 实例和持久化存储。真实项目中,模块化是必须的。

main.tf - 入口文件

# main.tf

terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

provider "aws" {
  region = "ap-northeast-1"
}

# 变量定义,用于灵活配置集群规模
variable "cluster_name" {
  description = "Name for the TiDB cluster"
  type        = string
  default     = "tidb-prod-alpha"
}

variable "pd_instance_count" {
  description = "Number of PD nodes"
  type        = number
  default     = 3
}

variable "tikv_instance_count" {
  description = "Number of TiKV nodes"
  type        = number
  default     = 3
}

variable "tidb_instance_count" {
  description = "Number of TiDB server nodes"
  type        = number
  default     = 2
}

# ... 其他变量如 instance_type, ami_id 等

# 调用网络模块
module "network" {
  source       = "./modules/network"
  cluster_name = var.cluster_name
}

# 调用安全组模块
module "security" {
  source  = "./modules/security"
  vpc_id  = module.network.vpc_id
}

# 调用 PD 节点模块
module "pd_nodes" {
  source            = "./modules/pd_node"
  count             = var.pd_instance_count
  cluster_name      = var.cluster_name
  instance_index    = count.index
  subnet_id         = module.network.private_subnet_ids[count.index % length(module.network.private_subnet_ids)]
  security_group_id = module.security.pd_sg_id
  # 初始集群成员地址列表,这是静态服务发现的关键
  initial_cluster   = join(",", [for i in range(var.pd_instance_count) : format("pd%d=http://%s:2380", i, aws_instance.pd[i].private_ip)])
}

# 调用 TiKV 节点模块
module "tikv_nodes" {
  source            = "./modules/tikv_node"
  count             = var.tikv_instance_count
  cluster_name      = var.cluster_name
  instance_index    = count.index
  subnet_id         = module.network.private_subnet_ids[count.index % length(module.network.private_subnet_ids)]
  security_group_id = module.security.tikv_sg_id
  # TiKV 启动时需要知道 PD 集群的地址
  pd_endpoints      = join(",", [for ip in module.pd_nodes[*].private_ip : format("%s:2379", ip)])
}

# 调用 TiDB Server 节点模块
module "tidb_nodes" {
  # ... 类似结构 ...
  pd_endpoints = join(",", [for ip in module.pd_nodes[*].private_ip : format("%s:2379", ip)])
}

这里的核心在于initial_clusterpd_endpoints的生成。我们利用 Terraform 的内建函数和资源引用,在 apply 阶段动态生成所有节点的 IP 地址,并将其作为参数传递给相应的模块。这就解决了在没有服务发现中间件(如 etcd 或 Consul)的情况下,集群组件如何互相找到对方的问题。

2. 节点配置层:cloud-initsystemd

当 EC2 实例启动时,我们需要自动化地安装 containerd、拉取 TiDB 镜像,并配置 systemd 服务来管理容器的生命周期。cloud-init(通过 EC2 的 user_data)是实现这一目标的标准方式。

modules/pd_node/main.tf - PD 节点模块示例

# modules/pd_node/main.tf

resource "aws_instance" "pd" {
  # ... EC2 实例的其他配置 ...
  ami           = "ami-0c55b159cbfafe1f0" # Amazon Linux 2
  instance_type = "t3.medium"
  
  # user_data 注入启动脚本
  user_data = templatefile("${path.module}/scripts/bootstrap.sh.tpl", {
    NODE_NAME         = "pd${var.instance_index}"
    CLUSTER_NAME      = var.cluster_name
    INITIAL_CLUSTER   = var.initial_cluster
    DATA_DIR          = "/data/pd"
    DEVICE_NAME       = "/dev/xvdf"
  })
}

resource "aws_ebs_volume" "pd_data" {
  # ... EBS 卷配置 ...
  size = 20
  type = "gp3"
}

resource "aws_volume_attachment" "pd_data_attachment" {
  device_name = "/dev/sdf" # 在 OS 中会识别为 /dev/xvdf
  volume_id   = aws_ebs_volume.pd_data.id
  instance_id = aws_instance.pd.id
}

modules/pd_node/scripts/bootstrap.sh.tpl - 启动脚本模板

#!/bin/bash
# Exit on any error
set -e

# 1. 安装 containerd 和依赖
yum update -y
yum install -y containerd
systemctl enable --now containerd

# 2. 准备数据目录
# 这里的坑在于:新挂载的 EBS 卷需要格式化和挂载。
# 真实项目中,需要增加逻辑判断设备是否已格式化,避免重格式化导致数据丢失。
mkfs.xfs ${DEVICE_NAME}
mkdir -p ${DATA_DIR}
mount ${DEVICE_NAME} ${DATA_DIR}
# 写入 /etc/fstab 以实现开机自动挂载
UUID=$(blkid -s UUID -o value ${DEVICE_NAME})
echo "UUID=$UUID ${DATA_DIR} xfs defaults,nofail 0 2" >> /etc/fstab

# 3. 拉取 TiDB PD 镜像
# 使用 ctr 工具,这是 containerd 的客户端
ctr image pull docker.io/pingcap/pd:v7.5.0

# 4. 创建并启用 systemd 服务单元
# 使用 cat 和 EOF 来动态生成配置文件,将 Terraform 变量注入
cat <<EOF > /etc/systemd/system/tidb-pd.service
[Unit]
Description=TiDB PD Service
After=network.target containerd.service
Requires=containerd.service

[Service]
Type=simple
ExecStartPre=-/usr/bin/ctr-unmount.sh tidb-pd-container
ExecStart=/usr/bin/ctr run --rm \
  --net-host \
  --mount type=bind,src=${DATA_DIR},dst=/data,options=rbind:rw \
  docker.io/pingcap/pd:v7.5.0 tidb-pd-container \
  /pd-server \
  --name="${NODE_NAME}" \
  --client-urls="http://0.0.0.0:2379" \
  --peer-urls="http://0.0.0.0:2380" \
  --advertise-client-urls="http://$(curl -s http://169.254.169.254/latest/meta-data/local-ipv4):2379" \
  --advertise-peer-urls="http://$(curl -s http://169.254.169.254/latest/meta-data/local-ipv4):2380" \
  --data-dir="/data" \
  --initial-cluster="${INITIAL_CLUSTER}" \
  --cluster-id "1" # 在生产环境中,这应该是一个唯一的、随机生成的值

ExecStop=/usr/bin/ctr task kill --signal SIGTERM tidb-pd-container
Restart=always
RestartSec=10s

[Install]
WantedBy=multi-user.target
EOF

# 创建一个简单的 unmount 脚本,以处理 systemd 重启时的清理工作
cat <<EOF > /usr/bin/ctr-unmount.sh
#!/bin/bash
CONTAINER_ID=\$1
ctr task kill --signal SIGKILL \$CONTAINER_ID || true
ctr container rm \$CONTAINER_ID || true
EOF
chmod +x /usr/bin/ctr-unmount.sh

systemctl daemon-reload
systemctl enable --now tidb-pd.service

这段 bootstrap.sh.tpl 脚本是整个方案的“粘合剂”。它完成了:

  • 依赖安装: 确保 containerd 运行。
  • 持久化存储处理: 格式化并挂载为 TiKV 和 PD 准备的数据盘。这是一个常见的坑,如果不处理好,重启后数据就丢失了。
  • 镜像拉取: 提前拉取镜像,避免服务启动时才去下载。
  • Systemd 服务定义: 这是关键。我们为每个 TiDB 组件创建了一个 systemd service unit。
    • --net-host:为了性能和简化,我们直接使用主机网络,避免了容器网络的额外开销。
    • --mount:将宿主机的持久化数据目录挂载到容器内。
    • 动态参数: 通过 $(curl ...) 从 EC2 metadata service 获取实例的私有 IP,用于 --advertise-client-urls,确保 PD 向外广播的是可路由的地址。INITIAL_CLUSTER 变量则由 Terraform 在实例创建时注入。
    • 生命周期管理: Restart=always 保证了进程意外退出后 systemd 会自动拉起,提供了基础的自愈能力。ExecStopExecStartPre 确保了服务的平滑停止和重启。

TiKV 和 TiDB Server 的节点模块与此类似,只是 systemd 服务中的启动命令和参数不同。

3. 错误处理与可测试性思路

虽然我们没有完整的单元测试框架,但 IaC 的代码质量同样重要。

  • 错误处理: bootstrap.sh 脚本开头的 set -e 确保了任何命令失败都会导致脚本中止,EC2 实例的创建也会因此失败,Terraform 会报告错误。这是一种“快速失败”的策略。
  • 静态分析: 使用 terraform validatetflint 等工具可以在提交代码前检查语法和最佳实践。
  • 测试思路:
    • 组件测试: 可以使用工具如 Terratest,编写 Go 代码来部署一个最小化的模块(例如,只部署一个 PD 节点),然后通过 SSH 连接到实例,检查 containerd 是否在运行,systemd 服务是否 active,数据目录是否正确挂载。
    • 集成测试: 部署一个完整的最小集群(3 PD, 3 TiKV, 1 TiDB),然后运行一个简单的 SQL 客户端去连接 TiDB Server,执行 CREATE TABLE, INSERT, SELECT 操作,验证集群端到端的连通性和功能。

最终成果:一个由代码定义的数据库集群

terraform apply 执行完毕后,我们就获得了一个功能完整、高可用的 TiDB 集群。

  • 变更管理: 需要增加一个 TiKV 节点?只需修改 main.tf 中的 tikv_instance_count 从 3 变为 4,然后再次 terraform apply。Terraform 会自动创建新的 EC2 实例、EBS 卷,并使用正确的配置启动 TiKV 容器。新节点会自动加入现有集群。
  • 版本升级: 需要将 TiDB 从 v7.5.0 升级到 v7.6.0?修改 bootstrap.sh.tpl 中拉取的镜像版本,然后对相应的模块资源触发一次替换更新(例如,通过修改 user_data 的内容让 Terraform 认为实例需要重建)。对于 TiDB 这种有状态应用,需要配合滚动更新策略,一次只更新一个节点。Terraform 的 create_before_destroy 生命周期钩子在这里可以派上用场。

一个典型的 TiKV systemd 配置文件 tidb-tikv.service 会是这样:

[Unit]
Description=TiDB TiKV Service
After=network.target containerd.service
Requires=containerd.service

[Service]
Type=simple
ExecStartPre=-/usr/bin/ctr-unmount.sh tidb-tikv-container
ExecStart=/usr/bin/ctr run --rm \
  --net-host \
  --mount type=bind,src=/data/tikv,dst=/data,options=rbind:rw \
  docker.io/pingcap/tikv:v7.5.0 tidb-tikv-container \
  /tikv-server \
  --addr="0.0.0.0:20160" \
  --advertise-addr="$(curl -s http://169.254.169.254/latest/meta-data/local-ipv4):20160" \
  --status-addr="0.0.0.0:20180" \
  --pd-endpoints="${PD_ENDPOINTS}" \
  --data-dir="/data" \
  --config="" # 实际项目中可以通过 templatefile 挂载一个详细的 toml 配置文件
  # 标签用于拓扑感知,非常重要
  # --labels="zone=ap-northeast-1a,host=tikv1"

ExecStop=/usr/bin/ctr task kill --signal SIGTERM tidb-tikv-container
Restart=always
RestartSec=10s

[Install]
WantedBy=multi-user.target

注意这里的 --pd-endpoints 参数,它由 Terraform 动态生成并注入,告诉 TiKV 去哪里注册自己。

遗留问题与未来迭代

这套方案成功地满足了我们对性能和控制力的要求,但它并非完美。它的运维模型与 Kubernetes Operator 有着本质的不同。

局限性:

  1. 故障自愈能力有限: systemd 只能处理进程级别的故障。如果整个 EC2 实例宕机,Terraform 本身不会自动创建一个新的实例来替代它。这需要额外的自动化脚本或与 AWS Auto Scaling Group 集成,但会增加复杂度。
  2. 扩缩容的原子性: Terraform 的 apply 是一个过程。在扩容 TiKV 节点时,Terraform 创建了云资源,但 TiKV Region 的数据调度和 rebalance 是在 TiDB 内部异步发生的。运维人员需要意识到这两者之间的时间差。
  3. 配置管理的复杂性: 随着集群配置项增多,通过命令行参数传递会变得非常臃肿。更好的方式是使用 Terraform 的 templatefile 函数生成完整的配置文件(pd.toml, tikv.toml),并将其作为 user_data 的一部分或者通过 remote-exec 推送到节点上。

未来迭代方向:
可以构建一个简单的外部健康检查服务,定期探测 TiDB 集群各组件的健康状态。当发现某个节点实例持续不可用时,该服务可以调用云厂商的 API 来终止并替换故障实例。这相当于我们自己实现了一个极简版的“Node Controller”。另外,将 Terraform 的执行集成到 CI/CD 流水线中,所有对集群的变更都通过代码审查和自动化测试,是通往真正健壮的 GitOps 的必经之路。


  目录