技术痛点:为何要绕开 Kubernetes?
在生产环境中部署 TiDB 集群,官方推荐的方式是使用 TiDB Operator on Kubernetes。这套方案成熟、稳定,提供了强大的自动化运维能力。但在某些特定场景下,这并非银弹。我们遇到的挑战是:在一个资源受限且对延迟极其敏感的核心交易后分析系统中,Kubernetes 的网络抽象层(如 CNI 插件和 Service 代理)引入了不可忽视的性能损耗。同时,整个团队对 Kubernetes 的运维复杂度有所顾虑,希望寻求一个更轻量、更贴近底层的容器化方案。
目标很明确:我们需要 TiDB 的分布式、高可用特性,也需要容器化带来的环境一致性与打包便利性,但要剥离 Kubernetes 的重量级编排层。我们需要的,是一个由基础设施即代码(IaC)完全驱动,直接运行在 containerd 上的、可预测、高性能的 TiDB 集群。这本质上是在构建一个“静态编排”的分布式系统,所有组件的生命周期和配置都通过代码严格定义,而非依赖一个动态的控制平面。
初步构想与技术选型决策
初步构想是使用 Terraform 来定义和管理整个集群的生命周期,包括计算实例、网络、存储以及其上的软件配置。计算实例上,我们选择直接使用 containerd 作为容器运行时,因为它轻量、稳定,并且是 Kubernetes 运行时的基石,社区成熟度高。
为什么是 Terraform 而不是 Ansible 或其他配置管理工具?
- 状态管理: Terraform 的核心优势在于其状态文件(
tfstate
),它精确记录了基础设施的实际状态,使得任何变更都可预测。对于一个复杂的 TiDB 集群拓扑,这一点至关重要。 - 声明式: 我们只需描述最终要达成的状态,Terraform 会计算出差异并执行。这与 Ansible 等过程式的工具在心智模型上有本质区别,更适合定义不可变基础设施。
- 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_cluster
和pd_endpoints
的生成。我们利用 Terraform 的内建函数和资源引用,在 apply
阶段动态生成所有节点的 IP 地址,并将其作为参数传递给相应的模块。这就解决了在没有服务发现中间件(如 etcd 或 Consul)的情况下,集群组件如何互相找到对方的问题。
2. 节点配置层:cloud-init
与 systemd
当 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 会自动拉起,提供了基础的自愈能力。ExecStop
和ExecStartPre
确保了服务的平滑停止和重启。
-
TiKV 和 TiDB Server 的节点模块与此类似,只是 systemd 服务中的启动命令和参数不同。
3. 错误处理与可测试性思路
虽然我们没有完整的单元测试框架,但 IaC 的代码质量同样重要。
- 错误处理:
bootstrap.sh
脚本开头的set -e
确保了任何命令失败都会导致脚本中止,EC2 实例的创建也会因此失败,Terraform 会报告错误。这是一种“快速失败”的策略。 - 静态分析: 使用
terraform validate
和tflint
等工具可以在提交代码前检查语法和最佳实践。 - 测试思路:
- 组件测试: 可以使用工具如 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 有着本质的不同。
局限性:
- 故障自愈能力有限: systemd 只能处理进程级别的故障。如果整个 EC2 实例宕机,Terraform 本身不会自动创建一个新的实例来替代它。这需要额外的自动化脚本或与 AWS Auto Scaling Group 集成,但会增加复杂度。
- 扩缩容的原子性: Terraform 的
apply
是一个过程。在扩容 TiKV 节点时,Terraform 创建了云资源,但 TiKV Region 的数据调度和 rebalance 是在 TiDB 内部异步发生的。运维人员需要意识到这两者之间的时间差。 - 配置管理的复杂性: 随着集群配置项增多,通过命令行参数传递会变得非常臃肿。更好的方式是使用 Terraform 的
templatefile
函数生成完整的配置文件(pd.toml
,tikv.toml
),并将其作为user_data
的一部分或者通过remote-exec
推送到节点上。
未来迭代方向:
可以构建一个简单的外部健康检查服务,定期探测 TiDB 集群各组件的健康状态。当发现某个节点实例持续不可用时,该服务可以调用云厂商的 API 来终止并替换故障实例。这相当于我们自己实现了一个极简版的“Node Controller”。另外,将 Terraform 的执行集成到 CI/CD 流水线中,所有对集群的变更都通过代码审查和自动化测试,是通往真正健壮的 GitOps 的必经之路。