读terraform up and running

读terraform up and running

不太遥远的过去,如想建软件公司,需管理大量硬件。设置机柜和架子,装服务器,连接电线,安装冷却系统,构建冗余电源系统等。Devs团队负责编写软件,Ops团队负责管理硬件

  • 由于发布是手动进行的,随着服务器数量增加,发布变得缓慢、痛苦且不可预测
  • 运维团队偶尔出错导致每个服务器(snowflake servers)有微妙不同的配置(称配置漂移问题configuration drift)

DevOps 目标: 大幅提高软件交付效率

  • 可持续集成代码,始终保持可部署状态,不再出现多天合并噩梦。可每天部署数十次甚至在每次提交后进行部署
  • 且在常见故障和停机时间方面, 构建具备弹性自愈能力的系统,使用监控与警报来捕捉无法自动解决的问题

DevOps有四个核心价值观:culture, automation, measurement, and sharing。简称CAMS

通过编码来管理基础架构,而不是通过网页点击或手动执行Shell命令,即IaC

five broad categories of IaC tools:

  • Ad hoc scripts: Bash/Python/...
  • Configuration management tools: Chef、Puppet和Ansible
  • Server templating tools: Docker(Container), Packer, Vagrant(VM)
  • Orchestration tools: Kubernetes、Marathon/Mesos、Amazon Elastic Container Service(Amazon ECS)、Docker Swarm和Nomad
  • Provisioning tools

Ansible role:

- name: Update the apt-get cache
  apt:
    update_cache: yes
 
- name: Install PHP
  apt:
    name: php
 
- name: Install Apache
  apt:
    name: apache2
 
- name: Copy the code from the repository
  git: repo=https://github.com/brikis98/php-app.git dest=/var/www/html/app
 
- name: Start Apache
  service: name=apache2 state=started enabled=yes

Idempotence 幂等性

编写即使反复运行也能正确工作的临时脚本很困难。无论运行多少次都能正常工作的代码称为幂等代码。大多数Ansible函数默认情况是幂等的

Server templating tools与在每个服务器上运行相同代码来启动一堆服务器并对其进行配置不同,服务器模板工具的理念是创建一个捕捉操作系统(OS)、软件、文件和其他相关细节完全自包含“快照”的服务器镜像。然后您可以使用其他IaC工具将该镜像安装到所有服务器上

Packer template => Packer => Server image => Ansible => servers

注意,不同的服务器模板工具有稍微不同的用途。Packer通常用于创建直接在生产服务器上运行的镜像,例如在您的生产AWS帐户中运行的AMI。Vagrant通常用于创建在开发计算机上运行的镜像,例如在Mac或Windows笔记本电脑上运行的VirtualBox镜像。Docker通常用于创建单个应用程序的镜像。只要其他工具已经配置了该计算机与Docker引擎一起使用,您可以在生产或开发计算机上运行Docker映像。例如,一个常见模式是使用Packer创建安装了Docker引擎的AMI,在您AWS账户中部署该AMI到一组服务器集群,并且然后跨该集群部署单独的Docker容器来运行应用程序。

  1. 使用Packer工具创建一个安装了Docker引擎的AMI镜像。Packer可以自动化创建AMI的过程。
  2. 在你的AWS账户中,使用创建的AMI镜像部署一组EC2实例作为服务器集群。所有这些实例都已经预装了Docker引擎。
  3. 在这组EC2实例上,你可以使用DockerCompose等工具,在集群内的各个节点上部署你的Docker容器化应用。每个节点上可以运行应用的不同服务。
  4. 这样就可以实现在AWS服务器集群上跨节点部署 Docker容器来运行分布式应用程序。

主要好处是通过Packer预装Docker可以标准化环境;而Docker容器化可以方便部署和管理分布式应用;集群化可以提供高可用和伸缩性。

服务器模板化是迁移到不可变基础架构(immutable infrastructure) 的关键组成部分。这个想法受到函数式编程启发,在函数式编程中变量是不可变动态绑定值得对象, 一旦你将一个变量设置为某个值之后就不能再改变它. 如果需要更新某些东西, 比如部署代码新版本, 那么你会从现有服务模板中创建一个新映像并将其部署到新服务器上. 因为服务器永远不会更改, 所以更容易理解部署的内容。

服务器模板化工具非常适用于创建虚拟机和容器,但是如何实际管理它们呢?对于大多数真实世界的使用情况,您需要以下方式来完成:

  1. 部署虚拟机和容器,并有效利用硬件资源。
  2. 通过滚动部署、蓝绿部署(blue-green development)和金丝雀(canary deployment)发布等策略,在现有的虚拟机和容器群中进行更新。
  3. 监控虚拟机和容器的健康状态,并自动替换不健康的实例(自动修复)。
  4. 根据负载情况,自动调整虚拟机和容器数量(自动扩缩容)。
  5. 在虚拟机和容器之间分发流量(负载均衡)。
  6. 使得您的虚拟机和容器能够在网络上找到并相互通信(服务发现)。

在 canary deployment 中,新版本的应用程序或服务会首先被部署到一小部分用户或服务器上(称为 "canary group"),然后观察其表现如何。如果没有出现问题,可以继续将新版本扩展到更多用户或服务器上。如果发现了任何问题,可以快速回滚到之前的稳定版本。

同时维护两个相同的生产环境(蓝色和绿色),其中一个环境处于活动状态(蓝色),而另一个环境则处于非活动状态(绿色)。通过这种方式,可以实现无缝地进行软件更新和回滚操作。举例来说,在 blue-green deployment 中,当需要发布新版本时,先将新版本部署到非活动环境(绿色),然后切换流量至该环境以测试其稳定性。如果出现问题,可以立即切换回原始的活动环境(蓝色)以保持系统正常运行。这样就能够最大程度地减少对用户的影响,并确保高可用性。

这第一章怎么这么复杂……

apiVersion: apps/v1
 
# 使用 Deployment 部署你的 Docker 容器的多个副本,并声明性地对其进行更新
kind: Deployment
 
# 关于此 Deployment 的元数据,包括其名称
metadata:
  name: example-app
 
# 配置此 Deployment 的规范
spec:
  # 这告诉 Deployment 如何找到你的容器
  selector:
    matchLabels:
      app: example-app
 
  # 这告诉 Deployment 在你的集群中运行三个 Docker 容器的副本
  replicas: 3
 
  # 指定如何更新 Deployment。在这里,我们配置了一个滚动更新。
  strategy:
    rollingUpdate:
      maxSurge: 3
      maxUnavailable: 0
    type: RollingUpdate
 
  # 这是要部署的容器的模板
  template:
 
    # 这些容器的元数据,包括标签
    metadata:
      labels:
        app: example-app
 
    # 你的容器的规范
    spec:
      containers:
 
        # 运行在 80 端口监听的 Apache
        - name: example-app
          image: httpd:2.4.39
          ports:
            - containerPort: 80
  • 一起运行一个或多个 Docker 容器。这组容器被称为 Pod。前面的代码中定义的 Pod 包含一个运行 Apache 的 Docker 容器。
  • Pod 中每个 Docker 容器的设置。前面的代码中的 Pod 配置 Apache 在 80 端口监听。
  • 在你的集群中运行 Pod 的副本的数量。前面的代码配置了三个副本。Kubernetes会自动确定在你的集群中部署每个 Pod 的位置,使用调度算法选择优化的服务器,考虑到高可用性(例如,尝试在单独的服务器上运行每个 Pod,以便单个服务器崩溃不会导致你的应用程序崩溃)

Provisioning Tools

虽然配置管理、服务器模板化和编排工具定义了每台服务器上运行的代码,而配置工具如 Terraform、CloudFormation、OpenStack Heat 和 Pulumi 则负责创建服务器本身。实际上,你可以使用配置工具来创建不仅仅是服务器,还包括数据库、缓存、负载均衡器、队列、监控、子网配置、防火墙设置、路由规则、安全套接层(SSL)证书,以及你的基础设施的几乎所有其他方面,如图1-5所示。

下面的代码使用 Terraform 部署了一个 web 服务器:

resource "aws_instance" "app" {
  instance_type     = "t2.micro"
  availability_zone = "us-east-2a"
  ami               = "ami-0fb653ca2d3203ac1"
 
  user_data = <<-EOF
              #!/bin/bash
              sudo service apache2 start
              EOF
}

