Terraform و Ansible کنار هم: زیرساخت به‌عنوان کد از صفر تا سرور آماده

تقسیم کار درست بین Terraform و Ansible، ساخت سرور و شبکه روی Hetzner Cloud، State روی MinIO، Inventory خودکار، Role برای Hardening و اجرای plan و apply در GitLab CI.

۵ دقیقه مطالعه

«این سرور را چه کسی و با چه تنظیماتی راه انداخت؟» اگر جواب این سؤال در ذهن یک نفر است، نه در Git، زیرساخت شما شکننده است. زیرساخت به‌عنوان کد (IaC) یعنی سرور، شبکه، فایروال و تنظیمات سیستم‌عامل همه در کد تعریف شوند؛ قابل بازبینی، قابل تکرار و قابل بازگشت.

دو ابزار اصلی این حوزه Terraform و Ansible هستند. سؤال رایج این است که کدام را انتخاب کنیم. جواب کوتاه: هر دو را، هر کدام برای کار خودش.

تقسیم کار

Terraform Ansible
کار اصلی ساختن منابع (Provisioning) پیکربندی داخل سرور (Configuration)
مدل Declarative با State Procedural و Idempotent، بدون State
مثال VM، شبکه، فایروال، DNS، Bucket نصب Docker، Hardening، کاربرها، containerd
نقطه‌ی قوت دانستن تفاوت وضعیت فعلی با وضعیت مطلوب کار روی هر سروری که SSH دارد، حتی VMware قدیمی

بخش اول: Terraform

Provider و منابع

مثال زیر سه سرور، یک شبکه‌ی خصوصی و یک فایروال روی Hetzner Cloud می‌سازد:

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

variable "hcloud_token" { sensitive = true }
provider "hcloud" { token = var.hcloud_token }

resource "hcloud_network" "private" {
  name     = "app-net"
  ip_range = "10.10.0.0/16"
}

resource "hcloud_network_subnet" "nodes" {
  network_id   = hcloud_network.private.id
  type         = "cloud"
  network_zone = "eu-central"
  ip_range     = "10.10.1.0/24"
}

resource "hcloud_firewall" "base" {
  name = "base"
  rule {
    direction  = "in"
    protocol   = "tcp"
    port       = "22"
    source_ips = ["203.0.113.10/32"]   # office / VPN only
  }
}

resource "hcloud_server" "node" {
  count        = 3
  name         = "node-${count.index + 1}"
  server_type  = "cpx31"
  image        = "ubuntu-24.04"
  location     = "fsn1"
  ssh_keys     = ["devops"]
  firewall_ids = [hcloud_firewall.base.id]
  labels       = { role = "k8s-worker", env = "production" }

  network {
    network_id = hcloud_network.private.id
  }
  depends_on = [hcloud_network_subnet.nodes]
}

output "nodes" {
  value = { for s in hcloud_server.node : s.name => s.ipv4_address }
}

State روی MinIO

State فایل حساسی است و باید بین اعضای تیم مشترک و محافظت‌شده باشد. اگر S3 ابری ندارید، MinIO داخلی کاملاً جوابگوست:

terraform {
  backend "s3" {
    bucket                      = "terraform-state"
    key                         = "production/infra.tfstate"
    region                      = "us-east-1"            # ignored by MinIO, but required
    endpoints                   = { s3 = "https://minio.example.ir" }
    use_path_style              = true
    skip_credentials_validation = true
    skip_region_validation      = true
    skip_requesting_account_id  = true
    skip_metadata_api_check     = true
  }
}

بخش دوم: از Terraform به Ansible

هیچ IPی نباید دستی در Inventory وارد شود. با یک local_file، Terraform خودش Inventory را می‌سازد:

resource "local_file" "inventory" {
  filename = "${path.module}/../ansible/inventory/production.ini"
  content  = <<-EOT
    [k8s_workers]
    %{for s in hcloud_server.node~}
    ${s.name} ansible_host=${s.ipv4_address}
    %{endfor~}

    [all:vars]
    ansible_user=root
  EOT
}

بخش سوم: Ansible Role برای Hardening

# roles/base/tasks/main.yml
- name: Install base packages
  ansible.builtin.apt:
    name: [curl, chrony, fail2ban, unattended-upgrades]
    state: present
    update_cache: true

- name: Disable SSH password login
  ansible.builtin.lineinfile:
    path: /etc/ssh/sshd_config
    regexp: "^#?PasswordAuthentication"
    line: "PasswordAuthentication no"
  notify: restart ssh

- name: Kernel parameters for Kubernetes
  ansible.posix.sysctl:
    name: "{{ item.key }}"
    value: "{{ item.value }}"
    sysctl_file: /etc/sysctl.d/90-k8s.conf
  loop: "{{ sysctl_params | dict2items }}"

- name: Point containerd to the Harbor mirror
  ansible.builtin.template:
    src: hosts.toml.j2
    dest: /etc/containerd/certs.d/docker.io/hosts.toml
    mode: "0644"
  notify: restart containerd
# playbooks/site.yml
- hosts: k8s_workers
  become: true
  roles: [base, containerd, kubernetes-node]

اجرای Playbook را همیشه اول با --check --diff انجام دهید تا قبل از اعمال، دقیقاً ببینید چه چیزی تغییر می‌کند.

بخش چهارم: Pipeline برای زیرساخت

کد زیرساخت هم مثل کد برنامه باید Review شود:

stages: [validate, plan, apply, configure]

.tf:
  image: { name: ghcr.io/opentofu/opentofu:1.8, entrypoint: [""] }
  before_script:
    - cd terraform && tofu init -input=false

validate:
  extends: .tf
  stage: validate
  script: [tofu fmt -check, tofu validate]

plan:
  extends: .tf
  stage: plan
  script: [tofu plan -input=false -out=plan.bin]
  artifacts: { paths: [terraform/plan.bin], expire_in: 1 week }

apply:
  extends: .tf
  stage: apply
  script: [tofu apply -input=false plan.bin]
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
      when: manual
  environment: { name: infra-production }

configure:
  stage: configure
  image: cytopia/ansible:latest-tools
  needs: [apply]
  script:
    - ansible-playbook -i ansible/inventory/production.ini ansible/playbooks/site.yml

apply دقیقاً همان plan.binی را اجرا می‌کند که در Merge Request دیده و تأیید شده است؛ نه یک plan جدید که ممکن است فرق کند.

اشتباهات رایج

  1. تغییر دستی در کنسول ابری: هر تغییر دستی باعث Drift می‌شود. اگر مجبور شدید، بلافاصله در کد هم اعمالش کنید.
  2. Secret در فایل‌های .tfvars: از متغیرهای Protected در CI یا HashiCorp Vault استفاده کنید.
  3. یک State برای همه‌چیز: State را بر اساس محیط و لایه (شبکه، کلاستر، DNS) جدا کنید تا یک اشتباه همه‌چیز را تحت تأثیر قرار ندهد.
  4. Ansible غیر Idempotent: استفاده‌ی زیاد از shell و command باعث می‌شود اجرای دوم نتیجه‌ی متفاوتی بدهد. تا جای ممکن از ماژول‌های اختصاصی استفاده کنید.

جمع‌بندی

Terraform و Ansible رقیب نیستند، مکمل‌اند. Terraform اسکلت زیرساخت را می‌سازد، Ansible آن را آماده‌ی کار می‌کند و GitLab CI هر دو را قابل بازبینی و تکرارپذیر نگه می‌دارد. نتیجه زیرساختی است که اگر فردا کاملاً از بین برود، در چند ساعت دوباره ساخته می‌شود.