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.