如果你还不熟悉一些语法,不必担心。现在,只需要关注两个参数:

  • ami: 此参数指定要在服务器上部署的 AMI 的 ID。你可以将此参数设置为从前一节的 web-server.json Packer 模板构建的 AMI 的 ID,该模板具有 PHP、Apache 和应用程序源代码。
  • user_data: 这是一个在 web 服务器启动时执行的 Bash 脚本。前面的代码使用此脚本来启动 Apache。

Terraform 使用 Go 编程语言编写。Go 代码编译成一个单一的二进制文件,这个文件被称为 Terraform。

使用这个二进制文件从你的笔记本电脑或构建服务器或其他任何计算机部署基础设施,你不需要运行任何额外的基础设施来实现这一点。这是因为在底层,terraform 二进制文件代表你向一个或多个提供商(例如 AWS、Azure、Google Cloud、DigitalOcean、OpenStack 等)发出 API 调用。这意味着 Terraform 可以利用这些提供商已经为他们的 API 服务器运行的基础设施,以及你已经与这些提供商使用的身份验证机制(例如,你已经拥有的 AWS 的 API 密钥)。

Terraform 配置的例子:

resource "aws_instance" "example" {
  ami           = "ami-0fb653ca2d3203ac1"
  instance_type = "t2.micro"
}

resource "google_dns_record_set" "a" {
  name         = "demo.google-example.com"
  managed_zone = "example-zone"
  type         = "A"
  ttl          = 300
  rrdatas      = [aws_instance.example.public_ip]
}

Terraform 向 AWS 发出 API 调用以部署服务器,然后向 Google Cloud 发出 API 调用以创建指向 AWS 服务器 IP 地址的域名系统(DNS)条目


配置管理与配置

Chef,Puppet 和 Ansible 都是配置管理工具,而 CloudFormation,Terraform,OpenStack Heat 和 Pulumi 都是配置工具。

虽然这种区别并不完全明确,因为配置管理工具通常可以做一些配置(例如,你可以使用 Ansible 部署服务器),并且配置工具通常可以做一些配置(例如,你可以在每个使用 Terraform 配置的服务器上运行配置脚本),但你通常希望选择最适合你用例的工具。

特别是,如果你使用服务器模板工具,你的大部分配置管理需求已经得到满足。一旦你从 Dockerfile 或 Packer 模板创建了一个镜像,剩下的就是为运行这些镜像配置基础设施。而在配置方面,配置工具将是你的最佳选择。在第 7 章,你将看到如何一起使用 Terraform 和 Docker 的例子,这是目前特别流行的组合。

也就是说,如果你没有使用服务器模板工具,一个好的替代方案是一起使用配置管理和配置工具。例如,一个流行的组合是使用 Terraform 配置你的服务器,然后使用 Ansible 配置每一个。

可变基础设施与不可变基础设施

Chef,Puppet 和 Ansible 这样的配置管理工具通常默认为可变基础设施范例。

例如,如果你指示 Chef 安装 OpenSSL 的新版本,它会在你现有的服务器上运行软件更新,变更会就地发生。随着你应用越来越多的更新,每个服务器都会积累一份独特的变更历史。因此,每个服务器都会稍微有所不同于其他所有服务器,导致难以诊断和复制的微妙配置错误(这是手动管理服务器时发生配置漂移问题的同一种情况,尽管在使用配置管理工具时问题要小得多)。即使有自动测试,这些错误也很难捕捉到;在测试服务器上的配置管理变更可能工作得很好,但同样的变更在生产服务器上可能表现不同,因为生产服务器已经积累了几个月的变更,这些变更在测试环境中没有反应出来。

如果你正在使用如 Terraform 这样的配置工具来部署由 Docker 或 Packer 创建的机器镜像,大多数的“变更”实际上是部署一个全新的服务器。例如,要部署 OpenSSL 的新版本,你会使用 Packer 创建一个包含 OpenSSL 新版本的新镜像,将该镜像部署在一组新的服务器上,然后终止旧的服务器。因为每次部署都在新的服务器上使用不可变的镜像,这种方法减少了配置漂移错误的可能性,使得更容易知道每个服务器上正在运行的是什么软件,并且可以随时轻松部署任何以前的软件版本(任何以前的镜像)。这也使你的自动化测试更有效,因为在测试环境中通过了你的测试的不可变镜像,在生产环境中的行为可能会完全相同。

当然,也可以强行让配置管理工具进行不可变的部署,但这并不是这些工具的惯用方法,而使用配置工具则是一种自然的方式。还值得一提的是,不可变方法也有它自己的缺点。例如,对于一项微不足道的变更,从服务器模板重建一个镜像并重新部署所有的服务器可能需要很长时间。此外,不可变性只持续到你实际运行镜像为止。一旦服务器启动并运行,它将开始在硬盘上做出变更,并会经历一定程度的配置漂移(尽管如果你频繁部署,这个问题就会得到缓解)。

程序化语言与声明式语言

Chef 和 Ansible 鼓励使用程序化风格,您可以编写代码,逐步指定如何实现某些期望的最终状态。

而 Terraform, CloudFormation, Puppet, OpenStack Heat, 和 Pulumi 都鼓励使用更声明式的风格,您编写的代码指定了您期望的最终状态,而 IaC 工具本身负责弄清楚如何实现该状态。

假设您想要部署 10 台服务器(在 AWS 术语中是 EC2 实例)以运行带有 ID 为ami-0fb653ca2d3203ac1的 AMI(Ubuntu 20.04) ,假设流量增加,您想将服务器数量增加到 15 台。对于 Ansible,您之前编写的程序化代码将不再有用;如果你只是将服务器数量更新为 15 并重新运行该代码,它将部署 15 台新服务器,总计有 25 台!所以相反,您需要知道已经部署了什么,并编写一个全新的程序式脚本来添加五台新服务器

- ec2:
    count: 5
    image: ami-0fb653ca2d3203ac1
    instance_type: t2.micro

对于声明式代码,因为您所做的只是声明您想要的最终状态,而 Terraform 会弄清楚如何达到那个最终状态,所以 Terraform 也会知道它过去创建的任何状态。因此,要部署五台更多的服务器,您需要做的就是回到同一个 Terraform 配置,并将数量从 10 更新为 15

resource "aws_instance" "example" {
  count         = 15
  ami           = "ami-0fb653ca2d3203ac1"
  instance_type = "t2.micro"
}

使用 Terraform 的 plan 命令预览它将做出什么变化:

$ terraform plan
 
# aws_instance.example[11] will be created
+ resource "aws_instance" "example" {
    + ami            = "ami-0fb653ca2d3203ac1"
    + instance_type  = "t2.micro"
    + (...)
  }
 
# aws_instance.example[12] will be created
+ resource "aws_instance" "example" {
    + ami            = "ami-0fb653ca2d3203ac1"
    + instance_type  = "t2.micro"
    + (...)
  }
 
# aws_instance.example[13] will be created
+ resource "aws_instance" "example" {
    + ami            = "ami-0fb653ca2d3203ac1"
    + instance_type  = "t2.micro"
    + (...)
  }
Plan: 5 to add, 0 to change, 0 to destroy.
 

当你想要部署应用的另一个版本,例如AMI ID ami-02bcbb802e03574ba,这时就会出现很多麻烦,需要编写另一个模板,追踪你之前部署的10个服务器(或者现在是15个?)并仔细地将每一个更新到新版本。使用Terraform的声明式方法,你只需要返回到完全相同的配置文件,简单地将ami参数更改为ami-02bcbb802e03574ba

这些例子是简化的。Ansible确实允许你在部署新的EC2实例前使用标签查找现有的EC2实例(例如,使用instance_tags和count_tag参数),但是,对于你使用Ansible管理的每一个资源,都必须手动找出这种逻辑,这可能会非常复杂:例如,你可能必须手动配置你的代码以便不仅通过标签,还通过镜像版本、可用区和其他参数查找现有的实例。

主服务器与无主服务器

默认情况下,Chef 和 Puppet 需要你运行一个主服务器,用于存储你的基础设施状态并分发更新。每次你想在基础设施中更新某些内容时,你会使用一个客户端(例如,命令行工具)向主服务器发出新命令,主服务器要么将更新推送到所有其他服务器,要么这些服务器会定期从主服务器那里拉取最新的更新。

主服务器提供了几个优点。首先,它是一个你可以查看和管理你的基础设施状态的单一、中心化的地方。许多配置管理工具甚至为主服务器提供了一个网页界面(例如,Chef 控制台,Puppet 企业控制台),使你更容易看到正在发生什么。其次,一些主服务器可以在后台持续运行,并执行你的配置。这样,如果有人在服务器上手动做了一些改动,主服务器可以撤销那些改动,以防止配置漂移。

