Terraform 基础设施即代码实战:把服务器与 DNS 配置写成代码、plan 预览与状态管理

服务器配置为什么总在"漂移"

几乎每个站长都经历过这种事:半年前搭好的服务器,当时记了笔记,改了哪些配置也写在文档里。半年后再上去看,发现 Nginx 配置被人手动加过几行、防火墙规则多了一条不知道谁加的、某个 systemd 服务被临时改过又忘了改回来。文档和现实对不上,这就是所谓的"配置漂移"。等哪天真要重装系统或者迁移到新机器,你会发现根本复现不出当初的环境,只能对着老机器一点点抠,抠到凌晨。

Terraform 解决的正是这个问题。它的核心思想叫"基础设施即代码"(Infrastructure as Code,简称 IaC):把服务器的规格、网络、防火墙、DNS 记录、云资源这些本来要靠控制台点鼠标或者敲命令完成的操作,全部写成一份声明式的配置文件。你描述"我要什么",Terraform 负责算出"现在还差什么"并把它补齐。配置文件进 Git,谁改了什么一目了然,重建环境只需一条命令。

本文面向个人站长,从零讲清楚 Terraform 的核心概念,用一个真实可跑的例子(在 VPS 服务商上开一台机器并配好防火墙)走完全流程,再讲几个新手最容易踩的坑。目标不是让你变成 DevOps 工程师,而是让你有能力把自己的站"代码化",从此告别手工配置。

声明式 vs 命令式:思维方式的转变

大多数人一开始写的是命令式脚本,比如一段 Bash:登录服务器,执行 apt install nginx,然后 systemctl start nginx,接着改配置文件,再 systemctl reload。命令式的问题是,它记录的是"步骤",而不是"目标状态"。脚本跑第二遍时你得自己判断每步是否已经完成,稍微有偏差就出错。而且它不知道"当前系统离目标还差多少"。

Terraform 是声明式的。你只写:

resource "hcloud_server" "web" {
  name        = "web-01"
  server_type = "cx22"
  image       = "debian-12"
  location    = "fsn1"
}

它表达的是"我要一台叫 web-01 的服务器存在"。当你运行 terraform apply,Terraform 会先读取当前状态(state),再和配置文件对比,算出差异,最后只执行必要的操作。如果机器已经存在且规格一致,它就什么都不做。这种"收敛"的特性,让重复执行变得安全。

三个必须搞懂的核心概念

第一是 Provider。Terraform 本身不认识任何云平台,它通过 Provider 插件与外部 API 对话。比如 Hetzner Cloud 用 hetznercloud/hcloud,Cloudflare 用 cloudflare/cloudflare,AWS 用 hashicorp/aws。Provider 需要在配置里声明并指定版本:

terraform {
  required_providers {
    hcloud = {
      source  = "hetznercloud/hcloud"
      version = "~> 1.45"
    }
  }
}

provider "hcloud" {
  token = var.hcloud_token
}

注意版本号用了 ~> 约束符,表示允许 1.45 以上的小版本升级但不跨大版本,这能避免 Provider 突然破坏性变更把你的流程搞崩。生产环境一定要锁版本。

第二是 Resource,也就是你要管理的具体对象,一台机器、一条 DNS 记录、一个防火墙规则都是一个 resource。第三是 State,这是 Terraform 最关键也最容易被忽视的部分。State 是一个记录"配置文件里的每个资源对应现实中哪个真实对象"的账本,默认存在本地的 terraform.tfstate 文件里。它不是可有可无的缓存,而是 Terraform 做差异计算的基础。

变量、输出与目录结构

硬编码配置值是不好的习惯。用变量把敏感信息和环境差异抽出来:

variable "hcloud_token" {
  type      = string
  sensitive = true
}

variable "ssh_key_name" {
  type    = string
  default = "my-laptop"
}

output "server_ip" {
  value = hcloud_server.web.ipv4_address
}

变量可以在 terraform.tfvars 里赋值,敏感值比如 API Token 应该通过环境变量 TF_VAR_hcloud_token 传入,绝不提交到 Git。一个合理的目录结构长这样:

infra/
  main.tf          # 资源和 provider
  variables.tf     # 变量声明
  outputs.tf       # 输出
  terraform.tfvars # 非敏感变量赋值
  .gitignore       # 忽略 tfstate 和 tfvars

.gitignore 里至少要排除 *.tfstate、*.tfstate.backup、.terraform/ 和 terraform.tfvars。把 state 提交到 Git 是常见事故,因为里面可能含明文密钥和全部资源信息。

完整走一遍:开机器 + 配防火墙 + 挂 DNS

下面这个例子在 Hetzner Cloud 上开一台 Debian 12 的小机器,配好只放行 22/80/443 的防火墙,再把域名 A 记录指向它。先写防火墙资源:

resource "hcloud_firewall" "web_fw" {
  name = "web-fw"

  rule {
    direction  = "in"
    protocol   = "tcp"
    port       = "22"
    source_ips = ["0.0.0.0/0", "::/0"]
  }
  rule {
    direction  = "in"
    protocol   = "tcp"
    port       = "80"
    source_ips = ["0.0.0.0/0", "::/0"]
  }
  rule {
    direction  = "in"
    protocol   = "tcp"
    port       = "443"
    source_ips = ["0.0.0.0/0", "::/0"]
  }
}

