Terraform ist ein Infrastructure-as-Code (IaC) Werkzeug, das vorrangig in DevOps-Teams eingesetzt wird. Durch das von HashiCorp entwickelte Tool können Infrastrukturressourcen in der Cloud oder On-Premises über deklarative Konfigurationsdateien in Form von Code verwaltet werden. Terraform wird verwendet, um eine konsistente und reproduzierbare Bereitstellung von Infrastrukturressourcen zu ermöglichen.
In diesem Artikel erfahren Sie Schritt für Schritt, wie Sie das Terraform Backend in Microsoft Azure initial konfigurieren und es dadurch mit Terraform verwalten können. Dies ist die Basis, um gemeinsam in Teams (z. B. via git) sicher mit Terraform State-Files in der Azure Cloud zu arbeiten. Gleichzeitig dient dies als standardisierter Ausgangspunkt für alle Projekte mit Terraform.
Auf ihrem Arbeitsgerät führen Sie initial die folgenden Installationen durch:
Zusätzlich vergeben Sie im Azure Portal auf die gewünschte Subscription die entsprechenden Berechtigungen, um Ressourcen und Rollen anzulegen (z. B. Owner).
Die exakte Struktur von Terraform Projekten kann je nach Projekt, Kunde und/oder persönlichen Vorlieben variieren. Wichtig ist jedoch eine nachvollziehbare und klare Benennung/Sortierung.
Als Basis für dieses Anleitung dient die folgende Struktur:
In der Praxis kann das im Zusammenspiel mit GitLab wie folgt aussehen:
Für das Projekt können Sie ein Versionskontrollsystem Ihrer Wahl nutzen (z. B. GitHub, GitLab oder AzureDevOps).
Im Verzeichnis azure_backend legen Sie die folgenden Dateien an:
Für alle Deployments nach Terraform Best Practices gilt:
Alle Variablen werden in der variables.tf definiert. Die terraform.tfvars wiederum dient der zentralen Pflege der Werte.
Dies sorgt für eine bessere Übersicht und erleichtert Anpassungen.
data "azurerm_subscription" "primary" {
}
resource "azurerm_resource_group" "main" {
name = var.rgname_tfstate
location = var.location
tags = merge(var.default_tags)
}
resource "random_string" "main" {
length = 4
upper = false
special = false
}
resource "azurerm_storage_account" "backend" {
name = "satfstate${random_string.main.result}"
resource_group_name = azurerm_resource_group.main.name
location = azurerm_resource_group.main.location
account_tier = var.account_tier
account_kind = var.account_kind
access_tier = var.access_tier
account_replication_type = var.account_replication_type
min_tls_version = var.min_tls_version
enable_https_traffic_only = var.enable_https_traffic_only
allow_nested_items_to_be_public = var.allow_nested_items_to_be_public
blob_properties {
last_access_time_enabled = var.last_access_time_enabled
}
network_rules {
default_action = var.default_action
bypass = var.bypass
ip_rules = var.ip_rules
}
lifecycle {
ignore_changes = [
network_rules,
]
}
tags = merge(var.default_tags)
}
resource "azurerm_storage_container" "tfstate" {
for_each = var.prefix_environment
name = "tfstate-${each.value}"
storage_account_name = azurerm_storage_account.backend.name
container_access_type = var.container_access_type
}
resource "azurerm_management_lock" "lock_storage_account_tfstate" {
name = "lock-${azurerm_storage_account.backend.name}"
scope = azurerm_storage_account.backend.id
lock_level = "CanNotDelete"
notes = "Locked because it's needed to store the tfstate file"
depends_on = [azurerm_storage_container.tfstate]
}
output "primary_subscription" {
description = "Displays the current subscription"
value = data.azurerm_subscription.primary.display_name
}
output "backend_storage_account" {
description = "Displays the name of the current storage account used as a backend"
value = azurerm_storage_account.backend.name
}
output "backend_resource_group" {
description = "Displays the name of the resource group, where the backend is located"
value = azurerm_resource_group.main.name
}
output "backend_storage_container_backend" {
description = "Displays the name of the storage container, where the tfstate is located"
value = azurerm_storage_container.tfstate["backend"].name
}
output "backend_storage_container_dev" {
description = "Displays the name of the storage container, where the tfstate is located"
value = azurerm_storage_container.tfstate["development"].name
}
output "backend_storage_container_prod" {
description = "Displays the name of the storage container, where the tfstate is located"
value = azurerm_storage_container.tfstate["production"].name
}
output "managed_by_terraform" {
description = "Displays the name of the tag, which is used to identify resources managed by terraform"
value = var.default_tags["managed_by_terraform"]
}
output "created_by" {
description = "Displays the name of the tag, which is used to identify the creator of a resource"
value = var.default_tags["created_by"]
}
output "responsibility" {
description = "Displays the name of the tag, which is used to identify the responsibility of a resource"
value = var.default_tags["responsibility"]
}
Der Output teilt sich in zwei Bereiche:
terraform {
required_version = ">=1.6.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~>3.80"
}
random = {
source = "hashicorp/random"
version = "~>3.6.0"
}
}
}
# Configure the Microsoft Azure Provider
provider "azurerm" {
features {}
}
# Project Constants
location = "West Europe"
prefix_environment = {
backend = "backend"
development = "dev"
production = "prod"
}
default_tags = {
managed_by_terraform = "True"
created_by = "Joshua Friderici"
responsibility = "Ansgar Dahlen"
}
rgname_tfstate = "rg-terraform-state"
# Storage Account
account_tier = "Standard"
account_kind = "StorageV2"
access_tier = "Cool"
account_replication_type = "RAGRS"
min_tls_version = "TLS1_2"
enable_https_traffic_only = true
allow_nested_items_to_be_public = false
last_access_time_enabled = true
default_action = "Deny"
bypass = ["Logging", "Metrics", "AzureServices"]
ip_rules = ["127.168.0.1"] # TODO: Edit the ip ranges/addresses to get access to the state file
# Container
container_access_type = "private"
# Project Constants
variable "location" {
description = "(Required) The location where the resource should be created."
type = string
}
variable "prefix_environment" {
description = "Used as prefix for resources: Defines the staging environment, where the Azure resources are getting deployed. It can either be dev (development) or prod (production)."
type = any
}
variable "default_tags" {
description = "Map of default tags, used as standard for the whole project."
type = any
}
variable "rgname_tfstate" {
description = "(Required) The Name which should be used for the Resource Group. Changing this forces a new Resource Group to be created."
type = string
}
# Storage Account
variable "account_tier" {
description = "(Required) Defines the Tier to use for this storage account. Valid options are Standard and Premium. For BlockBlobStorage and FileStorage accounts only Premium is valid. Changing this forces a new resource to be created."
type = string
validation {
condition = contains(["Standard", "Premium"], var.account_tier)
error_message = "The value of the account_tier property is invalid. Only Standard and Premium are allowed"
}
}
variable "account_kind" {
description = "(Optional) Defines the Kind of account. Valid options are BlobStorage, BlockBlobStorage, FileStorage, Storage and StorageV2. Defaults to StorageV2."
type = string
validation {
condition = contains(["BlobStorage", "BlockBlobStorage", "FileStorage", "StorageV2"], var.account_kind)
error_message = "The value of the account_kind property is invalid. Only BlobStorage, BlockBlobStorage, FileStorage and StorageV2 are allowed"
}
}
variable "access_tier" {
description = "(Optional) Defines the access tier for BlobStorage, FileStorage and StorageV2 accounts. Valid options are Hot and Cool, defaults to Hot."
type = string
validation {
condition = contains(["Hot", "Cool"], var.access_tier)
error_message = "The value of the access_tier property is invalid. Only Hot and Cool are allowed"
}
}
variable "account_replication_type" {
description = "(Required) Defines the type of replication to use for this storage account. Valid options are LRS, GRS, RAGRS, ZRS, GZRS and RAGZRS."
type = string
validation {
condition = contains(["LRS", "GRS", "RAGRS", "ZRS", "GZRS", "RAGZRS"], var.account_replication_type)
error_message = "The value of the account_replication_type property is invalid. Only LRS, GRS, RAGRS, ZRS, GZRS and RAGZRS are allowed"
}
}
variable "min_tls_version" {
description = "(Optional) The minimum supported TLS version for the storage account. Possible values are TLS1_0, TLS1_1, and TLS1_2. Defaults to TLS1_2 for new storage accounts."
type = string
validation {
condition = contains(["TLS1_2"], var.min_tls_version)
error_message = "The value of the account_replication_type property is invalid. Only TLS1_2 is allowed, because versions 1.0 and 1.1 are no longer supported by Microsoft."
}
}
variable "enable_https_traffic_only" {
description = "(Optional) Boolean flag which forces HTTPS if enabled, see here for more information. Defaults to true."
type = bool
}
variable "allow_nested_items_to_be_public" {
description = "(Optional) Allow or disallow nested items within this Account to opt into being public. Defaults to true."
type = bool
}
variable "last_access_time_enabled" {
description = "(Optional) Is the last access time based tracking enabled? Default to false."
type = bool
}
variable "default_action" {
description = "(Required) Specifies the default action of allow or deny when no other rules match. Valid options are Deny or Allow."
type = string
validation {
condition = contains(["Deny"], var.default_action)
error_message = "The value of the default_action property is invalid. Only Deny is allowed."
}
}
variable "bypass" {
description = "(Optional) Specifies whether traffic is bypassed for Logging/Metrics/AzureServices. Valid options are any combination of Logging, Metrics, AzureServices, or None."
type = list(string)
validation {
condition = length([for b in var.bypass : b if b != "None"]) <= length(["Logging", "Metrics", "AzureServices"])
error_message = "The value of the bypass property is invalid. Only Logging, Metrics, AzureServices, or None are allowed."
}
}
variable "ip_rules" {
description = "(Optional) List of public IP or IP ranges in CIDR Format. Only IPv4 addresses are allowed. /31 CIDRs, /32 CIDRs, and Private IP address ranges (as defined in RFC 1918), are not allowed."
type = list(string)
}
# Container
variable "container_access_type" {
description = "(Optional) The access type for the container. Possible values are blob, container or private. Defaults to private."
type = string
validation {
condition = contains(["private"], var.container_access_type)
error_message = "The value of the container_access_type property is invalid. Only private is allowed."
}
}
Beachten Sie hier vor allem die validation Blöcke, die Teil von einigen Variablen sind. Diese Blöcke erlauben es die Regeln für die Vergabe von Werten festzulegen. Zum Beispiel legt die validation der Variable min_tls_version fest, dass nur die aktuelle Version 1.2 verwendet werden darf, da Microsoft ältere Versionen nicht länger unterstützt.
Um das bereits erwähnte „Henne – Ei“ Problem in Azure zu lösen und den State des Backends zentral und sicher zu speichern, bedarf es folgender Schritte:
terraform {
backend "azurerm" {
resource_group_name = "rg-terraform-state"
storage_account_name = "satfstates8g8"
container_name = "tfstate-backend"
key = "backend.tfstate"
}
}
Herzlichen Glückwunsch!
Damit haben Sie Ihr standardisiertes und durch Terraform gemanagtes Backend in Azure bereitgestellt.
Hinweis:
Sollten Sie Ihre aktuelle IP-Adresse in der terraform.tfvars Datei angegeben haben, dann müssen Sie diese regelmäßig im Portal → Storage Account → Networking aktualisieren bzw. die jeweils aktuelle IP-Adresse hinterlegen. Ansonsten haben Sie keinen Zugriff auf die Terraform State Dateien.
Alternative initiale Bereitstellung:
Bekanntlich führen viele Wege nach Rom. Es ist Ihnen möglich den Storage Account für das Terraform Backend mittels PowerShell oder Azure CLI bereitzustellen. Anschließend können Sie durch Zuhilfenahme des Terraform Import Block, den bestehenden Storage Account importieren. Im Gegensatz zum Terraform-Import-Befehl ist der konfigurationsgesteuerte Import mit Import-Blöcken zuverlässig, funktioniert mit CICD-Pipelines und ermöglicht Ihnen eine Vorschau des Importvorgangs, bevor Sie den State ändern.
Einmal importiert, verwaltet Terraform die Ressource mithilfe Ihrer State-File. Sie können dann die importierte Ressource wie jede andere verwalten, ihre Attribute aktualisieren und sie als Teil eines Standard-Ressourcenlebenszyklus entfernen.
Die Frage nach effektiven Tagging-Methoden stellt sich, insbesondere in großen Projekten, immer wieder.
Hiermit stelle ich eine Möglichkeit vor, gängige Tags zentral im Backend zu verwalten und an weitere Module im Projekt zu übergeben.
Drei Dateien im azure_backend Modul stehen hierbei im Fokus:
variables.tf – Definition der Variable
variable "default_tags" {
description = "Map of default tags, used as standard for the whole project."
type = any
}
terraform.tfvars – Definition der Werte/der Tags mittels Map
default_tags = {
managed_by_terraform = "True"
created_by = "Joshua Friderici"
responsibility = "Ansgar Dahlen"
}
outputs.tf – Ausgabe der Tags, damit diese von weiteren Modulen im Projekt genutzt werden können.
output "managed_by_terraform" {
description = "Displays the name of the tag, which is used to identify resources managed by terraform"
value = var.default_tags["managed_by_terraform"]
}
output "created_by" {
description = "Displays the name of the tag, which is used to identify the creator of a resource"
value = var.default_tags["created_by"]
}
output "responsibility" {
description = "Displays the name of the tag, which is used to identify the responsibility of a resource"
value = var.default_tags["responsibility"]
}
Wie kann mein Modul diese Tags jetzt nutzen?
Nehmen wir an, ich habe ein Root Modul für eine Entwicklungsumgebung, mit dem ich Azure Virtual Desktop Ressourcen deployen möchte.
Das Verzeichnis und dessen Inhalt könnte bspw. wie folgt aussehen:
Damit wir die Tags des Backends verwenden können, verwende ich Terraform data und locals in meinem Modul.
In der data.tf Datei, definiere ich meinen terraform state, um anschließend auf die Outputs des Backends zugreifen zu können:
# Remote state lookup for managing common resource tags
data "terraform_remote_state" "backend" {
backend = "azurerm"
config = {
resource_group_name = "rg-terraform-state"
storage_account_name = "satfstates8g8"
container_name = "tfstate-backend"
key = "backend.tfstate"
}
}
In der locals.tf Datei, definiere ich meine default_tags
locals {
default_tags = {
managed_by_terraform = data.terraform_remote_state.backend.outputs.managed_by_terraform
created_by = data.terraform_remote_state.backend.outputs.created_by
responsibility = data.terraform_remote_state.backend.outputs.responsibility
environment = var.environment
}
}
In den variables.tf Dateien meiner einzelnen Module definiere ich ebenfalls default_tags:
variable "default_tags" {
description = "Map of default tags, used as standard for the whole project."
type = any
}
Die locals und entsprechenden Tags übergebe ich anschließend im Root Modul meines Deployments, siehe default_tags = local.default.tags
Der Vorteil dieser Methode besteht nicht nur darin, dass ich gängige Tags im gesamten Projekt zentral im Backend verwalten kann.
Es ist mir darüber hinaus möglich in der locals.tf des jeweiligen Root Moduls unter default_tags, weitere Tags zu ergänzen, die bspw. speziell auf die Entwicklungsumgebung ausgelegt sind. Hier in dem Beispiel ist es die Variable environment, hinter der sich der Wert development verbirgt. In der Praxis wären jedoch auch weitere umgebungsspezifische Tags möglich, wie z. B. die Kostenstelle.
Im Portal sieht das Ergebnis wie folgt aus:
Die in diesem Blogartikel aufgezeigte Konfiguration des Terraform Backends in Azure stellt die Grundlage für ein kollaboratives Arbeiten mit Terraform als IaC-Tool in Azure dar. Durch die detaillierte Schritt-für-Schritt-Anleitung mit eingearbeiteten Best Practices haben Sie nach dem Durchführen der Konfiguration Ihre Basis für erfolgreiche Terraform-Projekte in Azure geschaffen und sind startklar für Ihr Projekt.