然而,必须运行一个主服务器有一些严重的缺点:

  • 额外的基础设施
  • 维护: 你需要维护、升级、备份、监控和扩展主服务器。
  • 安全: 你需要提供一种方式让客户端与主服务器通信,以及让主服务器与所有其他服务器通信,这通常意味着需要开放额外的端口和配置额外的认证系统,这都会增加你的攻击面。

虽然 Chef 和 Puppet 都支持不同程度的无主模式,在这种模式下,你只需要在每个服务器上运行它们的代理软件,通常是按照一个周期性的时间表(例如,每五分钟运行一次的 cron 作业),并使用它从版本控制系统中拉取最新的更新(而不是从主服务器中拉取)。这大大减少了移动部分的数量,但是,如我在下一节中所讨论的,这仍然留下了许多未回答的问题,特别是关于如何首次在服务器上配置和安装代理软件的问题。

Ansible、CloudFormation、Heat、Terraform 和 Pulumi 默认都是无主的。或者,更准确地说,其中的一些依赖于主服务器,但它已经是你正在使用的基础设施的一部分,而不是你需要管理的额外部分。例如,Terraform 使用云提供商的 API 与云提供商通信,所以在某种意义上,API 服务器就是主服务器,除了它们不需要任何额外的基础设施或任何额外的认证机制(即,只使用你的 API 密钥)。Ansible 通过 SSH 直接连接到每个服务器,所以同样,你不需要运行任何额外的基础设施或管理额外的认证机制(即,只使用你的 SSH 密钥)。

代理与无代理

Chef 和 Puppet 要求你在每个需要配置的服务器上安装代理软件(例如,Chef 客户端,Puppet 代理)。代理通常在每台服务器的后台运行,并负责安装最新的配置管理更新。

这有一些缺点:

  • 引导: 你如何首次在服务器上配置和安装代理软件呢?一些配置管理工具推迟了这个问题,假设有一些外部过程会为它们处理这个问题(例如,你首先使用 Terraform 部署一批已经安装了代理的 AMI 服务器);其他的配置管理工具有一个特殊的引导过程,你在其中运行一次性命令,使用云提供商的 API 为服务器配置,并通过 SSH 在这些服务器上安装代理软件。
  • 维护: 你需要定期更新代理软件,小心保持它与主服务器(如果有的话)的同步。你还需要监控代理软件,并在它崩溃时重启它。
  • 安全: 如果代理软件从主服务器(或者如果你没有使用主服务器,那么就是其他服务器)拉取配置,你需要在每台服务器上打开出站端口。如果主服务器推送配置到代理,你需要在每台服务器上打开入站端口。无论哪种情况,你都必须找出如何让代理与其通信的服务器进行身份验证。所有这些都会增加你的攻击面。

再次强调,Chef 和 Puppet 确实支持不同程度的无代理模式,但这些感觉像是事后加上去的,并不支持配置管理工具的全部功能集。这就是为什么在实际中,Chef 和 Puppet 的默认配置或惯用配置几乎总是包含一个代理,通常还包括一个主服务器,如图 1-7 所示。所有这些额外的移动部分都为你的基础设施引入了大量的新故障模式。每次你在凌晨3点收到一个错误报告时,你都需要确定这是你的应用程序代码的错误,还是你的 IaC 代码的错误,或者是配置管理客户端的错误,或者是主服务器的错误,或者是客户端与主服务器通信的方式的错误,或者是其他服务器与主服务器通信的方式的错误,或者是……

Ansible、CloudFormation、Heat、Terraform 和 Pulumi 不要求你安装任何额外的代理。或者,更准确地说,其中的一些需要代理,但这些通常已经作为你正在使用的基础设施的一部分已经安装好了。例如,AWS、Azure、Google Cloud 和所有其他云提供商都负责在每台物理服务器上安装、管理和认证代理软件。作为 Terraform 的用户,你不需要担心任何这些:你只需要发出命令,云提供商的代理就会在你的所有服务器上为你执行这些命令,如图 1-8 所示。对于 Ansible,你的服务器需要运行 SSH 守护进程,这在大多数服务器上都很常见。

Use of Multiple Tools Together

Provisioning plus configuration management

Terraform and Ansible. You use Terraform to deploy all the underlying infrastructure, including the network topology (i.e., virtual private clouds [VPCs], subnets, route tables), data stores (e.g., MySQL, Redis), load balancers, and servers. You then use Ansible to deploy your apps on top of those servers 这是一个开始时很容易采用的方法,因为没有额外的基础设施需要运行(Terraform 和 Ansible 都只是客户端应用程序),并且有很多方法可以让 Ansible 和 Terraform 一起工作(例如,Terraform 为你的服务器添加特殊的标签,Ansible 使用这些标签来查找服务器并配置它们)。主要的缺点是,使用 Ansible 通常意味着你需要编写大量的过程性代码,并且服务器是可变的,因此随着你的代码库、基础设施和团队的增长,维护可能会变得更加困难。

Provisioning plus server templating

Terraform 和 Packer。你使用 Packer 将你的应用程序打包为 VM 镜像。然后你使用 Terraform 部署这些 VM 镜像的服务器和你的其他基础设施,包括网络拓扑(即,VPCs,子网,路由表)、数据存储(例如,MySQL,Redis)和负载均衡器,如图中所示。这也是一个开始时很容易采用的方法,因为没有额外的基础设施需要运行(Terraform 和 Packer 都只是客户端应用程序),并且你将在本书后面有很多机会使用 Terraform 部署 VM 镜像。此外,这是一种不可变基础设施的方法,将使维护更加简单。然而,有两个主要的缺点。首先,VM 可能需要很长时间来构建和部署,这将降低你的迭代速度。其次,如你将在后面的章节中看到,你可以使用 Terraform 实现的部署策略是有限的(例如,你不能在 Terraform 中原生实现蓝绿部署),所以你要么最后编写很多复杂的部署脚本,要么转向下一步描述的编排工具。

Provisioning plus server templating plus orchestration

示例:Terraform、Packer、Docker 和 Kubernetes。你使用 Packer 创建一个已经安装了 Docker 和 Kubernetes 代理的 VM 镜像。然后你使用 Terraform 部署一个服务器集群,每个服务器都运行这个 VM 镜像,以及你的其他基础设施,包括网络拓扑(即,VPCs,子网,路由表)、数据存储(例如,MySQL,Redis)和负载均衡器。最后,当服务器集群启动时,它形成了一个 Kubernetes 集群,你使用它来运行和管理你的 Docker 化的应用程序,如图 1-11 所示。

这种方法的优点是 Docker 镜像构建相当快,你可以在你的本地计算机上运行和测试它们,并且你可以利用 Kubernetes 的所有内置功能,包括各种部署策略、自动修复、自动缩放等等。缺点是增加了复杂性,无论是在需要运行的额外基础设施方面(Kubernetes 集群很难且昂贵的部署和运行,尽管大多数主要的云提供商现在提供托管的 Kubernetes 服务,这可以卸载一些这样的工作),还是在需要学习、管理和调试的几个额外抽象层(Kubernetes,Docker,Packer)方面。


第二章

provider "aws" {
  region = "us-east-2"
}

对于每种类型的提供商,你可以创建许多不同种类的资源,如服务器、数据库和负载均衡器。在 Terraform 中创建资源的通用语法如下:

resource "<PROVIDER>_<TYPE>" "<NAME>" {
  [CONFIG ...]
}

其中,PROVIDER 是提供商的名称(例如,aws)、TYPE 是要在该提供商中创建的资源类型(例如,instance)、NAME 是你可以在 Terraform 代码中用来引用这个资源的标识符(例如,my_instance),CONFIG 是针对该资源的一个或多个参数。

be aware that you need to run init anytime you start with new Terraform code and that it’s safe to run init multiple times (the command is idempotent).

terraform plan就像diff一样

.terraform folder, which Terraform uses as a temporary scratch directory, as well as *.tfstate files, which Terraform uses to store state

The reason this example uses port 8080, rather than the default HTTP port 80, is that listening on any port less than 1024 requires root user privileges. This is a security risk since any attacker who manages to compromise your server would get root privileges, too.

Therefore, it’s a best practice to run your web server with a non-root user that has limited permissions. That means you have to listen on higher-numbered ports, but as you’ll see later in this chapter, you can configure a load balancer to listen on port 80 and route traffic to the high-numbered ports on your server(s).

