-
Notifications
You must be signed in to change notification settings - Fork 41
/
search_plus_index.json
1 lines (1 loc) · 496 KB
/
search_plus_index.json
1
{"./":{"url":"./","title":"Introduction","keywords":"","body":"前言 很多年以前参加过一次 AWS AWSome Day,那是一种 AWS 在全球各大城市巡回举办的免费的技术研讨会,时长一天,为初次接触AWS大会的开发人员、IT 技术人员以及企业技术领域的决策者提供入门级的 AWS 产品介绍。在那次 AWSome Day 中,我第一次接触到了现在公有云里那些耳熟能详的概念,比如 Region、Availability Zone、Auto Scaling Group、RDS 这些经典产品。 最让我觉得惊奇的是,培训师现场演示了一种名为 CloudFormation 的产品,用培训师的话说就是“撒豆成兵”,通过编写一些 JSON 就可以批量反复创建一批云端资源,例如 AWS 官方提供的一个 CloudFormation 例子: { \"Description\" : \"Create an EC2 instance running the Amazon Linux 32 bit AMI.\", \"Parameters\" : { \"KeyPair\" : { \"Description\" : \"The EC2 Key Pair to allow SSH access to the instance\", \"Type\" : \"String\" } }, \"Resources\" : { \"Ec2Instance\" : { \"Type\" : \"AWS::EC2::Instance\", \"Properties\" : { \"KeyName\" : { \"Ref\" : \"KeyPair\" }, \"ImageId\" : \"ami-3b355a52\" } } }, \"Outputs\" : { \"InstanceId\" : { \"Description\" : \"The InstanceId of the newly created EC2 instance\", \"Value\" : { \"Ref\" : \"Ec2Instance\" } } }, \"AWSTemplateFormatVersion\" : \"2010-09-09\" } 这样一段简单的 JSON,就可以让我们用指定的镜像 id 创建一台云端虚拟机,不需要在界面上点点点。要知道在当时,我正在一家初创公司工作,同时身兼架构师、后台开发程序员、DBA 以及运维数职,要维护测试、预发布以及生产三套环境,时不时还因为要去修复因环境之间配置不一致而引发的种种错误而焦头烂额,那时的我就很期待 CloudFormation 能够给予我这种能够批量创建并管理\"招之能来,来之能战,战之能胜,胜之能去\"的环境的能力。但很可惜,CloudFormation 是 AWS 独家拥有的能力,而那时的 AWS 价格对我们来说太贵了,中国区的产品也非常少,所以这个梦想也就不了了之了,但是 CloudFormation 的那种高度标准化与自动化给我带来的冲击一直挥之不去。 我当时并不知道在西雅图的华盛顿大学,有一个美日混血大帅哥 Mitchell Hashimoto 和他的老板 Armon Dagar 也深深沉迷于 CloudFormation 所带来的那种优雅与高效,同时他们也在头疼 CloudFormation 本身的一系列问题,最主要的就是它是 AWS 独占的。强人和我这种庸人最大的区别就是,强人有了想法直接就去做,Mitchell 和 Armon 在讨论中渐渐有了一个想法——打造一个多云(Multi-Cloud)的开源的基础设施即代码(IaC)工具,并且要超越 CloudFormation。他们组建了一家名为 HashiCorp 的公司来实现这个目标。 在今年3月,HashiCorp 宣布成功获得 1.75 亿美元的E轮融资,投后公司估值 51 亿美元。HashiCorp 的产品线主要有 Nomad、Consul、Valut 以及 Terraform,另外还有 Vagrant 以及 Packer 两个开源工具,2020 年还推出了 Boundary 以及 Waypoint 两个新产品。 HashiCorp 的产品线主要是由 Nomad、Consul、Vault、Terraform 组成的 HashiStack,Terraform 扮演了承载整个 HashiStack 的关键角色,负责在不同的云平台之上创建出一致的基础设施来,当然我们完全可以只使用 Terraform 而不是使用完整的 HashiStack。 HashiCorp 这家公司有一个显著特点,就是他们极其有耐心,并且极其重视“基础设施”的建设。例如,他们在思考 Terraform 配置文件该用 JSON 还是 YAML 时,对两者都不满意,所以他们宁可慢下来,花时间去设计了 HCL(HashiCorp Configuration Language),使得他们对于声明式代码的可读性有了完全的掌控力。再比如在他们设计 Terraform 以及 Vault、Packer 时,他们使用的 go 语言因为是把引用代码下载下来后静态链接编译成单一可执行文件,所以不像 jar 或者 dll 那样有运行时动态加载插件的能力。因此他们又花时间开发了 go-plugin 这个项目,把插件编译成一个独立进程,与主进程通过 rpc 进行互操作。该项目上的投资很好地支撑了 Terraform、Vault、Packer 项目的插件机制,进而演化出如今百花齐放的 HashiCorp 开源生态。 我这些年陆陆续续向很多人推荐过 Terraform,并且很高兴地看到行业内越来越多的团队开始认可并采纳,但是前不久我仍然十分惊讶地意识到,由于 Terraform 官方并没有提供任何的中文文档,导致了许多中国互联网从业者没有足够的动力去啃完所有英文文档并付诸实践。这当然是一件非常正常的事情,对国人来说阅读英文文档毕竟比读中文的文档费力一些。先贤说:山不来就我,我便去就山。既然我期望 Terraform 能在中国得到更大的推广,那么我就为此做一些工作,为 Terraform 写一个入门级的中文教程,降低学习和推广的难度。 这个教程受到 HashiCorp Infrastructure Automation Certification 的启发,这是一个 HashiCorp 出品的 Terraform 认证,是一个相当基础的认证考试,内容涵盖了 Terraform 所有的常规操作技能。通过这门认证并不能让你成为一个高效的 Terraform 开发人员,但可以确保你装备齐全,拥有了足够全面的知识来进行 Terraform 实战和探索。 这个教程基本按照 Terraform 认证考试所列的考纲来编写,第三章到第五章内容主要是翻译官方文档,目标读者是((对“基础设施即代码”以及 Terraform 有兴趣 || 厌倦了在浏览器中依靠大量低级重复点点点操作云) && (懒得阅读英文文档)) 的人。如果你是为了确认 Terraform 某项功能,或是你的英语阅读能力足够好,请直接按照考纲去阅读官方文档,毕竟官方文档最为权威,更新也比较及时。假如你只是想偷个懒,想通过快速浏览中文文档来对 Terraform 有一个大概的了解,那么这个教程就是为你准备的。另外如果有朋友担心学习曲线问题的话,请不用担心,Terraform 在设计时就为降低学习曲线做了大量工作,可以这样说,只要你能够看懂 JSON,那么就能轻松掌握 Terraform。 另外本教程在编写过程中参考了 Terraform 著名教材 —— Yevgeniy Brikman 编纂的《Terraform Up & Runnning》: 这本书是目前 Terraform 最好的教材,喜闻电子工业出版社已于 2020 年 12 月出版,建议读者在掌握了 Terraform 基础知识以后阅读该教材,掌握更多的 Terraform 生态高阶技能。 对于这本书我再安利一下,目前中国人参加 ACM(acm.org,计算机协会)年费有折扣,折下来160+一年,可以在 learning.acm.org 上进入 O'REILLY 在线学习中心畅读大量 O'REILLY 的电子书,非常划算,基本读两三本就回本了。这本书也可以通过这种方式在线阅读。 教程编写时 Terraform 的主力版本是 0.13.5;Terraform 提供了 Macos、Linux 以及 Windows 的发行版,所以读者完全可以自己跟着教程进行一些练习和实验。 另外,Terraform 的生态环境到了今天,已经发展为三个分支,分别是: 开源版 Terraform Cloud 云服务版 Terraform 企业版 三个版本之间有些微的差别,包括对同一名词(例如 Workspace)的定义都会有所不同。本教程针对开源版编写,暂不涉及云服务版以及企业版。 特别鸣谢李宇飞同学为本书进行了非常细致的校对工作。 让我们开始我们的 Terraform 入门之旅吧。 2024-06-23 第二版更新声明 上述内容为本电子书刚完成时所著,时光荏苒,光阴如梭,一晃眼三年多过去了,HashiCorp 上市了,又被 IBM 收购了,Terraform 发布了 v1.0,然后陆陆续续更新到今天的 v1.8.5。很多内容都发生了变化,本书正在进行大规模内容更新,更新工作尚未完成,读者阅读时敬请留意内容,不便之处在此先赔礼了。 2022-07-17 马驰排除条款 本电子书使用 CC-BY-SA-4.0 license 授权发布,读者可以在该协议的许可范围内自由阅读、引用或是使用本电子书的内容,但以下情况除外: 禁止名为“马驰”的特定个人实体阅读、引用、复制本电子书的内容 禁止在名为“马驰”的特定个人实体所拥有的任何设备上打开、保存本书的内容或是离线副本、拷贝 禁止名为“马驰”的特定个人实体打印、抄写本书内容,或是保有本书内容的非数字化副本(包含并不限于书籍、手抄本、照片等) 禁止在与名为“马驰”的特定个人实体有劳动关系、股权关系,或是与其直系亲属有关联的企业、团体所拥有的任何电子设备上打开、保存本电子书的内容或是离线副本、拷贝 以上情况均会被视为侵权行为。若读者名为“马驰”,但不知道自己是否是该条款所禁止的特定“马驰”个人实体,可以在本书 GitHub 仓库 中提交 issue 与作者确认。 对本电子书的复刻(Fork)以及再创作遵循 CC-BY-SA-4.0 License 相关规定,但不允许去除本条款内容。 "},"1.Terraform初步体验/":{"url":"1.Terraform初步体验/","title":"Terraform 初步体验","keywords":"","body":"Terraform初步体验 安装 首先我们先安装Terraform。对于Ubuntu用户: sudo apt-get update && sudo apt-get install -y wget curl gnupg software-properties-common wget -O- https://apt.releases.hashicorp.com/gpg | \\ gpg --dearmor | \\ sudo tee /usr/share/keyrings/hashicorp-archive-keyring.gpg echo \"deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] \\ https://apt.releases.hashicorp.com $(lsb_release -cs) main\" | \\ sudo tee /etc/apt/sources.list.d/hashicorp.list sudo apt update sudo apt-get install terraform 对于CentOS用户: sudo yum install -y yum-utils sudo yum-config-manager --add-repo https://rpm.releases.hashicorp.com/RHEL/hashicorp.repo sudo yum -y install terraform 对于Mac用户: brew tap hashicorp/tap brew install hashicorp/tap/terraform 对于Windows用户,官方推荐的包管理器是choco,可以去https://chocolatey.org/ 下载安装好chocolatey后,以管理员身份启动powershell,然后: choco install terraform 如果只想纯手动安装,那么可以前往Terraform官网下载对应操作系统的可执行文件(Terraform是用go编写的,只有一个可执行文件),解压缩到指定的位置后,配置一下环境变量的PATH,使其包含Terraform所在的目录即可。 验证 terraform version Terraform v1.7.3 terraform -h Usage: terraform [global options] [args] The available commands for execution are listed below. The primary workflow commands are given first, followed by less common or more advanced commands. Main commands: init Prepare your working directory for other commands validate Check whether the configuration is valid plan Show changes required by the current configuration apply Create or update infrastructure destroy Destroy previously-created infrastructure All other commands: console Try Terraform expressions at an interactive command prompt fmt Reformat your configuration in the standard style force-unlock Release a stuck lock on the current workspace get Install or upgrade remote Terraform modules graph Generate a Graphviz graph of the steps in an operation import Associate existing infrastructure with a Terraform resource login Obtain and save credentials for a remote host logout Remove locally-stored credentials for a remote host metadata Metadata related commands output Show output values from your root module providers Show the providers required for this configuration refresh Update the state to match remote systems show Show the current state or a saved plan state Advanced state management taint Mark a resource instance as not fully functional test Execute integration tests for Terraform modules untaint Remove the 'tainted' state from a resource instance version Show the current Terraform version workspace Workspace management Global options (use these before the subcommand, if any): -chdir=DIR Switch to a different working directory before executing the given subcommand. -help Show this help output, or the help for a specified subcommand. -version An alias for the \"version\" subcommand. 一个简单的例子 为了给读者一个安全的体验环境,避免付费,我们下面使用 LocalStack 搭配 Terraform 来创建一批虚拟的云资源。 启动 LocalStack 最简单的方法是使用 Docker: docker run \\ --rm -it \\ -p 4566:4566 \\ -p 4510-4559:4510-4559 \\ localstack/localstack 看到如下输出时说明 LocalStack 已经准备好了: ... LocalStack version: 3.1.1.dev LocalStack build date: 2024-02-14 LocalStack build git hash: ebaf4ea8d 2024-02-15T01:56:11.845 INFO --- [-functhread4] hypercorn.error : Running on https://0.0.0.0:4566 (CTRL + C to quit) 2024-02-15T01:56:11.845 INFO --- [-functhread4] hypercorn.error : Running on https://0.0.0.0:4566 (CTRL + C to quit) 2024-02-15T01:56:12.103 INFO --- [ MainThread] localstack.utils.bootstrap : Execution of \"start_runtime_components\" took 1804.56ms Ready. 然后我们创建一个干净的空文件夹,在里面创建一个 main.tf 文件(.tf 就是 Terraform,Terraform 代码大部分是 .tf 文件,语法是 HCL,当然目前也支持 JSON 格式的 Terraform 代码,但我们暂时只以 .tf 为例): terraform { required_providers { aws = { source = \"hashicorp/aws\" version = \"~>5.0\" } } } provider \"aws\" { access_key = \"test\" secret_key = \"test\" region = \"us-east-1\" s3_use_path_style = false skip_credentials_validation = true skip_metadata_api_check = true skip_requesting_account_id = true endpoints { apigateway = \"http://localhost:4566\" apigatewayv2 = \"http://localhost:4566\" cloudformation = \"http://localhost:4566\" cloudwatch = \"http://localhost:4566\" dynamodb = \"http://localhost:4566\" ec2 = \"http://localhost:4566\" es = \"http://localhost:4566\" elasticache = \"http://localhost:4566\" firehose = \"http://localhost:4566\" iam = \"http://localhost:4566\" kinesis = \"http://localhost:4566\" lambda = \"http://localhost:4566\" rds = \"http://localhost:4566\" redshift = \"http://localhost:4566\" route53 = \"http://localhost:4566\" s3 = \"http://s3.localhost.localstack.cloud:4566\" secretsmanager = \"http://localhost:4566\" ses = \"http://localhost:4566\" sns = \"http://localhost:4566\" sqs = \"http://localhost:4566\" ssm = \"http://localhost:4566\" stepfunctions = \"http://localhost:4566\" sts = \"http://localhost:4566\" } } data \"aws_ami\" \"ubuntu\" { most_recent = true filter { name = \"name\" values = [\"ubuntu/images/hvm-ssd/ubuntu-trusty-14.04-amd64-server-20170727\"] } filter { name = \"virtualization-type\" values = [\"hvm\"] } owners = [\"099720109477\"] # Canonical } resource \"aws_instance\" \"web\" { ami = data.aws_ami.ubuntu.id instance_type = \"t3.micro\" tags = { Name = \"HelloWorld\" } } resource \"aws_eip_association\" \"eip_assoc\" { instance_id = aws_instance.web.id allocation_id = aws_eip.example.id } resource \"aws_eip\" \"example\" { domain = \"vpc\" } output \"instance_id\" { value = aws_instance.web.id } 这里要注意代码中的这一段: provider \"aws\" { access_key = \"test\" secret_key = \"test\" region = \"us-east-1\" s3_use_path_style = false skip_credentials_validation = true skip_metadata_api_check = true skip_requesting_account_id = true endpoints { apigateway = \"http://localhost:4566\" apigatewayv2 = \"http://localhost:4566\" cloudformation = \"http://localhost:4566\" cloudwatch = \"http://localhost:4566\" dynamodb = \"http://localhost:4566\" ec2 = \"http://localhost:4566\" es = \"http://localhost:4566\" elasticache = \"http://localhost:4566\" firehose = \"http://localhost:4566\" iam = \"http://localhost:4566\" kinesis = \"http://localhost:4566\" lambda = \"http://localhost:4566\" rds = \"http://localhost:4566\" redshift = \"http://localhost:4566\" route53 = \"http://localhost:4566\" s3 = \"http://s3.localhost.localstack.cloud:4566\" secretsmanager = \"http://localhost:4566\" ses = \"http://localhost:4566\" sns = \"http://localhost:4566\" sqs = \"http://localhost:4566\" ssm = \"http://localhost:4566\" stepfunctions = \"http://localhost:4566\" sts = \"http://localhost:4566\" } } 因为我们使用的是通过 LocalStack 模拟的虚拟 AWS 服务,所以在这里我们需要在 endpoints 中把各个服务 API 的端点设置为 LocalStack 暴露的本地端点。原本的 access_key 和 secret_key 应该是通过 AWS IAM 获取的,在这里我们就可以用假的 Key 来替代。 这段代码比较简单,头部的terraform这一段声明了这段代码所需要的Terraform版本以及 AWS 插件版本,后面的 provider 段则是给出了调用 AWS API所需要的 key 和 region 等信息。 真正定义云端基础设施的代码就是后面的部分,分为三部分,data、resource 和 output。 data 代表利用 AWS 插件定义的 data 模型对 AWS 进行查询,例如我们在代码中利用 data 查询名为 \"ubuntu/images/hvm-ssd/ubuntu-trusty-14.04-amd64-server-20170727 的 AWS 虚拟机镜像 ID,这样我们就不需要人工在界面上去查询相关 ID,再硬编码到代码中。请注意,由于我们使用的是 LocalStack 虚拟的 AWS,所以这里的 name 和 owners 都是参照了 LocalStack 自带的模拟数据 构造的。 resource 代表我们需要在云端创建的资源,在例子里我们创建了三个资源,分别是主机、弹性公网 ip,以及主机和公网 ip 的绑定。 我们在定义主机时给定了主机的尺寸名称等关键信息,最后,我们声明了一个output,名字是eip,它的值就是我们创建的弹性公网ip的值。 运行这段代码很简单,让我们在代码所在的路径下进入命令行,执行: $ terraform init 这时Terraform会进行初始化操作,通过官方插件仓库下载对应操作系统的UCloud插件。如果一切都正常,读者应该会看到: Initializing the backend... Initializing provider plugins... - Finding latest version of hashicorp/aws... - Installing hashicorp/aws v5.36.0... - Installed hashicorp/aws v5.36.0 (signed by HashiCorp) Terraform has created a lock file .terraform.lock.hcl to record the provider selections it made above. Include this file in your version control repository so that Terraform can guarantee to make the same selections by default when you run \"terraform init\" in the future. ╷ │ Warning: Provider development overrides are in effect │ │ The following provider development overrides are set in the CLI configuration: │ - azure/modtm in C:\\Users\\hezijie\\go\\bin │ - lonegunmanb/aztfteam in C:\\Users\\hezijie\\go\\bin │ │ Skip terraform init when using provider development overrides. It is not necessary and may error unexpectedly. ╵ Terraform has been successfully initialized! You may now begin working with Terraform. Try running \"terraform plan\" to see any changes that are required for your infrastructure. All Terraform commands should now work. If you ever set or change modules or backend configuration for Terraform, rerun this command to reinitialize your working directory. If you forget, other commands will detect it and remind you to do so if necessary. PS C:\\project\\mySpike> terraform terraform apply -auto-approve Terraform has no command named \"terraform\". To see all of Terraform's top-level commands, run: terraform -help 然后我们可以预览一下代码即将产生的变更: $ data.aws_ami.ubuntu: Reading... data.aws_ami.ubuntu: Read complete after 0s [id=ami-1e749f67] Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: + create Terraform will perform the following actions: # aws_eip.example will be created + resource \"aws_eip\" \"example\" { + allocation_id = (known after apply) + association_id = (known after apply) + carrier_ip = (known after apply) + customer_owned_ip = (known after apply) + domain = \"vpc\" + id = (known after apply) + instance = (known after apply) + network_border_group = (known after apply) + network_interface = (known after apply) + private_dns = (known after apply) + private_ip = (known after apply) + public_dns = (known after apply) + public_ip = (known after apply) + public_ipv4_pool = (known after apply) + tags_all = (known after apply) + vpc = (known after apply) } # aws_eip_association.eip_assoc will be created + resource \"aws_eip_association\" \"eip_assoc\" { + allocation_id = (known after apply) + id = (known after apply) + instance_id = (known after apply) + network_interface_id = (known after apply) + private_ip_address = (known after apply) + public_ip = (known after apply) } # aws_instance.web will be created + resource \"aws_instance\" \"web\" { + ami = \"ami-1e749f67\" + arn = (known after apply) + associate_public_ip_address = (known after apply) + availability_zone = (known after apply) + cpu_core_count = (known after apply) + cpu_threads_per_core = (known after apply) + disable_api_stop = (known after apply) + disable_api_termination = (known after apply) + ebs_optimized = (known after apply) + get_password_data = false + host_id = (known after apply) + host_resource_group_arn = (known after apply) + iam_instance_profile = (known after apply) + id = (known after apply) + instance_initiated_shutdown_behavior = (known after apply) + instance_lifecycle = (known after apply) + instance_state = (known after apply) + instance_type = \"t3.micro\" + ipv6_address_count = (known after apply) + ipv6_addresses = (known after apply) + key_name = (known after apply) + monitoring = (known after apply) + outpost_arn = (known after apply) + password_data = (known after apply) + placement_group = (known after apply) + placement_partition_number = (known after apply) + primary_network_interface_id = (known after apply) + private_dns = (known after apply) + private_ip = (known after apply) + public_dns = (known after apply) + public_ip = (known after apply) + secondary_private_ips = (known after apply) + security_groups = (known after apply) + source_dest_check = true + spot_instance_request_id = (known after apply) + subnet_id = (known after apply) + tags = { + \"Name\" = \"HelloWorld\" } + tags_all = { + \"Name\" = \"HelloWorld\" } + tenancy = (known after apply) + user_data = (known after apply) + user_data_base64 = (known after apply) + user_data_replace_on_change = false + vpc_security_group_ids = (known after apply) } Plan: 3 to add, 0 to change, 0 to destroy. Changes to Outputs: + instance_id = (known after apply) ╷ │ Warning: AWS account ID not found for provider │ │ with provider[\"registry.terraform.io/hashicorp/aws\"], │ on main.tf line 1, in provider \"aws\": │ 1: provider \"aws\" { │ │ See https://registry.terraform.io/providers/hashicorp/aws/latest/docs#skip_requesting_account_id for implications. ╵ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── Note: You didn't use the -out option to save this plan, so Terraform can't guarantee to take exactly these actions if you run \"terraform apply\" now. 这段输出告诉我们,代码即将创建 3 个新资源,修改 0 个资源,删除 0 个资源。资源的属性少部分是我们在代码中直接给出的,或是通过 data 查询的,所以在plan命令的结果中可以看到它们的值;更多的属性只有在资源真正被创建以后我们才能看到,所以会显示 (known after apply)。 然后我们运行一下: $ terraform apply data.aws_ami.ubuntu: Reading... data.aws_ami.ubuntu: Read complete after 0s [id=ami-1e749f67] Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: + create Terraform will perform the following actions: # aws_eip.example will be created + resource \"aws_eip\" \"example\" { + allocation_id = (known after apply) + association_id = (known after apply) + carrier_ip = (known after apply) + customer_owned_ip = (known after apply) + domain = \"vpc\" + id = (known after apply) + instance = (known after apply) + network_border_group = (known after apply) + network_interface = (known after apply) + private_dns = (known after apply) + private_ip = (known after apply) + public_dns = (known after apply) + public_ip = (known after apply) + public_ipv4_pool = (known after apply) + tags_all = (known after apply) + vpc = (known after apply) } # aws_eip_association.eip_assoc will be created + resource \"aws_eip_association\" \"eip_assoc\" { + allocation_id = (known after apply) + id = (known after apply) + instance_id = (known after apply) + network_interface_id = (known after apply) + private_ip_address = (known after apply) + public_ip = (known after apply) } # aws_instance.web will be created + resource \"aws_instance\" \"web\" { + ami = \"ami-1e749f67\" + arn = (known after apply) + associate_public_ip_address = (known after apply) + availability_zone = (known after apply) + cpu_core_count = (known after apply) + cpu_threads_per_core = (known after apply) + disable_api_stop = (known after apply) + disable_api_termination = (known after apply) + ebs_optimized = (known after apply) + get_password_data = false + host_id = (known after apply) + host_resource_group_arn = (known after apply) + iam_instance_profile = (known after apply) + id = (known after apply) + instance_initiated_shutdown_behavior = (known after apply) + instance_lifecycle = (known after apply) + instance_state = (known after apply) + instance_type = \"t3.micro\" + ipv6_address_count = (known after apply) + ipv6_addresses = (known after apply) + key_name = (known after apply) + monitoring = (known after apply) + outpost_arn = (known after apply) + password_data = (known after apply) + placement_group = (known after apply) + placement_partition_number = (known after apply) + primary_network_interface_id = (known after apply) + private_dns = (known after apply) + private_ip = (known after apply) + public_dns = (known after apply) + public_ip = (known after apply) + secondary_private_ips = (known after apply) + security_groups = (known after apply) + source_dest_check = true + spot_instance_request_id = (known after apply) + subnet_id = (known after apply) + tags = { + \"Name\" = \"HelloWorld\" } + tags_all = { + \"Name\" = \"HelloWorld\" } + tenancy = (known after apply) + user_data = (known after apply) + user_data_base64 = (known after apply) + user_data_replace_on_change = false + vpc_security_group_ids = (known after apply) } Plan: 3 to add, 0 to change, 0 to destroy. Changes to Outputs: + instance_id = (known after apply) ╷ │ Warning: AWS account ID not found for provider │ │ with provider[\"registry.terraform.io/hashicorp/aws\"], │ on main.tf line 1, in provider \"aws\": │ 1: provider \"aws\" { │ │ See https://registry.terraform.io/providers/hashicorp/aws/latest/docs#skip_requesting_account_id for implications. ╵ Do you want to perform these actions? Terraform will perform the actions described above. Only 'yes' will be accepted to approve. Enter a value: yes 当我们运行 terraform apply 时,Terraform 会首先重新计算一下变更计划,并且像刚才执行 plan 命令那样把变更计划打印给我们,要求我们人工确认。让我们输入 yes,然后回车: aws_eip.example: Creating... aws_instance.web: Creating... aws_eip.example: Creation complete after 0s [id=eipalloc-c71b7984] aws_instance.web: Still creating... [10s elapsed] aws_instance.web: Creation complete after 10s [id=i-c22230eecf2b8a950] aws_eip_association.eip_assoc: Creating... aws_eip_association.eip_assoc: Creation complete after 0s [id=eipassoc-194f62e6] ╷ │ Warning: AWS account ID not found for provider │ │ with provider[\"registry.terraform.io/hashicorp/aws\"], │ on main.tf line 1, in provider \"aws\": │ 1: provider \"aws\" { │ │ See https://registry.terraform.io/providers/hashicorp/aws/latest/docs#skip_requesting_account_id for implications. ╵ Apply complete! Resources: 3 added, 0 changed, 0 destroyed. Outputs: instance_id = \"i-c22230eecf2b8a950\" 可以看到,Terraform 成功地创建了我们定义的资源,并且把我们定义的输出给打印了出来。 清理 完成这个体验后,不要忘记清理我们的云端资源。我们可以通过调用 destroy 命令来轻松完成清理: $ terraform destroy data.aws_ami.ubuntu: Reading... aws_eip.example: Refreshing state... [id=eipalloc-c71b7984] data.aws_ami.ubuntu: Read complete after 0s [id=ami-1e749f67] aws_instance.web: Refreshing state... [id=i-c22230eecf2b8a950] aws_eip_association.eip_assoc: Refreshing state... [id=eipassoc-194f62e6] Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: - destroy Terraform will perform the following actions: # aws_eip.example will be destroyed - resource \"aws_eip\" \"example\" { - allocation_id = \"eipalloc-c71b7984\" -> null - association_id = \"eipassoc-194f62e6\" -> null - domain = \"vpc\" -> null - id = \"eipalloc-c71b7984\" -> null - instance = \"i-c22230eecf2b8a950\" -> null - network_interface = \"eni-b689ba2c\" -> null - private_dns = \"ip-10-73-124-108.ec2.internal\" -> null - private_ip = \"10.73.124.108\" -> null - public_dns = \"ec2-127-127-89-71.compute-1.amazonaws.com\" -> null - public_ip = \"127.127.89.71\" -> null - tags = {} -> null - tags_all = {} -> null - vpc = true -> null } # aws_eip_association.eip_assoc will be destroyed - resource \"aws_eip_association\" \"eip_assoc\" { - allocation_id = \"eipalloc-c71b7984\" -> null - id = \"eipassoc-194f62e6\" -> null - instance_id = \"i-c22230eecf2b8a950\" -> null - network_interface_id = \"eni-b689ba2c\" -> null - private_ip_address = \"10.73.124.108\" -> null - public_ip = \"127.127.89.71\" -> null } # aws_instance.web will be destroyed - resource \"aws_instance\" \"web\" { - ami = \"ami-1e749f67\" -> null - arn = \"arn:aws:ec2:us-east-1::instance/i-c22230eecf2b8a950\" -> null - associate_public_ip_address = true -> null - availability_zone = \"us-east-1a\" -> null - disable_api_stop = false -> null - disable_api_termination = false -> null - ebs_optimized = false -> null - get_password_data = false -> null - hibernation = false -> null - id = \"i-c22230eecf2b8a950\" -> null - instance_initiated_shutdown_behavior = \"stop\" -> null - instance_state = \"running\" -> null - instance_type = \"t3.micro\" -> null - ipv6_address_count = 0 -> null - ipv6_addresses = [] -> null - monitoring = false -> null - placement_partition_number = 0 -> null - primary_network_interface_id = \"eni-b689ba2c\" -> null - private_dns = \"ip-10-73-124-108.ec2.internal\" -> null - private_ip = \"10.73.124.108\" -> null - public_dns = \"ec2-127-127-89-71.compute-1.amazonaws.com\" -> null - public_ip = \"127.127.89.71\" -> null - secondary_private_ips = [] -> null - security_groups = [] -> null - source_dest_check = true -> null - subnet_id = \"subnet-73eb6c90\" -> null - tags = { - \"Name\" = \"HelloWorld\" } -> null - tags_all = { - \"Name\" = \"HelloWorld\" } -> null - tenancy = \"default\" -> null - user_data_replace_on_change = false -> null - vpc_security_group_ids = [] -> null - root_block_device { - delete_on_termination = true -> null - device_name = \"/dev/sda1\" -> null - encrypted = false -> null - iops = 0 -> null - tags = {} -> null - throughput = 0 -> null - volume_id = \"vol-b56e5dc7\" -> null - volume_size = 8 -> null - volume_type = \"gp2\" -> null } } Plan: 0 to add, 0 to change, 3 to destroy. Changes to Outputs: - instance_id = \"i-c22230eecf2b8a950\" -> null ╷ │ Warning: AWS account ID not found for provider │ │ with provider[\"registry.terraform.io/hashicorp/aws\"], │ on main.tf line 1, in provider \"aws\": │ 1: provider \"aws\" { │ │ See https://registry.terraform.io/providers/hashicorp/aws/latest/docs#skip_requesting_account_id for implications. ╵ Do you really want to destroy all resources? Terraform will destroy all your managed infrastructure, as shown above. There is no undo. Only 'yes' will be accepted to confirm. Enter a value: yes 可以看到,Terraform 列出了它即将清理的资源信息,并且要求我们人工确认同意继续执行清理操作。我们输入 yes,然后回车: aws_eip_association.eip_assoc: Destroying... [id=eipassoc-194f62e6] aws_eip_association.eip_assoc: Destruction complete after 0s aws_eip.example: Destroying... [id=eipalloc-c71b7984] aws_instance.web: Destroying... [id=i-c22230eecf2b8a950] aws_eip.example: Destruction complete after 0s aws_instance.web: Still destroying... [id=i-c22230eecf2b8a950, 10s elapsed] aws_instance.web: Destruction complete after 10s Destroy complete! Resources: 3 destroyed. 很快的,刚才创建的资源就全部被删除了。(在本例中因为我们使用了 LocalStack,所以我们并没有真的创建 AWS 资源,所以不执行 destroy 也没什么问题,但是在试验后清理实验资源是一个很好的习惯) Terraform 与以往诸如 Ansible 等配置管理工具比较大的不同在于,它是根据代码计算出的目标状态与当前状态的差异来计算变更计划的,有兴趣的读者可以在执行 terraform apply 以后,直接再执行一次 terraform apply,看看会发生什么,就能明白他们之间的差异。 实际上这段代码在 apply 以后,直接再次 apply,得到的计划会是什么也不做: $ terraform plan data.aws_ami.ubuntu: Reading... aws_eip.example: Refreshing state... [id=eipalloc-c12bcca1] data.aws_ami.ubuntu: Read complete after 0s [id=ami-1e749f67] aws_instance.web: Refreshing state... [id=i-0159574cc2f7986ad] aws_eip_association.eip_assoc: Refreshing state... [id=eipassoc-7f849867] No changes. Your infrastructure matches the configuration. 因为当前云端的资源状态已经完全符合代码所描述的期望状态了,所以 Terraform 什么也不会做。好了,这就是我们对 Terraform 的一个初步体验。 "},"2.Terraform基础概念/1.Provider.html":{"url":"2.Terraform基础概念/1.Provider.html","title":"Provider","keywords":"","body":"Terraform 基础概念 —— Provider Terraform 被设计成一个多云基础设施编排工具,不像 CloudFormation 那样绑定 AWS 平台,Terraform 可以同时编排各种云平台或是其他基础设施的资源。Terraform 实现多云编排的方法就是 Provider 插件机制。 Terraform 使用的是 HashiCorp 自研的 go-plugin 库),本质上各个 Provider 插件都是独立的进程,与 Terraform 进程之间通过 Rpc 进行调用。Terraform 引擎首先读取并分析用户编写的 Terraform 代码,形成一个由 data 与 resource 组成的图(Graph),再通过 Rpc 调用这些 data 与 resource 所对应的 Provider 插件;Provider 插件的编写者根据 Terraform 所制定的插件框架来定义各种 data 和 resource,并实现相应的 CRUD 方法;在实现这些 CRUD 方法时,可以调用目标平台提供的 SDK,或是直接通过调用 Http(s) API来操作目标平台。 下载 Provider 我们在第一章的小例子中,写完代码后在 apply 之前,首先我们执行了一次terraform init。terraform init会分析代码中所使用到的 Provider,并尝试下载 Provider 插件到本地。如果我们观察执行完第一章例子的文件夹,我们会发现有一个 .terraform 文件夹,我们所使用的 AWS Provider 插件就被下载安装在里面。 .terraform └── providers └── registry.terraform.io └── hashicorp └── aws └── 5.37.0 └── windows_amd64 └── terraform-provider-aws_v5.37.0_x5.exe 有的时候下载某些 Provider 会非常缓慢,或是在开发环境中存在许多的 Terraform 项目,每个项目都保有自己独立的插件文件夹非常浪费磁盘,这时我们可以使用插件缓存。 有两种方式可以启用插件缓存: 第一种方法是配置 TF_PLUGIN_CACHE_DIR 这个环境变量: export TF_PLUGIN_CACHE_DIR=\"$HOME/.terraform.d/plugin-cache\" 第二种方法是使用 CLI 配置文件。Windows 下是在相关用户的 %APPDATA% 目录下创建名为 \"terraform.rc\" 的文件,Macos 和 Linux 用户则是在用户的 home 下创建名为 \".terraformrc\" 的文件。在文件中配置如下: plugin_cache_dir = \"$HOME/.terraform.d/plugin-cache\" 当启用插件缓存之后,每当执行 terraform init 命令时,Terraform 引擎会首先检查期望使用的插件在缓存文件夹中是否已经存在,如果存在,那么就会将缓存的插件拷贝到当前工作目录下的 .terraform 文件夹内。如果插件不存在,那么 Terraform 仍然会像之前那样下载插件,并首先保存在插件文件夹中,随后再从插件文件夹拷贝到当前工作目录下的 .terraform 文件夹内。为了尽量避免同一份插件被保存多次,只要操作系统提供支持,Terraform 就会使用符号连接而不是实际从插件缓存目录拷贝到工作目录。 需要特别注意的是,Windows 系统下 plugin_cache_dir 的路径也必须使用 / 作为分隔符,应使用 C:/somefolder/plugin_cahce 而不是 C:\\somefolder\\plugin_cache Terraform 引擎永远不会主动删除缓存文件夹中的插件,缓存文件夹的尺寸可能会随着时间而增长到非常大,这时需要手工清理。 搜索 Provider 想要了解有哪些被官方接纳的 Provider,就是前往registry.terraform.io进行搜索: 一般来说,相关 Provider 如何声明,以及相关 data、resource 的使用说明,都可以在 registry 上查阅到相关文档。 registry.terraform.io 不但可以查询 Provider,也可以用来发布 Provider;并且它也可以用来查询和发布模块(Module),不过模块将是我们后续篇章讨论的话题。 Provider 的声明 一组 Terraform 代码要被执行,相关的 Provider 必须在代码中被声明。不少的 Provider 在声明时需要传入一些关键信息才能被使用,例如我们在第一章的例子中,必须给出访问密钥以及期望执行的 AWS 区域(Region)信息。 terraform { required_providers { aws = { source = \"hashicorp/aws\" version = \"~>5.0\" } } } provider \"aws\" { access_key = \"test\" secret_key = \"test\" region = \"us-east-1\" s3_use_path_style = false skip_credentials_validation = true skip_metadata_api_check = true skip_requesting_account_id = true endpoints { apigateway = \"http://localhost:4566\" apigatewayv2 = \"http://localhost:4566\" cloudformation = \"http://localhost:4566\" cloudwatch = \"http://localhost:4566\" dynamodb = \"http://localhost:4566\" ec2 = \"http://localhost:4566\" es = \"http://localhost:4566\" elasticache = \"http://localhost:4566\" firehose = \"http://localhost:4566\" iam = \"http://localhost:4566\" kinesis = \"http://localhost:4566\" lambda = \"http://localhost:4566\" rds = \"http://localhost:4566\" redshift = \"http://localhost:4566\" route53 = \"http://localhost:4566\" s3 = \"http://s3.localhost.localstack.cloud:4566\" secretsmanager = \"http://localhost:4566\" ses = \"http://localhost:4566\" sns = \"http://localhost:4566\" sqs = \"http://localhost:4566\" ssm = \"http://localhost:4566\" stepfunctions = \"http://localhost:4566\" sts = \"http://localhost:4566\" } } 在这段 Provider 声明中,首先在 terraform 块的 required_providers 里声明了本段代码必须要名为 aws 的 Provider 才可以执行,source = \"hashicorp/aws\"这一行声明了 aws 这个插件的源地址(Source Address)。一个源地址是全球唯一的,它指示了 Terraform 如何下载该插件。一个源地址由三部分组成: [/]/ Hostname 是选填的,默认是官方的 registry.terraform.io,读者也可以构建自己私有的Terraform仓库。Namespace 是在 Terraform 仓库内注册的组织名,这代表了发布和维护插件的组织或是个人。Type 是代表插件的一个短名,在特定的 HostName/Namespace 下 Type 必须唯一。 required_providers 中的插件声明还声明了该源码所需要的插件的版本约束,在例子里就是 version = \"~>5.0\"。Terraform 插件的版本号采用 MAJOR.MINOR.PATCH 的语义化格式,版本约束通常使用操作符和版本号表达约束条件,条件之间可以用逗号拼接,表达 AND 关联,例如 \">= 1.2.0, 。可以采用的操作符有: =(或者不加 =,直接使用版本号):只允许特定版本号,不允许与其他条件合并使用 !=:不允许特定版本号 \\>,>=,,:与特定版本号进行比较,可以是大于、大于等于、小于、小于等于 ~>:只允许 最右边 的版本号增加。这种格式被称为 悲观约束 操作符。例如,要允许在特定的 MINOR 版本中允许新的 PATCH 版本: ~> 1.0.4:允许 Terraform 安装 1.0.5 和 1.0.10,但不允许 1.1.0。 ~> 1.1:允许 Terraform 安装 1.2 和 1.10,但不允许 2.0。 Terraform 会检查当前工作环境或是插件缓存中是否存在满足版本约束的插件,如果不存在,那么 Terraform 会尝试下载。如果 Terraform 无法获得任何满足版本约束条件的插件,那么它会拒绝继续执行任何后续操作。 可以用添加后缀的方式来声明预览版,例如:1.2.0-beta。预览版只能通过 \"=\" 操作符(或是忽略操作符)后接明确的版本号的方式来指定,不可以与>=、~>等搭配使用。 当依赖第三方模块时,需要指定特定版本,以确保只在您需要的时候进行更新。 对于在您的组织内维护的模块,如果一致使用语义版本控制,或者有一个定义良好的发布流程可以避免不必要的更新,那么指定版本范围可能是合适的。 可重用的模块应仅限制其 Terraform 和 Provider 的最低允许版本,例如 >= 0.12.0。这有助于避免已知的不兼容性,同时允许模块的用户在不改变模块的情况下升级到 Terraform 的新版本。 根模块应使用 ~> 约束为它们依赖的每个 Provider 设置一个下限和上限版本。 以上建议来自于 HashiCorp 官方文档,笔者个人给出一条个人建议: 可复用的模块不但应该限制 Provider 的最低版本,同时也应该限制 Provider 的 MAJOR 版本。例如,>= 1.5.0, 。这样可以避免在 Provider 的 MAJOR 版本升级时,因为不兼容性导致的问题,Provider 的 MAJOR 版本升级通常会伴随着不兼容的改动,不应该在未加测试的情况下轻易升级。 内建 Provider 绝大多数 Provider 是以插件形式单独分发的,但是目前有一个 Provider 是内建于Terraform主进程中的,那就是 terraform_remote_state data source。该 Provider 由于是内建的,所以使用时不需要在 terraform 中声明 required_providers。这个内建Provider的源地址是 terraform.io/builtin/terraform。这个地址有时可能出现在 Terraform 的错误消息和其他输出中,以便无歧义地引用内建 Provider,而不是假设的第三方提供者,其类型名称为 \"terraform\"。 还存在一个源地址为 hashicorp/terraform 的 Provider,这是现在内置 Provider 的旧版本,被 Terraform 的旧版本使用。hashicorp/terraform 与 Terraform v0.11 或更高版本不兼容,因此永远不应在 required_providers 块中声明。 Provider 的配置 Provider 的配置是声明在根模块中的一组 Terraform 配置。(子模块接收来自于根模块的 Provider 配置,更多信息,请参阅模块的 provider 元参数) 一个 Provider 配置是通过 provider 块来创建的: provider \"google\" { project = \"acme-app\" region = \"us-central1\" } 块头部设置的名称(例子中的 \"google\")就是要配置的 Provider 的Local Name。这个 Provider 必须已在 required_providers 块中声明。 块体({ 和 } 中间的内容)包含了 Provider 的配置参数。这些参数大多数是由 Provider 自己定义的;在这个例子中,project 和 region 都是 google Provider 特有的。 你可以在这些配置的值当中使用表达式,但是只能引用在配置 Provider 时已知的值。这意味着你可以安全地引用输入变量,但是不能引用从 resource 返回的属性(一个例外是直接在配置中硬编码的 resource 参数)。 一个 Provider 的文档应该列出它所需要的配置参数。对那些注册在 Terraform Registry 上的 Provider 来说,每个 Provider 的页面上都有版本化的文档,可以通过 Provider 页头的 \"Documentation\" 链接访问。 一些 Provider 可以使用环境变量(或是其他替代配置源,例如 AWS 的虚拟机实例 Profile)作为某些配置参数的值;我们建议尽可能使用这种方式来避免将凭证保存于版本控制的 Terraform 代码中。 There are also two \"meta-arguments\" that are defined by Terraform itself and available for all provider blocks: 有两个由 Terraform 自身定义的“元参数”,对所有 provider 块都可用: alias,用以为不同的 resource 块配置参数不同的同类 Provider 实例 version, 废弃,不推荐使用,现在请使用 required_providers 与 Terraform 语言中的许多其他对象不同,如果 provider 块的内容为空,则可以省略该块。Terraform 假定未显式配置的任何 Provider 程序都具有空的默认配置。 多 Provider 实例 provider 块声明了 aws 这个 Provider 所需要的各项配置。在上文的代码示例中,provider \"aws\"和required_providers中aws = {...}块里的aws,都是 Provider 的 Local Name,一个 Local Name 是在一个模块中对一个 Provider 的唯一的标识。 你可以选择为同一个 Provider 定义多个配置,并且可以根据每个资源或每个模块来选择使用哪一个。这主要是为了支持云平台的多个区域;其他例子包括针对多个 Docker 主机,多个 Consul 主机等。 要为某一个 Provider 创建多个配置,包括具有相同提供者名称的多个 provider 块。对于每个额外的非默认配置,使用 alias 元参数提供额外的名称段。例如: # The default provider configuration; resources that begin with `aws_` will use # it as the default, and it can be referenced as `aws`. provider \"aws\" { region = \"us-east-1\" } # Additional provider configuration for west coast region; resources can # reference this as `aws.west`. provider \"aws\" { alias = \"west\" region = \"us-west-2\" } 在模块内声明配置 alias 以从父模块接收备用的 provider 配置,需要在该 provider 的 required_providers 条目中添加 configuration_aliases 参数。以下示例在包含的模块中声明了 mycloud 和 mycloud.alternate 的 provider 配置名称: terraform { required_providers { mycloud = { source = \"mycorp/mycloud\" version = \"~> 1.0\" configuration_aliases = [ mycloud.alternate ] } } } 默认 Provider 配置 没有 alias 参数的 provider 块是该 provider 的 默认 配置。未设置 provider 元参数的资源将使用与资源类型名称的第一个单词匹配的默认 provider 配置。(例如,除非另有说明,否则 aws_instance 资源将使用默认的 aws provider 配置。) 如果 provider 的每个显式配置都有别名,Terraform 将使用隐含的空配置作为该 provider 的默认配置。(如果 provider 有任何必需的配置参数,当资源默认使用空配置时,Terraform 将引发错误。) 引用备用 Provider 配置 当 Terraform 需要 provider 配置的名称时,它期望的是 . 形式的引用。在上面的例子中,aws.west 将引用 us-west-2 区域的 provider。 这些引用是特殊的表达式。像对其他命名实体(例如 var.image_id)的引用一样,它们不是字符串,不需要引号。但是它们只在 resource、data 和 module 块的特定元参数中有效,不能在任意表达式中使用。 选择备用 Provider 配置 默认情况下,资源使用从资源类型名称的第一个单词推断出的默认 provider 配置(没有 alias 参数的配置)。 要为资源或数据源指定备用 provider 配置,将其 provider 元参数设置为 . 引用: resource \"aws_instance\" \"foo\" { provider = aws.west # ... } 要为子模块指定备用 provider 配置,使用其 providers 元参数指定应将哪些 provider 配置映射到模块内的哪些本地 provider 名称: module \"aws_vpc\" { source = \"./aws_vpc\" providers = { aws = aws.west } } 在传递 provider 时,模块有一些特殊要求;有关更多详细信息,请参见 模块 providers 元参数。在大多数情况下,只有 根模块 应定义 provider 配置,所有子模块都应从其父模块获取其 provider 配置。 "},"2.Terraform基础概念/2.状态管理.html":{"url":"2.Terraform基础概念/2.状态管理.html","title":"状态管理","keywords":"","body":"Terraform 基础概念——状态管理 我们在第一章的末尾提过,当我们成功地执行了一次 terraform apply,创建了期望的基础设施以后,我们如果再次执行 terraform apply,生成的新的执行计划将不会包含任何变更,Terraform 会记住当前基础设施的状态,并将之与代码所描述的期望状态进行比对。第二次 apply 时,因为当前状态已经与代码描述的状态一致了,所以会生成一个空的执行计划。 初探状态文件 在这里,Terraform 引入了一个独特的概念——状态管理,这是 Ansible 等配置管理工具或是自研工具调用 SDK 操作基础设施的方案所没有的。简单来说,Terraform 将每次执行基础设施变更操作时的状态信息保存在一个状态文件中,默认情况下会保存在当前工作目录下的 terraform.tfstate 文件里。例如我们之前在使用 LocalStack 模拟环境的代码中声明一个 data 和一个 resource: terraform { required_providers { aws = { source = \"hashicorp/aws\" version = \"~>5.0\" } } } provider \"aws\" { access_key = \"test\" secret_key = \"test\" region = \"us-east-1\" s3_use_path_style = false skip_credentials_validation = true skip_metadata_api_check = true skip_requesting_account_id = true endpoints { apigateway = \"http://localhost:4566\" apigatewayv2 = \"http://localhost:4566\" cloudformation = \"http://localhost:4566\" cloudwatch = \"http://localhost:4566\" dynamodb = \"http://localhost:4566\" ec2 = \"http://localhost:4566\" es = \"http://localhost:4566\" elasticache = \"http://localhost:4566\" firehose = \"http://localhost:4566\" iam = \"http://localhost:4566\" kinesis = \"http://localhost:4566\" lambda = \"http://localhost:4566\" rds = \"http://localhost:4566\" redshift = \"http://localhost:4566\" route53 = \"http://localhost:4566\" s3 = \"http://s3.localhost.localstack.cloud:4566\" secretsmanager = \"http://localhost:4566\" ses = \"http://localhost:4566\" sns = \"http://localhost:4566\" sqs = \"http://localhost:4566\" ssm = \"http://localhost:4566\" stepfunctions = \"http://localhost:4566\" sts = \"http://localhost:4566\" } } data \"aws_ami\" \"ubuntu\" { most_recent = true filter { name = \"name\" values = [\"ubuntu/images/hvm-ssd/ubuntu-trusty-14.04-amd64-server-20170727\"] } filter { name = \"virtualization-type\" values = [\"hvm\"] } owners = [\"099720109477\"] # Canonical } resource \"aws_instance\" \"web\" { ami = data.aws_ami.ubuntu.id instance_type = \"t3.micro\" tags = { Name = \"HelloWorld\" } } 使用 terraform apply 后,我们可以看到 terraform.tfstate 的内容: { \"version\": 4, \"terraform_version\": \"1.7.3\", \"serial\": 1, \"lineage\": \"159018e2-63f4-2dfa-ce0d-873a37a1e0a7\", \"outputs\": {}, \"resources\": [ { \"mode\": \"data\", \"type\": \"aws_ami\", \"name\": \"ubuntu\", \"provider\": \"provider[\\\"registry.terraform.io/hashicorp/aws\\\"]\", \"instances\": [ { \"schema_version\": 0, \"attributes\": { \"architecture\": \"x86_64\", \"arn\": \"arn:aws:ec2:us-east-1::image/ami-1e749f67\", \"block_device_mappings\": [ { \"device_name\": \"/dev/sda1\", \"ebs\": { \"delete_on_termination\": \"false\", \"encrypted\": \"false\", \"iops\": \"0\", \"snapshot_id\": \"snap-15bd5527\", \"throughput\": \"0\", \"volume_size\": \"15\", \"volume_type\": \"standard\" }, \"no_device\": \"\", \"virtual_name\": \"\" } ], \"boot_mode\": \"\", \"creation_date\": \"2024-02-20T13:52:42.000Z\", \"deprecation_time\": \"\", \"description\": \"Canonical, Ubuntu, 14.04 LTS, amd64 trusty image build on 2017-07-27\", \"ena_support\": false, \"executable_users\": null, \"filter\": [ { \"name\": \"name\", \"values\": [ \"ubuntu/images/hvm-ssd/ubuntu-trusty-14.04-amd64-server-20170727\" ] }, { \"name\": \"virtualization-type\", \"values\": [ \"hvm\" ] } ], \"hypervisor\": \"xen\", \"id\": \"ami-1e749f67\", \"image_id\": \"ami-1e749f67\", \"image_location\": \"amazon/getting-started\", \"image_owner_alias\": \"amazon\", \"image_type\": \"machine\", \"imds_support\": \"\", \"include_deprecated\": false, \"kernel_id\": \"None\", \"most_recent\": true, \"name\": \"ubuntu/images/hvm-ssd/ubuntu-trusty-14.04-amd64-server-20170727\", \"name_regex\": null, \"owner_id\": \"099720109477\", \"owners\": [ \"099720109477\" ], \"platform\": \"\", \"platform_details\": \"\", \"product_codes\": [], \"public\": true, \"ramdisk_id\": \"ari-1a2b3c4d\", \"root_device_name\": \"/dev/sda1\", \"root_device_type\": \"ebs\", \"root_snapshot_id\": \"snap-15bd5527\", \"sriov_net_support\": \"\", \"state\": \"available\", \"state_reason\": { \"code\": \"UNSET\", \"message\": \"UNSET\" }, \"tags\": {}, \"timeouts\": null, \"tpm_support\": \"\", \"usage_operation\": \"\", \"virtualization_type\": \"hvm\" }, \"sensitive_attributes\": [] } ] }, { \"mode\": \"managed\", \"type\": \"aws_instance\", \"name\": \"web\", \"provider\": \"provider[\\\"registry.terraform.io/hashicorp/aws\\\"]\", \"instances\": [ { \"schema_version\": 1, \"attributes\": { \"ami\": \"ami-1e749f67\", \"arn\": \"arn:aws:ec2:us-east-1::instance/i-288a34165ed2ad2f7\", \"associate_public_ip_address\": true, \"availability_zone\": \"us-east-1a\", \"capacity_reservation_specification\": [], \"cpu_core_count\": null, \"cpu_options\": [], \"cpu_threads_per_core\": null, \"credit_specification\": [], \"disable_api_stop\": false, \"disable_api_termination\": false, \"ebs_block_device\": [], \"ebs_optimized\": false, \"enclave_options\": [], \"ephemeral_block_device\": [], \"get_password_data\": false, \"hibernation\": false, \"host_id\": \"\", \"host_resource_group_arn\": null, \"iam_instance_profile\": \"\", \"id\": \"i-288a34165ed2ad2f7\", \"instance_initiated_shutdown_behavior\": \"stop\", \"instance_lifecycle\": \"\", \"instance_market_options\": [], \"instance_state\": \"running\", \"instance_type\": \"t3.micro\", \"ipv6_address_count\": 0, \"ipv6_addresses\": [], \"key_name\": \"\", \"launch_template\": [], \"maintenance_options\": [], \"metadata_options\": [], \"monitoring\": false, \"network_interface\": [], \"outpost_arn\": \"\", \"password_data\": \"\", \"placement_group\": \"\", \"placement_partition_number\": 0, \"primary_network_interface_id\": \"eni-68899bf6\", \"private_dns\": \"ip-10-13-239-41.ec2.internal\", \"private_dns_name_options\": [], \"private_ip\": \"10.13.239.41\", \"public_dns\": \"ec2-54-214-132-221.compute-1.amazonaws.com\", \"public_ip\": \"54.214.132.221\", \"root_block_device\": [ { \"delete_on_termination\": true, \"device_name\": \"/dev/sda1\", \"encrypted\": false, \"iops\": 0, \"kms_key_id\": \"\", \"tags\": {}, \"throughput\": 0, \"volume_id\": \"vol-6dde834f\", \"volume_size\": 8, \"volume_type\": \"gp2\" } ], \"secondary_private_ips\": [], \"security_groups\": [], \"source_dest_check\": true, \"spot_instance_request_id\": \"\", \"subnet_id\": \"subnet-dbb4c2f9\", \"tags\": { \"Name\": \"HelloWorld\" }, \"tags_all\": { \"Name\": \"HelloWorld\" }, \"tenancy\": \"default\", \"timeouts\": null, \"user_data\": null, \"user_data_base64\": null, \"user_data_replace_on_change\": false, \"volume_tags\": null, \"vpc_security_group_ids\": [] }, \"sensitive_attributes\": [], \"private\": \"eyJlMmJmYjczMC1lY2FhLTExZTYtOGY4OC0zNDM2M2JjN2M0YzAiOnsiY3JlYXRlIjo2MDAwMDAwMDAwMDAsImRlbGV0ZSI6MTIwMDAwMDAwMDAwMCwidXBkYXRlIjo2MDAwMDAwMDAwMDB9LCJzY2hlbWFfdmVyc2lvbiI6IjEifQ==\", \"dependencies\": [ \"data.aws_ami.ubuntu\" ] } ] } ], \"check_results\": null } 我们可以看到,查询到的 data 以及创建的 resource 信息都被以 json 格式保存在 tfstate 文件里。 我们前面已经说过,由于 tfstate 文件的存在,我们在 terraform apply 之后立即再次 apply 是不会执行任何变更的,那么如果我们删除了这个 tfstate 文件,然后再执行 apply 会发生什么呢?Terraform 读取不到 tfstate 文件,会认为这是我们第一次创建这组资源,所以它会再一次创建代码中描述的所有资源。更加麻烦的是,由于我们前一次创建的资源所对应的状态信息被我们删除了,所以我们再也无法通过执行 terraform destroy 来销毁和回收这些资源,实际上产生了资源泄漏。所以妥善保存这个状态文件是非常重要的。 另外,如果我们对 Terraform 的代码进行了一些修改,导致生成的执行计划将会改变状态,那么在实际执行变更之前,Terraform 会复制一份当前的 tfstate 文件到同路径下的 terraform.tfstate.backup 中,以防止由于各种意外导致的 tfstate 损毁。 在 Terraform 发展的极早期,HashiCorp 曾经尝试过无状态文件的方案,也就是在执行 Terraform 变更计划时,给所有涉及到的资源都打上特定的 tag,在下次执行变更时,先通过 tag 读取相关资源来重建状态信息。但因为并不是所有资源都支持打 tag,也不是所有公有云都支持多 tag,所以 Terraform 最终决定用状态文件方案。 还有一点,HashiCorp 官方从未公开过 tfstate 的格式,也就是说,HashiCorp 保留随时修改 tfstate 格式的权力。所以不要试图手动或是用自研代码去修改 tfstate,Terraform 命令行工具提供了相关的指令(我们后续会介绍到),请确保只通过命令行的指令操作状态文件。 极其重要的安全警示—— tfstate 是明文的 关于 Terraform 状态,还有极其重要的事,所有考虑在生产环境使用 Terraform 的人都必须格外小心并再三警惕:Terraform 的状态文件是明文的,这就意味着代码中所使用的一切机密信息都将以明文的形式保存在状态文件里。例如我们创建一个私钥文件: resource \"tls_private_key\" \"example\" { algorithm = \"RSA\" } 执行了terraform apply后我们观察 tfstate 文件中相关段落: { \"version\": 4, \"terraform_version\": \"1.7.3\", \"serial\": 1, \"lineage\": \"dec42d6b-d61f-30b3-0b83-d5d8881c29ea\", \"outputs\": {}, \"resources\": [ { \"mode\": \"managed\", \"type\": \"tls_private_key\", \"name\": \"example\", \"provider\": \"provider[\\\"registry.terraform.io/hashicorp/tls\\\"]\", \"instances\": [ { \"schema_version\": 1, \"attributes\": { \"algorithm\": \"RSA\", \"ecdsa_curve\": \"P224\", \"id\": \"d47d1465586d25322bb8ca16029fe4fb2ec001e0\", \"private_key_openssh\": \"-----BEGIN OPENSSH PRIVATE KEY-----\\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABFwAAAAdz\\nc2gtcnNhAAAAAwEAAQAAAQEAsdD9sJ/L5m8cRB19V20HA9BIqZwnT3id5JoMNUuW\\nYn4sJTWHa/JHkQ5akrWH50aIdzQrQneIZXJyx1OMlqKbVxAc2+u/8qd2m2GrsWKZ\\neqQcpNT7v76QowDcvdggad3Tezn3XU/eBukVj9i+lnN1ofyQzVEQAdvW8TpRdUL5\\nb/bIsz1RzGWzUrWD8XTFLf2RuvvzhgBViuuWI0ns3WQMpa6Dcu+nWfGLCl26Wlph\\nNmoUAv8wCay7KoynG58pJW+uqYA7lTx4tNMLhIW7rM4roYbXctkCi03PcW3x25O8\\nyKSzYIi5xH7CQ7ggwXzcx4r06NXkaE9/LHuBSFJcIMvH8QAAA7jnMVXy5zFV8gAA\\nAAdzc2gtcnNhAAABAQCx0P2wn8vmbxxEHX1XbQcD0EipnCdPeJ3kmgw1S5Zifiwl\\nNYdr8keRDlqStYfnRoh3NCtCd4hlcnLHU4yWoptXEBzb67/yp3abYauxYpl6pByk\\n1Pu/vpCjANy92CBp3dN7OfddT94G6RWP2L6Wc3Wh/JDNURAB29bxOlF1Qvlv9siz\\nPVHMZbNStYPxdMUt/ZG6+/OGAFWK65YjSezdZAylroNy76dZ8YsKXbpaWmE2ahQC\\n/zAJrLsqjKcbnyklb66pgDuVPHi00wuEhbusziuhhtdy2QKLTc9xbfHbk7zIpLNg\\niLnEfsJDuCDBfNzHivTo1eRoT38se4FIUlwgy8fxAAAAAwEAAQAAAQEAleLv5ZFd\\nY9mm/vfIrwg1UI6ioW4CaOfoWElOHyKfGlj2x0qu41wv3WM3D9G7REVdRPYRvQ5b\\nSABIJiMUL+nTfXkUioDXpShqPyH+gyD09L8fcgYiS4fMDcrtR43GDNcyq/25uMtZ\\nAYQ6a62tQc8Dik8GlDtPffGc5mxdO7X/4tLAObBPqO+lvGX2K/2hV2ql/a4fBVXR\\nOMPc9A0eva2exifZyFo9vT9CCcW4iNY2BHY2hXAPI1gFpBnmnY2twFof4EvX6tfZ\\nGjt20QCqTi41P8Obrfqi108zRAKtjJFeezNY+diVvxZaCDb/7ceFbFUrXq9u2UVD\\nExn9joOLTJTEwQAAAIEAgOQ/mjRousgSenXW2nE2aq0m7oKQzhsF/8k5UPj6mym1\\nvwUyC2gglTIOGVkUpj91L/Fh2nCuX5BLyzzIee0twRvT1Kj11BU6UoElStpR/JEC\\n7trKWJrBddphBWHAuVcU5AQQPwI/9sg27q/9y16WTIQAJzx8GwGcDbgZj8/LbB4A\\nAACBAMpVcX+2smqt8T9mbwU7e7ZaCQM0c3/7F2S2Z26Zl16k+8WPr6+CyJd3d2s2\\nQkrmqVKJzDqPYidU0EmaNOrqytvUTUDK9KJgKsJuNC9ZbODqTCMA03ntr+hVcfdt\\nd9F5fxyBJSrzpAhUQKadHs8jtAa1ENAhPmwKzvcJ51Gp4R3ZAAAAgQDg+s4nk926\\njuLQGlweOi2HY6eeMQF8MOnXEhM8P7ErdRR73ql7GlhBNoGzuI6YTaqjLXBGNetj\\nm4iziPLapbqBXSeia3y1JU71e1M+J342CxQwZRKTI9G/AB2AzspB4VfjAT9ZYJM1\\nSFH7cDTejcrkfWko8TqyRLwtTPDa7Xlz2QAAAAAB\\n-----END OPENSSH PRIVATE KEY-----\\n\", \"private_key_pem\": \"-----BEGIN RSA PRIVATE KEY-----\\nMIIEpAIBAAKCAQEAsdD9sJ/L5m8cRB19V20HA9BIqZwnT3id5JoMNUuWYn4sJTWH\\na/JHkQ5akrWH50aIdzQrQneIZXJyx1OMlqKbVxAc2+u/8qd2m2GrsWKZeqQcpNT7\\nv76QowDcvdggad3Tezn3XU/eBukVj9i+lnN1ofyQzVEQAdvW8TpRdUL5b/bIsz1R\\nzGWzUrWD8XTFLf2RuvvzhgBViuuWI0ns3WQMpa6Dcu+nWfGLCl26WlphNmoUAv8w\\nCay7KoynG58pJW+uqYA7lTx4tNMLhIW7rM4roYbXctkCi03PcW3x25O8yKSzYIi5\\nxH7CQ7ggwXzcx4r06NXkaE9/LHuBSFJcIMvH8QIDAQABAoIBAQCV4u/lkV1j2ab+\\n98ivCDVQjqKhbgJo5+hYSU4fIp8aWPbHSq7jXC/dYzcP0btERV1E9hG9DltIAEgm\\nIxQv6dN9eRSKgNelKGo/If6DIPT0vx9yBiJLh8wNyu1HjcYM1zKr/bm4y1kBhDpr\\nra1BzwOKTwaUO0998ZzmbF07tf/i0sA5sE+o76W8ZfYr/aFXaqX9rh8FVdE4w9z0\\nDR69rZ7GJ9nIWj29P0IJxbiI1jYEdjaFcA8jWAWkGeadja3AWh/gS9fq19kaO3bR\\nAKpOLjU/w5ut+qLXTzNEAq2MkV57M1j52JW/FloINv/tx4VsVSter27ZRUMTGf2O\\ng4tMlMTBAoGBAMpVcX+2smqt8T9mbwU7e7ZaCQM0c3/7F2S2Z26Zl16k+8WPr6+C\\nyJd3d2s2QkrmqVKJzDqPYidU0EmaNOrqytvUTUDK9KJgKsJuNC9ZbODqTCMA03nt\\nr+hVcfdtd9F5fxyBJSrzpAhUQKadHs8jtAa1ENAhPmwKzvcJ51Gp4R3ZAoGBAOD6\\nzieT3bqO4tAaXB46LYdjp54xAXww6dcSEzw/sSt1FHveqXsaWEE2gbO4jphNqqMt\\ncEY162ObiLOI8tqluoFdJ6JrfLUlTvV7Uz4nfjYLFDBlEpMj0b8AHYDOykHhV+MB\\nP1lgkzVIUftwNN6NyuR9aSjxOrJEvC1M8NrteXPZAoGAIQf/5nSh/e51ov8LAtSq\\nJqPeMsq+TFdmg0eP7Stf3dCbVa5WZRW5v5h+Q19xRR8Q52udjrXXtUoQUuO83dkE\\n0wx+rCQ1+cgvUtyA4nX741/8m/5Hh/E4tXo1h8o0NFtcV//xXGi4D7AJeenOnMxc\\nWHf4zbGPqj29efEA9YEBQkkCgYABhG+DgNHMAk6xTJw2b/oCob9tp7L03XeWRb7v\\ndxaAzodW1oeaFvFlbzKsvZ/okw2FkDbjolV2FIR1gYTxyJBbcv9jbwomRpwjt7M2\\nBhopzyVRtjzL1UAC48NPLRXcH+Lx2v5MYgRcJaK36WfR4G7v35CoAAh/T0tdmtk9\\nAMEC8QKBgQCA5D+aNGi6yBJ6ddbacTZqrSbugpDOGwX/yTlQ+PqbKbW/BTILaCCV\\nMg4ZWRSmP3Uv8WHacK5fkEvLPMh57S3BG9PUqPXUFTpSgSVK2lH8kQLu2spYmsF1\\n2mEFYcC5VxTkBBA/Aj/2yDbur/3LXpZMhAAnPHwbAZwNuBmPz8tsHg==\\n-----END RSA PRIVATE KEY-----\\n\", \"private_key_pem_pkcs8\": \"-----BEGIN PRIVATE KEY-----\\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCx0P2wn8vmbxxE\\nHX1XbQcD0EipnCdPeJ3kmgw1S5ZifiwlNYdr8keRDlqStYfnRoh3NCtCd4hlcnLH\\nU4yWoptXEBzb67/yp3abYauxYpl6pByk1Pu/vpCjANy92CBp3dN7OfddT94G6RWP\\n2L6Wc3Wh/JDNURAB29bxOlF1Qvlv9sizPVHMZbNStYPxdMUt/ZG6+/OGAFWK65Yj\\nSezdZAylroNy76dZ8YsKXbpaWmE2ahQC/zAJrLsqjKcbnyklb66pgDuVPHi00wuE\\nhbusziuhhtdy2QKLTc9xbfHbk7zIpLNgiLnEfsJDuCDBfNzHivTo1eRoT38se4FI\\nUlwgy8fxAgMBAAECggEBAJXi7+WRXWPZpv73yK8INVCOoqFuAmjn6FhJTh8inxpY\\n9sdKruNcL91jNw/Ru0RFXUT2Eb0OW0gASCYjFC/p0315FIqA16Uoaj8h/oMg9PS/\\nH3IGIkuHzA3K7UeNxgzXMqv9ubjLWQGEOmutrUHPA4pPBpQ7T33xnOZsXTu1/+LS\\nwDmwT6jvpbxl9iv9oVdqpf2uHwVV0TjD3PQNHr2tnsYn2chaPb0/QgnFuIjWNgR2\\nNoVwDyNYBaQZ5p2NrcBaH+BL1+rX2Ro7dtEAqk4uNT/Dm636otdPM0QCrYyRXnsz\\nWPnYlb8WWgg2/+3HhWxVK16vbtlFQxMZ/Y6Di0yUxMECgYEAylVxf7ayaq3xP2Zv\\nBTt7tloJAzRzf/sXZLZnbpmXXqT7xY+vr4LIl3d3azZCSuapUonMOo9iJ1TQSZo0\\n6urK29RNQMr0omAqwm40L1ls4OpMIwDTee2v6FVx92130Xl/HIElKvOkCFRApp0e\\nzyO0BrUQ0CE+bArO9wnnUanhHdkCgYEA4PrOJ5Pduo7i0BpcHjoth2OnnjEBfDDp\\n1xITPD+xK3UUe96pexpYQTaBs7iOmE2qoy1wRjXrY5uIs4jy2qW6gV0nomt8tSVO\\n9XtTPid+NgsUMGUSkyPRvwAdgM7KQeFX4wE/WWCTNUhR+3A03o3K5H1pKPE6skS8\\nLUzw2u15c9kCgYAhB//mdKH97nWi/wsC1Komo94yyr5MV2aDR4/tK1/d0JtVrlZl\\nFbm/mH5DX3FFHxDna52Otde1ShBS47zd2QTTDH6sJDX5yC9S3IDidfvjX/yb/keH\\n8Ti1ejWHyjQ0W1xX//FcaLgPsAl56c6czFxYd/jNsY+qPb158QD1gQFCSQKBgAGE\\nb4OA0cwCTrFMnDZv+gKhv22nsvTdd5ZFvu93FoDOh1bWh5oW8WVvMqy9n+iTDYWQ\\nNuOiVXYUhHWBhPHIkFty/2NvCiZGnCO3szYGGinPJVG2PMvVQALjw08tFdwf4vHa\\n/kxiBFwlorfpZ9Hgbu/fkKgACH9PS12a2T0AwQLxAoGBAIDkP5o0aLrIEnp11tpx\\nNmqtJu6CkM4bBf/JOVD4+psptb8FMgtoIJUyDhlZFKY/dS/xYdpwrl+QS8s8yHnt\\nLcEb09So9dQVOlKBJUraUfyRAu7ayliawXXaYQVhwLlXFOQEED8CP/bINu6v/cte\\nlkyEACc8fBsBnA24GY/Py2we\\n-----END PRIVATE KEY-----\\n\", \"public_key_fingerprint_md5\": \"a9:cc:2d:0f:66:b9:a9:89:94:f1:da:8f:93:df:63:d5\", \"public_key_fingerprint_sha256\": \"SHA256:ias4wauIC4J/zd/cVoXT6UUSUZLQXaxdpJfkGAfRDz0\", \"public_key_openssh\": \"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCx0P2wn8vmbxxEHX1XbQcD0EipnCdPeJ3kmgw1S5ZifiwlNYdr8keRDlqStYfnRoh3NCtCd4hlcnLHU4yWoptXEBzb67/yp3abYauxYpl6pByk1Pu/vpCjANy92CBp3dN7OfddT94G6RWP2L6Wc3Wh/JDNURAB29bxOlF1Qvlv9sizPVHMZbNStYPxdMUt/ZG6+/OGAFWK65YjSezdZAylroNy76dZ8YsKXbpaWmE2ahQC/zAJrLsqjKcbnyklb66pgDuVPHi00wuEhbusziuhhtdy2QKLTc9xbfHbk7zIpLNgiLnEfsJDuCDBfNzHivTo1eRoT38se4FIUlwgy8fx\\n\", \"public_key_pem\": \"-----BEGIN PUBLIC KEY-----\\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsdD9sJ/L5m8cRB19V20H\\nA9BIqZwnT3id5JoMNUuWYn4sJTWHa/JHkQ5akrWH50aIdzQrQneIZXJyx1OMlqKb\\nVxAc2+u/8qd2m2GrsWKZeqQcpNT7v76QowDcvdggad3Tezn3XU/eBukVj9i+lnN1\\nofyQzVEQAdvW8TpRdUL5b/bIsz1RzGWzUrWD8XTFLf2RuvvzhgBViuuWI0ns3WQM\\npa6Dcu+nWfGLCl26WlphNmoUAv8wCay7KoynG58pJW+uqYA7lTx4tNMLhIW7rM4r\\noYbXctkCi03PcW3x25O8yKSzYIi5xH7CQ7ggwXzcx4r06NXkaE9/LHuBSFJcIMvH\\n8QIDAQAB\\n-----END PUBLIC KEY-----\\n\", \"rsa_bits\": 2048 }, \"sensitive_attributes\": [] } ] } ], \"check_results\": null } 可以看到不应该被第三方知晓的 private_key_openssh、private_key_pem 和 private_key_pem_pkcs8 是以明文形式被写在 tfstate 文件里的。这是 Terraform 从设计之初就确定的,并且在可见的未来不会有改善。不论你是在代码中明文硬编码,还是使用参数(variable,我们之后的章节会介绍),亦或是妙想天开地使用函数在运行时从外界读取,都无法改变这个结果。 解决之道有两种,一种是使用 Vault 或是 AWS Secret Manager 这样的动态机密管理工具生成临时有效的动态机密(比如有效期只有 5 分钟,即使被他人读取到,机密也早已失效);另一种就是我们下面将要介绍的 —— Terraform Backend。 生产环境的 tfstate 管理方案—— Backend 到目前为止我们的 tfstate 文件是保存在当前工作目录下的本地文件,假设我们的计算机损坏了,导致文件丢失,那么 tfstate 文件所对应的资源都将无法管理,而产生资源泄漏。 另外如果我们是一个团队在使用 Terraform 管理一组资源,团队成员之间要如何共享这个状态文件?能不能把 tfstate 文件签入源代码管理工具进行保存? 把 tfstate 文件签入管代码管理工具是非常错误的,这就好比把数据库签入了源代码管理工具,如果两个人同时签出了同一份 tfstate,并且对代码做了不同的修改,又同时 apply 了,这时想要把 tfstate 签入源码管理系统可能会遭遇到无法解决的冲突。况且如果代码仓库是公开的,那么保存在 State 中的明文机密就会泄露。 为了解决状态文件的存储和共享问题,Terraform 引入了远程状态存储机制,也就是 Backend。Backend 是一种抽象的远程存储接口,如同 Provider 一样,Backend 也支持多种不同的远程存储服务: local remote azurerm consul cos gcs http kubernetes oss pg s3 注意:在 Terraform 1.1.0 之前的版本中,Backend 分为标准和增强两种。增强是一种特殊的 remote Backend,它既可以存储状态,也可以执行 Terraform 操作。但是这种分类已被移除。请参考使用 Terraform Cloud 了解关于存储状态、执行远程操作以及直接从 Terraform 调用 Terraform Cloud 的详细信息。 状态锁是指,当针对一个 tfstate 进行变更操作时,可以针对该状态文件添加一把全局锁,确保同一时间只能有一个变更被执行。不同的 Backend 对状态锁的支持不尽相同,实现状态锁的机制也不尽相同,例如 consul Backend就通过一个 .lock 节点来充当锁,一个 .lockinfo 节点来描述锁对应的会话信息,tfstate 文件被保存在 Backend 定义的路径节点内;s3 Backend 则需要用户传入一个 Dynamodb 表来存放锁信息,而 tfstate 文件被存储在 S3 存储桶里,等等等等。读者可以根据实际情况,挑选自己合适的 Backend。接下来我将以 consul 为范例为读者演示 Backend 机制。 Consul简介以及安装 Consul 是 HashiCorp 推出的一个开源工具,主要用来解决服务发现、配置中心以及 Service Mesh 等问题;Consul 本身也提供了类似 ZooKeeper、Etcd 这样的分布式键值存储服务,具有基于 Gossip 协议的最终一致性,所以可以被用来充当 Terraform Backend 存储。 安装 Consul 十分简单,如果你是 Ubuntu 用户: wget -O- https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg echo \"deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main\" | sudo tee /etc/apt/sources.list.d/hashicorp.list sudo apt update && sudo apt install consul 对于CentOS用户: sudo yum install -y yum-utils sudo yum-config-manager --add-repo https://rpm.releases.hashicorp.com/RHEL/hashicorp.repo sudo yum -y install consul 对于Macos用户: brew tap hashicorp/tap brew install hashicorp/tap/consul 对于 Windows 用户,如果按照前文安装 Terraform 教程已经配置了 Chocolatey 的话: choco install consul 安装完成后的验证: $ consul Usage: consul [--version] [--help] [] Available commands are: acl Interact with Consul's ACLs agent Runs a Consul agent catalog Interact with the catalog config Interact with Consul's Centralized Configurations connect Interact with Consul Connect debug Records a debugging archive for operators event Fire a new event exec Executes a command on Consul nodes force-leave Forces a member of the cluster to enter the \"left\" state info Provides debugging information for operators. intention Interact with Connect service intentions join Tell Consul agent to join cluster keygen Generates a new encryption key keyring Manages gossip layer encryption keys kv Interact with the key-value store leave Gracefully leaves the Consul cluster and shuts down lock Execute a command holding a lock login Login to Consul using an auth method logout Destroy a Consul token created with login maint Controls node or service maintenance mode members Lists the members of a Consul cluster monitor Stream logs from a Consul agent operator Provides cluster-level tools for Consul operators peering Create and manage peering connections between Consul clusters reload Triggers the agent to reload configuration files resource Interact with Consul's resources rtt Estimates network round trip time between nodes services Interact with services snapshot Saves, restores and inspects snapshots of Consul server state tls Builtin helpers for creating CAs and certificates troubleshoot CLI tools for troubleshooting Consul service mesh validate Validate config files/directories version Prints the Consul version watch Watch for changes in Consul 安装完 Consul 后,我们可以启动一个测试版 Consul 服务: $ consul agent -dev Consul 会在本机 8500 端口开放 Http 终结点,我们可以通过浏览器访问 http://localhost:8500 : 使用 Backend 我们还是利用 LocalStack 来执行一段简单的 Terraform 代码: terraform { required_providers { aws = { source = \"hashicorp/aws\" version = \"~>5.0\" } } backend \"consul\" { address = \"localhost:8500\" scheme = \"http\" path = \"localstack-aws\" } } provider \"aws\" { access_key = \"test\" secret_key = \"test\" region = \"us-east-1\" s3_use_path_style = false skip_credentials_validation = true skip_metadata_api_check = true skip_requesting_account_id = true endpoints { apigateway = \"http://localhost:4566\" apigatewayv2 = \"http://localhost:4566\" cloudformation = \"http://localhost:4566\" cloudwatch = \"http://localhost:4566\" docdb = \"http://localhost:4566\" dynamodb = \"http://localhost:4566\" ec2 = \"http://localhost:4566\" es = \"http://localhost:4566\" elasticache = \"http://localhost:4566\" firehose = \"http://localhost:4566\" iam = \"http://localhost:4566\" kinesis = \"http://localhost:4566\" lambda = \"http://localhost:4566\" rds = \"http://localhost:4566\" redshift = \"http://localhost:4566\" route53 = \"http://localhost:4566\" s3 = \"http://s3.localhost.localstack.cloud:4566\" secretsmanager = \"http://localhost:4566\" ses = \"http://localhost:4566\" sns = \"http://localhost:4566\" sqs = \"http://localhost:4566\" ssm = \"http://localhost:4566\" stepfunctions = \"http://localhost:4566\" sts = \"http://localhost:4566\" } } data \"aws_ami\" \"ubuntu\" { most_recent = true filter { name = \"name\" values = [\"ubuntu/images/hvm-ssd/ubuntu-trusty-14.04-amd64-server-20170727\"] } filter { name = \"virtualization-type\" values = [\"hvm\"] } owners = [\"099720109477\"] # Canonical } resource \"aws_instance\" \"web\" { ami = data.aws_ami.ubuntu.id instance_type = \"t3.micro\" tags = { Name = \"HelloWorld\" } } 在 terraform 节中,我们添加了 backend 配置节,指定使用 localhost:8500 为地址(也就是我们刚才启动的测试版 Consul 服务),指定使用 http 协议访问该地址,指定 tfstate 文件存放在 Consul 键值存储服务的 localstack-aws 路径下。 当我们执行完 terraform apply 后,我们访问 http://localhost:8500/ui/dc1/kv : 可以看到 localstack-aws,点击进入: 可以看到,原本保存在工作目录下的 tfstate 文件的内容,被保存在了 Consul 的名为 localstack-aws 的键下。 让我们执行 terraform destroy 后,重新访问 http://localhost:8500/ui/dc1/kv : 可以看到,localstack-aws 这个键仍然存在。让我们点击进去: 可以看到,它的内容为空,代表基础设施已经被成功销毁。 观察锁文件 那么在这个过程里,锁究竟在哪里?我们如何能够体验到锁的存在?让我们对代码进行一点修改: terraform { required_providers { aws = { source = \"hashicorp/aws\" version = \"~>5.0\" } } backend \"consul\" { address = \"localhost:8500\" scheme = \"http\" path = \"localstack-aws\" } } provider \"aws\" { access_key = \"test\" secret_key = \"test\" region = \"us-east-1\" s3_use_path_style = false skip_credentials_validation = true skip_metadata_api_check = true skip_requesting_account_id = true endpoints { apigateway = \"http://localhost:4566\" apigatewayv2 = \"http://localhost:4566\" cloudformation = \"http://localhost:4566\" cloudwatch = \"http://localhost:4566\" docdb = \"http://localhost:4566\" dynamodb = \"http://localhost:4566\" ec2 = \"http://localhost:4566\" es = \"http://localhost:4566\" elasticache = \"http://localhost:4566\" firehose = \"http://localhost:4566\" iam = \"http://localhost:4566\" kinesis = \"http://localhost:4566\" lambda = \"http://localhost:4566\" rds = \"http://localhost:4566\" redshift = \"http://localhost:4566\" route53 = \"http://localhost:4566\" s3 = \"http://s3.localhost.localstack.cloud:4566\" secretsmanager = \"http://localhost:4566\" ses = \"http://localhost:4566\" sns = \"http://localhost:4566\" sqs = \"http://localhost:4566\" ssm = \"http://localhost:4566\" stepfunctions = \"http://localhost:4566\" sts = \"http://localhost:4566\" } } data \"aws_ami\" \"ubuntu\" { most_recent = true filter { name = \"name\" values = [\"ubuntu/images/hvm-ssd/ubuntu-trusty-14.04-amd64-server-20170727\"] } filter { name = \"virtualization-type\" values = [\"hvm\"] } owners = [\"099720109477\"] # Canonical } resource \"aws_instance\" \"web\" { ami = data.aws_ami.ubuntu.id instance_type = \"t3.micro\" tags = { Name = \"HelloWorld\" } provisioner \"local-exec\" { command = \"sleep 1000\" } } 这次的变化是我们在 aws_instance 的定义上添加了一个 local-exec 类型的 Provisioner。Provisioner 我们在后续的章节中会专门叙述,在这里读者只需要理解,Terraform 进程在成功创建了该资源后,会在执行 Terraform 命令行的机器上执行一条命令:sleep 1000,这个时间足以将 Terraform 进程阻塞足够长的时间,以便让我们观察锁信息了。如果读者正在使用 Windows,可以把 provisioner 改成这样: provisioner \"local-exec\" { command = \"Start-Sleep -s 1000\" interpreter = [\"PowerShell\", \"-Command\"] } 让我们执行terraform apply,这一次 apply 将会被 sleep 阻塞,而不会成功完成: data.aws_ami.ubuntu: Reading... data.aws_ami.ubuntu: Read complete after 1s [id=ami-1e749f67] Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: + create Terraform will perform the following actions: # aws_instance.web will be created + resource \"aws_instance\" \"web\" { + ami = \"ami-1e749f67\" + arn = (known after apply) + associate_public_ip_address = (known after apply) + availability_zone = (known after apply) + cpu_core_count = (known after apply) + cpu_threads_per_core = (known after apply) + disable_api_stop = (known after apply) + disable_api_termination = (known after apply) + ebs_optimized = (known after apply) + get_password_data = false + host_id = (known after apply) + host_resource_group_arn = (known after apply) + iam_instance_profile = (known after apply) + id = (known after apply) + instance_initiated_shutdown_behavior = (known after apply) + instance_lifecycle = (known after apply) + instance_state = (known after apply) + instance_type = \"t3.micro\" + ipv6_address_count = (known after apply) + ipv6_addresses = (known after apply) + key_name = (known after apply) + monitoring = (known after apply) + outpost_arn = (known after apply) + password_data = (known after apply) + placement_group = (known after apply) + placement_partition_number = (known after apply) + primary_network_interface_id = (known after apply) + private_dns = (known after apply) + private_ip = (known after apply) + public_dns = (known after apply) + public_ip = (known after apply) + secondary_private_ips = (known after apply) + security_groups = (known after apply) + source_dest_check = true + spot_instance_request_id = (known after apply) + subnet_id = (known after apply) + tags = { + \"Name\" = \"HelloWorld\" } + tags_all = { + \"Name\" = \"HelloWorld\" } + tenancy = (known after apply) + user_data = (known after apply) + user_data_base64 = (known after apply) + user_data_replace_on_change = false + vpc_security_group_ids = (known after apply) } Plan: 1 to add, 0 to change, 0 to destroy. aws_instance.web: Creating... aws_instance.web: Still creating... [10s elapsed] aws_instance.web: Provisioning with 'local-exec'... aws_instance.web (local-exec): Executing: [\"PowerShell\" \"-Command\" \"Start-Sleep -s 1000\"] aws_instance.web: Still creating... [20s elapsed] ... 让我们重新访问 http://localhost:8500/ui/dc1/kv : 这一次情况发生了变化,我们看到除了localstack-aws这个键之外,还多了一个同名的文件夹。让我们点击进入文件夹: 在这里我们成功观测到了 .lock 和 .lockinfo 文件。让我们点击 .lock 看看: Consul UI提醒我们,该键值对目前正被锁定,而它的内容是空的。让我们查看 .lockinfo 的内容: .lockinfo 里记录了锁 ID、我们执行的操作,以及其他的一些信息。 让我们另起一个新的命令行窗口,在同一个工作目录下尝试另一次执行 terraform apply: $ terraform apply Acquiring state lock. This may take a few moments... ╷ │ Error: Error acquiring the state lock │ │ Error message: Lock Info: │ ID: 11c859fd-d3e5-4eab-46d6-586b73133430 │ Path: localstack-aws │ Operation: OperationTypeApply │ Who: *** │ Version: 1.7.3 │ Created: 2024-02-25 02:00:21.3700184 +0000 UTC │ Info: consul session: 11c859fd-d3e5-4eab-46d6-586b73133430 │ │ │ Terraform acquires a state lock to protect the state from being written │ by multiple users at the same time. Please resolve the issue above and try │ again. For most commands, you can disable locking with the \"-lock=false\" │ flag, but this is not recommended. ╵ 可以看到,同时另一个人试图对同一个 tfstate 执行变更的尝试失败了,因为它无法顺利获取到锁。 让我们用 ctrl-c 终止原先被阻塞的 terraform apply 的执行,然后用 terraform force-unlock 11c859fd-d3e5-4eab-46d6-586b73133430 解锁: $ terraform force-unlock 11c859fd-d3e5-4eab-46d6-586b73133430 Do you really want to force-unlock? Terraform will remove the lock on the remote state. This will allow local Terraform commands to modify this state, even though it may still be in use. Only 'yes' will be accepted to confirm. Enter a value: yes Terraform state has been successfully unlocked! The state has been unlocked, and Terraform commands should now be able to obtain a new lock on the remote state. 然后重新访问 http://localhost:8500/ui/dc1/kv : 可以看到,包含锁的文件夹消失了。 Backend 配置的动态赋值 有些读者会注意到,到目前为止我所写的代码里的配置项基本都是硬编码的,Terraform 是否支持运行时用变量动态赋值?答案是支持的,Terraform 可以通过 variable 变量来传值给 provider、data 和 resource。 但有一些例外,其中就有 backend 配置。backend 配置只允许硬编码,或者不传值。 这个问题是因为 Terraform 运行时本身设计的运行顺序导致的,一直到 2019 年 05 月官方才给出了解决方案,那就是“部分配置“(partial configuration)。 简单来说就是我们可以在 Terraform 代码的 backend 声明中不给出具体的配置: terraform { required_providers { aws = { source = \"hashicorp/aws\" version = \"~>5.0\" } } backend \"consul\" { } } 而在另一个独立的文件中给出相关配置,例如我们在工作目录下创建一个名为 backend.hcl 的文件: address = \"localhost:8500\" scheme = \"http\" path = \"localstack-aws\" 本质上我们就是把原本属于 backend consul 块的属性赋值代码搬迁到一个独立的 hcl 文件内,然后我们执行 terraform init 时附加 backend-config 参数: $ terraform init -backend-config=backend.hcl 这样也可以初始化成功。通过这种打补丁的方式,我们可以复用他人预先写好的 Terraform 代码,在执行时把属于我们自己的 Backend 配置信息以独立的 backend-config 文件的形式传入来进行初始化。 Backend 的权限控制以及版本控制 Backend 本身并没有设计任何的权限以及版本控制,这方面完全依赖于具体的 Backend 实现。以 AWS S3 为例,我们可以针对不同的 Bucket 设置不同的 IAM,用以防止开发测试人员直接操作生产环境,或是给予部分人员对状态信息的只读权限;另外我们也可以开启 S3 的版本控制功能,以防我们错误修改了状态文件(Terraform 命令行有修改状态的相关指令)。 状态的隔离存储 我们讲完 Backend,现在要讨论另一个问题。假设我们的 Terraform 代码可以创建一个通用的基础设施,比如说是云端的一个 EKS、AKS 集群,或者是一个基于 S3 的静态网站,那么我们可能要为很多团队创建并维护这些相似但要彼此隔离的 Stack,又或者我们要为部署的应用维护开发、测试、预发布、生产四套不同的部署。那么该如何做到不同的部署,彼此状态文件隔离存储和管理呢? 一种简单的方法就是分成不同的文件夹存储。 我们可以把不同产品不同部门使用的基础设施分成不同的文件夹,在文件夹内维护相同的代码文件,配置不同的 backend-config,把状态文件保存到不同的 Backend 上。这种方法可以给予最大程度的隔离,缺点是我们需要拷贝许多份相同的代码。 第二种更加轻量级的方法就是 Workspace。注意,Terraform 开源版的 Workspace 与 Terraform Cloud 云服务的 Workspace 实际上是两个不同的概念,我们这里介绍的是开源版的 Workspace。 Workspace 允许我们在同一个文件夹内,使用同样的 Backend 配置,但可以维护任意多个彼此隔离的状态文件。还是我们刚才那个使用测试 Consul 服务作为 Backend 的例子: 当前我们有一个状态文件,名字是 localstack-aws。然后我们在工作目录下执行这样的命令: $ terraform workspace new feature1 Created and switched to workspace \"feature1\"! You're now on a new, empty workspace. Workspaces isolate their state, so if you run \"terraform plan\" Terraform will not see any existing state for this configuration. 通过调用 workspace 命令,我们成功创建了名为 feature1 的 Workspace。这时我们观察 .terraform 文件夹: .. ├── environment ├── providers │ └── registry.terraform.io │ └── hashicorp │ └── aws │ └── 5.38.0 ...... 我们会发现多了一个 environment 文件,它的内容是 feature1。这实际上就是 Terraform 用来保存当前上下文环境使用的是哪个 Workspace 的文件。 重新观察 Consul 存储会发现多了一个文件:localstack-aws-env:feature1。这就是 Terraform 为 feature1 这个 Workspace 创建的独立的状态文件。让我们执行一下 apply,然后再看这个文件的内容: 可以看到,状态被成功写入了 feature1 的状态文件。 我们可以通过以下命令来查询当前 Backend 下所有的 Workspace: $ terraform workspace list default * feature1 我们有 default 和 feature1 两个 Workspace,当前我们工作在 feature1 上。我们可以用以下命令切换回 default: $ terraform workspace select default Switched to workspace \"default\". 我们可以用以下命令确认我们成功切换回了 default: $ terraform workspace show default 我们可以用以下命令删除 feature1: $ terraform workspace delete feature1 ╷ │ Error: Workspace is not empty │ │ Workspace \"feature1\" is currently tracking the following resource instances: │ - aws_instance.web │ │ Deleting this workspace would cause Terraform to lose track of any associated remote objects, which would then require you to │ delete them manually outside of Terraform. You should destroy these objects with Terraform before deleting the workspace. │ │ If you want to delete this workspace anyway, and have Terraform forget about these managed objects, use the -force option to │ disable this safety check. ╵ Terraform 发现 feature1 还有资源没有被销毁,所以它拒绝了我们的删除请求。因为我们目前是使用 LocalStack 模拟的例子,所以不会有资源泄漏的问题,我们可以用以下命令强制删除 feature1: $ terraform workspace delete -force feature1 Deleted workspace \"feature1\"! WARNING: \"feature1\" was non-empty. The resources managed by the deleted workspace may still exist, but are no longer manageable by Terraform since the state has been deleted. 再观察 Consul 存储,就会发现 feature1 的状态文件被删除了: 目前支持多工作区的 Backend 有: AzureRM Consulf COS GCS Kubernetes Local OSS Postgres Remote S3 该使用哪种隔离 相比起多文件夹隔离的方式来说,基于 Workspace 的隔离更加简单,只需要保存一份代码,在代码中不需要为 Workspace 编写额外代码,用命令行就可以在不同工作区之间来回切换。 但是 Workspace 的缺点也同样明显,由于所有工作区的 Backend 配置是一样的,所以有权读写某一个 Workspace 的人可以读取同一个 Backend 路径下所有其他 Workspace;另外 Workspace 是隐式配置的(调用命令行),所以有时人们会忘记自己工作在哪个 Workspace 下。 Terraform 官方为 Workspace 设计的场景是:有时开发人员想要对既有的基础设施做一些变更,并进行一些测试,但又不想直接冒险修改既有的环境。这时他可以利用 Workspace 复制出一个与既有环境完全一致的平行环境,在这个平行环境里做一些变更,并进行测试和实验工作。 Workspace 对应的源代码管理模型里的主干——分支模型,如果团队希望维护的是不同产品之间不同的基础设施,或是开发、测试、预发布、生产环境,那么最好还是使用不同的文件夹以及不同的 backend-config 进行管理。 "},"3.Terraform代码的书写/":{"url":"3.Terraform代码的书写/","title":"Terraform 代码的书写","keywords":"","body":"Terraform 代码的书写 我们将在本章讲解 Terraform 配置文件的编写。 Terraform 早期仅支持使用 HCL(Hashicorp Configuration Language)语法的 .tf 文件,近些年来也开始支持 JSON。HashiCorp 甚至修改了他们的 json 解析器,使得他们的 json 可以支持注释,但 HCL 相比起 JSON 来说有着更好的可读性,所以我们还是会以 HCL 来讲解。其实我个人是不太喜欢用 JSON 编写 Terraform 代码的,有些团队使用 JSON 是因为他们是用其他代码来生成相应的 JSON 格式的 Terraform 代码(比如自研的 GUI 工具,通过拖拽的方式定义基础设施,继而生辰相关代码)。我个人不太喜欢这种方式,因为它鼓励用户从零开始拖拽出所需的所有基础设施,而不是通过组装成熟的可复用的模块代码。我个人认为应该像对待业务逻辑代码一样对待基础设施代码。 这里特别之处一点,我们将在这一章节提到模块(Module)的概念,但我们会在后续单独的章节专门讲解模块。在本章内,读者可以简单地将一个模块理解成一个含有多个 Terraform 代码文件的目录,不包含其子目录。 本章内容基本是对官方文档的翻译,英语阅读能力好的读者应该直接阅读官方文档获取最权威的信息。 "},"3.Terraform代码的书写/1.类型.html":{"url":"3.Terraform代码的书写/1.类型.html","title":"类型","keywords":"","body":"类型 表达式的结果是一个值。所有的值都有一个类型,这个类型决定了这个值可以在哪里使用以及可以对它应用哪些转换。 Terraform 的某些类型之间存在隐式类型转换规则,如果无法隐式转换类型,那么不同类型数据间的赋值将会报错。 Terraform 类型分为原始类型、复杂类型,以及 null。 原始类型 原始类型分三类:string、number、bool。 string 代表一组 Unicode 字符串,例如:\"hello\"。 number 代表数字,可以为整数,也可以为小数。 bool 代表布尔值,要么为 true,要么为 false。bool 值可以被用做逻辑判断。 number 和 bool 都可以和 string 进行隐式转换,当我们把 number 或 bool 类型的值赋给 string 类型的值,或是反过来时,Terraform 会自动替我们转换类型,其中: true 值会被转换为 \"true\",反之亦然 false 值会被转换为 \"false\",反之亦然 15 会被转换为 \"15\",3.1415 会被转换为 \"3.1415\",反之亦然 复杂类型 复杂类型是一组值所组成的符合类型,有两类复杂类型。 一种是集合类型。一个集合包含了一组同一类型的值。集合内元素的类型成为元素类型。一个集合变量在构造时必须确定集合类型。集合内所有元素的类型必须相同。 Terraform 支持三种集合: list(...):列表是一组值的连续集合,可以用下标访问内部元素,下标从 0 开始。例如名为 l 的 list,l[0] 就是第一个元素。list 类型的声明可以是 list(number)、list(string)、list(bool)等,括号中的类型即为元素类型。 map(...):字典类型(或者叫映射类型),代表一组键唯一的键值对,键类型必须是 string,值类型任意。map(number) 代表键为 string 类型而值为 number 类型,其余类推。map 值有两种声明方式,一种是类似 {\"foo\": \"bar\", \"bar\": \"baz\"},另一种是 {foo=\"bar\", bar=\"baz\"}。键可以不用双引号,但如果键是以数字开头则例外。多对键值对之间要用逗号分隔,也可以用换行符分隔。推荐使用 = 号(Terraform 代码规范中规定按等号对齐,使用等号会使得代码在格式化后更加美观) set(...):集合类型,代表一组不重复的值。 以上集合类型都支持通配类型缩写,例如 list 等价于 list(any),map 等价于 map(any),set 等价于 set(any)。any 代表支持任意的元素类型,前提是所有元素都是一个类型。例如,将 list(number) 赋给 list(any) 是合法的,list(string) 赋给 list(any) 也是合法的,但是 list 内部所有的元素必须是同一种类型的。 第二种复杂类型是结构化类型。一个结构化类型允许多个不同类型的值组成一个类型。结构化类型需要提供一个 schema 结构信息作为参数来指明元素的结构。 Terraform 支持两种结构化类型: object(...):对象是指一组由具有名称和类型的属性所构成的符合类型,它的 schema 信息由 { \\=\\, \\=\\,...} 的形式描述,例如 object({age=number, name=string}),代表由名为 \"age“ 类型为number,以及名为 \"name\" 类型为 string 两个属性组成的对象。赋给 object 类型的合法值必须含有所有属性值,但是可以拥有多余的属性(多余的属性在赋值时会被抛弃)。例如对于 object({age=number,name=string}) 来说,{ age=18 } 是一个非法值,而 { age=18, name=\"john\", gender=\"male\" } 是一个合法值,但赋值时 gender 会被抛弃 tuple(...):元组类似 list,也是一组值的连续集合,但每个元素都有独立的类型。元组同 list 一样,也可以用下标访问内部元素,下标从 0 开始。元组 schema 用 [\\, \\, ...] 的形式描述。元组的元素数量必须与 schema 声明的类型数量相等,并且每个元素的类型必须与元组 schema 相应位置的类型相等。例如,tuple([string, number, bool]) 类型的一个合法值可以是 [\"a\", 15, true] 复杂类型也支持隐式类型转换。 Terraform 会尝试转换相似的类型,转换规则有: object 和 map:如果一个 map 的键集合含有 object 规定的所有属性,那么 map 可以被转换为 object,map 里多余的键值对会被抛弃。由 map -> object -> map 的转换可能会丢失数据。 tuple 和 list:当一个 list 元素的数量正好等于一个 tuple 声明的长度时,list 可以被转换为 tuple。例如:值为 [\"18\", \"true\", \"john\"] 的 list 转换为 tuple([number,bool, string]) 的结果为 [18, true, \"john\"] set 和 tuple:当一个 list 或是 tuple 被转换为一个 set,那么重复的值将被丢弃,并且值原有的顺序也将丢失。如果一个 set 被转换到 list 或是 tuple,那么元素将按照以下顺序排列:如果 set 的元素是 string,那么将按照字段顺序排列;其他类型的元素不承诺任何特定的排列顺序。 复杂类型转换时,元素类型将在可能的情况下发生隐式转换,类似上述 list 到 tuple 转换举的例子。 如果类型不匹配,Terraform 会报错,例如我们试图把object({name = [\"Kristy\", \"Claudia\", \"Mary Anne\", \"Stacey\"], age = 12})转换到 map(string) 类型,这是不合法的,因为 name 的值为 list,无法转换为 string。 any any 是 Terraform 中非常特殊的一种类型约束,它本身并非一个类型,而只是一个占位符。每当一个值被赋予一个由 any 约束的复杂类型时,Terraform 会尝试计算出一个最精确的类型来取代 any。 例如我们把 [\"a\", \"b\", \"c\"] 赋给 list(any),它在 Terraform 中实际的物理类型首先被编译成 tuple([string, string, string]),然后 Terraform 认为 tuple 和 list 相似,所以会尝试将它转换为 list(string)。然后 Terraform 发现 list(string) 符合 list(any) 的约束,所以会用 string 取代 any,于是赋值后最终的类型是 list(string)。 由于即使是 list(any),所有元素的类型也必须是一样的,所以某些类型转换到 list(any) 时会对元素进行隐式类型转换。例如将 [\"a\", 1, \"b\"] 赋给 list(any),Terraform 发现 1 可以转换到 \"1\",所以最终的值是 [\"a\", \"1\", \"b\"],最终的类型会是 list(string)。再比如我们想把 [\"a\", \\[\\], \"b\"] 转换成 list(any),由于 Terraform 无法找到一个一个合适的目标类型使得所有元素都能成功隐式转换过去,所以 Terraform 会报错,要求所有元素都必须是同一个类型的。 声明类型时如果不想有任何的约束,那么可以用 any: variable \"no_type_constraint\" { type = any } 这样的话,Terraform 可以将任何类型的数据赋予它。 null 存在一种特殊值是无类型的,那就是 null。null 代表数据缺失。如果我们把一个参数设置为 null,Terraform 会认为你忘记为它赋值。如果该参数有默认值,那么 Terraform 会使用默认值;如果没有又恰巧该参数是必填字短,Terraform 会报错。null 在条件表达式中非常有用,你可以在某项条件不满足时跳过对某参数的赋值。 object 的 optional 成员 自 Terraform 1.3 开始,我们可以在 object 类型定义中使用 optional 修饰属性。 在 1.3 之前,如果一个 variable 的类型为 object,那么使用时必须传入一个结构完全相符的对象。例如: variable \"an_object\" { type = object({ a = string b = string c = number }) } 如果我们想传入一个对象给 var.an_object,但不准备给 b 和 c 赋值,我们必须这样: { a = \"a\" b = null c = null } 传入的对象必须完全匹配类型定义的结构,哪怕我们不想对某些属性赋值。这使得我们如果想要定义一些比较复杂,属性比较多的 object 类型时会给用户在使用上造成一些麻烦。 Terraform 1.3 允许我们为一个属性添加 optional 声明,还是用上面的例子: variable \"with_optional_attribute\" { type = object({ a = string # a required attribute b = optional(string) # an optional attribute c = optional(number, 127) # an optional attribute with default value }) } 在这里我们将 b 声明为 optional,如果传入的对象没有 b,则会使用 null 作为值;c 不但声明为 optional 的,还添加了 127 作为默认值,传入的对象如果没有 c,那么会使用 127 作为它的值。 optional 修饰符有这样两个参数: 类型:(必填)第一个参数标明了属性的类型 默认值:(选填)第二个参数定义了 Terraform 在对象中没有定义该属性值时使用的默认值。默认值必须与类型参数兼容。如果没有指定默认值,Terraform 会使用 null 作为默认值。 一个包含非 null 默认值的 optional 属性在模块内使用时可以确保不会读到 null 值。当用户没有设置该属性,或是显式将其设置为 null 时,Terraform 会使用默认值,所以模块内无需再次判断该属性是否为 null。 Terraform 采用自上而下的顺序来设置对象的默认值,也就是说,Terraform 会先应用 optional 修饰符中的指定的默认值,然后再为其中可能存在的内嵌对象设置默认值。 例子:带有 optional 属性和默认值的内嵌结构 下面的例子演示了一个输入变量,用来描述一个存储了静态网站内容的存储桶。该变量的类型包含了一系列的 optional 属性,包括 website,不但其自身是 optional 的,其内部包含了数个 optional 的属性以及默认值。 variable \"buckets\" { type = list(object({ name = string enabled = optional(bool, true) website = optional(object({ index_document = optional(string, \"index.html\") error_document = optional(string, \"error.html\") routing_rules = optional(string) }), {}) })) } 以下给出一个样例 terraform.tfvars 文件,为 var.buckets 定义了三个存储桶: production 配置了一条重定向的路由规则 archived 使用了默认配置,但被关闭了 docs 使用文本文件取代了索引页和错误页 production 桶没有指定索引页和错误页,archived 桶完全忽略了网站配置。Terraform 会使用 bucket 类型约束中指定的默认值。 buckets = [ { name = \"production\" website = { routing_rules = 该配置会产生如下的 variable 值: 对 production 和 docs 桶,Terraform 会将 enabled 设置为 true。Terraform 会同时使用默认值配置 website,然后使用 docs 中指定的值来覆盖默认值。 对 archived 和 docs 桶,Terraform 会将 routing_rules 设置为 null。当 Terraform 没有读取到 optional 的属性,并且属性上没有设置默认值时,Terraform 会将这些属性设置为 null。 对于 archived 桶,Terraform 会将 website 属性设置为 buckets 类型约束中定义的默认值。 tolist([ { \"enabled\" = true \"name\" = \"production\" \"website\" = { \"error_document\" = \"error.html\" \"index_document\" = \"index.html\" \"routing_rules\" = 例子:有条件地设置一个默认属性 有时我们需要根据其他数据的值来动态决定是否要为一个 optional 参数设置值。在这种场景下,发起调用的 module 块可以使用条件表达式搭配 null 来动态地决定是否设置该参数。 还是上一个例子中的 variable \"buckets\" 的例子,使用下面演示的例子可以根据新输入参数 var.legacy_filenames 的值来有条件地覆盖 website 对象中 index_document 以及 error_document 的设置: variable \"legacy_filenames\" { type = bool default = false nullable = false } module \"buckets\" { source = \"./modules/buckets\" buckets = [ { name = \"maybe_legacy\" website = { error_document = var.legacy_filenames ? \"ERROR.HTM\" : null index_document = var.legacy_filenames ? \"INDEX.HTM\" : null } }, ] } 当 var.legacy_filenames 设置为 true 时,调用会覆盖 document 的文件名。当它的值为 false 时,调用不会指定这两个文件名,这样就会使得模块使用定义的默认值。 "},"3.Terraform代码的书写/2.配置语法.html":{"url":"3.Terraform代码的书写/2.配置语法.html","title":"配置语法","keywords":"","body":"配置语法 这里讲的仍然是 HCL 的语法,但我们只讲一些关键语法。如果读者有兴趣了解完整信息可以访问 HCL 语法规约 HCL 的语法由两个关键元素构成:参数(Argument)与块(Block) 参数 HCL 中的参数就是将一个值赋给一个特定的名称: image_id = \"abc123\" 等号前的标识符就是参数名,等号后的表达式就是参数值。参数赋值时 Terraform 会检查类型是否匹配。参数名是确定的,参数值可以是确定的字面量硬编码,也可以是一组表达式,用以通过其他的值加以计算得出结果值。 块 一个块是包含一组其他内容(参数和块)的容器,例如: resource \"aws_instance\" \"example\" { ami = \"abc123\" network_interface { # ... } } 一个块有一个类型(上面的例子里类型就是 resource)。每个块类型都定义了类型关键字后面要跟多少标签,例如 resource 块规定了后面要跟两个标签 —— 在例子里就是 aws_instance 和 example。一个块类型可以规定任意多个标签,也可以没有标签,比如内嵌的 network_interface 块。 在块类型及其后续标签之后,就是块体。块体必须被包含在一对花括号中间。在块体中可以进一步定义各种参数和其他的块。 Terraform 规范定义了有限个顶级块类型,也就是可以游离于任何其他块独立定义在配置文件中的块。大部分的 Terraform 功能(例如 resource, variable, output, data等)都是顶级块。 标识符(Identifiers) 参数名、块类型名以及其他 Terraform 规范中定义的结构的名称,例如 resource、variable 等,都是标识符。 合法的标识符可以包含字母、数字、下划线(_)以及连字符(-)。标识符首字母不可以为数字。 要了解完整的标识符规范,请访问 Unicode 标识符语法。 注释 Terraform支持三种注释: # 单行注释,其后的内容为注释 // 单行注释,其后的内容为注释 /* 和 */,多行注释,可以注释多行 默认情况下单行注释优先使用 #。自动化格式整理工具会自动把 // 替换成 #。 编码以及换行 Terraform 配置文件必须始终使用 UTF-8 编码。分隔符必须使用 ASCII 符号,其他标识符、注释以及字符串字面量均可使用非 ASCII 字符。 Terraform 兼容 Unix 风格的换行符(LF)以及 Windows 风格的换行符(CRLF),但是理想状态下应使用 Unix 风格换行符。 "},"3.Terraform代码的书写/3.输入变量.html":{"url":"3.Terraform代码的书写/3.输入变量.html","title":"输入变量","keywords":"","body":"输入变量 在前面的例子中,我们在代码中都是使用字面量硬编码的,如果我们想要在创建、修改基础设施时动态传入一些值呢?比如说在代码中定义 Provider 时用变量替代硬编码的访问密钥,或是由创建基础设施的用户来决定创建什么样尺寸的主机?我们需要的是输入变量。 如果我们把一组 Terraform 代码想像成一个函数,那么输入变量就是函数的入参。输入变量用 variable 块进行定义: variable \"image_id\" { type = string } variable \"availability_zone_names\" { type = list(string) default = [\"us-west-1a\"] } variable \"docker_ports\" { type = list(object({ internal = number external = number protocol = string })) default = [ { internal = 8300 external = 8300 protocol = \"tcp\" } ] } 这些都是合法的输入参数定义。紧跟 variable 关键字的就是变量名。在一个 Terraform 模块(同一个文件夹中的所有 Terraform 代码文件,不包含子文件夹)中变量名必须是唯一的。我们在代码中可以通过var.的方式引用变量的值。有一组关键字不可以被用作输入变量的名字: source version providers count for_each lifecycle depends_on locals 输入变量只能在声明该变量的目录下的代码中使用。 输入变量块中可以定义一些属性。 类型 (type) 可以在输入变量块中通过 type 定义类型,例如: variable \"name\" { type = string } variable \"ports\" { type = list(number) } 定义了类型的输入变量只能被赋予符合类型约束的值。 默认值 (default) 默认值定义了当 Terraform 无法获得一个输入变量得到值的时候会使用的默认值。例如: variable \"name\" { type = string default = \"John Doe\" } 当 Terraform 无法通过其他途径获得name的值时,var.name 的值为 \"John Doe\"。 描述 (description) 可以在输入变量中定义一个描述,简单地向调用者描述该变量的意义和用法: variable \"image_id\" { type = string description = \"The id of the machine image (AMI) to use for the server.\" } 如果在执行 terraform plan 或是 terraform apply 时 Terraform 不知道某个输入变量的值,Terraform 会在命令行界面上提示我们为输入变量设置一个值。例如上面的输入变量代码,执行 terraform apply 时: $ terraform apply var.image_id The id of the machine image (AMI) to use for the server. Enter a value: 为了使得代码的使用者能够准确理解输入变量的意义和用法,我们应该站在代码使用者而非代码维护者的角度编写输入变量的描述。描述并不是注释! 断言 (validation) 输入变量的断言是 Terraform 0.13.0 开始引入的新功能,在过去,Terraform 只能用类型约束确保输入参数的类型是正确的,曾经有不少人试图通过奇技淫巧来实现更加复杂的变量校验断言。如今 Terraform 终于正式添加了相关的功能。 variable \"image_id\" { type = string description = \"The id of the machine image (AMI) to use for the server.\" validation { condition = length(var.image_id) > 4 && substr(var.image_id, 0, 4) == \"ami-\" error_message = \"The image_id value must be a valid AMI id, starting with \\\"ami-\\\".\" } } condition 参数是一个 bool 类型的参数,我们可以用一个表达式来定义如何界定输入变量是合法的。当 condition 为 true 时输入变量合法,反之不合法。condition 表达式中只能通过 var.\\ 引用当前定义的变量,并且它的计算不能产生错误。 假如表达式的计算产生一个错误是输入变量验证的一种判定手段,那么可以使用 can 函数来判定表达式的执行是否抛错。例如: variable \"image_id\" { type = string description = \"The id of the machine image (AMI) to use for the server.\" validation { # regex(...) fails if it cannot find a match condition = can(regex(\"^ami-\", var.image_id)) error_message = \"The image_id value must be a valid AMI id, starting with \\\"ami-\\\".\" } } 上述例子中,如果输入的 image_id 不符合正则表达式的要求,那么 regex 函数调用会抛出一个错误,这个错误会被 can 函数捕获,输出 false。 condition 表达式如果为 false,Terraform 会返回 error_message 中定义的错误信息。error_message 应该完整描述输入变量校验失败的原因,以及输入变量的合法约束条件。 在命令行输出中隐藏值 (sensitive) 该功能于 Terraform v0.14.0 开始引入。 将变量设置为 sensitive 可以防止我们在配置文件中使用变量时 Terraform 在 plan 和 apply 命令的输出中展示与变量相关的值。 Terraform 仍然会将敏感数据记录在状态文件中,任何可以访问状态文件的人都可以读取到明文的敏感数据值。 声明一个变量包含敏感数据值需要将 sensitive 参数设置为 true: variable \"user_information\" { type = object({ name = string address = string }) sensitive = true } resource \"some_resource\" \"a\" { name = var.user_information.name address = var.user_information.address } 任何使用了敏感变量的表达式都将被视为敏感的,因此在上面的示例中,resource “some_resource” “a”的两个参数也将在计划输出中被隐藏: Terraform will perform the following actions: # some_resource.a will be created + resource \"some_resource\" \"a\" { + name = (sensitive) + address = (sensitive) } Plan: 1 to add, 0 to change, 0 to destroy. 在某些情况下,我们会在嵌套块中使用敏感变量,Terraform 可能会将整个块视为敏感的。这发生在那些包含有要求值是唯一的内嵌块的资源中,公开这种内嵌块的部分内容可能会暗示兄弟块的内容。 # some_resource.a will be updated in-place ~ resource \"some_resource\" \"a\" { ~ nested_block { # At least one attribute in this block is (or was) sensitive, # so its contents will not be displayed. } } Provider 还可以将资源属性声明为敏感属性,这将导致 Terraform 将其从常规输出中隐藏。 如果打算使用敏感值作为输出值的一部分,Terraform 将要求您将输出值本身标记为敏感值,以确认确实打算将其导出。 Terraform 可能暴露敏感变量的情况 sensitive 变量是一个以配置文件为中心的概念,值被不加混淆地发送给 Provider。如果该值被包含在错误消息中,则 Provider 报错时可能会暴露该值。例如,即使 \"foo\" 是敏感值,Provider 也可能返回以下错误:\"Invalid value 'foo' for field\" 如果将资源属性用作、或是作为 Provider 定义的资源 ID 的一部分,则 apply 将公开该值。在下面的示例中,前缀属性已设置为 sensitive 变量,但随后该值(\"jae\")作为资源 ID 的一部分公开: # random_pet.animal will be created + resource \"random_pet\" \"animal\" { + id = (known after apply) + length = 2 + prefix = (sensitive) + separator = \"-\" } Plan: 1 to add, 0 to change, 0 to destroy. ... random_pet.animal: Creating... random_pet.animal: Creation complete after 0s [id=jae-known-mongoose] 禁止输入变量为空 (nullable) 该功能自 Terraform v1.1.0 开始被引入 输入变量的 nullable 参数控制模块调用者是否可以将 null 赋值给变量。 variable \"example\" { type = string nullable = false } nullable 的默认值为 true。当 nullable 为 true 时,null 是变量的有效值,并且模块代码必须始终考虑变量值为 null 的可能性。将 null 作为模块输入参数传递将覆盖输入变量上定义的默认值。 将 nullable 设置为 false 可确保变量值在模块内永远不会为空。如果 nullable 为 false 并且输入变量定义有默认值,则当模块输入参数为 null 时,Terraform 将使用默认值。 nullable 参数仅控制变量的直接值可能为 null 的情况。对于集合或对象类型的变量,例如列表或对象,调用者仍然可以在集合元素或属性中使用 null,只要集合或对象本身不为 null。 对输入变量赋值 命令行参数 对输入变量赋值有几种途径,一种是在调用 terraform plan 或是 terraform apply 命令时以参数的形式传入: $ terraform apply -var=\"image_id=ami-abc123\" $ terraform apply -var='image_id_list=[\"ami-abc123\",\"ami-def456\"]' $ terraform plan -var='image_id_map={\"us-east-1\":\"ami-abc123\",\"us-east-2\":\"ami-def456\"}' 可以在一条命令中使用多个 -var 参数。 参数文件 第二种方法是使用参数文件。参数文件的后缀名可以是 .tfvars 或是 .tfvars.json。.tfvars 文件使用 HCL 语法,.tfvars.json 使用 JSON 语法。 以 .tfvars 为例,参数文件中用 HCL 代码对需要赋值的参数进行赋值,例如: image_id = \"ami-abc123\" availability_zone_names = [ \"us-east-1a\", \"us-west-1c\", ] 后缀名为 .tfvars.json 的文件用一个 JSON 对象来对输入变量赋值,例如: { \"image_id\": \"ami-abc123\", \"availability_zone_names\": [\"us-west-1a\", \"us-west-1c\"] } 调用 terraform 命令时,通过 -var-file 参数指定要用的参数文件,例如: terraform apply -var-file=\"testing.tfvars\" terraform apply -var-file=\"testing.tfvars.json\" 有两种情况,你无需指定参数文件: 当前模块内有名为 terraform.tfvars 或是 terraform.tfvars.json 的文件 当前模块内有一个或多个后缀名为 .auto.tfvars 或是 .auto.tfvars.json 的文件 Terraform 会自动使用这两种自动参数文件对输入参数赋值。 环境变量 可以通过设置名为 TF_VAR_ 的环境变量为输入变量赋值,例如: $ export TF_VAR_image_id=ami-abc123 $ terraform plan ... 在环境变量名大小写敏感的操作系统上,Terraform 要求环境变量中的 与 Terraform 代码中定义的输入变量名大小写完全一致。 环境变量传值非常适合在自动化流水线中使用,尤其适合用来传递敏感数据,类似密码、访问密钥等。 交互界面传值 在前面介绍断言的例子中我们看到过,当我们从命令行界面执行 terraform 操作,Terraform 无法通过其他途径获取一个输入变量的值,而该变量也没有定义默认值时,Terraform 会进行最后的尝试,在交互界面上要求我们给出变量值。 输入变量赋值优先级 当上述的赋值方式同时存在时,同一个变量可能会被赋值多次。Terraform 会使用新值覆盖旧值。 Terraform 加载变量值的顺序是: 环境变量 terraform.tfvars 文件(如果存在的话) terraform.tfvars.json 文件(如果存在的话) 所有的 .auto.tfvars 或者 .auto.tfvars.json 文件,以字母顺序排序处理 通过 -var 或是 -var-file 命令行参数传递的输入变量,按照在命令行参数中定义的顺序加载 假如以上方式均未能成功对变量赋值,那么 Terraform 会尝试使用默认值;对于没有定义默认值的变量,Terraform 会采用交互界面方式要求用户输入一个。对于某些 Terraform 命令,如果执行时带有 -input=false 参数禁用了交互界面传值方式,那么就会报错。 重要提示:在 Terraform 0.12 及更高版本中,类型为 map 或 object 的输入变量的读取行为与其他变量相同:后找到的值会覆盖之前的值。这与 Terraform 的早期版本不同,早期版本会合并 map,而不是覆盖它们。 Terraform 测试中的输入变量值 在 Terraform 测试文件中,您可以在 variable 块中指定变量值,这些 variable 块可以嵌套在 run 块中,也可以直接在文件中定义。 以这种方式定义的变量在测试执行期间优先于所有其他机制,其中在 run 块中定义的变量优先于在文件中定义的变量。 复杂类型传值 通过参数文件传值时,可以直接使用 HCL 或是 JSON 语法对复杂类型传值,例如 list 或 map。 对于某些场景下必须使用 -var 命令行参数,或是环境变量传值时,可以用单引号引用 HCL 语法的字面量来定义复杂类型,例如: export TF_VAR_availability_zone_names='[\"us-west-1b\",\"us-west-1d\"]' 由于采用这种方法需要手工处理引号的转义,所以这种方法比较容易出错,复杂类型的传值建议尽量通过参数文件。 "},"3.Terraform代码的书写/4.输出值.html":{"url":"3.Terraform代码的书写/4.输出值.html","title":"输出值","keywords":"","body":"输出值 我们在介绍输入变量时提到过,如果我们把一组 Terraform 代码想像成一个函数,那么输入变量就是函数的入参;函数可以有入参,也可以有返回值,同样的,Terraform 代码也可以有返回值,这就是输出值。 大部分语言的的函数只支持无返回值或是单返回值,但是 Terraform 支持多返回值。在当前模块 apply 一段 Terraform 代码,运行成功后命令行会输出代码中定义的返回值。另外我们也可以通过 terraform output 命令来输出当前模块对应的状态文件中的返回值。 输出值的声明 输出值的声明使用输出块,例如: output \"instance_ip_addr\" { value = aws_instance.server.private_ip } output 关键字后紧跟的就是输出值的名称。在当前模块内的所有输出值的名字都必须是唯一的。output 块内的 value 参数即为输出值,它可以像是上面的例子里那样某个 resource 的输出属性,也可以是任意合法的表达式。 输出值只有在执行 terraform apply 后才会被计算,光是执行 terraform plan 并不会计算输出值。 Terraform 代码中无法引用本目录下定义的输出值。 output 块还有一些可选的属性: 描述 description output \"instance_ip_addr\" { value = aws_instance.server.private_ip description = \"The private IP address of the main server instance.\" } 与输入变量的description类似,我们不再赘述。 在命令行输出中隐藏值 sensitive 一个输出值可以标记 sensitive 为 true,表示该输出值含有敏感信息。被标记 sensitive 的输出值只是在执行 terraform apply 命令成功后会打印 \"\" 以取代真实的输出值,执行 terraform output 时也会输出\"\",但仍然可以通过执行 terraform output -json 看到实际的敏感值。 需要注意的是,标记为 sensitive 输出值仍然会被记录在状态文件中,任何有权限读取状态文件的人仍然可以读取到敏感数据。 depends_on 关于 depends_on 的内容将在 resource 章节里详细介绍,所以这里我们只是粗略地介绍一下。 Terraform 会解析代码所定义的各种 data、resource,以及他们之间的依赖关系,例如,创建虚拟机时用的 image_id 参数是通过 data 查询而来的,那么虚拟机实例就依赖于这个镜像的 data,Terraform 会首先创建 data,得到查询结果后,再创建虚拟机 resource。一般来说,data、resource 之间的创建顺序是由 Terraform 自动计算的,不需要代码的编写者显式指定。但有时有些依赖关系无法通过分析代码得出,这时我们可以在代码中通过 depends_on 显式声明依赖关系。 一般 output 很少会需要显式依赖某些资源,但有一些特殊场景,例如某些资源的属性必须在另外一些资源被创建后才能被读取,这种情况下我们可以通过 depends_on 来显式声明依赖关系。 depends_on 的用法如下: output \"instance_ip_addr\" { value = aws_instance.server.private_ip description = \"The private IP address of the main server instance.\" depends_on = [ # Security group rule must be created before this IP address could # actually be used, otherwise the services will be unreachable. aws_security_group_rule.local_access, ] } 我们不鼓励针对 output 定义depends_on,只能作为最后的手段加以应用。如果不得不针对 output 定义depends_on,请务必通过注释说明原因,方便后人进行维护。 断言 precondition output 块从 Terraform v1.2.0 开始也可以包含一个 precondition 块。 output 块上的 precondition 对应于 variable 块中的 validation 块。validation 块检查输入变量值是否符合模块的要求,precondition 则确保模块的输出值满足某种要求。我们可以通过 precondition 来防止 Terraform 把一个不合法的输入值写入状态文件。我们可以在合适的场景下通过 precondition 来保护上一次 apply 留下的合法的输出值。 Terraform 在计算输出值的 value 表达式之前执行 precondition 检查,这可以防止 value 表达式中的潜在错误被激发。 "},"3.Terraform代码的书写/5.局部值.html":{"url":"3.Terraform代码的书写/5.局部值.html","title":"局部值","keywords":"","body":"局部值 有时我们会需要用一个比较复杂的表达式计算某一个值,并且反复使用之,这时我们把这个复杂表达式赋予一个局部值,然后反复引用该局部值。如果说输入变量相当于函数的入参,输出值相当于函数的返回值,那么局部值就相当于函数内定义的局部变量。 局部值通过 locals 块定义,例如: locals { service_name = \"forum\" owner = \"Community Team\" } 一个 locals 块可以定义多个局部值,也可以定义任意多个 locals 块。赋给局部值的可以是更复杂的表达式,也可以是其他 data、resource 的输出、输入变量,甚至是其他的局部值: locals { # Ids for multiple sets of EC2 instances, merged together instance_ids = concat(aws_instance.blue.*.id, aws_instance.green.*.id) } locals { # Common tags to be assigned to all resources common_tags = { Service = local.service_name Owner = local.owner } } 引用局部值的表达式是 local. (注意,虽然局部值定义在 locals 块内,但引用是务必使用 local 而不是 locals),例如: resource \"aws_instance\" \"example\" { # ... tags = local.common_tags } 局部值只能在同一模块内的代码中引用。 局部值可以帮助我们避免重复复杂的表达式,提升代码的可读性,但如果过度使用也有可能增加代码的复杂度,使得代码的维护者更难理解所使用的表达式和值。适度使用局部值,仅用于反复引用同一复杂表达式的场景,未来当我们需要修改该表达式时局部值将使得修改变得相当轻松。 "},"3.Terraform代码的书写/6.资源.html":{"url":"3.Terraform代码的书写/6.资源.html","title":"资源","keywords":"","body":"资源 资源是 Terraform 最重要的组成部分,而本节亦是本教程最重要的一节。资源通过 resource 块来定义,一个 resource 可以定义一个或多个基础设施资源对象,例如 VPC、虚拟机,或是 DNS 记录、Consul 的键值对数据等。 资源语法 资源通过 resource 块定义,我们首先讲解通过 resource 块定义单个资源对象的场景。 resource \"aws_vpc\" \"main\" { cidr_block = var.base_cidr_block } \"\" \"\" { # Block body = # Argument } 块 是其他内容的容器,通常代表某种对象的配置,比如资源。块有一个块类型,可以有零个或多个标签,有一个包含任意数量的参数和嵌套块的块体。Terraform 的大部分功能都是由配置文件中的顶级块控制的。 参数 为一个名称赋值。它们出现在块内。 表达式 表示一个值,可以是字面量,也可以是引用和组合其他值。它们出现在参数的值中,或者在其他表达式中。 Terraform 是一种声明式语言,描述的是一个期望的资源状态,而不是达到期望状态所需要的步骤。块的顺序和它们所在的文件通常不重要;Terraform 只在确定操作顺序时考虑资源之间的隐式和显式关系。 在下面的例子里: resource \"aws_instance\" \"web\" { ami = \"ami-a1b2c3d4\" instance_type = \"t2.micro\" } 紧跟 resource 关键字的是资源类型,在上面的例子里就是 aws_instance。后面是资源的 Local Name,例子里就是 web。Local Name 可以在同一模块内的代码里被用来引用该资源,但类型加 Local Name 的组合在当前模块内必须是唯一的,不同类型的两个资源 Local Name 可以相同。随后的花括号内的内容就是块体,创建资源所用到的各种参数的值就在块体内定义。例子中我们定义了虚拟机所使用的镜像 id 以及虚拟机的尺寸。 请注意:资源名称必须以字母或下划线开头,只能包含字母、数字、下划线(_)和连字符(-)。 资源类型 每个资源都与一个资源类型相关联,资源类型决定了它管理的基础设施对象的类型,以及资源支持的参数和其他属性。 Providers Provider 是 Terraform 用以提供一组资源类型的插件。每个资源类型都是由一个 Provider 实现的。Provider 提供了管理单个云或本地基础设施平台的资源。Provider 与 Terraform 分开发布,但 Terraform 可以在初始化工作目录时自动安装大多数 Provider。 要管理资源,Terraform 模块必须指定所需的 Provider。有关更多信息,请参阅Provider 的声明。 大部分 Provider 需要一些配置来访问远程 API,这些配置是在根模块中配置的。有关更多信息,请参阅Provider 配置。 根据一个 resource 块的类型名,Terraform 通常可以确定使用哪个 Provider。按照约定,资源类型名以其 Provider 的首选 Local Name 开头。当使用一个 Provider 的多个配置或非首选的本地 Provider 名称时,你必须使用 provider 元参数 来手动选择一个 Provider 配置。 资源参数 不同资源定义了不同的可赋值的属性,官方文档将之称为参数(Argument),有些参数是必填的,有些参数是可选的。使用某项资源前可以通过阅读相关文档了解参数列表以及他们的含义、赋值的约束条件。 参数值可以是简单的字面量,也可以是一个复杂的表达式。 资源类型的文档 每一个 Terraform Provider 都有自己的文档,用以描述它所支持的资源类型种类,以及每种资源类型所支持的属性列表。 大部分公共的 Provider 都是通过 Terraform Registry 连带文档一起发布的。当我们在 Terraform Registry 站点上浏览一个 Provider 的页面时,我们可以点击 \"Documentation\" 链接来浏览相关文档。Provider 的文档都是版本化的,我们可以选择特定版本的 Provider 文档。 需要注意的是,Provider 文档曾经是直接托管在 terraform.io 站点上的,也就是 Terraform 核心主站的一部分,有些 Provider 的文档目前依然托管在那里,但目前 Terraform Registry 才是所有公共 Provider 文档的主站。 资源的行为 一个 resource 块声明了作者想要创建的一个确切的基础设施对象,并且设定了各项属性的值。如果我们正在编写一个新的 Terraform 代码文件,那么代码所定义的资源仅仅只在代码中存在,并没有与之对应的实际的基础设施资源存在。 对一组 Terraform 代码执行 terraform apply 可以创建、更新或者销毁实际的基础设施对象,Terraform 会制定并执行变更计划,以使得实际的基础设施符合代码的定义。 每当 Terraform 按照一个 resource 块创建了一个新的基础设施对象,这个实际的对象的 id 会被保存进 Terraform 状态中,使得将来 Terraform 可以根据变更计划对它进行更新或是销毁操作。如果一个 resource 块描述的资源在状态文件中已有记录,那么 Terraform 会比对记录的状态与代码描述的状态,如果有必要,Terraform 会制定变更计划以使得资源状态能够符合代码的描述。 这种行为适用于所有资源而无关其类型。创建、更新、销毁一个资源的细节会根据资源类型而不同,但是这个行为规则却是普适的。 访问资源输出属性 资源不但可以通过参数传值,成功创建的资源还对外输出一些通过调用 API 才能获得的只读数据,经常包含了一些我们在实际创建一个资源之前无法获知的数据,比如云主机的 id 等,官方文档将之称为属性(Attribute)。我们可以在同一模块内的代码中引用资源的属性来创建其他资源或是表达式。在表达式中引用资源属性的语法是..。 要获取一个资源类型输出的属性列表,我们可以查阅对应的 Provider 文档,一般在文档中会专门记录资源的输出属性列表。 敏感的资源属性 在为资源类型定义架构时,Provider 开发着可以将某些属性标记为 sensitive,在这种情况下,Terraform 将在展示涉及该属性的计划时显示占位符标记(sensitive) 而不是实际值。 标记为 sensitive 的 Provider 属性的行为类似于声明为 sensitive 的输入变量,Terraform 将隐藏计划中的值,还将隐藏从该值派生出的任何其他敏感值。但是,该行为存在一些限制,如 Terraform 可能暴露敏感变量。 如果使用资源属性中的敏感值作为输出值的一部分,Terraform 将要求将输出值本身标记为 sensitive,以确认确实打算将其导出。 Terraform 仍会在状态中记录敏感值,因此任何可以访问状态数据的人都可以以明文形式访问敏感值。 注意:Terraform 从 v0.15 开始将从敏感资源属性派生的值视为敏感值本身。早期版本的 Terraform 将隐藏敏感资源属性的直接值,但不会自动隐藏从敏感资源属性派生的其他值。 资源的依赖关系 我们在介绍输出值的depends_on的时候已经简单介绍过了依赖关系。一般来说在 Terraform 代码定义的资源之间不会有特定的依赖关系,Terraform 可以并行地对多个无依赖关系的资源执行变更,默认情况下这个并行度是 10。 然而,创建某些资源所需要的信息依赖于另一个资源创建后输出的属性,又或者必须在某些资源成功创建后才可以被创建,这时资源之间就存在依赖关系。 大部分资源间的依赖关系可以被 Terraform 自动处理,Terraform 会分析 resource 块内的表达式,根据表达式的引用链来确定资源之间的引用,进而计算出资源在创建、更新、销毁时的执行顺序。大部分情况下,我们不需要显式指定资源之间的依赖关系。 然而,有时候某些依赖关系是无法从代码中推导出来的。例如,Terraform 必须要创建一个访问控制权限资源,以及另一个需要该权限才能成功创建的资源。后者的创建依赖于前者的成功创建,然而这种依赖在代码中没有表现为数据引用关联,这种情况下,我们需要用 depends_on 来显式声明这种依赖关系。 元参数 resource 块支持几种元参数声明,这些元参数可以被声明在所有类型的 resource 块内,它们将会改变资源的行为: depends_on:显式声明依赖关系 count:创建多个资源实例 for_each:迭代集合,为集合中每一个元素创建一个对应的资源实例 provider:指定非默认 Provider 实例 lifecycle:自定义资源的生命周期行为 provisioner 和 connection:在资源创建后执行一些额外的操作 下面我们将逐一讲解他们的用法。 depends_on 使用 depends_on 可以显式声明资源之间哪些 Terraform 无法自动推导出的隐含的依赖关系。只有当资源间确实存在依赖关系,但是彼此间又没有数据引用的场景下才有必要使用 depends_on。 使用 depends_on 的例子是这样的: resource \"aws_iam_role\" \"example\" { name = \"example\" # assume_role_policy is omitted for brevity in this example. See the # documentation for aws_iam_role for a complete example. assume_role_policy = \"...\" } resource \"aws_iam_instance_profile\" \"example\" { # Because this expression refers to the role, Terraform can infer # automatically that the role must be created first. role = aws_iam_role.example.name } resource \"aws_iam_role_policy\" \"example\" { name = \"example\" role = aws_iam_role.example.name policy = jsonencode({ \"Statement\" = [{ # This policy allows software running on the EC2 instance to # access the S3 API. \"Action\" = \"s3:*\", \"Effect\" = \"Allow\", }], }) } resource \"aws_instance\" \"example\" { ami = \"ami-a1b2c3d4\" instance_type = \"t2.micro\" # Terraform can infer from this that the instance profile must # be created before the EC2 instance. iam_instance_profile = aws_iam_instance_profile.example # However, if software running in this EC2 instance needs access # to the S3 API in order to boot properly, there is also a \"hidden\" # dependency on the aws_iam_role_policy that Terraform cannot # automatically infer, so it must be declared explicitly: depends_on = [ aws_iam_role_policy.example, ] } 我们来分段解释一下这个场景,首先我们声明了一个 AWS IAM 角色,将角色绑定在一个主机实例配置文件上: resource \"aws_iam_role\" \"example\" { name = \"example\" # assume_role_policy is omitted for brevity in this example. See the # documentation for aws_iam_role for a complete example. assume_role_policy = \"...\" } resource \"aws_iam_instance_profile\" \"example\" { # Because this expression refers to the role, Terraform can infer # automatically that the role must be created first. role = aws_iam_role.example.name } 虚拟机的声明代码中的这个赋值使得 Terraform 能够判断出虚拟机依赖于主机实例配置文件: resource \"aws_instance\" \"example\" { ami = \"ami-a1b2c3d4\" instance_type = \"t2.micro\" # Terraform can infer from this that the instance profile must # be created before the EC2 instance. iam_instance_profile = aws_iam_instance_profile.example 至此,Terraform 规划出的创建顺序是 IAM 角色 -> 主机实例配置文件 -> 主机实例。但是我们又为这个 IAM 角色添加了对 S3 存储服务的完全控制权限: resource \"aws_iam_role_policy\" \"example\" { name = \"example\" role = aws_iam_role.example.name policy = jsonencode({ \"Statement\" = [{ # This policy allows software running on the EC2 instance to # access the S3 API. \"Action\" = \"s3:*\", \"Effect\" = \"Allow\", }], }) } 也就是说,虚拟机实例由于绑定了主机实例配置文件,从而在运行时拥有了一个 IAM 角色,而这个 IAM 角色又被赋予了 S3 的权限。但是虚拟机实例的声明代码中并没有引用 S3 权限的任何输出属性,这将导致 Terraform 无法理解他们之间存在依赖关系,进而可能会并行地创建两者,如果虚拟机实例被先创建了出来,内部的程序开始运行时,它所需要的 S3 权限却还没有创建完成,那么就将导致程序运行错误。为了确保虚拟机创建时 S3 权限一定已经存在,我们可以用 depends_on 显式声明它们的依赖关系: # However, if software running in this EC2 instance needs access # to the S3 API in order to boot properly, there is also a \"hidden\" # dependency on the aws_iam_role_policy that Terraform cannot # automatically infer, so it must be declared explicitly: depends_on = [ aws_iam_role_policy.example, ] depends_on 的赋值必须是包含同一模块内声明的其他资源名称的列表,不允许包含其他表达式,例如不允许使用其他资源的输出属性,这是因为 Terraform 必须在计算资源间关系之前就能理解列表中的值,为了能够安全地完成表达式计算,所以限制只能使用资源实例的名称。 depends_on 只能作为最后的手段使用,如果我们使用 depends_on,我们应该用注释记录我们使用它的原因,以便今后代码的维护者能够理解隐藏的依赖关系。 count 一般来说,一个 resource 块定义了一个对应的实际基础设施资源对象。但是有时候我们希望创建多个相似的对象,比如创建一组虚拟机。Terraform 提供了两种方法实现这个目标:count 与 for_each。 count 参数可以是任意自然数,Terraform 会创建 count 个资源实例,每一个实例都对应了一个独立的基础设施对象,并且在执行 Terraform 代码时,这些对象是被分别创建、更新或者销毁的: resource \"aws_instance\" \"server\" { count = 4 # create four similar EC2 instances ami = \"ami-a1b2c3d4\" instance_type = \"t2.micro\" tags = { Name = \"Server ${count.index}\" } } 我们可以在 resource 块中的表达式里使用 count 对象来获取当前的 count 索引号。count 对象只有一个属性: count.index:代表当前对象对应的 count 下标索引(从 0 开始) 如果一个 resource 块定义了 count 参数,那么 Terraform 会把这种多资源实例对象与没有 count 参数的单实例资源对象区别开: 访问单资源实例对象:.(例如:aws_instance.server) 访问多资源实例对象:.[] (例如:aws_instance.server[0],aws_instance.server[1]) 声明了 count 或 for_each 的资源必须使用下标索引或者键来访问。 count 参数可以是任意自然数,然而与 resource 的其他参数不同,count 的值在 Terraform 进行任何远程资源操作(实际的增删改查)之前必须是已知的,这也就意味着赋予 count 参数的表达式不可以引用任何其他资源的输出属性(例如由其他资源对象创建时返回的一个唯一的 ID)。 for_each for_each 是 Terraform 0.12.6 开始引入的新特性。一个 resource 块不允许同时声明 count 与 for_each。for_each 参数可以是一个 map 或是一个 set(string),Terraform 会为集合中每一个元素都创建一个独立的基础设施资源对象,和 count 一样,每一个基础设施资源对象在执行 Terraform 代码时都是独立创建、修改、销毁的。 使用 map 的例子: resource \"azurerm_resource_group\" \"rg\" { for_each = { a_group = \"eastus\" another_group = \"westus2\" } name = each.key location = each.value } 使用 set(string) 的例子: resource \"aws_iam_user\" \"the-accounts\" { for_each = toset( [\"Todd\", \"James\", \"Alice\", \"Dottie\"] ) name = each.key } 我们可以在声明了 for_each 参数的 resource 块内使用 each 对象来访问当前的迭代器对象: each.key:map 的键,或是 set 中的值 each.value:map 的值,或是 set 中的值 如果 for_each 的值是一个 set,那么 each.key 和 each.value 是相等的。 使用 for_each 时,map 的所有键、set 的所有 string 值都必须是已知的,也就是状态文件中已有记录的值。所以有时候我们可能需要在执行 terraform apply 时添加 -target 参数,实现分步创建。另外,for_each 所使用的键集合不能够包含或依赖非纯函数,也就是反复执行会返回不同返回值的函数,例如 uuid、bcrypt、timestamp 等。 当一个 resource 声明了 for_each 时,Terraform 会把这种多资源实例对象与没有 count 参数的单资源实例对象区别开: 访问单资源实例对象:.(例如:aws_instance.server) 访问多资源实例对象:.[] (例如:aws_instance.server[\"ap-northeast-1\"],aws_instance.server[\"ap-northeast-2\"]) 声明了count或 for_each 的资源必须使用下标索引或者键来访问。 由于 Terraform 没有用以声明 set 的字面量,所以我们有时需要使用 toset 函数把 list(string) 转换为 set(string): locals { subnet_ids = toset([ \"subnet-abcdef\", \"subnet-012345\", ]) } resource \"aws_instance\" \"server\" { for_each = local.subnet_ids ami = \"ami-a1b2c3d4\" instance_type = \"t2.micro\" subnet_id = each.key # note: each.key and each.value are the same for a set tags = { Name = \"Server ${each.key}\" } } 在这里我们用 toset 把一个 list(string) 转换成了 set(string),然后赋予 for_each。在转换过程中,list 中所有重复的元素会被抛弃,只剩下不重复的元素,例如 toset([\"b\", \"a\", \"b\"]) 的结果只有\"a\"和\"b\",并且 set 的元素没有特定顺序。 如果我们要把一个输入变量赋予 for_each,我们可以直接定义变量的类型约束来避免显式调用 toset 转换类型: variable \"subnet_ids\" { type = set(string) } resource \"aws_instance\" \"server\" { for_each = var.subnet_ids # (and the other arguments as above) } 在 for_each 和 count 之间选择 如果创建的资源实例彼此之间几乎完全一致,那么 count 比较合适。如果彼此之间的参数差异无法直接从 count 的下标派生,那么使用 for_each 会更加安全。 在 Terraform 引入 for_each 之前,我们经常使用 count.index 搭配 length 函数和 list 来创建多个资源实例: variable \"subnet_ids\" { type = list(string) } resource \"aws_instance\" \"server\" { # Create one instance for each subnet count = length(var.subnet_ids) ami = \"ami-a1b2c3d4\" instance_type = \"t2.micro\" subnet_id = var.subnet_ids[count.index] tags = { Name = \"Server ${count.index}\" } } 这种实现方法是脆弱的,因为资源仍然是以他们的下标而不是实际的字符串值来区分的。如果我们从 subnet_ids 列表的中间移除了一个元素,那么从该位置起后续所有的 aws_instance 都会发现它们的 subnet_id 发生了变化,结果就是所有后续的 aws_instance 都需要更新。这种场景下如果使用 for_each 就更为妥当,如果使用 for_each,那么只有被移除的 subnet_id 对应的 aws_instance 会被销毁。 provider 关于 provider 的定义我们在前面介绍 Provider 的章节已经提到过了,如果我们声明了同一类型 Provider 的多个实例,那么我们在创建资源时可以通过指定 provider 参数选择要使用的 Provider 实例。如果没有指定 provider 参数,那么 Terraform 默认使用资源类型名中第一个单词所对应的 Provider 实例,例如 google_compute_instance 的默认 Provider 实例就是 google,aws_instance 的默认 Provider 就是 aws。 指定 provider 参数的例子: # default configuration provider \"google\" { region = \"us-central1\" } # alternate configuration, whose alias is \"europe\" provider \"google\" { alias = \"europe\" region = \"europe-west1\" } resource \"google_compute_instance\" \"example\" { # This \"provider\" meta-argument selects the google provider # configuration whose alias is \"europe\", rather than the # default configuration. provider = google.europe # ... } provider参数期待的赋值是或是.,不需要双引号。因为在Terraform开始计算依赖路径图时,provider关系必须是已知的,所以除了这两种以外的表达式是不被接受的。 lifecycle 通常一个资源对象的生命周期在前面“资源的行为”一节中已经描述了,但是我们可以用 lifecycle 块来定一个不一样的行为方式,例如: resource \"azurerm_resource_group\" \"example\" { # ... lifecycle { create_before_destroy = true } } lifecycle 块和它的内容都属于元参数,可以被声明于任意类型的资源块内部。Terraform 支持如下几种 lifecycle: create_before_destroy (bool):默认情况下,当 Terraform 需要修改一个由于服务端 API 限制导致无法直接升级的资源时,Terraform 会删除现有资源对象,然后用新的配置参数创建一个新的资源对象取代之。create_before_destroy 参数可以修改这个行为,使得 Terraform 首先创建新对象,只有在新对象成功创建并取代老对象后再销毁老对象。这并不是默认的行为,因为许多基础设施资源需要有一个唯一的名字或是别的什么标识属性,在新老对象并存时也要符合这种约束。有些资源类型有特别的参数可以为每个对象名称添加一个随机的前缀以防止冲突。Terraform 不能默认采用这种行为,所以在使用 create_before_destroy 前你必须了解每一种资源类型在这方面的约束。 prevent_destroy (bool):这个参数是一个保险措施,只要它被设置为 true 时,Terraform 会拒绝执行任何可能会销毁该基础设施资源的变更计划。这个参数可以预防意外删除关键资源,例如错误地执行了 terraform destroy,或者是意外修改了资源的某个参数,导致 Terraform 决定删除并重建新的资源实例。在 resource 块内声明了 prevent_destroy = true 会导致无法执行 terraform destroy,所以对它的使用要节制。需要注意的是,该措施无法防止我们删除 resource 块后 Terraform 删除相关资源,因为对应的 prevent_destroy = true 声明也被一并删除了。 ignore_changes (list(string)):默认情况下,Terraform 检测到代码描述的配置与真实基础设施对象之间有任何差异时都会计算一个变更计划来更新基础设施对象,使之符合代码描述的状态。在一些非常罕见的场景下,实际的基础设施对象会被 Terraform 之外的流程所修改,这就会使得 Terraform 不停地尝试修改基础设施对象以弥合和代码之间的差异。这种情况下,我们可以通过设定 ignore_changes 来指示 Terraform 忽略某些属性的变更。ignore_changes 的值定义了一组在创建时需要按照代码定义的值来创建,但在更新时不需要考虑值的变化的属性名,例如: resource \"aws_instance\" \"example\" { # ... lifecycle { ignore_changes = [ # Ignore changes to tags, e.g. because a management agent # updates these based on some ruleset managed elsewhere. tags, ] } } 你也可以忽略 map 中特定的元素,例如 tags[\"Name\"],但是要注意的是,如果你是想忽略 map 中特定元素的变更,那么你必须首先确保 map 中含有这个元素。如果一开始 map 中并没有这个键,而后外部系统添加了这个键,那么 Terraform 还是会把它当成一次变更来处理。比较好的方法是你在代码中先为这个键创建一个占位元素来确保这个键已经存在,这样在外部系统修改了键对应的值以后 Terraform 会忽略这个变更。 resource \"aws_instance\" \"example\" { # ... tags = { # Initial value for Name is overridden by our automatic scheduled # re-tagging process; changes to this are ignored by ignore_changes # below. Name = \"placeholder\" } lifecycle { ignore_changes = [ tags[\"Name\"], ] } } 除了使用一个 list(string),也可以使用关键字 all ,这时 Terraform 会忽略资源一切属性的变更,这样 Terraform 只会创建或销毁一个对象,但绝不会尝试更新一个对象。你只能在 ignore_changes 里忽略所属的 resource 的属性,ignore_changes 不可以赋予它自身或是其他任何元参数。 replace_triggered_by (包含资源引用的列表):强制 Terraform 在引用的资源或是资源属性发生变更时替换声明该块的父资源,值为一个包含了托管资源、实例或是实例属性引用表达式的列表。当声明该块的资源声明了 count 或是 for_each 时,我们可以在表达式中使用 count.index 或是 each.key 来指定引用实例的序号。 replace_triggered_by 可以在以下几种场景中使用: 如果表达式指向多实例的资源声明(例如声明了 count 或是 for_each 的资源),那么这组资源中任意实例发生变更或被替换时都将引发声明 replace_triggered_by 的资源被替换 如果表达式指向单个资源实例,那么该实例发生变更或被替换时将引发声明 replace_triggered_by 的资源被替换 如果表达式指向单个资源实例的单个属性,那么该属性值的任何变化都将引发声明 replace_triggered_by 的资源被替换 我们在 replace_triggered_by 中只能引用托管资源。这允许我们在不引发强制替换的前提下修改这些表达式。 resource \"aws_appautoscaling_target\" \"ecs_target\" { # ... lifecycle { replace_triggered_by = [ # Replace `aws_appautoscaling_target` each time this instance of # the `aws_ecs_service` is replaced. aws_ecs_service.svc.id ] } } lifecycle 配置影响了 Terraform 如何构建并遍历依赖图。作为结果,lifecycle 内赋值仅支持字面量,因为它的计算过程发生在 Terraform 计算的极早期。这就是说,例如 prevent_destroy、create_before_destroy 的值只能是 true 或者 false,ignore_changes、replace_triggered_by 的列表内只能是硬编码的属性名。 Precondition 与 Postcondition 请注意,Precondition 与 Postcondition 是从 Terraform v1.2.0 开始被引入的功能。 在 lifecycle 块中声明 precondition 与 postcondition 块可以为资源、数据源以及输出值创建自定义的验证规则。 Terraform 在计算一个对象之前会首先检查该对象关联的 precondition,并且在对象计算完成后执行 postcondition 检查。Terraform 会尽可能早地执行自定义检查,但如果表达式中包含了只有在 apply 阶段才能知晓的值,那么该检查也将被推迟执行。 每一个 precondition 与 postcondition 块都需要一个 condition 参数。该参数是一个表达式,在满足条件时返回 true,否则返回 false。该表达式可以引用同一模块内的任意其他对象,只要这种引用不会产生环依赖。在 postcondition 表达式中也可以使用 self 对象引用声明 postcondition 的资源实例的属性。 如果 condition 表达式计算结果为 false,Terraform 会生成一条错误信息,包含了 error_message 表达式的内容。如果我们声明了多条 precondition 或 postcondition,Terraform 会返回所有失败条件对应的错误信息。 下面的例子演示了通过 postcondition 检测调用者是否不小心传入了错误的 AMI 参数: data \"aws_ami\" \"example\" { id = var.aws_ami_id lifecycle { # The AMI ID must refer to an existing AMI that has the tag \"nomad-server\". postcondition { condition = self.tags[\"Component\"] == \"nomad-server\" error_message = \"tags[\\\"Component\\\"] must be \\\"nomad-server\\\".\" } } } 在 resource 或 data 块中的 lifecycle 块可以同时包含 precondition 与 postcondition 块。 Terraform 会在计算完 count 和 for_each 元参数后执行 precondition 块。这使得 Terraform 可以对每一个实例独立进行检查,并允许在表达式中使用 each.key、count.index 等。Terraform 还会在计算资源的参数表达式之前执行 precondition 检查。precondition 可以用来防止参数表达式计算中的错误被激发。 Terraform 在计算和执行对一个托管资源的变更之后执行 postcondition 检查,或是在完成数据源读取后执行它关联的 postcondition 检查。postcondition 失败会阻止其他依赖于此失败资源的其他资源的变更。 在大多数情况下,我们不建议在同一配置文件中同时包含表示同一个对象的 data 块和 resource 块。这样做会使得 Terraform 无法理解 data 块的结果会被 resource 块的变更所影响。然而,当我们需要检查一个 resource 块的结果,恰巧该结果又没有被资源直接输出时,我们可以使用 data 块并在块中直接使用 postcondition 来检查该对象。这等于告诉 Terraform 该 data 块是用来检查其他什么地方定义的对象的,从而允许 Terraform 以正确的顺序执行操作。 provisioner 和 connection 某些基础设施对象需要在创建后执行特定的操作才能正式工作。比如说,主机实例必须在上传了配置或是由配置管理工具初始化之后才能正常工作。 像这样创建后执行的操作可以使用预置器(Provisioner)。预置器是由 Terraform 所提供的另一组插件,每种预置器可以在资源对象创建后执行不同类型的操作。 使用预置器需要节制,因为他们采取的操作并非 Terraform 声明式的风格,所以 Terraform 无法对他们执行的变更进行建模和保存。 预置器也可以声明为资源销毁前执行,但会有一些限制。 作为元参数,provisioner 和 connection 可以声明在任意类型的 resource 块内。 举一个例子: resource \"aws_instance\" \"web\" { # ... provisioner \"file\" { source = \"conf/myapp.conf\" destination = \"/etc/myapp.conf\" connection { type = \"ssh\" user = \"root\" password = var.root_password host = self.public_ip } } } 我们在 aws_instance 中定义了类型为 file 的预置器,该预置器可以本机文件或文件夹拷贝到目标机器的指定路径下。我们在预置器内部定义了connection块,类型是ssh。我们对connection的host赋值self.public_ip,在这里self代表预置器所在的母块,也就是aws_instance.web,所以self.public_ip代表着aws_instance.web.public_ip,也就是创建出来的主机的公网ip。 file 类型预置器支持 ssh 和 winrm 两种类型的 connection。 预置器根据运行的时机分为两种类型,创建时预置器以及销毁时预置器。 创建时预置器 默认情况下,创建时资源对象会运行预置器,在对象更新、销毁时则不会运行。预置器的默认行为是为了引导一个系统。 如果创建时预置器失败了,那么资源对象会被标记污点(我们将在介绍 terraform taint 命令时详细介绍)。一个被标记污点的资源在下次执行 terraform apply 命令时会被销毁并重建。Terraform 的这种设计是因为当预置器运行失败时标志着资源处于半就绪的状态。由于 Terraform 无法衡量预置器的行为,所以唯一能够完全确保资源被正确初始化的方式就是删除重建。 我们可以通过设置 on_failure 参数来改变这种行为。 销毁时预置器 如果我们设置预置器的 when 参数为 destroy,那么预置器会在资源被销毁时执行: resource \"aws_instance\" \"web\" { # ... provisioner \"local-exec\" { when = destroy command = \"echo 'Destroy-time provisioner'\" } } 销毁时预置器在资源被实际销毁前运行。如果运行失败,Terraform 会报错,并在下次运行 terraform apply 操作时重新执行预置器。在这种情况下,需要仔细关注销毁时预置器以使之能够安全地反复执行。 注意:销毁时预置器不会在 resource 块配置了 create_before_destroy = true 时运行。 销毁时预置器只有在存在于代码中的情况下才会在销毁时被执行。如果一个 resource 块连带内部的销毁时预置器块一起被从代码中删除,那么被删除的预置器在资源被销毁时不会被执行。要解决这个问题,我们需要使用多个步骤来绕过这个限制: 修改资源声明代码,添加 count = 0 参数 执行 terraform apply,运行删除时预置器,然后删除资源实例 删除 resource 块 重新执行 terraform apply,此时应该不会有任何变更需要执行 该限制在未来将会得到解决,但目前来说我们必须节制使用销毁时预置器。 注意:一个被标记污点的 resource 块内的销毁时预置器不会被执行。这包括了因为创建时预置器失败或是手动使用 terraform taint 命令标记污点的资源。 预置器失败行为 默认情况下,预置器运行失败会导致terraform apply执行失败。可以通过设置on_failure参数来改变这一行为。可以设置的值为: continue:忽视错误,继续执行创建或是销毁 fail:报错并终止执行变更(这是默认行为)。如果这是一个创建时预置器,则在对应资源对象上标记污点 样例: resource \"aws_instance\" \"web\" { # ... provisioner \"local-exec\" { command = \"echo The server's IP address is ${self.private_ip}\" on_failure = continue } } 删除资源 注意:removed 块是在 Terraform v1.7 引入的功能。对于早期的 Terraform 版本,您可以使用 terraform state rm 命令来处理。 要从 Terraform 中删除资源,只需从 Terraform 代码中删除 resource 块即可。 默认情况下,删除 resource 块后,Terraform 将计划销毁该资源管理的所有实际基础设施对象。 有时,我们可能希望从 Terraform 配置中删除资源,而不破坏它管理的实际基础设施对象。在这种情况下,资源将从 Terraform 状态中删除,但真正的基础设施对象不会被破坏。 要声明资源已从 Terraform 配置中删除,但不应销毁其托管对象,请从配置中删除 resource 块并将其替换为 removed 块: removed { from = aws_instance.example lifecycle { destroy = false } } from 参数是您要删除的资源的地址,没有任何实例键(例如 aws_instance.example[1])。 lifecycle 块是必需的。 destroy 参数确定 Terraform 是否会尝试销毁资源管理的对象。 false 值表示 Terraform 将从状态中删除资源而不销毁实际的远程资源。 removed 块还可以包含销毁时预置器,以便即使 resource 块已被删除,预制器也可以保留在代码中。 removed { from = aws_instance.example lifecycle { destroy = true } provisioner \"local-exec\" { when = destroy command = \"echo 'Instance ${self.id} has been destroyed.'\" } } 与普通的销毁时预置器中的引用规则相同,仅允许使用 count.index、each.key 和 self。预置器必须指定 when = destroy,并且 removed 块必须声明 destroy = true 才能执行预置器。 本地资源 虽然大部分资源类型都对应的是通过远程基础设施 API 控制的一个资源对象,但也有一些资源对象他们只存在于 Terraform 进程自身内部,用来计算生成某些结果,并将这些结果保存在状态中以备日后使用。 比如说,我们可以用 tls_private_key 生成公私钥,用 tls_self_signed_cert 生成自签名证书,或者是用 random_id 生成随机 id。虽不像其他“真实”基础设施对象那般重要,但这些本地资源也可以成为连接其他资源有用的黏合剂。 本地资源的行为与其他类型资源是一致的,但是他们的结果数据仅存在于 Terraform 状态文件中。“销毁”这种资源只是将结果数据从状态中删除。 操作超时设置 有些资源类型提供了特殊的 timeouts 内嵌块参数,它允许我们配置我们允许操作持续多长时间,超时将被认定为失败。比如说,aws_db_instance 资源允许我们分别为 create,update,delete 操作设置超时时间。 超时完全由资源对应的 Provider 来处理,但支持超时设置的 Provider 一般都遵循相同的传统,那就是由一个名为 timeouts 的内嵌块参数定义超时设置,timeouts 块内可以分别设置不同操作的超时时间。超时时间由 string 描述,比如 \"60m\" 代表 60 分钟,\"10s\" 代表 10 秒,\"2h\" 代表 2 小时。 resource \"aws_db_instance\" \"example\" { # ... timeouts { create = \"60m\" delete = \"2h\" } } 可配置超时的操作类别由每种支持超时设定的资源类型自行决定。大部分资源类型不支持设置超时。使用超时前请先查阅相关文档。 "},"3.Terraform代码的书写/7.数据源.html":{"url":"3.Terraform代码的书写/7.数据源.html","title":"数据源","keywords":"","body":"数据源 数据源允许查询或计算一些数据以供其他地方使用。使用数据源可以使得 Terraform 代码使用在 Terraform 管理范围之外的一些信息,或者是读取其他 Terraform 代码保存的状态。 每一种 Provider 都可以在定义一些资源类型的同时定义一些数据源。 使用数据源 数据源通过一种特殊的资源访问:data 资源。数据源通过 data 块声明: data \"aws_ami\" \"example\" { most_recent = true owners = [\"self\"] tags = { Name = \"app-server\" Tested = \"true\" } } 一个 data 块请求 Terraform 从一个指定的数据源 aws_ami 读取指定数据并且把结果输出到 Local Name 为 example 的实例中。我们可以在同一模块内的代码中通过数据源名称来引用数据源,但无法从模块外部直接访问数据源。 同资源类似,一个数据源类型以及它的名称一同构成了该数据源的标识符,所以数据源类型加名称的组合在同一模块内必须是唯一的。 在 data 块体({ 与 } 中间的内容)是传给数据源的查询条件。查询条件参数的种类取决于数据源的类型,在上述例子中,most_recent、owners 和 tags 都是定义查询 aws_ami 数据源时使用的查询条件。 与数据源这种特殊资源不同的是,我们在上一节介绍的主要资源(使用 resource 块定义的)是一种“托管资源”。这两种资源都可以接收参数并对外输出属性,但托管资源会触发 Terraform 对基础设施对象进行增删改操作,而数据源只会触发读取操作。简单来说,我们一般说的“资源”就是特指托管资源。 数据源参数 每一种数据源资源都关联到一种外部数据源,数据源类型决定了它接收的查询参数以及输出的数据。每一种数据源类型都属于一个 Provider。大部分 data 块内的数据源参数都是由对应的数据源类型定义的,这些参数的赋值可以使用完整的 Terraform 表达式能力或其他 Terraform 语言的功能。 然而类似资源,Terraform 也为所有类型的数据源定义了一些元参数。这些元参数的限制和功能我们将在后续节当中叙述。 数据源行为 如果数据源的查询参数涉及到的表达式只引用了字面量或是在执行 terraform plan 时就已知的数据(比如输入变量),那么数据源会在执行 Terraform 的 \"refersh\" 阶段时被读取,然后 Terraform 会构建变更计划。这保证了在制定变更计划时 Terraform 可以使用这些数据源的返回数据。 如果查询参数的表达式引用了那些只有执行部分执行变更计划以后才能知晓的数据,比如另一个还未被创建的托管资源的输出,那么数据源的读取操作会被推迟到 \"apply\" 阶段。以下几种情况下 Terraform 会推迟数据源的读取: 给定的参数中至少有一个是一个托管资源的属性或是其他值,Terraform 在执行步骤之前无法预测。 data 块内的查询参数引用了一个还未被创建的托管资源的输出。 data 块内声明的 precondition 或 postcondition 直接或间接地依赖了一个在当前计划中有变更的托管资源。 任何引用该数据源输出的表达式的值在执行到数据源被读取完之前都是未知的。 本地数据源 虽然绝大多数数据源都对应了一个通过远程基础设施 API 访问的外部数据源,但是也有一些特殊的数据源仅存在于 Terraform 进程内部,计算并对外输出一些数据。 比如说,本地数据源有 template_file、local_file、aws_iam_policy_document 等。 本地数据源的行为与其他数据源完全一致,但他们输出的结果数据只是临时存在于 Terraform 运行时,每次计算一个新的变更计划时这些值都会被重新计算。 数据源的依赖关系 数据源有着与资源一样的依赖机制,我们也可以在 data 块内设置 depends_on 元参数来显式声明依赖关系,在此不再赘述。 注意:在 Terraform 0.12 及更早版本中,由于 data 会将尚不知晓值的读取推迟到 Apply 阶段,因此将 dependent_on 与 data 一起使用将强制将数据的读取推迟到 Apply 阶段,因此,使用 depends_on 的 data 数据源配置永远无法收敛。由于这种行为,我们不建议对 data 使用 depends_on。 Precondition 与 Postcondition 您可以使用 precondition 和 postcondition 块来指定有关 data 如何运行的假设和验证。以下实力创建一个 postcondition 来检查 AMI 是否具有正确的标签: data \"aws_ami\" \"example\" { id = var.aws_ami_id lifecycle { # The AMI ID must refer to an existing AMI that has the tag \"nomad-server\". postcondition { condition = self.tags[\"Component\"] == \"nomad-server\" error_message = \"tags[\\\"Component\\\"] must be \\\"nomad-server\\\".\" } } } 自定义条件检查可以声明对数据的假设,帮助未来的维护人员了解代码的设计和意图。它们还可以更早地在上下文中返回有关错误的有用信息,帮助使用者更轻松地诊断其配置中的问题。 生命周期 同资源不一样,数据源目前的 lifecycle 块中只支持 precondition 和 postcondition 块。 多数据源实例 与资源一样,数据源也可以通过设置 count、for_each 元参数来创建一组多个数据源实例,并且 Terraform 也会把每个数据源实例单独创建并读取相应的外部数据,对 count.index 与 each 的使用也是一样的,在 count 与 for_each 之间选择的原则也是一样的。 指定特定 Provider 实例 同资源一样,数据源也可以通过 provider 元参数指定使用特定 Provider 实例,在此不再赘述。 例子 一个数据源定义例子如下: # Find the latest available AMI that is tagged with Component = web data \"aws_ami\" \"web\" { filter { name = \"state\" values = [\"available\"] } filter { name = \"tag:Component\" values = [\"web\"] } most_recent = true } 引用数据源 引用数据源数据的语法是data...: resource \"aws_instance\" \"web\" { ami = data.aws_ami.web.id instance_type = \"t1.micro\" } "},"3.Terraform代码的书写/8.表达式.html":{"url":"3.Terraform代码的书写/8.表达式.html","title":"表达式","keywords":"","body":"表达式 表达式用来在配置文件中进行一些计算。最简单的表达式就是字面量,比如 \"hello\",或者 5。Terraform 也支持一些更加复杂的表达式,比如引用其他 resource 的输出值、数学计算、布尔条件计算,以及一些内建的函数。 Terraform 配置中很多地方都可以使用表达式,但某些特定的场景下限制了可以使用的表达式的类型,例如只准使用特定数据类型的字面量,或是禁止使用 resource 的输出值。 您可以通过运行 terraform console 命令,从 Terraform 表达式控制台测试 Terraform 表达式的行为。 我们在类型章节中已经基本介绍了类型以及类型相关的字面量,下面我们来介绍一些其他的表达式。 下标和属性 list 和 tuple 可以通过下标访问成员,例如 local.list[3]、var.tuple[2]。map 和 object 可以通过属性访问成员,例如 local.object.attrname、local.map.keyname。由于 map 的键是用户定义的,可能无法成为合法的 Terraform 标识符,所以访问 map 成员时我们推荐使用方括号:local.map[\"keyname\"]。 引用命名值 Terraform 中定义了多种命名值,表达式中的每一个命名值都关联到一个具体的值,我们可以用单一命名值作为一个表达式,或是组合多个命名值来计算出一个新值。 命名值有如下种类: .:表示一个资源对象。凡是不符合后面列出的命名值模式的表达式都会被 Terraform 解释为一个托管资源。如果资源声明了 count 元参数,那么该表达式表示的是一个对象实例的 list。如果资源声明了 for_each 元参数,那么该表达式表示的是一个对象实例的 map。 var.:表示一个输入变量 local.:表示一个局部值 module..:表示一个模块的一个输出值 data..:表示一个数据源实例。如果数据源声明了 count 元参数,那么该表达式表示的是一个数据源实例 list。如果数据源声明了 for_each 元参数,那么该表达式表示的是一个数据源实例 map。 path.module:表示当前模块在文件系统中的路径 path.root:表示根模块(调用 Terraform 命令行执行的代码文件所在的模块)在文件系统中的路径 path.cwd:表示当前工作目录的路径。一般来说该路径等同于 path.root,但在调用 Terraform 命令行时如果指定了代码路径,那么二者将会不同。 terraform.workspace:当前使用的 Workspace (我们在状态管理的\"状态的隔离存储\"中介绍过) 虽然这些命名表达式可以使用 . 号来访问对象的各种属性,但实际上他们实际类型并不是我们在类型章节里提到过的 object。两者的区别在于,object 同时支持使用 . 或者 [\"\"] 两种方式访问对象成员属性,而上述命名表达式仅支持 .。 局部命名值 在某些特定表达式或上下文当中,有一些特殊的命名值可以被使用,他们是局部命名值。几种比较常见的局部命名值有: count.index:表达当前 count 下标序号 each.key:表达当前 for_each 迭代器实例 self:在预置器中指代声明预置器的资源 命名值的依赖关系 构建资源或是模块时经常会使用含有命名值的表达式赋值,Terraform 会分析这些表达式并自动计算出对象之间的依赖关系。 引用资源输出属性 最常见的引用类型就是引用一个 resource 或 data 块定义的对象的输出属性。由于这些资源与数据源对象结构可能非常复杂,所以对它们的输出属性的引用表达式也可能非常复杂。 比如下面这个例子: resource \"aws_instance\" \"example\" { ami = \"ami-abc123\" instance_type = \"t2.micro\" ebs_block_device { device_name = \"sda2\" volume_size = 16 } ebs_block_device { device_name = \"sda3\" volume_size = 20 } } aws_instance 文档列出了该类型所支持的所有输入参数和内嵌块,以及对外输出的属性列表。所有这些不同的资源类型 Schema 都可以在引用中使用,如下所示: ami 参数可以在可以在其他地方用 aws_instance.example.ami 表达式来引用 id 属性可以用 aws_instance.example.id 的表达式来引用 内嵌的 ebs_block_device 参数可以通过后面会介绍的展开表达式(splat expression)来访问,比如我们获取所有的 ebs_block_device 的 device_name 列表:aws_instance.example.ebs_block_device[*].device_name 在 aws_instance 类型里的内嵌块并没有任何输出属性,但如果 ebs_block_device 添加了一个名为 \"id\" 的输出属性,那么可以用 aws_instance.example.ebs_block_device[*].id 表达式来访问含有所有 id 的列表 有时多个内嵌块会各自包含一个逻辑键来区分彼此,类似用资源名访问资源,我们也可以用内嵌块的名字来访问特定内嵌块。假如 aws_instance 类型有一个假想的内嵌块类型 device 并规定 device 可以赋予这样的一个逻辑键,那么代码看起来就会是这样的: device \"foo\" { size = 2 } device \"bar\" { size = 4 } 我们可以使用键来访问特定块的数据,例如:aws_instance.example.device[\"foo\"].size 要获取一个 device 名称到 device 大小的映射,可以使用 for 表达式: {for k, device in aws_instance.example.device : k => device.size} 当一个资源声明了 count 参数,那么资源本身就成了一个资源对象列表而非单个资源。这种情况下要访问资源输出属性,要么使用展开表达式,要么使用下标索引: aws_instance.example[*].id:返回所有 instance 的 id 列表 aws_instance.example[0].id:返回第一个 instance的 id 当一个资源声明了 for_each 参数,那么资源本身就成了一个资源对象字典而非单个资源。这种情况下要访问资源的输出属性,要么使用特定键,要么使用 for 表达式: aws_instance.example[\"a\"].id:返回 \"a\" 对应的实例的 id [for value in aws_instance.example: value.id]:返回所有 instance 的 id 注意不像使用 count,使用 for_each 的资源集合不能直接使用展开表达式,展开表达式只能适用于列表。你可以把字典转换成列表后再使用展开表达式: values(aws_instance.example)[*].id 尚不知晓的值 当 Terraform 在计算变更计划时,有些资源输出属性无法立即求值,因为他们的值取决于远程API的返回值。比如说,有一个远程对象可以在创建时返回一个生成的唯一 id,Terraform 无法在创建它之前就预知这个值。 为了允许在计算变更阶段就能计算含有这种值的表达式,Terraform 使用了一个特殊的\"尚不知晓(unknown value)\"占位符来代替这些结果。大部分时候你不需要特意理会它们,因为 Terraform 语言会自动处理这些尚不知晓的值,比如说使两个尚不知晓的值相加得到的会是一个尚不知晓的值。 然而,有些情况下表达式中含有尚不知晓的值会有明显的影响: count 元参数不可以为尚不知晓,因为变更计划必须明确地知晓到底要维护多少个目标实例 如果尚不知晓的值被用于数据源,那么数据源在计算变更计划阶段就无法读取,它会被推迟到执行阶段读取。这种情况下,在计划阶段该数据源的一切输出均为尚不知晓 如果声明 module 块时传递给模块输入变量的表达式使用了尚不知晓值,那么在模块代码中任何使用了该输入变量值的表达式的值都将是尚不知晓 如果模块输出值表达式中含有尚不知晓值,任何使用该模块输出值的表达式都将是尚不知晓 Terraform 会尝试验证尚不知晓值的数据类型是否合法,但仍然有可能无法正确检查数据类型,导致执行阶段发生错误 尚不知晓值在执行 terraform plan 时会被输出为 \"(not yet known)\"。 算数和逻辑操作符 一个操作符是一种用以转换或合并一个或多个表达式的表达式。操作符要么是把两个值计算为第三个值,也就是二元操作符;要么是把一个值转换成另一个值,也就是一元操作符。 二元操作符位于两个表达式的中间,类似 1+2。一元操作符位于一个表达式的前面,类似 !true。 Terraform 的 HCL 语言支持一组算数和逻辑操作符,它们的功能类似于 JavaScript 或 Ruby 里的操作符功能。 当一个表达式中含有多个操作符时,它们的优先级顺序为: !,- (负号) *,/,% +,- (减号) >,>=,, ==,!= && || 可以使用小括号覆盖默认优先级。如果没有小括号,高优先级操作符会被先计算,例如 1+2*3 会被解释成 1+(2*3) 而不是 (1+2)*3。 不同的操作符可以按它们之间相似的行为被归纳为几组,每一组操作符都期待被给予特定类型的值。Terraform 会在类型不符时尝试进行隐式类型转换,如果失败则会抛错。 算数操作符 a + b:返回 a 与 b 的和 a - b:返回 a 与 b 的差 a * b:返回 a 与 b 的积 a / b:返回 a 与 b 的商 a % b:返回 a 与 b 的模。该操作符一般仅在 a 与 b 是整数时有效 -a:返回 a 与 -1 的商 相等性操作符 a == b:如果 a 与 b 类型与值都相等返回 true,否则返回 false a != b:与 == 相反 比较操作符 a :如果 a 比 b 小则为 true,否则为 false a > b:如果 a 比 b 大则为 true,否则为 false a :如果 a 比 b 小或者相等则为 true,否则为 false a >= b:如果 a 比 b 大或者相等则为 true,否则为 false 逻辑操作符 a || b:a 或 b 中有至少一个为 true 则为 true,否则为 false a && b:a 与比都为 true 则为 true,否则为 false !a:如果 a 为 true 则为 false,如果 a 为 false 则为 true 条件表达式 条件表达式是判断一个布尔表达式的结果以便于在后续两个值当中选择一个: condition ? true_val : false_val 如果 condition 表达式为 true,那么结果是 true_value,反之则为 false_value。 一个常见的条件表达式用法是使用默认值替代非法值: var.a != \"\" ? var.a : \"default-a\" (注:以上表达式目前推荐写为:coalesce(var.a, \"default-a\")) 如果输入变量 a 的值是空字符串,那么结果会是 default-a,否则返回输入变量 a 的值。 条件表达式的判断条件可以使用上述的任意操作符。供选择的两个值也可以是任意类型,但它们的类型必须相同,这样 Terraform 才能判断条件表达式的输出类型。 函数调用 Terraform 支持在计算表达式时使用一些内建函数,函数调用表达式类似操作符,通用语法是: (, ) 函数名标明了要调用的函数。每一个函数都定义了数量不等、类型不一的入参以及不同类型的返回值。 有些函数定义了不定长的入参表,例如,min 函数可以接收任意多个数值类型入参,返回其中最小的数值: min(55, 3453, 2) 展开函数入参 如果想要把列表或元组的元素作为参数传递给函数,那么我们可以使用展开符: min([55, 2453, 2]...) 展开符使用的是三个独立的 . 号组成的 ...,不是 Unicode 中的省略号 …。展开符是一种只能用在函数调用场景下的特殊语法。 有关完整的内建函数我们可能会在今后撰写相应的章节介绍。 for 表达式 for 表达式是将一种复杂类型映射成另一种复杂类型的表达式。输入类型值中的每一个元素都会被映射为一个或零个结果。 举例来说,如果 var.list 是一个字符串列表,那么下面的表达式将会把列表元素全部转为大写: [for s in var.list : upper(s)] 在这里 for 表达式迭代了 var.list 中每一个元素(就是 s),然后计算了 upper(s),最后构建了一个包含了所有 upper(s) 结果的新元组,元组内元素顺序与源列表相同。 for 表达式周围的括号类型决定了输出值的类型。上面的例子里我们使用了方括号,所以输出类型是元组。如果使用的是花括号,那么输出类型是对象,for 表达式内部冒号后面应该使用以 => 符号分隔的表达式: {for s in var.list : s => upper(s)} 该表达式返回一个对象,对象的成员属性名称就是源列表中的元素,值就是对应的大写值。 一个 for 表达式还可以包含一个可选的 if 子句用以过滤结果,这可能会减少返回的元素数量: [for s in var.list : upper(s) if s != \"\"] 被 for 迭代的也可以是对象或者字典,这样的话迭代器就会被表示为两个临时变量: [for k, v in var.map : length(k) + length(v)] 最后,如果返回类型是对象(使用花括号)那么表达式中可以使用 ... 符号实现 group by: {for s in var.list : substr(s, 0, 1) => s... if s != \"\"} 展开表达式(Splat Expression) 展开表达式提供了一种类似 for 表达式的简洁表达方式。比如说 var.list 包含一组对象,每个对象有一个属性 id,那么读取所有 id 的 for 表达式会是这样: [for o in var.list : o.id] 与之等价的展开表达式是这样的: var.list[*].id 这个特殊的 [*] 符号迭代了列表中每一个元素,然后返回了它们在 . 号右边的属性值。 展开表达式只能被用于列表(所以使用 for_each 参数的资源不能使用展开表达式,因为它的类型是字典)。然而,如果一个展开表达式被用于一个既不是列表又不是元组的值,那么这个值会被自动包装成一个单元素的列表然后被处理。 比如说,var.single_object[*].id 等价于 [var.single_object][*].id。大部分场景下这种行为没有什么意义,但在访问一个不确定是否会定义 count 参数的资源时,这种行为很有帮助,例如: aws_instance.example[*].id 上面的表达式不论 aws_instance.example 定义了 count 与否都会返回实例的 id 列表,这样如果我们以后为 aws_instance.example 添加了 count 参数我们也不需要修改这个表达式。 遗留的旧有展开表达式 曾经存在另一种旧的展开表达式语法,它是一种比较弱化的展开表达式,现在应该尽量避免使用。 这种旧的展开表达式使用 .* 而不是 [*]: var.list.*.interfaces[0].name 要特别注意该表达式与现有的展开表达式结果不同,它的行为等价于: [for o in var.list : o.interfaces][0].name 而现有 [*] 展开表达式的行为等价于: [for o in var.list : o.interfaces[0].name] 注意两者右方括号的位置。 dynamic 块 在顶级块,例如 resource 块当中,一般只能以类似 name = expression 的形式进行一对一的赋值。大部分情况下这已经够用了,但某些资源类型包含了可重复的内嵌块,无法使用表达式循环赋值: resource \"aws_elastic_beanstalk_environment\" \"tfenvtest\" { name = \"tf-test-name\" # can use expressions here setting { # but the \"setting\" block is always a literal block } } 你可以用 dynamic 块来动态构建重复的 setting 这样的内嵌块: resource \"aws_elastic_beanstalk_environment\" \"tfenvtest\" { name = \"tf-test-name\" application = \"${aws_elastic_beanstalk_application.tftest.name}\" solution_stack_name = \"64bit Amazon Linux 2018.03 v2.11.4 running Go 1.12.6\" dynamic \"setting\" { for_each = var.settings content { namespace = setting.value[\"namespace\"] name = setting.value[\"name\"] value = setting.value[\"value\"] } } } dynamic 可以在 resource、data、provider 和 provisioner 块内使用。一个 dynamic 块类似于 for 表达式,只不过它产生的是内嵌块。它可以迭代一个复杂类型数据然后为每一个元素生成相应的内嵌块。在上面的例子里: dynamic 的标签(也就是 \"setting\")确定了我们要生成的内嵌块种类 for_each 参数提供了需要迭代的复杂类型值 iterator 参数(可选)设置了用以表示当前迭代元素的临时变量名。如果没有设置 iterator,那么临时变量名默认就是 dynamic 块的标签(也就是 setting) labels 参数(可选)是一个表示块标签的有序列表,用以按次序生成一组内嵌块。有 labels 参数的表达式里可以使用临时的 iterator 变量 内嵌的 content 块定义了要生成的内嵌块的块体。你可以在 content 块内部使用临时的 iterator 变量 由于 for_each 参数可以是集合或者结构化类型,所以你可以使用 for 表达式或是展开表达式来转换一个现有集合的类型。 iterator 变量(上面的例子里就是 setting)有两个属性: key:迭代容器如果是 map,那么就是当前元素的键;迭代容器如果是 list,那么就是当前元素在 list 中的下标序号;如果是由 for_each 表达式产出的 set,那么 key 和 value 是一样的,这时我们不应该使用 key。 value:当前元素的值 一个 dynamic 块只能生成属于当前块定义过的内嵌块参数。无法生成诸如 lifecycle、provisioner 这样的元参数,因为 Terraform 必须在确保对这些元参数求值的计算是成功的。 for_each 的值必须是不为空的 map 或者 set。如果你需要根据内嵌数据结构或者多个数据结构的元素组合来声明资源实例集合,你可以使用 Terraform 表达式和函数来生成合适的值。 dynamic 块的最佳实践 过度使用 dynamic 块会导致代码难以阅读以及维护,所以我们建议只在需要构造可重用的模块代码时使用 dynamic 块。尽可能手写内嵌块。 字符串字面量 Terraform 有两种不同的字符串字面量。最通用的就是用一对双引号包裹的字符,比如 \"hello\"。在双引号之间,反斜杠 \\ 被用来进行转义。Terraform 支持的转义符有: Sequence Replacement \\n 换行 \\r 回车 \\t 制表符 \\\" 双引号 (不会截断字符串) \\\\ 反斜杠 \\uNNNN 普通字符映射平面的Unicode字符(NNNN代表四位16进制数) \\UNNNNNNNN 补充字符映射平面的Unicode字符(NNNNNNNN代表八位16进制数) 另一种字符串表达式被称为 \"heredoc\" 风格,是受 Unix Shell 语言启发。它可以使用自定义的分隔符更加清晰地表达多行字符串: 标记后面直到行尾组成的标识符开启了字符串,然后 Terraform 会把剩下的行都添加进字符串,直到遇到与标识符完全相等的字符串为止。在上面的例子里,EOT 就是标识符。任何字符都可以用作标识符,但传统上标识符一般以 EO 开头。上面例子里的 EOT 代表\"文本的结束(end of text)\"。 上面例子里的 heredoc 风格字符串要求内容必须对齐行头,这在块内声明时看起来会比较奇怪: block { value = 为了改进可读性,Terraform 也支持缩进的 heredoc,只要把 改成 : block { value = 上面的例子里,Terraform 会以最靠近行头的行作为基准来调整行头缩进,得到的字符串是这样的: hello world heredoc 中的反斜杠不会被解释成转义,而只会是简单的反斜杠。 双引号和 heredoc 两种字符串都支持字符串模版,模版的形式是 ${...} 以及 %{...}。如果想要表达 ${ 或者 %{ 的字面量,那么可以重复第一个字符:$${ 和 %%{ 。 字符串模版 字符串模版允许我们在字符串中嵌入表达式,或是通过其他值动态构造字符串。 插值(Interpolation) 一个 ${...} 序列被称为插值,插值计算花括号之间的表达式的值,有必要的话将之转换为字符串,然后插入字符串模版,形成最终的字符串: \"Hello, ${var.name}!\" 上面的例子里,输入变量 var.name 的值被访问后插入了字符串模版,产生了最终的结果,比如:\"Hello, Juan!\" 命令(Directive) 一个 %{...} 序列被称为命令,命令可以是一个布尔表达式或者是对集合的迭代,类似条件表达式以及 for 表达式。有两种命令: if \\ / else /endif 命令根据布尔表达式的结果在两个模版中选择一个: \"Hello, %{ if var.name != \"\" }${var.name}%{ else }unnamed%{ endif }!\" else 部分可以省略,这样如果布尔表达结果为false那么就会插入空字符串。 for \\ in \\ / endfor 命令迭代一个结构化对象或者集合,用每一个元素渲染模版,然后把它们拼接起来: for 关键字后紧跟的名字被用作代表迭代器元素的临时变量,可以用来在内嵌模版中使用。 为了在不添加额外空格和换行的前提下提升可读性,所有的模版序列都可以在首尾添加 ~ 符号。如果有 ~ 符号,那么模版序列会去除字符串左右的空白(空格以及换行)。如果 ~ 出现在头部,那么会去除字符串左侧的空白;如果出现在尾部,那么会去除字符串右边的空白: 上面的例子里,命令符后面的换行符被忽略了,但是 server ${ip} 后面的换行符被保留了,这确保了每一个元素生成一行输出: server 10.1.16.154 server 10.1.16.1 server 10.1.16.34 当使用模版命令时,我们推荐使用 heredoc 风格字符串,用多行模版提升可读性。双引号字符串内最好只使用插值。 Terraform 插值 Terraform 曾经只支持在表达式中使用插值,例如 resource \"aws_instance\" \"example\" { ami = var.image_id # ... } 这种语法是在 Terraform 0.12 后才被支持的。在 Terraform 0.11 及更早的版本中,这段代码只能被写成这样: resource \"aws_instance\" \"example\" { ami = \"${var.image_id}\" # ... } Terraform 0.12 保持了向前兼容,所以现在这样的代码也仍然是合法的。读者们也许会在一些 Terraform 代码和文档中继续看到这样的写法,但请尽量避免继续这样书写纯插值字符串,而是直接使用表达式。 "},"3.Terraform代码的书写/9.重载文件.html":{"url":"3.Terraform代码的书写/9.重载文件.html","title":"重载文件","keywords":"","body":"重载文件 一般来说 Terraform 会加载模块内所有的 .tf 和 .tf.json 文件,并要求文件内定义了一组无重复的对象。如果两个文件尝试定义同一个对象,那么 Terraform 会报错。 在某些少见场景中,能够用单独的文件重载已有对象配置的特定部分将会十分有用。比如说,由工程师编写的配置文件能够在运行时被程序生成的 JSON 文件部分重载。 为支持这些少见场景,Terraform 会对后缀名为 override.tf 和 override.tf.json 的代码文件进行特殊处理。对于名为 override.tf 和 override.tf.json 的代码文件也会进行相同的特殊处理。 Terraform 一开始加载代码文件时会跳过这些重载文件,然后才会按照字典序一个一个处理重载文件。对重载文件中定义的所有顶级块(resource、data等),Terraform 会尝试找到对应的已有对象并且将重载内容合并进已有对象。 重载文件只应使用于特殊场景,过度使用会使得读者在阅读原始代码文件时被迫还要阅读所有的重载文件才能理解对象配置,从而降低了代码的可读性。使用重载文件时,请在原始文件被重载的部分添加相应注释,提醒未来的读者哪些部分会被重载文件修改。 例子 如果我们有一个名为 example.tf 的代码文件: resource \"aws_instance\" \"web\" { instance_type = \"t2.micro\" ami = \"ami-408c7f28\" } 然后我们创建一个名为 override.tf 的文件: resource \"aws_instance\" \"web\" { ami = \"foo\" } Terraform 随后会合并两者,实际的配置会是这样的: resource \"aws_instance\" \"web\" { instance_type = \"t2.micro\" ami = \"foo\" } 合并行为 不同的块类型有着些微不同的合并行为,某些特定块内的特殊构造会以特殊形式被合并。 一般来说: 重载文件内的顶级块会和普通文件内同类型同名的顶级块合并 重载文件内的顶级块配置册参数会覆盖普通文件内对应块内的同名参数 重载块内的内嵌块会取代普通文件内对应块内的所有同类型内嵌块。所有重载块内没有定义的内嵌块在普通文件内保持不变 内嵌块的内容不会进行合并 合并后的块仍然需要符合对应块类型的所有验证规则 如果有多个重载文件定义了同一个顶级块,那么重载效果是叠加的,后加载的重载块会在先前加载的重载块生效的基础上合并。重载操作首先按照文件名的字典序其次是在重载文件中的位置决定执行顺序。 有一些针对特定顶级块类型的特殊合并行为规则,我们将重载文件中定义的块称为重载块,重载块在普通文件中对应的块称为源块: 合并 resource 块以及合并 data 块 在 resource 块内,所有 lifecycle 块的内容会按照参数逐条合并。比如说,一个重载块只定义了 create_before_destroy 参数而源块定义了 ignore_changes,那么 create_before_destroy 被合并的同时 igonore_changes 将会被保留。 如果重载的 resource 块包含了一个或多个 provisioner,那么源块内所有的 provisioner 会被忽略。 如果重载的 resource 块内包含了一个 connection 块,那么它将会完全覆盖所有源块内定义的 connection 块 不允许在重载块内定义 depends_on 参数,那将会引发一个错误。 合并 variable 块 variable 块内参数的合并遵循上述的标准流程,但对于 type 和 default 参数的处理会有一些特殊的考虑。 如果源块定义了 default 值而重载块修改了变量的 type,Terraform 会尝试将 default 值转换成新类型,如果转换失败则会报错。 同样的,如果源块定义了 type 参数而重载块修改了 default 值,那么新的 default 值必须能够被转换成原先的类型。 合并 output 块 不允许在重载块内定义 depends_on 参数,这会引发一个错误。 合并 locals 块 所有的 locals 块都定义了一个或多个命名值。针对 locals 的合并会是按照命名值的名字逐条执行的,不论命名值是在哪个 locals 块内被定义的。 合并 terraform 块 如果重载块定义了 required_providers 参数,那么它的值会被逐条合并,这就允许重载块在不影响其他Provider的情况下调整单个 Provider 的版本约束。 重载块内的 requeired_version 和 required_providers 里的配置完全覆盖源块内的相应配置。如果源块和重载块都定义了 required_version,那么源块的配置会被完全忽略。 "},"3.Terraform代码的书写/10.代码风格规范.html":{"url":"3.Terraform代码的书写/10.代码风格规范.html","title":"代码风格规范","keywords":"","body":"代码风格规范 Terraform 推荐以下代码规范: 使用两个空格缩进 同一缩进层级的多个赋值语句以等号对齐: ami = \"abc123\" instance_type = \"t2.micro\" 当块体内同时有参数赋值以及内嵌块时,请先编写参数赋值,然后是内嵌块。参数与内嵌块之间空一行分隔 对于同时包含参数赋值以及元参数赋值的块,请先编写元参数赋值语句,然后是参数赋值语句,之间空一行分隔。元参数块请置于块体的最后,空一行分隔: resource \"aws_instance\" \"example\" { count = 2 # meta-argument first ami = \"abc123\" instance_type = \"t2.micro\" network_interface { # ... } lifecycle { # meta-argument block last create_before_destroy = true } } 顶层块之间应空一行分隔。内嵌块之间也应该空一行分隔,除非是相同类型的内嵌块(比如 resource 块内部多个 provisioner 块) 同类型块之间尽量避免插入其他类型块,除非不同类型块共同组成了一个有语义的家族(比方说,aws_instnace 资源内的 root_block_device、ebs_block_device、ephemeral_block_device 内嵌块共同构成了描述 AWS 块存储的块家族,所以他们可以被混合编写)。 "},"3.Terraform代码的书写/11.checks.html":{"url":"3.Terraform代码的书写/11.checks.html","title":"Checks","keywords":"","body":"Checks check 块是 Terraform 1.5 开始引入的新功能。 过去我们可以在 resource 块里的 lifecycle 块中验证基础设施的状态。check 块填补了在 Terraform apply 后验证基础设施状态这一功能中的一块空白。 check 块允许我们定义在每次 plan 以及 apply 操作后执行的自定义的验证。check 块定义的验证逻辑是作为 plan 和 apply 操作的最后一步执行的。 语法 你可以定义一个包含本地名称的 check 块,其中可以定义一个 有限作用范围的 data 块,以及至少一个的断言。 下面的例子演示了加载 Terraform 官网并验证 HTTP 返回状态码为 200。 check \"health_check\" { data \"http\" \"terraform_io\" { url = \"https://www.terraform.io\" } assert { condition = data.http.terraform_io.status_code == 200 error_message = \"${data.http.terraform_io.url} returned an unhealthy status code\" } } 有限作用范围的数据源 我们可以在 check 块使用任意 Provider 提供的任意数据源作为一个有限作用范围的数据源。 一个 check 块可以配一个可选的内嵌(也叫有限作用范围)数据源。该 data 块和普通的 data 块行为类似,但你不能在定义它的 check 块以外引用它。另外,如果一个有限作用范围的数据源运行时触发了任意错误,这些错误将被标记为警告,不会阻止 Terraform 继续执行操作。 你可以使用有限作用范围的数据源在 resource 的 lifecycle 外验证相关基础设施片段的状态。在上面的例子里,如果 terraform_io 数据源在加载时发生错误,那么我们将会收到一个警告而不是中断执行的错误。 元参数 有限作用域的数据源支持 depends_on 和 provider 元参数,但不支持 count 或 for_each 元参数。 depends_on depends_on 元参数配合有限作用域数据源可以提供非常强大的能力。 假设上述例子中的 Terraform 网站是我们即将用同一目录下的 Terraform 代码部署的,在第一次创建 Plan 时因为网站还没有被创建,所以验证会失败,Terraform 总是会在一开始显示一条让人分心的警告信息。 我们可以给该内嵌数据源添加 depends_on 来确保该数据源依赖于某项组成基础设施的必要资源,例如负载均衡器。这样对该数据源的检查结果将保持 known after apply 直到依赖项创建完成。该策略避免了在配置阶段产生无意义的警告信息,直到在 plan 和 apply 操作的合适阶段执行检查。 该策略的一个问题是如果有限作用域数据源所依赖的资源发生了变化,那么 check 块将返回 known after apply 直到 Terraform 完成了对被依赖资源的更新。在某些情况下,这种行为将会引发一些问题。 我们推荐只有在内嵌数据源依赖于某项资源,但又没有显式的引用其数据时使用 depends_on 元参数。 断言 我们在 check 块中使用 assert 块定义自定义的断言条件。每个 check 块必须声明至少一个或更多的 assert 块。每个 assert 块都包含了一个 condition 属性与一个 error_message 属性。 与其他自定义检查(variable 中的 validation 以及 lifecycle 中的 precondition 和 postcondition)不同,assert 的断言不会影响 Terraform 执行操作。失败的断言将以警告信息的形式输出而不会中断后续的操作。这与其他诸如 postcondition 这样的自定义检查形成了对比,因为它们的检查失败会立即终止后续的 plan 以及 apply 操作,返回错误信息。 assert 块中的断言条件表达式可以引用同一 check 块里的内嵌数据源数据,以及同一模块中的任意输入参数、资源、数据源、模块的输出值。 check 块的元参数 check 块目前不支持元参数。Terraform 团队目前正在收集有关这一功能的反馈。 是使用 check 块还是其他自定义条件检查 check 块提供了 Terraform 中最灵活的验证功能。我们可以在其中引用输出值、输入参数、资源以及数据源的值。我们的确可以使用 check 块取代所有其他的自定义条件检查,但这并不意味着我们应该要这么做。 check 与其他检查最大的区别在于 check 块不会中断 Terraform 的执行。我们需要将这种非阻塞性的行为特点计入考量来决定采取何种检查。 输出值与输入参数 输出值的 precondition 以及 输入变量的 validation都可以对输入输出值进行断言。 这些检查是用来阻止 Terraform 在数据有问题时继续执行的。 举例来说,如果输入参数的值是无效的那么任由 Terraform 执行整个配置文件并没有什么意义,这种情况下,check 块只会输出有关无效输入参数的警告,不会打断 Terraform 的执行,而 validation 块则会警告输入参数值非法,并终止 Terraform 执行 plan 或 apply 操作。 resource 块的 precondition 与 postcondition check 块与 precondition 和 postcondition 的区别更加微妙。 precondition 是自定义条件检查中最特殊的,因为它们是在资源的变更被计算或应用之前执行的检查。决定使用 precondition 还是 postcondition 的考量也适用于选择是使用 precondition 还是 check 块。 我们可以在 postcondition 与 check 块之间互换来验证资源和数据源。例如,我们可以把上述例子中的 check 块改写成 postcondition,以下的 postcondition 块将会验证对 Terraform 网站的请求是否返回了状态码 200: data \"http\" \"terraform_io\" { url = \"https://www.terraform.io\" lifecycle { postcondition { condition = self.status_code == 200 error_message = \"${self.url} returned an unhealthy status code\" } } } check 和 postcondition 块都在 plan 或 apply 操作中验证了 Terraform 网站是否返回 200 状态码,它们的区别是发生错误时的行为。 如果是 postcondition 失败,那么将无法继续执行。Terraform 会阻止任意后续的 plan 或 apply 操作。 我们推荐使用 check 块来验证基础设施的整体状态,仅在希望确保单一资源状态符合预期时使用 postcondition。 "},"4.Terraform文件与目录/":{"url":"4.Terraform文件与目录/","title":"Terraform 文件与目录","keywords":"","body":""},"4.Terraform文件与目录/1.override_file.html":{"url":"4.Terraform文件与目录/1.override_file.html","title":"Override 文件","keywords":"","body":""},"4.Terraform文件与目录/2.依赖锁文件.html":{"url":"4.Terraform文件与目录/2.依赖锁文件.html","title":"依赖锁文件","keywords":"","body":""},"4.Terraform文件与目录/3.测试文件.html":{"url":"4.Terraform文件与目录/3.测试文件.html","title":"测试文件","keywords":"","body":""},"5.Terraform模块/":{"url":"5.Terraform模块/","title":"Terraform 模块","keywords":"","body":"Terraform模块 到目前为止我们介绍了一些代码书写的知识,但我们创建的所有资源和数据源的代码都是我们在代码文件中编写出来的。我们有没有办法不通过复制粘贴代码从而直接使用别人编写好的 Terraform 代码来创建一组资源呢? Terraform 对此给出的答案就是模块 (Module)。简单来讲模块就是包含一组 Terraform 代码的文件夹,我们之前篇章中编写的代码实际上也是在模块中。要想真正理解模块的功能,我们需要去体验一下模块的使用。 Terraform 模块是编写高质量 Terraform 代码,提升代码复用性的重要手段,可以说,一个成熟的生产环境应该是由数个可信成熟的模块组装而成的。我们将在本章介绍关于模块的知识。 "},"5.Terraform模块/1.创建模块.html":{"url":"5.Terraform模块/1.创建模块.html","title":"创建模块","keywords":"","body":"创建模块 实际上所有包含 Terraform 代码文件的文件夹都是一个 Terraform 模块。我们如果直接在一个文件夹内执行 terraform apply 或者 terraform plan 命令,那么当前所在的文件夹就被称为根模块(root module)。我们也可以在执行 Terraform 命令时通过命令行参数指定根模块的路径。 模块结构 旨在被重用的模块与我们编写的根模块使用的是相同的 Terraform 代码和代码风格规范。一般来讲,在一个模块中,会有: 一个 README 文件,用来描述模块的用途。文件名可以是 README 或者 README.md,后者应采用 Markdown 语法编写。可以考虑在 README 中用可视化的图形来描绘创建的基础设施资源以及它们之间的关系。README 中不需要描述模块的输入输出,因为工具会自动收集相关信息。如果在 README 中引用了外部文件或图片,请确保使用的是带有特定版本号的绝对 URL 路径以防止未来指向错误的版本 一个 LICENSE 描述模块使用的许可协议。如果你想要公开发布一个模块,最好考虑包含一个明确的许可证协议文件,许多组织不会使用没有明确许可证协议的模块 一个 examples 文件夹用来给出一个调用样例(可选) 一个 variables.tf 文件,包含模块所有的输入变量。输入变量应该有明确的描述说明用途 一个 outputs.tf 文件,包含模块所有的输出值。输出值应该有明确的描述说明用途 嵌入模块文件夹,出于封装复杂性或是复用代码的目的,我们可以在 modules 子目录下建立一些嵌入模块。所有包含 README 文件的嵌入模块都可以被外部用户使用;不含 README 文件的模块被认为是仅在当前模块内使用的(可选) 一个 main.tf,它是模块主要的入口点。对于一个简单的模块来说,可以把所有资源都定义在里面;如果是一个比较复杂的模块,我们可以把创建的资源分布到不同的代码文件中,但引用嵌入模块的代码还是应保留在 main.tf 里 其他定义了各种基础设施对象的代码文件(可选) 如果模块含有多个嵌入模块,那么应避免它们彼此之间的引用,由根模块负责组合它们。 由于 examples/ 的代码经常会被拷贝到其他项目中进行修改,所有在 examples/ 代码中引用本模块时使用的引用路径应使用外部调用者可以使用的路径,而非相对路径。 一个最小化模块推荐的结构是这样的: $ tree minimal-module/ . ├── README.md ├── main.tf ├── variables.tf ├── outputs.tf 一个更完整一些的模块结构可以是这样的: $ tree complete-module/ . ├── README.md ├── main.tf ├── variables.tf ├── outputs.tf ├── ... ├── modules/ │ ├── nestedA/ │ │ ├── README.md │ │ ├── variables.tf │ │ ├── main.tf │ │ ├── outputs.tf │ ├── nestedB/ │ ├── .../ ├── examples/ │ ├── exampleA/ │ │ ├── main.tf │ ├── exampleB/ │ ├── .../ 避免过深的模块结构 我们刚才提到可以在 modules/ 子目录下创建嵌入模块。Terraform 倡导\"扁平\"的模块结构,只应保持一层嵌入模块,防止在嵌入模块中继续创建嵌入模块。应将嵌入模块设计成易于组合的结构,使得在根模块中可以通过组合各个嵌入模块创建复杂的基础设施。 "},"5.Terraform模块/2.使用模块.html":{"url":"5.Terraform模块/2.使用模块.html","title":"使用模块","keywords":"","body":"引用模块 在 Terraform 代码中引用一个模块,使用的是 module 块。 每当在代码中新增、删除或者修改一个 module 块之后,都要执行 terraform init 或是 terraform get 命令来获取模块代码并安装到本地磁盘上。 模块源 module 块定义了一个 source 参数,指定了模块的源;Terraform 目前支持如下模块源: 本地路径 Terraform Registry GitHub Bitbucket 通用Git、Mercurial仓库 HTTP地址 S3 buckets GCS buckets 我们后面会一一讲解这些模块源的使用。source 使用的是 URL 风格的参数,但某些源支持在 source 参数中通过额外参数指定模块版本。 出于消除重复代码的目的我们可以重构我们的根模块代码,将一些拥有重复模式的代码重构为可反复调用的嵌入模块,通过本地路径来引用。 许多的模块源类型都支持从当前系统环境中读取认证信息,例如环境变量或系统配置文件。我们在介绍模块源的时候会介绍到这方面的信息。 我们建议每个模块把期待被重用的基础设施声明在各自的根模块位置上,但是直接引用其他模块的嵌入模块也是可行的。 本地路径 使用本地路径可以使我们引用同一项目内定义的子模块: module \"consul\" { source = \"./consul\" } 一个本地路径必须以 ./ 或者 ../ 为前缀来标明要使用的本地路径,以区别于使用 Terraform Registry 路径。 本地路径引用模块和其他源类型有一个区别,本地路径引用的模块不需要下载相关源代码,代码已经存在于本地相关路径的磁盘上了。 Terraform Registry Registry 目前是 Terraform 官方力推的模块仓库方案,采用了 Terraform 定制的协议,支持版本化管理和使用模块。 官方提供的公共仓库保存和索引了大量公共模块,在这里可以很容易地搜索到各种官方和社区提供的高质量模块。 读者也可以通过 Terraform Cloud 服务维护一个私有模块仓库,或是通过实现 Terraform 模块注册协议来实现一个私有仓库。 公共仓库的的模块可以用 // 形式的源地址来引用,在公共仓库上的模块介绍页面上都包含了确切的源地址,例如: module \"consul\" { source = \"hashicorp/consul/aws\" version = \"0.1.0\" } 对于那些托管在其他仓库的模块,在源地址头部添加 / 部分,指定私有仓库的主机名: module \"consul\" { source = \"app.terraform.io/example-corp/k8s-cluster/azurerm\" version = \"1.1.0\" } 如果你使用的是 SaaS 版本的 Terraform Cloud,那么托管在上面的私有仓库的主机名是 app.terraform.io。如果使用的是私有部署的 Terraform 企业版,那么托管在上面的私有仓库的主机名就是 Terraform 企业版服务的主机名。 模块仓库支持版本化。你可以在 module 块中指定模块的版本约束。 如果要引用私有仓库的模块,你需要首先通过配置命令行工具配置文件来设置访问凭证。 GitHub Terraform 发现 source 参数的值如果是以 github.com 为前缀时,会将其自动识别为一个 GitHub 源: module \"consul\" { source = \"github.com/hashicorp/example\" } 上面的例子里会自动使用 HTTPS 协议克隆仓库。如果要使用 SSH 协议,那么请使用如下的地址: module \"consul\" { source = \"git@github.com:hashicorp/example.git\" } GitHub 源的处理与后面要介绍的通用 Git 仓库是一样的,所以他们获取 git 凭证和通过 ref 参数引用特定版本的方式都是一样的。如果要访问私有仓库,你需要额外配置 git 凭证。 Bitbucket Terraform 发现 source 参数的值如果是以 bitbucket.org 为前缀时,会将其自动识别为一个 Bitbucket 源: module \"consul\" { source = \"bitbucket.org/hashicorp/terraform-consul-aws\" } 这种捷径方法只针对公共仓库有效,因为 Terraform 必须访问 ButBucket API 来了解仓库使用的是 Git 还是 Mercurial 协议。 Terraform 根据仓库的类型来决定将它作为一个 Git 仓库还是 Mercurial 仓库来处理。后面的章节会介绍如何为访问仓库配置访问凭证以及指定要使用的版本号。 通用 Git 仓库 可以通过在地址开头加上特殊的 git:: 前缀来指定使用任意的 Git 仓库。在前缀后跟随的是一个合法的 Git URL。 使用 HTTPS 和 SSH 协议的例子: module \"vpc\" { source = \"git::https://example.com/vpc.git\" } module \"storage\" { source = \"git::ssh://username@example.com/storage.git\" } Terraform 使用 git clone 命令安装模块代码,所以 Terraform 会使用本地 Git 系统配置,包括访问凭证。要访问私有 Git 仓库,必须先配置相应的凭证。 如果使用了 SSH 协议,那么会自动使用系统配置的 SSH 证书。通常情况下我们通过这种方法访问私有仓库,因为这样可以不需要交互式提示就可以访问私有仓库。 如果使用 HTTP/HTTPS 协议,或是其他需要用户名、密码作为凭据,你需要配置 Git 凭据存储来选择一个合适的凭据源。 默认情况下,Terraform 会克隆默认分支。可以通过 ref 参数来指定版本: module \"vpc\" { source = \"git::https://example.com/vpc.git?ref=v1.2.0\" } ref 参数会被用作 git checkout 命令的参数,可以是分支名或是 tag 名。 使用 SSH 协议时,我们更推荐 ssh:// 的地址。你也可以选择 scp 风格的语法,故意忽略 ssh:// 的部分,只留 git::,例如: module \"storage\" { source = \"git::username@example.com:storage.git\" } 通用 Mercurial 仓库 可以通过在地址开头加上特殊的 hg:: 前缀来指定使用任意的 Mercurial 仓库。在前缀后跟随的是一个合法的 Mercurial URL: module \"vpc\" { source = \"hg::http://example.com/vpc.hg\" } Terraform 会通过运行 hg clone 命令从 Mercurial 仓库安装模块代码,所以 Terraform 会使用本地 Mercurial 系统配置,包括访问凭证。要访问私有 Mercurial 仓库,必须先配置相应的凭证。 如果使用了 SSH 协议,那么会自动使用系统配置的 SSH 证书。通常情况下我们通过这种方法访问私有仓库,因为这样可以不需要交互式提示就可以访问私有仓库。 类似 Git 源,我们可以通过 ref 参数指定非默认的分支或者标签来选择特定版本: module \"vpc\" { source = \"hg::http://example.com/vpc.hg?ref=v1.2.0\" } HTTP 地址 当我们使用 HTTP 或 HTTPS 地址时,Terraform 会向指定 URL 发送一个 GET 请求,期待返回另一个源地址。这种间接的方法使得 HTTP 可以成为一个更复杂的模块源地址的指示器。 然后 Terraform 会再发送一个 GET 请求到之前响应的地址上,并附加一个查询参数 terraform-get=1,这样服务器可以选择当 Terraform 来查询时可以返回一个不一样的地址。 如果相应的状态码是成功的(200 范围的成功状态码),Terraform 就会通过以下位置来获取下一个访问地址: 响应头部的 X-Terraform-Get 值 如果响应内容是一个 HTML 页面,那么会检查名为 terraform-get 的 html meta 元素: 不管用哪种方式返回的地址,Terraform 都会像本章提到的其他的源地址那样处理它。 如果 HTTP/HTTPS 地址需要认证凭证,可以在 HOME 文件夹下配置一个 .netrc 文件,详见相关文档 也有一种特殊情况,如果 Terraform 发现地址有着一个常见的存档文件的后缀名,那么 Terraform 会跳过 terraform-get=1 重定向的步骤,直接将响应内容作为模块代码使用。 module \"vpc\" { source = \"https://example.com/vpc-module.zip\" } 目前支持的后缀名有: zip tar.bz2和tbz2 tar.gz和tgz tar.xz和txz 如果 HTTP 地址不以这些文件名结尾,但又的确指向模块存档文件,那么可以使用 archive 参数来强制按照这种行为处理地址: module \"vpc\" { source = \"https://example.com/vpc-module?archive=zip\" } S3 Bucket 你可以把模块存档保存在 AWS S3 桶里,使用 s3:: 作为地址前缀,后面跟随一个 S3 对象访问地址 module \"consul\" { source = \"s3::https://s3-eu-west-1.amazonaws.com/examplecorp-terraform-modules/vpc.zip\" } Terraform 识别到 s3:: 前缀后会使用 AWS 风格的认证机制访问给定地址。这使得这种源地址也可以搭配其他提供了 S3 协议兼容的对象存储服务使用,只要他们的认证方式与 AWS 相同即可。 保存在 S3 桶内的模块存档文件格式必须与上面 HTTP 源提到的支持的格式相同,Terraform 会下载并解压缩模块代码。 模块安装器会从以下位置寻找AWS凭证,按照优先级顺序排列: AWS_ACCESS_KEY_ID 和 AWS_SECRET_ACCESS_KEY 环境变量 HOME 目录下 .aws/credentials 文件内的默认 profile 如果是在 AWS EC2 主机内运行的,那么会尝试使用搭载的 IAM 主机实例配置。 GCS Bucket 你可以把模块存档保存在谷歌云 GCS 储桶里,使用 gcs:: 作为地址前缀,后面跟随一个 GCS 对象访问地址: module \"consul\" { source = \"gcs::https://www.googleapis.com/storage/v1/modules/foomodule.zip\" } 模块安装器会使用谷歌云 SDK 的凭据来访问 GCS。要设置凭据,你可以: 通过 GOOGLE_APPLICATION_CREDENTIALS 环境变量配置服务账号的密钥文件 如果是在谷歌云主机上运行的 Terraform,可以使用默认凭据。访问相关文档获取完整信息 可以使用命令行 gcloud auth application-default login 设置 直接引用子文件夹中的模块 引用版本控制系统或是对象存储服务中的模块时,模块本身可能存在于存档文件的一个子文件夹内。我们可以使用特殊的 // 语法来指定 Terraform 使用存档内特定路径作为模块代码所在位置,例如: hashicorp/consul/aws//modules/consul-cluster git::https://example.com/network.git//modules/vpc https://example.com/network-module.zip//modules/vpc s3::https://s3-eu-west-1.amazonaws.com/examplecorp-terraform-modules/network.zip//modules/vpc 如果源地址中包含又参数,例如指定特定版本号的 ref 参数,那么把子文件夹路径放在参数之前: git::https://example.com/network.git//modules/vpc?ref=v1.2.0 Terraform 会解压缩整个存档文件后,读取特定子文件夹。所以,对于一个存在于子文件夹中的模块来说,通过本地路径引用同一个存档内的另一个模块是安全的。 使用模块 我们刚才介绍了如何用 source 指定模块源,下面我们继续讲解如何在代码中使用一个模块。 我们可以把模块理解成类似函数,如同函数有输入参数表和输出值一样,我们之前介绍过 Terraform 代码有输入变量和输出值。我们在 module 块的块体内除了 source 参数,还可以对该模块的输入变量赋值: module \"servers\" { source = \"./app-cluster\" servers = 5 } 在这个例子里,我们将会创建 ./app-cluster 文件夹下 Terraform 声明的一系列资源,该模块的 servers 输入变量的值被我们设定成了5。 在代码中新增、删除或是修改一个某块的 source,都需要重新运行 terraform init 命令。默认情况下,该命令不会升级已安装的模块(例如 source 未指定版本,过去安装了旧版本模块代码,那么执行 terraform init 不会自动更新到新版本);可以执行 terraform init -upgrade 来强制更新到最新版本模块。 访问模块输出值 在模块中定义的资源和数据源都是被封装的,所以模块调用者无法直接访问它们的输出属性。然而,模块可以声明一系列输出值,来选择性地输出特定的数据供模块调用者使用。 举例来说,如果 ./app-cluster 模块定义了名为 instance_ids 的输出值,那么模块的调用者可以像这样引用它: resource \"aws_elb\" \"example\" { # ... instances = module.servers.instance_ids } 其他的模块元参数 除了 source 以外,目前 Terraform 还支持在 module 块上声明其他一些可选元参数: version:指定引用的模块版本,在后面的部分会详细介绍 count 和 for_each:这是 Terraform 0.13 开始支持的特性,类似 resource 与 data,我们可以创建多个 module 实例 providers:通过传入一个 map 我们可以指定模块中的 Provider 配置,我们将在后面详细介绍 depends_on:创建整个模块和其他资源之间的显式依赖。直到依赖项创建完毕,否则声明了依赖的模块内部所有的资源及内嵌的模块资源都会被推迟处理。模块的依赖行为与资源的依赖行为相同 除了上述元参数以外,lifecycle 参数目前还不能被用于模块,但关键字被保留以便将来实现。 模块版本约束 使用 registry 作为模块源时,可以使用 version 元参数约束使用的模块版本: module \"consul\" { source = \"hashicorp/consul/aws\" version = \"0.0.5\" servers = 3 } version 元参数的格式与 Provider 版本约束的格式一致。在满足版本约束的前提下,Terraform 会使用当前已安装的最新版本的模块实例。如果当前没有满足约束的版本被安装过,那么会下载符合约束的最新的版本。 version 元参数只能配合 registry 使用,公共的或者私有的模块仓库都可以。其他类型的模块源可能支持版本化,也可能不支持。本地路径模块不支持版本化。 多实例模块 可以通过在 module 块上声明 for_each 或者 count 来创造多实例模块。在使用上 module 上的 for_each 和 count 与资源、数据源块上的使用是一样的。 # my_buckets.tf module \"bucket\" { for_each = toset([\"assets\", \"media\"]) source = \"./publish_bucket\" name = \"${each.key}_bucket\" } # publish_bucket/bucket-and-cloudfront.tf variable \"name\" {} # this is the input parameter of the module resource \"aws_s3_bucket\" \"example\" { # Because var.name includes each.key in the calling # module block, its value will be different for # each instance of this module. bucket = var.name # ... } resource \"aws_iam_user\" \"deploy_user\" { # ... } 这个例子定义了一个位于 ./publish_bucket 目录下的本地子模块,模块创建了一个 S3 存储桶,封装了桶的信息以及其他实现细节。 我们通过 for_each 参数声明了模块的多个实例,传入一个 map 或是 set 作为参数值。另外,因为我们使用了 for_each,所以在 module 块里可以使用 each 对象,例子里我们使用了 each.key。如果我们使用的是 count 参数,那么我们可以使用 count.index。 子模块里创建的资源在执行计划或UI中的名称会以 module.module_name[module index] 作为前缀。如果一个模块没有声明 count 或者 for_each,那么资源地址将不包含 module index。 在上面的例子里,./publish_bucket 模块包含了 aws_s3_bucket.example 资源,所以两个 S3 桶实例的名字分别是module.bucket[\"assets\"].aws_s3_bucket.example 以及 module.bucket[\"media\"].aws_s3_bucket.example。 模块内的 Provider 当代码中声明了多个模块时,资源如何与 Provider 实例关联就需要特殊考虑。 每一个资源都必须关联一个 Provider 配置。不像 Terraform 其他的概念,Provider 配置在 Terraform 项目中是全局的,可以跨模块共享。Provider 配置声明只能放在根模块中。 Provider 有两种方式传递给子模块:隐式继承,或是显式通过 module 块的 providers 参数传递。 一个旨在被复用的模块不允许声明任何 provider 块,只有使用\"代理 Provider\"模式的情况除外,我们后面会介绍这种模式。 出于向前兼容 Terraform 0.10 及更早版本的考虑,Terraform 目前在模块代码中只用到了 Terraform 0.10 及更早版本的功能时,不会针对模块代码中声明 provider 块报错,但这是一个不被推荐的遗留模式。一个含有自己的 provider 块定义的遗留模块与 for_each、count 和 depends_on 等 0.13 引入的新特性是不兼容的。 Provider 配置被用于相关资源的所有操作,包括销毁远程资源对象以及更新状态信息等。Terraform 会在状态文件中保存针对最近用来执行所有资源变更的 Provider 配置的引用。当一个 resource 块被删除时,状态文件中的相关记录会被用来定位到相应的配置,因为原来包含 provider 参数(如果声明了的话)的 resource 块已经不存在了。 这导致了,你必须确保删除所有相关的资源配置定义以后才能删除一个 Provider 配置。如果 Terraform 发现状态文件中记录的某个资源对应的 Provider 配置已经不存在了会报错,要求你重新给出相关的 Provider 配置。 模块内的 Provider 版本限制 虽然 Provider 配置信息在模块间共享,每个模块还是得声明各自的模块需求,这样 Terraform 才能决定一个适用于所有模块配置的 Provider 版本。 为了定义这样的版本约束要求,可以在 terraform 块中使用 required_providers 块: terraform { required_providers { aws = { source = \"hashicorp/aws\" version = \">= 2.7.0\" } } } 有关 Provider 的 source 和版本约束的信息我们已经在前文中有所记述,在此不再赘述。 隐式 Provider 继承 为了方便,在一些简单的代码中,一个子模块会从调用者那里自动地继承默认的 Provider 配置。这意味着显式 provider 块声明仅位于根模块中,并且下游子模块可以简单地声明使用该类型 Provider 的资源,这些资源会自动关联到根模块的 Provider 配置上。 例如,根模块可能只含有一个 provider 块和一个 module 块: provider \"aws\" { region = \"us-west-1\" } module \"child\" { source = \"./child\" } 子模块可以声明任意关联 aws 类型 Provider 的资源而无需额外声明 Provider 配置: resource \"aws_s3_bucket\" \"example\" { bucket = \"provider-inherit-example\" } 当每种类型的 Provider 都只有一个实例时我们推荐使用这种方式。 要注意的是,只有 Provider 配置会被子模块继承,Provider 的 source 或是版本约束条件则不会被继承。每一个模块都必须声明各自的 Provider 需求条件,这在使用非 HashiCorp 的 Provider 时尤其重要。 显式传递 Provider 当不同的子模块需要不同的 Provider 实例,或者子模块需要的 Provider 实例与调用者自己使用的不同时,我们需要在 module 块上声明 providers 参数来传递子模块要使用的 Provider 实例。例如: # The default \"aws\" configuration is used for AWS resources in the root # module where no explicit provider instance is selected. provider \"aws\" { region = \"us-west-1\" } # An alternate configuration is also defined for a different # region, using the alias \"usw2\". provider \"aws\" { alias = \"usw2\" region = \"us-west-2\" } # An example child module is instantiated with the alternate configuration, # so any AWS resources it defines will use the us-west-2 region. module \"example\" { source = \"./example\" providers = { aws = aws.usw2 } } module 块里的 providers 参数类似 resource 块里的 provider 参数,区别是前者接收的是一个 map 而不是单个 string,因为一个模块可能含有多个不同的 Provider。 providers 的 map 的键就是子模块中声明的 Provider 需求中的名字,值就是在当前模块中对应的 Provider 配置的名字。 如果 module 块内声明了 providers 参数,那么它将重载所有默认的继承行为,所以你需要确保给定的 map 覆盖了子模块所需要的所有 Provider。这避免了显式赋值与隐式继承混用时带来的混乱和意外。 额外的 Provider 配置(使用 alias 参数的)将永远不会被子模块隐式继承,所以必须显式通过 providers 传递。比如,一个模块配置了两个 AWS 区域之间的网络打通,所以需要配置一个源区域 Provider 和目标区域 Provider。这种情况下,根模块代码看起来是这样的: provider \"aws\" { alias = \"usw1\" region = \"us-west-1\" } provider \"aws\" { alias = \"usw2\" region = \"us-west-2\" } module \"tunnel\" { source = \"./tunnel\" providers = { aws.src = aws.usw1 aws.dst = aws.usw2 } } 子目录 ./tunnel 必须包含像下面的例子那样声明\"Provider 代理\",声明模块调用者必须用这些名字传递的 Provider 配置: provider \"aws\" { alias = \"src\" } provider \"aws\" { alias = \"dst\" } ./tunnel 模块中的每一种资源都应该通过 provider 参数声明它使用的是 aws.src 还是 aws.dst。 Provider 代理配置块 一个 Provider 代理配置只包含 alias 参数,它就是一个模块间传递 Provider 配置的占位符,声明了模块期待显式传递的额外(带有 alias 的)Provider 配置。 需要注意的是,一个完全为空的 Provider 配置块也是合法的,但没有必要。只有在模块内需要带 alias 的 Provider 时才需要代理配置块。如果模块中只是用默认 Provider 时请不要声明代理配置块,也不要仅为了声明 Provider 版本约束而使用代理配置块。 "},"5.Terraform模块/3.模块元参数.html":{"url":"5.Terraform模块/3.模块元参数.html","title":"模块元参数","keywords":"","body":"模块元参数 在 Terraform 0.13 之前,模块在使用上存在一些限制。例如我们通过模块来创建 EC2 主机,可以这样: module \"ec2_instance\" { source = \"terraform-aws-modules/ec2-instance/aws\" version = \"~> 3.0\" name = \"single-instance\" ami = \"ami-ebd02392\" instance_type = \"t2.micro\" key_name = \"user1\" monitoring = true vpc_security_group_ids = [\"sg-12345678\"] subnet_id = \"subnet-eddcdzz4\" tags = { Terraform = \"true\" Environment = \"dev\" } } 如果我们要创建两台这样的主机怎么办?在 Terraform 0.13 之前的版本中,由于 Module 不支持元参数,所以我们只能手动拷贝模块代码: module \"ec2_instance_0\" { source = \"terraform-aws-modules/ec2-instance/aws\" version = \"~> 3.0\" name = \"single-instance-0\" ami = \"ami-ebd02392\" instance_type = \"t2.micro\" key_name = \"user1\" monitoring = true vpc_security_group_ids = [\"sg-12345678\"] subnet_id = \"subnet-eddcdzz4\" tags = { Terraform = \"true\" Environment = \"dev\" } } module \"ec2_instance_1\" { source = \"terraform-aws-modules/ec2-instance/aws\" version = \"~> 3.0\" name = \"single-instance-1\" ami = \"ami-ebd02392\" instance_type = \"t2.micro\" key_name = \"user1\" monitoring = true vpc_security_group_ids = [\"sg-12345678\"] subnet_id = \"subnet-eddcdzz4\" tags = { Terraform = \"true\" Environment = \"dev\" } } 自从 Terraform 0.13 开始,模块也像资源一样,支持count、for_each、depends_on三种元参数。比如我们可以这样: module \"ec2_instance\" { count = 2 source = \"terraform-aws-modules/ec2-instance/aws\" version = \"~> 3.0\" name = \"single-instance-${count.index}\" ami = \"ami-ebd02392\" instance_type = \"t2.micro\" key_name = \"user1\" monitoring = true vpc_security_group_ids = [\"sg-12345678\"] subnet_id = \"subnet-eddcdzz4\" tags = { Terraform = \"true\" Environment = \"dev\" } } 要注意的是 Terraform 0.13 之后在模块上声明depends_on,列表中也可以传入另一个模块。声明depends_on的模块中的所有资源的创建都会发生在被依赖的模块中所有资源创建完成之后。 "},"5.Terraform模块/4.重构.html":{"url":"5.Terraform模块/4.重构.html","title":"重构","keywords":"","body":"重构 请注意,本节介绍的通过 moved 块进行模块重构的功能是从 Terraform v1.1 开始被引入的。如果要在之前的版本进行这样的操作,必须通过 terraform state mv 命令来完成。 对于一些旨在被人复用的老模块来说,最初的模块结构和资源名称可能会逐渐变得不再合适。例如,我们可能发现将以前的一个子模块分割成两个单独的模块会更合理,这需要将现有资源的一个子集移动到新的模块中。 Terraform 将以前的状态与新代码进行比较,资源与每个模块或资源的唯一地址相关联。因此,默认情况下,移动或重命名对象会被 Terraform 理解为销毁旧地址的对象并在新地址创建新的对象。 当我们在代码中添加 moved 块以记录我们移动或重命名对象过去的地址时,Terraform 会将旧地址的现有对象视为现在属于新地址。 moved 块语法 moved 块只包含 from 和 to 参数,没有名称: moved { from = aws_instance.a to = aws_instance.b } 上面的例子演示了模块先前版本中的 aws_instance.a 如今以 aws_instance.b 的名字存在。 在为 aws_instance.b 创建新的变更计划之前,Terraform 会首先检查当前状态中是否存在地址为 aws_instance.a 的记录。如果存在该记录,Terraform 会将之重命名为 aws_instance.b 然后继续创建变更计划。最终生成的变更计划中该对象就好像一开始就是以 aws_instance.b 的名字被创建的,防止它在执行变更时被删除。 from 和 to 的地址使用一种特殊的地址语法,该语法允许选定模块、资源以及子模块中的资源。下面是几种不同的重构场景中所需要的地址语法: 重命名一个资源 考虑模块代码中这样一个资源: resource \"aws_instance\" \"a\" { count = 2 # (resource-type-specific configuration) } 第一次应用该代码时 Terraform 会创建 aws_instance.a[0] 以及 aws_instance.a[1]。 如果随后我们修改了该资源的名称,并且把旧名字记录在一个 moved 块里: resource \"aws_instance\" \"b\" { count = 2 # (resource-type-specific configuration) } moved { from = aws_instance.a to = aws_instance.b } 当下一次应用使用了该模块的代码时,Terraform 会把所有地址为 aws_instance.a 的对象看作是一开始就以 aws_instance.b 的名字创建的:aws_instance.a[0] 会被看作是 aws_instance.b[0],aws_instance.a[1] 会被看作是 aws_instance.b[1]。 新创建的模块实例中,因为从来就不存在 aws_instance.a,于是会忽略 moved 块而像通常那样直接创建 aws_instance.b[0] 以及 aws_instance.b[1]。 为资源添加 count 或 for_each 声明 一开始代码中有这样一个单实例资源: resource \"aws_instance\" \"a\" { # (resource-type-specific configuration) } 应用该代码会使得 Terraform 创建了一个地址为 aws_instance.a 的资源对象。 随后我们想要在该资源上添加 for_each 来创建多个实例。为了保持先前关联到 aws_instance.a 的资源对象不受影响,我们必须添加一个 moved 块来指定新代码中原先的对象实例所关联的键是什么: locals { instances = tomap({ big = { instance_type = \"m3.large\" } small = { instance_type = \"t2.medium\" } }) } resource \"aws_instance\" \"a\" { for_each = local.instances instance_type = each.value.instance_type # (other resource-type-specific configuration) } moved { from = aws_instance.a to = aws_instance.a[\"small\"] } 上面的代码会防止 Terraform 在变更计划中销毁已经存在的 aws_instance.a 对象,并且将其看作是以 aws_instance.a[\"small\"] 的地址创建的。 当 moved 块的两个地址中的至少一个包含实例键时,如上例中的 [\"small\"],Terraform 将这两个地址理解为引用资源的特定实例而不是整个资源。这意味着您可以使用 moved 在键之间切换以及在 count、for_each 之间切换时添加和删除键。 下面的例子演示了几种其他类似的记录了资源实例键变更的合法 moved 块: # Both old and new configuration used \"for_each\", but the # \"small\" element was renamed to \"tiny\". moved { from = aws_instance.b[\"small\"] to = aws_instance.b[\"tiny\"] } # The old configuration used \"count\" and the new configuration # uses \"for_each\", with the following mappings from # index to key: moved { from = aws_instance.c[0] to = aws_instance.c[\"small\"] } moved { from = aws_instance.c[1] to = aws_instance.c[\"tiny\"] } # The old configuration used \"count\", and the new configuration # uses neither \"count\" nor \"for_each\", and you want to keep # only the object at index 2. moved { from = aws_instance.d[2] to = aws_instance.d } 注意:当我们在原先没有声明 count 的资源上添加 count 时,Terraform 会自动将原先的对象移动到第 0 个位置,除非我们通过一个 moved 块显式声明该资源。然而,我们建议使用 moved 块显式声明资源的移动,使得读者在未来阅读模块的代码时能够更清楚地了解到这些变更。 重命名对模块的调用 我们可以用类似重命名资源的方式来重命名对模块的调用。假设我们开始用以下代码调用一个模块: module \"a\" { source = \"../modules/example\" # (module arguments) } 当应用该代码时,Terraform 会在模块内声明的资源路径前面加上一个模块路径前缀 module.a。比方说,模块内的 aws_instance.example 的完整地址为 module.a.aws_instance.example。 如果我们随后打算修改模块名称,我们可以直接修改 module 块的标签,并且在一个 moved 块内部记录该变更: module \"b\" { source = \"../modules/example\" # (module arguments) } moved { from = module.a to = module.b } 当下一次应用包含该模块调用的代码时,Terraform 会将所有路径前缀为 module.a 的对象看作从一开始就是以 module.b 为前缀创建的。module.a.aws_instance.example 会被看作是 module.b.aws_instance.example。 该例子中的 moved 块中的两个地址都代表对模块的调用,而 Terraform 识别出将原模块地址中所有的资源移动到新的模块地址中。如果该模块声明时使用了 count 或是 for_each,那么该移动也将被应用于所有的实例上,不需要逐个指定。 为模块调用添加 count 或 for_each 声明 考虑一下单实例的模块: module \"a\" { source = \"../modules/example\"q # (module arguments) } 应用该段代码会导致 Terraform 创建的资源地址都拥有 module.a 的前缀。 随后如果我们可能需要再通过添加 count 来创建多个资源实例。为了保留先前的 aws_instance.a 实例不受影响,我们可以添加一个 moved 块来设置在新代码中该实例的对应的键。 module \"a\" { source = \"../modules/example\" count = 3 # (module arguments) } moved { from = module.a to = module.a[2] } 上面的代码引导 Terraform 将所有 module.a 中的资源看作是从一开始就是以 module.a[2] 的前缀被创建的。结果就就是,Terraform 生成的变更计划中只会创建 module.a[0] 以及 module.a[1]。 当 moved 块的两个地址中的至少一个包含实例键时,例如上面例子中的 [2]那样,Terraform 会理解将这两个地址理解为对模块的特定实例的调用而非对模块所有实例的调用。这意味着我们可以使用 moved 块在不同键之间切换来添加或是删除键,该机制可用于 count 和 for_each,或删除模块上的这种声明。 将一个模块分割成多个模块 随着模块提供的功能越来越多,最终模块可能变得过大而不得不将之拆分成两个独立的模块。 我们看一下下面的这个例子: resource \"aws_instance\" \"a\" { # (other resource-type-specific configuration) } resource \"aws_instance\" \"b\" { # (other resource-type-specific configuration) } resource \"aws_instance\" \"c\" { # (other resource-type-specific configuration) } 我们可以将该模块分割为三个部分: aws_instance.a 现在归属于模块 \"x\"。 aws_instance.b 也属于模块 \"x\"。 aws_instance.c 现在归属于模块 \"y\"。 要在不替换绑定到旧资源地址的现有对象的情况下实现此重构,我们需要: 编写模块 \"x\",将属于它的两个资源拷贝过去。 编写模块 \"y\",将属于它的一个资源拷贝过去。 编辑原有模块代码,删除这些资源,只包含有关迁移现有资源的非常简单的配置代码。 新的模块 \"x\" 和 \"y\" 应该只包含 resource 块: # module \"x\" resource \"aws_instance\" \"a\" { # (other resource-type-specific configuration) } resource \"aws_instance\" \"b\" { # (other resource-type-specific configuration) } # module \"y\" resource \"aws_instance\" \"c\" { # (other resource-type-specific configuration) } 而原有模块则被修改成只包含有向下兼容逻辑的垫片,调用两个新模块,并使用 moved 块定义哪些资源被移动到新模块中去了: module \"x\" { source = \"../modules/x\" # ... } module \"y\" { source = \"../modules/y\" # ... } moved { from = aws_instance.a to = module.x.aws_instance.a } moved { from = aws_instance.b to = module.x.aws_instance.b } moved { from = aws_instance.c to = module.y.aws_instance.c } 当一个原模块的调用者升级模块版本到这个“垫片”版本时,Terraform 会注意到这些 moved 块,并将那些关联到老地址的资源对象看作是从一开始就是由新模块创建的那样。 该模块的新用户可以选择使用这个垫片模块,或是独立调用两个新模块。我们需要通知老模块的现有用户老模块已被废弃,他们将来的开发中需要独立使用这两个新模块。 多模块重构的场景是不多见的,因为它违反了父模块将其子模块视为黑盒的典型规则,不知道在其中声明了哪些资源。这种妥协的前提是假设所有这三个模块都由同一个人维护并分布在一个模块包中。 为避免独立模块之间的耦合,Terraform 只允许声明在同一个目录下的模块间的移动。换句话讲,Terraform 不允许将资源移动到一个 source 地址不是本地路径的模块中去。 Terraform 使用定义 moved 块的模块实例的地址的地址来解析 moved 块中的相对地址。例如,如果上面的原模块已经是名为 module.original 的子模块,则原模块中对 module.x.aws_instance.a 的引用在根模块中将被解析为 module.original.module.x.aws_instance.a。一个模块只能针对它自身或是它的子模块中的资源声明 moved 块。 如果需要引用带有 count 或 for_each 元参数的模块中的资源,则必须指定要使用的特定实例键以匹配资源配置的新位置: moved { from = aws_instance.example to = module.new[2].aws_instance.example } 删除 moved 块 随着时间的推移,一些老模块可能会积累大量 moved 块。 删除 moved 块通常是一种破坏性变更,因为删除后所有使用旧地址引用的对象都将被删除而不是被移动。我们强烈建议保留历史上所有的 moved 块来保存用户从任意版本升级到当前版本的升级路径信息。 如果我们决定要删除 moved 块,需要谨慎行事。对于组织内部的私有模块来说删除 moved 块可能是安全的,因为我们可以确认所有用户都已经使用新版本模块代码运行过 terraform apply 了。 如果我们需要多次重命名或是移动一个对象,我们建议使用串联的 moved 块来记录完整的变更信息,新的块引用已有的块: moved { from = aws_instance.a to = aws_instance.b } moved { from = aws_instance.b to = aws_instance.c } 像这样记录下移动的序列可以使 aws_instance.a 以及 aws_instance.b 两种地址的资源都得到成功更新,Terraform 会将他们视作从一开始就是以 aws_instance.c 的地址创建的。 删除模块 注意:removed 块是在 Terraform v1.7 引入的功能。对于早期的 Terraform 版本,您可以使用 terraform state rm 命令来处理。 要从 Terraform 中删除模块,只需从 Terraform 代码中删除模块调用即可。 默认情况下,删除模块块后,Terraform 将计划销毁由该模块中声明的所有资源。这是因为当您删除模块调用时,该模块的代码将不再包含在我们当前的 Terraform 代码中。 有时我们可能希望从 Terraform 代码中删除模块而不破坏它管理的实际基础设施对象。在这种情况下,资源将从 Terraform 状态中删除,但真正的基础设施对象不会被销毁。 要声明模块已从 Terraform 配置中删除,但不应销毁其托管对象,请从配置中删除 module 块并将其替换为 removed 块: removed { from = module.example lifecycle { destroy = false } } from 参数是要删除的模块的地址,不带任何实例键(例如 module.example[1])。 lifecycle 块是必需的。 destroy 参数确定 Terraform 是否会尝试销毁模块管理的对象。 false 值表示 Terraform 将从状态中删除资源而不破坏它们。 "},"5.Terraform模块/5.设计新模块的模式.html":{"url":"5.Terraform模块/5.设计新模块的模式.html","title":"设计新模块的模式","keywords":"","body":"设计新模块的模式 Terraform 模块是独立的基础设施即代码片段,抽象了基础设施部署的底层复杂性。Terraform 用户通过使用预置的配置代码加速采用 IaC,并降低了使用门槛。所以,模块的作者应尽量遵循诸如清晰的代码结构以及 DRY(\"Dont't Repeat Yourself\")原则的代码最佳实践。 本篇指导讨论了模块架构的原则,用以帮助读者编写易于组合、易于分享及重用的基础设施模块。这些架构建议对使用任意版本 Terraform 的企业都有助益,某些诸如“私有模块注册表(Registry)”的模式仅在 Terraform Cloud 以及企业版中才能使用。(本文不对相关内容进行翻译) 本文是对 Terraform 模块文档的补充和扩展。 通过阅读文本,读者可以: 学习有关 Terraform 模块创建的典型工作流程和基本原则。 探索遵循这些原则的示例场景。 学习如何通过协作改进 Terraform 模块 了解如何创建一套使用模块的工作流程。 模块创建的工作流 要创建一个新模块,第一步是寻找一个早期采纳者团队,收集他们的需求。 与这支早期采纳团队一起工作使我们可以通过使用输入变量以及输出值来确保模块足够灵活,从而打磨模块的功能。此外,还可以用最小的代码变更代价吸纳其他有类似需求的团队加入进来。这消除了代码重复,并缩短了交付时间。 完成以上任务后,需要谨记两点: 将需求范围划分成合适的模块。 创建模块的最小可行产品(Minimum Viable Product, MVP) 将需求范围划分成合适的模块 创建新 Terraform 模块时最具挑战的方面之一是决定要包含哪些基础设施资源。 模块设计应该是有主见的,并且被设计成能很好地完成一个目标。如果一个模块的功能或目的很难解释,那么这个模块可能太复杂了。在最初确定模块的范围时,目标应当足够小且简单,易于开始编写。 当构建一个模块时,需要考虑以下三个方面: 封装:一组始终被一起部署的基础设施资源 在模块中包含更多的基础设施资源简化了终端用户部署基础设施的工作,但会使得模块的目的与需求变得更难理解。 职责:限制模块职责的边界 如果模块中的基础设施资源由多个组来负责,使用该模块可能会意外违反职责分离原则。模块中仅包含职责边界内的一组资源可以提升基础设施资源的隔离性,并保护我们的基础设施。 变化频率:隔离长短生命周期基础设施资源 举例来说,数据库基础设施资源相对来说较为静态,而团队可能在一天内多次部署更新应用程序服务器。在同一个模块中同时管理数据库与应用程序服务器使得保存状态数据的重要基础设施没有必要地暴露在数据丢失的风险之中。 创建模块的最小可行产品 如同所有类型的代码一样,模块的开发永远不会完成,永远会有新的模块需求以及变更。拥抱变化,最初的模块版本应致力于满足最小可行产品(MVP)的标准。以下是在设计最小可行产品时需要谨记的指导清单: 永远致力于交付至少可以满足 80% 场景的模块 模块中永远不要处理边缘场景。边缘场景是很少见的。一个模块应该是一组可重用的代码。 在最小可行产品中避免使用条件表达式。最小可行产品应缩小范围,不应该同时完成多种任务。 模块应该只将最常被修改的参数公开为输入变量。一开始时,模块应该只提供最可能需要的输入变量。 尽可能多输出 在最小可行产品中输出尽可能多的信息,哪怕目前没有用户需要这些信息。这使得那些通常使用多个模块的终端用户在使用该模块时更加轻松,可以使用一个模块的输出作为下一个模块的输入。 请记住在模块的 README 文档中记录输出值的文档。 探索遵循这些原则的一个示例场景 某团队想要通过 Terraform 创建一套包含 Web 层应用、App 层应用的基础设施。 他们想要使用一个专用的 VPC,并遵循传统的三层架构设计。他们的 Web 层应用需要一个自动伸缩组(AutoScaling Group)。他们的 App 层服务需要一个自动伸缩组,一个 S3 存储桶以及一个数据库。下面的架构图描述了期望的结果: 该场景中,一个负责从零开始撰写 Terraform 代码的团队,负责编写一组用以配置基础设施及应用的模块。负责应用程序的团队成员将使用这些模块来配置他们需要的基础设施。 请注意,虽然该示例使用了 AWS 命名,但所描述的模式适用于所有云平台。 经过对应用程序团队的需求进行审核,模块团队将该应用基础设施分割成如下模块:网络、Web、App、数据库、路由,以及安全。 当 Terraform 模块团队完成模块开发后,他们应该将模块导入到私有模块注册表中,并且向对应的团队成员宣传模块的使用方法。举例来说,负责网络的团队成员将会使用开发的网络模块来部署和配置相应的应用程序网络。 网络模块 网络模块负责网络基础设施。它包含了网络访问控制列表(ACL)以及 NAT 网关。它也可以包含应用程序所需的 VPC、子网、对等连接以及 Direct Connect 等。 该模块包含这些资源是因为它们需要特定权限并且变化频率较低。 只有应用程序团队中有权创建或修改网络资源的成员可以使用该模块。 该模块的资源不会经常变更。将它们组合在单独的模块中可以保护它们免于暴露在没有必要的数据丢失的风险之中。 网络模块返回一组其他工作区(Workspace)以及模块可以使用的输出值。如果 VPC 的创建过程是由多个方面组成的,我们可能最终会需要将该模块进一步切割成拥有不同功能的不同模块。 应用程序模块 本场景中有两个应用程序模块 —— 一个是 Web 层模块,另一个是 App 层模块。 Terraform 模块团队完成这两个模块的开发后,它们应被分发给对应的团队成员来部署他们的应用。随着应用程序团队的成员变得越来越熟悉 Terraform 代码,它们可以提出基础设施方面的增强建议,或是通过 Pull Request 配合他们自己的应用代码发布提交对基础设施的变更请求。 Web 模块 Web 模块创建和管理运行 Web 应用程序所需的基础设施。它包含了负载均衡器和自动伸缩组,同时也可以包含应用程序中使用的 EC2 虚拟机实例、S3 存储桶、安全组,以及日志系统。该模块接收一个通过 Packer 预构建的包含最新 Web 层应用发布版本代码的虚拟机镜像的 AMI ID 作为输入。 该模块包含这些资源是因为它们是高度封装的,并且它们变化频率较高。 此模块中的资源高度内聚,并且与 Web 应用程序紧密相关(例如,此模块需要一个包含最新 Web 层应用程序代码版本的 AMI)。结果就是它们被编制进同一个模块,这样 Web 应用团队的成员们就可以轻松地部署它们。 该模块的资源变更频率较高(每次发布更新版本都需要更新对应基础设施资源)。通过将它们组合在单独的模块中,我们降低了将其他模块的资源暴露在没有必要的数据丢失的风险中的可能性。 App 模块 App 模块创建和管理运行 App 层应用所需的基础设施。它包含了负载均衡器和自动伸缩组,同时也包含了应用程序中使用的 EC2 虚拟机实例、S3 存储桶、安全组,以及日志系统。该模块接收一个通过 Packer 预构建的包含最新 App 层应用发布版本代码的虚拟机镜像的 AMI ID 作为输入。 该模块包含这些资源是因为它们是高度封装的,并且它们变化频率较高。 此模块中的资源高度内聚,并且与 App 应用程序紧密相关。结果就是它们被编制进同一个模块,这样 App 层应用团队的成员们就可以轻松地部署它们。 该模块的资源变更频率较高(每次发布更新版本都需要更新对应基础设施资源)。通过将它们组合在单独的模块中,我们降低了将其他模块的资源暴露在没有必要的数据丢失的风险中的可能性。 数据库模块 数据库模块创建并管理了运行数据库所需的基础设施资源。它包含了应用程序所需的 RDS 实例,也包含了所有关联的存储、备份以及日志资源。 该模块包含这些资源是因为它们需要特定权限并且变化频率较低。 只有应用程序团队中有权创建或修改数据库资源的成员可以使用该模块。 该模块的资源不会经常变更。将它们组合在单独的模块中可以保护它们免于暴露在没有必要的数据丢失的风险之中。 路由模块 路由模块创建并管理网络路由所需的基础设施资源。它包含了公共托管区域(Hosted Zone)、Route 53 以及路由表,也可以包含私有托管区域。 该模块包含这些资源是因为它们需要特定权限并且变化频率较低。 只有应用程序团队中有权创建或修改路由资源的成员可以使用该模块。 该模块的资源不会经常变更。将它们组合在单独的模块中可以保护它们免于暴露在没有必要的数据丢失的风险之中。 安全模块 安全模块创建并管理所有安全所需的基础设施资源。它包含一组 IAM 资源,也可以包含安全组(Security Group)及多因素认证(MFA)。 该模块包含这些资源是因为它们需要特定权限并且变化频率较低。 只有应用程序团队中有权创建或修改 IAM 或是安全资源的成员可以使用该模块。 该模块的资源不会经常变更。将它们组合在单独的模块中可以保护它们免于暴露在没有必要的数据丢失的风险之中。 创建模块的提示 除了范围界定之外,我们在创建模块时还应牢记以下几点: 嵌套模块 嵌套模块是指在当前模块中对另一个模块的引用。嵌套模块可以是外部的,也可以是当前工作空间内的。使用嵌套模块是一项强大的功能;然而我们必须谨慎实践以避免引入错误。 对于所有类型的嵌套模块,请考虑以下事项: 嵌套模块可以加速开发速度,但可能会引发未知以及意料之外的结果。请在文档中清晰地记录输入变量、模块行为以及输出值。 通常来说,不要让主模块的嵌套深度超过两层。常用且简单的工具模块,例如专门用来定义 Tag 的模块,则不受此限制制约。 嵌套模块必须包含必要的用来创建指定的资源配置的输入参数以及输出值。 输入参数以及输出值的命名应遵循一致的命名约定,以使得模块可以更容易地被分享,以及将一个模块的的输出值作为另一个模块的输入参数。 嵌套模块可能会导致代码冗余。必须同时在父模块与嵌套模块中声明输入参数和输出值。 嵌套的外部模块 当我们需要使用那些定义了被多个应用程序堆栈、应用程序和团队复用的标准化资源的通用模块时,嵌套的外部模块会很有用。外部模块通被集中管理和版本化控制,以使得消费者在使用新版本之前可以对其进行验证。当我们依赖或希望使用位于外部的子模块时,请注意以下几点: 外部模块必须被独立维护,并可供任何需要调用它的模块使用。使用模块注册表可以确保这一点。 根据模块注册要求,嵌套模块将拥有自己的版本控制代码仓库,独立于调用模块进行版本控制。 对嵌套模块的变更可能会影响调用模块,即使调用模块的调用代码及版本没有发生变化,这会破坏调用代码的信任。 对调用模块如何使用外部模块在文档中进行记录,使得模块行为以及调用关系可以被轻松理解。 对外部模块的变更应该是向后兼容的。如果向后兼容是不可能的,则应清楚地记录需要对任何调用模块进行的更改,并将之分发给所有模块使用者以避免意外。 嵌套的嵌入模块 在当前工作空间中嵌入一个模块使得我们能够清晰地分离模块的逻辑组件,或是创建可在调用模块执行期间多次调用的可重用代码块。在下面的例子中,ec2-instance 是一个嵌入模块,根模块的 main.tf 引用了该模块: root-module-directory ├── README.md ├── main.tf └── ec2-instances └── main.tf 如果我们需要或者倾向于使用嵌入模块,需要考虑以下几点: 在“根模块”中添加嵌入模块意味着子模块与根模块被放在一起进行版本控制。 任何影响两个模块间兼容性的变更都会被快速发现,因为它们必须被一同测试和发布。 (嵌入的)子模块不能被代码树之外的其他模块调用,所以可能会增加重复的代码。举例来说,如果嵌入的 ec2-instance 模块是用来创建一台被用在多个地方的标准化的计算实例,该模块无法以这种形式被分享。 标签化模块名并记录在文档中 为我们的模块创建并遵循一个命名约定将使得模块易于理解与使用。这将促进模块的采用和贡献。以下是一个用以提升模块元素一致性的建议列表: 使用一个对人类来说一致且易于理解的模块命名约定。举例来说: terraform cloud provider function full name terraform aws consul cluster terraform-aws-consul_cluser terraform aws security module terraform-aws-security terraform azure database terraform-azure-database 使用人类可以理解的输入变量命名约定。模块是编写一次并多次使用的代码,因此请完整命名所有内容以提升可读性,并在编写代码时在文档中进行记录。 对所有模块进行文档记录。确保文档中包含有: 必填的输入变量:这些输入变量应该是经过深思熟虑后的选择。如果这些输入变量值未定义,模块运行将失败。只在必要时为这些输入变量设置默认值。例如 var.vpc_id 永远不应该有默认值,因为每次使用模块时值都会不同。 可选的输入变量:这些输入变量应该有一个合理的,适用于大多数场景的默认值,同时又可以根据需求进行调整。公告输入变量的默认值。例如 var.elb_idle_timeout 会有一个合理的默认值,但调用者也可以根据需求修改它的值。 输出值:列出模块的所有输出值,并将重要的输出和信息性的输出包装在对用户友好的输出模板中。 定义并使用一个一致的模块结构 虽然模块结构是一个品味问题,我们应当将模块的结构记录在文档中,并且在我们的所有模块之间保持统一的结构。为了要维持模块结构的一致: 定义一组模块必须包含的 .tf 文件,定义它们应包含哪些内容 为模块定义一个 .gitignore(或类似作用的)文件 创建供样例代码所使用的输入变量值的标准方式(例如一个 terraform.tfvars.example 文件) 使用具有固定子目录的一致的目录结构,即使它们可能是空的 所有模块目录都必须包含一个 README 文件详细记述目录存在的目的以及如何使用其中的文件 模块的协作 随着团队模块的开发工作,简化我们的协作。 为每个模块创建路线图 从用户处收集需求信息,并按受欢迎程度进行优先级排序。 不使用模块的最常见原因是“它不符合我的要求”。收集这些需求并将它们添加到路线图或对用户的工作流程提出建议。 检查每一项需求以确认它引用的用例是否正确。 公布和维护需求列表。分享该列表并让用户参与列表管理过程。 不要为边缘用例排期。 将每一个决策记录进文档。 在公司内部采用开源社区原则。一些用户希望尽可能高效地使用这些模块,而另一些用户则希望帮助创建这些模块。 创建一个社区 维护一份清晰和公开的贡献指引 最终,我们将允许可信的社区成员获得某些模块的所有权 使用源代码控制系统追踪模块 一个 Terraform 模块应遵守所有良好的代码实践: 将模块置于源代码控制中以管理版本发布、协作、变更的审计跟踪。 为所有 main 分支的发布版本建立版本标签,记录文档(最起码在 CHANGELOG 及 README 中记录)。 对 main 分支的所有变更进行代码审查 鼓励模块的用户通过版本标签引用模块 为每一个模块指派一位负责人 一个代码仓库只负责一个模块 这对于模块的幂等性和作为库的功能至关重要。 我们应该对模块打上版本标签或是版本化控制。打上版本标签或是版本化的模块应该是不可变的。 发布到私有模块注册表的模块必须要有版本标签。 开发一套模块消费工作流 定义和宣传一套消费者团队使用模块时应遵循的可重复工作流程。这个工作流程,就像模块本身一样,应该考虑到用户的需求。 阐明团队应该如何使用模块 分散的安全性:如果每个模块都在自己的存储库中进行版本控制,则可以使用存储库 RBAC 来管理谁拥有写访问权限,从而允许相关团队管理相关的基础设施(例如网络团队拥有对网络模块的写访问权限)。 培育代码社区:鉴于上述建议,模块开发的最佳实践是允许对存储在私有模块存储库中的模块的所有模块存储库提出 Pull Request。这促进了组织内的代码社区,保持模块内容的相关性和最大的灵活性,并有助于保持模块注册表的长期有效性。 "},"6.Terraform命令行/":{"url":"6.Terraform命令行/","title":"Terraform 命令行","keywords":"","body":"Terraform命令行 我们在前面的的章节中主要介绍了如何书写和组织Terraform代码,下面我们要介绍一下如何使用Terraform命令行工具来应用这些代码,并且管理和操作我们的云端基础设施。 Terraform是用Go语言编写的,所以它的交付物只有一个可执行命令行文件:terraform。在Terraform执行发生错误时,terraform进程会返回一个非零值,所以在脚本代码中我们可以轻松判断执行是否成功。 我们可以在命令行中输入terraform来查看所有可用的子命令: $ terraform Usage: terraform [-version] [-help] [args] The available commands for execution are listed below. The most common, useful commands are shown first, followed by less common or more advanced commands. If you're just getting started with Terraform, stick with the common commands. For the other commands, please read the help and docs before usage. Common commands: apply Builds or changes infrastructure console Interactive console for Terraform interpolations destroy Destroy Terraform-managed infrastructure env Workspace management fmt Rewrites config files to canonical format get Download and install modules for the configuration graph Create a visual graph of Terraform resources import Import existing infrastructure into Terraform init Initialize a Terraform working directory login Obtain and save credentials for a remote host logout Remove locally-stored credentials for a remote host output Read an output from a state file plan Generate and show an execution plan providers Prints a tree of the providers used in the configuration refresh Update local state file against real resources show Inspect Terraform state or plan taint Manually mark a resource for recreation untaint Manually unmark a resource as tainted validate Validates the Terraform files version Prints the Terraform version workspace Workspace management All other commands: 0.12upgrade Rewrites pre-0.12 module source code for v0.12 0.13upgrade Rewrites pre-0.13 module source code for v0.13 debug Debug output management (experimental) force-unlock Manually unlock the terraform state push Obsolete command for Terraform Enterprise legacy (v1) state Advanced state management 通过 -chdir 参数切换工作目录 运行Terraform时一般要首先切换当前工作目录到包含有想要执行的根模块.tf代码文件的目录下(比如使用cd命令),这样Terraform才能够自动发现要执行的代码文件以及参数文件。 在某些情况下——尤其是将Terraform封装进某些自动化脚本时,如果能够从其他路径直接执行特定路径下的根模块代码将会十分的方便。为了达到这一目的,Terraform目前支持一个全局参数-chdir=...,你可以在任意子命令的参数中使用该参数指定要执行的代码路径: $ terraform -chdir=environments/production apply -chdir参数指引Terraform在执行具体子命令之前切换工作目录,这意味着使用该参数后Terraform将会在指定路径下读写文件,而非当前工作目录下的文件。 在两种场景下Terraform会坚持使用当前工作目录而非指定的目录,即使是我们通过-chdir指定了一个目标路径: Terraform处理命令行配置文件中的设置而非执行某个具体的子命令时,该阶段发生在解析-chdir参数之前 如果你需要使用当前工作目录下的文件作为你配置的一部分时,你可以通过在代码中使用path.cwd变量获得对当前工作路径的引用,而不是-chdir所指定的路径。可以通过使用path.root来获取代表根模块所在的路径。 自动补全 如果你使用的是bash或是zsh,那么可以轻松安装自动补全: $ terraform -install-autocomplete 卸载自动补全也很容易: $ terraform -uninstall-autocomplete 目前自动补全并没有覆盖到所有子命令。 版本信息 Terraform命令行会与HashiCorp的Checkpoint服务交互来检查当前版本是否有更新或是关键的安全公告。 可以通过执行terraform version命令来检查是否有新版本可用。 Checkpoint服务 Terraform会收集一些不涉及用户身份信息或是主机信息的数据发送给Checkpoint服务。一个匿名ID会被发送到Checkpoint来消除重复的告警信息。我们可以关闭与Checkpoint的交互。 我们可以设置CHECKPOINT_DISABLE环境变量的值为任意非空值来完全关闭HashiCorp所有产品与Checkpoint的交互。另外,我们也可以通过设置命令行配置文件来关闭这些功能: disable_checkpoint:设置为true可以完全关闭与Checkpoint的交互 disable_checkpoint_signature:设置为true可以阻止向Checkpoint发送匿名ID "},"6.Terraform命令行/1.命令行配置文件.html":{"url":"6.Terraform命令行/1.命令行配置文件.html","title":"命令行配置文件","keywords":"","body":"命令行配置文件(.terraformrc 或 terraform.rc) 命令行配置文件为每个用户配置了命令行的行为,适用于所有的 Terraform 工作目录,这与我们编写的 Terraform 代码是分开的。 位置 配置文件的位置取决于用户使用的操作系统: Windows 平台上,文件名必须是 terraform.rc,位置必须在相关用户的 %APPDATA% 目录下。这个目录的物理路径取决于 Windows 的版本以及系统配置;在 PowerShell 中查看 $env:APPDATA 可以找到对应的路径 在其他操作系统上,文件名必须是 .terraformrc(注意第一个是 .),位置必须是在相关用户的 HOME 目录 在 Windows 上创建配置文件时,要注意 Windows Explorer 默认隐藏文件扩展名的行为。Terraform 不会把 terraform.rc.txt 文件识别为命令行配置文件,而默认情况下 Windows Explorer 会将它的文件名显示为 terraform.rc (隐藏了扩展名的缘故)。可以在 PowerShell 或命令行中使用 dir 命令来确认文件名。 可以通过设置 TF_CLI_CONFIG_FILE 环境变量的方式来修改配置文件的位置。 配置文件语法 配置文件本身如同 .tf 文件那样也采用HCL语法,但使用不同的属性和块。以下是常见语法的演示,后续的部分会详细介绍这些配置项: plugin_cache_dir = \"$HOME/.terraform.d/plugin-cache\" disable_checkpoint = true 可用配置 命令行配置文件中可以设置的配置项有: credentials:使用 Terraform Cloud 服务或 Terraform 企业版时使用的凭据 credentials_helper:配置一个外部的用于读写 Terraform Cloud 或 Terraform 企业版凭据的帮助程序 disable_checkpoint:设置为 true 可以完全关闭与 Checkpoint 的交互 disable_checkpoint_signature:设置为 true 可以阻止向 Checkpoint 发送匿名 ID plugin_cache_dir:开启插件缓存,我们在介绍 Provider 的章节中介绍过 provider_installation:定制化执行 terraform init 时安装插件的行为 鉴于本教程无意涉及与 Terraform Cloud 或企业版相关的部分,所以我们会略过对 credentials 和 credentials_helper 的介绍;插件缓存的相关知识我们在 Provider 章节中已做过介绍,在此先偷懒略过。感兴趣的读者可以自行查阅相关文档 Provider 的安装 默认情况下 Terraform 从官方 Provider Registry 下载安装 Provider 插件。Provider 在 Registry 中的原始地址采用类似 registry.terraform.io/hashicorp/aws 的编码规则。通常为了简便,Terraform 允许省略地址中的主机名部分 registry.terraform.io,所以我们可以直接使用地址 hashicorp/aws。 有时无法直接从官方 Registry 下载插件,例如我们要在一个与公网隔离的环境中运行 Terraform 时。为了允许 Terraform 工作在这样的环境下,有一些可选方法允许我们从其他地方获取 Provider 插件。 显式安装方法配置 可以在命令行配置文件中定义一个 provider_installation 块来修改 Terraform 默认的插件安装行为,命令 Terraform 使用本地的 Registry 镜像服务,或是使用一些用户修改过的插件。 通常 provider_installation 块的结构如下: provider_installation { filesystem_mirror { path = \"/usr/share/terraform/providers\" include = [\"example.com/*/*\"] } direct { exclude = [\"example.com/*/*\"] } } provider_installation 块中每一个内嵌块都指定了一种安装方式。每一种安装方式都可以同时包含 include 与 exclude 模式来指定安装方式使用的 Provider 类型。在上面的例子里,我们把所有原先位于 example.com 这个 Registry 存储中的 Provider 设置成只能从本地文件系统的 /usr/share/terraform/providers 镜像存储中寻找并安装,而其他的 Provider 只能从它们原先的 Registry 存储下载安装。 如果你为一种安装方式同时设置了 include 与 exclude,那么 exclude 模式将拥有更高的优先级。举例:包含registry.terraform.io/hashicorp/*但排除registry.terraform.io/hashicorp/dns将对所有hashicorp空间下的插件有效,但是hashicorp/dns除外。 和Terraform代码文件中Provider的source属性一样的是,在provider_installation里你也可以省略registry.terraform.io/的前缀,甚至是使用通配符时亦是如此。比如,registry.terraform.io/hashicorp/*和hashicorp/*是等价的;*/*是registry.terraform.io/*/*的缩写,而不是*/*/*的缩写。 目前支持的安装方式如下: direct:要求直接从原始的Registry服务下载。该方法不需要额外参数。 filesystem_mirror:一个本地存有 Provider 插件拷贝的目录。该方法需要一个额外的参数 path 来指定存有插件拷贝的目录路径。 Terraform 期待给定路径的目录内通过路径来描述插件的一些元信息。支持一下两种目录结构: 打包式布局: HOSTNAME/NAMESPACE/TYPE/terraform-provider-TYPE_VERSION_TARGET.zip 的格式指定了一个从原始 Registry 获取的包含插件的发行版 zip 文件 解压式布局:HOSTNAME/NAMESPACE/TYPE/VERSION/TARGET 式一个包含有 Provider 插件发行版 zip 文件解压缩后内容物的目录 这两种布局下,VERSION 都是代表着插件版本的字符串,比如 2.0.0;TARGET 则指定了插件发行版对应的目标平台,例如 darwin_amd64、linux_arm、windows_amd64 等等。 如果使用解压式布局,Terraform 在安装插件时会尝试创建一个到镜像目录的符号连接来避免拷贝文件夹。打包式布局则不会这样做,因为 Terraform 必须在安装插件时解压发行版文件。 你可以指定多个filesystem_mirror块来指定多个不同的目录。 network_mirror:指定一个 HTTPS 服务地址提供 Provider 插件的拷贝,不论这些插件原先属于哪些 Registry 服务。该方法需要一个额外参数 url 来指定镜像服务的地址,url 地址必须使用 https: 作为前缀,以斜杠结尾。 Terraform期待该地址指定的服务实现了 Provider网络镜像协议,这是一种被设计用来托管插件拷贝的网站所需要实现的协议,在此我们不展开讨论。 需要特别注意的是,请不要使用不可信的 network_mirror 地址。Terraform 会验证镜像站点的 TLS 证书以确认身份,但一个拥有合法 TLS 证书的镜像站可能会提供包含恶意内容的插件文件。 dev_overrides:指定使用本地的开发版本插件。有时我们想要对 Provider 代码做一些修改,为了调试本地代码编译后的插件,可以使用 dev_overrides 指定使用本地编译的版本。 例如,我们想要调试本地修改过的 UCloud Provider 插件,我们可以从 github 上克隆该项目源代码,修改完代码后,编译一个可执行版本(以Mac OS为例): $ GOOS=darwin GOARCH=arm64 go build -o bin/terraform-provider-ucloud $ chmod +x bin/terraform-provider-ucloud 然后编写如下provider_installation配置: provider_installation{ dev_overrides { \"ucloud/ucloud\" = \"PATH_TO_PROJECT/terraform-provider-ucloud/\" } } 当 Terraform 代码中要求了 source 为 ucloud/ucloud 的 Provider 时,执行 terraform init 仍然会报错,抱怨找不到 ucloud/ucloud 这个 Provider,但执行 terraform plan 或是 terraform apply 等操作时可以顺利执行,此时 Terraform 会使用路径指定的本地 Provider 插件。这种方式比较适合于调试本地 Provider 插件代码。 对于上述的几种插件安装方式,Terraform 会尝试通过 include 和 exclude 模式匹配 Provider,遍历匹配的安装方式,选择一个符合 Terraform 代码中对插件版本约束的最新版本。如果你拥有一个插件的特定版本的本地镜像,并且你希望 Terraform 只使用这个本地镜像,那么你需要移除 direct 安装方式,或是在 direct 中通过exclude 参数排除特定的 Provider。 隐式的本地镜像目录 如果命令行配置文件中没有包含 provider_installation 块,那么 Terraform 会生成一个隐式的配置。该隐式配置包含了一个 filesystem_mirror 方法以及一个 direct 方法。 在不同的操作系统上,Terraform 会选择不同的路径作为隐式 filesystem_mirror 路径: Windows:%APPDATA%/terraform.d/plugins 以及 %APPDATA%/HashiCorp/Terraform/plugins Mac OS X:$HOME/.terraform.d/plugins/,~/Library/Application Support/io.terraform/plugins 以及 /Library/Application Support/io.terraform/plugins Linux 以及其他 Unix 风格系统:$HOME/.terraform.d/plugins/,以及配置的 XDG 基础目录后接 terraform/plugins。如果没有设置 XDG 环境变量,Terraform 会使用 ~/.local/share/terraform/plugins,/usr/local/share/terraform/plugins,以及 /usr/share/terraform/plugins Terraform 会在启动时为上述路径的每一个目录创建一个隐式 filesystem_mirror 块。另外如果当前工作目录下包含有 terraform.d/plugins 目录,那么也会为它创建一个隐式 filesystem_mirror 块。 相对于任意多个隐式 filesystem_mirror 块,Terraform 同时也会创建一个隐式 direct 块。Terraform 会扫描所有文件系统镜像目录,对找到的 Provider 自动从 direct 块中排除出去(这种自动的 exclude 行为只对隐式 direct 块有效。如果你在 provider_installation 块中显式指定了 direct 块,那么你需要自己显式定义 exclude 规则)。 TODO:https://developer.hashicorp.com/terraform/cli/config/config-file#provider-plugin-cache Provider 插件缓存 允许 Provider 缓存跳过依赖锁文件检查 "},"6.Terraform命令行/2.环境变量.html":{"url":"6.Terraform命令行/2.环境变量.html","title":"环境变量","keywords":"","body":"环境变量 Terraform使用一系列的环境变量来定制化各方面的行为。如果只是想简单使用Terraform,我们并不需要设置这些环境变量;但他们可以在一些不常见的场景下帮助我们改变Terraform的默认行为,或者是出于调试目的修改输出日志的级别。 TF_LOG 该环境变量可以设定 Terraform 内部日志的输出级别,例如: $ export TF_LOG=TRACE Terraform 日志级别有 TRACE、DEBUG、INFO、WARN 和 ERROR。TRACE 包含的信息最多也最冗长,如果 TF_LOG 被设定为这五级以外的值时 Terraform 会默认使用 TRACE。 如果在使用 Terraform 的过程中遇到未知的错误并怀疑是 Terraform 或相关插件的 bug,请设置 TF_LOG 级别后收集输出的日志并提交给相关人员。 有志于获取 Terraform 认证的读者请注意,该知识点近乎属于必考。 TF_LOG_PATH 该环境变量可以设定日志文件保存的位置。注意,如果TF_LOG_PATH被设置了,那么 TF_LOG 也必须被设置。举例来说,想要始终把日志输出到当前工作目录,我们可以这样: $ export TF_LOG_PATH=./terraform.log TF_INPUT 该环境变量设置为 \"false\" 或 \"0\" 时,等同于运行 Terraform 相关命令行命令时添加了参数 -input=false。如果你想在自动化环境下避免 Terraform 通过命令行的交互式提示要求给定输入变量的值而是直接报错时(无 default 值的输入变量,无法通过任何途径获得值)可以设置该环境变量: $ export TF_INPUT=0 TF_VAR_name 我们在介绍输入变量赋值时介绍过,可以通过设置名为 TF_VAR_name 的环境变量来为名为 \"name\" 的输入变量赋值: $ export TF_VAR_region=us-west-1 $ export TF_VAR_ami=ami-049d8641 $ export TF_VAR_alist='[1,2,3]' $ export TF_VAR_amap='{ foo = \"bar\", baz = \"qux\" }' TF_CLI_ARGS 以及 TF_CLI_ARGS_name TF_CLI_ARGS 的值指定了附加给命令行的额外参数,这使得在自动化 CI 环境下可以轻松定制 Terraform 的默认行为。 该参数的值会被直接插入在子命令后(例如 plan)以及通过命令行指定的参数之前。这种做法确保了环境变量参数优先于通过命令行传递的参数。 例如,执行这样的命令:TF_CLI_ARGS=\"-input=false\" terraform apply -force,它等价于手工执行 terraform apply -input=false -force。 TF_CLI_ARGS 变量影响所有的 Terraform 命令。如果你只想影响某个特定的子命令,可以使用 TF_CLI_ARGS_name 变量。例如:TF_CLI_ARGS_plan=\"-refresh=false\",就只会针对 plan 子命令起作用。 该环境变量的值会与通过命令行传入的参数一样被解析,你可以在值里使用单引号和双引号来定义字符串,多个参数之间以空格分隔。 TF_DATA_DIR TF_DATA_DIR 可以修改 Terraform 保存在每个工作目录下的数据的位置。一般来说,Terraform 会把这些数据写入当前工作目录下的 .terraform 文件夹内,但这一位置可以通过设置 TF_DATA_DIR 来修改。 大部分情况下我们不应该设置该变量,但有时我们不得不这样做,比如默认路径下我们无权写入数据时。 该数据目录被用来保存下一次执行任意命令时需要读取的数据,所以必须被妥善保存,并确保所有的 Terraform 命令都可以一致地读写它,否则 Terraform 会找不到 Provider 插件、模块代码以及其他文件。 TF_WORKSPACE 多环境部署时,可以使用此环境变量而非 terraform workspace select your_workspace 来切换 workspace。使用 TF_WORKSPACE 允许设置使用的工作区。 比如: export TF_WORKSPACE=your_workspace 建议仅在非交互式使用中使用此环境变量,因为在本地 shell 环境中,很容易忘记设置了该变量并将变更执行到错误的环境中。 可以在这里阅读工作区的更多信息。 TF_IN_AUTOMATION 如果该变量被设置为非空值,Terraform 会意识到自己运行在一个自动化环境下,从而调整自己的输出以避免给出关于该执行什么子命令的建议。这可以使得输出更加一致且减少非必要的信息量。 TF_REGISTRY_DISCOVERY_RETRY 该变量定义了尝试从 registry 拉取插件或模块代码遇到错误时的重试次数。 TF_REGISTRY_CLIENT_TIMEOUT 该变量定义了发送到 registry 连接请求的超时时间,默认值为 10 秒。可以这样设置超时: $ export TF_REGISTRY_CLIENT_TIMEOUT=15 TF_CLI_CONFIG_FILE 该变量设定了 Terraform 命令行配置文件的位置: $ export TF_CLI_CONFIG_FILE=\"$HOME/.terraformrc-custom\" TF_PLUGIN_CACHE_DIR TF_PLUGIN_CACHE_DIR 环境变量是配置插件缓存目录的另一种方法。你也可以使用 TF_PLUGIN_CACHE_MAY_BREAK_DEPENDENCY_LOCK_FILE 环境变量设置 plugin_cache_may_break_dependency_lock_file 配置项 TF_IGNORE 如果 TF_IGNORE 设置为 \"trace\",Terraform 会在调试信息中输出被忽略的文件和目录。该配置与 .terraformignore 文件搭配时对调试大型代码仓库相当有用: export TF_IGNORE=trace "},"6.Terraform命令行/3.资源地址.html":{"url":"6.Terraform命令行/3.资源地址.html","title":"资源地址","keywords":"","body":"资源地址 在编码时我们有时会需要引用一些资源的输出属性或是一些模块的输出值,这都涉及到如何在代码中引用特定模块或是资源。另外在执行某些命令行操作时也需要我们显式指定一些目标资源,这时我们要掌握Terraform的资源路径规则。 一个资源地址是用以在一个庞大的基础设施中精确引用一个特定资源对象的字符串。一个地址由两部分组成:[module path][resource spec]。 模块路径 一个模块路径在模块树上定位了一个特定模块。它的形式是这样的:module.module_name[module index] module:module 关键字标记了这时一个子模块而非根模块。在路径中可以包含多个 module 关键字 module_name:用户定义的模块名 [module index]:(可选)访问多个子模块中特定实例的索引,由方括号包围 一个不包含具体资源的地址,例如 module.foo 代表了模块内所有的资源(如果只是单个模块而不是多实例模块),或者是多实例模块的所有实例。要指代特定模块实例的所有资源,需要在地址中附带下标,例如 module.foo[0]。 如果地址中模块部分被省略,那么地址就指代根模块资源。 这里有一个包含多个 module 关键字应用于多实例模块的例子:module.foo[0].module.bar[\"a\"]。 要注意的是,由于模块的 count 和 for_each 元参数是 Terraform 0.13 开始引进的,所以多实例模块地址也只能在 0.13 及之后的版本使用。 资源地址形式 一个资源地址定位了代码中特定资源对象,它的形式是这样的:resource_type.resource_name[resource index] resource_type:资源类型 resource_name:用户定义的资源名称 [resource index]:(可选)访问多实例资源中特定资源实例的索引,由方括号包围 多实例模块与资源的访问索引 以下规约适用于访问多实例模块及资源时使用的索引值: [N]:当使用 count 元参数时N是一个自然数。如果省略,并且 count > 1,那么指代所有的实例 [\"INDEX\"]:当使用 for_each 元参数时 INDEX 是一个字母数字混合的字符串 例子 count 的例子 给定一个代码定义: resource \"aws_instance\" \"web\" { # ... count = 4 } 给定一个地址:aws_instance.web[3],它指代的是最后一个名为 web 的 aws_instance 实例;给定地址 aws_instance.web,指代的是所有名为 web 的 aws_instance 实例。 for_each 的例子 给定如下代码: resource \"aws_instance\" \"web\" { # ... for_each = { \"terraform\": \"value1\", \"resource\": \"value2\", \"indexing\": \"value3\", \"example\": \"value4\", } } 地址 aws_instance.web[\"example\"] 引用的是 aws_instance.web 中键为 \"example\" 的实例。 "},"6.Terraform命令行/4.apply.html":{"url":"6.Terraform命令行/4.apply.html","title":"apply","keywords":"","body":"apply Terraform 最重要的命令就是 apply。apply 命令可以生成执行计划(可选)并执行之,使得基础设施资源状态符合代码的描述。 用法 terraform apply [options] [plan file] Terraform 的 Apply 有两种模式:自动 Plan 模式以及既有 Plan 模式。 自动 Plan 模式 当我们运行 terraform apply 而不指定计划文件时,Terraform 会自动创建一个新的执行计划,就像我们已运行 terraform plan 一样,提示我们批准该计划,并采取指示的操作。我们可以使用所有 plan 模式和 plan 选项来自定义 Terraform 创建计划的方式。 我们可以设置 -auto-approve 选项来要求 Terraform 跳过确认直接执行计划。 警告:如果使用 -auto-approve,建议确保没有人可以在 Terraform 工作流程之外更改我们的基础设施。这可以最大限度地降低不可预测的变更和配置漂移的风险。 既有 Plan 模式 当您将既有的计划文件传递给 terraform apply 时,Terraform 会执行既有的计划中的操作,而不提示确认。在自动化运行 Terraform 时,可能需要使用由这样的两个步骤组成的工作流。 我们在应用计划之前可以使用 terraform show 检查既有的计划文件。 使用既有的计划时,我们无法指定任何其他计划模式或选项。这些选项只会影响 Terraform 关于采取哪些操作的决策,而这些决策的最终结果已经在计划文件中包含了。 Plan 参数 在未提供既有计划文件时,terraform apply 命令支持 terraform plan 命令所支持的所有 Plan 模式参数以及 Plan 选项参数。 Plan 模式参数:包括 -destroy(创建销毁所有远程对象的计划)和 -refresh-only(创建更新 Terraform 状态和根模块输出值的计划)。 Plan 选项参数:包括指定 Terraform 应替换哪些资源实例、设置 Terraform 输入变量等的参数。 Apply 参数 下面的参数可以更改 apply 命令的执行方式和 apply 操作生成的报告格式。 -auto-approve:跳过交互确认步骤,直接执行变更。此选项将被忽略,因为 Terraform 认为我们指定了计划文件即已批准执行,因此在这种情况下永远不会提示。 -compact-warnings:以紧凑的形式显示所有警告消息,其中仅包含摘要消息,除非输出信息中存在至少一个错误,因此警告文本中可能包含有错误的上下文信息。 -input=true:禁用 Terraform 的所有交互式提示。请注意,这也会阻止 Terraform 提示交互式批准计划,这时 Terraform 将保守地假设您不希望应用该计划,从而导致操作失败。如果您希望在非交互式上下文中运行 Terraform,请参阅 Terraform 与自动化 了解一些不同的方法。 -json:启用机器可读的 JSON UI 输出。这意味着 -input=false,因此配置 variable 值都已赋值才能继续。要启用此参数,您还必须启用 -auto-approve 标志或指定既有的计划文件。 -lock=false:执行时是否先锁定状态文件。如果其他人可能同时对同一工作区运行命令,则这是危险的。 -lock-timeout=DURATION:除非使用 -lock=false 禁用锁定,否则命令 Terraform 为上锁操作设置一个超时时长。持续时间语法是一个数字后跟一个时间单位字母,例如“3s”表示三秒。 -no-color:关闭彩色输出。在无法解释输出色彩的终端中运行 Terraform 时请使用此参数。 -parallelism=n:限制 Terraform 遍历图时的最大并行度,默认值为 10(考试高频考点) 当配置中只使用了 local Backend 时,terraform apply 还支持以下三个遗留参数: -backup-path:保存备份文件的路径。默认等于 -state-out 参数后加上 \".backup\" 后缀。设置为 \"-\" 可关闭 -state=path:保存状态文件的路径,默认值是 \"terraform.tfstate\"。如果使用了远程 Backend 该参数设置无效。该参数不影响其他命令,比如执行 init 时会找不到它设置的状态文件。如果要使得所有命令都可以使用同一个特定位置的状态文件,请使用 Local Backend -state-out=path:写入更新的状态文件的路径,默认情况使用 -state 的值。该参数在使用远程 Backend 时设置无效 指定其他配置文件目录 Terraform v0.13 及更早版本接受提供目录路径的附加位置参数,在这种情况下,Terraform 将使用该目录作为根模块而不是当前工作目录。 该用法在 Terraform v0.14 中已弃用,并在 Terraform v0.15 中删除。如果您的工作流程需要修改根模块目录,请改用 -chdir 全局选项,该选项适用于所有命令,并使 Terraform 始终在给定目录中查找它通常在当前工作目录中读取或写入的所有文件。 如果我们之前使用此遗留模式时同时需要 Terraform 将 .terraform 子目录写入当前工作目录,即使根模块目录已被覆盖,请使用 TF_DATA_DIR 环境变量命令 Terraform 将 .terraform 目录写入其他位置,而不是当前工作目录。 "},"6.Terraform命令行/5.console.html":{"url":"6.Terraform命令行/5.console.html","title":"console","keywords":"","body":"console 有时我们想要一个安全的调试工具来帮助我们确认某个表达式是否合法,或者表达式的值是否符合预期,这时我们可以使用 terraform console 启动一个交互式控制台。 用法 terraform console [options] [options] console 命令提供了一个用以执行和测试各种表达式的命令行控制台。在编码时如果我们不确定某个表达式的最终结果时(例如使用字符串模版),我们可以在这个控制台中搭配当前状态文件中的数据进行各种测试。 如果当前状态是空的或还没有创建状态文件,那么控制台可以用来测试各种表达式语法以及内建函数。假如当前根模块有状态,console 命令将会对状态加锁,这使得我们无法在运行其他可能会修改状态的操作时使用 console 命令。 在控制台中可以使用 exit 命令或是 Ctrl-C 或是 Ctrl-D 退出。 当使用的是 local Backend 时,terraform console 可以使用 -state 遗留参数: -state=path:指向本机状态文件的路径。表达式计算会使用该状态文件中记录的值。如果没有指定,则会使用当前工作区(Workspace)关联的状态文件 脚本化 terraform console 命令可以搭配非交互式脚本使用,可以使用管道符将其他命令输出接入控制台执行。如果没有发生错误,只有最终结果会被打印。 样例: echo 'split(\",\", \"foo,bar,baz\")' | terraform console tolist([ \"foo\", \"bar\", \"baz\", ]) 远程状态 如果使用了远程 Backend 存储状态,Terraform 会从远程 Backend 读取当前工作区的状态数据来计算表达式。 搭配既有计划文件运行 默认情况下,terraform console 根据当前 Terraform 状态计算表达式,因此对于尚未通过 Apply 创建的资源实例,结果通常非常有限。 您可以使用 -plan 选项首先生成执行计划,就像运行 terraform plan 一样,然后根据计划的状态进行计算,以描述 Terraform 期望在应用计划后应得的值。这通常会在控制台提示出现之前引发更长的延迟,但作为回报,可知的表达式范围中将有一组更完整的可用值。 一个好的 Terraform 配置代码,在 Plan 阶段不应对实际远程对象进行任何修改,但我们可以编写一个在 Plan 时可以执行重要操作的配置。例如,使用 hashcorp/external Provider 程序的 external 数据源的配置可能会在 Plan 阶段运行设置的的外部命令,这意味着该外部命令也会被 terraform console -plan 运行。 我们不建议编写在 Plan 阶段进行更改的配置。如果您不顾该建议而编写了此类配置,则在 Plan 模式下针对该配置使用控制台时请务必小心。 例子 terraform console 命令将从配置的 Backend 读取当前工作目录中的 Terraform 配置和 Terraform 状态文件,以便可以根据配置和状态文件中的值计算表达式。 假设我们有如下的 main.tf: variable \"apps\" { type = map(any) default = { \"foo\" = { \"region\" = \"us-east-1\", }, \"bar\" = { \"region\" = \"eu-west-1\", }, \"baz\" = { \"region\" = \"ap-south-1\", }, } } resource \"random_pet\" \"example\" { for_each = var.apps } 执行 terraform console 会进入交互式 shell,我们可以在其中计算表达式: 打印一个 map: > var.apps.foo { \"region\" = \"us-east-1\" } 根据给定值过滤 map: > { for key, value in var.apps : key => value if value.region == \"us-east-1\" } { \"foo\" = { \"region\" = \"us-east-1\" } } 确认特定值是否为尚不知晓(Known after apply)值: > random_pet.example (known after apply) 测试各种函数: > cidrnetmask(\"172.16.0.0/12\") \"255.240.0.0\" "},"6.Terraform命令行/6.destroy.html":{"url":"6.Terraform命令行/6.destroy.html","title":"destroy","keywords":"","body":"destroy terraform destroy 命令可以用来销毁并回收所有由 Terraform 配置所管理的基础设施资源。 虽然我们一般不会删除长期存在于生产环境中的对象,但有时我们会用 Terraform 管理用于开发目的的临时基础设施,在这种情况下,您可以在完成后使用 terraform destroy 来方便地清理所有这些临时资源。 用法 terraform destroy [options] 该命令是以下命令的快捷方式: terraform apply -destroy 因此,此命令接受 terraform apply 所支持的大部分选项,但是它不支持 -destroy 模式搭配指定计划文件的用法。 我们还可以通过运行以下命令创建推测性销毁计划,以查看销毁的效果: terraform plan -destroy 该命令会以 destroy 模式运行 terraform plan 命令,显示准备要销毁的变更,但不予执行。 注意:terraform apply 的 -destroy 选项仅存在于 Terraform v0.15.2 及更高版本中。对于早期版本,必须使用 terraform destroy 才能获得 terraform apply -destroy 的效果。 "},"6.Terraform命令行/7.fmt.html":{"url":"6.Terraform命令行/7.fmt.html","title":"fmt","keywords":"","body":"fmt terraform fmt 命令被用来格式化 Terraform 代码文件的格式和规范。该命令会对代码文件应用我们之前介绍过的代码风格规范中的一些规定,另外会针对可读性对代码做些微调整。 其他具有生成Terraform代码文件功能的命令会按照terraform fmt的标准来生成代码,所以请在项目中遵循fmt的代码风格以保持代码风格的统一。 其他那些会生成 Terraform 代码的 Terraform 命令,生成的代码都会符合 terraform fmt 所强制推行的格式,因此对我们自己编写的文件使用该命令可以保持所有代码风格的一致。 Terraform 不同版本的代码风格规范会有些微不同,所以在升级 Terraform 后我们建议要对代码执行一次 terraform fmt。 我们不会将修改 terraform fmt 执行的格式规则视作是 Terraform 新版本的破坏性变更(意为,不同版本的 terraform fmt 可能会对代码做不同的格式化),但我们的目标是最大限度地减少对那些已符合 Terraform 文档中显示的样式示例的代码的更改。添加新的格式规则时,他们通常会按照文档中代码示例中展示的新规则来制定,因此我们建议遵循文档中的样式,即使这些文档中的样式尚未被 terraform fmt 强制执行。 格式化决定始终是主观的,因此您可能不同意 terraform fmt 做出的决定。该命令是被设计成固执己见的,并且没有自定义选项,因为它的主要目标是鼓励不同 Terraform 代码库之间风格的一致性,即使所选的风格永远不可能是每个人都喜欢的。 我们建议代码作者在编写 Terraform 模块时遵循 terraform fmt 应用的样式约定,但如果您发现结果特别令人反感,那么您可以选择不使用此命令,并可能选择使用第三方格式化工具。如果您选择使用第三方工具,那么您还应该在 Terraform 自动生成的文件上运行它,以获得手写文件和生成文件之间的一致性。 用法 terraform fmt [options] [target...] 默认情况下,fmt 会扫描当前文件夹以寻找代码文件。如果 [target...] 参数指向一个目录,那么 fmt 会扫描该目录。如果 [target...] 参数是一个文件,那么 fmt 只会处理那个文件。如果 [target...] 参数是一个减号(-),那么 fmt 命令会从标准输入中读取(STDIN)。 该命令支持以下参数: -list=false:不列出包含不一致风格的文件 -write=false:不要重写输入文件(通过 -check 参数实现,或是使用标准输入流时) -diff:展示格式差异 -check:检查输入是否合规。返回状态码 0 则代表所有输入的代码风格都是合规,反之则不是 0,并且会打印一份包含了文件内容不合规的文件名清单。 -recursive:是否递归处理所有子文件夹。默认情况下为 false(只有当前文件夹会被处理,不涉及内嵌子模块) "},"6.Terraform命令行/8.force-unlock.html":{"url":"6.Terraform命令行/8.force-unlock.html","title":"force-unlock","keywords":"","body":"force-unlock 手动解除状态锁。 这个命令不会修改你的基础设施,它只会删除当前工作区对应的状态锁。具体操作步骤取决于使用的 Backend。本地状态文件无法被其他进程解锁。 用法 terraform force-unlock [options] LOCK_ID 参数: -force=true:解锁时不提示确认 需要注意的是,就像我们在状态管理篇当中介绍过的那样,每一个状态锁都有一个锁 ID。Terraform 为了确保我们解除正确的状态锁,所以会要求我们显式输入锁 ID。 一般情况下我们不需要强制解锁,只有在 Terraform 异常终止,来不及解除锁时需要我们手动强制解除锁。错误地解除状态锁可能会导致状态混乱,所以请小心使用。 "},"6.Terraform命令行/9.get.html":{"url":"6.Terraform命令行/9.get.html","title":"get","keywords":"","body":"get terraform get 命令可以用来下载以及更新根模块中使用的模块。 用法 terraform get [options] 模块被下载并安装在当前工作目录下 .terraform 子目录中。这个子目录不应该被提交至版本控制系统。 get 命令支持以下参数: -update:如果指定,已经被下载的模块会被检查是否有新版本,如果存在新版本则会更新 -no-color:禁用输出中的文字颜色 "},"6.Terraform命令行/10.graph.html":{"url":"6.Terraform命令行/10.graph.html","title":"graph","keywords":"","body":"graph terraform graph 命令可以用来生成代码描述的基础设施或是执行计划的可视化图形。它的输出是 DOT 格式),可以使用 GraphViz 来生成图片,也有许多网络服务可以读取这种格式。 用法 terraform graph [options] 默认情况下,该命令输出一个简化图,仅描述配置中资源(resource 和 data 块)的依赖顺序。 -type=... 参数可以在一组具有更多细节的其他图类型中进行选择,但作为代价,它也暴露了 Terraform 语言运行时的一些实现细节。 参数: -plan=tfplan:针对指定计划文件生成图。使用该参数暗示着 -type=apply。 -draw-cycles:用彩色的边高亮图中的环,这可以帮助我们分析代码中的环错误(Terraform 禁止环状依赖)。该参数只有在通过 -type=... 参数指定了操作类型时有效。 -type=...:生成图表的类型,默认生成只包含 resource 的简化图。可以是:plan、plan-destroy 或是 apply。 创建图片文件 terraform graph 命令输出的是 DOT 格式)的数据,可以轻松地使用 GraphViz 转换为图形文件: $ terraform graph -type=plan | dot -Tpng >graph.png 输出的图片大概是这样的: 如何安装GraphViz 安装GraphViz也很简单,对于Ubuntu: $ sudo apt install graphviz 对于CentOS: $ sudo dnf install graphviz 对于Windows,也可以使用choco: > choco install graphviz 对于Mac用户: $ brew install graphviz "},"6.Terraform命令行/11.import.html":{"url":"6.Terraform命令行/11.import.html","title":"import","keywords":"","body":"import terraform import 命令用来将已经存在的资源对象导入 Terraform。 我们并不总是那么幸运,能够在项目一开始就使用 Terraform 来构建和管理我们的基础设施;有时我们有一组已经运行着的基础设施资源,然后我们为它们编写了相应的 Terraform 代码,我们进行了测试,确认了这组代码描述的基础设施与当前正在使用的基础设施是等价的;但是我们仍然无法直接使用这套代码来管理现有的基础设施,因为我们缺乏了相应的状态文件。这时我们需要使用 terraform import 将资源对象“导入”到 Terraform 状态文件中去。 用法 terraform import [options] ADDRESS ID terraform import 会根据资源 ID 找到相应资源,并将其信息导入到状态文件中 ADDRESS 对应的资源上。ADDRESS 必须符合我们在资源地址中描述的合法资源地址格式,这样 terraform import 不但可以把资源导入到根模块中,也可以导入到子模块中。 ID 取决于被导入的资源对象的类型。举例来说,AWS 主机的 ID 格式类似于 i-abcd1234,而 AWS Route53 Zone 的 ID 类似于 Z12ABC4UGMOZ2N,请参考相关 Provider 文档来获取有关 ID 的详细信息。如果不确信的话,可以随便尝试任意 ID。如果 ID 不合法,你会看到一个错误信息。 警告,Terraform设想的是每一个资源对象都仅对应一个独一无二的实际基础设施对象,通常来说如果我们完全使用 Terraform 创建并管理基础设施时这一点不是问题;但如果你是通过导入的方式把基础设施对象导入到 Terraform 里,要绝对避免将同一个对象导入到两个以及更多不同的地址上,这会导致 Terraform 产生不可预测的行为。 该命令有以下参数可以使用: -config=path:包含含有导入目标的Terraform代码的文件夹路径。默认为当前工作目录。如果当前目录不包含 Terraform 代码文件,则必须通过手动输入或环境变量来配置 Provider。 -input=true:是否允许提示输入 Provider 配置信息 -lock=false:如果 Backend 支持,是否锁定状态文件。在其他人可能会同时修改同一工作区的配置时关闭锁是危险的。 -lock-timeout=0s:重试尝试获取状态锁的间隔 -no-color:如果设定该参数,则不会输出彩色信息 -parallelism=n:限制 Terraform 遍历图的最大并行度,默认值为 10(又是考点) -var 'foo=bar':通过命令行设置输入变量值,类似 plan 命令中的介绍 -var-file=foo:类似 plan 命令中的介绍 当代码只使用了 local 类型的 Backend 时,terraform import 同时接受以下遗留参数: -state=FILENAME:要读取的状态文件的地址 -state-out=FILENAME:指定修改后的状态文件的保存路径,如果我们设置了 -state 而没同时设置 -state-out,则 Terraform -state 的值写给 -state-out,这意味着 Terraform 如果创建新的状态快照,将直接写入源状态文件。 -backup=FILENAME:生成状态备份文件的地址,默认情况下是 -state-out 路径加上 .backup 后缀名。设置为 - 可以关闭备份(不推荐) Provider配置 Terraform 会尝试读取要导入的资源对应的 Provider 的配置信息。如果找不到相关 Provider 的配置,那么 Terraform 会提示你输入相关的访问凭据。你也可以通过环境变量来配置访问凭据。 Terraform 在读取 Provider 配置时唯一的限制是不能依赖于\"非输入变量\"的输入。举例来说,Provider 配置不能依赖于数据源的输出。 举一个例子,如果你想要导入 AWS 资源而你有这样的一份代码文件,那么 Terraform 会使用这两个输入变量来配置 AWS Provider: variable \"access_key\" {} variable \"secret_key\" {} provider \"aws\" { access_key = var.access_key secret_key = var.secret_key } 例子 $ terraform import aws_instance.foo i-abcd1234 $ terraform import module.foo.aws_instance.bar i-abcd1234 $ terraform import 'aws_instance.baz[0]' i-abcd1234 $ terraform import 'aws_instance.baz[\"example\"]' i-abcd1234 上面这条命令如果是在PowerShell下: $ terraform import 'aws_instance.baz[\\\"example\\\"]' i-abcd1234 如果是cmd: $ terraform import aws_instance.baz[\\\"example\\\"] i-abcd1234 "},"6.Terraform命令行/12.init.html":{"url":"6.Terraform命令行/12.init.html","title":"init","keywords":"","body":"init terraform init 命令被用来初始化一个包含 Terraform 代码的工作目录。在编写了一些 Terraform 代码或是克隆了一个 Terraform 项目后应首先执行该命令。反复执行该命令是安全的(考点)。 用法 terraform init [options] 该命令为初始化工作目录执行了多个不同的步骤。详细说明可以见下文,总体来说用户不需要担心这些步骤。即使某些步骤可能会遭遇错误,但是该命令绝对不会删除你的基础设施资源或是状态文件。 常用参数 -input=true:是否在取不到输入变量值时提示用户输入 -lock=false:是否在运行时锁定状态文件 -lock-timeout=:尝试获取状态文件锁时的超时时间,默认为 0s(0 秒),意为一旦发现锁已被其他进程获取立即报错 -no-color:禁止输出中包含颜色 -upgrade:是否升级模块代码以及插件 从模块源拷贝模块 默认情况下,terraform init 会假设工作目录下已经包含了 Terraform 代码文件。 我们也可以在空文件夹内搭配 -from-module=MODULE-SOURCE 参数运行该命令,这样在运行任何其他初始化步骤之前,指定的模块将被复制到目标目录中。 这种特殊的使用方式有两种场景: 我们可以用这种方法从模块源对应的版本控制系统当中签出指定版本代码并为它初始化工作目录 如果模块源指向的是一个样例项目,那么这种方式可以把样例代码拷贝到本地目录以便我们后续基于样例编写新的代码 如果是常规使用时,建议使用版本控制系统自己的命令单独检查版本控制的配置。这样,可以在必要时将额外的标志传递给版本控制系统,并在运行 terraform init 之前执行其他准备步骤(例如代码生成或激活凭据)。 Backend 初始化 在执行 init 时,会分析根模块代码以寻找 Backend 配置,然后使用给定的配置设定初始化 Backend 存储。 在已经初始化 Backend 后重复执行 init 命令会更新工作目录以使用新的 Backend 设置。这时我们必须设置 -reconfigure 或是 -migrate-state 来升级 Backend 配置。 -migrate-state 选项会尝试将现有状态复制到新 Backend,并且根据更改的内容,可能会导致交互式提示以确认工作区状态的迁移。 设置 -force-copy 选项可以阻止这些提示并对迁移问题回答 yes。启用 -force-copy 还会自动启用 -migrate-state 选项。 -reconfigure 选项会忽略任何现有配置,从而防止迁移任何现有状态。 要跳过后端配置,请使用 -backend=false。请注意,其他一些初始化步骤需要初始化后端,因此建议仅当先前已为特定后端初始化工作目录时才使用此标志。 要跳过 Backend 配置,可以设置 -backend=false。注意某些其他 init 步骤需要已经被初始化的 Backend,所以推荐只在已经初始化过 Backend 后使用该参数。 -backend-config=... 参数可以用来动态指定 Backend 配置,我们在状态管理章节中介绍“部分配置”时已经提过,在此不再赘述。 初始化子模块 init 会搜索代码中的 module 块,然后通过 source 参数取回引用的模块代码。 模块安装之后重新运行 init 命令会继续安装那些自从上次 init 之后新增的模块,但不会修改已被安装的模块。设置 -upgrade 可以改变这种行为,将所有模块升级到最新版本的代码。 要跳过子模块安装步骤,可以设置 -get=false 参数。要注意其他一些init步骤需要模块树完整,所以建议只在成功安装过模块以后使用该参数。 插件安装 我们在 Provider 章节中介绍了插件安装,所以在此不再赘述,我们值介绍一下参数: -upgrade:将之前所有已安装的插件升级到符合 version 约束的最新版本。 -plugin-dir=PATH:跳过插件安装,只从命令行配置文件的 filesystem_mirror 指定的目录加载插件。该参数会跳过用户插件目录以及所有当前工作目录下的插件。我们推荐使用命令行参数文件来全局设置 Terraform 安装方法,-plugin-dir 可以作为一次性的临时方法,例如测试当前本地正在开发的 Provider 插件。 -lockfile=MODE 设置依赖锁文件的模式 该参数的可选值有: readonly:禁用锁定文件更改,但根据已记录的信息验证校验和。该参数与 -upgrade 参数冲突。如果我们使用第三方依赖项管理工具更新锁定文件,那么控制它何时显式更改将很有用。 在自动化环境下运行 terraform init 命令 如果在团队的变更管理和部署流水线中 Terraform 扮演了关键角色,那么我们可能需要以某种自动化方式编排 Terraform 运行,以确保运行之间的一致性,并提供其他有趣的功能,例如与版本控制系统的钩子进行集成。 在此类环境中运行 init 时需要注意一些特殊问题,包括可选择在本地提供插件以避免重复重新安装。有关更多信息,请参阅 Terraform 与自动化。 设置其他代码文件夹 Terraform v0.13 及更早版本还可以设置目录路径来代替 terraform apply 的计划文件参数,在这种情况下,Terraform 将使用该目录作为根模块而不是当前工作目录。 Terraform v0.14 中仍支持该用法,但现已在 Terraform v0.15 中弃用并删除。如果我们的工作流程依赖于覆盖根模块目录,请改用 -chdir 全局选项,该选项适用于所有命令,并使 Terraform 始终在给定目录中查找它通常在当前工作目录中读取或写入的所有文件。 如果您之前的工作流同时要求 Terraform 即使根模块目录已被修改也要将 .terraform 子目录写入当前工作目录,请使用 TF_DATA_DIR 环境变量命令 Terraform 将 .terraform 目录写入当前工作目录之外的其他位置。 "},"6.Terraform命令行/13.output.html":{"url":"6.Terraform命令行/13.output.html","title":"output","keywords":"","body":"output terraform output 命令被用来提取状态文件中输出值的值。 用法 terraform output [options] [NAME] 如果不添加参数,output 命令会展示根模块内定义的所有输出值。如果指定了 NAME,只会输出相关输出值。 可以使用以下参数: -json:设置该参数后 Terraform 会使用 JSON 格式输出。如果指定了 NAME,只会输出相关输出值。该参数搭配 jq 使用可以构建复杂的流水线 -raw:设置该参数后 Terraform 会将指定的输出值转换为字符串,并将该字符串直接打印到输出中,不带任何特殊格式。这在使用 shell 脚本时很方便,但它仅支持字符串、数字和布尔值。处理复杂的数据类型时还请使用 -json。 -no-color:不输出颜色 -state=path:状态文件的路径,默认为 terraform.tfstate。启用远程 Backend 时该参数无效 注意:设置 -json 或 -raw 参数时,Terraform 状态中的任何敏感值都将以纯文本显示。有关详细信息,请参阅状态中的敏感数据。 样例 假设有如下输出值代码: output \"instance_ips\" { value = aws_instance.web.*.public_ip } output \"lb_address\" { value = aws_alb.web.public_dns } output \"password\" { sensitive = true value = var.secret_password } 列出所有输出值: $ terraform output instance_ips = [ \"54.43.114.12\", \"52.122.13.4\", \"52.4.116.53\" ] lb_address = \"my-app-alb-1657023003.us-east-1.elb.amazonaws.com\" password = 注意password输出值定义了sensitive = true,所以它的值在输出时会被隐藏: $ terraform output password password = 要查询负载均衡的DNS地址: $ terraform output lb_address my-app-alb-1657023003.us-east-1.elb.amazonaws.com 查询所有主机的IP: $ terraform output instance_ips test = [ 54.43.114.12, 52.122.13.4, 52.4.116.53 ] 使用-json和jq查询指定主机的ip: $ terraform output -json instance_ips | jq '.value[0]' 在自动化环境下运行 terraform output 命令 terraform output 命令默认以便于人类阅读的格式显示,该格式可以随着时间的推移而改变以提高易读性。 对于脚本编写和自动化,请使用 -json 生成稳定的 JSON 格式。您可以使用 JSON 命令行解析器(例如 jq)解析输出: $ terraform output -json instance_ips | jq -r '.[0]' 54.43.114.12 如果要在 shell 脚本中直接使用字符串值,可以转而使用 -raw 参数,它将直接打印字符串,没有额外的转义或空格。 $ terraform output -raw lb_address my-app-alb-1657023003.us-east-1.elb.amazonaws.com -raw 选项仅适用于 Terraform 可以自动转换为字符串的值。处理复杂类型的值(例如对象)时还请改用 -json(可以与 jq 结合使用)。 Terraform 字符串是 Unicode 字符序列而不是原始字节,因此 -raw 输出在包含非 ASCII 字符时将采用 UTF-8 编码。如果您需要不同的字符编码,请使用单独的命令(例如 iconv)对 Terraform 的输出进行转码。 "},"6.Terraform命令行/14.plan.html":{"url":"6.Terraform命令行/14.plan.html","title":"plan","keywords":"","body":"plan terraform plan 命令被用来创建变更计划。Terraform 会先运行一次 refresh(我们后面的章节会介绍,该行为也可以被显式关闭),然后决定要执行哪些变更使得现有状态迁移到代码描述的期待状态。 该命令可以方便地审查状态迁移的所有细节而不会实际更改现有资源以及状态文件。例如,在将代码提交到版本控制系统前可以先执行 terraform plan,确认变更行为如同预期一般。 如果您直接在交互式终端中使用 Terraform 并且希望执行 Terraform 所提示的变更,您也可以直接运行 terraform apply。默认情况下,apply 命令会自动生成新计划并提示您批准它。 可选参数 -out 可以将变更计划保存在一个文件中,以便日后使用 terraform apply 命令来执行该计划。 在使用版本控制和代码审查工作流程对实际基础架构进行更改的团队中,开发人员可以使用保存下来的的计划文件来验证更改的效果,然后再对提交的变更进行代码审查。但是,要慎重考虑考虑对目标系统同时进行的其他更改可能会导致配置更改的最终效果与早期保存的计划所指示的不同,因此您应该始终重新检查最终的实际执行的计划,在执行之前确保它仍然符合您的意图。 如果 Terraform 检测不到任何变更,那么 terraform plan 会提示没有任何需要执行的变更。 用法 terraform plan [options] plan 命令在当前工作目录中查找根模块配置。 由于 plan 命令是 Terraform 的主要命令之一,因此它有多种不同的选项,如下部分所述。但是,大多数时候我们不需要设置这些选项,因为 Terraform 配置通常应设计为无需特殊的附加选项即可进行日常工作。 plan 命令的参数选项可以分为以下三大类 Plan 模式:当我们的目标不仅仅是更改远程系统以匹配代码配置时,我们可以在某些特殊情况下使用一些特殊的替代规划模式。 Plan 选项:除了特殊的 Plan 模式之外,我们还可以设置一些选项,以便根据特殊的需求来定制计划流程。 其他选项:这些选项改变了规划命令本身的行为,而不是定制生成的计划的内容。 Plan 模式 上一节描述了 Terraform 的默认规划变更计划行为,该行为会变更远程系统以匹配我们对配置代码所做的更改。 Terraform 还有两种不同的规划模式,每种模式都会创建具有不同预期结果的计划。这些选项可用于 terraform plan 和 terraform apply。 Destroy 模式:创建一个计划,其目的是销毁当前存在的所有远程对象,留下空的 Terraform 状态。这与运行 terraform destroy 相同。销毁模式对于临时的开发环境等情况非常有用,在这种情况下,一旦开发任务完成,托管对象就不再需要保留。 通过 -destroy 命令行选项启用销毁模式。 Refresh-Only 模式:创建一个计划,其目标仅是更新 Terraform 状态和所有根模块的输出值,以匹配对 Terraform 外部远程对象所做的更改。如果您使用 Terraform 之外的工具更改了一个或多个远程对象(例如,在响应事件时),并且您现在需要使 Terraform 的记录与这些更改保持一致,那么该命令会很有帮助。 使用 -refresh-only 命令行选项启用 Refresh-Only 模式。 相对而言,我们把 Terraform 在未选择任何替代模式时使用的默认规划模式的情况下的行为称为“正常模式”。由于上述的替代模式仅适用于特殊情况,因此其他一些 Terraform 文档仅讨论正常规划模式。 Plan 模式都是互斥的,因此启用任何非默认 Plan 模式时都会禁用“正常”计划模式,并且我们不能同时使用多种替代模式。 注意:在 Terraform v0.15 及更早版本中,只有 terraform plan 命令支持 -destroy 选项,terraform apply 命令是不支持的。要在早期版本中以 Destroy 模式创建并应用计划,我们必须运行 terraform destroy。另外,-refresh-only 选项仅在 Terraform v0.15.4 及之后的版本中可用。 Plan 选项 相较于 Plan 模式,还有一些可以用来更改规划行为的参数选项。 -refresh=false:在检查配置更改之前跳过同步 Terraform 状态与远程对象的默认行为。这可以减少远程 API 请求的数量,加快规划操作的速度。但是,设置 refresh=false 会导致 Terraform 忽略外部更改,这可能会导致计划不完整或不正确。您不能在 Refresh Only 计划模式中使用 refresh=false,因为这将导致什么都不做。 -replace=ADDRESS - 命令 Terraform 在计划中替换给定地址的资源实例。当一个或多个远程对象降级时,这非常有用,并且我们可以替换成具有相同配置的对象来与不可变的基础架构模式保持一致。如果指定的资源在计划中存在“更新”操作或没有变化,则 Terraform 将使用“替换”操作。多次包含此选项可一次替换多个对象。您不能将 -replace 与 -destroy 选项一起使用,并且该功能仅从 Terraform v0.15.2 开始可用。对于早期版本,使用 terraform taint 来实现类似的结果。 -target=ADDRESS - 命令 Terraform 只计算给定地址匹配的资源实例以及这些实例所依赖的任何对象的变更。 注意:应该仅在特殊情况下使用 -target=ADDRESS,例如从错误中恢复或绕过 Terraform 限制。有关更多详细信息,请参阅资源定位。 -var 'NAME=VALUE' - 设置在配置的根模块中声明的单个输入变量的值。多次设置该选项可设置多个变量。有关详细信息,请参阅在命令行中输入变量。 -var-file=FILENAME - 使用 tfvars 文件中的定义为配置的根模块中声明的潜在多个输入变量设置值。多次设置该选项可包含多个文件中的值。 除了 -var 和 -var-file 选项之外,还有其他几种方法可以在根模块中设置输入变量的值。有关详细信息,请参阅为根模块输入变量赋值。 在命令行中输入变量 我们可以使用 -var 命令行选项来指定根模块中声明的输入变量的值。 然而,要做到这一点,需要编写一个可由您选择的 shell 和 Terraform 解析的命令,对于涉及大量引号和转义序列的表达式来说这可能会很复杂。在大多数情况下,我们建议改用 -var-file 选项,并将实际值写入单独的文件中,以便 Terraform 可以直接解析它们,而不是解释 shell 解析后的结果。 警告:如果在等号之前或之后包含空格(例如 -var \"length = 2\"),Terraform 将报错。 要在 Linux 或 macOS 等系统上的 Unix 风格 shell 上使用 -var,我们建议将选项参数写在单引号 ' 中,以确保 shell 按字面解释该值: terraform plan -var 'name=value' 如果我们的预期值还包含单引号,那么我们仍然需要对其进行转义,以便 shell 进行正确解释,这还需要暂时终止引号序列,以便反斜杠转义字符合法: terraform plan -var 'name=va'\\''lue' 在 Windows 上使用 Terraform 时,我们建议使用 Windows 命令提示符 (cmd.exe)。当您从 Windows 命令提示符将变量值传递给 Terraform 时,请在参数周围使用双引号 \": terraform plan -var \"name=value\" 如果我们的预期值还包含双引号,那么您需要用反斜杠转义它们: terraform plan -var \"name=va\\\"lue\" Windows 上的 PowerShell 无法正确地将文字引号传递给外部程序,因此我们不建议您在 Windows 上时将 Terraform 与 PowerShell 结合使用。请改用 Windows 命令提示符。 根据变量的类型约束,声明变量值的语法有所不同。原始类型 string、number 和 bool 对应一个直接的字符串值,除非您的 shell 如上面的示例所示需要,否则不需要添加特殊的标点符号。对于所有其他类型约束,包括 list、map 和 set 类型以及特殊的 any 关键字,您必须编写一个表示该值的有效 Terraform 语言表达式,并附带必要的引用或转义字符以确保它将通过您的 shell 逐字传递到 Terraform。例如,对于 list(string) 类型约束: # Unix-style shell terraform plan -var 'name=[\"a\", \"b\", \"c\"]' # Windows Command Prompt (do not use PowerShell on Windows) terraform plan -var \"name=[\\\"a\\\", \\\"b\\\", \\\"c\\\"]\" 使用环境变量设置输入变量时也适用类似的约束。有关设置根模块输入变量的各种方法的更多信息,请参阅为根模块变量赋值。 资源定位 我们可以使用 -target 选项将 Terraform 的计算范围仅集中在少数资源上。我们可以使用资源地址语法来指定约束。Terraform 对代码中的资源地址的解释行为如下: 如果给定地址定位了一个特定资源实例,Terraform 将单独选择该实例。对于设置了 count 或 for_each 的资源,资源实例地址必须包含实例索引部分,例如 azurerm_resource_group.example[0]。 如果给定的地址对应到一个资源整体(即表达式中不含索引部分),Terraform 将选择该资源的所有实例。对于设置了 count 或 for_each 的资源,这意味着选择当前与该资源关联的所有实例索引。对于单实例资源(没有 count 或 for_each),资源地址和资源实例地址相同,因此这种可能性不适用。 如果给定的地址标识整个 Module 实例,Terraform 将选择属于该 Module 实例及其所有子 Module 实例的所有资源的所有实例。 一旦 Terraform 选择了我们直接定位的一个或多个资源实例,它还会扩展选择范围以包括这些选择直接或间接依赖的所有其他对象。 这种资源定位功能是为某些特殊情况设计的,例如从故障中恢复或绕过 Terraform 的某些限制。不建议将 -target 用于常规操作,因为这可能会导致未检测到的配置漂移以及对资源真实状态与配置的关系的混淆。 与其使用 -target 作为对非常庞大的配置的一小部分子集进行操作的方法,不如将大型配置分解为多个较小的配置,每个配置都可以独立应用。数据源可用于访问有关在其他配置中创建的资源的信息,从而允许将复杂的系统架构分解为更易于管理且可以独立更新的部分。 其他选项 terraform plan 命令还有一些与规划命令的输入和输出相关的其他选项,这些配置不会影响 Terraform 将创建哪种类型的计划。这些命令不一定在 terraform apply 上也可用,除非该命令的文档中另有说明。 -compact-warnings:如果 Terraform 生成了一些告警信息而没有伴随的错误信息,那么以只显示消息总结的精简形式展示告警 -detailed-exitcode:当命令退出时返回一个详细的返回码。如果有该参数,那么返回码将会包含更详细的含义: 0 = 成功的空计划(没有变更) 1 = 错误 2 = 成功的非空计划(有变更) -generate-config-out=PATH -(实验功能)如果配置中存在 import 块,则命令 Terraform 为尚未存在的任何导入资源生成 HCL。配置将写入 PATH 位置的新文件,该文件不可以存在,否则 Terraform 将报错。如果 plan 命令因为其他原因失败,Terraform 仍可能尝试写入配置。 -input=false:在取不到值的情况下是否提示用户给定输入变量值。此参数在非交互式自动化系统中运行 Terraform 时特别有用。 -lock=false:操作过程中不对状态文件上锁。如果其他人可能同一时间对同一工作区运行命令可能引发事故。 -lock-timeout=DURATION:除非使用 -lock=false 禁用锁定,否则指示 Terraform 在返回错误之前重试获取锁定一段时间。持续时间语法是一个数字后跟一个时间单位字母,例如 \"3s\" 表示三秒。 -no-color:关闭彩色输出。在无法解释输出色彩的终端中运行 Terraform 时请使用此参数。 -out=FILENAME:将变更计划保存到指定路径下的文件中,随后我们可以使用terraform apply执行该计划 Terraform 将允许计划文件使用任何文件名,但典型的约定是将其命名为 tfplan。请不要使用 Terraform 支持的后缀名来命名文件;如果您使用 .tf 后缀,那么 Terraform 将尝试将该文件解释为配置源文件,这将导致后续命令出现语法错误。 -parallelism-n:限制Terraform遍历图的最大并行度,默认值为 10。 指定其他配置文件目录 Terraform v0.13 及更早版本接受提供目录路径的附加位置参数,在这种情况下,Terraform 将使用该目录作为根模块而不是当前工作目录。 该用法在 Terraform v0.14 中已弃用,并在 Terraform v0.15 中删除。如果您的工作流程需要修改根模块目录,请改用 -chdir 全局选项,该选项适用于所有命令,并使 Terraform 始终在给定目录中查找它通常在当前工作目录中读取或写入的所有文件。 如果我们之前使用此遗留模式时同时需要 Terraform 将 .terraform 子目录写入当前工作目录,即使根模块目录已被覆盖,请使用 TF_DATA_DIR 环境变量命令 Terraform 将 .terraform 目录写入其他位置,而不是当前工作目录。 安全警告 被保存的变更计划文件(使用 -out 参数)内部可能含有敏感信息,Terraform 本身并不会加密计划文件。如果你要移动或是保存该文件一段时间,强烈建议你自行加密该文件。 Terraform 未来打算增强计划文件的安全性。 "},"6.Terraform命令行/15.providers/":{"url":"6.Terraform命令行/15.providers/","title":"providers","keywords":"","body":"providers terraform providers 命令显示有关当前工作目录中代码的 Provider 声明的信息,以帮助我们了解每个被需要的 Provider 是从何而来。 该命令是含有内嵌子命令,这些子命令我们会逐个解释。 用法 terraform providers "},"6.Terraform命令行/15.providers/1.mirror.html":{"url":"6.Terraform命令行/15.providers/1.mirror.html","title":"mirror","keywords":"","body":"terraform providers mirror 该子命令从 Terraform 0.13 开始引入。 terraform providers mirror 命令下载当前代码所需要的 Provider 并且将其拷贝到本地文件系统的一个目录下。 一般情况下,terraform init 会在初始化当前工作目录时自动从 registry 下载所需的 Provider。有时 Terraform 工作在无法执行该操作的环境下,例如一个无法访问 registry 的局域网内。这时可以通过显式配置 Provider 安装方式来使得在这样的环境下 Terraform 可以从本地插件镜像存储中获取插件。 terraform providers mirror 命令可以自动填充准备用以作为本地插件镜像存储的目录。 用法 terraform providers mirror [options] target-dir 参数是必填的。Terraform 会自动在目标目录下建立起插件镜像存储所需的文件结构,填充包含插件文件的 .zip 文件。 Terraform同时会生成一些包含了合法的网络镜像协议响应的 .json 索引文件,如果我们把填充好的文件夹上传到一个静态站点,那就能够得到一个静态的网络插件镜像存储服务。Terraform 在使用本地文件镜像存储时会忽略这些镜像文件,因为使用本地文件镜像时文件夹本身的信息更加权威。 该命令支持如下可选参数: -platform=OS_ARCH:选择构建镜像的目标平台。默认情况下,Terraform 会使用当前运行 Terraform 的平台。可以多次设置该参数以构建多目标平台插件镜像 目标平台必须包含操作系统以及 CPU 架构。例如:linux_amd64 代表运行在 AMD64 或是 X86_64 CPU 之上的 Linux 操作系统。 我们可以针对已构建的镜像文件夹重新运行 terraform providers mirror 来添加新插件。例如,可以通过设置 -platform 参数来添加新目标平台的插件,Terraform 会下载新平台插件同时保留原先的插件,将二者合并存储,并更新索引文件。 "},"6.Terraform命令行/15.providers/2.schema.html":{"url":"6.Terraform命令行/15.providers/2.schema.html","title":"schema","keywords":"","body":"terraform providers schema terraform providers schema 命令被用来打印当前代码使用的 Provider 的架构。Provider 架构包含了使用的所有 Provider 本身的参数信息,以及所提供的 resource、data 的架构信息。 用法 terraform providers schema [options] 可选参数为: -json:用机器可读的 JSON 格式打印架构 请注意,目前 -json 参数是必填的,未来该命令将允许使用其他参数。 输出包含一个 format_version 键,就拿 Terraform 1.1.0 来说,其值为 \"1.0\"。该版本的语义是: 对于向后兼容的变更或新增字段,我们将增加 minor 版本号,例如 \"1.1\"。这种变更会忽略所有不认识的对象属性,以保持与未来其他 minor 版本的前向兼容。 对于不向后兼容的变更,我们将增加 major 版本,例如 \"2.0\"。不同的 major 版本之间的数据无法直接传递。 我们只会在 Terraform 1.0 兼容性承诺的范围内更新 major 版本。 "},"6.Terraform命令行/15.providers/3.lock.html":{"url":"6.Terraform命令行/15.providers/3.lock.html","title":"lock","keywords":"","body":"terraform providers lock terraform providers lock 会查询上游 registry(默认情况下),以便将 Provider 的依赖项信息写入依赖项锁文件。 更新依赖项锁定文件的常见方法是由 terraform init 命令安装 Provider 时生成,但在某些情况下,这种自动生成的方法可能不够: 如果您在使用其他 Provider 程序安装方法(例如文件系统或网络镜像)的环境中运行 Terraform,则常规的 Provider 安装程序将不会访问 Provider 程序在 Registry 上的源,因此 Terraform 将无法填充所有可能的包校验和选定的 Provider 版本。 如果您使用 terraform lock 将 Provider 程序的官方版本校验和写入依赖项锁定文件中,则将来的 terraform init 运行将根据之前记录的官方校验和验证所选镜像中可用的软件包,从而进一步确保镜像返回的 Provider 程序的确是官方版本。 如果您的团队在多个不同平台上运行 Terraform(例如在 Windows 和 Linux 上),并且 Provider 的上游 Registry 无法使用最新的哈希方案提供签名的校验和,则后续在其他平台上运行 Terraform 可能会添加额外的校验和锁定文件。您可以通过使用 terraform providers lock 命令为您打算使用的所有平台预先填充哈希值来避免这种情况。 terraform providers lock 仅在 Terraform v0.14 或更高版本中可用。 用法 terraform providers lock [options] [providers...] 在没有额外的命令行参数的情况下,terraform providers lock 将分析当前工作目录中的代码,以查找它所依赖的所有 Provider 程序,并且将从其源 Registry 中获取有关这些 Provider 程序的关键数据,然后更新依赖项锁文件,写入所有选定的 Provider 程序版本以及 Provider 程序开发人员的私钥签名的包校验和。 警告:terraform providers lock 命令会打印有关其获取的内容以及每个包是否使用加密签名进行签名的信息,但它无法自动验证 Provider 提供者是否值得信赖以及它们是否符合您的本地系统策略或相关法规。在将更新的锁定文件提交到版本控制系统之前,请检查输出中的签名密钥信息,以确认您信任所有签名者。 如果您在命令行上列出一个或多个 Provider 程序源地址,则 terraform providers lock 将其工作仅限于这些提供程序,而其他提供程序(如果有的话)的锁定条目保持不变。 我们可以使用以下附加选项定制该命令的行为: -fs-mirror=PATH - 命令 Terraform 在给定的本地文件系统镜像目录中查找提供程序包,而不是在上游注册表中。给定目录必须使用通常的文件系统镜像目录布局。 -net-mirror=URL - 命令 Terraform 在给定的网络镜像服务中查找提供程序包,而不是在上游注册表中。给定的 URL 必须实现 Terraform Provider 网络镜像协议。 -platform=OS_ARCH - 设置打算用于处理此 Terraform 配置的平台。Terraform 将确保 Provider 程序均可用于指定的平台,并将在锁文件中保存足够的包校验和以至少支持指定的平台。 可以多次设置此选项以包含多个目标系统的校验和。 目标平台名称由操作系统和 CPU 架构组成。例如,linux_amd64 选择在 AMD64 或 x86_64 CPU 上运行的 Linux 操作系统。 我们将在后续节中讲述有关于此参数的更多详细信息。 -enable-plugin-cache - 启用全局配置的插件缓存的使用。这将加快锁定过程。默认情况下不启用此功能,因为插件缓存不是权威来源。由于 terraform providers lock 命令用于确保使用受信任的 Provider 程序版本,因此从缓存安装插件被认为是有风险的。 指定目标平台 例如,在我们的团队中可能既有在 Windows 或 macOS 工作站上使用 Terraform 配置的开发人员,也有在 Linux 上运行 Terraform 配置的自动化系统。 在这种情况下,我们可以选择验证所有 Provider 程序是否支持所有这些平台,并通过运行 terraform providers lock 并指定这三个平台来预先填充锁文件所需的校验和: terraform providers lock \\ -platform=windows_amd64 \\ # 64-bit Windows -platform=darwin_amd64 \\ # 64-bit macOS -platform=linux_amd64 # 64-bit Linux (上面的示例使用 Unix 风格的 shell 包装语法来提高可读性。如果您在 Windows 上运行该命令,则需要将所有参数放在一行上,并删除反斜杠和注释。) 内部 Providers 的锁条目 所谓内部 Provider 程序是还没有在真正的 Terraform Provider 注册表上发布的 Provider 程序,因为它仅在特定组织内开发和使用,并通过文件系统镜像或网络镜像进行分发。 默认情况下,terraform providers lock 命令假定所有 Provider 程序都是在 Terraform Provider 程序注册表中可用,并尝试联系源注册表以访问有关提供程序包的所有细节信息。 要为仅在本地镜像中可用的特定 Provider 程序创建锁定条目,可以使用 -fs-mirror 或 -net-mirror 命令行选项来覆盖查询 Provider 程序的原始注册表的默认行为: terraform providers lock \\ -fs-mirror=/usr/local/terraform/providers -platform=windows_amd64 \\ -platform=darwin_amd64 \\ -platform=linux_amd64 \\ tf.example.com/ourcompany/ourplatform (上面的示例使用 Unix 风格的 shell 包装语法来提高可读性。如果您在 Windows 上运行该命令,则需要将所有参数放在一行上,并删除反斜杠和注释。) 由于上面的命令包含 Provider 程序源地址 tf.example.com/ourcompany/ourplatform,因此 terraform providers lock 将仅尝试访问该特定 Provider 程序,并将保留所有其他 Provider 程序的锁条目不变。如果我们有来自不同来源的各种不同的 Provider 程序,可以多次运行 terraform providers lock 并每次指定不同的 Provider 程序子集。 -fs-mirror 和 -net-mirror 选项与 Provider 程序安装方法配置中的 filesystem_mirror 和 network_mirror 块具有相同的含义,但仅配置其中之一,以便明确您打算从何处派生包校验和信息。 请注意,只有原始注册表可以提供开发人员的原始加密签名所签署的官方校验和。因此,从文件系统或网络镜像创建的锁条目将仅覆盖您请求的确切平台,并且记录的校验和将是镜像报告的校验和,而不是原始注册表的官方校验和。如果要确保记录的校验和是由原始 Provider 发布者签名的校验和,请运行不带 -fs-mirror 或 -net-mirror 选项的此命令,以从原始注册表中获取所有信息。 如果您愿意,您可以通过内部 Provider 程序注册表发布您的内部 Provider 程序,然后该注册表将允许锁定和安装这些 Provider 程序,而无需任何特殊选项或额外的 CLI 配置。有关详细信息,请参阅 Provider 注册协议。 "},"6.Terraform命令行/16.refresh.html":{"url":"6.Terraform命令行/16.refresh.html","title":"refresh","keywords":"","body":"refresh terraform refresh 命令将实际存在的基础设施对象的状态同步到状态文件中记录的对象状态。它可以用来检测真实状态与记录状态之间的漂移并更新状态文件。 警告!!!该命令已在最新版本 Terraform 中被废弃,因为该命令的默认行为在当前用户错误配置了使用的云平台令牌时会引发对状态文件错误的变更。 该命令并不会修改基础设施对象,只修改状态文件。 我们一般不需要使用该命令,因为 Terraform 会自动执行相同的刷新操作,作为在 terraform plan 和 terraform apply 命令中创建计划的一部分。本命令在这里主要是为了向后兼容,但我们不建议使用它,因为它没有提供在更新状态之前检查操作效果的机会。 用法 terraform refresh [options] 该命令本质上是以下命令的别名,具有完全相同的效果: terraform apply -refresh-only -auto-approve 因此,该命令支持所有 terraform apply 所支持的参数,除了它不接受一个现存的变更计划文件,不允许选择 \"refresh only\" 之外的模式,并且始终应用 -auto-approve 选项。 自动执行 refresh 是很危险的,因为如果当前用户错误配置了使用的 Provider 的令牌,那么 Terraform 会错误地以为当前状态文件中记录的所有资源都被删除了,随即从状态文件中无预警地删除所有相关记录。 我们推荐运行如下命令来取得相同的效果,同时可以在修改状态文件之前预览即将对其作出的修改: terraform apply -refresh-only 该命令将会在交互界面中提示用户检测到的变更,并提示用户确认执行。 terraform apply 和 terraform plan 命令的 -refresh-only 选项是从 Terraform v0.15.4 版本开始被引入的。对更早的版本,用户只能直接使用 terraform refresh 命令,同时要小心本篇警告过的危险副作用。尽可能避免显式使用 terraform refresh 命令,Terraform 在执行 terraform plan 和 terraform apply 命令时都会自动执行刷新状态的操作以生成变更计划,尽可能依赖该机制来维持状态文件的同步。 "},"6.Terraform命令行/17.show.html":{"url":"6.Terraform命令行/17.show.html","title":"show","keywords":"","body":"show terraform show 命令从状态文件或是变更计划文件中打印人类可读的输出信息。这可以用来检查变更计划以确定所有操作都是符合预期的,或是审查当前的状态文件。 可以通过添加 -json 参数输出机器可读的 JSON 格式输出。 需要注意的是,使用 -json 输出时所有标记为 sensitive 的敏感数据都会以明文形式被输出。 JSON 输出 可以使用 terraform show -json 命令打印 JSON 格式的状态信息。 如果指定了一个变更计划文件,terraform show -json 会以 JSON 格式记录变更计划、配置以及当前状态。 如果在写入状态文件后更新了包含新架构版本的 Provider 程序,则需要先升级状态,然后才能使用 show -json 显示状态。如果要查看计划,必须先在不使用 -refresh=false 的情况下创建计划文件。如果要查看当前状态,请先运行 terraform refresh。 用法 terraform show [options] [file] 您可以将为 file 指定状态文件或计划文件的路径。如果不指定文件路径,Terraform 将显示最新的状态快照。 该命令支持以下参数: -json:以 JSON 格式输出 -no-color:与 apply 类似,不再赘述 "},"6.Terraform命令行/18.state/":{"url":"6.Terraform命令行/18.state/","title":"state","keywords":"","body":"state terraform state 命令可以用来进行复杂的状态管理操作。随着你对 Terraform 的使用越来越深入,有时候你需要对状态文件进行一些修改。由于我们在状态管理章节中提到过的,状态文件的格式属于 HashiCorp 未公开的私有格式,所以直接修改状态文件是不适合的,我们可以使用 terraform state 命令来执行修改。 该命令含有数个子命令,我们会一一介绍。 用法 terraform state [options] [args] 远程状态 所有的 state 子命令都可以搭配本地状态文件以及远程状态使用。使用远程状态时读写操作可能用时稍长,因为读写都要通过网络完成。备份文件仍然会被写入本地磁盘。 备份 所有会修改状态文件的 terraform state 子命令都会生成备份文件。可以通过 -backup 参数指定备份文件的位置。 只读子命令(例如 list )由于不会修改状态,所以不会生成备份文件。 注意修改状态的 state 子命令无法禁用备份。由于状态文件的敏感性,Terraform 强制所有修改状态的子命令都必须生成备份文件。如果你不想保存备份,可以手动删除。 命令行友好 state 子命令的输出以及命令结构都被设计得易于同 Unix 下其他命令行工具搭配使用,例如 grep、awk 等等。同样的,输出结果也可以在 Windows 上轻松使用 PowerShell 处理。 对于复杂场景,我们建议使用管道组合 state 子命令与其他命令行工具一同使用。 资源地址 state 子命令中大量使用了资源地址,我们在资源地址章节中做了相关的介绍。 "},"6.Terraform命令行/18.state/1.list.html":{"url":"6.Terraform命令行/18.state/1.list.html","title":"list","keywords":"","body":"list terraform state list 命令可以列出状态文件中记录的资源对象。 用法 terraform state list [options] [address...] 该命令会根据 address 列出状态文件中相关资源的信息(如果给定了 address 的话)。如果没有给定 address,那么所有资源都会被列出。 列出的资源根据模块深度以及字典序进行排序,这意味着根模块的资源在前,越深的子模块定义的资源越在后。 对于复杂的基础设施,状态文件可能包含成千上万到的资源对象。可以指定一个或多个资源地址来进行过滤。 可以使用的可选参数有: -state=path:指定使用的状态文件地址。默认为 terraform.tfstate。使用远程 Backend 时该参数设置无效 -id=id:要显示的资源 ID 例子:列出所有资源 $ terraform state list aws_instance.foo aws_instance.bar[0] aws_instance.bar[1] module.elb.aws_elb.main 例子:根据资源地址过滤 $ terraform state list aws_instance.bar aws_instance.bar[0] aws_instance.bar[1] 例子:根据模块过滤 该例子列出给定模块及其子模块的所有资源: $ terraform state list module.elb module.elb.aws_elb.main module.elb.module.secgroups.aws_security_group.sg 例子:根据ID过滤 此示例将仅列出在命令行中指定 ID 的资源,查找特定资源在代码中的位置时非常有用: $ terraform state list -id=sg-1234abcd module.elb.aws_security_group.sg "},"6.Terraform命令行/18.state/2.mv.html":{"url":"6.Terraform命令行/18.state/2.mv.html","title":"mv","keywords":"","body":"mv Terraform 状态的主要功能是记录下代码中的资源实例地址与其代表的远程对象之间的绑定。通常,Terraform 会自动更新状态以响应应用计划时采取的操作,例如删除已被删除的远程对象的绑定。 在修改了 resource 块名称,或是将资源移动到代码中的不同模块时,如果想保留现有的远程对象,可以使用 terraform state mv 命令。 用法 terraform state mv [options] SOURCE DESTINATION Terraform 将在当前状态中查找与给定地址匹配的资源实例、资源或模块,如果找到,则将原本由源地址跟踪的远程对象移动到目标地址下。 源地址和目标地址都必须使用资源地址语法,并且它们引用对象的类型必须相同:我们只能将一个资源实例移动到另一个资源实例,将整个模块实例移动到另一个整个模块实例,等等。此外,如果我们要移动资源或资源实例,则只能将其移动到具有相同资源类型的新地址。 terraform state mv 最常见的用途是当我们在代码中重命名 resource 块,或是将 resource 块移动到子模块中时,这两种情况都是为了保留现有对象但以新地址跟踪它。默认情况下,Terraform 会将移动或重命名资源配置理解为删除旧对象并在新地址创建新对象的请求,因此 terraform state mv 允许我们已经存在的对象附加到Terraform 中的新地址上。 警告:如果我们在多人协作环境中使用 Terraform,则必须确保当我们使用 terraform state mv 进行代码重构时,我们与同事进行了仔细沟通,以确保没有人在我们的配置更改和 terraform 状态之间进行任何其他更改mv 命令,因为否则他们可能会无意中创建一个计划,该计划将销毁旧对象并在新地址创建新对象。 该命令提供以下可选参数: -dry-run:报告与给定地址匹配的所有资源实例。 -lock=false:执行时是否先锁定状态文件。如果其他人可能同时对同一工作区运行命令,则这是危险的。 -lock-timeout=DURATION:除非使用 -lock=false 禁用锁定,否则命令 Terraform 为上锁操作设置一个超时时长。持续时间语法是一个数字后跟一个时间单位字母,例如“3s”表示三秒。 以下是使用 local Backend 时可用的遗留参数: -backup=FILENAME:指定源状态文件的备份地址,默认为源状态文件加上\".backup\"后缀 -bakcup-out=FILENAME:指定目标状态文件的备份地址,默认为目标状态文件加上\".backup\"后缀 -state=FILENAME:源状态文件地址,默认为当前 Backend 或是\"terraform.tfstate\" -state-out=FILENAME:目标状态文件地址。如果不指定则使用源状态文件。可以是一个已经存在的文件或新建一个文件 例子:重命名一个资源 $ terraform state mv 'packet_device.worker' 'packet_device.helper' ... -resource \"packet_device\" \"worker\" { +resource \"packet_device\" \"helper\" { # ... } 例子:将一个资源移动进一个模块 如果我们最初在根模块中编写了资源,但现在希望将其重构进子模块,则可以将 resource 块移动到子模块代码中,删除根模块中的原始资源,然后运行以下命令告诉 Terraform 将其视为一次移动: $ terraform state mv 'packet_device.worker' 'module.app.packet_device.worker' 在上面的示例中,新资源具有相同的名称,但模块地址不同。如果新的模块组织建议不同的命名方案,您还可以同时更改资源名称: $ terraform state mv packet_device.worker module.worker.packet_device.main 例子:移动一个模块进入另一个模块 我们还可以将整个模块重构为子模块。在配置中,将代表模块的 module 块移动到不同的模块中,然后使用如下命令将更改配对: $ terraform state mv 'module.app' 'module.parent.module.app' 例子:移动一个模块到另一个状态文件 $ terraform state mv -state-out=other.tfstate 'module.app' 'module.app' 移动一个带有 count 参数的资源 使用 count 元参数定义的资源具有多个实例,每个实例都由一个整数标识。我们可以通过在给定地址中包含显式索引来选择特定实例: $ terraform state mv 'packet_device.worker[0]' 'packet_device.helper[0]' 不使用 count 或 for_each 的资源只有一个资源实例,其地址与资源本身相同,因此我们可以从不包含索引的地址移动到包含索引的地址,或相反: $ terraform state mv 'packet_device.main' 'packet_device.all[0]' 方括号 ([, ]) 在某些 shell 中具有特殊含义,因此您可能需要引用或转义地址,以便将其逐字传递给 Terraform。上面的示例显示了 Unix 风格 shell 的典型引用语法。 移动一个带有 for_each 参数的资源 使用 for_each 元参数定义的资源具有多个实例,每个实例都由一个字符串标识。我们可以通过在给定地址中包含显式的键来选择特定实例。 但是,字符串的语法包含引号,并且引号符号通常在命令 shell 中具有特殊含义,因此我们需要为正在使用的 shell 使用适当的引用和/或转义语法。例如: Linux、MacOS 以及 Unix: $ terraform state mv 'packet_device.worker[\"example123\"]' 'packet_device.helper[\"example456\"]' PowerShell: $ terraform state mv 'packet_device.worker[\\\"example123\\\"]' 'packet_device.helper[\\\"example456\\\"]' Windows 命令行(cmd.exe): $ terraform state mv packet_device.worker[\\\"example123\\\"] packet_device.helper[\\\"example456\\\"] 除了使用字符串而不是整数作为实例键之外,for_each 资源的处理与 count 资源类似,因此具有和不具有索引组件的相同地址组合都是有效的,如上一节所述。 "},"6.Terraform命令行/18.state/3.pull.html":{"url":"6.Terraform命令行/18.state/3.pull.html","title":"pull","keywords":"","body":"pull terraform state pull 命令可以从远程 Backend 中人工下载状态并输出。该命令也可搭配本地状态文件使用。 用法 terraform state pull 该命令下载当前位置对应的状态文件,并以原始格式打印到标准输出流。 由于状态文件使用 JSON 格式,该功能可以搭配例如 jq 这样的命令行工具使用,也可以用来人工修改状态文件。 注意:Terraform 状态文件必须采用 UTF-8 格式,不带字节顺序标记 (BOM)。对于 Windows 上的 PowerShell,使用 Set-Content 自动以 UTF-8 格式对文件进行编码。例如,运行 terraform state pull | sc terraform.tfstate "},"6.Terraform命令行/18.state/4.push.html":{"url":"6.Terraform命令行/18.state/4.push.html","title":"push","keywords":"","body":"push terraform push 命令被用来手动上传本地状态文件到远程 Backend。该命令也可以被用在当前使用的本地状态文件上。 该命令应该很少使用。它时一种需要对远程状态进行手动干预的情况下使用的工具。 用法 terraform state push [options] PATH 该命令会把 PATH 位置的状态文件推送到当前使用的 Backend 上(可以是当前使用的 terraform.tfstate 文件)。 如果 PATH 为 -,则从标准输入流读取要推送的状态数据。该数据在写入目标状态之前被完全加载到内存中并进行验证。 注意:Terraform 状态文件必须采用 UTF-8 格式,不带字节顺序标记 (BOM)。对于 Windows 上的 PowerShell,使用 Set-Content 自动以 UTF-8 格式对文件进行编码。例如,运行 terraform state push | sc terraform.tfstate。 Terraform 会进行一系列检查以防止你进行一些不安全的变更: 检查 lineage:如果两个状态文件的 lineage 值不同,Terraform 会禁止推送。一个不同的 lineage 说明两个状态文件描述的是完全不同的基础设而你可能会因此丢失重要数据 序列号检查:如果目标状态文件的 serial 值大于你要推送的状态的 serial 值,Terraform 会禁止推送。一个更高的 serial 值说明目标状态文件已经无法与要推送的状态文件对应上了 这两种检查都可以通过添加 -force 参数禁用,但不推荐这样做。如果禁用安全检查直接推送,那么目标状态文件将被覆盖。 "},"6.Terraform命令行/18.state/5.replace-provider.html":{"url":"6.Terraform命令行/18.state/5.replace-provider.html","title":"replace-provider","keywords":"","body":"replace-provider terraform state replace-provider 命令可以替换状态文件中资源对象所使用的 Provider. 用法 terraform state replace-provider [options] FROM_PROVIDER_FQN TO_PROVIDER_FQN 该命令会更新所有使用 from 的 Provider 的资源,将它们使用的 Provider 更新为 to Provider。这让我们可以更新状态文件中资源所使用的 Provider 的源。 该命令在进行任意修改之前会先生成一个备份文件。备份机制不可关闭。 支持以下可选参数: -auto-approve:跳过交互式提示确认环节 -lock=false:执行时是否先锁定状态文件。如果其他人可能同时对同一工作区运行命令,则这是危险的。 -lock-timeout=0s:除非使用 -lock=false 禁用锁定,否则命令 Terraform 为上锁操作设置一个超时时长。持续时间语法是一个数字后跟一个时间单位字母,例如“3s”表示三秒。 以下是使用 local Backend 时可用的遗留参数: -backup=FILENAME:指定源状态文件的备份地址,默认为源状态文件加上\".backup\"后缀 -bakcup-out=FILENAME:指定目标状态文件的备份地址,默认为目标状态文件加上\".backup\"后缀 -state=FILENAME:源状态文件地址,默认为当前 Backend 或是\"terraform.tfstate\" -state-out=FILENAME:目标状态文件地址。如果不指定则使用源状态文件。可以是一个已经存在的文件或新建一个文件 样例 下面的示例将 hashicorp/aws Provider 程序替换为 acme 的复刻版本,该 Provider 托管在 registry.acme.corp 的私有注册表中: $ terraform state replace-provider hashicorp/aws registry.acme.corp/acme/aws "},"6.Terraform命令行/18.state/6.rm.html":{"url":"6.Terraform命令行/18.state/6.rm.html","title":"rm","keywords":"","body":"rm Terraform 状态的主要功能是记录下代码中的资源实例地址与其代表的远程对象之间的绑定。通常,Terraform 会自动更新状态以响应应用计划时采取的操作,例如删除已被删除的远程对象的绑定。 terraform state rm 命令可以用来从状态文件中删除对象和实际远程对象的绑定,该命令只是删除绑定,不会删除实际存在的远程对象,删除后 Terraform 会“忘记”这个对象的存在。 注意:从 Terraform v1.7.0 开始支持 removed 块。与 terraform state rm 命令不同,您可以使用 removed 块一次删除多个资源,并且您可以将删除操作作为正常计划和执行工作流程的一部分进行审查。了解有关将 removed 块与资源一起使用以及将 removed 块与模块一起使用的更多信息。 用法 terraform state rm [options] ADDRESS... Terraform 将在状态中搜索与给定资源地址匹配的任何实例,并删除所有实例对应的记录,以便 Terraform 将不再跟踪相应的远程对象。 这意味着尽管这些对象仍将继续存在于远程系统中,但后续的 terraform plan 会尝试新建这些被“遗忘”的实例。根据远程系统施加的约束,如果这些对象的名称或其他标识符与仍然存在的旧对象发生冲突,创建这些对象可能会失败。 可以使用如下可选参数: -dry-run:报告与给定地址匹配的所有资源实例(由于此时并未执行删除,所以 Terraform 这时还不会“遗忘”任何资源)。 -lock=false:执行时是否先锁定状态文件。如果其他人可能同时对同一工作区运行命令,则这是危险的。 -lock-timeout=DURATION:除非使用 -lock=false 禁用锁定,否则命令 Terraform 为上锁操作设置一个超时时长。持续时间语法是一个数字后跟一个时间单位字母,例如“3s”表示三秒。 以下是使用 local Backend 时可用的遗留参数: -backup=FILENAME:指定源状态文件的备份地址,默认为源状态文件加上\".backup\"后缀 -bakcup-out=FILENAME:指定目标状态文件的备份地址,默认为目标状态文件加上\".backup\"后缀 -state=FILENAME:源状态文件地址,默认为当前 Backend 或是\"terraform.tfstate\" -state-out=FILENAME:目标状态文件地址。如果不指定则使用源状态文件。可以是一个已经存在的文件或新建一个文件 删除一个资源 下面的例子演示了如何让 Terraform “遗忘”所有类型为 packet_device,并且名为 worker 的资源实例: $ terraform state rm 'packet_device.worker' 不使用 count 或 for_each 的资源只有一个实例,因此该示例也是选择该单个实例的正确语法。 删除一个模块 $ terraform state rm 'module.foo' 删除一个模块内资源 要选择在子模块中定义的资源,我们必须指定该模块的路径作为资源地址的一部分: $ terraform state rm 'module.foo.packet_device.worker' 删除一个声明count的资源 使用 count 元参数定义的资源具有多个实例,每个实例都由一个整数标识。我们可以通过在给定地址中包含显式索引来选择特定实例: $ terraform state rm 'packet_device.worker[0]' 方括号 ([, ]) 在某些 shell 中具有特殊含义,因此我们可能需要引用或转义地址,以便将其逐字传递给 Terraform。上面的例子使用了 Unix 风格 shell 的典型引用语法。 删除一个声明for_each的资源 使用 for_each 元参数定义的资源具有多个实例,每个实例都由一个字符串标识。我们可以通过在给定地址中包含显式密钥来选择特定实例。 但是,字符串的语法包含引号,并且引号符号通常在命令 shell 中具有特殊含义,因此我们需要为我们正在使用的 shell 使用适当的引用和/或转义语法。例如: Linux, MacOS, and Unix: $ terraform state rm 'packet_device.worker[\"example\"]' PowerShell: $ terraform state rm 'packet_device.worker[\\\"example\\\"]' Windows命令行(cmd.exe): $ terraform state rm packet_device.worker[\\\"example\\\"] "},"6.Terraform命令行/18.state/7.show.html":{"url":"6.Terraform命令行/18.state/7.show.html","title":"show","keywords":"","body":"show terraform state show 命令可以展示状态文件中单个资源的属性。 用法 terraform state show [options] ADDRESS 该命令需要指定一个资源地址。资源地址需要遵循资源地址格式。 该命令支持以下可选参数: -state=path:指向状态文件的路径。默认情况下是 terraform.tfstate。如果启用了远程 Backend 则该参数设置无效 terraform state show 的输出被设计成人类可读而非机器可读。如果想要从输出中提取数据,请使用 terraform show -json。 展示单个资源 $ terraform state show 'packet_device.worker' # packet_device.worker: resource \"packet_device\" \"worker\" { billing_cycle = \"hourly\" created = \"2015-12-17T00:06:56Z\" facility = \"ewr1\" hostname = \"prod-xyz01\" id = \"6015bg2b-b8c4-4925-aad2-f0671d5d3b13\" locked = false } 展示单个模块资源 $ terraform state show 'module.foo.packet_device.worker' 展示声明count资源中特定实例 $ terraform state show 'packet_device.worker[0]' 展示声明for_each资源中特定实例 Linux, MacOS, and Unix: $ terraform state show 'packet_device.worker[\"example\"]' PowerShell: $ terraform state show 'packet_device.worker[\\\"example\\\"]' Windows命令行: $ terraform state show packet_device.worker[\\\"example\\\"] "},"6.Terraform命令行/19.taint.html":{"url":"6.Terraform命令行/19.taint.html","title":"taint","keywords":"","body":"taint terrform taint 命令可以手动标记某个Terraform管理的资源有\"污点\",强迫在下一次执行apply时删除并重建之。 该命令并不会修改基础设施,而是在状态文件中的某个资源对象上标记污点。当一个资源对象被标记了污点,在下一次 plan 操作时会计划将之删除并且重建,apply 操作会执行这个变更。 强迫重建某个资源可以使你能够触发某种副作用。举例来说,你想重新执行某个预置器操作,或是某些人绕过 Terraform 修改了虚拟机状态,而你想将虚拟机重置。 注意为某个资源标记污点并重建之会影响到所有依赖该资源的对象。举例来说,一条 DNS 记录使用了服务器的 IP 地址,我们在服务器上标记污点会导致 IP 发生变化从而影响到 DNS 记录。这种情况下可以使用 plan 命令查看变更计划。 警告:此命令已被弃用。从 Terraform v0.15.2 开始,我们建议使用 -replace 选项和 terraform apply 代替(详细信息如下)。 推荐的替代方法 从 Terraform v0.15.2 开始,我们建议使用 terraform apply 的 -replace 选项来强制 Terraform 替换对象,即使没有发生需要变更的配置更改。 terraform apply -replace=\"aws_instance.example[0]\" 我们推荐使用 -replace 参数,因为这可以在 Terraform 计划中显示将要发生的变更,让我们在采取任何会影响系统的操作之前了解计划将如何影响我们的基础设施。当我们使用 terraform taint 时,其他用户有可能可以在我们审查变更之前针对标记的对象创建新的变更计划。 用法 terraform taint [options] address 参数是要标记污点的资源地址。该地址格式遵循资源地址语法,例如: aws_instance.foo aws_instance.bar[1] aws_instance.baz[\\\"key\\\"] (资源地址中的引号必须在命令行中转义,这样它们就不会被 shell 解释) module.foo.module.bar.aws_instance.qux 该命令可以使用如下可选参数: -allow-missing:如果声明该参数,那么即使资源不存在,命令也会返回成功(状态码0)。对于其他异常情况,该命令可能仍会返回错误,例如读取或写入状态时出现问题。 -lock=false:执行时是否先锁定状态文件。如果其他人可能同时对同一工作区运行命令,则这是危险的。 -lock-timeout=DURATION:除非使用 -lock=false 禁用锁定,否则命令 Terraform 为上锁操作设置一个超时时长。持续时间语法是一个数字后跟一个时间单位字母,例如“3s”表示三秒。 以下是使用 local Backend 时可用的遗留参数: -backup=FILENAME:指定源状态文件的备份地址,默认为源状态文件加上\".backup\"后缀 -bakcup-out=FILENAME:指定目标状态文件的备份地址,默认为目标状态文件加上\".backup\"后缀 -state=FILENAME:源状态文件地址,默认为当前 Backend 或是\"terraform.tfstate\" -state-out=FILENAME:目标状态文件地址。如果不指定则使用源状态文件。可以是一个已经存在的文件或新建一个文件 标记单个资源 $ terraform taint aws_security_group.allow_all The resource aws_security_group.allow_all in the module root has been marked as tainted. 标记使用for_each创建的资源的特定实例 $ terraform taint \"module.route_tables.azurerm_route_table.rt[\\\"DefaultSubnet\\\"]\" The resource module.route_tables.azurerm_route_table.rt[\"DefaultSubnet\"] in the module root has been marked as tainted. 标记模块中的资源 $ terraform taint \"module.couchbase.aws_instance.cb_node[9]\" Resource instance module.couchbase.aws_instance.cb_node[9] has been marked as tainted. 虽然我们推荐模块深度不要超过1,但是我们仍然可以标记多层模块中的资源: $ terraform taint \"module.child.module.grandchild.aws_instance.example[2]\" Resource instance module.child.module.grandchild.aws_instance.example[2] has been marked as tainted. "},"6.Terraform命令行/20.validate.html":{"url":"6.Terraform命令行/20.validate.html","title":"validate","keywords":"","body":"validate terraform validate 命令可以检查目录下 Terraform 代码,只检查语法文件,不会访问诸如远程 Backend、Provider 的 API 等远程资源。 validate 检查代码的语法是否合法以及一致,不管输入变量以及现存状态。 自动运行此命令是安全的,例如作为文本编辑器中的保存后检查或作为 CI 系统中可复用的测试步骤。 validate 命令需要已初始化的工作目录,所有引用的插件与模块都被安装完毕。如果只想检查语法而不想与 Backend 交互,可以这样初始化工作目录: $ terraform init -backend=false 要验证特定运行上下文中的配置(特定目标工作空间、输入变量值等),请改用 terraform plan 命令,其中包括隐式验证检查。 用法 terraform validate [options] 默认情况下 validate 命令不需要任何参数就可以在当前工作目录下进行检查。 可以使用如下可选参数: -json:使用 JSON 格式输出机器可读的结果 -no-color:禁止使用彩色输出 JSON 输出格式 当您使用 -json 选项时,Terraform 将生成 JSON 格式的验证结果,使得我们可以将之与验证结果的工具进行集成,例如在文本编辑器中突出显示错误。 与所有 JSON 输出选项一样,Terraform 在开始验证任务之前就可能会遇到错误,因此输出的错误可能不会是 JSON 格式的。因此,使用 Terraform 输出的外部软件应该准备好在 stdout 上读取到非有效 JSON 的数据,然后将其视为一般错误情况。 输出包含一个 format_version 键,从 Terraform 1.1.0 开始,其值为“1.0”。该版本的语义是: 对于向后兼容的变更或新增字段,我们将增加 minor 版本号,例如 \"1.1\"。这种变更会忽略所有不认识的对象属性,以保持与未来其他 minor 版本的前向兼容。 对于不向后兼容的变更,我们将增加 major 版本,例如 \"2.0\"。不同的 major 版本之间的数据无法直接传递。 我们只会在 Terraform 1.0 兼容性承诺的范围内更新 major 版本。 在正常情况下,Terraform 会将 JSON 对象打印到标准输出流。顶级 JSON 对象将具有以下属性: valid(bool):总体验证结果结论,如果 Terraform 认为当前配置有效,则为 true;如果检测到任何错误,则为 false。 error_count(number):零或正整数,给出 Terraform 检测到的错误计数。如果 valid 为 true,则 error_count 将始终为零,因为错误的存在表明配置无效。 warning_count(number):零或正整数,给出 Terraform 检测到的警告计数。警告不会导致 Terraform 认为配置无效,但用户应考虑并尝试解决它们。 diagnostics(对象数组):嵌套对象的 JSON 数组,每个对象描述来自 Terraform 的错误或警告。 diagnostics 中的对象拥有如下属性: severity(string):字符串关键字,可以是 \"error\" 或 \"warning\",指示诊断严重性。 error 的存在会导致 Terraform 认为配置无效,而 warning 只是对用户的建议或警告,不会阻止代码运行。Terraform 的后续版本可能会引入新的严重性等级,因此解析错误信息时应该准备好接受并忽略他们不了解的 severity 值。 summary(string):诊断报告的问题性质的简短描述。 在 Terraform 易于阅读的的诊断消息中,summary 充当诊断的一种“标题”,打印在 \"Error:\" 或 \"Warning:\" 指示符之后。 摘要通常是简短的单个句子,但如果返回错误的子系统并没有设计成返回全面的诊断信息时,就只能把整个错误信息作为摘要返回,导致较长的摘要。这种情况下,摘要可能包含换行符,渲染摘要信息时需要注意。 detail(string):可选的附加消息,提供有关问题的更多详细信息。 在 Terraform 易于阅读的的诊断消息中,详细信息提供了标题和源位置引用之后出现的文本段落。 详细消息通常是多个段落,并且可能散布有非段落行,因此旨在向用户呈现详细消息的工具应该区分没有前导空格的行,将它们视为段落,以及有前导空格的行,将它们视为预格式化文本。然后,渲染器应该对段落进行软换行以适合渲染容器的宽度,但保留预格式化的行不换行。 一些 Terraform 详细消息包含使用 ASCII 字符来标记项目符号的近似项目符号列表。这不是官方承诺,因此渲染器应避免依赖它,而应将这些行视为段落或预格式化文本。此格式的未来版本可能会为其他文本约定定义附加规则,但将保持向后兼容性。 range(对象):引用与诊断消息相关的配置源代码的一部分的可选对象。对于错误,这通常指示被检测为无效的特定块头、属性或表达式的边界。 源范围是一个具有 filename 属性的对象,该 filename 为当前工作目录的相对路径,然后两个属性 start 和 end 本身都是描述源位置的对象,如下所述。 并非所有诊断消息都与配置的特定部分相关,因此对于不相关的诊断消息,range 将被省略或为 null。 snippet(对象):可选对象,包括与诊断消息相关的配置源代码的摘录。 snippet 信息包括了: context(string):诊断的根上下文的可选摘要。例如,这可能是包含触发诊断的表达式的 resource 块。对于某些诊断,此信息不可用,并且此属性将为空。 code(string):Terraform 配置的片段,包括诊断源。可能包含多行,并且可能包括触发诊断的表达式周围的附加配置源代码。 start_line(number):从一开始的行计数,表示源文件中代码摘录开始的位置。该值不一定与 range.start.line 相同,因为 code 可能在诊断源之前包含一行或多行上下文。 highlight_start_offset(number):代码字符串中从零开始的字符偏移量,指向触发诊断的表达式的开头。 highlight_end_offset(number):代码字符串中从零开始的字符偏移量,指向触发诊断的表达式的末尾。 values(对象数组):包含零个或多个表达式值,帮助我们理解复杂表达式中的诊断来源。这些表达式值对象如下所述。 源位置(Source Position) 在诊断对象的 range 属性中源位置对象具有以下属性: byte(number):指定文件中从零开始的字节偏移量。 line(number):从一开始的行计数,指向文件中相关位置的行。 column(number):从一开始的列计数,指向 line 对应的行开头开始的 Unicode 字符计数位置。 start 位置是包含的(数学的 []),而 end 位置是不包含的(数学的 ())。用于特定错误消息的确切位置仅供人类解读。 表达式值 表达式值对象提供有关触发诊断的表达式一部分的值的附加信息。当使用 for_each 或类似结构时,这特别有用,以便准确识别哪些值导致错误。该对象有两个属性: traversal (string):类似 HCL 的可遍历表达式字符串,例如 var.instance_count。复杂的索引键值可能会被省略,因此该属性并非总是合法、可解析的 HCL。该字符串的内容旨在便于人类阅读。 statement(string):一个简短的英语片段,描述触发诊断时表达式的值。该字符串的内容旨在便于人类阅读,并且在 Terraform 的未来版本中可能会发生变化。 "},"6.Terraform命令行/21.untaint.html":{"url":"6.Terraform命令行/21.untaint.html","title":"untaint","keywords":"","body":"untaint Terraform 有一个名为“tainted”的标记,用于跟踪可能损坏的对象,该命令已被废弃,应使用 terraform apply -replace 代替。 如果创建一个资源的操作由多个步骤组成,操作期间其中之一的操作发生错误,Terraform 会自动将对象标记为“受污染”,因为 Terraform 无法确定该对象是否处于完整功能状态。 terraform untaint 命令可以手动清除一个 Terraform 管理的资源对象上的污点,恢复它在状态文件中的状态。它是 terraform taint 的逆向操作。 该命令不会修改实际的基础设施资源,只会在资源文件中清除资源对象上的污点标记。 如果我们从对象中删除污点标记,但后来发现它还是损坏了,则可以使用如下命令创建并应用一个计划来替换受损的资源对象,而无需首先重新在该对象上标记污点: terraform apply -replace=\"aws_instance.example[0]\" 用法 terraform untaint [options] address name参数是要清除污点的资源的资源名称。该参数的格式为TYPE.NAME,比如aws_instance.foo。 可以使用如下可选参数: -allow-missing:如果声明该参数,那么即使资源不存在,命令也会返回成功(状态码0)。对于其他异常情况,该命令可能仍会返回错误,例如读取或写入状态时出现问题。 -lock=false:执行时是否先锁定状态文件。如果其他人可能同时对同一工作区运行命令,则这是危险的。 -lock-timeout=DURATION:除非使用 -lock=false 禁用锁定,否则命令 Terraform 为上锁操作设置一个超时时长。持续时间语法是一个数字后跟一个时间单位字母,例如“3s”表示三秒。 -no-color:关闭彩色输出。在无法解释输出色彩的终端中运行 Terraform 时请使用此参数。 以下是使用 local Backend 时可用的遗留参数: -backup=FILENAME:指定源状态文件的备份地址,默认为源状态文件加上\".backup\"后缀 -bakcup-out=FILENAME:指定目标状态文件的备份地址,默认为目标状态文件加上\".backup\"后缀 -state=FILENAME:源状态文件地址,默认为当前 Backend 或是\"terraform.tfstate\" -state-out=FILENAME:目标状态文件地址。如果不指定则使用源状态文件。可以是一个已经存在的文件或新建一个文件 "},"6.Terraform命令行/22.workspace/":{"url":"6.Terraform命令行/22.workspace/","title":"workspace","keywords":"","body":"workspace terraform workspace 命令可以用来管理当前使用的工作区。我们在状态管理章节中介绍过工作区的概念。 该命令包含一系列子命令,我们将会一一介绍。 用法 terraform workspace [options] [args] "},"6.Terraform命令行/22.workspace/1.list.html":{"url":"6.Terraform命令行/22.workspace/1.list.html","title":"list","keywords":"","body":"list terraform workspace list 命令列出当前存在的工作区。 用法 terraform workspace list [DIR] 该命令会打印出存在的工作区。当前工作会使用 * 号标记: $ terraform workspace list default * development jsmith-test "},"6.Terraform命令行/22.workspace/2.select.html":{"url":"6.Terraform命令行/22.workspace/2.select.html","title":"select","keywords":"","body":"select terraform workspace select 命令用来选择使用的工作区。 用法 terraform workspace select NAME [DIR] NAME 指定的工作区必须已经存在: 该命令支持以下参数 -or-create:如果指定的工作区不存在,则创建之。默认为 false。 $ terraform workspace list default * development jsmith-test $ terraform workspace select default Switched to workspace \"default\". "},"6.Terraform命令行/22.workspace/3.new.html":{"url":"6.Terraform命令行/22.workspace/3.new.html","title":"new","keywords":"","body":"new terraform workspace new 命令用来创建新的工作区。 用法 terraform workspace new [OPTIONS] NAME [DIR] 该命令使用给定名字创建一个新的工作区。不可存在同名工作区。 如果使用了 -state 参数,那么给定路径的状态文件会被拷贝到新工作区。 该命令支持以下可选参数: -lock=false:执行时是否先锁定状态文件。如果其他人可能同时对同一工作区运行命令,则这是危险的。 -lock-timeout=DURATION:除非使用 -lock=false 禁用锁定,否则命令 Terraform 为上锁操作设置一个超时时长。持续时间语法是一个数字后跟一个时间单位字母,例如“3s”表示三秒。默认为 0s。 -state=path:用来初始化新环境所使用的状态文件路径 创建新工作区: $ terraform workspace new example Created and switched to workspace \"example\"! You're now on a new, empty workspace. Workspaces isolate their state, so if you run \"terraform plan\" Terraform will not see any existing state for this configuration. 使用状态文件创建新工作区: $ terraform workspace new -state=old.terraform.tfstate example Created and switched to workspace \"example\". You're now on a new, empty workspace. Workspaces isolate their state, so if you run \"terraform plan\" Terraform will not see any existing state for this configuration. "},"6.Terraform命令行/22.workspace/4.delete.html":{"url":"6.Terraform命令行/22.workspace/4.delete.html","title":"delete","keywords":"","body":"delete terraform workspace delete 命令被用以删除已经存在的工作区。 用法 terraform workspace delete [OPTIONS] NAME [DIR] 该命令被用以删除已经存在的工作区。 被删除的工作区必须已经存在,并且不可以删除当前正在使用的工作区。如果工作区状态不是空的(存在跟踪中的远程对象),Terraform 会禁止删除,除非声明 -force 参数。 另外,不同的 Backend 在没有 -force 参数时可能会有不同的限制,以实现对工作区的安全删除,例如检查工作区是否已上锁。 如果使用 -force 删除非空工作区,那么原本跟踪的资源的状态就将处于\"dangling\",也就是实际基础设施资源仍然存在,但脱离了 Terraform的 管理。有时我们希望这样,只是希望当前 Terraform 项目不再管理这些资源,交由其他项目管理。但大多数情况下并非这样,所以 Terraform 默认会禁止删除非空工作区。 该命令可以使用如下可选参数: -force:删除含有非空状态文件的工作区。默认为 false。 -lock=false:执行时是否先锁定状态文件。如果其他人可能同时对同一工作区运行命令,则这是危险的。 -lock-timeout=DURATION:除非使用 -lock=false 禁用锁定,否则命令 Terraform 为上锁操作设置一个超时时长。持续时间语法是一个数字后跟一个时间单位字母,例如“3s”表示三秒。默认为 0s。 例子: $ terraform workspace delete example Deleted workspace \"example\". "},"6.Terraform命令行/22.workspace/5.show.html":{"url":"6.Terraform命令行/22.workspace/5.show.html","title":"show","keywords":"","body":"show terraform workspace show 命令被用以输出当前使用的工作区。 用法 terraform workspace show 例子: $ terraform workspace show development "},"6.Terraform命令行/23.test.html":{"url":"6.Terraform命令行/23.test.html","title":"test","keywords":"","body":"test terraform test 命令读取 Terraform 测试文件并执行其中的测试。 test 命令和测试文件对于想要验证和测试其旨在被复用的模块的作者特别有用。我们也可以使用 test 命令来验证根模块。 用法 terraform test [options] 该命令在当前目录和指定的测试目录(默认情况下是 test 目录)中搜索所有 Terraform 测试文件,并执行指定的测试。有关测试文件的更多详细信息,请参阅测试。 Terraform 然后会根据测试文件的规范执行一系列 Terraform 的 plan 或 apply 命令,并根据测试文件的规范验证相关计划和状态文件。 警告:Terraform 测试命令可以创建真正的基础设施,但可能会产生成本。请参阅 Terraform 测试清理部分,了解确保创建的基础设施被清理的最佳实践。 一般参数 Terraform test 命令支持以下参数: -cloud-run= - 通过 HCP Terraform 远程运行针对指定的 Terraform 私有注册表模块的测试。 -filter=testfile - 将 terraform test 操作限制为指定的测试文件。 -json - 显示测试结果的机器可读 JSON 输出。 -test-directory= - 指定 Terraform 查找测试文件的目录。请注意,Terraform 始终在主代码目录中加载测试文件。默认的测试目录是 tests。 -verbose - 根据每个运行块的 command 属性打印出测试文件中每个 run 块的计划或状态。 状态管理 每个 Terraform 测试文件在执行时都会在内存中从无到有地维护所需的所有 Terraform 状态。该状态完全独立于被测代码的任何现有状态,因此您可以安全地执行 Terraform 测试命令,而不会影响任何已存在的基础设施。 Terraform 测试清理 Terraform test 命令可以创建真实的基础设施。一旦 Terraform 完全执行了所有测试文件,Terraform 就会尝试销毁所有遗留的基础设施。如果无法销毁,Terraform 会报告由它创建但无法销毁的资源列表。 我们应该密切监视测试命令的输出,以确保 Terraform 清理了它创建的基础设施,否则需要执行手动清理。我们建议为目标 Provider 创建专用的测试帐户,这样可以定期安全地清除该帐户内的资源,确保不会意外地留下昂贵的资源。 Terraform 还提供诊断,解释为什么它无法自动清理。我们应该检查这些诊断,以确保未来的清理操作成功。 在 HCP Terraform 上运行测试 我们可以使用 -cloud-run 参数在 HCP Terraform 上远程执行测试。 -cloud-run 参数接受私有注册表模块地址。此参数针对 HCP Terraform 用户界面中指定的私有模块运行测试。 我们必须提供来自私有注册表的模块,而不是公共 Terraform 注册表。 在使用该参数之前,您必须执行 terraform login,并确保您的 host 参数与目标模块的私有注册表主机名匹配。 例子:测试的目录结构与命令 以下目录结构表示包含测试和配置(setup)模块的 Terraform 模块的示例目录树: project/ |-- main.tf |-- outputs.tf |-- terraform.tf |-- variables.tf |-- tests/ | |-- validations.tftest.hcl | |-- outputs.tftest.hcl |-- testing/ |-- setup/ |-- main.tf |-- outputs.tf |-- terraform.tf |-- variables.tf 在项目的根目录下,有一些典型的 Terraform 配置文件:main.tf、outputs.tf、terraform.tf 和 variables.tf。测试文件 validations.tftest.hcl 和 outputs.tftest.hcl 位于默认测试目录 tests 中。 另外 testing 目录下有一个为测试而存在的设置(setup)模块 要执行测试,我们应该从代码根目录运行 terraform test,如同运行 terraform plan 或 terraform apply 一样。尽管实际的测试文件位于内嵌的 tests 目录中,但 Terraform 仍从主代码目录执行。 可以使用 -filter 参数指定执行特定的测试文件。 Linux、Mac 操作系统和 UNIX 下: terraform test -filter=tests/validations.tftest.hcl PowerShell: terraform test -filter='tests\\validations.tftest.hcl' Windows cmd.exe: terraform test -filter=tests\\validations.tftest.hcl 另一种测试目录结构 在上面的示例中,测试文件位于默认的 tests 目录中。测试文件也可以直接包含在主代码目录中: project/ |-- main.tf |-- outputs.tf |-- terraform.tf |-- variables.tf |-- validations.tftest.hcl |-- outputs.tftest.hcl |-- testing/ |-- setup/ |-- main.tf |-- outputs.tf |-- terraform.tf |-- variables.tf 测试文件的位置不会影响 terraform test 的运行。测试文件的所有引用以及其中的绝对文件路径都应相对于主代码目录。 我们还可以使用 -test-directory 参数来更改测试文件的位置。例如, terraform test -test-directory=testing 将命令 Terraform 从 testing 目录加载测试,而不是 tests。 测试目录必须位于主代码目录下,但可以多层嵌套。 注意:无论 -test-directory 的值为何,根代码目录中的测试文件始终会被加载。 我们不建议更改默认测试目录。这些自定义选项是为那些在 terraform test 功能发布之前可能已在其代码中包含了 tests 子模块的代码作者准备的。一般来说,应始终使用默认的 tests 目录。 "},"7.test/":{"url":"7.test/","title":"Terraform Test","keywords":"","body":"测试 -> 注意: 该测试框架在 Terraform v1.6.0 及以后版本中可用。 Terraform 测试功能允许模块作者验证配置变更不会引入破坏性更改。测试针对特定的、临时的资源进行,防止对现有的基础设施或状态产生任何风险。 集成测试或单元测试 默认情况下,Terraform 测试会创建真实的基础设施,并可以对这些基础设施进行断言和验证。这相当于集成测试,它通过调用 Terraform 创建基础设施并对其进行验证来测试 Terraform 的核心功能。 你可以通过更新 run 块中的 command 属性(下面有示例)来覆盖默认的测试行为。默认情况下,每个 run 块都会执行 command = apply,命令 Terraform 对你的配置执行完整的 apply 操作。将 command 值替换为 command = plan 会告诉 Terraform 不为这个 run 块创建新的基础设施。这将允许测试作者验证他们的基础设施中的逻辑操作和自定义条件,相当于编写了单元测试。 Terraform v1.7.0 引入了在 terraform test 执行期间模拟 Provider 返回数据的能力。这可以用于编写更详细和完整的单元测试。 语法 每个 Terraform 测试都保存在一个测试文件中。Terraform 根据文件扩展名发现测试文件:.tftest.hcl 或 .tftest.json。 每个测试文件包含以下根级别的属性和块: 一个到多个 run 块。 零个到一个 variables 块。 零个到多个 provider 块。 Terraform 按顺序执行 run 块,模拟一系列直接在配置目录中执行的 Terraform 命令。 variables 和 provider 块的顺序并不重要,Terraform 在测试操作开始时处理这些块中的所有值。我们建议首先在测试文件的开头定义你的 variables 和 provider 块。 示例 以下示例演示了一个简单的 Terraform 配置,该配置创建了一个 AWS S3 存储桶,并使用输入变量来修改其名称。我们将创建一个示例测试文件(如下)来验证存储桶的名称是否如预期那样被创建。 # main.tf provider \"aws\" { region = \"eu-central-1\" } variable \"bucket_prefix\" { type = string } resource \"aws_s3_bucket\" \"bucket\" { bucket = \"${var.bucket_prefix}-bucket\" } output \"bucket_name\" { value = aws_s3_bucket.bucket.bucket } 以下测试文件运行了一个单独的Terraform plan 命令,该命令创建了S3存储桶,然后通过检查实际名称是否与预期名称匹配,来验证计算名称的逻辑是否正确。 # valid_string_concat.tftest.hcl variables { bucket_prefix = \"test\" } run \"valid_string_concat\" { command = plan assert { condition = aws_s3_bucket.bucket.bucket == \"test-bucket\" error_message = \"S3 bucket name did not match expected\" } } run 块 每个 run 块都有以下字段和块: 字段或块名称 描述 默认值 command 一个可选属性,可以是 apply 或 plan。 apply plan_options.mode 一个可选属性,可以是 normal 或 refresh-only。 normal plan_options.refresh 一个可选的 bool 属性。 true plan_options.replace 一个可选属性,包含一个资源地址列表,引用测试配置中的资源。 plan_options.target 一个可选属性,包含一个资源地址列表,引用测试配置中的资源。 variables 一个可选的 variables 块。 module 一个可选的 module 块。 providers 一个可选的 providers 属性。 assert 可选的 assert 块。 expect_failures 一个可选属性。 command 属性和 plan_options 块告诉 Terraform 对于每个 run 块执行哪个命令和选项。如果您没有指定 command 属性或 plan_options 块,那么默认操作是普通的 terraform apply 操作。 command 属性指明操作应该是一个 plan 操作还是一个 apply 操作。 plan_options 块允许测试的作者定义他们通常需要通过命令行标志和选项定义的 plan mode 和 选项。我们将在 变量 部分介绍 -var 和 -var-file 选项。 断言 Terraform 测试的 run 块断言是自定义条件,由条件和错误消息组成。 在 Terraform 测试命令执行结束时,Terraform 会将所有失败的断言作为测试通过或失败状态的一部分展示出来。 断言中的引用 测试中的断言可以引用主 Terraform 配置中的其他自定义条件可用的任何现有命名值。 此外,测试断言可以直接引用当前和先前 run 块的输出。比如引用了上一个示例中的输出的一个合法的表达式条件:condition = output.bucket_name == \"test_bucket\"。 variable 块 你可以直接在你的测试文件中为 输入变量 设置值。 你可以在测试文件的根级别或者 run 块内部定义 variables 块。Terraform 将测试文件中的所有变量值传递到文件中的所有 run 块。你可以通过在某个 run 块中直接设置变量值来覆盖从根部继承的值。 在上述 示例 的测试文件中添加: # variable_precedence.tftest.hcl variables { bucket_prefix = \"test\" } run \"uses_root_level_value\" { command = plan assert { condition = aws_s3_bucket.bucket.bucket == \"test-bucket\" error_message = \"S3 bucket name did not match expected\" } } run \"overrides_root_level_value\" { command = plan variables { bucket_prefix = \"other\" } assert { condition = aws_s3_bucket.bucket.bucket == \"other-bucket\" error_message = \"S3 bucket name did not match expected\" } } 我们添加了第二个 run 块,该块指定 bucket_prefix 变量值为 other,覆盖了测试文件提供的,并在第一个 run 块中使用的值 —— test。 通过命令行或定义文件指定变量 除了通过测试文件指定变量值外,Terraform test 命令还支持指定变量值的其他方法。 您可以通过 命令行 和 变量定义文件 为所有测试指定变量值。 像普通的 Terraform 命令一样,Terraform 会自动加载测试目录中定义的任何变量文件。自动变量文件包括 terraform.tfvars、terraform.tfvars.json,以及所有以 .auto.tfvars 或 .auto.tfvars.json 结尾的文件。 注意: 从测试目录中的自动变量文件加载的变量值只适用于在同一测试目录中定义的测试。以所有其他方式定义的变量将适用于给定测试运行中的所有测试。 这在使用敏感变量值和设置 Provider 配置时特别有用。否则,测试文件可能会直接暴露这些敏感值。 变量定义优先级 除了测试文件中设置的变量值,变量定义优先级 在测试中保持不变。在测试文件中定义的变量具有最高优先级,可以覆盖环境变量、变量文件或命令行输入。 对于在测试目录中定义的测试,任何在测试目录的自动变量文件中定义的变量值都将覆盖主配置目录的自动变量文件中定义的值。 变量中的引用 在 run 块中定义的 variable 中可以引用在先前 run 块中执行的模块的输出和在更高优先级定义的变量。 例如,以下代码块显示了变量如何引用更高优先级的变量和先前的 run 块: variables { global_value = \"some value\" } run \"run_block_one\" { variables { local_value = var.global_value } # ... # 这里应该有一些测试断言 # ... } run \"run_block_two\" { variables { local_value = run.run_block_one.output_one } # ... # 这里应该有一些测试断言 # ... } 上面,run_block_one 中的 local_value 从 global_value 变量获取值。如果你想给多个变量分配相同的值,这种模式很有用。你可以在文件级别一次指定一个变量的值,然后与不同的变量共享它。 相比之下,run_block_two 中的 local_value 引用了 run_block_one 的 output_one 的输出值。这种模式对于在 run 块之间传递值特别有用,特别是如果 run 块正在执行模块部分中详细描述的不同模块。 provider 块 您可以通过使用 provider 和 providers 块和属性,在测试文件中设置或覆盖 Terraform 代码所需的 Provider。 您可以在 Terraform 测试文件的根级别,定义 provider 块,就像在 Terraform 配置代码中创建它们一样。然后,Terraform 会将这些 provider 块传递到其配置中,每个 run 块执行时都是如此。 默认情况下,您指定的每个 Provider 都直接在每个 run 块中可用。您可以通过使用 providers 属性在特定 run 块中设置 Provider 的可用性。这个块的行为和语法与 providers meta-argument 的行为相匹配。 如果您在测试文件中不提供 Provider 配置,Terraform 会尝试使用 Provider 的默认设置初始化其配置中的所有 Provider。例如,任何旨在配置 Provider 的环境变量仍然可用,并且 Terraform 可以使用它们来创建默认 Provider。 下面,我们将扩展我们之前的 示例,用测试代码而不是 Terraform 配置代码来指定 region。在这个示例中,我们将测试以下配置文件: # main.tf terraform { required_providers { aws = { source = \"hashicorp/aws\" } } } variable \"bucket_prefix\" { type = string } resource \"aws_s3_bucket\" \"bucket\" { bucket = \"${var.bucket_prefix}-bucket\" } output \"bucket_name\" { value = aws_s3_bucket.bucket.bucket } 我们现在可以在以下测试文件中定义如下的 provider 块: # customised_provider.tftest.hcl provider \"aws\" { region = \"eu-central-1\" } variables { bucket_prefix = \"test\" } run \"valid_string_concat\" { command = plan assert { condition = aws_s3_bucket.bucket.bucket == \"test-bucket\" error_message = \"S3 bucket name did not match expected\" } } 现在我们也可以创建一个更复杂的示例配置,使用多个 Provider 以及别名: # main.tf terraform { required_providers { aws = { source = \"hashicorp/aws\" configuration_aliases = [aws.secondary] } } } variable \"bucket_prefix\" { default = \"test\" type = string } resource \"aws_s3_bucket\" \"primary_bucket\" { bucket = \"${var.bucket_prefix}-primary\" } resource \"aws_s3_bucket\" \"secondary_bucket\" { provider = aws.secondary bucket = \"${var.bucket_prefix}-secondary\" } 在我们的测试文件中,我们可以设定多个 Provider: # customised_providers.tftest.hcl provider \"aws\" { region = \"us-east-1\" } provider \"aws\" { alias = \"secondary\" region = \"eu-central-1\" } run \"providers\" { command = plan assert { condition = aws_s3_bucket.primary_bucket.bucket == \"test-primary\" error_message = \"invalid value for primary S3 bucket\" } assert { condition = aws_s3_bucket.secondary_bucket.bucket == \"test-secondary\" error_message = \"invalid value for secondary S3 bucket\" } } 我们也可以在特定 run 块中声明特定的 Provider: # main.tf terraform { required_providers { aws = { source = \"hashicorp/aws\" configuration_aliases = [aws.secondary] } } } data \"aws_region\" \"primary\" {} data \"aws_region\" \"secondary\" { provider = aws.secondary } variable \"bucket_prefix\" { default = \"test\" type = string } resource \"aws_s3_bucket\" \"primary_bucket\" { bucket = \"${var.bucket_prefix}-${data.aws_region.primary.name}-primary\" } resource \"aws_s3_bucket\" \"secondary_bucket\" { provider = aws.secondary bucket = \"${var.bucket_prefix}-${data.aws_region.secondary.name}-secondary\" } 我们的测试文件可以为不同的 run 块配置的 Provider: # customised_providers.tftest.hcl provider \"aws\" { region = \"us-east-1\" } provider \"aws\" { alias = \"secondary\" region = \"eu-central-1\" } provider \"aws\" { alias = \"tertiary\" region = \"eu-west-2\" } run \"default_providers\" { command = plan assert { condition = aws_s3_bucket.primary_bucket.bucket == \"test-us-east-1-primary\" error_message = \"invalid value for primary S3 bucket\" } assert { condition = aws_s3_bucket.secondary_bucket.bucket == \"test-eu-central-1-secondary\" error_message = \"invalid value for secondary S3 bucket\" } } run \"customised_providers\" { command = plan providers = { aws = aws aws.secondary = aws.tertiary } assert { condition = aws_s3_bucket.primary_bucket.bucket == \"test-us-east-1-primary\" error_message = \"invalid value for primary S3 bucket\" } assert { condition = aws_s3_bucket.secondary_bucket.bucket == \"test-eu-west-2-secondary\" error_message = \"invalid value for secondary S3 bucket\" } } 注意: 在使用 command = apply 运行测试时,run 块之间切换 Provider 可能会导致运行和测试失败,因为由一个 Provider 定义创建的资源在被另一个修改时将无法使用。 从 Terraform v1.7.0 开始,provider 块也可以引用测试文件变量和 run 块输出。这意味着测试框架可以从一个 Provider 获取凭证和其他设置信息,并在初始化第二个 Provider 时使用这些信息。 在下面的示例中,首先初始化 vault Provider,然后在一个设置模块中使用它来提取 aws Provider 的凭证。有关 setup 模块的更多信息,请参阅 模块。 provider \"vault\" { # ... vault configuration ... } provider \"aws\" { region = \"us-east-1\" # The `aws` provider can reference the outputs of the \"vault_setup\" run block. access_key = run.vault_setup.aws_access_key secret_key = run.vault_setup.aws_secret_key } run \"vault_setup\" { module { # This module should only include reference to the Vault provider. Terraform # will automatically work out which providers to supply based on the module # configuration. The tests will error if a run block requires access to a # provider that references outputs from a run block that has not executed. source = \"./testing/vault-setup\" } } run \"use_aws_provider\" { # This run block can then use both the `aws` and `vault` providers, as the # previous run block provided all the data required for the `aws` provider. } module 块 您可以修改特定的 run 块执行的模块。 默认情况下,Terraform 针对正在测试的配置代码,依次执行所有 run 块中设定的命令。Terraform 在您执行 terraform test 命令的目录(或者您用 -chdir 参数指向的目录)内测试配置。每个 run 块也允许用户使用 module 块更改目标配置。 与传统的 module 块不同,测试文件中的 module 块 仅 支持 source 属性和 version 属性。通常通过传统的 module 块提供的其余属性应由 run 块内的替代属性和块提供。 注意: Terraform 测试文件只支持 source 属性中的 本地 和 注册表 模块。 在执行其他模块时,run 块内的所有其他块和属性都受支持,assert 块执行时使用来自其他模块的值。这在 模块状态 中有更详细的说明。 测试文件中 modules 块的两个示例用例是: 一个设置模块,为待测 Terraform 配置代码创建测试所需的基础设施。 一个加载模块,用于加载和验证 Terraform 配置代码未直接创建的次要基础设施(如数据源)。 以下示例演示了这两种用例。 首先,我们有一个模块,它将创建并将多个文件加载到已创建的 S3 存储桶中。这是我们要测试的配置。 # main.tf variable \"bucket\" { type = string } variable \"files\" { type = map(string) } data \"aws_s3_bucket\" \"bucket\" { bucket = var.bucket } resource \"aws_s3_object\" \"object\" { for_each = var.files bucket = data.aws_s3_bucket.bucket.id key = each.key source = each.value etag = filemd5(each.value) } 然后,我们使用配置模块创建这个 S3 存储桶,这样在测试时就可以使用它: # testing/setup/main.tf variable \"bucket\" { type = string } resource \"aws_s3_bucket\" \"bucket\" { bucket = var.bucket } 第三步,我们使用一个加载模块,读取 S3 存储桶中的文件。这是一个比较牵强的例子,因为我们完全可以直接在创建这些文件的模块中创建这些数据源,但它在这里可以很好地演示如何编写测试: # testing/loader/main.tf variable \"bucket\" { type = string } data \"aws_s3_objects\" \"objects\" { bucket = var.bucket } 最后,我们使用测试文件把刚才创建的多个助手模块以及待测模块编织在一起形成一个有效的测试配置: # file_count.tftest.hcl variables { bucket = \"my_test_bucket\" files = { \"file-one.txt\": \"data/files/file_one.txt\" \"file-two.txt\": \"data/files/file_two.txt\" } } provider \"aws\" { region = \"us-east-1\" } run \"setup\" { # Create the S3 bucket we will use later. module { source = \"./testing/setup\" } } run \"execute\" { # This is empty, we just run the configuration under test using all the default settings. } run \"verify\" { # Load and count the objects created in the \"execute\" run block. module { source = \"./testing/loader\" } assert { condition = length(data.aws_s3_objects.objects.keys) == 2 error_message = \"created the wrong number of s3 objects\" } } 模块状态 当 Terraform 执行 terraform test 命令时,Terraform 会为每个测试文件在内存中维护一个或多个状态文件。 总是至少有一个状态文件维护在测试下的 Terraform 配置代码的状态。这个状态文件由所有没有 module 块指定要加载的替代模块的 run 块共享。 此外,Terraform 加载的每个替代模块都有一个状态文件。一个替代模块的状态文件被执行给定模块的所有 run 块共享。 Terraform 团队对任何需要手动状态管理或在 test 命令中对同一状态执行不同配置的用例感兴趣。如果你有一个用例,请提交一个 issue并与我们分享。 以下示例使用注释来解释每个 run 块的状态文件的来源。在下面的示例中,Terraform 创建并管理了总共三个状态文件。第一个状态文件是针对测试下的主模块,第二个是针对设置模块,第三个是针对加载模块。 run \"setup\" { # This run block references an alternate module and is the first run block # to reference this particular alternate module. Therefore, Terraform creates # and populates a new empty state file for this run block. module { source = \"./testing/setup\" } } run \"init\" { # This run block does not reference an alternate module, so it uses the main # state file for the configuration under test. As this is the first run block # to reference the main configuration, the previously empty state file now # contains the resources created by this run block. assert { # In practice we'd do some interesting checks and tests here but the # assertions aren't important for this example. } # ... more assertions ... } run \"update_setup\" { # We've now re-referenced the setup module, so the state file that was created # for the first \"setup\" run block will be reused. It will contain any # resources that were created as part of the other run block before this run # block executes and will be updated with any changes made by this run block # after. module { source = \"./testing/setup\" } variables { # In practice, we'd likely make some changes to the module compared to the # first run block here. Otherwise, there would be no point recalling the # module. } } run \"update\" { # As with the \"init\" run block, we are executing against the main configuration # again. This means we'd load the main state file that was initially populated # by the \"init\" run block, and any changes made by this \"run\" block will be # carried forward to any future run blocks that execute against the main # configuration. # ... updated variables ... # ... assertions ... } run \"loader\" { # This run block is now referencing our second alternate module so will create # our third and final state file. The other two state files are managing # resources from the main configuration and resources from the setup module. # We are getting a new state file for this run block as the loader module has # not previously been referenced by any run blocks. module { source = \"./testing/loader\" } } 模块的清理 在测试文件执行结束时,Terraform 会试图销毁在该测试文件执行过程中创建的每个资源。当 Terraform 加载替代模块时,Terraform 销毁这些对象的顺序很重要。例如,在第一个 模块 示例中,Terraform 不能在 \"execute\" run 块中创建的对象之前销毁在 \"setup\" run 块中创建的资源,因为我们在 \"setup\" 步骤中创建的 S3 桶在包含对象的情况下无法被销毁。 Terraform 按照 run 块的反向顺序销毁资源。在最近的 例子 中,有三个状态文件。一个用于主状态,一个用于 ./testing/loader 模块,还有一个用于 ./testing/setup 模块。由于 ./testing/loader 状态文件最近被最后一个运行块引用,因此首先被销毁。主状态文件将被第二个销毁,因为它被 \"update\" run 块引用。然后 ./testing/setup 状态文件将被最后销毁。 请注意,前两个 run 块 \"setup\" 和 \"init\" 在销毁操作中不做任何事情,因为它们的状态文件被后续的 run 块使用,并且已经被销毁。 如果你使用单个设置模块作为替代模块,并且它首先执行,或者你不使用任何替代模块,那么销毁顺序不会影响你。更复杂的情况可能需要仔细考虑,以确保资源的销毁可以自动完成。 预期失败 默认情况下,如果在执行 Terraform 测试文件期间,任何自定义条件,包括 check 块断言失败,则整体命令会将测试报告为失败。 然而,我们经常想要测试代码运行失败时的行为。Terraform 为此用例提供了 expect_failures 属性。 在每个 run 块中,expect_failures 属性可以设置应该导致自定义条件检查失败的可检查对象(资源,数据源,检查块,输入变量和输出)的列表。如果您指定的可检查对象报告问题,测则试通过,如果没有报告错误,那么测试总体上失败。 您仍然可以在 expect_failures 块附近编写断言,但您应该注意,除了 check 块断言外,所有自定义条件都会停止 Terraform 的执行。这在测试执行期间仍然适用,所以这些断言应该只考虑你确定会在可检查对象应该失败之前可知的值。您可以使用引用或在主配置中的 depends_on 元参数来管理这一点。 这也意味着,除了 check 块,你只能可靠地包含一个可检查的对象。我们支持在 expect_failures 属性中列出可检查对象的列表,仅用于 check 块。 下面的一个快速示例演示了测试输入变量的 validation 块。配置文件接受一个必须是偶数的单一输入变量。 # main.tf variable \"input\" { type = number validation { condition = var.input % 2 == 0 error_message = \"must be even number\" } } 测试文件包含了两个 run 块。一个验证了我们的自定义条件在偶数条件下是通过的,另一个验证输入奇数时会失败。 # input_validation.tftest.hcl variables { input = 0 } run \"zero\" { # The variable defined above is even, so we expect the validation to pass. command = plan } run \"one\" { # This time we set the variable is odd, so we expect the validation to fail. command = plan variables { input = 1 } expect_failures = [ var.input, ] } 注意:Terraform 只期望在 run 块的 command 属性指定的操作中出现失败。 在使用 command = apply 的 run 块中使用 expect_failures 时要小心。一个 run 块中的 command = apply 如果期望自定义条件失败,那么如果该自定义条件在 plan 期间失败,整体将会失败。 这在逻辑上是正确的,因为 run 块期望能够运行应用操作,但由于 plan 失败而不能运行,但这也可能会引起混淆,因为即使那个失败被标记为预期的,你还是会在诊断中看到失败。 有时,Terraform 在计划阶段不执行自定义条件,因为该条件依赖于只有在 Terraform 创建引用资源后才可用的计算属性。在这些情况下,你可以在设置 command = apply 时使用 expect_failures 块。然而,大多数情况下,我们建议只在 command = plan 时使用 expect_failures。 注意:预期的失败只适用于用户定义的自定义条件。 除了在可检查对象中指定的预期失败之外的其他种类的失败仍会导致整体测试失败。例如,一个期望布尔值作为输入的变量,如果 Terraform 收到的是错误的值类型,即使该变量包含在 expect_failures 属性中,也会导致周围的测试失败。 expect_failures 属性包含在其中是为了允许作者测试他们的配置和任何定义的逻辑。像前面的例子中的类型不匹配错误,不是 Terraform 作者应该担心和测试的事情,因为 Terraform 本身会处理强制类型约束。因此,你只能在自定义条件中 expect_failures。 "},"8.技巧/":{"url":"8.技巧/","title":"Aha——会心一击","keywords":"","body":"Aha —— 会心一击 Terraform 使用的是声明式而非命令式的语法,其本身并不是图灵完备的,所以在遇到某些场景时会显得力不从心。 本章我们会介绍一些小技巧以及设计模式和特殊 Provider,可以在必要的时候帮助你实现某些特殊的逻辑,起到“会心一击”的效果。 "},"8.技巧/1.有条件创建.html":{"url":"8.技巧/1.有条件创建.html","title":"有条件创建","keywords":"","body":"有条件创建 Terraform被设计成声明式而非命令式,例如没有常见的 if 条件语句,后来才加上了 count 和 for_each 实现的循环语句(但循环的次数必须是在 plan 阶段就能够确认的,无法根据其他 resource 的输出动态决定) 有时候我们需要根据某种条件来判断是否创建一个资源。虽然我们无法使用if来完成条件判断,但我们还有 count 和 for_each 可以帮助我们完成这个目标。 我们以 UCloud 为例,假如我们正在编写一个旨在被复用的模块,模块的逻辑要创建一台虚拟机,我们的代码可以是这样的: data ucloud_vpcs \"default\" { name_regex = \"^Default\" } data \"ucloud_images\" \"centos\" { name_regex = \"^CentOS 7\" } resource \"ucloud_instance\" \"web\" { availability_zone = \"cn-bj2-02\" image_id = data.ucloud_images.centos.images[0].id instance_type = \"n-basic-2\" } output \"uhost_id\" { value = ucloud_instance.web.id } 非常简单。但是如果我们想进一步,让模块的调用者决定创建的主机是否要搭配一个弹性公网 IP 该怎么办? 我们可以在上面的代码后面接上这样的代码: variable \"allocate_public_ip\" { description = \"Decide whether to allocate a public ip and bind it to the host\" type = bool default = false } resource \"ucloud_eip\" \"public_ip\" { count = var.allocate_public_ip ? 1 : 0 name = \"public_ip_for_${ucloud_instance.web.name}\" internet_type = \"bgp\" } resource \"ucloud_eip_association\" \"public_ip_binding\" { count = var.allocate_public_ip ? 1 : 0 eip_id = ucloud_eip.public_ip[0].id resource_id = ucloud_instance.web.id } 我们首先创建了名为 allocate_public_ip 的输入变量,然后在编写弹性 IP 相关资源代码的时候都声明了 count 参数,值使用了条件表达式,根据 allocate_public_ip 这个输入变量的值决定是 1 还是 0。这实际上实现了按条件创建资源。 需要注意的是,由于我们使用了 count,所以现在弹性 IP 相关的资源实际上是多实例资源类型的。我们在 ucloud_eip_association.public_ip_binding 中引用 ucloud_eip.public 时,还是要加上访问下标。由于 ucloud_eip_association.public_ip_binding 与 ucloud_eip.public 实际上是同生同死,所以在这里他们之间的引用还比较简单;如果是其他没有声明 count 的资源引用它们的话,还要针对 allocate_public_ip 为 false 时 ucloud_eip.public 实际为空做相应处理,比如在 output 中: output \"public_ip\" { value = join(\"\", ucloud_eip.public_ip[*].public_ip) } 使用 join 函数就可以在即使没有创建弹性 IP 时也能返回空字符串。或者我们也可以用条件表达式: output \"public_ip\" { value = length(ucloud_eip.public_ip[*].public_ip) > 0 ? ucloud_eip.public_ip[0].public_ip : \"\" } "},"8.技巧/2.依赖反转.html":{"url":"8.技巧/2.依赖反转.html","title":"依赖反转","keywords":"","body":"依赖反转 Terraform 编排的基础设施对象彼此之间可能互相存在依赖关系,有时我们在编写一些旨在重用的模块时,模块内定义的资源可能本身需要依赖其他一些资源,这些资源可能已经存在,也可能有待创建。 举一个例子,假设我们编写了一个模块,定义了在 UCloud 上同一个 VPC 中的两台服务器;第一台服务器部署了一个 Web 应用,它被分配在一个 DMZ 子网里;第二台服务器部署了一个数据库,它被分配在一个内网子网里。现在的问题是,在我们编写模块时,我们并没有关于 VPC 和子网的任何信息,我们甚至连服务器应该部署在哪个可用区都不知道。VPC 和子网可能已经存在,也可以有待创建。 我们可以定义这样的一个模块代码: terraform { required_providers { ucloud = { source = \"ucloud/ucloud\" version = \"~>1.22.0\" } } } variable \"network_config\" { type = object({ vpc_id = string web_app_config = object({ az = string subnet_id = string }) db_config = object({ az = string subnet_id = string }) }) } data \"ucloud_images\" \"web_app\" { name_regex = \"^WebApp\" } data \"ucloud_images\" \"mysql\" { name_regex = \"^MySql 5.7\" } resource \"ucloud_instance\" \"web_app\" { availability_zone = var.network_config.web_app_config.az image_id = data.ucloud_images.web_app.images[0].id instance_type = \"n-basic-2\" vpc_id = var.network_config.vpc_id subnet_id = var.network_config.web_app_config.subnet_id } resource \"ucloud_instance\" \"mysql\" { availability_zone = var.network_config.db_config.az image_id = data.ucloud_images.mysql.images[0].id instance_type = \"n-basic-2\" vpc_id = var.network_config.vpc_id subnet_id = var.network_config.db_config.subnet_id } 在代码中我们把依赖的网络参数定义为一个复杂类型,一个强类型对象结构。这样的话模块代码就不用再关注网络层究竟是查询而来的还是创建的,模块中只定义了抽象的网络层定义,其具体实现由调用者从外部注入,从而实现了依赖反转。 如果调用者需要创建网络层,那么代码可以是这样的(假设我们把前面编写的模块保存在 ./machine 目录下而成为一个内嵌模块): resource \"ucloud_vpc\" \"vpc\" { cidr_blocks = [ \"192.168.0.0/16\"] } resource \"ucloud_subnet\" \"dmz\" { cidr_block = \"192.168.0.0/24\" vpc_id = ucloud_vpc.vpc.id } resource \"ucloud_subnet\" \"db\" { cidr_block = \"192.168.1.0/24\" vpc_id = ucloud_vpc.vpc.id } module \"machine\" { source = \"./machine\" network_config = { vpc_id = ucloud_vpc.vpc.id web_app_config = { az = \"cn-bj2-02\" subnet_id = ucloud_subnet.dmz.id } db_config = { az = \"cn-bj2-02\" subnet_id = ucloud_subnet.db.id } } } 或者我们想使用现存的网络来托管服务器: data \"ucloud_vpcs\" \"vpc\" { name_regex = \"^AVeryImportantVpc\" } data \"ucloud_subnets\" dmz_subnet { vpc_id = data.ucloud_vpcs.vpc.vpcs[0].id name_regex = \"^DMZ\" } data \"ucloud_subnets\" \"db_subnet\" { vpc_id = data.ucloud_vpcs.vpc.vpcs[0].id name_regex = \"^DataBase\" } module \"machine\" { source = \"./machine\" network_config = { vpc_id = data.ucloud_vpcs.vpc.vpcs[0].id web_app_config = { az = \"cn-bj2-02\" subnet_id = data.ucloud_subnets.dmz_subnet.subnets[0].id } db_config = { az = \"cn-bj2-02\" subnet_id = data.ucloud_subnets.db_subnet.subnets[0].id } } } 由于模块代码中对网络层的定义是抽象的,并没有指定必须是 resource 或是 data,所以使得模块的调用者可以自己决定如何构造模块的依赖层,作为参数注入模块。 "},"8.技巧/3.多可用区分布.html":{"url":"8.技巧/3.多可用区分布.html","title":"多可用区分布","keywords":"","body":"多可用区分布 这是一个相当常见的小技巧。多数公有云为了高可用性,都在单一区域内提供了多可用区的设计。一个可区是一个逻辑上的数据中心,单个可用区可能由于各种自然灾害、网络故障而导致不可用,所以公有云应用部署高可用应用应时刻考虑跨可用区设计。 假如我们想要创建 N 台不同的云主机实例,在 Terraform 0.12 之前的版本中,我们只能用 count 配合模运算来达成这个目的 variable \"az\" { type = list(string) default = [ \"cn-bj2-03\", \"cn-bj2-04\", ] } variable \"instance_count\" { type = number default = 4 } data \"ucloud_images\" \"centos\" { name_regex = \"^CentOS 7\" } resource \"ucloud_instance\" \"web\" { count = var.instance_count availability_zone = var.az[count.index % length(var.az)] image_id = data.ucloud_images.centos.images[0].id instance_type = \"n-standard-1\" charge_type = \"dynamic\" name = \"${var.az[count.index % length(var.az)]}-${floor(count.index/length(var.az))}\" } 简单来说就是使用 count 创建多实例资源时,用 var.az[count.index % length(var.az)] 可以循环使用每个可用区,使得机器尽可能均匀分布在各个可用区。 $ terraform apply -auto-approve data.ucloud_images.centos: Refreshing state... ucloud_instance.web[2]: Creating... ucloud_instance.web[0]: Creating... ucloud_instance.web[1]: Creating... ucloud_instance.web[3]: Creating... ucloud_instance.web[2]: Still creating... [10s elapsed] ucloud_instance.web[0]: Still creating... [10s elapsed] ucloud_instance.web[1]: Still creating... [10s elapsed] ucloud_instance.web[3]: Still creating... [10s elapsed] ucloud_instance.web[2]: Still creating... [20s elapsed] ucloud_instance.web[0]: Still creating... [20s elapsed] ucloud_instance.web[1]: Still creating... [20s elapsed] ucloud_instance.web[3]: Still creating... [20s elapsed] ucloud_instance.web[2]: Creation complete after 22s [id=uhost-txa2owrp] ucloud_instance.web[3]: Creation complete after 24s [id=uhost-v3qxdbju] ucloud_instance.web[1]: Creation complete after 26s [id=uhost-td3x545p] ucloud_instance.web[0]: Still creating... [30s elapsed] ucloud_instance.web[0]: Still creating... [40s elapsed] ucloud_instance.web[0]: Creation complete after 43s [id=uhost-scq1prqj] Apply complete! Resources: 4 added, 0 changed, 0 destroyed. 我们可以看一下创建的主机信息: $ terraform show # data.ucloud_images.centos: data \"ucloud_images\" \"centos\" { id = \"475496684\" ids = [ \"uimage-22noyd\", \"uimage-3p0wg0\", \"uimage-4keil1\", \"uimage-aqvo5l\", \"uimage-f1chxn\", \"uimage-hq5elw\", \"uimage-rkn1v2\", ] images = [ { availability_zone = \"cn-bj2-02\" create_time = \"2019-04-23T17:39:46+08:00\" description = \"\" features = [ \"NetEnhanced\", \"HotPlug\", ] id = \"uimage-rkn1v2\" name = \"CentOS 7.0 64位\" os_name = \"CentOS 7.0 64位\" os_type = \"linux\" size = 20 status = \"Available\" type = \"base\" }, { availability_zone = \"cn-bj2-02\" create_time = \"2019-04-16T21:05:03+08:00\" description = \"\" features = [ \"NetEnhanced\", \"HotPlug\", ] id = \"uimage-f1chxn\" name = \"CentOS 7.2 64位\" os_name = \"CentOS 7.2 64位\" os_type = \"linux\" size = 20 status = \"Available\" type = \"base\" }, { availability_zone = \"cn-bj2-02\" create_time = \"2019-09-09T11:40:31+08:00\" description = \" \" features = [ \"NetEnhanced\", \"HotPlug\", ] id = \"uimage-aqvo5l\" name = \"CentOS 7.4 64位\" os_name = \"CentOS 7.4 64位\" os_type = \"linux\" size = 20 status = \"Available\" type = \"base\" }, { availability_zone = \"cn-bj2-02\" create_time = \"2020-05-07T17:40:42+08:00\" description = \"\" features = [ \"NetEnhanced\", \"HotPlug\", \"CloudInit\", ] id = \"uimage-hq5elw\" name = \"CentOS 7.6 64位\" os_name = \"CentOS 7.6 64位\" os_type = \"linux\" size = 20 status = \"Available\" type = \"base\" }, { availability_zone = \"cn-bj2-02\" create_time = \"2019-04-16T21:05:05+08:00\" description = \"\" features = [ \"NetEnhanced\", \"HotPlug\", ] id = \"uimage-3p0wg0\" name = \"CentOS 7.3 64位\" os_name = \"CentOS 7.3 64位\" os_type = \"linux\" size = 20 status = \"Available\" type = \"base\" }, { availability_zone = \"cn-bj2-02\" create_time = \"2019-04-16T21:05:02+08:00\" description = \"\" features = [ \"NetEnhanced\", \"HotPlug\", ] id = \"uimage-4keil1\" name = \"CentOS 7.1 64位\" os_name = \"CentOS 7.1 64位\" os_type = \"linux\" size = 20 status = \"Available\" type = \"base\" }, { availability_zone = \"cn-bj2-02\" create_time = \"2019-04-16T21:04:53+08:00\" description = \"\" features = [ \"NetEnhanced\", \"HotPlug\", ] id = \"uimage-22noyd\" name = \"CentOS 7.5 64位\" os_name = \"CentOS 7.5 64位\" os_type = \"linux\" size = 20 status = \"Available\" type = \"base\" }, ] most_recent = false name_regex = \"^CentOS 7\" total_count = 7 } # ucloud_instance.web[1]: resource \"ucloud_instance\" \"web\" { auto_renew = true availability_zone = \"cn-bj2-04\" boot_disk_size = 20 boot_disk_type = \"local_normal\" charge_type = \"dynamic\" cpu = 1 cpu_platform = \"Intel/Broadwell\" create_time = \"2020-11-28T23:09:04+08:00\" disk_set = [ { id = \"df06380a-00e1-42df-8c07-eec67d817f97\" is_boot = true size = 20 type = \"local_normal\" }, ] expire_time = \"2020-11-29T00:09:06+08:00\" id = \"uhost-td3x545p\" image_id = \"uimage-dhe5m2\" instance_type = \"n-standard-1\" ip_set = [ { internet_type = \"Private\" ip = \"10.9.44.37\" }, ] memory = 4 name = \"cn-bj2-04-0\" private_ip = \"10.9.44.37\" root_password = (sensitive value) security_group = \"firewall-juhsrlvr\" status = \"Running\" subnet_id = \"subnet-dtu3dgpr\" tag = \"Default\" vpc_id = \"uvnet-f1c3jq2b\" } # ucloud_instance.web[2]: resource \"ucloud_instance\" \"web\" { auto_renew = true availability_zone = \"cn-bj2-03\" boot_disk_size = 20 boot_disk_type = \"local_normal\" charge_type = \"dynamic\" cpu = 1 cpu_platform = \"Intel/IvyBridge\" create_time = \"2020-11-28T23:09:01+08:00\" disk_set = [ { id = \"1d7f07c9-7342-431b-85bb-d3ee0022063d\" is_boot = true size = 20 type = \"local_normal\" }, ] expire_time = \"2020-11-29T00:09:02+08:00\" id = \"uhost-txa2owrp\" image_id = \"uimage-pxplaj\" instance_type = \"n-standard-1\" ip_set = [ { internet_type = \"Private\" ip = \"10.9.45.234\" }, ] memory = 4 name = \"cn-bj2-03-1\" private_ip = \"10.9.45.234\" root_password = (sensitive value) security_group = \"firewall-juhsrlvr\" status = \"Running\" subnet_id = \"subnet-dtu3dgpr\" tag = \"Default\" vpc_id = \"uvnet-f1c3jq2b\" } # ucloud_instance.web[3]: resource \"ucloud_instance\" \"web\" { auto_renew = true availability_zone = \"cn-bj2-04\" boot_disk_size = 20 boot_disk_type = \"local_normal\" charge_type = \"dynamic\" cpu = 1 cpu_platform = \"Intel/Broadwell\" create_time = \"2020-11-28T23:09:04+08:00\" disk_set = [ { id = \"31e2cad6-79a1-4475-a9f5-2c5c95605b18\" is_boot = true size = 20 type = \"local_normal\" }, ] expire_time = \"2020-11-29T00:09:04+08:00\" id = \"uhost-v3qxdbju\" image_id = \"uimage-dhe5m2\" instance_type = \"n-standard-1\" ip_set = [ { internet_type = \"Private\" ip = \"10.9.85.40\" }, ] memory = 4 name = \"cn-bj2-04-1\" private_ip = \"10.9.85.40\" root_password = (sensitive value) security_group = \"firewall-juhsrlvr\" status = \"Running\" subnet_id = \"subnet-dtu3dgpr\" tag = \"Default\" vpc_id = \"uvnet-f1c3jq2b\" } # ucloud_instance.web[0]: resource \"ucloud_instance\" \"web\" { auto_renew = true availability_zone = \"cn-bj2-03\" boot_disk_size = 20 boot_disk_type = \"local_normal\" charge_type = \"dynamic\" cpu = 1 cpu_platform = \"Intel/IvyBridge\" create_time = \"2020-11-28T23:09:04+08:00\" disk_set = [ { id = \"da27595d-9645-4883-bf95-87b9076ab7e4\" is_boot = true size = 20 type = \"local_normal\" }, ] expire_time = \"2020-11-29T00:09:04+08:00\" id = \"uhost-scq1prqj\" image_id = \"uimage-pxplaj\" instance_type = \"n-standard-1\" ip_set = [ { internet_type = \"Private\" ip = \"10.9.107.152\" }, ] memory = 4 name = \"cn-bj2-03-0\" private_ip = \"10.9.107.152\" root_password = (sensitive value) security_group = \"firewall-juhsrlvr\" status = \"Running\" subnet_id = \"subnet-dtu3dgpr\" tag = \"Default\" vpc_id = \"uvnet-f1c3jq2b\" } 可以看到,主机的确是均匀地分散在两个可用区了。 但是这样做在调整可用区时会发生大问题,例如: variable \"az\" { type = list(string) default = [ \"cn-bj2-03\", # \"cn-bj2-04\", ] } 我们禁用了 cn-bj2-04 可用区,按道理我们期待的变更计划应该是将两台原本属于 cn-bj2-04 的主机删除,在 cn-bj2-03 可用区新增两台主机。让我们看看会发生什么: $ terraform plan Refreshing Terraform state in-memory prior to plan... The refreshed state will be used to calculate this plan, but will not be persisted to local or remote state storage. data.ucloud_images.centos: Refreshing state... [id=475496684] ucloud_instance.web[0]: Refreshing state... [id=uhost-scq1prqj] ucloud_instance.web[3]: Refreshing state... [id=uhost-v3qxdbju] ucloud_instance.web[2]: Refreshing state... [id=uhost-txa2owrp] ucloud_instance.web[1]: Refreshing state... [id=uhost-td3x545p] ------------------------------------------------------------------------ An execution plan has been generated and is shown below. Resource actions are indicated with the following symbols: ~ update in-place -/+ destroy and then create replacement Terraform will perform the following actions: # ucloud_instance.web[1] must be replaced -/+ resource \"ucloud_instance\" \"web\" { ~ auto_renew = true -> (known after apply) ~ availability_zone = \"cn-bj2-04\" -> \"cn-bj2-03\" # forces replacement ~ boot_disk_size = 20 -> (known after apply) ~ boot_disk_type = \"local_normal\" -> (known after apply) charge_type = \"dynamic\" ~ cpu = 1 -> (known after apply) ~ cpu_platform = \"Intel/Broadwell\" -> (known after apply) ~ create_time = \"2020-11-28T23:09:04+08:00\" -> (known after apply) + data_disk_size = (known after apply) + data_disk_type = (known after apply) ~ disk_set = [ - { - id = \"df06380a-00e1-42df-8c07-eec67d817f97\" - is_boot = true - size = 20 - type = \"local_normal\" }, ] -> (known after apply) ~ expire_time = \"2020-11-29T00:09:06+08:00\" -> (known after apply) ~ id = \"uhost-td3x545p\" -> (known after apply) ~ image_id = \"uimage-dhe5m2\" -> \"uimage-rkn1v2\" instance_type = \"n-standard-1\" ~ ip_set = [ - { - internet_type = \"Private\" - ip = \"10.9.44.37\" }, ] -> (known after apply) + isolation_group = (known after apply) ~ memory = 4 -> (known after apply) ~ name = \"cn-bj2-04-0\" -> \"cn-bj2-03-1\" ~ private_ip = \"10.9.44.37\" -> (known after apply) + remark = (known after apply) ~ root_password = (sensitive value) ~ security_group = \"firewall-juhsrlvr\" -> (known after apply) ~ status = \"Running\" -> (known after apply) ~ subnet_id = \"subnet-dtu3dgpr\" -> (known after apply) tag = \"Default\" ~ vpc_id = \"uvnet-f1c3jq2b\" -> (known after apply) } # ucloud_instance.web[2] will be updated in-place ~ resource \"ucloud_instance\" \"web\" { auto_renew = true availability_zone = \"cn-bj2-03\" boot_disk_size = 20 boot_disk_type = \"local_normal\" charge_type = \"dynamic\" cpu = 1 cpu_platform = \"Intel/IvyBridge\" create_time = \"2020-11-28T23:09:01+08:00\" disk_set = [ { id = \"1d7f07c9-7342-431b-85bb-d3ee0022063d\" is_boot = true size = 20 type = \"local_normal\" }, ] expire_time = \"2020-11-29T00:09:02+08:00\" id = \"uhost-txa2owrp\" image_id = \"uimage-pxplaj\" instance_type = \"n-standard-1\" ip_set = [ { internet_type = \"Private\" ip = \"10.9.45.234\" }, ] memory = 4 ~ name = \"cn-bj2-03-1\" -> \"cn-bj2-03-2\" private_ip = \"10.9.45.234\" root_password = (sensitive value) security_group = \"firewall-juhsrlvr\" status = \"Running\" subnet_id = \"subnet-dtu3dgpr\" tag = \"Default\" vpc_id = \"uvnet-f1c3jq2b\" } # ucloud_instance.web[3] must be replaced -/+ resource \"ucloud_instance\" \"web\" { ~ auto_renew = true -> (known after apply) ~ availability_zone = \"cn-bj2-04\" -> \"cn-bj2-03\" # forces replacement ~ boot_disk_size = 20 -> (known after apply) ~ boot_disk_type = \"local_normal\" -> (known after apply) charge_type = \"dynamic\" ~ cpu = 1 -> (known after apply) ~ cpu_platform = \"Intel/Broadwell\" -> (known after apply) ~ create_time = \"2020-11-28T23:09:04+08:00\" -> (known after apply) + data_disk_size = (known after apply) + data_disk_type = (known after apply) ~ disk_set = [ - { - id = \"31e2cad6-79a1-4475-a9f5-2c5c95605b18\" - is_boot = true - size = 20 - type = \"local_normal\" }, ] -> (known after apply) ~ expire_time = \"2020-11-29T00:09:04+08:00\" -> (known after apply) ~ id = \"uhost-v3qxdbju\" -> (known after apply) ~ image_id = \"uimage-dhe5m2\" -> \"uimage-rkn1v2\" instance_type = \"n-standard-1\" ~ ip_set = [ - { - internet_type = \"Private\" - ip = \"10.9.85.40\" }, ] -> (known after apply) + isolation_group = (known after apply) ~ memory = 4 -> (known after apply) ~ name = \"cn-bj2-04-1\" -> \"cn-bj2-03-3\" ~ private_ip = \"10.9.85.40\" -> (known after apply) + remark = (known after apply) ~ root_password = (sensitive value) ~ security_group = \"firewall-juhsrlvr\" -> (known after apply) ~ status = \"Running\" -> (known after apply) ~ subnet_id = \"subnet-dtu3dgpr\" -> (known after apply) tag = \"Default\" ~ vpc_id = \"uvnet-f1c3jq2b\" -> (known after apply) } Plan: 2 to add, 1 to change, 2 to destroy. ------------------------------------------------------------------------ Note: You didn't specify an \"-out\" parameter to save this plan, so Terraform can't guarantee that exactly these actions will be performed if \"terraform apply\" is subsequently run. 变更计划与期望略有不同。我们仔细看细节: # ucloud_instance.web[2] will be updated in-place ~ resource \"ucloud_instance\" \"web\" { auto_renew = true availability_zone = \"cn-bj2-03\" boot_disk_size = 20 boot_disk_type = \"local_normal\" charge_type = \"dynamic\" cpu = 1 cpu_platform = \"Intel/IvyBridge\" create_time = \"2020-11-28T23:09:01+08:00\" disk_set = [ { id = \"1d7f07c9-7342-431b-85bb-d3ee0022063d\" is_boot = true size = 20 type = \"local_normal\" }, ] expire_time = \"2020-11-29T00:09:02+08:00\" id = \"uhost-txa2owrp\" image_id = \"uimage-pxplaj\" instance_type = \"n-standard-1\" ip_set = [ { internet_type = \"Private\" ip = \"10.9.45.234\" }, ] memory = 4 ~ name = \"cn-bj2-03-1\" -> \"cn-bj2-03-2\" private_ip = \"10.9.45.234\" root_password = (sensitive value) security_group = \"firewall-juhsrlvr\" status = \"Running\" subnet_id = \"subnet-dtu3dgpr\" tag = \"Default\" vpc_id = \"uvnet-f1c3jq2b\" } 原本名为 cn-bj2-03-1 的主机被更名为 cn-bj2-03-2 了,原本属于 cn-bj2-04 的第一台主机的变更计划是: # ucloud_instance.web[1] must be replaced -/+ resource \"ucloud_instance\" \"web\" { ~ auto_renew = true -> (known after apply) ~ availability_zone = \"cn-bj2-04\" -> \"cn-bj2-03\" # forces replacement ~ boot_disk_size = 20 -> (known after apply) ~ boot_disk_type = \"local_normal\" -> (known after apply) charge_type = \"dynamic\" ~ cpu = 1 -> (known after apply) ~ cpu_platform = \"Intel/Broadwell\" -> (known after apply) ~ create_time = \"2020-11-28T23:09:04+08:00\" -> (known after apply) + data_disk_size = (known after apply) + data_disk_type = (known after apply) ~ disk_set = [ - { - id = \"df06380a-00e1-42df-8c07-eec67d817f97\" - is_boot = true - size = 20 - type = \"local_normal\" }, ] -> (known after apply) ~ expire_time = \"2020-11-29T00:09:06+08:00\" -> (known after apply) ~ id = \"uhost-td3x545p\" -> (known after apply) ~ image_id = \"uimage-dhe5m2\" -> \"uimage-rkn1v2\" instance_type = \"n-standard-1\" ~ ip_set = [ - { - internet_type = \"Private\" - ip = \"10.9.44.37\" }, ] -> (known after apply) + isolation_group = (known after apply) ~ memory = 4 -> (known after apply) ~ name = \"cn-bj2-04-0\" -> \"cn-bj2-03-1\" ~ private_ip = \"10.9.44.37\" -> (known after apply) + remark = (known after apply) ~ root_password = (sensitive value) ~ security_group = \"firewall-juhsrlvr\" -> (known after apply) ~ status = \"Running\" -> (known after apply) ~ subnet_id = \"subnet-dtu3dgpr\" -> (known after apply) tag = \"Default\" ~ vpc_id = \"uvnet-f1c3jq2b\" -> (known after apply) } 它的名字从 cn-bj2-04-0 变成了 cn-bj2-03-1。 仔细想想,实际上这是一个比较低效的变更计划。原本属于 cn-bj2-03 的两台主机应该不做任何变更,只需要删除 cn-bj2-04 的主机,再补充两台 cn-bj2-03 的主机即可。这是因为我们使用的是 count,而 count 只看元素在列表中的序号。当我们删除一个可用区时,实际上会引起主机序号的重大变化,导致出现大量低效的变更,这就是我们在讲 count 与 for_each 时强调过的,如果创建的资源实例彼此之间几乎完全一致,那么 count 比较合适。否则,那么使用 for_each 会更加安全。 让我们尝试使用 for_each 改写这段逻辑: variable \"az\" { type = list(string) default = [ \"cn-bj2-03\", \"cn-bj2-04\", ] } variable \"instance_count\" { type = number default = 4 } locals { instance_names = [for i in range(var.instance_count):\"${var.az[i%length(var.az)]}-${floor(i/length(var.az))}\"] } data \"ucloud_images\" \"centos\" { name_regex = \"^CentOS 7\" } resource \"ucloud_instance\" \"web\" { for_each = toset(local.instance_names) name = each.value availability_zone = var.az[index(local.instance_names, each.value) % length(var.az)] image_id = data.ucloud_images.centos.images[0].id instance_type = \"n-standard-1\" charge_type = \"dynamic\" } 为了生成主机独一无二的名字,我们首先用 range 函数生成了一个序号集合,比如目标主机数是 4,那么 range(4) 的结果就是 [0, 1, 2, 3];然后我们通过取模运算使得名字前缀在可用区列表之间循环递增,最后用 floor(i/length(var.az)) 计算出当前序号对应在当前可用区是第几台。例如 4 号主机在第二个可用区就是第二台,生成的名字应该就是 cn-bj-04-1。 执行结果是: $ terraform apply -auto-approve data.ucloud_images.centos: Refreshing state... ucloud_instance.web[\"cn-bj2-03-1\"]: Creating... ucloud_instance.web[\"cn-bj2-03-0\"]: Creating... ucloud_instance.web[\"cn-bj2-04-0\"]: Creating... ucloud_instance.web[\"cn-bj2-04-1\"]: Creating... ucloud_instance.web[\"cn-bj2-03-1\"]: Still creating... [10s elapsed] ucloud_instance.web[\"cn-bj2-03-0\"]: Still creating... [10s elapsed] ucloud_instance.web[\"cn-bj2-04-0\"]: Still creating... [10s elapsed] ucloud_instance.web[\"cn-bj2-04-1\"]: Still creating... [10s elapsed] ucloud_instance.web[\"cn-bj2-03-1\"]: Still creating... [20s elapsed] ucloud_instance.web[\"cn-bj2-03-0\"]: Still creating... [20s elapsed] ucloud_instance.web[\"cn-bj2-04-0\"]: Still creating... [20s elapsed] ucloud_instance.web[\"cn-bj2-04-1\"]: Still creating... [20s elapsed] ucloud_instance.web[\"cn-bj2-04-1\"]: Creation complete after 21s [id=uhost-fjci1i4o] ucloud_instance.web[\"cn-bj2-04-0\"]: Creation complete after 23s [id=uhost-bkkhmref] ucloud_instance.web[\"cn-bj2-03-1\"]: Creation complete after 26s [id=uhost-amosgdaa] ucloud_instance.web[\"cn-bj2-03-0\"]: Still creating... [30s elapsed] ucloud_instance.web[\"cn-bj2-03-0\"]: Still creating... [40s elapsed] ucloud_instance.web[\"cn-bj2-03-0\"]: Creation complete after 45s [id=uhost-kltudgnf] Apply complete! Resources: 4 added, 0 changed, 0 destroyed. 如果我们去掉一个可用区: variable \"az\" { type = list(string) default = [ \"cn-bj2-03\", # \"cn-bj2-04\", ] } 我们可以检查一下执行计划: $ terraform plan Refreshing Terraform state in-memory prior to plan... The refreshed state will be used to calculate this plan, but will not be persisted to local or remote state storage. data.ucloud_images.centos: Refreshing state... [id=475496684] ucloud_instance.web[\"cn-bj2-03-1\"]: Refreshing state... [id=uhost-amosgdaa] ucloud_instance.web[\"cn-bj2-04-0\"]: Refreshing state... [id=uhost-bkkhmref] ucloud_instance.web[\"cn-bj2-03-0\"]: Refreshing state... [id=uhost-kltudgnf] ucloud_instance.web[\"cn-bj2-04-1\"]: Refreshing state... [id=uhost-fjci1i4o] ------------------------------------------------------------------------ An execution plan has been generated and is shown below. Resource actions are indicated with the following symbols: + create - destroy Terraform will perform the following actions: # ucloud_instance.web[\"cn-bj2-03-2\"] will be created + resource \"ucloud_instance\" \"web\" { + auto_renew = (known after apply) + availability_zone = \"cn-bj2-03\" + boot_disk_size = (known after apply) + boot_disk_type = (known after apply) + charge_type = \"dynamic\" + cpu = (known after apply) + cpu_platform = (known after apply) + create_time = (known after apply) + data_disk_size = (known after apply) + data_disk_type = (known after apply) + disk_set = (known after apply) + expire_time = (known after apply) + id = (known after apply) + image_id = \"uimage-rkn1v2\" + instance_type = \"n-standard-1\" + ip_set = (known after apply) + isolation_group = (known after apply) + memory = (known after apply) + name = \"cn-bj2-03-2\" + private_ip = (known after apply) + remark = (known after apply) + root_password = (sensitive value) + security_group = (known after apply) + status = (known after apply) + subnet_id = (known after apply) + tag = \"Default\" + vpc_id = (known after apply) } # ucloud_instance.web[\"cn-bj2-03-3\"] will be created + resource \"ucloud_instance\" \"web\" { + auto_renew = (known after apply) + availability_zone = \"cn-bj2-03\" + boot_disk_size = (known after apply) + boot_disk_type = (known after apply) + charge_type = \"dynamic\" + cpu = (known after apply) + cpu_platform = (known after apply) + create_time = (known after apply) + data_disk_size = (known after apply) + data_disk_type = (known after apply) + disk_set = (known after apply) + expire_time = (known after apply) + id = (known after apply) + image_id = \"uimage-rkn1v2\" + instance_type = \"n-standard-1\" + ip_set = (known after apply) + isolation_group = (known after apply) + memory = (known after apply) + name = \"cn-bj2-03-3\" + private_ip = (known after apply) + remark = (known after apply) + root_password = (sensitive value) + security_group = (known after apply) + status = (known after apply) + subnet_id = (known after apply) + tag = \"Default\" + vpc_id = (known after apply) } # ucloud_instance.web[\"cn-bj2-04-0\"] will be destroyed - resource \"ucloud_instance\" \"web\" { - auto_renew = true -> null - availability_zone = \"cn-bj2-04\" -> null - boot_disk_size = 20 -> null - boot_disk_type = \"local_normal\" -> null - charge_type = \"dynamic\" -> null - cpu = 1 -> null - cpu_platform = \"Intel/Broadwell\" -> null - create_time = \"2020-11-28T22:35:53+08:00\" -> null - disk_set = [ - { - id = \"b214d840-ffec-4958-a3da-3580846fd2a3\" - is_boot = true - size = 20 - type = \"local_normal\" }, ] -> null - expire_time = \"2020-11-28T23:35:53+08:00\" -> null - id = \"uhost-bkkhmref\" -> null - image_id = \"uimage-dhe5m2\" -> null - instance_type = \"n-standard-1\" -> null - ip_set = [ - { - internet_type = \"Private\" - ip = \"10.9.48.82\" }, ] -> null - memory = 4 -> null - name = \"cn-bj2-04-0\" -> null - private_ip = \"10.9.48.82\" -> null - root_password = (sensitive value) - security_group = \"firewall-juhsrlvr\" -> null - status = \"Running\" -> null - subnet_id = \"subnet-dtu3dgpr\" -> null - tag = \"Default\" -> null - vpc_id = \"uvnet-f1c3jq2b\" -> null } # ucloud_instance.web[\"cn-bj2-04-1\"] will be destroyed - resource \"ucloud_instance\" \"web\" { - auto_renew = true -> null - availability_zone = \"cn-bj2-04\" -> null - boot_disk_size = 20 -> null - boot_disk_type = \"local_normal\" -> null - charge_type = \"dynamic\" -> null - cpu = 1 -> null - cpu_platform = \"Intel/Broadwell\" -> null - create_time = \"2020-11-28T22:35:53+08:00\" -> null - disk_set = [ - { - id = \"6a3f274f-e072-4a46-90f8-edc7dbaa27f7\" - is_boot = true - size = 20 - type = \"local_normal\" }, ] -> null - expire_time = \"2020-11-28T23:35:53+08:00\" -> null - id = \"uhost-fjci1i4o\" -> null - image_id = \"uimage-dhe5m2\" -> null - instance_type = \"n-standard-1\" -> null - ip_set = [ - { - internet_type = \"Private\" - ip = \"10.9.176.28\" }, ] -> null - memory = 4 -> null - name = \"cn-bj2-04-1\" -> null - private_ip = \"10.9.176.28\" -> null - root_password = (sensitive value) - security_group = \"firewall-juhsrlvr\" -> null - status = \"Running\" -> null - subnet_id = \"subnet-dtu3dgpr\" -> null - tag = \"Default\" -> null - vpc_id = \"uvnet-f1c3jq2b\" -> null } Plan: 2 to add, 0 to change, 2 to destroy. ------------------------------------------------------------------------ Note: You didn't specify an \"-out\" parameter to save this plan, so Terraform can't guarantee that exactly these actions will be performed if \"terraform apply\" is subsequently run. 可以看到,原来属于 cn-bj2-03 的两台主机原封不动,删除了属于 cn-bj2-04 的两台主机,并且在 cn-bj2-03 可用区新增两台主机。 "},"8.技巧/4.provisioner与user_data.html":{"url":"8.技巧/4.provisioner与user_data.html","title":"provisioner与user_data","keywords":"","body":"provisioner 与 user_data 我们在介绍资源时介绍了预置器 provisioner。同时不少公有云厂商的虚拟机都提供了 cloud-init 功能,可以让我们在虚拟机实例第一次启动时执行一段自定义的脚本来执行一些初始化操作。例如我们在Terraform 初步体验一章里举的例子,在 UCloud 主机第一次启动时我们通过 user_data 来调用 yum 安装并配置了 ngnix 服务。预置器与 cloud-init 都可以用于初始化虚拟机,那么我们应该用哪一种呢? 首先要指出的是,provisioner 的官方文档里明确指出,由于预置器内部的行为 Terraform 无法感知,无法将它执行的变更纳入到声明式的代码管理中,所以预置器应被作为最后的手段使用,那么也就是说,如果 cloud-init 能够满足我们的要求,那么我们应该优先使用 cloud-init。 但是仍然存在一些 cloud-init 无法满足的场景。例如一个最常见的情况是,比如我们要在 cloud-init 当中格式化卷,后续的所有操作都必须在主机成功格式化并挂载卷之后才能顺利进行下去。但是比如 aws_instance,它的创建是不会等待 user_data 代码执行完成的,只要虚拟机创建成功开始启动,Terraform 就会认为资源创建完成从而继续后续的创建了。 解决这个问题目前来看还是只能依靠预置器。我们以一段 UCloud 云主机代码为例: resource \"ucloud_instance\" \"web\" { availability_zone = \"cn-bj2-03\" image_id = data.ucloud_images.centos.images[0].id instance_type = \"n-standard-1\" charge_type = \"dynamic\" network_interface { eip_internet_type = \"bgp\" eip_charge_mode = \"traffic\" eip_bandwidth = 1 } delete_eips_with_instance = true root_password = var.root_password provisioner \"remote-exec\" { connection { type = \"ssh\" host = [for ipset in self.ip_set: ipset.ip if ipset.internet_type==\"BGP\"][0] user = \"root\" password = var.root_password timeout = \"1h\" } inline = [ \"sleep 1h\" ] } } 我们在资源声明中附加了一个 remote-exec 类型的预置器,它的 host 取值使用了 self.ip_set,self 在当前上下文中指代 provisioner 所属的 ucloud_instance.web,ip_set 是 ucloud_instance 的一个输出属性,内含云主机的内网 IP 以及绑定的弹性公网 IP 信息。我们用一个 for 表达式过滤出弹性公网 IP 地址,然后使用 ssh 连接。预置器执行的脚本代码很简单,休眠一小时。如果我们执行这段代码: $ terraform apply -auto-approve data.ucloud_images.centos: Refreshing state... ucloud_instance.web: Creating... ucloud_instance.web: Still creating... [10s elapsed] ucloud_instance.web: Still creating... [20s elapsed] ucloud_instance.web: Provisioning with 'remote-exec'... ucloud_instance.web (remote-exec): Connecting to remote host via SSH... ucloud_instance.web (remote-exec): Host: 106.75.87.148 ucloud_instance.web (remote-exec): User: root ucloud_instance.web (remote-exec): Password: true ucloud_instance.web (remote-exec): Private key: false ucloud_instance.web (remote-exec): Certificate: false ucloud_instance.web (remote-exec): SSH Agent: true ucloud_instance.web (remote-exec): Checking Host Key: false ucloud_instance.web: Still creating... [30s elapsed] ucloud_instance.web (remote-exec): Connecting to remote host via SSH... ucloud_instance.web (remote-exec): Host: 106.75.87.148 ucloud_instance.web (remote-exec): User: root ucloud_instance.web (remote-exec): Password: true ucloud_instance.web (remote-exec): Private key: false ucloud_instance.web (remote-exec): Certificate: false ucloud_instance.web (remote-exec): SSH Agent: true ucloud_instance.web (remote-exec): Checking Host Key: false ucloud_instance.web: Still creating... [40s elapsed] ucloud_instance.web (remote-exec): Connecting to remote host via SSH... ucloud_instance.web (remote-exec): Host: 106.75.87.148 ucloud_instance.web (remote-exec): User: root ucloud_instance.web (remote-exec): Password: true ucloud_instance.web (remote-exec): Private key: false ucloud_instance.web (remote-exec): Certificate: false ucloud_instance.web (remote-exec): SSH Agent: true ucloud_instance.web (remote-exec): Checking Host Key: false ucloud_instance.web (remote-exec): Connected! ucloud_instance.web: Still creating... [50s elapsed] ucloud_instance.web: Still creating... [1m0s elapsed] ucloud_instance.web: Still creating... [1m10s elapsed] ucloud_instance.web: Still creating... [1m20s elapsed] ucloud_instance.web: Still creating... [1m30s elapsed] ucloud_instance.web: Still creating... [1m40s elapsed] ... 不出所料的话,该过程会持续一小时,也就是说,无论预置器脚本中执行的操作耗时多长,ucloud_instance 的创建都会等待它完成,或是触发超时。 在这里我们可以使用这种方法的前提是我们使用的 UCloud 云主机的资源定义允许我们定义资源时声明 network_interface 属性,直接绑定一个公网 IP。如果我们使用的云厂商 Provider 无法让我们在创建主机时绑定公网 IP,而是必须事后绑定弹性 IP 呢?又或者,初始化脚本必须在云主机成功绑定了云盘之后才能成功运行?这种情况下我们还有最后的武器,就是 null_resource。 null_resource 可能是 Terraform 体系中最“不 Terraform”的存在,它就是我们用来在 Terraform 这样一个声明式世界里干各种命令式脏活的工具。null_resouce 本身是一个空的 resource,只有一个名为 triggers 的参数以及 id 作为输出属性。 我们看下这个例子: data \"ucloud_images\" \"centos\" { name_regex = \"^CentOS 7\" } resource \"ucloud_eip\" \"eip\" { internet_type = \"bgp\" bandwidth = 1 charge_mode = \"traffic\" } resource \"ucloud_disk\" \"data_disk\" { availability_zone = \"cn-bj2-03\" disk_size = 10 charge_type = \"dynamic\" disk_type = \"data_disk\" } resource \"ucloud_instance\" \"web\" { availability_zone = \"cn-bj2-03\" image_id = data.ucloud_images.centos.images[0].id instance_type = \"n-standard-1\" charge_type = \"dynamic\" root_password = var.root_password } resource \"ucloud_eip_association\" \"eip_association\" { eip_id = ucloud_eip.eip.id resource_id = ucloud_instance.web.id } resource \"ucloud_disk_attachment\" \"data_disk\" { availability_zone = \"cn-bj2-03\" disk_id = ucloud_disk.data_disk.id instance_id = ucloud_instance.web.id } resource \"null_resource\" \"web_init\" { depends_on = [ ucloud_eip_association.eip_association, ucloud_disk_attachment.data_disk ] provisioner \"remote-exec\" { connection { type = \"ssh\" host = ucloud_eip.eip.public_ip user = \"root\" password = var.root_password } inline = [ \"echo hello\" ] } } 我们假设需要远程执行的操纵是必须在云盘挂载成功以后才可以运行的,那么我们可以声明一个 null_resource,把 provisioner 声明放在那里,通过显式声明 depends_on 确保它的执行一定是在云盘挂载结束以后。 另外这个例子里我们运行的脚本非常简单,考虑一种更加复杂一些的场景,我们运行的脚本是通过文件读取的,我们希望在文件内容发生变化时能够重新在服务器上运行该脚本,这时我们可以使用 null_resource 的 triggers 参数: resource \"null_resource\" \"web_init\" { depends_on = [ ucloud_eip_association.eip_association, ucloud_disk_attachment.data_disk ] triggers = { script_hash = filemd5(\"${path.module}/init.sh\") } provisioner \"remote-exec\" { connection { type = \"ssh\" host = ucloud_eip.eip.public_ip user = \"root\" password = var.root_password } script = \"${path.module}/init.sh\" } } 现在 provisioner 运行的脚本是通过 script 参数传入的脚本文件路径,而我们通过 filemd5 函数把文件内容的哈希值传入了 triggers。triggers 会在值发生改变时触发 null_resource 的重建,这样脚本发生些许变化都会导致重新执行。 官方文档上还给出了对于 triggers 的另一个妙用: resource \"aws_instance\" \"cluster\" { count = 3 # ... } resource \"null_resource\" \"cluster\" { # Changes to any instance of the cluster requires re-provisioning triggers = { cluster_instance_ids = \"${join(\",\", aws_instance.cluster.*.id)}\" } # Bootstrap script can run on any instance of the cluster # So we just choose the first in this case connection { host = \"${element(aws_instance.cluster.*.public_ip, 0)}\" } provisioner \"remote-exec\" { # Bootstrap script called with private_ip of each node in the clutser inline = [ \"bootstrap-cluster.sh ${join(\" \", aws_instance.cluster.*.private_ip)}\", ] } } 这个例子里,我们需要所有 AWS 主机的内网 IP 参与才能够成功初始化集群,可能是类似 Kafka 或是 RabbitMQ 这样的应用,我们需要把集群节点的IP写入配置文件。如何确保未来机器数量发生调整以后,机器上的配置文件始终能够获得完整的集群内网 IP 信息,这里使用 triggers 就可以轻松完成目标。 另外在绝大多数生产环境中,服务器都不允许拥有独立的公网 IP,或是禁止从服务器对外服务的公网 IP 直接连接 ssh。这时一般我们会在集群中配置一台堡垒机,通过堡垒机进行跳转连接。可以访问通过堡垒机使用SSH的官方文档获取详细信息,在此不再赘述。 "},"8.技巧/5.destroy-provisioner中使用变量.html":{"url":"8.技巧/5.destroy-provisioner中使用变量.html","title":"destroy-provisioner中使用变量","keywords":"","body":"destroy-provisioner 中使用变量 我们可以在定义一个 provisioner 块时设置 when 为 destroy,资源在销毁之前会首先执行 provisioner,可以帮助我们执行一些析构逻辑。但是如果我们在 Destroy-Provisioner 中引用了变量的话,比如这样的代码: resource \"aws_volume_attachment\" \"attachement_myservice\" { count = \"${length(var.network_myservice_subnet_ids)}\" device_name = \"/dev/xvdg\" volume_id = \"${element(aws_ebs_volume.ebs_myservice.*.id, count.index)}\" instance_id = \"${element(aws_instance.myservice.*.id, count.index)}\" provisioner \"local-exec\" { command = \"aws ec2 stop-instances --instance-ids ${element(aws_instance.myservice.*.id, count.index)} --region ${var.region} && sleep 30\" when = \"destroy\" } } 那么我们会看见这样的报错信息: | Error: Invalid reference from destroy provisioner │ │ Destroy-time provisioners and their connection configurations may only reference attributes of the related resource, via 'self', 'count.index', or 'each.key'. │ │ References to other resources during the destroy phase can cause dependency cycles and interact poorly with create_before_destroy. 从 0.12 开始 Terraform 会对在 Destroy-Time Provisioner 中引用除 self、count.index、each.key 以外的变量做警告,从 0.13 开始则会直接报错。 解决方法 目前官方推荐的做法是把需要引用的变量值通过 triggers “捕获”一下再引用,例如: resource \"null_resource\" \"foo\" { triggers { interpreter = var.local_exec_interpreter } provisioner { when = destroy interpreter = self.triggers.interpreter ... } } 通过这种方法就可以避免这个问题。 "},"8.技巧/6.利用null_resource的triggers触发其他资源更新.html":{"url":"8.技巧/6.利用null_resource的triggers触发其他资源更新.html","title":"利用null_resource的triggers触发其他资源更新","keywords":"","body":"利用 null_resource 的 triggers 触发其他资源更新 社区有人提了一个 Terraform 问题,他写了这样一段 Terraform 代码: resource \"azurerm_key_vault_secret\" \"service_bus_connection_string\" { name = \"service-bus-connection-string\" value = azurerm_servicebus_topic_authorization_rule.mysb.primary_connection_string key_vault_id = azurerm_key_vault.main.id } resource \"azurerm_function_app\" \"main\" { name = \"myfn\" location = azurerm_resource_group.main.location resource_group_name = azurerm_resource_group.main.name app_service_plan_id = azurerm_app_service_plan.main.id enable_builtin_logging = true https_only = true os_type = \"linux\" storage_account_name = azurerm_storage_account.main.name storage_account_access_key = azurerm_storage_account.main.primary_access_key version = \"~3\" app_settings = { AzureWebJobsServiceBus = \"@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.service_bus_connection_string.id})\" } } 意思大概是他把一段含有机密信息的连接字符串保存在 Azure KeyVault 服务中,然后创建了一个 Azure Faas 函数,通过 KeyVault 机密引用地址传递该机密。 问题描述 这位老兄发现,如果他修改了机密的内容,也就是 azurerm_key_vault_secret 声明里的 value = azurerm_servicebus_topic_authorization_rule.mysb.primary_connection_string 这一段的值的时候,KeyVault 保存的机密内容的确会正确更新,但 Azure Function 读取到的还是旧的机密引用地址,也就是这段代码中得到的 KeyVault 机密引用地址没有更新: app_settings = { AzureWebJobsServiceBus = \"@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.service_bus_connection_string.id})\" } 更加奇怪的是,这之后他什么都没有做,只是重新再执行一次 terraform apply,该引用地址又被正确更新了?! 问题原因 因为 KeyVault Secret 被设计成是不可变的,所以更新 azurerm_key_vault_secret 的 value 会导致资源被重新创建。Terraform 官网上的相关文档中对该参数的定义如下: value - (Required) Specifies the value of the Key Vault Secret. 在 Terraform 中 ,一个参数如果被标记为 Required,那么它不但是必填项,同时类似数据库记录的主键的概念,主键不同的记录被认定是两条不同的记录,修改记录的主键值可以看作是删除重建之。Terraform 资源的 Required 参数如果发生变化会触发重新创建资源,这就导致了修改 value 后,该 azurerm_key_vault_secret 的 id 也会发生变化。 那么为什么在 azurerm_key_vault_secret 被重新创建之后,我们会发现 azurerm_function_app 中引用的 id 没有变化呢? Terraform 的工作流含有 Plan 和 Apply 两个主要阶段,首先会分析 Terraform 代码,调用 terraform refresh(可以用参数跳过该步骤)读取资源在云端目前的最新状态,再加上 State 文件中记录的状态,三个状态对比出一个执行计划,使得最终产生的云端状态能够符合当前代码描述的状态。 就这个场景而言,Terraform 能够意识到 azurerm_key_vault_secret 的参数发生了变化,这会导致某种程度的更新,但它无法意识到这个更新会导致 azurerm_key_vault_secret 的 id 发生变化,进而导致 azurerm_function_app 也必须进行更新,所以就发生了他第一次执行 terraform apply 后看到的情况。 当他第二次执行 terraform apply 时,Terraform 记录的 State 文件里,azurerm_key_vault_secret 的 id和azurerm_function_app 里使用的 id 已经对不上了,这时 Terraform 会再生成一个更新 azurerm_function_app 的 Plan,执行后一切恢复正常。 有没有办法让 azurerm_function_app 能在第一次生成 Plan 时就感知到这个变更? 巧用 null_resource 的 triggers HashiCorp 提供了一个非常常用的内建 Provider —— null。其中最知名的资源就是 null_resource 了,一般它都是和 provisioner 搭配出现,可以用来在某些资源创建完成后执行一些自定义脚本等等。但是它还有一个很有用的参数: The triggers argument allows specifying an arbitrary set of values that, when changed, will cause the resource to be replaced. triggers 参数可以用来指向一些值,只要这些值的内容发生了变动,会导致 null_resource 资源被重新创建,从而生成一个新的 id。 一个小实验 我们尝试构建一个简单的实验环境来验证一下,首先是这样一段代码: resource \"azurerm_key_vault_secret\" \"example\" { name = \"secret-sauce\" value = \"szechuan\" key_vault_id = azurerm_key_vault.example.id } resource \"local_file\" \"output\" { filename = \"${path.module}/output.txt\" content = azurerm_key_vault_secret.example.id } 我们创建一个 azurerm_key_vault_secret,然后把它的 id 输出到一个文件里。随后我们复制一下该文件,比如叫 output.bak 好了。随后我们修改 azurerm_key_vault_secret 的 value 到一个新的值,执行 terraform apply 以后,我们会发现 output.txt 与 output.bak 的内容完全一样,说明 value 的更新并没有触发 local_file 的更新。 随后我们把代码改成这样: resource \"azurerm_key_vault_secret\" \"example\" { name = \"secret-sauce\" value = \"szechuan2\" key_vault_id = azurerm_key_vault.example.id } resource \"null_resource\" \"example\" { triggers = { trigger = azurerm_key_vault_secret.example.value } } resource \"local_file\" \"output\" { filename = \"${path.module}/output.txt\" content = null_resource.example.id == null_resource.example.id ? azurerm_key_vault_secret.example.id : \"\" } 我们在代码中插入了一个 null_resource,并设置 triggers 的内容,盯住 azurerm_key_vault_secret.example.value。在 value 发生变化时,null_resource 的 id 也会发生变化。 然后我们在 local_file 的代码中,content 的赋值改成了这样一个三目表达式:null_resource.example.id == null_resource.example.id ? azurerm_key_vault_secret.example.id : \"\"。这个表达式里实际上 null_resource.example.id 是不起作用的,自己等于自己的永真条件会导致仍然使用 azurerm_key_vault_secret.example.id 作为值,但是由于掺入了 null_resource.example.id,使得 Terraform 在第一次计算 Plan 时就感知到 local_file 的内容发生了变化,从而使得我们可以一次 terraform apply 搞定。 "},"8.技巧/7.利用null_resource搭配replace_triggered_by更新无法读取的属性.html":{"url":"8.技巧/7.利用null_resource搭配replace_triggered_by更新无法读取的属性.html","title":"利用 null_resource 搭配 replace_triggered_by 更新无法从服务端读取内容的属性","keywords":"","body":"利用 null_resource 搭配 replace_triggered_by 更新无法从服务端读取内容的属性 曾经处理的一个提问,有人写了这样一段 Terraform 代码: resource \"azurerm_container_group\" \"this\" { name = var.name location = var.location resource_group_name = var.resource_group_name ip_address_type = \"Private\" network_profile_id = azurerm_network_profile.this.id os_type = \"Linux\" container { name = \"someName\" image = \"someImage\" cpu = \"0.5\" memory = \"0.5\" commands = [\"some\", \"commands\"] ports { port = 53 protocol = \"UDP\" } volume { mount_path = \"/app/conf\" name = \"someName\" read_only = true secret = { Corefile = base64encode(someContent) } } } tags = var.tags } 结果每次执行 apply 操作时,都会发现 Terraform 试图重建这个容器: # module.dns_forwarder.azurerm_container_group.this must be replaced -/+ resource \"azurerm_container_group\" \"this\" { ~ exposed_port = [ - { - port = 53 - protocol = \"UDP\" }, ] -> (known after apply) + fqdn = (known after apply) ~ id = \"/subscriptions//resourceGroups//providers/Microsoft.ContainerInstance/containerGroups/\" -> (known after apply) ~ ip_address = \"someIp\" -> (known after apply) name = \"someName\" - tags = {} -> null # (6 unchanged attributes hidden) ~ container { - environment_variables = {} -> null name = \"someName\" - secure_environment_variables = (sensitive value) # (4 unchanged attributes hidden) ~ volume { name = \"someName\" ~ secret = (sensitive value) # forces replacement # (3 unchanged attributes hidden) } # (1 unchanged block hidden) } } 这个问题的原因是 API 在读取容器信息时不会返回 volume 的 secret 数据,这其实是一个还挺合理的设定,机密数据的确不应该可以直接从 API 返回,但这就导致 Terraform 每次制定变更计划时都会试图重新设置这个值(因为会理解成服务端这个值被修改成了空),而容器是不可变的,要修改容器的任何配置都会导致容器被重建。 有没有办法能够避免这种问题?经验告诉我们,可以使用 ignore_changes 让 Terraform 忽略这个属性的变更来避免重建,但如果 secret 真的变了怎么办? 我们可以这样干,第一,在 azurerm_container_group 添加这样一段 lifecycle 块: lifecycle { ignore_changes = [container[0].volume[0].secret] replace_triggered_by = [null_resource.secret_trigger.id] } 这会忽略 secret 的变化,但我们同时声明了一个 replace_triggered_by,在 null_resource.secret_trigger.id 的值发生变化时可以删除重建 azurerm_container_group 实例。 其次,我们把 secret 的内容提取到一个 local 里,这时 azurerm_container_group 的 volume 看起来大概是这样的: volume { mount_path = \"/app/conf\" name = \"somename\" read_only = true secret = { Corefile = local.secret } } local.secret 存放着使用的机密数据。这时我们再定义一个 null_resource 充当触发器: locals { secret = base64encode(\"abcdefg\") } resource \"null_resource\" \"secret_trigger\" { triggers = { trigger = local.secret } } 这样在机密数据真的发生变化的时候,triggers 会触发 null_resource 的重建,导致 null_resource.secret_trigger.id 发生变化,进而触发 azurerm_container_group 的重建。 "},"8.技巧/8.创建资源的条件依赖另一个资源的输出时怎么办.html":{"url":"8.技巧/8.创建资源的条件依赖另一个资源的输出时怎么办.html","title":"创建资源的条件依赖另一个资源的输出时怎么办","keywords":"","body":"创建资源的条件依赖另一个资源的输出时怎么办 我们在有条件创建当中介绍了如何可以通过判断用户的输入参数来决定是否要创建某个资源,让我们来看一下这样一个 Module 的例子: variable \"vpc_id\" { type = string default = null } resource \"ucloud_vpc\" \"vpc\" { count = var.vpc_id == null ? 1 : 0 cidr_blocks = [\"10.0.0.0/16\"] name = \"vpc\" } resource \"ucloud_subnet\" \"subnet\" { cidr_block = \"10.0.0.0/24\" vpc_id = var.vpc_id == null ? ucloud_vpc.vpc[0].id : var.vpc_id } 我们想在 Module 中创建一个 ucloud_subnet,用户可以输入一个 vpc_id 配置给它,也可以不输入,这时 Module 会创建一个 ucloud_vpc 来用。 假如我们使用这个模块,不传入 vpc_id: module vpc { source = \"./vpc\" } 这段代码生成的 Plan 内容如下: Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: + create Terraform will perform the following actions: # module.vpc.ucloud_subnet.subnet will be created + resource \"ucloud_subnet\" \"subnet\" { + cidr_block = \"10.0.0.0/24\" + create_time = (known after apply) + id = (known after apply) + name = (known after apply) + remark = (known after apply) + tag = \"Default\" + vpc_id = (known after apply) } # module.vpc.ucloud_vpc.vpc[0] will be created + resource \"ucloud_vpc\" \"vpc\" { + cidr_blocks = [ + \"10.0.0.0/16\", ] + create_time = (known after apply) + id = (known after apply) + name = \"vpc\" + network_info = (known after apply) + remark = (known after apply) + tag = \"Default\" + update_time = (known after apply) } Plan: 2 to add, 0 to change, 0 to destroy. 完全符合预期。假如我们希望由模块的调用者来创建 Vpc 的话: resource \"ucloud_vpc\" \"vpc\" { cidr_blocks = [\"10.0.0.0/16\"] name = \"vpc\" } module vpc { source = \"./vpc\" vpc_id = ucloud_vpc.vpc.id } 我们执行 terraform plan 的话,会得到这样的结果: ╷ │ Error: Invalid count argument │ │ on vpc/main.tf line 16, in resource \"ucloud_vpc\" \"vpc\": │ 16: count = var.vpc_id == null ? 1 : 0 │ │ The \"count\" value depends on resource attributes that cannot be determined until apply, so Terraform cannot predict how many instances will be created. To work around this, │ use the -target argument to first apply only the resources that the count depends on. ╵ Terraform 试图向我们抱怨,我们在 count 参数的表达式里使用了一个必须在 apply 阶段才能知道的值,所以它无法在 plan 阶段就计算出 count 的值。它建议我们先用 terraform apply 命令搭配 -target 参数把 Vpc 先创建出来,消除后续计算 Plan 时尚不知晓的值来解决这个问题。 这当然是一种很麻烦的方法,所以我们在设计 Module 时就要考虑到这种问题。有一种很简单的方法可以解决这个问题: variable \"vpc\" { type = object( { id = string } ) default = null } resource \"ucloud_vpc\" \"vpc\" { count = var.vpc == null ? 1 : 0 cidr_blocks = [\"10.0.0.0/16\"] name = \"vpc\" } resource \"ucloud_subnet\" \"subnet\" { cidr_block = \"10.0.0.0/24\" vpc_id = var.vpc == null ? ucloud_vpc.vpc[0].id : var.vpc.id } 我们把用来判断创建条件的输入参数类型改成 object,调用 Module 的代码就变成了: Terraform will perform the following actions: # ucloud_vpc.vpc will be created + resource \"ucloud_vpc\" \"vpc\" { + cidr_blocks = [ + \"10.0.0.0/16\", ] + create_time = (known after apply) + id = (known after apply) + name = \"vpc\" + network_info = (known after apply) + remark = (known after apply) + tag = \"Default\" + update_time = (known after apply) } # module.vpc.ucloud_subnet.subnet will be created + resource \"ucloud_subnet\" \"subnet\" { + cidr_block = \"10.0.0.0/24\" + create_time = (known after apply) + id = (known after apply) + name = (known after apply) + remark = (known after apply) + tag = \"Default\" + vpc_id = (known after apply) } Plan: 2 to add, 0 to change, 0 to destroy. 成功计算出 Plan。请注意虽然这个 Plan 仍然是创建两个资源,但 ucloud_vpc 资源并不是 Module 创建的。 这个方法的原理就是虽然 var.vpc.id 仍然是一个只有在 apply 阶段才能知道的值,但 var.vpc 本身是一个在 plan 阶段就可以知道的值,直接可以判读它是否为 null,所以该方法可以绕过这个限制。 "},"8.技巧/9.利用create_before_destroy调整资源update执行的顺序.html":{"url":"8.技巧/9.利用create_before_destroy调整资源update执行的顺序.html","title":"利用 create_before_destroy 调整资源 Update 的执行顺序","keywords":"","body":"利用 create_before_destroy 调整资源 Update 的执行顺序 最近处理了一个问题,有人写了这样一段代码: provider \"azurerm\" { features { resource_group { prevent_deletion_if_contains_resources = false } } } resource \"azurerm_resource_group\" \"rg\" { location = \"eastus\" name = \"example\" } locals { environments = toset([\"one\", \"two\", \"three\"]) } resource \"azurerm_public_ip\" \"lb\" { for_each = local.environments name = \"frontend-lb-${each.key}\" location = azurerm_resource_group.rg.location resource_group_name = azurerm_resource_group.rg.name allocation_method = \"Static\" ip_version = \"IPv4\" sku = \"Standard\" zones = [1, 2, 3] } resource \"azurerm_lb\" \"this\" { name = \"azurelb\" location = azurerm_resource_group.rg.location resource_group_name = azurerm_resource_group.rg.name sku = \"Standard\" dynamic \"frontend_ip_configuration\" { for_each = local.environments content { name = frontend_ip_configuration.key public_ip_address_id = azurerm_public_ip.lb[frontend_ip_configuration.key].id } } } 当他从 local.environments 中删除一个元素,然后执行 terraform apply 时,他遇到了下面的问题: │ Error: deleting Public Ip Address: (Name \"azurelb\" / Resource Group \"example\"): network.PublicIPAddressesClient#Delete: Failure sending request: StatusCode=400 -- Original Error: Code=\"PublicIPAddressCannotBeDeleted\" Message=\"Public IP address /subscriptions/subscription-id/resourceGroups/resource-group/providers/Microsoft.Network/publicIPAddresses/one can not be deleted since it is still allocated to resource /subscriptions/subscription-id/resourceGroups/resource-group/providers/Microsoft.Network/loadBalancers/azurelb/frontendIPConfigurations/one. In order to delete the public IP, disassociate/detach the Public IP address from the resource. To learn how to do this, see aka.ms/deletepublicip.\" Details=[] 这其实是一个还挺常见的问题,azurerm_lb.this 依赖于 azurerm_public_ip.lb[index],正确的变更顺序应该是先更新 azurerm_lb.this,再删除 azurerm_public_ip.lb 的成员,但是 Terraform 默认的执行顺序会首先尝试执行删除操作,这时因为 ip 仍然被 LoadBalancer 使用着,所以会引发一个错误。 解决方法是给 azurerm_public.lb 添加一个 create_before_destroy: resource \"azurerm_public_ip\" \"lb\" { for_each = local.environments name = \"frontend-lb-${each.key}\" location = azurerm_resource_group.rg.location resource_group_name = azurerm_resource_group.rg.name allocation_method = \"Static\" ip_version = \"IPv4\" sku = \"Standard\" zones = [1, 2, 3] lifecycle { create_before_destroy = true } } create_before_destroy 名字里虽然看起来是与 Create 有关,实际上它也会将 Update 与 Create 放在一起调整,声明该参数后实际上是将 azurerm_public_ip.lb 的 Delete 推迟到执行 Update 之后再执行了,该问题得解。 "},"8.技巧/10.terraform与自动化.html":{"url":"8.技巧/10.terraform与自动化.html","title":"Terraform 与自动化","keywords":"","body":"Terraform 与自动化 如果团队使用 Terraform 作为变更管理和部署管道的核心工具,可能需要以某种自动化方式编排 Terraform 的运行,以确保运行之间的一致性,并提供其他有趣的功能,例如与版本控制系统钩子的集成。 Terraform 的自动化可以有多种形式,并且程度不同。一些团队继续在本地运行 Terraform,但使用脚本代码来准备一致的工作目录来运行 Terraform,而另一些团队则完全在 Jenkins 等 CI 工具中运行 Terraform。 本篇涵盖了实现此类自动化时应考虑的一些事项,既确保 Terraform 的安全运行,又适应 Terraform 工作流程中当前需要仔细注意的一些限制。它假设 Terraform 将在非交互式环境中运行,无法在终端提示输入。对于脚本代码来说不一定如此,但在 CI 工具中运行时通常如此。 自动化的 Terraform 命令行工作流 在自动化流程中运行 Terraform 时,重点通常是核心的 plan/apply 循环。那么,使用 Terraform 命令行的流程大体如下: 初始化 Terraform 工作目录。 针对当前代码,为产生变化的资源计算变更计划 让操作员审查计划,以确保其可接受 应用计划描述的更改。 步骤 1、2 和 4 可以使用熟悉的 Terraform 命令以及一些附加选项来执行: terraform init -input=false 初始化工作目录。 terraform plan -out=tfplan -input=false 创建计划文件并将其保存到名为 tfplan 的本地文件。 terraform apply -input=false tfplan 执行存储在文件 tfplan 中的计划。 -input=false 参数命令 Terraform 不应尝试提示输入,而是要求配置文件或命令行提供所有必要的值。因此,可能需要在 terraform plan 上使用 -var 和 -var-file 参数来指定所有传统上在交互式使用下手动输入的变量值。 强烈建议使用支持远程状态的 Backend,因为 Terraform 可以自动将持久保存状态,后续运行可以在找回并更新状态。选择支持状态锁定的 Backend 还将提供针对 Terraform 并发运行的竞争安全保障。 控制自动化中的 Terraform 输出 默认情况下,一些 Terraform 命令会提示用户下一步可能执行的步骤,通常包括具体的下一步要运行的命令。 自动化工具通常会封装正在运行的命令的具体细节,只提供抽象的步骤,这时 Terraform 输出的此类消息反而令人困惑,且无法操作,如果它们无意中鼓励用户完全绕过自动化工具,则可能还是有害的。 当环境变量 TF_IN_AUTOMATION 设置为任何非空值时,Terraform 会对其输出进行一些细微调整,不再强调要运行的特定命令。所做的具体更改会随着时间的推移而变化,但一般来说,Terraform 发现该变量时,会认为存在某种包装了 Terraform 的应用程序,它们会帮助用户进行下一步。 为了降低复杂性,该功能主要针对 Terraform 主要的工作流程命令实现。无论该变量为何值如何,其他辅助命令仍可能会产生命令行建议。 在不同的机器上运行 plan 和 apply 在 CI 工具中运行时,可能很难或无法确保 plan 和 apply 命令在同一台计算机上的同一目录中运行,并且所有的文件都保持相同。 在不同的机器上运行 plan 和 apply 需要一些额外的步骤来确保正确的行为。稳健的策略如下: plan 完成后,保存整个工作目录,包括 init 期间创建的 .terraform 子目录,并将其保存在 apply 阶段可以访问得到的位置。常见的选择是作为所选 CI 工具中的“Build Artifact”。 在运行 apply 之前,获取上一步中创建的存档并将其解压到相同的绝对路径。这会重新创建 plan 后出现的所有内容,避免在 plan 步骤期间创建本地文件的奇怪问题。 Terraform 目前为此类自动化系统设置了一些必须满足的前提条件: 保存的计划文件可以包含子模块的绝对路径以及代码中引用的其他数据文件。因此,必须确保在相同的绝对路径中还原保存的工作目录。这通常是通过在某种隔离中运行 Terraform 来实现的,例如可以控制文件系统布局的 Docker 容器。 Terraform 假设该计划将在与其创建时相同的操作系统和 CPU 架构上 Apply。例如,这意味着无法在 Windows 计算机上创建计划,然后将其应用到 Linux 服务器上。 Terraform 期望用于生成计划的 Provider 程序插件在应用计划时可用且相同,以确保正确执行计划。如果在创建和应用计划之间升级 Terraform 或任何插件,将会产生错误。 Terraform 无法自动检测用于创建计划的凭据是否授予对用于应用该计划的相同资源的访问权限。如果对每个凭据使用不同的凭据(例如,使用只读凭据生成计划),那么确保两套凭据在它们所属的相应服务的帐户中保持一致非常重要。 警告:计划文件包含代码的完整副本、计划所要应用的状态数据以及传递给 terraform plan 的所有变量。如果其中包含任意敏感数据,则包含计划文件的存档工作目录应受到相应保护。对于 Provider 使用的身份验证凭据,建议尽可能使用环境变量,因为这些变量不会被包含在计划中或由 Terraform 以任何其他方式保存到磁盘。 交互式审批计划 自动化 Terraform 工作流程的另一个挑战是需要在计划和应用之间进行交互式审批步骤。为了稳健地实现这一点,重要的是要确保一次只能有一个计划未完成,或者两个步骤相互连接,以便批准计划将足够的信息传递到应用步骤,以确保应用正确的计划,与后来也存在的一些计划相反。 不同的 CI 工具以不同的方式解决这个问题,但通常这是通过构建管道功能实现的,其中可以按顺序应用不同的步骤,后面的步骤可以访问前面步骤生成的数据。 推荐的方法是一次只允许一个计划处于未应用状态。应用计划时,针对同一状态生成的任何其他现有计划都会失效,因为现在必须相对于新状态重新计算它们。通过强制计划按顺序获得批准(或驳回),可以避免这种情况。 自动批准计划 虽然强烈建议对生产环境应用计划前要进行人工审查,但有时在预生产或开发环境中部署时需要采取更自动化的方法。 如果不需要手动批准,可以使用更简单的命令序列: terraform init -input=false terraform apply -input=false -auto-approve apply 命令的这个变体隐式地创建一个新计划,然后立即应用它。 -auto-approve 选项告诉 Terraform 在应用计划之前不需要对计划进行交互式批准。 警告:当 Terraform 有权对基础设施进行破坏性更改时,始终建议对计划进行人工审查,除非在发生意外更改时可以容忍停机。仅对非关键基础设施使用自动批准。 用 terraform plan 命令测试 Pull Requests terraform plan 可以用来对 Terraform 配置的有效性进行某些有限的验证,而不影响实际的基础设施。尽管 plan 命令会更新状态以匹配实际资源,从而确保准确的计划,但更新后的状态文件并不会持久保存,因此可以安全地使用该命令来生成仅为了帮助代码审查而创建的“一次性”计划。 实现此类工作流程时,可以在相关代码审查工具(例如,Github Pull Request)中使用钩子,为每个正在审查的新提交触发 CI 工具。在这种情况下,Terraform 可以按如下方式运行: terraform plan -input=false 与在“主”工作流程中一样,可能需要根据需要设置 -var 或 -var-file。在这种情况下不使用 -out 选项,因为为代码审查目的而生成的计划永远不会被应用。相反,一旦合并更改,就可以从主版本控制分支创建并应用新计划。 警告:请注意,通过输入变量或环境变量将敏感秘密数据传递给 Terraform 将使任何可以提交 PR 的人都可以看到,因此在开源项目或任何私人项目上必须谨慎使用此流程部分,或所有贡献者不应能够直接访问凭据等。 多环境部署 Terraform 的自动化通常会被用来创建数个相同的配置,比如为预发布、测试或多租户基础设施等场景生成平行的环境。这种情况下的自动化可以帮助确保为每个环境使用正确的设置,并且在每次操作之前正确配置工作目录。 多环境编排最有趣的两个命令是 terraform init 和 terraform workspace。前者可以与其他参数一起使用,以针对环境之间的差异定制 Backend 配置,而后者可用于在单个 Backend 中存储的相同配置的多个状态之间安全切换。 如果可能,建议对所有环境使用单一后端配置,并使用 terraform workspace 命令在工作空间之间切换: terraform init -input=false terraform workspace select QA 在此使用模型中,Backend 存储中使用固定的命名方案,以允许多个状态共存,而无需任何进一步的配置。 或者,自动化工具可以将环境变量 TF_WORKSPACE 设置为现有工作空间名称,这将覆盖使用 terraform workspace select 命令所做的任何选择。建议仅在非交互式使用中使用此环境变量,因为在本地 shell 环境中,很容易忘记设置该变量并将变更应用到错误的状态。 在一些更复杂的情况下,不可能跨环境共享相同的 Backend 配置。例如,环境可能运行在完全独立的不同帐户的服务里,因此需要对 Backend 本身使用不同的凭据或端点。在这种情况下,可以通过 terraform init 的 -backend-config 选项覆盖后端配置设置。 预先安装的插件 在默认使用情况下,terraform init 会自动下载并安装代码中使用的所有 Provider 程序的插件,并将它们放置在 .terraform 目录的子目录中。这为简单的情况提供了更简单的工作流程,并允许每段代码可以使用不同版本的插件。 在自动化环境中,可能需要禁用此行为,而是提供一组已安装在运行 Terraform 的系统上的固定插件。这样就避免了每次执行时重新下载插件的开销,并允许系统管理员控制可以使用哪些插件。 要使用此机制,请在系统上的某个位置创建一个 Terraform 运行时会将插件可执行文件放入其中的目录。已发布的插件文件可在 releases.hashicorp.com 上下载。请务必下载适合目标操作系统和体系结构的文件。 提取必要的插件后,新插件目录的内容将如下所示: $ ls -lah /usr/lib/custom-terraform-plugins -rwxrwxr-x 1 user user 84M Jun 13 15:13 terraform-provider-aws-v1.0.0-x3 -rwxrwxr-x 1 user user 84M Jun 13 15:15 terraform-provider-rundeck-v2.3.0-x3 -rwxrwxr-x 1 user user 84M Jun 13 15:15 terraform-provider-mysql-v1.2.0-x3 文件名末尾的版本信息很重要,它使得 Terraform 可以推断每个插件的版本号。可以安装同一 Provider 程序插件的多个版本,Terraform 将使用与 Terraform 代码中的 Provider 程序版本约束相匹配的最新版本。 填充此目录后,可以使用 terraform init 的 -plugin-dir 选项跳过常规的自动下载和插件发现行为: terraform init -input=false -plugin-dir=/usr/lib/custom-terraform-plugins 使用该组参数时,只有给定目录中的插件可以被使用。这使系统管理员可以对执行环境进行强力控制,但另一方面,它会阻止使用尚未安装到本地插件目录中的较新插件版本。哪种方法更合适将取决于每个组织内的特定情况。 还可以通过创建 terraform.d/plugins/OS_ARCH 目录与配置一起提前安装插件,在自动下载其他插件之前将搜索该目录。 -get-plugins=false 参数可禁止 Terraform 自动下载其他插件。 "},"8.技巧/11.CloudPosse最佳实践.html":{"url":"8.技巧/11.CloudPosse最佳实践.html","title":"来自CloudPosse的Terraform最佳实践","keywords":"","body":"CloudPosse 的 Terraform 最佳实践 CloudPosse是一家在美国的提供 DevOps 咨询顾问服务的公司,他们的口号是: DevOps Accelerator for Startups Own your infrastructure. We build it. You drive it. 我注意到这家公司是因为他们向社区贡献了大量高品质的 Terraform Aws Module,其代码之工整与完善与其他社区模块完全不在一个水平。经过研究找到了他们总结的一篇 Terraform 的最佳实践,在此翻译为中文以飨读者。 不过话说在前,该文档可能编写的时间较早,目前我并不完全同意列出的所有规则,某几条可能因为 Terraform 自身的发展已经显得不再那么绝对,并且由于 CloudPosse 是一家咨询公司,难免有以开源代码为自己打广告招揽顾客的动机,所以其代码在某些程度上甚至有些“过于工程化”。这只是 CloudPosse 的一家之言,读者还是需要结合自身实际情况来分析。 语言 使用带缩进的 HEREDOC 语法 使用 (与之相对的是 ,没有 -)来确保内联代码可以与项目中其他部分的代码共同锁进。注意,EOT 可以用任意大写字符串代替(例如 CONFIG_FILE) block { value = 不要使用 HEREDOC 语法来编写 JSON、YAML 或者 Aws IAM 策略代码 Terraform 对于这几种格式有更好的方法来格式化: 对于 JSON,请在 locals 块中使用 jsonencode 函数 对于 YAML,请在 locals 块中使用 yamlencode 函数 对于 Aws IAM 策略代码,请使用名为 aws_iam_policy_document 的 Datasource。 不要编写过长的 HEREDOC 代码 如果内容很长,请把配置内容转移到一个独立的文件中,然后使用名为 template_file 的 Datasource 读取。 使用 Terraform Linting Linting 工具确保代码格式的统一,提升代码质量,并且可以检查一些常见的语法错误。 在提交所有代码之前运行 terraform fmt。创建一个 pre-commit 钩子来自动化调用该命令。 使用合适的数据类型 在 Terraform 中使用合适的数据类型可以更容易地验证输入参数以及编写相关文档。 使用 null 而不是空字符串(\"\") 当要表达true/false 时,使用bool 类型而不是 string 或者 number 使用 string 存储文本 不要滥用 object 类型,为 object 类型编写校验规则以及文档比较困难 使用 CIDR 相关函数来计算网络地址空间 这可以降低其他合作者贡献代码的门槛并降低人为疏失的空间。CloudPosse 编写了一些相关的 Terraform 模块来帮助用户计算子网的地址空间: terraform-aws-dynamic-subnets terraform-aws-multi-az-subnets terraform-aws-named-subnets 更多相关信息请阅读官方文档。 在所有项目仓库中使用 .editorconfig 文件规定一致的空格风格 所有主流的 IDE 都有插件支持 .editorconfig 文件,使得我们可以强制实施一致的空格风格 我们推荐针对特定语言或项目规定空格风格。 CloudPosse 使用的标准 .editorconfig 文件内容如下: # Override for Makefile [{Makefile, makefile, GNUmakefile, Makefile.*}] indent_style = tab indent_size = 4 [*.yaml] intent_style = space indent_size = 2 [*.sh] indent_style = tab indent_size = 4 [*.{tf,tfvars,tpl}] indent_size = 2 indent_style = space 锁定所使用的 Provider 的最低版本 Terraform 的 Provider 保持着持续的更新,在编写模块时很难确定模块代码是否能够在更早的 Provider 版本上正确工作,并且这种测试旧版本 Provider 的努力通常不值得。由此我们希望对外通告我们所测试过的最低的 Provider 版本。 当然未来的 Provider 版本也有可能引入破坏性更新,但在 CloudPosse 的实践中这并不太会发生。另外,对于 CloudPosse 发布的模块代码,他们无法测试限制了 Provider 最高版本后会引入什么样的问题。所以对于 CloudPosse 发布的模块,他们只会锁定 Provider 的最低版本。 在用户编写的根模块中,用户可以锁定 Provider 的最高版本,或是锁定使用指定版本的 Provider 来避免发生意外。这是一个在稳定性与易用性之间进行的权衡,用户必须按照自身情况进行决策。 使用 locals 改善可读性 使用 locals 使得代码更加声明式以及可维护。与其在某些 Terraform 资源代码参数中使用复杂的表达式,不如将该表达式封装成一个 local,然后在声明资源时引用它。 输入参数 在合适的时候使用上游模块或 Provider 的变量名 当编写一个接受输入参数的模块时,确保参数名与上游模块的 output 名一致以防止误解以及二义性。 变量名使用小写字母,以下划线作为分隔符 避免使用其他语言的语法规则,例如驼峰命名。所有变量的命名要统一,要遵循 HashiCorp 命名规范。 使用肯定的变量名以避免双重否定 所有用来代表打开或者关闭某项设置的输入变量名都应该以 ...._enabled 结尾(例如:encryption_enabled)。变量的默认值可以是 false 也可以是 true。 使用特性开关来配置打开或关闭某项功能 所有模块都应该通过特性开关来设置打开或是关闭某项功能。所有特性开关都应该以 _enabled 结尾,数据类型必须为 bool。 所有输入参数都应该声明 description 值 所有输入参数都需要声明 descripition 值。当该参数源自于另一个上游 Provider(例如:terraform-aws-provider),请完整照搬上游 Provider 文档中的字句。 在合适的时候定义合理的默认值 模块应尽量开箱即用。默认值应尽可能确保整体配置的安全性(例如:encryption_enabled 为 true)。 所有传递机密的输入变量不可定义默认值 所有用来传递机密的输入变量都不应该定义默认值,这可以确保 Terraform 可以校验用户的输入。唯一的例外是该机密是可选的,并且在用户输入 null 或是 \"\"(空字符串)时会自动生成一个。 输出值 所有的输出值都应该声明 description 值 所有输输出值都需要声明 descripition 值。尽可能照搬上游 Provider 中对应参数的 description。避免在输出值的 description 中简单地重复输入参数名。 使用合规的蛇式命名法命名输出参数 避免使用其他语言的语法规则,例如驼峰命名。所有输出值的命名要统一,要遵循 HashiCorp 命名规范。 永远不要输出机密信息 模块永远不应该输出机密,相应的,机密信息应该被写入安全的存储,例如 AWS Secrets Manager,AWS SSM Parameter Store(由 KMS 加密),或是 S3 存储中(由 KMS 加密)。CloudPosse 更倾向于使用 AWS SSM Parameter Store。写入 SSM 的信息可以很容易地被其他 Terraform 模块读取,或是其他诸如 chamber 的命令行工具使用。 我们在编写根模块时严格执行该规定,因为这些机密信息很容易被泄漏到 CI/CD 流水线中。对于那些内嵌在其他模块中的子模块,我们的规定不会那么严格。 与其输出机密,我们可以输出一段文本指示机密存储的位置,例如创建 RDS 数据时,我们把管理员密码保存在路径为 /rds/master_password 的 SSM 存储中。我们可能还需要另一个输出值来保存该机密存储的密钥,这样其他需要读取管理员密码的程序可以使用该密钥读取到密码。 命名要对称 CloudPosse 喜欢确保 Terraform 输出值的名字尽可能与上游资源或模块对称,可以添加前缀。这能减少代码中的混乱或是二义性,同时提升一致性。下面是一个反面例子。输出值的名字应该是 user_secret_access_key,这是因为它的取值来自于另一个模块的输出值 secret_access_key,模块名含有 user,所以可以添加前缀 user_,最终处于一致性,输出值的名称应该是 user_secret_access_key 状态 使用远程状态存储 使用 Terraform 创建用以存储远程状态的存储桶 这需要一个两阶段步骤来实施,第一阶段我们使用本地状态文件来创建一个远程存储桶。第二阶段我们启用远程状态存储配置(例如使用 s3 {})并且将本地状态导入远程存储(添加相关配置文件后简单执行 terraform init 即可自动导入)。CloudPosse 推荐这种策略因为它可以使用最好的工具来简化工作以及使用一致的工具。 可以使用 terraform-aws-tfstate-backend 模块来简化创建状态存储桶的工作。 使用支持状态锁的远程存储 CloudPosse 推荐使用 S3 存储状态的同时使用 DynamoDB 提供状态锁控制。 提示:使用 terraform-aws-tfstate-backend 模块可以轻松完成这一目标。 官方文档 https://www.terraform.io/docs/backends/types/s3.html 严格锁定使用的 Terraform CLI 版本 Terraform 状态文件有时在不同版本的 CLI 之间是不兼容的。CloudPosse 推荐开发人员通过容器使用 Terraform CLI 以锁定使用的版本。 提示:使用geodesic(一款 CloudPosse 出品的开源工具)管理所有的 Terraform 交互。 使用 Terraform CLI 设置状态存储参数 为提升根模块在不同账号之间的可重用性,应避免硬编码状态存储参数。相应的,应使用 Terraform CLI 设置当前使用的参数。 不要锁定使用的 Terraform 的最高版本 terraform { required_version = \">= 0.12.26\" backend \"s3\" {} } Terraform 大多数情况下都能保持向前兼容,所以我们希望可以在未来使用新版本来测试现有的模块代码。所以请不要限制使用的 Terraform 的最高版本,例如 ~>0.12.26或是>=0.13, 这样都会阻止未来的 Terraform 新版本运行当前模块。应使用>=限制最低版本即可。 使用加密的 S3 存储桶并开启版本控制、加密存储以及严格的 IAM 访问控制策略 CloudPosse 不推荐用一个存储桶存储不同栈的状态文件,这有可能会导致状态文件被错误覆盖或是泄漏。注意,状态文件中包含了所有输出值的内容。尽可能确保 100% 的物理隔离(每个 Stage 拥有独立的存储桶,独立的账号) 提示:使用 terraform-aws-tfstate-backend 可以轻松地为每一个 Stage 创建独立的状态存储桶。 启用状态存储桶的版本控制 启用状态存储桶的静态加密存储(Encryption at Rest) 使用 .gitignore 排除 Terraform 状态文件、状态文件备份、Terraform 文件夹等 .terraform .terraform.tfstate.lock.info *.tfstate *.tfstate.backup 使用 .dockerignore 文件排除 Terraform 状态文件 样例: **/.terraform* 命名规范 使用一致的编程命名规范 所有资源名(比如:在 AWS 上创建的那些资源)必须遵循一个一致的命名规范,这点之所以重要的原因是模块经常被用以组装成其他模块。强制实施一致的命名规范可以降低模块与其他模块创建的资源在名字上发生冲突的概率。 为了确保一致性,CloudPosse 要求所有模块都要调用 terraform-null-label 模块。使用该模块,用户可以通过修改参数的顺序或是分隔符的方式来修改生成资源名的方式。虽然该模块的使用不是必须的,但事实证明该机制是一种解决命名冲突非常有效的方法。 DNS 基础设施 使用独立的 DNS Zone 不要在不同 Stage 和环境之间混用 DNS Zone。 为每个 AWS 账户委派一个独享 DNS 区域 区分品牌域名与服务发现域名的管理 服务发现域名是指用来提供服务发现服务的域名。终端用户极少会直接使用这种域名。应该只有一个服务发现域名,但不同环境下使用各自独立的 DNS Zone 来管理该域名的解析。 品牌域名是终端用户用来访问服务所使用的域名,这些域名由产品、市场和业务场景来决定。可以有多个不同的品牌域名指向同一个服务发现域名。品牌域名的架构并非是服务发现域名架构的镜像。 模块的设计 小而精的模块 CloudPosse 认为一个模块应该把一件事做到最好。为了达到这个目标,简单地把 Terraform 资源打包成模块化代码并没有什么用。为这些资源设计一种专门的使用场景则更为有用。(译者理解:每一个模块都应有一个特定的使用场景,例如创建 Subnet ,CloudPosse 就编写了 dynamic-subnet、named-subnets、multi-az-subnets 三种模块 可组合的模块 模块应编写成易于与其他模块进行组合,这是 CloudPosse 用以提升规模经济性以及停止重新发明模式轮子大的方法。 使用输入参数 模块应尽可能使用输入参数。应避免定义类型为 object 的输入参数,因为该类型很难编写文档(译者理解:很难为 object 类型输入参数编写详尽的 description 提示调用者)。当然,这并不是一个绝对的禁令,有时候使用 object 的确更加合适。需要注意类似 terraform-docs 这样的工具能否生成有意义的文档。 模块的使用 使用 Terraform registry 格式锁定指定的模块版本 Terraform 模块的 source 参数有多种表达方式。CloudPosse 的传统是使用 Terraform registry 语法显式锁定一个确切的版本: source = \"cloudposse/label/null\" version = \"0.22.0\" 显式锁定确切版本的原因是因为,使用例如 >=0.22.0 这样可升级的版本约束可能会引入破坏性变更。对基础设施的所有变更都应该是由最终用户来控制和审查的,不能在部署变更时盲目信任变更结果。 (译者理解:由于模块版本变化带来的变更并非模块调用者所触发的,亦非模块调用者所能控制的,故应避免这种情况的发生。) "},"9.后记/1.第一版后记.html":{"url":"9.后记/1.第一版后记.html","title":"第一版后记","keywords":"","body":"后记 这本教程的编写花的时间比我想象的多,一方面是因为个人能力有限,另一方面不少内容是翻译自官方文档。由于官方文档类似于使用手册,不需要考虑阅读者循序渐进学习的顺序,所以花了不少时间斟酌和调整章节的顺序,以期能够使得初学者在按照顺序阅读时不至于会被尚未学习的概念所困扰。 在我编写本作时,其实已经在家里歇了很长一段时间了,有各方面的原因,其中有一部分原因是因为我在近些年以来越来越强烈地感觉到中国的软件行业,尤其是互联网产业出现了问题。当我们越来越依赖软件时,软件已经成为了像电力、自来水、道路桥梁这样关系到国计民生的重要基础设施,但如果我们仔细地观察许多的互联网公司的工作方式,经常会看到许多团队仍然停留在手工作坊的水准;这种问题并不只是小公司有,其实互联网巨头也有,我们不能把巨头看作是水平均匀分布的利维坦,其实巨头内部更像是部落联盟,不同的事业部,不同的产品线,不同的团队,可能理解、眼界和水平相差非常巨大。 大公司有一种精细化分工的倾向,总希望把软件生产过程细分成一个高度协同的流水线,但流水线却是根据技能来分隔成不同的团队的。这样的好处是每个功能团队都可以独立发展自己的技术栈、工具链,都可以独立安排各自的优先等级,另外把技能切分的非常细也有助于用流程和文档把技能沉淀下来,降低特定人员离职所引发的风险,但另一方面这种做法人为地在团队间建立了高高的部门墙,产品经理会说“我不管你怎么实现”;开发人员会不管测试团队如何测试,写出可测性很低的代码,同时视公认的开发最佳实践单元测试为测试的事从而跳过编写单元测试;测试人员很委屈,既然你不配合我做测试,那我索性也不专注于测试具体应用了,测试团队转型变成了测试工具开发团队,反正平台和工具我做出来了,开发不用那就不是我的事了;运维团队往往是最后才拿到要部署的应用,应用上线各种各样的故障运维团队能做的事不多,也只能疲于奔命。 我看了很多“团队”用这种低效的方式协同,造成了许多的问题,互相扯皮指责;管理者为了进度,无意挽起裤脚管下地去了解一线真正的现状,而是制定一些简单的量化指标,比如故障级别、故障率、故障时长、加班时长、BUG 率等等,希望简单粗暴的 996 就可以解决问题。我认为这一切都是非常荒唐的,就像盲人摸象,没有一个盲人认为自己有能力,或者有义务先去了解整个大象的全貌,再下定论。这种精细化分工和简单量化的方式是错误的。 我之所以对 Terraform 和 Pulumi 这样的基础设施即代码工具着迷,原因是我认为它们所指出的方向非常正确。在传统的软件开发,或者互联网开发中,开发多多少少会鄙视基础设施和运维,总觉得写代码的能力强,部署应用、配置服务器的那是比较低级的活;开发甚至内部都存在鄙视链,多少面试都是必问算法、原理、底层,考操作系统,考多线程高并发,考数据库锁,结果实际生产中写出来的代码连个像样的单元测试都没法编写,因为可测性太差了,就这样的水准,还会上知乎询问“业务代码写多了只会 CRUD 怎么办?”;写代码时完全不管安全性、高可用这些问题,因为这些问题都是“低级”的运维和基础设施团队该做的。这种认知是有毒的,是错误的,正确的方式应该是,围绕着一个产品,所有相关的人,不论他的技能特点如何,他们都是一个团队,他们的认知应该是统一的,信息是全面的,我们需要一种新的,覆盖全生命周期的软件研发方法论,技术人员和非技术人员要学会用一种统一的全面的视角来思考问题,眼界绝对不能局限于自己擅长的技能点上。基础设施即代码技术是一个很好的实践,它能使得开发、运维以及测试,对基础设施这件事有完全一样的认知,更好的是,它使得在传统软件开发中一些很好的实践,比如设计模式、SOLID 原则等,都可以应用到基础设施领域,我们甚至可以使用单元测试驱动开发的方式来编写基础设施代码。摸象的盲人们虽然还是盲的,但是这一次,盲人们至少认识到,长鼻子和大耳朵都是长在同一个象头上的。这算是我在技术方面的“天下大同”的梦想吧。 在本书的编写过程中,得到了许多师长朋友的鼓励和支持,尤其是我的太太,对于我这样闲居在家的人并没有给予什么压力,使我可以自由地思考和进行学习探索。得妻如此,夫复何求?我太太是一个特别热爱学习特别独立的优秀女性,我为她感到由衷的自豪。 愿中国互联网能够有一个更好的发展,愿中国技术人员不用再被强迫进行无意义的 996。 "},"9.后记/2.第二版后记.html":{"url":"9.后记/2.第二版后记.html","title":"第二版后记","keywords":"","body":"第二版后记 这是拖了很久才更新完的第二版后记。逝者如斯夫,距离最早编写这本 Terraform 中文教程已经过去了快四年,这四年对很多人来说都是难忘的四年,光是新冠疫情就不知道改变了多少人的命运。 HashiCorp 在这四年里上市了,然后又被 IBM 收购了,第一版的时候 Terraform 版本号应该还是在 0.13 还是 0.15 吧,具体忘记了,现在是 1.9.2。这四年里 Terraform 也变了很多,我也变了很多。 可以明显感受到美国目前的高利率对科技业的影响,很多我觉得很有趣的科技企业要么倒闭了,要么被收购了,HashiCorp 也是其中一例。中美之间神仙打架,凡间的蝼蚁却要时刻提防不知道哪里飞来的二向箔,世间之事,真是残酷。 这四年里国内 Terraform 社群有所发展,但是我能力有限,精力有限,我没能很好地引导发展出一个全国范围的 Terraform 社区,我的精力无法允许我胜任这种维护工作,而 HashiCorp 自己也是泥菩萨过江,对中文互联网敬而远之。 中美之间的对抗已经直接影响到了我,今年我做了也给很重大的决定,拒绝迁徙到澳大利亚,因为我爱的人他们全部都在这里,离开他们,我就不再是我,我就一无是处。但愿历史能证明我今天的选择是正确的。 今年最愉快的事情是收了一个可爱的干女儿,我自己膝下无子,不知不觉间由于各种因缘际会,自己发觉时竟已将她视如己出,但又很担心对方是否会拒绝,或是觉得突兀,幸而得到了孩子与其母亲的认可。短短数周时间,我已然开始用一个父亲的视角来思考很多问题,过去的很多事浮上心头,顿感自己远不如父母,我的父母把我照顾的太好太好,远比我能为女儿做的要好太多,我真是一个混蛋的儿子。幸而双亲尚在,还有机会弥补。此生惟愿家人身体健康,平安幸福。 谨以本书第二版献给我的女儿 —— Su,我是一个迟到的父亲,愿意陪你长大,希望我们能够真的情同父女。 "}}