As they say, all good things must come to an end. This is the third and last post in our deploying LAMP on Azure with Terraform series, where we’ll create cache, databases and DDoS protection in Azure with Terraform.
In today’s article, we are closing up with the compute sections of the deployment, namely our scale-out set, Redis cache, MySQL service, and also our storage component and finally our DDoS protection.
On this journey, we have been introduced to the vagaries of Azure and some of the architectural differences between the AWS and Azure and the differing methods of deploying in a different eco-system.
We have been introduced to the Vault approle method of authentication and learned about issues with Azure AD and replication timing that forced us to add a delay time into our authentication path, also introducing us to the Terraform external code block. We have also been exposed to new terraform command-line options to import pre-existing infrastructure into a virgin terraform.tfstate file and investigated that state to see if our import had been successful.
So as we still have a lot to cover so let’s get started.
How simple Terraform plans make hybrid and multi-cloud a reality: an introduction
Deploying a LAMP Stack with Terraform – AMIs, network & security
Deploying a LAMP Stack with Terraform – Databases & Webservers
How to create resilient Terraform code
Deploying and configuring HashiCorp Vault to service Terraform
Deploying a LAMP Stack with Terraform Modules
How to configure Azure for Terraform
Terraform prepare migrate to azure
Building out an enhanced lamp stack on azure part 1
Building out an enhanced lamp stack on azure part 2
Building out an enhanced lamp stack on azure part 3
Create your Virtual Machine Scale Set with Terraform
The core of the compute layer was laid down with the image we created way back with Packer in our first post in this shot series. Now we get to use that image in anger. The next two code blocks discuss the build-out and setting out the rule sets for scaling up and down of the scale out-group.
The azurerm_linux_virtual_machine_scale_set together with its sister block azure_windows_linux_virtual_machine_scale_set replaces the old azurerm_virutal_machine_scale_set to define the architecture of the scale out set.
The first three options are self-explanatory and are common across a large majority of code blocks. The next option sku defines the size of the virtual machine to be deployed from the image file. One thing to note here is that there may be costs depending upon the option chosen here. For a good review of Azure VM sizes read this article. We have chosen a standard B1S machine this has 1GB of Ram and 4GB of temporary storage and a single CPU. It offers a good balance of performance to price. Perfect for a scaling group were machines will be scaled up and down as needed.
The Instances option dictates the number of machines to be initially spun up, in our case we are starting with a single App server.
resource "azurerm_linux_virtual_machine_scale_set" "main" { name = "AmazicVirtualMachineVMSS" resource_group_name = azurerm_resource_group.main.name location = azurerm_resource_group.main.location sku = "Standard_B1s" instances = 1 admin_username = "azureuser"
this section points to the SSH key you created and uploaded with your packer build
admin_ssh_key { username = "azureuser" public_key = file("~/.ssh/id_rsa.pub") }
You have two options for your source disk, you can use an image from Azure’s managed image repository, in this case, you would use the following code block, here the imaged is a default ubuntu 16-04-LTS server
source_image_reference { publisher = "Canonical" offer = "UbuntuServer" sku = "16.04-LTS" version = "latest" }
However, as we have created a custom image, which has all our applications and configuration already configured; we will need to use the alternative option that of the source_image_id value. The value will be derived from a data code block which we will describe later.
source_image_id = data.azurerm_image.image.id
This section defines the disc format, note that the storage_account_type has cost implications. For a fuller explanation of the SKU types available for storage read this Azure article.
os_disk { storage_account_type = "Standard_LRS" caching = "ReadWrite" }
The next block is assigning the network interface and setting which network the machines will be attached too, as this is the only NIC attached to the machine we could have omitted the primary = true, however, it is better to call it as it removed ambiguity.
network_interface { name = "AmazicInternal" primary = true
this block is a part of the network_interface block and defines what subnet the NIC is assigned too, in our case as this is the LAMP stacks application server we are giving the subnet_id the value of the compute subnet.
ip_configuration { name = "internal" primary = true subnet_id = azurerm_subnet.compute.id } } }
If you remember we earlier stated that the source_image_id was set by a later block of code, we now we get to explain that code. As explained in an earlier article, the data source is used to access information about a pre-existing resource, in our case, we will be looking at two resources the first is our resource group. In this case AmazicDevResourceGroup.
data "azurerm_resource_group" "image" { name = "AmazicDevResourceGroup" }
The second is our image data source, here we pass our image id as seen with the option resource_group_name = data.azurerm_resource_group.image.name
data "azurerm_image" "image" { name = "Amazic-Image" resource_group_name = data.azurerm_resource_group.image.name }
Now that we have set up the autoscale group, let’s look at the rules and logic that needs to be assigned to the auto scale-out group to manage scale up and down of the machines based on utilization.
Setup the autoscale settings.
So how do we introduce the logic into the auto-scale group to manage the scaling up and down of the compute resources? This is where the azurerm_monitor_autoscal_setting comes into play. This next section will run through the scaling, and trigger logic that creates greater resilience in your environment by automatically grown compute resource as needed and then scaling it back when utilization lowers thereby helping to keep your costs under control.
The first three options should be familiar by now. The fourth option target_linux_id set the target of this particular block to the relevant auto-scale group.
resource "azurerm_monitor_autoscale_setting" "main" { name = "amazicVirtualMachineAutoscaler" resource_group_name = azurerm_resource_group.main.name location = azurerm_resource_group.main.location target_resource_id = azurerm_linux_virtual_machine_scale_set.main.id
the next section defines the default scaling if there are no metrics to available for evaluation, and minimum size a scale set will shrink too and maximum size it will grow too.
profile { name = "defaultProfile" capacity { default = 2 minimum = 2 maximum = 10 }
Now we move on the main course for this particular block. The rules, for simplicity we have set a single increase trigger rule to grow and a corresponding decrease trigger rule to control shrinkage. You can get quite granular with your rule sets as you can declare a maximum of ten per code block.
If we walk through the rule we can see that it is set to monitor the Percentage of CPU utilization, and will trigger a scaling event when CPU is either greater or less than fifty percent utilization the important controls on this are found in the time_grain and time_window options . in these options you can see the values of PT1M and PT5M respectively. These relate to the time polling periods. So PT1M means that polling granularity is set to once per minute, this is fed into the time_window metric that takes five time_grain metrics and finds the average, if the result is greater or less than 50% utilization over those 5 minutes the respective rule will be triggered.
rule { metric_trigger { metric_name = "Percentage CPU" metric_resource_id = azurerm_linux_virtual_machine_scale_set.main.id time_grain = "PT1M" statistic = "Average" time_window = "PT5M" time_aggregation = "Average" operator = "GreaterThan" threshold = 50 }
The next section is the scale_action block, this sets the actions that will occur after the relevant rule trigger action has been activated. In our case, depending on whether the trigger is above or below 50% utilization there will be either an increase or decrease in the number of LAMP applications servers deployed. For completeness, we have shown both the scale-up and scale-down rules to show the slight differences in format. The interesting option is cooldown this sets the time that azure will wait before allowing another activation of the scale_action block.
NOTE the format of the timings is based on ISO-8601 if you want to know more about this read this article.
This section relates to the scale-up
scale_action { direction = "Increase" type = "ChangeCount" value = 1 #var.vmssautoscaleroutincrease cooldown = "PT1M" } rule { metric_trigger { metric_name = "Percentage CPU" metric_resource_id = azurerm_linux_virtual_machine_scale_set.main.id time_grain = "PT1M" statistic = "Average" time_window = "PT5M" time_aggregation = "Average" operator = "LessThan" threshold = 30 }
This section relates to the scale down
scale_action { direction = "Decrease" type = "ChangeCount" value = 1 cooldown = "PT1M" } rule { metric_trigger { metric_name = "Percentage CPU" metric_resource_id = azurerm_linux_virtual_machine_scale_set.main.id time_grain = "PT1M" statistic = "Average" time_window = "PT5M" time_aggregation = "Average" operator = "LessThan" threshold = 30 } } } }
Now that we have fully defined the infrastructure concerning the LAMP Stack servers we shall move on to he supporting architecture.
Deploy the Azure Cache for Redis with Terraform
Redis is an in-memory datastore, used in our case as a caching store to increase the performance of the backend MySQL databases.
Each Redis cache store must be globally unique across your environment, hence we introduce another new entity to you the resource random_id this creates a random hexadecimal number, the size of which is bound by the byte_length option, in our case two bytes in length.
resource "random_id" "redis" { byte_length = 2 }
The azurerm_redis_cache block introduces the count argument this is a good way to make a decision-based logic choice. This effectively states that if the value of var.redissku is “Standard” then execute this block, if you refer back to this post and review the variables you can see that the input value of var.redissku was set to Standard so only the first block is executed.
resource "azurerm_redis_cache" "standard" { count = $(var.redisSku == "Standard" ? 1 : 0) #var.redissku
The name value has to be globally unique, so we have created a derived input value from the two variables, var.prefix (set to Amazic) and random.id.redis.hex (which is the derived value of random_id.redis.
The next three options in the Standard block relate to the size and licensing of Redis on Azure,
The size of a Standard or Basic Redis cache can range from 256mb through to 53GB in size with 0 being the lowest size (256mb) and 6 being 53GB – the 1 stated as the input value of the capacity option means that the cache is 1GB in size.
Family defines the SKU pricing group to use, note that choosing the incorrect value here has cost implications, the final option is the sku_name, this has three possible options Basic, Standard, and Premium. The sku_name and family identifier need to match for example if you have chosen P for premium as your family then your sku_name must be Premium.
name = "AmazicRedis-6F" #“($(var.prefix)Redis$(random.id.redis.hex)” location = azurerm_resource_group.main.location resource_group_name = azurerm_resource_group.main.name capacity = 1 family = "C" sku_name = "Standard" } resource "azurerm_redis_cache" "premium" { count = "Standard" == "Premium" ? 1 : 0 #var.redissku name = "AmazicRedis-4C" #“($(var.prefix)Redis$(random.id.redis.hex)” location = azurerm_resource_group.main.location resource_group_name = azurerm_resource_group.main.name capacity = 1 family = "P" sku_name = "Premium" shard_count = 0 subnet_id = azurerm_subnet.redis.id }
That is the discussion surrounding Redis finished, now we will look at setting up the MySQL database.
Deploy the Azure Database for MySQL.
They say that history repeats itself, and here is our random number generator again. So that we can use the generated value to provide uniqueness to our MySQL database.
resource "random_id" "mysql" { byte_length = 2 }
Azure has a constraint with MySQL database naming, and that is that all names need to be in lower case. How can we guarantee this, well say hello to the lower function, this will take the output of what is contained within its brackets and rewrite the string to be all lower case. So taking, for example, our input $(var.prefix)mysql$(random_id.mysql.hex) this will according to your variable file resolve to Amazicmysql4F (remember the hexadecimal is randomly generated and can be between 01 and FF), this will fail the verification of machine name when deploying a MySQL database server. so by wrapping it all up with the lower functions we change the output to read amazicmysql4f which is acceptable to azure.
The next input of interest is the sku_name, again the thing to note here is that there are cost implications in the choice for a full overview of options read this article. The remaining options are self-explanatory. We have 50GB of storage and 7 days of backups to fail back on if all goes wrong.
resource "azurerm_mysql_server" "main" { name = "amazic-db" #lower($(var.prefix)mysql$(random_id.mysql.hex)) location = azurerm_resource_group.main.location resource_group_name = azurerm_resource_group.main.name sku_name = "GP_Gen5_2" storage_mb = 51200 backup_retention_days = 7 administrator_login = "azuremysqluser" administrator_login_password = "MyP@55w0r!d" version = 5.7 ssl_enforcement_enabled = true }
This section defines the actual MySQL Instance. There is nothing too out of place here. Charset and collation relate to encoding tables and Unicode characters.
resource "azurerm_mysql_database" "main" { name = "amazic-db-instance1" resource_group_name = azurerm_resource_group.main.name server_name = azurerm_mysql_server.main.name charset = "utf8" collation = "utf8_general_ci" }
This section is included because we chose the sku_name that selected a Memory-optimized their for the database, we would have needed to included it too if the tier was GeneralPurpose.
resource "azurerm_mysql_virtual_network_rule" "main" { name = "mysql-vnet-rule" resource_group_name = azurerm_resource_group.main.name server_name = azurerm_mysql_server.main.name subnet_id = azurerm_subnet.mysql.id }
The final section set up a firewall rule, you can use this to set the IP address range that as access, however as we have not allowed direct external access to the environment we have set this very loose. Remember that access to this MySQL database was set with the service endpoint setting in the relevant subnet.
resource "azurerm_mysql_firewall_rule" "rule1" { name = "mysql-firewall-rule1" resource_group_name = azurerm_resource_group.main.name server_name = azurerm_mysql_server.main.name start_ip_address = "0.0.0.0" end_ip_address = "255.255.255.255" }
Next, we need to create the storage for which holds the SQL database.
Create the Azure Storage account and container.
First, we meet our old friend the random_id generator.
resource "random_id" "storage" { byte_length = 2 }
Next, we create the storage, account_tier has cost implications, the options are Standard and Premium. The account_replication_type relates to the data replication, read the this for a deeper understanding of the options
resource "azurerm_storage_account" "main" { name = "amazicstrg74" #lower("${var.prefix}STRG${random_id.storage.hex}") resource_group_name = azurerm_resource_group.main.name location = azurerm_resource_group.main.location account_tier = "standard" account_replication_type = "LRS"
locks down the storage subnet
network_rules { default_action = "Deny" virtual_network_subnet_ids = [azurerm_subnet.storage.id] } }
Next and finally we will discuss the DDoS option that has been added to this environment.
Create the Azure DDoS Protection rule set.
DDoS is a form of attack meant to overwhelm the ingress address of a service. It is an enhanced denial of service, in that it is distributed; thus making it much more scalable and dangerous.
This next block of code puts in place the ability to protect against this form of attack by creating a dedicated DDOS subnet and for a good overview of the backend architecture behind Azure’s DDoS protection read this.
resource "azurerm_network_ddos_protection_plan" "main" { name = "AmazicDdosPlan" location = azurerm_resource_group.main.location resource_group_name = azurerm_resource_group.main.name }
As a part of the creation of the DDoS protection plan, we create another virtual network with the same range as the core CIDR.
resource "azurerm_virtual_network" "update11" { name = "DDOSVNET" location = azurerm_resource_group.main.location resource_group_name = azurerm_resource_group.main.name address_space = ["10.0.0.0/16"]
next, we set up the subnets that are to be protected and we introduce a new term the depends_on option, this means that the protection plan will only be created and be activated when the main virtual network is present.
ddos_protection_plan { id = azurerm_network_ddos_protection_plan.main.id enable = true } subnet { name = "amazicCompute" address_prefix = "10.0.0.0/24" } subnet { name = "amazicRedisSubnet" address_prefix = "10.0.1.0/24" } subnet { name = "amazicMySQLSubnet" address_prefix = "10.0.2.0/24" } subnet { name = "amazicSTRGSubnet" address_prefix = "10.0.3.0/24" } depends_on = [azurerm_virtual_network.main] }
Summary
We have worked to create cache, databases and DDoS protection in Azure with Terraform, and have finally completed our long journey. We’ve learned how to deploy an advanced, cloud-native LAMP application stack in Azure.
Azure is a completely different beast from AWS and some things that appeared easy in AWS have been problematic in Azure. Building an image disk for the auto-scaling group is impossible to create with Terraform due to the necessary API calls not being exposed, resulting in us having to utilize a Packer build plan. Our breadth of Terraform knowledge has grown with the introduction of new commands and also our vault knowledge.