resource "aws_instance" "example" {
  ami                    = "ami-0fb653ca2d3203ac1"
  instance_type          = "t2.micro"
  vpc_security_group_ids = [aws_security_group.instance.id]
 
  user_data = <<-EOF
              #!/bin/bash
              echo "Hello, World" > index.html
              nohup busybox httpd -f -p 8080 &
              EOF
 
  user_data_replace_on_change = true
 
  tags = {
    Name = "terraform-example"
  }
}
 
resource "aws_security_group" "instance" {
  name = "terraform-example-instance"
 
  ingress {
    from_port   = 8080
    to_port     = 8080
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

<<-EOF and EOF are Terraform’s heredoc syntax, which allows you to create multiline strings without having to insert \n characters all over the place.

The user_data_replace_on_change parameter is set to true so that when you change the user_data parameter and run apply, Terraform will terminate the original instance and launch a totally new one. Terraform’s default behavior is to update the original instance in place, but since User Data runs only on the very first boot, and your original instance already went through that boot process, you need to force the creation of a new instance to ensure your new User Data script actually gets executed.

By default, AWS does not allow any incoming or outgoing traffic from an EC2 Instance. To allow the EC2 Instance to receive traffic on port 8080, you need to create a security group:

创建了一个名为 aws_security_group 的新资源(注意所有 AWS 提供商的资源都以 aws_ 开头),并指定该组允许来自 CIDR 块 0.0.0.0/0 的 TCP 请求在 8080 端口进入。CIDR 块是指定 IP 地址范围的一种简洁方式。例如,CIDR 块 10.0.0.0/24 表示所有位于 10.0.0.0 和 10.0.0.255 之间的 IP 地址。CIDR 块 0.0.0.0/0 是一个包括所有可能 IP 地址的 IP 地址范围,因此这个安全组允许来自任何 IP 的请求在 8080 端口进入。

仅仅创建一个安全组是不够的;你需要通过将安全组的 ID 传入到 aws_instance 资源的 vpc_security_group_ids 参数,告诉 EC2 实例实际使用它

要访问安全组资源的 ID,你需要使用资源属性引用,其语法如下:

<PROVIDER>_<TYPE>.<NAME>.<ATTRIBUTE>
其中,PROVIDER 是提供商的名称(例如,aws)、TYPE 是资源类型(例如,security_group)、NAME 是该资源的名称(例如,安全组名为 "instance"),ATTRIBUTE 是该资源的参数之一(例如,name)或由资源导出的属性之一(你可以在每个资源的文档中找到可用属性列表)。安全组导出了一个名为 id 的属性,所以引用它的表达式看起来像这样:
aws_security_group.instance.id

terraform graph The output is in a graph description language called DOT, which you can turn into an image

输出中的 -/+ 意味着“替换”;查找输出中的“forces replacement”文本,可以找出是什么迫使 Terraform进行替换。由于将 user_data_replace_on_change 设置为 true 并改变了 user_data 参数,这将强制进行替换,这意味着原始的 EC2 实例将被终止,并将创建一个全新的实例。值得一提的是,虽然 web 服务器正在被替换,但任何使用该 web 服务器的用户都会经历停机时间

为了保持本书中所有的例子都很简单,它们不仅部署到你的默认 VPC(如前所述),而且还部署到该 VPC 的默认子网。一个 VPC 被划分为一个或多个子网,每个子网都有自己的 IP 地址。默认 VPC 中的子网都是公共子网,这意味着它们获得的 IP 地址可以从公共互联网访问。这就是为什么你能够从你的家庭电脑测试你的 EC2 实例的原因。

在公共子网中运行服务器对于快速实验来说是可以的,但在实际使用中,这是一个安全风险。全世界的黑客都在不断地随机扫描 IP 地址以寻找任何弱点。如果你的服务器公开暴露,只需要无意间留下一个未受保护的端口或运行已知漏洞的过时代码,就有可能被人入侵。

因此,对于生产系统,你应该将所有的服务器,当然也包括所有的数据存储,部署在私有子网中,这些子网的 IP 地址只能从 VPC 内部访问,而不能从公共互联网访问。你在公共子网中运行的唯一服务器应该是少量的反向代理和负载均衡器,你应尽可能地锁定它们

hcl声明变量的语法:

variable "NAME" {
  [CONFIG ...]
}
 
variable "server_port" {
  description = "The port the server will use for HTTP requests"
  type        = number
}

变量声明的主体可以包含以下可选参数:

  • description: 始终使用此参数来记录变量的使用方式是个好主意。你的团队成员不仅能在阅读代码时看到这个描述,而且在运行计划或应用命令时也能看到(你将很快看到一个例子)。
  • default: 有多种方式可以为变量提供值,包括在命令行中传递(使用 -var 选项),通过文件(使用 -var-file 选项),或通过环境变量(Terraform 寻找名称为 TF_VAR_<variable_name> 的环境变量)。如果没有传入值,变量将回退到此默认值。如果没有默认值,Terraform 将交互式地提示用户提供一个。
  • type: 这允许你对用户传入的变量强制类型约束。Terraform 支持多种类型约束,包括 string, number, bool, list, map, set, object, tuple, 和 any。定义类型约束以捕获简单错误始终是个好主意。如果你不指定类型,Terraform 假定类型为 any。
  • validation: 这允许你为输入变量定义自定义验证规则,超越基本的类型检查,例如对一个数字强制实施最小或最大值。你将在第 8 章看到验证的例子。
  • sensitive: 如果你在输入变量上将此参数设置为 true,Terraform 在你运行计划或应用时不会记录它。你应该在你通过变量传入 Terraform 代码的任何秘密上使用这个:例如,密码,API 密钥等
terraform output public_ip
"54.174.13.5"

This is particularly handy for scripting. For example, you could create a deployment script that runs terraform apply to deploy the web server, uses terraform output public_ip to grab its public IP, and runs curl on the IP as a quick smoke test to validate that the deployment worked.

Managing such a cluster manually is a lot of work. Fortunately, you can let AWS take care of it for you by using an Auto Scaling Group (ASG). An ASG takes care of a lot of tasks for you completely automatically, including launching a cluster of EC2 Instances, monitoring the health of each Instance, replacing failed Instances, and adjusting the size of the cluster in response to load.

The first step in creating an ASG is to create a launch configuration, which specifies how to configure each EC2 Instance in the ASG

The aws_launch_configuration resource uses almost the same parameters as the aws_instance resource, although it doesn’t support tags (you’ll handle these in the aws_autoscaling_group resource later) or the user_data_replace_on_change parameter (ASGs launch new instances by default, so you don’t need this parameter). ami is now image_id, and vpc_security_group_ids is now security_groups

resource "aws_launch_configuration" "example" {
  image_id        = "ami-0fb653ca2d3203ac1"
  instance_type   = "t2.micro"
  security_groups = [aws_security_group.instance.id]
 
  user_data = <<-EOF
              #!/bin/bash
              echo "Hello, World" > index.html
              nohup busybox httpd -f -p ${var.server_port} &
              EOF
 
  # Required when using a launch configuration with an auto scaling group.
  lifecycle {
    create_before_destroy = true
  }
}
 
resource "aws_autoscaling_group" "example" {
  launch_configuration = aws_launch_configuration.example.name
  vpc_zone_identifier  = data.aws_subnets.default.ids
 
  min_size = 2
  max_size = 10
 
  tag {
    key                 = "Name"
    value               = "terraform-asg-example"
    propagate_at_launch = true
  }
}

launch configurations are immutable, so if you change any parameter of your launch configuration, Terraform will try to replace it. Normally, when replacing a resource, Terraform would delete the old resource first and then creates its replacement, but because your ASG now has a reference to the old resource, Terraform won’t be able to delete it.

To solve this problem, you can use a lifecycle setting. Every Terraform resource supports several lifecycle settings that configure how that resource is created, updated, and/or deleted. A particularly useful lifecycle setting is create_before_destroy. If you set create_before_destroy to true, Terraform will invert the order in which it replaces resources, creating the replacement resource first (including updating any references that were pointing at the old resource to point to the replacement) and then deleting the old resource.

添加另一个参数到你的 ASG 以使其正常工作:subnet_ids。这个参数指定了 EC2 实例应该部署到哪些 VPC 子网中(请参阅“网络安全”以获取子网背景信息)。每个子网都位于隔离的 AWS AZ(也就是隔离的数据中心)中,因此,通过在多个子网中部署你的实例,你可以确保即使有些数据中心发生故障,你的服务也能持续运行。你可以硬编码子网列表,但这样做并不可维护或可移植,因此,更好的选择是使用数据源来获取你 AWS 账户中的子网列表

数据源代表每次运行 Terraform 时从供应商(在这种情况下,是 AWS)获取的只读信息。向你的 Terraform 配置添加数据源并不会创建任何新的东西;它只是一种查询供应商的 API 以获取数据,并使这些数据可供你的 Terraform 代码使用的方式。每个 Terraform 提供商都公开了各种数据源。例如,AWS 提供商包括用于查找 VPC 数据、子网数据、AMI ID、IP 地址范围、当前用户的身份等的数据源

data "<PROVIDER>_<TYPE>" "<NAME>" {
  [CONFIG ...]
}
 
data "aws_vpc" "default" {
  default = true
}

使用数据源时,你传入的参数通常是搜索过滤器,它们指示你正在查找什么信息。对于 aws_vpc 数据源,你只需要 default = true 这个过滤器,它会指示 Terraform 查找你的 AWS 账户中的默认 VPC

在你有多个服务器,每个服务器都有自己的 IP 地址,但你通常希望给你的最终用户只提供一个 IP 地址。解决这个问题的一种方法是部署一个负载均衡器,将流量在你的服务器之间进行分配,并将所有用户的 IP(实际上是负载均衡器的 DNS 名称)提供给所有用户

AWS 提供了三种类型的负载均衡器:

  • 应用负载均衡器(ALB): 最适合用于 HTTP 和 HTTPS 流量的负载均衡。在开放系统互联(OSI)模型的应用层(第 7 层)上运行。
  • 网络负载均衡器(NLB) 最适合用于 TCP、UDP 和 TLS 流量的负载均衡。可以比 ALB 更快地根据负载进行扩展(NLB 旨在扩展到每秒数千万个请求)。在 OSI 模型的传输层(第 4 层)上运行。

ALB 包括几个部分,如图 2-11 所示:

  • 监听器: 在特定的端口(例如,80)和协议(例如,HTTP)上监听。
  • 监听器规则: 接收进入监听器的请求,并将匹配特定路径(例如,/foo 和 /bar)或主机名(例如,foo.example.com 和 bar.example.com)的请求发送到特定的目标组。
  • 目标组: 一个或多个从负载均衡器接收请求的服务器。目标组还对这些服务器进行健康检查,并只向健康的节点发送请求
resource "aws_lb" "example" {
  name               = "terraform-asg-example"
  load_balancer_type = "application"
  subnets            = data.aws_subnets.default.ids
  security_groups    = [aws_security_group.alb.id]
}
 
resource "aws_lb_listener" "http" {
  load_balancer_arn = aws_lb.example.arn
  port              = 80
  protocol          = "HTTP"
 
  # By default, return a simple 404 page
  default_action {
    type = "fixed-response"
 
    fixed_response {
      content_type = "text/plain"
      message_body = "404: page not found"
      status_code  = 404
    }
  }
}

AWS 负载均衡器并不是由单个服务器组成,而是由可以在不同子网(因此,不同数据中心)运行的多个服务器组成。AWS 根据流量自动扩展负载均衡器服务器的数量,并在其中一个服务器发生故障时处理故障转移,所以你可以直接获得可扩展性和高可用性。

请注意,默认情况下,包括 ALB 在内的所有 AWS 资源都不允许任何进入或出去的流量,因此你需要为 ALB 创建一个新的安全组。这个安全组应该允许在端口 80 上的进入请求,以便你可以通过 HTTP 访问负载均衡器,并允许所有端口的出去的请求,以便负载均衡器可以进行健康检查。你需要通过 security_groups 参数告诉 aws_lb 资源使用这个安全组

resource "aws_security_group" "alb" {
  name = "terraform-example-alb"
 
  # Allow inbound HTTP requests
  ingress {
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }
 
  # Allow all outbound requests
  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}
resource "aws_lb_target_group" "asg" {
  name     = "terraform-asg-example"
  port     = var.server_port
  protocol = "HTTP"
  vpc_id   = data.aws_vpc.default.id
 
  health_check {
    path                = "/"
    protocol            = "HTTP"
    matcher             = "200"
    interval            = 15
    timeout             = 3
    healthy_threshold   = 2
    unhealthy_threshold = 2
  }
}

使用 aws_lb_target_group 资源为你的 ASG 创建一个目标组。这个目标组将通过定期向每个实例发送一个 HTTP 请求来对你的实例进行健康检查,并只有当实例返回一个与配置的匹配器(例如,你可以配置一个匹配器来查找 200 OK 的响应)匹配的响应时,才会考虑实例是“健康的”。如果一个实例没有响应,可能是因为该实例已经停止运行或者过载,它将被标记为“不健康”,并且目标组会自动停止向它发送流量,以最小化对你的用户的影响。

目标组如何知道向哪些 EC2 实例发送请求呢?你可以使用 aws_lb_target_group_attachment 资源将一个静态的 EC2 实例列表附加到目标组,但是对于 ASG,实例可能在任何时候启动或终止,所以静态列表是行不通的。相反,你可以利用 ASG 和 ALB 之间的一流集成。回到 aws_autoscaling_group 资源,并将其 target_group_arns 参数设置为指向你的新目标组。你还应该将 health_check_type 更新为 "ELB"。默认的 health_check_type 是 "EC2",这是一个最小的健康检查,只有当 AWS 虚拟机管理器说 VM 完全无法使用或无法访问时,才会认为实例是不健康的。"ELB" 健康检查更为健全,因为它指示 ASG 使用目标组的健康检查来确定一个实例是否健康,并在目标组报告它们为不健康时自动替换实例。这样,实例不仅会在它们完全关闭的时候被替换,而且如果例如它们因为内存耗尽或关键进程崩溃而停止服务请求,也会被替换

resource "aws_autoscaling_group" "example" {
  launch_configuration = aws_launch_configuration.example.name
  vpc_zone_identifier  = data.aws_subnets.default.ids
 
  target_group_arns = [aws_lb_target_group.asg.arn]
  health_check_type = "ELB"
 
  min_size = 2
  max_size = 10
 
  tag {
    key                 = "Name"
    value               = "terraform-asg-example"
    propagate_at_launch = true
  }
}
resource "aws_lb_listener_rule" "asg" {
  listener_arn = aws_lb_listener.http.arn
  priority     = 100
 
  condition {
    path_pattern {
      values = ["*"]
    }
  }
 
  action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.asg.arn
  }
}

把所有这些部件通过创建监听器规则使用 aws_lb_listener_rule 资源绑定在一起了。前面的代码添加了一个监听器规则,将匹配任何路径的请求发送到包含你的 ASG 的目标组。

在你部署负载均衡器之前还有最后一件事要做——用显示 ALB 的 DNS 名称的输出替换之前单个 EC2 实例的旧 public_ip 输出:

output "alb_dns_name" {
  value       = aws_lb.example.dns_name
  description = "The domain name of the load balancer"
}

此时,你可以看到你的集群如何响应启动新实例或关闭旧实例的情况。例如,转到 "Instances" 标签,通过选择其复选框,点击顶部的 "Actions" 按钮,然后将 "Instance State" 设置为 "Terminate" 来终止其中一个实例。继续测试 ALB URL,你应该会在每次请求中获得 200 OK,即使在终止实例的同时,也不会影响正常访问,因为 ALB 会自动检测到实例已关闭并停止向其路由。更有趣的是,实例关闭后不久,ASG 会检测到运行的实例少于两个,并自动启动一个新的实例来替换它(自我修复!)。你也可以通过在 Terraform 代码中添加 desired_capacity 参数并重新运行 apply 来查看 ASG 是如何调整自身大小的。

由于 Terraform 跟踪了你创建的所有资源,所以清理工作非常简单。你需要做的就是运行 destroy 命令。不用说,你应该很少(如果有的话)在生产环境中运行 destroy 命令!destroy 命令没有“撤销”功能,所以 Terraform 会给你最后一次机会来审查你正在做什么,向你展示你即将删除的所有资源的列表,并提示你确认删除


第三章

每次你运行 Terraform 时,它都会在 Terraform 状态文件中记录关于它创建的基础设施的信息。默认情况下,当你在 /foo/bar 文件夹中运行 Terraform 时,Terraform 会创建 /foo/bar/terraform.tfstate 文件。这个文件包含一个自定义的 JSON 格式,记录了从你的配置文件中的 Terraform 资源到现实世界中这些资源表示的映射

状态文件是一个私有 API 状态文件格式是一个仅供 Terraform 内部使用的私有 API。你永远不应手动编辑 Terraform 状态文件或编写直接读取它们的代码。

如果出于某种原因你需要操作状态文件——这应该是一个相对罕见的情况——使用 terraform import 或 terraform state 命令

在真实的产品中作为一个团队使用 Terraform,你会遇到几个问题:

  • 状态文件的共享存储: 为了能够使用 Terraform 更新你的基础设施,你的团队成员需要访问相同的 Terraform 状态文件。这意味着你需要将这些文件存储在一个共享的位置。
  • 锁定状态文件: 只要数据被共享,你就会遇到一个新问题:锁定。如果没有锁定,如果有两个团队成员同时运行 Terraform,他们可能会同时更新同一个状态文件,从而导致数据丢失、混淆或更糟的情况。Terraform 的一个重要特性是它可以自动锁定状态文件,因此在任何时候只有一个 Terraform 操作能够进行。
  • 隔离状态文件: 在对基础设施进行更改时,隔离不同的环境是最佳的实践。例如,当在测试或暂存环境中进行更改时,你需要确保无论如何都不会意外中断生产。但是,如果所有的基础设施都在同一份 Terraform 状态文件中定义,你如何隔离你的更改呢?

虽然你绝对应该将你的 Terraform 代码存储在版本控制中,但是将 Terraform 状态存储在版本控制中是个坏主意,原因如下:

  • 手动错误: 在运行 Terraform 或在运行 Terraform 后将最新的更改推送到版本控制之前,很容易忘记从版本控制中拉取最新的更改。你的团队中的某个人用过时的状态文件运行 Terraform,结果意外地回滚或复制之前的部署,只是时间问题。
  • 锁定: 大多数版本控制系统都不提供任何形式的锁定,这会阻止两个团队成员在同一时间对同一状态文件运行 terraform apply。
  • 秘密: 所有在 Terraform 状态文件中的数据都是以纯文本形式存储的。这是个问题,因为某些 Terraform 资源需要存储敏感数据。例如,如果你使用 aws_db_instance 资源创建一个数据库,Terraform 将以纯文本形式在状态文件中存储数据库的用户名和密码,你不应该在版本控制中存储纯文本的秘密。

与其使用版本控制,管理状态文件的共享存储的最好方式是使用 Terraform 的内建支持远程后端。Terraform 后端决定了 Terraform 如何加载和存储状态。默认的后端,你一直在使用的,是本地后端,它在你的本地磁盘上存储状态文件。远程后端允许你在远程,共享的存储中存储状态文件。支持的远程后端有很多,包括 Amazon S3,Azure Storage,Google Cloud Storage 和 HashiCorp 的 Terraform Cloud 和 Terraform Enterprise。

  • 手动错误: 在你配置了一个远程后端后,每次你运行 plan 或 apply 时,Terraform 将自动从该后端加载状态文件,并且在每次 apply 后,它将自动将状态文件存储在该后端,所以没有手动错误的机会。
  • 锁定: 大多数的远程后端本地支持锁定。为了运行 terraform apply,Terraform 将自动获得一个锁;如果有其他人已经在运行 apply,他们将已经有了锁,你将不得不等待。你可以用 -lock-timeout=<TIME> 参数运行 apply,以指导 Terraform 等待最长时长 TIME 以释放锁(例如,-lock-timeout=10m 将等待 10 分钟)。
  • 秘密: 大多数的远程后端本地支持在传输和休息时对状态文件进行加密。此外,这些后端通常会暴露出配置访问权限的方式(例如,使用 Amazon S3 桶的 IAM 策略),所以你可以控制谁可以访问你的状态文件和他们可能包含的秘密。如果 Terraform 本地支持在状态文件中加密秘密会更好,但是这些远程后端减少了大部分的安全问题,因为至少状态文件不会在任何地方的磁盘上以纯文本形式存储。

如果你正在使用 AWS 的 Terraform,Amazon S3(简单存储服务),也就是 Amazon 的托管文件存储,通常是你作为远程后端的最佳选择

resource "aws_s3_bucket" "terraform_state" {
  bucket = "terraform-up-and-running-state"
 
  # Prevent accidental deletion of this S3 bucket
  lifecycle {
    prevent_destroy = true
  }
}
 
resource "aws_s3_bucket_versioning" "enabled" {
  bucket = aws_s3_bucket.terraform_state.id
  versioning_configuration {
    status = "Enabled"
  }
}
 
resource "aws_s3_bucket_server_side_encryption_configuration" "default" {
  bucket = aws_s3_bucket.terraform_state.id
 
  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm = "AES256"
    }
  }
}
 
