Provisioning Immutable Infrastructure in GCP with Terraform

Infrastructure as code (IaC) is the practice of declaratively deploying infrastructure components (network, virtual machines, load balancers, etc.) using the same DevOps principles you use to develop applications. The same code always generates the same binary: Similarly, the same IaC code always provisions the same infrastructure components—no matter what environment you run it in. Used in conjunction with continuous delivery, IaC is a key DevOps practice.

IaC evolved to avoid environmental drift between different releases. Prior to this, teams maintained the configuration of each environment separately. This caused drifts in the environments over time, leading to inconsistencies among different environments. In turn, these inconsistencies caused issues in deployments and added to the workload of running and maintaining the environments.

IaC tools are both idempotent and declarative, which allows them to provision consistent and immutable infrastructure components, ensuring repeatable deployments and no environmental drifts. Idempotence means that no matter which state you start in, you'll always end up in the same final state. A declarative approach means that you define what the environment should look like, and the IaC tools take care of how to do it. The declarative code is usually written in well-documented code formats, such as JSON or YAML, and follows the same release cycle as application code. If you need to make a change to the infrastructure, you should change the code, rather than the infrastructure components directly. 

Terraform by HashiCorp is an IaC tool that allows you to write your infrastructure configuration in human-readable and declarative files. Terraform’s plugin-based architecture helps you manage infrastructure on multiple cloud platforms, and its state management allows you to track changes throughout the deployments.

In this article, you will learn how to provision immutable infrastructure using Terraform on Google Cloud Platform (GCP).

Setup

Let's deploy a Cloud Run instance using Terraform. But before getting started, you need to set up gcloud and terraform on your system.

To install gcloud, follow the instructions in the official documentation

Once installed, authenticate using the command below, then continue to follow the instructions so that Terraform can use the credentials to authenticate.

$ gcloud auth application-default login

Next follow the instructions to install Terraform based on your platform. This demo uses Terraform v1.0.8.

Note: Terraform uses Hashicorp Configuration Language (HCL) to declare the infrastructure components. Terraform source code is written in files ending with a .tf extension.

Deploying a Cloud Run Instance on Terraform

Now you’re ready to get started.

First, create a folder for all of your Terraform source code files. Let’s call it gcp-terraform-demo.

Create a plugins.tf file, where you will configure Terraform’s GCP plugin.

provider "google" {
  project = "YOUR-PROJECT-ID"
  region  = "europe-west3"
  version = "3.65.0"
}

This plugin implements Terraform resources to provision infrastructure components in GCP. You need to configure the Project ID of your GCP project to get started.

Next, create a main.tf file, in which you will write resources that you want to provision. Start by provisioning a Google Cloud Storage bucket to store the state of your Terraform code.

Add the following resource to the main.tf file:

resource "google_storage_bucket" "state-bucket" {
  name     = "terraform-state-bucket-demo"
  location = "EU"
  versioning {
    enabled = true
  }
}

Note: The name of a bucket should be globally unique, so you won’t be able to use the same bucket name in your demo.

Now you’re ready to run your code.

First, initialize your code by running the following command:

$ terraform init

This will initialize the backend for state and download the plugins that are defined in the plugins.tf file.

You will see the following log lines:

Initializing the backend...

Initializing provider plugins...
- Checking for available provider plugins...
- Downloading plugin for provider "google" (hashicorp/google) 3.65.0...

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.

Next, run the plan command. The plan command works by finding the current state of the infrastructure and figuring out what changes need to be applied to reach the desired state.

$ terraform plan -out planfile

You will then see the output below, which contains how many resources need to change. In this case, you only declared one bucket that doesn’t exist, so you see 1 to add.

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.


------------------------------------------------------------------------

An execution plan has been generated and is shown below.
Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:

  # google_storage_bucket.state-bucket will be created
  + resource "google_storage_bucket" "state-bucket" {
      + bucket_policy_only          = (known after apply)
      + force_destroy               = false
      + id                          = (known after apply)
      + location                    = "EU"
      + name                        = "terraform-state-bucket-demo"
      + project                     = (known after apply)
      + self_link                   = (known after apply)
      + storage_class               = "STANDARD"
      + uniform_bucket_level_access = (known after apply)
      + url                         = (known after apply)

      + versioning {
          + enabled = true
        }
    }

Plan: 1 to add, 0 to change, 0 to destroy.

------------------------------------------------------------------------

This plan was saved to: planfile

To perform exactly these actions, run the following command to apply:
    terraform apply "planfile"

