github.com/rstandt/terraform@v0.12.32-0.20230710220336-b1063613405c/website/docs/modules/composition.html.markdown (about) 1 --- 2 layout: "docs" 3 page_title: "Module Composition" 4 sidebar_current: "docs-modules-composition" 5 description: |- 6 Module composition allows infrastructure to be described from modular 7 building blocks. 8 --- 9 10 # Module Composition 11 12 -> This section is written for **Terraform v0.12 or later**. The general patterns 13 described in this section _do_ apply to earlier versions, but the examples 14 shown are using v0.12-only syntax and features. For general information 15 on module usage in prior versions, see 16 [the v0.11 documentation about modules](/docs/configuration-0-11/modules.html). 17 18 In a simple Terraform configuration with only one root module, we create a 19 flat set of resources and use Terraform's expression syntax to describe the 20 relationships between these resources: 21 22 ```hcl 23 resource "aws_vpc" "example" { 24 cidr_block = "10.1.0.0/16" 25 } 26 27 resource "aws_subnet" "example" { 28 vpc_id = aws_vpc.example.id 29 30 availability_zone = "us-west-2b" 31 cidr_block = cidrsubnet(aws_vpc.example.cidr_block, 4, 1) 32 } 33 ``` 34 35 When we introduce `module` blocks, our configuration becomes heirarchical 36 rather than flat: each module contains its own set of resources, and possibly 37 its own child modules, which can potentially create a deep, complex tree of 38 resource configurations. 39 40 However, in most cases we strongly recommend keeping the module tree flat, 41 with only one level of child modules, and use a technique similar to the 42 above of using expressions to describe the relationships between the modules: 43 44 ```hcl 45 module "network" { 46 source = "./modules/aws-network" 47 48 base_cidr_block = "10.0.0.0/8" 49 } 50 51 module "consul_cluster" { 52 source = "./modules/aws-consul-cluster" 53 54 vpc_id = module.network.vpc_id 55 subnet_ids = module.network.subnet_ids 56 } 57 ``` 58 59 We call this flat style of module usage _module composition_, because it 60 takes multiple [composable](https://en.wikipedia.org/wiki/Composability) 61 building-block modules and assembles them together to produce a larger system. 62 Instead of a module _embedding_ its dependencies, creating and managing its 63 own copy, the module _receives_ its dependencies from the root module, which 64 can therefore connect the same modules in different ways to produce different 65 results. 66 67 The rest of this page discusses some more specific composition patterns that 68 may be useful when describing larger systems with Terraform. 69 70 ## Dependency Inversion 71 72 In the example above, we saw a `consul_cluster` module that presumably describes 73 a cluster of [HashiCorp Consul](https://www.consul.io/) servers running in 74 an AWS VPC network, and thus it requires as arguments the identifiers of both 75 the VPC itself and of the subnets within that VPC. 76 77 An alternative design would be to have the `consul_cluster` module describe 78 its _own_ network resources, but if we did that then it would be hard for 79 the Consul cluster to coexist with other infrastructure in the same network, 80 and so where possible we prefer to keep modules relatively small and pass in 81 their dependencies. 82 83 This [dependency inversion](https://en.wikipedia.org/wiki/Dependency_inversion_principle) 84 approach also improves flexibility for future 85 refactoring, because the `consul_cluster` module doesn't know or care how 86 those identifiers are obtained by the calling module. A future refactor may 87 separate the network creation into its own configuration, and thus we may 88 pass those values into the module from data sources instead: 89 90 ```hcl 91 data "aws_vpc" "main" { 92 tags = { 93 Environment = "production" 94 } 95 } 96 97 data "aws_subnet_ids" "main" { 98 vpc_id = data.aws_vpc.main.id 99 } 100 101 module "consul_cluster" { 102 source = "./modules/aws-consul-cluster" 103 104 vpc_id = data.aws_vpc.main.id 105 subnet_ids = data.aws_subnet_ids.main.ids 106 } 107 ``` 108 109 ### Conditional Creation of Objects 110 111 In situations where the same module is used across multiple environments, 112 it's common to see that some necessary object already exists in some 113 environments but needs to be created in other environments. 114 115 For example, this can arise in development environment scenarios: for cost 116 reasons, certain infrastructure may be shared across multiple development 117 environments, while in production the infrastructure is unique and managed 118 directly by the production configuration. 119 120 Rather than trying to write a module that itself tries to detect whether something 121 exists and create it if not, we recommend applying the dependency inversion 122 approach: making the module accept the object it needs as an argument, via 123 an input variable. 124 125 For example, consider a situation where a Terraform module deploys compute 126 instances based on a disk image, and in some environments there is a 127 specialized disk image available while other environments share a common 128 base disk image. Rather than having the module itself handle both of these 129 scenarios, we can instead declare an input variable for an object representing 130 the disk image. Using AWS EC2 as an example, we might declare a common subtype 131 of the `aws_ami` resource type and data source schemas: 132 133 ```hcl 134 variable "ami" { 135 type = object({ 136 # Declare an object using only the subset of attributes the module 137 # needs. Terraform will allow any object that has at least these 138 # attributes. 139 id = string 140 architecture = string 141 }) 142 } 143 ``` 144 145 The caller of this module can now itself directly represent whether this is 146 an AMI to be created inline or an AMI to be retrieved from elsewhere: 147 148 ```hcl 149 # In situations where the AMI will be directly managed: 150 151 resource "aws_ami_copy" "example" { 152 name = "local-copy-of-ami" 153 source_ami_id = "ami-abc123" 154 source_ami_region = "eu-west-1" 155 } 156 157 module "example" { 158 source = "./modules/example" 159 160 ami = aws_ami_copy.example 161 } 162 ``` 163 164 ```hcl 165 # Or, in situations where the AMI already exists: 166 167 data "aws_ami" "example" { 168 owner = "9999933333" 169 170 tags = { 171 application = "example-app" 172 environment = "dev" 173 } 174 } 175 176 module "example" { 177 source = "./modules/example" 178 179 ami = data.aws_ami.example 180 } 181 ``` 182 183 This is consistent with Terraform's declarative style: rather than creating 184 modules with complex conditional branches, we directly describe what 185 should already exist and what we want Terraform to manage itself. 186 187 By following this pattern, we can be explicit about in which situations we 188 expect the AMI to already be present and which we don't. A future reader 189 of the configuration can then directly understand what it is intending to do 190 without first needing to inspect the state of the remote system. 191 192 In the above example, the object to be created or read is simple enough to 193 be given inline as a single resource, but we can also compose together multiple 194 modules as described elsewhere on this page in situations where the 195 dependencies themselves are complicated enough to benefit from abstractions. 196 197 ## Multi-cloud Abstractions 198 199 Terraform itself intentionally does not attempt to abstract over similar 200 services offered by different vendors, because we want to expose the full 201 functionality in each offering and yet unifying multiple offerings behind a 202 single interface will tend to require a "lowest common denominator" approach. 203 204 However, through composition of Terraform modules it is possible to create 205 your own lightweight multi-cloud abstractions by making your own tradeoffs 206 about which platform features are important to you. 207 208 Opportunities for such abstractions arise in any situation where multiple 209 vendors implement the same concept, protocol, or open standard. For example, 210 the basic capabilities of the domain name system are common across all vendors, 211 and although some vendors differentiate themselves with unique features such 212 as geolocation and smart load balancing, you may conclude that in your use-case 213 you are willing to eschew those features in return for creating modules that 214 abstract the common DNS concepts across multiple vendors: 215 216 ```hcl 217 module "webserver" { 218 source = "./modules/webserver" 219 } 220 221 locals { 222 fixed_recordsets = [ 223 { 224 name = "www" 225 type = "CNAME" 226 ttl = 3600 227 records = [ 228 "webserver01", 229 "webserver02", 230 "webserver03", 231 ] 232 }, 233 ] 234 server_recordsets = [ 235 for i, addr in module.webserver.public_ip_addrs : { 236 name = format("webserver%02d", i) 237 type = "A" 238 records = [addr] 239 } 240 ] 241 } 242 243 module "dns_records" { 244 source = "./modules/route53-dns-records" 245 246 route53_zone_id = var.route53_zone_id 247 recordsets = concat(local.fixed_recordsets, local.server_recordsets) 248 } 249 ``` 250 251 In the above example, we've created a lightweight abstraction in the form of 252 a "recordset" object. This contains the attributes that describe the general 253 idea of a DNS recordset that should be mappable onto any DNS provider. 254 255 We then instantiate one specific _implementation_ of that abstraction as a 256 module, in this case deploying our recordsets to Amazon Route53. 257 258 If we later wanted to switch to a different DNS provider, we'd need only to 259 replace the `dns_records` module with a new implementation targeting that 260 provider, and all of the configuration that _produces_ the recordset 261 definitions can remain unchanged. 262 263 We can create lightweight abstractions like these by defining Terraform object 264 types representing the concepts involved and then using these object types 265 for module input variables. In this case, all of our "DNS records" 266 implementations would have the following variable declared: 267 268 ```hcl 269 variable "recordsets" { 270 type = list(object({ 271 name = string 272 type = string 273 ttl = number 274 records = list(string) 275 })) 276 } 277 ``` 278 279 While DNS serves as a simple example, there are many more opportunities to 280 exploit common elements across vendors. A more complex example is Kubernetes, 281 where there are now many different vendors offering hosted Kubernetes clusters 282 and even more ways to run Kubernetes yourself. 283 284 If the common functionality across all of these implementations is sufficient 285 for your needs, you may choose to implement a set of different modules that 286 describe a particular Kubernetes cluster implementation and all have the common 287 trait of exporting the hostname of the cluster as an output value: 288 289 ```hcl 290 output "hostname" { 291 value = azurerm_kubernetes_cluster.main.fqdn 292 } 293 ``` 294 295 You can then write _other_ modules that expect only a Kubernetes cluster 296 hostname as input and use them interchangeably with any of your Kubernetes 297 cluster modules: 298 299 ```hcl 300 module "k8s_cluster" { 301 source = "modules/azurerm-k8s-cluster" 302 303 # (Azure-specific configuration arguments) 304 } 305 306 module "monitoring_tools" { 307 source = "modules/monitoring_tools" 308 309 cluster_hostname = module.k8s_cluster.hostname 310 } 311 ``` 312 313 ## Data-only Modules 314 315 Most modules contain `resource` blocks and thus describe infrastructure to be 316 created and managed. It may sometimes be useful to write modules that do not 317 describe any new infrastructure at all, but merely retrieve information about 318 existing infrastructure that was created elsewhere using 319 [data sources](/docs/configuration/data-sources.html). 320 321 As with conventional modules, we suggest using this technique only when the 322 module raises the level of abstraction in some way, in this case by 323 encapsulating exactly how the data is retrieved. 324 325 A common use of this technique is when a system has been decomposed into several 326 subsystem configurations but there is certain infrastructure that is shared 327 across all of the subsystems, such as a common IP network. In this situation, 328 we might write a shared module called `join-network-aws` which can be called 329 by any configuration that needs information about the shared network when 330 deployed in AWS: 331 332 ``` 333 module "network" { 334 source = "./modules/join-network-aws" 335 336 environment = "production" 337 } 338 339 module "k8s_cluster" { 340 source = "./modules/aws-k8s-cluster" 341 342 subnet_ids = module.network.aws_subnet_ids 343 } 344 ``` 345 346 The `network` module itself could retrieve this data in a number of different 347 ways: it could query the AWS API directly using 348 [`aws_vpc`](/docs/providers/aws/d/vpc.html) 349 and 350 [`aws_subnet_ids`](/docs/providers/aws/d/subnet_ids.html) 351 data sources, or it could read saved information from a Consul cluster using 352 [`consul_keys`](https://www.terraform.io/docs/providers/consul/d/keys.html), 353 or it might read the outputs directly from the state of the configuration that 354 manages the network using 355 [`terraform_remote_state`](https://www.terraform.io/docs/providers/terraform/d/remote_state.html). 356 357 The key benefit of this approach is that the source of this information can 358 change over time without updating every configuration that depends on it. 359 Furthermore, if you design your data-only module with a similar set of outputs 360 as a corresponding management module, you can swap between the two relatively 361 easily when refactoring.