resource "aws_s3_bucket_public_access_block" "public_access" {
  bucket                  = aws_s3_bucket.terraform_state.id
  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}
 
resource "aws_dynamodb_table" "terraform_locks" {
  name         = "terraform-up-and-running-locks"
  billing_mode = "PAY_PER_REQUEST"
  hash_key     = "LockID"
 
  attribute {
    name = "LockID"
    type = "S"
  }
}
  • bucket: S3 bucket的名称。注意,S3 bucket的名称必须在所有AWS客户中全球唯一。请确保记住这个名称,并注意你正在使用的AWS区域
  • prevent_destroy: 生命周期设置。当你在资源上设置prevent_destroy为true时,任何尝试删除该资源的操作(例如,通过运行terraform destroy)都会导致Terraform退出并报错。这是防止意外删除重要资源(如此S3 bucket,它将存储你所有的Terraform状态)的好方法。当然,如果你真的想删除它,你可以只是注释掉这个设置。

使用aws_s3_bucket_versioning资源为S3 bucket启用版本控制,这样每次更新bucket中的文件实际上都会创建该文件的新版本。这使你可以查看文件的旧版本,并在任何时候恢复到那些旧版本,这是一个有用的回退机制,如果出现问题的话

使用aws_s3_bucket_server_side_encryption_configuration资源默认启用服务器端加密,对所有写入此S3 bucket的数据进行加密。这确保你的状态文件以及它们可能包含的任何秘密在存储在S3时都是加密的