You also stored this plan information in a file called planfile by providing the -out switch in the plan command. In the next step, this will allow you to apply the exact changes that your plan command showed you.

Apply these changes to provision your bucket.

$ terraform apply planfile

You’ll see the following output:

google_storage_bucket.state-bucket: Creating...
google_storage_bucket.state-bucket: Creation complete after 2s [id=terraform-state-bucket-demo]

Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

The state of your infrastructure has been saved to the path
below. This state is required to modify and destroy your
infrastructure, so keep it safe. To inspect the complete state
use the `terraform show` command.

State path: terraform.tfstate

Now go to your Google Cloud Console. You’ll see your bucket there.

Once the changes are applied, go to the gcp-terraform-demo folder. There, you’ll see a terraform.tfstate file that was created by applying the changes. This file stores the current state of your infrastructure components, but it’s on your local machine.

If someone else tried to run this code from another machine, they wouldn’t have access to this state, so they’d try to provision the same bucket again. This would fail because a bucket with the same name already exists. You can see the problem...

That’s where Terraform’s remote state comes into play. If you store the state in a GCS bucket (which everyone in your team can access, no matter from where you run your Terraform code), you’ll always start from the same state.

Add a state.tf file with the following configuration:

terraform {
  backend "gcs" {
    bucket = "terraform-state-bucket-demo"
    prefix = "demo/state"
  }
}

Make sure the bucket name is the same as the one you provisioned in the main.tf file.

Initialize the module again using the terraform init command. This time, you’ll be asked if a local state already exists. You’ll also be asked if you wish to copy the local state to the remote backend. Type yes

The rest of the initialization will be the same as when you ran the Terraform init command to initialize the module.

Now go to the Google Cloud Console and navigate to the bucket you created. You’ll see that the terraform.tfstate file is copied from local machine to the bucket.

If you check your Terraform code into the SCM repository, anyone with access can clone and run it. The benefit of remote state is that it can be shared, so you can collaborate with your team.

With remote state out of the way, let’s move towards provisioning Cloud Run resources.

Add the following resources block to the main.tf file:

resource "google_cloud_run_service" "nginx-service" {
  name     = "nginx-service"
  location = "europe-west3"
  template {
    spec {
      containers {
        image = "marketplace.gcr.io/google/nginx1"
        ports {
          container_port = 80
        }
      }
    }
  }

  traffic {
    percent         = 100
    latest_revision = true
  }
}

resource "google_cloud_run_service_iam_member" "member" {
  location = google_cloud_run_service.nginx-service.location
  project  = google_cloud_run_service.nginx-service.project
  service  = google_cloud_run_service.nginx-service.name
  role     = "roles/run.invoker"
  member   = "allUsers"
}

Here you are adding two resources types: google_cloud_run_service and google_cloud_run_service_iam_member:

  • google_cloud_run_service: Defines the Cloud Run service with the usual parameters, such as name, location, container image, and ports.
  • google_cloud_run_service_iam_member: Adds a member to Cloud Run Identity and Access Management (IAM). This particular resource allows allUsers (everyone on the Internet) to access your NGINX Cloud Run service.

Note: This IAM configuration is sufficient for demo purposes. For production, make sure to narrow down access and only grant access to services which needs it.

Once you follow the plan and apply steps, you should see your nginx-service in your Cloud Run dashboard. Navigate to the Details page to get the URL of the service, then try to access it. You should see the NGINX default home page.

Maintaining Different Environments

Now that you’ve seen how to provision infrastructure with Terraform, let’s look at how you can manage different environments using the same code base by using variables. Variables are placeholders for which you can provide the values at runtime. You can employ variables to use the same code with different variable values and provision infrastructure components in different environments.

Here, you’ll modify your code to use two variables: project and environment. You’ll then provision two different sets of Cloud Run services using the same code, but passing in different values.

First, add another file, called variables.tf, with the following content:

variable "environment" {
  type = string
}

variable "project" {
  type = string
}

Now update the google_cloud_run_service resource in main.tf to use these variables.

resource "google_cloud_run_service" "nginx-service" {
  name = "${var.environment}-nginx-service"
  location = "europe-west3"
  template {
    spec {
      containers {
        image = "marketplace.gcr.io/google/nginx1"
        ports {
          container_port = 80
        }
      }
    }
  }
  traffic {
    percent = 100
    latest_revision = true
  }
}

You should also update the plugins.tf to use the project variable.

provider "google" {
  project = var.project
  region  = "europe-west3"
  version = "3.65.0"
}