然后开机器并绑定防火墙:

resource "hcloud_server" "web" {
  name        = "web-01"
  server_type = "cx22"
  image       = "debian-12"
  location    = "fsn1"
  ssh_keys    = [var.ssh_key_name]

  firewall_ids = [hcloud_firewall.web_fw.id]

  user_data = <<-EOF
#cloud-config
package_update: true
packages:
  - nginx
runcmd:
  - systemctl enable --now nginx
EOF
}

这里用了 user_data(cloud-init 脚本)在机器首次启动时自动装 Nginx,这是"基础设施即代码"里的初始化环节。注意 firewall_ids 直接引用了上面资源的 id,Terraform 会自动推导出"先建防火墙、再建机器"的依赖顺序,这就是隐式依赖,比手写 depends_on 更优雅。

最后接上 DNS:

resource "cloudflare_record" "www" {
  zone_id = var.cf_zone_id
  name    = "www"
  type    = "A"
  content = hcloud_server.web.ipv4_address
  proxied = true
}

运行流程是固定的三步。第一步 terraform init,下载 Provider 插件并初始化工作目录。第二步 terraform plan,它会打印一份"将要执行的操作"预览,绿色的 + 是新增,~ 是修改,红色的 - 是删除。每次 apply 前必须认真看 plan,这是 IaC 的安全带。第三步 terraform apply,确认后真正执行。

改配置、销毁与漂移检测

想扩一台机器?改改配置加一个 resource,再 plan 一次,你会看到它只新增这一台,不影响已有的。想删掉所有资源?terraform destroy 会按依赖关系逆序清理,先解绑 DNS 再删机器再删防火墙,不会留下悬空资源。这正是手工操作难以做到的整洁。

如果有人在控制台手工改了某条防火墙规则,terraform plan 会检测到并显示"要把规则改回来"。这既是优点——保证实际状态与代码一致——也提醒你一条纪律:配置的一切变更都走代码,不走控制台。否则你会在每次 plan 时看到一堆意料之外的差异,陷入混乱。

Remote State:多人协作与安全

本地 state 只适合单人单机。一旦多于一个人操作,或者你想在 CI 里跑,就必须用远程后端。以 S3 兼容存储(比如 Cloudflare R2 或 MinIO)为例:

terraform {
  backend "s3" {
    bucket                      = "tfstate"
    key                         = "prod/terraform.tfstate"
    region                      = "auto"
    endpoints                   = { s3 = "https://xxx.r2.cloudflarestorage.com" }
    skip_credentials_validation = true
    skip_region_validation      = true
  }
}

远程 state 的最大好处是支持锁(state locking),防止两个人同时 apply 导致状态损坏。绝大多数后端都自带锁机制,这是本地文件做不到的。同时要记得给存放 state 的桶加密,因为里面可能含敏感数据。

新手最容易踩的五个坑

第一,手改线上后不 import。如果机器是你手工开的,现在想纳入 Terraform 管理,必须用 terraform import hcloud_server.web 1234567 把它导入 state,否则 Terraform 会试图再开一台新的。第二,state 丢失。state 文件没了,Terraform 就失去了对现实资源的追踪,重建时会造成重复资源,所以远程 state 加自动备份是必备。

第三,把密钥写进配置文件。API Token、密码一律走环境变量或专用的密钥管理,代码仓库里的明文密钥迟早泄露。第四,不锁 Provider 版本。随便跑一次 terraform init -upgrade 可能就把 Provider 升到有破坏性变更的版本,导致 plan 出一堆莫名其妙的差异,务必用版本约束加 .terraform.lock.hcl 提交锁文件。

第五,把 Terraform 当配置管理工具用。Terraform 擅长管理"资源的生命周期"——创建、修改、销毁机器和网络,但它不适合做机器内部的软件配置细节,那是 Ansible、cloud-init 或者 Nix 的地盘。用 Terraform 的 user_data 做一次性初始化是合理的,但别指望它去管一个软件包里几十个配置文件。把职责分清楚,工具组合才好用:Terraform 管资源,Ansible 管配置,这是业界的经典搭配。

对个人站长的现实价值

你可能会想,我就一台 VPS,值得上 Terraform 吗?如果只有一台机器、从不折腾,确实没必要。但只要你符合下面任一条,它就值回票价:经常试用不同 VPS 服务商、需要快速在多地区布点、想把整套环境备份成可复现的代码、或者担心哪天服务器被删能快速重建。

实实在在的价值是"可恢复性"和"可复现性"。当你的整站基础设施都在一个 Git 仓库里,重装、扩容、迁移都从"熬夜手工操作"变成"改几行、跑一条命令、看着 plan 执行"。这不仅是效率,更是一种对意外的保险。哪怕最终你只把它用于管理 DNS 和防火墙,也比全部手工有据可查。基础设施即代码的真正门槛不在工具,而在于你是否愿意把"配置"当成"代码"来对待——版本化、评审、留痕、可回滚。养成这个习惯,服务器运维就从救火变成了工程。

Last modification:October 2nd, 2026 at 08:24 pm

Leave a Comment