使用aws_s3_bucket_public_access_block资源来阻止所有对S3 bucket的公共访问。S3 buckets默认是私有的,但由于它们经常用于提供静态内容,比如图片、字体、CSS、JS、HTML,因此公开buckets是可能的,甚至很容易。由于你的Terraform状态文件可能包含敏感数据和秘密,因此值得添加这一额外的保护层,确保你的团队中没有人能够意外地使这个S3 bucket变为公开

创建一个DynamoDB表用于锁定。DynamoDB是Amazon的分布式键值存储。它支持强一致性读取和条件写入,这是你需要的所有分布式锁系统的成分。而且,它完全托管,所以你不需要运行任何基础设施

使用DynamoDB与Terraform一起进行锁定,你必须创建一个DynamoDB表,该表有一个名为LockID(准确的拼写和大小写)的主键。你可以使用aws_dynamodb_table资源创建这样的表

为了配置Terraform将状态存储在你的S3 bucket(带有加密和锁定),你需要在你的Terraform代码中添加一个后端配置。这是Terraform本身的配置,所以它位于一个terraform块内,并具有以下语法:

terraform {
  backend "<BACKEND_NAME>" {
    [CONFIG...]
  }
}
 
terraform {
  backend "s3" {
    # Replace this with your bucket name!
    bucket         = "terraform-up-and-running-state"
    key            = "global/s3/terraform.tfstate"
    region         = "us-east-2"
 
    # Replace this with your DynamoDB table name!
    dynamodb_table = "terraform-up-and-running-locks"
    encrypt        = true
  }
}
 
output "s3_bucket_arn" {
  value       = aws_s3_bucket.terraform_state.arn
  description = "S3 bucket的ARN"
}
 
output "dynamodb_table_name" {
  value       = aws_dynamodb_table.terraform_locks.name
  description = "DynamoDB表的名称"
}

要指示Terraform将你的状态文件存储在此S3 bucket中,你将再次使用terraform init命令。这个命令不仅可以下载供应商代码,还可以配置你的Terraform后端(稍后你还会看到另一种用途)。此外,init命令是幂等的,所以可以多次运行

erraform的后端有一些限制和陷阱,你需要知道。首个限制是使用Terraform创建你想要存储Terraform状态的S3 bucket的先有鸡还是先有蛋的情况。为了让这个工作,你必须使用一个两步过程:

写Terraform代码以创建S3 bucket和DynamoDB表,并使用本地后端部署该代码。 返回Terraform代码,将远程后端配置添加到其中,以使用新创建的S3 bucket和DynamoDB表,并运行terraform init将你的本地状态复制到S3。 如果你想要删除S3 bucket和DynamoDB表,你需要反向执行这个两步过程:

转到Terraform代码,移除后端配置,并重新运行terraform init将Terraform状态复制回你的本地磁盘。 运行terraform destroy删除S3 bucket和DynamoDB表。

Terraform的后端块不允许你使用任何变量或引用。以下代码将无法工作:

# This will NOT work. Variables aren't allowed in a backend configuration.
terraform {
  backend "s3" {
    bucket         = var.bucket
    region         = var.region
    dynamodb_table = var.dynamodb_table
    key            = "example/terraform.tfstate"
    encrypt        = true
  }
}

这意味着你需要手动复制和粘贴S3 bucket名称,区域,DynamoDB表名称等,到你的每一个Terraform模块

减少复制和粘贴的一种选择是使用部分配置,其中你省略了Terraform代码中的后端配置的某些参数,而是通过-backend-config命令行参数在调用terraform init时传入这些参数。例如,你可以将重复的后端参数,如bucket和region,提取到一个名为backend.hcl的单独文件中:

# backend.hcl
bucket         = "terraform-up-and-running-state"
region         = "us-east-2"
dynamodb_table = "terraform-up-and-running-locks"
encrypt        = true

只有key参数仍在Terraform代码中,因为你仍需要为每个模块设置不同的key值:

# Partial configuration. The other settings (e.g., bucket, region) will be
# passed in from a file via -backend-config arguments to 'terraform init'
terraform {
  backend "s3" {
    key = "example/terraform.tfstate"
  }
}

要将所有你的部分配置放在一起,使用-backend-config参数运行terraform init:

$ terraform init -backend-config=backend.hcl

Terraform将backend.hcl中的部分配置与你的Terraform代码中的部分配置合并,以产生你的模块使用的完整配置。你可以在你所有的模块中使用相同的backend.hcl文件,这大大减少了重复;然而,你仍然需要在每个模块中手动设置一个唯一的key值。

减少复制和粘贴的另一种选择是使用Terragrunt,这是一个开源工具,试图填补Terraform的一些空白。Terragrunt可以帮你保持你的整个后端配置DRY(不重复自己)通过在一个文件中定义所有基本的后端设置(bucket名称,区域,DynamoDB表名称)并自动设置key参数为模块的相对文件夹路径。

状态文件隔离 有了远程后端和锁定,协作不再是问题。然而,还有一个问题需要解决:隔离。当你开始使用Terraform时,你可能会忍不住在一个Terraform文件或一个文件夹的Terraform文件集中定义所有你的基础设施。这种方法的问题在于你的所有Terraform状态现在也存储在一个单独的文件中,任何地方的错误都可能导致所有东西出问题。

例如,在尝试在staging环境部署你的应用的新版本时,你可能会在生产环境中破坏应用。或者,更糟糕的是,你可能会破坏你的整个状态文件,不管是因为你没有使用锁定,还是由于一个罕见的Terraform bug,现在你所有环境中的所有基础设施都出现了问题。

设立独立的环境的全部目的就是让它们之间能够相互隔离,所以如果你使用一套Terraform配置管理所有环境,你就破坏了这种隔离。

需要在独立的配置集中定义每个环境(下图),这样一个环境中的问题就能完全与其他环境隔离。有两种方式可以隔离状态文件:

  • 通过工作空间隔离: 对于在同一配置上进行快速、独立的测试很有用
  • 通过文件布局隔离: 对于需要在环境之间强烈分离的生产用例很有用

Terraform工作空间允许你在多个、独立的、命名的工作空间中存储你的Terraform状态。Terraform开始时有一个名为“default”的工作空间,如果你从未明确指定一个工作空间,default工作空间就是你一直使用的那个。要创建一个新的工作空间或切换工作空间,你可以使用terraform workspace命令

在每个工作空间中,Terraform都会使用你在后端配置中指定的键,因此你应该会发现有一个example1/workspaces-example/terraform.tfstate和一个example2/workspaces-example/terraform.tfstate。换句话说,切换到不同的工作空间等同于更改你的状态文件的存储路径。

你甚至可以通过读取 terraform.workspace 表达式来根据你所在的工作空间改变该模块的行为。例如,下面是如何在default工作空间中将实例类型设置为t2.medium,而在所有其他工作空间中设置为t2.micro(例如,为了在实验时节省资金):

resource "aws_instance" "example" {
  ami           = "ami-0fb653ca2d3203ac1"
  instance_type = terraform.workspace == "default" ? "t2.medium" : "t2.micro"
}

所有工作空间的状态文件都存储在同一个后端(例如,同一个S3 bucket)。这意味着你对所有工作空间使用相同的身份验证和访问控制,这是工作空间不适合用于隔离环境的一个主要原因(例如,将staging从production中隔离)。

除非你运行 terraform workspace 命令,否则在代码或终端中看不到工作空间。在浏览代码时,一个模块在一个工作空间中的部署看起来和在10个工作空间中的部署完全一样。这使得维护更加困难,因为你无法很好地了解你的基础设施。

将以上两点结合起来,结果是工作空间可能非常容易出错。缺乏可见性使得你很容易忘记你处在哪个工作空间,而错误地在错误的工作空间中部署更改(例如,在“生产”工作空间而不是“预演”工作空间中误运行 terraform destroy),并且由于你必须为所有工作空间使用相同的认证机制,你没有其他的防御层来防止这种错误

为了在环境之间获得适当的隔离,你最可能希望使用文件布局

要实现环境之间的完全隔离,你需要执行以下步骤:

将每个环境的 Terraform 配置文件放入一个单独的文件夹。例如,所有的 staging 环境配置可以在一个名为 stage 的文件夹中,所有的生产环境配置可以在一个名为 prod 的文件夹中。

为每个环境配置不同的后端,使用不同的认证机制和访问控制:例如,每个环境都可以在一个单独的 AWS 账户中,使用单独的 S3 存储桶作为后端。

采用这种方法,使用单独的文件夹使得你可以更清楚地知道你正在部署到哪些环境,使用单独的状态文件和单独的认证机制,使得在一个环境中的错误对另一个环境产生影响的可能性大大减小。

实际上,你可能希望将隔离的概念超越环境,延伸到“组件”级别,其中一个组件是一组常常一起部署的资源。例如,在你为你的基础设施设置完基本的网络拓扑之后——在 AWS 术语中,你的 Virtual Private Cloud(VPC)以及所有相关的子网、路由规则、VPN 和网络 ACL——你可能只会在几个月内改变它一次。另一方面,你可能会每天多次部署新版本的 web 服务器。如果你在同一组 Terraform 配置中管理 VPC 组件和 web 服务器组件的基础设施,那么你就不必要地将你整个网络拓扑置于破坏的风险中(例如,来自代码中的一个简单的拼写错误或有人不小心运行了错误的命令)。

因此,我推荐为每个环境(staging, production 等)和每个环境中的每个组件(VPC,服务,数据库)使用单独的 Terraform 文件夹(因此也是单独的状态文件)

stage
|_vpc
|_services
  |_frontend-app
  |_backend-app
    |_variables.tf
    |_outputs.tf
    |_main.tf
|_data-storage
  |_mysql
  |_redis


prod
|_vpc
|_services
  |_frontend-app
  |_backend-app
    |_variables.tf
    |_outputs.tf
    |_main.tf
|_data-storage
  |_mysql
  |_redis

mgmt
|_vpc
|_services
  |_bastion-host
  |_jenkins

global
|_iam
|_s3

在每个环境中,有单独的文件夹用于每个“组件”。每个项目的组件都不同,但这里是典型的:

vpc 该环境的网络拓扑。

services 在此环境中运行的应用程序或微服务,如 Ruby on Rails 前端或 Scala 后端。每个应用甚至可以在其自己的文件夹中,将其与所有其他应用隔离。

data-storage 在此环境中运行的数据存储,如 MySQL 或 Redis。每个数据存储甚至可以在其自己的文件夹中,将其与所有其他数据存储隔离。

在每个组件中,有实际的 Terraform 配置文件,这些文件按照以下命名约定进行组织:

variables.tf 输入变量

outputs.tf 输出变量

main.tf 资源和数据源

dependencies.tf 通常将所有的数据源放入一个 dependencies.tf 文件中,以便更容易看到代码依赖于哪些外部事物。

providers.tf 你可能想要将你的提供者块放入一个 providers.tf 文件中,这样你可以一目了然地看到代码与哪些提供者进行交谈,并需要提供什么身份验证。