Now run the plan command. It should prompt you to get the values for these variables. You can also do this via CLI or a variable file.

var.environment
  Enter a value: development

var.project
  Enter a value: coder-society

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.

google_storage_bucket.state-bucket: Refreshing state... [id=terraform-state-bucket-demo]

------------------------------------------------------------------------

An execution plan has been generated and is shown below.
Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:

  # google_cloud_run_service.nginx-service will be created
  + resource "google_cloud_run_service" "nginx-service" {
      + autogenerate_revision_name = false
      + id                         = (known after apply)
      + location                   = "europe-west3"
      + name                       = "development-nginx-service"
      + project                    = (known after apply)
      + status                     = (known after apply)

      + metadata {
          + annotations      = (known after apply)
          + generation       = (known after apply)
          + labels           = (known after apply)
          + namespace        = (known after apply)
          + resource_version = (known after apply)
          + self_link        = (known after apply)
          + uid              = (known after apply)
        }

      + template {
          + metadata {
              + annotations      = (known after apply)
              + generation       = (known after apply)
              + labels           = (known after apply)
              + name             = (known after apply)
              + namespace        = (known after apply)
              + resource_version = (known after apply)
              + self_link        = (known after apply)
              + uid              = (known after apply)
            }

          + spec {
              + container_concurrency = (known after apply)
              + serving_state         = (known after apply)
              + timeout_seconds       = (known after apply)

              + containers {
                  + image = "marketplace.gcr.io/google/nginx1"

                  + ports {
                      + container_port = 80
                    }

                  + resources {
                      + limits   = (known after apply)
                      + requests = (known after apply)
                    }
                }
            }
        }

      + traffic {
          + latest_revision = true
          + percent         = 100
        }
    }

  # google_cloud_run_service_iam_member.member will be created
  + resource "google_cloud_run_service_iam_member" "member" {
      + etag     = (known after apply)
      + id       = (known after apply)
      + location = "europe-west3"
      + member   = "allUsers"
      + project  = (known after apply)
      + role     = "roles/run.invoker"
      + service  = "development-nginx-service"
    }

Plan: 2 to add, 0 to change, 0 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.

Next, apply the changes. This will force Terraform to create/update/delete some of the resources to achieve the desired state.

Now you can run the same code with different variable values for environment and project to provision the same resources in different environments.

Once you are done with an environment, you can tear it down just as easily. Simply issue the following command:

$ terraform destroy

You will be asked for all the variable values. When prompted, type yes. It’s important to note, however, that your state bucket won’t get deleted, and you’ll see an error message, as shown below.

Error: Error trying to delete bucket terraform-state-bucket-demo containing objects without `force_destroy` set to true

This is by design, since you don’t want someone to accidentally destroy the state bucket (that’s why you didn’t set force_destroy to true). All the other resources (Cloud Run service and IAM) will be successfully destroyed.

Versioning Your Code

Now that your infrastructure components are defined via code, you’ll want to apply versioning practices to them—just like you do with software code. You can store your Terraform files in GIT and follow the same branching and versioning strategy that you used for your application code.

As shown earlier, if you add resources or modify the existing resources (in the code), Terraform will automatically detect the changes and do what’s needed to ensure that the final state of the infrastructure looks exactly the same as what was declared in the code.

Importing Existing Resources into Terraform

Let’s say that you already have a lot of resources manually deployed in your Google Cloud. Now, you want to use Terraform to provision any future resources and you want to follow IaC principles. In such cases, you can import your existing cloud resources (which were deployed previously) into Terraform’s purview. Use tools like Terraformer to create the tf resource files for existing infrastructure resources and import their state.

Don’t Start from Scratch

This tutorial shows you how to start implementing resources from scratch and follows best practices. However, you don't have to start from scratch all the time. Rather, you can use pre-defined Terraform modules that follow Google's best practices, available in the Cloud Foundation Toolkit Github repository. Using these modules will help you get started with Terraform more quickly.

Conclusion

IaC principles allow teams to provision repeatable and immutable infrastructure using DevOps practices. With Terraform and its human-readable configuration language, HCL, you can define the desired state of your infrastructure components and leave the rest up to the tool itself. The Terraform configuration files can be checked in to source control and can follow the same versioning strategy as your application code.

The idempotence of IaC tools allows developers to declaratively define the target state; the tool takes care of the implementation. This enables teams to quickly provision and tear down production-like environments and test their codes early in the development cycle, leading to the delivery of higher quality software.

Contact us