main-xxx.tf 如果 main.tf 文件变得很长,因为它包含大量的资源,你可以将它分解成较小的文件,将资源以某种逻辑方式组织在一起:例如,main-iam.tf 可以包含所有的 IAM 资源,main-s3.tf 可以包含所有的 S3 资源,等等。使用 main- 前缀使得在按字母顺序组织的文件夹中扫描文件列表时更容易,因为所有的资源将被组织在一起。值得注意的是,如果你发现自己正在管理大量的资源,并且在许多文件中分解它们感到困难,那可能是一个信号,说明你应该将你的代码分解成更小的模块

你还应该更新 web 服务器集群以使用 S3 作为后端。你可以从 global/s3/main.tf 中复制和粘贴后端配置,但要确保将 key 改为与 web 服务器 Terraform 代码相同的文件夹路径:stage/services/webserver-cluster/terraform.tfstate。这为你在版本控制中的 Terraform 代码和 S3 中的 Terraform 状态文件之间的布局提供了 1:1 的映射,所以很明显两者是如何连接的。s3 模块已经使用这个约定设置了 key。

多文件夹工作 将组件分割到不同的文件夹可以防止你一次性误操作破坏整个基础设施,但同时也阻止了你一次性创建整个基础设施。如果某个环境的所有组件都在一个 Terraform 配置中定义,你可以通过单次调用 terraform apply 来启动整个环境。但如果所有组件都在不同的文件夹中,那么你需要在每个文件夹中分别运行 terraform apply。

解决方案:如果你使用 Terragrunt,你可以使用 run-all 命令并行运行多个文件夹的命令。

复制/粘贴 这一节描述的文件布局有很多重复。例如,同样的前端应用和后端应用在 stage 和 prod 文件夹中都存在。

解决方案:你实际上不需要复制和粘贴所有的代码!在第四章中,你将看到如何使用 Terraform 模块保持代码的 DRY(Don't Repeat Yourself,不重复自己)。

资源依赖 将代码拆分到多个文件夹使得使用资源依赖变得更困难。如果你的应用代码在与数据库代码相同的 Terraform 配置文件中定义,那么应用代码可以直接通过属性引用访问数据库的属性(例如,通过 aws_db_instance.foo.address 访问数据库地址)。但如果应用代码和数据库代码位于不同的文件夹,如我推荐的,你就无法再这样做了。

解决方案:一种选择是在 Terragrunt 中使用依赖块,如你将在第十章中看到。另一种选择是使用 terraform_remote_state 数据源,如下一节所述。

terraform_remote_state. You can use this data source to fetch the Terraform state file stored by another set of Terraform configurations.

for each input variable foo defined in your Terraform configurations, you can provide Terraform the value of this variable using the environment variable TF_VAR_foo

Note that Amazon RDS can take roughly 10 minutes to provision even a small database, so be patient.

理解terraform_remote_state数据源返回的数据和所有Terraform数据源一样是只读的,这一点很重要。您在web服务器集群的Terraform代码中所做的任何事情都无法修改该状态,所以您可以无风险地拉取数据库的状态数据。

数据库的所有输出变量都存储在状态文件中,您可以使用如下形式的属性引用从terraform_remote_state数据源读取它们:

data.terraform_remote_state.<NAME>.outputs.<ATTRIBUTE>

随着User Data脚本变得越来越长,内联定义变得越来越混乱。通常,在另一种语言(Terraform)中嵌入一种编程语言(Bash)会使维护每个语言变得更加困难,所以让我们暂停一下,将Bash脚本外部化。为此,您可以使用templatefile内置函数。

Terraform包含许多内置函数,您可以使用如下形式的表达式执行它们:

function_name(...)

验内置函数的好方法是运行terraform console命令获取一个交互式控制台,在那里您可以尝试Terraform语法,查询基础设施的状态,并立即看到结果. Terraform控制台是只读的,所以您不必担心意外改变基础设施或状态。

还有许多其他内置函数可以用于操作字符串、数字、列表和映射。其中一个是templatefile函数:

templatefile(<PATH>, <VARS>)

该函数读取PATH中的文件,将其渲染为模板,并将结果作为字符串返回。当我说“将其渲染为模板”时,我的意思是PATH中的文件可以使用Terraform中的字符串插值语法(${...}),Terraform将渲染该文件的内容,从VARS中填充变量引用。

请注意,与原始脚本相比,此Bash脚本有一些更改:

#!/bin/bash
 
cat > index.html <<EOF
<h1>Hello, World</h1>
<p>DB address: ${db_address}</p>
<p>DB port: ${db_port}</p>
EOF
 
nohup busybox httpd -f -p ${server_port} &

它使用Terraform的标准插值语法查找变量,只不过它只能访问通过templatefile的第二个参数传递的那些变量(如您稍后所见),因此您不需要任何前缀就可以访问它们:例如,您应该使用${server_port}而不是${var.server_port}

将代码放入Terraform模块中,并在代码的多个位置重用该模块。您不必在staging和production环境中复制粘贴相同的代码,而是可以让两个环境都从同一个模块重用代码

模块是编写可重用、可维护和可测试的Terraform代码的关键

文件夹中的任何Terraform配置文件集都是模块。

如果直接在模块上运行apply,则称之为根模块。要看到模块的真正能力,您需要创建一个可重用模块,这是一个旨在在其他模块中使用的模块

provider应该只在根模块中配置,而不是在可重用模块中配置

module "<NAME>" {
  source = "<SOURCE>"
 
  [CONFIG ...]
}

请注意,每当您向Terraform配置添加模块或修改模块的source参数时,在运行plan或apply之前都需要运行init命令

注意webserver-cluster模块存在一个问题:所有名称都是硬编码的。也就是说,安全组、ALB和其他资源的名称全部硬编码,因此如果您在同一个AWS账户中多次使用此模块,将会遇到名称冲突错误。甚至读取数据库状态的详情也是硬编码的,因为您复制到modules/services/webserver-cluster中的main.tf文件使用了一个terraform_remote_state数据源来确定数据库地址和端口,而该terraform_remote_state是硬编码的,指向staging环境。

要解决这些问题,您需要为webserver-cluster模块添加可配置的输入,以便它可以在不同的环境中表现不同。


Kubernetes 主要由两部分组成:

  • 控制平面: 负责管理 Kubernetes 集群。它是操作的“大脑”,负责存储集群状态、监控容器,并在集群中协调动作。它还运行 API 服务器,该服务器提供了一个 API,您可以通过命令行工具(例如 kubectl)、Web UI(例如 Kubernetes Dashboard)和 IaC 工具(例如 Terraform)来控制集群中的动态。
  • 工作节点: 用来实际运行容器的服务器。工作节点完全由控制平面管理,控制平面指示每个工作节点应运行哪些容器。

要在 Kubernetes 中部署某物,您需要创建 Kubernetes 对象,这些对象是您通过 API 服务器写入 Kubernetes 集群的持久实体,记录您的意图:例如,您的意图是运行特定的 Docker 镜像。集群运行一个协调循环,不断检查您存储其中的对象,并努力使集群状态与您的意图相匹配。

Kubernetes 提供了许多不同类型的对象。以下两个对象:

  • Kubernetes 部署: 以声明方式在 Kubernetes 中管理应用程序的一种方式。您声明要运行哪些 Docker 镜像,运行它们的副本数量(称为副本),这些镜像的各种设置(例如 CPU、内存、端口号、环境变量),以及更新这些镜像的策略,然后 Kubernetes 部署将努力确保您声明的要求始终得到满足。例如,如果您指定要三个副本,但其中一个工作节点宕机,只剩下两个副本,那么部署会自动在其他工作节点之一上启动第三个副本。
  • Kubernetes 服务: 在 Kubernetes 中运行的 Web 应用程序作为网络服务公开的方式。例如,您可以使用 Kubernetes 服务配置一个负载均衡器,公开一个公共端点,并将该端点的流量分配到 Kubernetes 部署中的副本上。

与 Kubernetes 交互的惯用方式是创建描述您想要的内容的 YAML 文件——例如,一个定义 Kubernetes 部署的 YAML 文件,另一个定义 Kubernetes 服务的文件——并使用 kubectl apply 命令将这些对象提交给集群。相比YAML,许多 Kubernetes 用户转向替代方案,例如 Helm 或 Terraform

在 Kubernetes 中,您一次部署的不是单个容器,而是 Pod,这些是一组意图一起部署的容器。例如,您可以有一个 Pod,其中一个容器用于运行 Web 应用程序(例如,您之前看到的 Python 应用程序),另一个容器用于收集有关 Web 应用程序的指标并将它们发送到中央服务(例如,Datadog)。template 块是您定义 Pod 模板的地方,它指定要运行的容器、使用的端口、要设置的环境变量等。