The aim of this project is to use the DevOps tool, Infrastructure-as-Code to deploy a Web Server as a Virtual Machine. We will use the Provisioning tool, Terraform, to provision the Virtual Machine (VM). The server template tool, Packer, will be used to develop a server template that will be used by Terraform. To provision the VM, the infrastructure should have the necessary resources in place in other for the VM to run.
Scalability: The VMs should be in an Availability Set (with a minimum and default of 2 VMs running with the capacity to increase the VM count to 5). Security: The Virtual Network should be in such a way where the VMs in the Network should not be accessible by the Internet but should be accessible within the VMs in the subnet. Efficiency: The Virtual Network should have a Load Balancer to distribute the work load to the VMs available in the Network. Disks: The client has also requested that there are Managed Disks attached for each VM deployed.
At the end of this project we should have a TerraForm and Packer template that can be used to deploy VMs of the same requirements as need.
What is Packer? Parker is one of the DevOps tools used to generate Server Templates for automated Deployment. These server templates can also be configured to include the application and software as required by the project. Packer tools are scripted with JSON. The Packer template is made up of 3 key key attributes: The Variable attribute, the Builders attribute and the Provisioners Attribute. The Variable attributes is used to hold variables that can be used in the building of the server template. These variables can also bind to variables stored in the shell environment. The Builder attribute is used to identify the properties of the Server to be built, including the type of image (Windows or Linux), size of CPU, etc. The last attribute we are going to talk about is the Provisioner Attribute. This attribute is used to deploy applications after the Image has being built. Here, you can give instructions to run an application, Install a Web Server, etc. For this project we will just be creating a little html file with the output "Hello, World".
Please visit: https://learn.hashicorp.com/tutorials/packer/getting-started-install to view the Packer Installation and Setup up process for your machine.
Terraform is a DevOps Provisioning tool that can be used to automate the creation of Resources needed for a Cloud environment. TerraForm tools are written with a propitiatory language called HCL. HCL is a script language similar to JSON. The HCL script is usually contains attributes that tell the script what to do. The 3 major attributes are Provider, Resource and Data. The Provider Attribute is used to identify the type of Cloud environment being utilized, in this case it is Azure. The Resource Attributes are used as a template to generate resources in the Cloud environment for example, Virtual Networks and Managed Disks.
Please visit: https://learn.hashicorp.com/tutorials/terraform/install-cli to view the Packer Installation and Setup up process for your machine.
BEfore we begin, we will take a look at all the resources that is required to meet the client specifications. When creating a VM in a cloud enviroment, the following resources are typically created along side it. The first this we need is to create a Resource Group. A resource group will contain all the resources necessary to deploy these VMs. A Virtual Machine needs to be in a Network to be effective, so a Virtual Network (VNet) is required when a VM is spurn (a way of saying created). To be in a Network, the VM must have Network Interface Cards (NIC) so this resource is created. The VMs will need to be segregated within a Network to restrict access to those who don't need to be on it. It is recommended that Virtual Networks have subnets so that the Network can be managed better. This is the reason why we will need a Subnet resource. Chances are that we want to be able to access our VMs from the Internet at some point in time, this means our network will require a Public IP address. We will also need a Network Security Gateway resouce that will house polices on communication rules within the Network. The client also specified a need for a Load Balancer so we will need that resource and finally, we will need a Managed Disk* to be attached to the VM. This Managed Disk is different from the OS disk that is created by default alongside the VM. A recap of the resources we need to create a VM so far:
- Resource Group
- Virtual Machine
- Virtual Network (the Subnet resource is included in VNet resource)
- Public IP Address
- Network Security Gateway
- Network Interface Card
Note: More on resources below.
One of the requirements from the client was to ensure that any resources created was tagged appropriately. So we need to Create a policy definition for this purpose and assign the policy to our scope of work - which for this project will be applied to the subscription.
By Default, when a network resource is created, there is always an NSG (Network Security Group) deployed with it. The NSG contains a group of rules or policies telling the network how to send or receive information. By deafault, it has some set of rules that restrict communication with the internet on all ports. Only the ports specified in the resource creation stage is allowed. In addition to this policy, the client has requested that she doesn't want any communications from the internet in the network, only communication between the VMs in the network.
Install the latest version of Powershell 7, and run it. Ensure you have installed the latest CLI module to start with as we will be using the this for this task. Note: You can also utilize Bash and the codes are similar.
First, we log on to Azure. Enter the following on PowerShell/Bash:
> az login
You will be sent to a web-page where you can sign into your Azure account. The next step will be to check for the subscription id for your account.:
> az account show --query "{ subscription_name: name, subscription_id: id }" -o table
This displays your Subscription id as shown:
output similar to:
Subscription_name Subscription_id
------------------- ------------------------------------
MySubscription xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
Copy the subscription id, you will need it soon.
Note: If you have multiple subscriptions, you can list the accounts you have and select the subscription you are interested.
> az account list --query "[].{subscription_name: name, subscription_id: id}" -o table
output similar to:
Subscription_name Subscription_id
------------------------ ------------------------------------
Subscription_1 xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
Subscription_2 yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy
Note: If you need to clear the Azure Subscription from the CLI, you can run the code below. You will have to log into Azure again and your current subscription will be the whatever was set as default.
> az account clear
Note: You can also logout using the following command. This retains the subscription that was set in CLI
> az logout
Now that we have logged into our Azure Account using the CLI, we will create a policy on the account.
The Policy should be in place to ensure that when resources are created, they have a tag name. We use JSON to create a policy template which will be used to define our policy.
We want to store the name of our policy definition in the CLI Environment. This way we can just make reference to it as we go along.
Note: It is good practice to do this, we need to avoid repetitions as often as possible. We will keep track of all the environment variables as we proceed with this task.
In the CLI, we will save our Subscription ID (as you had copied above) and Policy definition name, using the variable names subs_id
and policy_def
respectively, in the Environment with the following code:
> $subs_id='xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'
> $policy_def='DenyIfNoTagsPolicy'
Now, let use develop a template for the policy we will be defining in our Azure Subscription. The main goal of the policy is to ensure that resources always have a tag name. We create a JSON file, rules.json
which will contain the Policy rules as follows:
{
"if": {
"field": "[concat('tags[', parameters('tagName'), ']')]",
"exists": "false"
},
"then": {
"effect": "deny"
}
}
This JSON file defines the Policy rule. The policy simple checks if the Resource has a tag that does not exists (i.e. Exists = false) and if this is the case, it denies creation of the Resource.
The problem, at least for me, with this is that it was too intrusive and required that I tagged all my resource in my subscription, even test resources. I decided to modify the policy to check if the resource was in a resource group named in a certain format, and if it was then check if the tag key exists. In this case, we are checking to see if the resource group starts with the words "myproject" and if it does then check if the tag key exists. This was, this policy will only be restricted to Resource Groups with names starting with "myproject".
Alternative:
{
"if": {
"allof": [
{
"value": "[resourceGroup().name]",
"like": "myproject*"
},
{
"field": "[concat('tags[', parameters('tagName'), ']')]",
"exists": "false"
}
]
},
"then": {
"effect": "deny"
}
}
Note: For more information on Tagging logic in Azure, please visit: https://docs.microsoft.com/en-us/azure/governance/policy/concepts/definition-structure.
Now we need to define the tag parameter for the rules. We will save this parameter in a JSON file called rulesparams.json
and we will populate as follows:
{
"tagName": {
"type": "String",
"metadata": {
"displayName": "Tag Name",
"description": "Name of the tag, such as 'test'"
}
}
}
Note: that during assignment of this policy, we will need to specify the tagName
we want to use for this policy.
Now that we have structured the Policy on JSON, its time to create the definition on Azure. Run the following code, which creates the Policy Definition from the JSON file.:
\policy> az policy definition create -n $policy_def --rules rules.json --params rulesparams.json
output similar to:
{
"description": null,
"displayName": null,
"id": "/subscriptions/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx/providers/Microsoft.Authorization/policyDefinitions/DenyIfNoTagsPolicy",
"metadata": {
"createdBy": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"createdOn": "2020-09-02T16:49:29.5984076Z",
"updatedBy": null,
"updatedOn": null
},
"mode": "Indexed",
"name": "DenyIfNoTagsPolicy",
"parameters": {
"tagName": {
"allowedValues": null,
"defaultValue": null,
"metadata": {
"additionalProperties": null,
"description": "Name of the tag, such as 'test'",
"displayName": "Tag Name"
},
"type": "String"
}
},
"policyRule": {
"if": {
"allof": [
{
"like": "myproject*",
"value": "[resourceGroup().name]"
},
{
"exists": "false",
"field": "[concat('tags[', parameters('tagName'), ']')]"
}
]
},
"then": {
"effect": "deny"
}
},
"policyType": "Custom",
"type": "Microsoft.Authorization/policyDefinitions"
}
We have now defined our policy, the next step will be to assign this policy to our subscription.
To assign the defined policy we will used the az policy assignment create
command. We need to pass a parameter to this policy (as we created in the policy definition) so we need to create a JSON file, tagparam.json
to pass this value.
Copy the following code into the JSON file just created.
{
"tagName": {
"value": "test"
}
}
Then run the following command:
\policy> az policy assignment create -n 'tagging_policy' --policy $policy_def --params tagparam.json
output similar to:
{
"description": null,
"displayName": null,
"enforcementMode": "Default",
"id": "/subscriptions/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx/providers/Microsoft.Authorization/policyAssignments/tagging_policy",
"identity": null,
"location": null,
"metadata": {
"createdBy": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"createdOn": "2020-09-02T17:51:44.890264Z",
"updatedBy": null,
"updatedOn": null
},
"name": "tagging_policy",
"notScopes": null,
"parameters": {
"tagName": {
"value": "test"
}
},
"policyDefinitionId": "/subscriptions/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx/providers/Microsoft.Authorization/policyDefinitions/DenyIfNoTagsPolicy",
"scope": "/subscriptions/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"sku": {
"name": "A0",
"tier": "Free"
},
"type": "Microsoft.Authorization/policyAssignments"
}
We can check to see the list of policy assigned in our subscription by using the az policy assignment list
and verifying that our policy is in the list. Alternatively, you can verify in the Azure Portal.
As a DevOps Engineer, it is imperative that we reduce manual imputations of variables. The best way to do this is by using scripts, but for this project, we are going to be storing variables in the shell environment so we can reuse across the board.
So far, we have defined two Environment Variables: policy_def
that holds the name of the Policy Definition and subs_id
that holds the Subscription ID.
In PowerShell, Environment Variables are declared with the $
prefix.
Note: The final list of Environment Variables will be available at the end.
We will capture Environment Variables in a table for reference. Values can be defined as required. The table below identifies some of the variables we will utilize.
Env. Variable | Value | Comment |
---|---|---|
subs_id | xxxxxxxxx | This holds the Subscription ID |
policy_def | DenyIfNoTagPolicy | This is the Policy Definition Name |
prefix | myproject | This is the prefix used for naming resources |
rg_name | {$prefix}-rg | This holds the resource Group name |
image_name | {$prefix}-vmimage | This holds the VM Image name |
rg_location | eastus | This holds the location resources |
rs_tag_key | test | This holds the Tag Key for resources |
rs_tag_value | {$prefix} | This holds the Tag Value for resources |
vm_username | azureterrauser | This holds the username for the VM |
vm_password | xxxxxxxxxxxxx | This holds the password for the user |
In PowerShell, enter the following commands to ensure we have these variables in the environment as we will be accessing them when we build the Packer and TerraForm templates:
> $prefix='myproject'
> $rg_name="$prefix-rg"
> $image_name="$prefix-vmimage"
> $rg_location='eastus'
> $rs_tag_key='test'
> $rs_tag_value=$prefix
> $vm_username='azureterrauser'
> $vm_password='1234567890
The first thing we need to do before creating any resource on Azure is to create a resource group. Resource Groups are containers that hold all the resources together.
To create the resource group, we use the command az group create
as shown below:
az group create -n $rg_name -l $rg_location
output similar to:
{
"id": "/subscriptions/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx/resourceGroups/myproject-rg",
"location": "eastus",
"managedBy": null,
"name": "myproject-rg",
"properties": {
"provisioningState": "Succeeded"
},
"tags": null,
"type": "Microsoft.Resources/resourceGroups"
}
The next step will be to Create the VM image using Packer.
The client has indicated that she wants an Linux UbuntuServer 18.04 LTS. She also wants a simple HTML file (that returns Hello, World!
) to be Provisioned after the Image has been deployed in the VM.
To create the VM Image, lets investigate what variables we will need.
We would need to grant Packer App access to our Azure Subscriptions by creating a Service Principal using az ad sp create
. Once the Service Principal has been created, we need to have the following attributes of the Service Principal:
- Client ID
- Client Password (Secret)
- Tenant ID
To create a quick Service Principal using defaults, let use run the command as shown below:
az ad sp create-for-rbac --query "{Client_ID: appId, Client_Secret : password, Tenant_ID: tenant}"
output similar to:
Creating a role assignment under the scope of "/subscriptions/c5b70caf-bdd9-4336-b990-edf1b1ea365d"
Retrying role assignment creation: 1/36
{
"Client_ID": "zzzzzzzz-zzzz-zzzz-zzzz-zzzzzzzzzzzz",
"Client_Secret": "wwwwwwwwwwwwwwwwwwwwwwwwwwwwwww",
"Tenant_ID": "yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy"
}
Take a note of these outputs as we will use them in the Packer Template.
A Packer template is constructed with JSON using the following key parameters:
- variables
- builders
- provisioners
Note: For more on Packer Templates, Visit: https://www.packer.io/guides/packer-on-cicd
Packer Variables | Comment |
---|---|
"client_id" | Client ID from Service Principal (environment) |
"client_secret" | Client ID from Service Principal (environment) |
"subscription_id" | Azure Subscription ID (environment) |
"tenant_id" | Azure Tenant ID (environment) |
"vmlocation" | Location of VM (environment) |
"vmcpu_size" | Size of VM (hardcoded) |
"vm_image_rg_name" | Name of VM Image Resource Group (environment) |
"vm_image_name" | Name of VM Image (environment) |
"resource_tag_name" | Resource Tag Key (environment) |
"resource_tag_value" | Resource Tag Value (environment) |
To use variables from Environment, Packer looks for tags in the following format: ARM_XXXXXX
where XXXXXX
is the variable name and then this tag can be assigned in the template by using "{{env `ARM_XXXXX`}}"
.
We can now create these Variables in the Environment. (Service Principal credentials copied above)
> $env:ARM_CLIENT_ID='zzzzzzzz-zzzz-zzzz-zzzz-zzzzzzzzzzzz'
> $env:ARM_CLIENT_SECRET='wwwwwwwwwwwwwwwwwwwwwwwwwwwwwww'
> $env:ARM_SUBSCRIPTION_ID=$subs_id
> $env:ARM_TENANT_ID='yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy'
> $env:ARM_IMAGE_LOCATION=$rg_location
> $env:ARM_IMAGE_RG = $rg_name
> $env:ARM_IMAGE_NAME = $image_name
> $env:ARM_RS_TAG_KEY = $rs_tag_key
> $env:ARM_RS_TAG_VALUE = $rs_tag_value
The Builder is used to get the characteristics and features of the VM image. Below are the characteristics and feature we will be utilizing.
Builders Features | Comments |
---|---|
"type" | Cloud Resource Type (hardcoded: azure-arm) |
"client_id" | Client ID from Service Principal (user variable: client_id) |
"client_secret" | Client ID from Service Principal (user variable: client_secret) |
"tenant_id" | Azure Tenant ID (user variable: tenant_id) |
"subscription_id" | Azure Subscription ID (user variable: subscription_id) |
"os_type" | OS Type (hardcored: Linux) |
"image_publisher" | Image Publisher (hardcored: Canonical) |
"image_offer" | Image Offer (hardcoded: UbuntuServer) |
"image_sku" | Name of VM Image Resource Group (hardcoded: 18.04-LTS) |
"managed_image_resource_group_name" | Name of VM Image Resource Group (user variable: vm_image_rg_name) |
"managed_image_name" | VM Image Name (user variable: vm_image_name) |
"azure_tags"/"resource_tag_name" | Resource Tag Name (user variable: resource_tag_name) |
"azure_tags"/"resource_tag_value" | Resource Tag Value (user variable: resource_tag_value) |
To use variables from template, Packer uses the following format "{{user `ZZZZZ`}}"
where ZZZZZ
is the variable defined in the variable parameter.
The Provisioner is used to install an application when the image as being deployed in the new VM. For this task, it will just be an HTML file with the content "Hello, World!"
. Visit the Packer GitHub for more examples and templates.
Now that we have all the requirements for the template, we create a JSON file, server.json
, and copy the code below to it.
{
"variables": {
"client_id": "{{env `ARM_CLIENT_ID`}}",
"client_secret": "{{env `ARM_CLIENT_SECRET`}}",
"subscription_id": "{{env `ARM_SUBSCRIPTION_ID`}}",
"tenant_id": "{{env `ARM_TENANT_ID`}}",
"vmlocation" : "{{env `ARM_IMAGE_LOCATION`}}",
"vmcpu_size": "Standard_D2s_v3",
"vm_image_rg_name" : "{{env `ARM_IMAGE_RG`}}",
"vm_image_name" : "{{env `ARM_IMAGE_NAME`}}",
"resource_tag_name" : "{{env `ARM_RS_TAG_KEY`}}",
"resource_tag_value" : "{{env `ARM_RS_TAG_VALUE`}}"
},
"builders": [
{
"type": "azure-arm",
"client_id": "{{user `client_id`}}",
"client_secret": "{{user `client_secret`}}",
"tenant_id": "{{user `tenant_id`}}",
"subscription_id": "{{user `subscription_id`}}",
"os_type": "Linux",
"image_publisher": "Canonical",
"image_offer": "UbuntuServer",
"image_sku": "18.04-LTS",
"managed_image_resource_group_name": "{{user `vm_image_rg_name`}}",
"managed_image_name": "{{user `vm_image_name`}}",
"azure_tags": {
"{{user `resource_tag_name`}}": "{{user `resource_tag_value`}}"
},
"location": "{{user `vmlocation`}}",
"vm_size": "{{user `vmcpu_size`}}",
"async_resourcegroup_delete": true
}
],
"provisioners": [
{
"type": "shell",
"inline": [
"echo 'Hello, World!' > index.html",
"nohup busybox httpd -f -p 80 &"
],
"inline_shebang": "/bin/sh -x"
}
]
}
Use the command, packer build
, to build the VM image.
Note: Before you do, verify that there is already a resource group created by using the command az group exists -n $rg_name
.
\packer> packer build server.json
output similar to:
packer build server.json
azure-arm: output will be in this color.
==> azure-arm: Running builder ...
==> azure-arm: Getting tokens using client secret
==> azure-arm: Getting tokens using client secret
azure-arm: Creating Azure Resource Manager (ARM) client ...
==> azure-arm: WARNING: Zone resiliency may not be supported in eastus, checkout the docs at https://docs.microsoft.com/en-us/azure/availability-zones/
==> azure-arm: Creating resource group ...
==> azure-arm: -> ResourceGroupName : 'pkr-Resource-Group-kohkjcbwwh'
==> azure-arm: -> Location : 'eastus'
==> azure-arm: -> Tags :
==> azure-arm: ->> test : myproject
==> azure-arm: Validating deployment template ...
==> azure-arm: -> ResourceGroupName : 'pkr-Resource-Group-kohkjcbwwh'
==> azure-arm: -> DeploymentName : 'pkrdpkohkjcbwwh'
==> azure-arm: Deploying deployment template ...
==> azure-arm: -> ResourceGroupName : 'pkr-Resource-Group-kohkjcbwwh'
==> azure-arm: -> DeploymentName : 'pkrdpkohkjcbwwh'
==> azure-arm: Getting the VM's IP address ...
==> azure-arm: -> ResourceGroupName : 'pkr-Resource-Group-kohkjcbwwh'
==> azure-arm: -> PublicIPAddressName : 'pkripkohkjcbwwh'
==> azure-arm: -> NicName : 'pkrnikohkjcbwwh'
==> azure-arm: -> Network Connection : 'PublicEndpoint'
==> azure-arm: -> IP Address : '13.82.176.207'
==> azure-arm: Waiting for SSH to become available...
==> azure-arm: Connected to SSH!
==> azure-arm: Provisioning with shell script: C:\Users\User\AppData\Local\Temp\packer-shell325797499
==> azure-arm: + echo Hello, World!
==> azure-arm: + nohup busybox httpd -f
==> azure-arm: Querying the machine's properties ...
==> azure-arm: -> ResourceGroupName : 'pkr-Resource-Group-kohkjcbwwh'
==> azure-arm: -> ComputeName : 'pkrvmkohkjcbwwh'
==> azure-arm: -> Managed OS Disk : '/subscriptions/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx/resourceGroups/PKR-RESOURCE-GROUP-KOHKJCBWWH/providers/Microsoft.Compute/disks/pkroskohkjcbwwh'
==> azure-arm: Querying the machine's additional disks properties ...
==> azure-arm: -> ResourceGroupName : 'pkr-Resource-Group-kohkjcbwwh'
==> azure-arm: -> ComputeName : 'pkrvmkohkjcbwwh'
==> azure-arm: Powering off machine ...
==> azure-arm: -> ResourceGroupName : 'pkr-Resource-Group-kohkjcbwwh'
==> azure-arm: -> ComputeName : 'pkrvmkohkjcbwwh'
==> azure-arm: Capturing image ...
==> azure-arm: -> Compute ResourceGroupName : 'pkr-Resource-Group-kohkjcbwwh'
==> azure-arm: -> Compute Name : 'pkrvmkohkjcbwwh'
==> azure-arm: -> Compute Location : 'eastus'
==> azure-arm: -> Image ResourceGroupName : 'myproject-rg'
==> azure-arm: -> Image Name : 'myproject-vmimage'
==> azure-arm: -> Image Location : 'eastus'
==> azure-arm: Deleting resource group ...
==> azure-arm: -> ResourceGroupName : 'pkr-Resource-Group-kohkjcbwwh'
==> azure-arm:
==> azure-arm: The resource group was created by Packer, deleting ...
==> azure-arm:
==> azure-arm: Resource Group is being deleted, not waiting for deletion due to config. Resource Group Name 'pkr-Resource-Group-kohkjcbwwh'
==> azure-arm: Deleting the temporary OS disk ...
==> azure-arm: -> OS Disk : skipping, managed disk was used...
==> azure-arm: Deleting the temporary Additional disk ...
==> azure-arm: -> Additional Disk : skipping, managed disk was used...
==> azure-arm: Removing the created Deployment object: 'pkrdpkohkjcbwwh'
==> azure-arm: ERROR: -> ResourceGroupBeingDeleted : The resource group 'pkr-Resource-Group-kohkjcbwwh' is in deprovisioning state and cannot perform this operation.
==> azure-arm:
Build 'azure-arm' finished.
==> Builds finished. The artifacts of successful builds are:
--> azure-arm: Azure.ResourceManagement.VMImage:
OSType: Linux
ManagedImageResourceGroupName: myproject-rg
ManagedImageName: myproject-vmimage
ManagedImageId: /subscriptions/c5b70caf-bdd9-4336-b990-edf1b1ea365d/resourceGroups/myproject-rg/providers/Microsoft.Compute/images/myproject-vmimage
ManagedImageLocation: eastus
As seen in the output above, the Package Image has been created and is located in the Resource Group we specified. We can confirm this by using the the command, az resource list
, to look inside the resource group, i.e:
> az resource list --resource-group $rg_name --query "[].{Resource_Name: name, Resource_Type:type}" -o table
output similar to:
Resource_Name Resource_Type
----------------- ------------------------
myproject-vmimage Microsoft.Compute/images
TerraForm uses 2 main files to hold its template. A main file, which we will name as main.tf
and a file for variables, which we will call var.tf
. TerraForm templates are generated with a proprietary language called HCL. HCL language uses the .tf
suffix on its files.
The main file, main.tf
, is used to house parameters responsible for building resources.
The variables file, var.tf
, is used to hold variables that will be used by main file.
Note: For more information on TerraForm Template structure, please visit: https://www.terraform.io/docs/providers/azurerm/index.html
And the following variables will be used.
Variable | Comments |
---|---|
vmcount | Number of VMs to be deployed (between 2 and 5) (command-line) |
prefix | The prefix which should be used for all resources in this example (environment) |
location | The location where resources are created (environment) |
tagKey | Resource Tag Key (environment) |
tagValue | Resource Tag Value (environment) |
vmimage | This is the name of the image created (environment) |
vmimagerg | This is the name of the Resource Group that image was created (environment) |
username | Username of VM (environment) |
password | Password of User (environment) |
TerraForm can read values in the environment, provided the have the following syntax: TF_VAR_XXXXX
where XXXXX
is the variable name.
So we will create the variables shown above in the environment as follows:
> $env:TF_VAR_prefix=$prefix
> $env:TF_VAR_location=$rg_location
> $env:TF_VAR_tagKey=$rs_tag_key
> $env:TF_VAR_tagValue=$rs_tag_value
> $env:TF_VAR_vmimage=$image_name
> $env:TF_VAR_vmimagerg=$rg_name
> $env:TF_VAR_username=$vm_username
> $env:TF_VAR_password=$vm_password
Create a file named var.tf
, if you haven't done so already, and copy the following code to it.
variable "vmcount" {
type = number
description = "Number of VM to create?"
validation {
condition = var.vmcount > 1 && var.vmcount < 6
error_message = "The VM count should be between 2 and 5 (Default is 2. Max is 5)."
}
}
variable "prefix" {
description = "The prefix which should be used for all resources in this example"
}
variable "location" {
description = "The location where resources are created"
}
variable "tagKey" {
description = "Resource Tag Key"
}
variable "tagValue" {
description = "Resource Tag Value"
}
variable "vmimage" {
description = "This is the name of the image created"
}
variable "vmimagerg" {
description = "This is the name of the Resource Group that image was created"
}
variable "username" {
description = "Enter Username"
}
variable "password" {
description = "Enter Password"
}
Notice that we has environment values set for all but the vmcount
variable. This is because we intend to make the user be prompted to specify how many VMs to create. There is a validation logic on the variable that ensures that no more than 5 VMs are created at one time with the tool and no fewer than 2.
We need to build resources to meet the requirements as described in the image below:
The following are resources that we would like to be provisioned. Some resources will be created multiple times, as required based on the count of VMs requested by user. Some resources are really just resource parameters and do not need to have a resource tags specified.
Resource | Count | Tags | Comments |
---|---|---|---|
Resource Group | - | - | Resource Group has already been created, so we load it into TerraForm and reference it |
Virtual Network | 1 | Y | Address Space: 10.0.0.0/16 |
Subnet | 1 | N | Address Prefix: 10.0.2.0/24 |
Network Security Group | 1 | Y | Deny Internet Access to VM |
Public IP Address | 1 | Y | Will not be used as the Load Balancer is Internal |
Load Balancer | 1 | Y | This load balancer is internal and will use Private IP address from the subnet address range above |
Backend Address Pool | 1 | N | This is the backend pool feature of the Load Balancer |
Availability Set | 1 | Y | Availability Set that will be Assigned to VMs |
Network Interface | 2 - 5 | Y | Network Interface for VM |
Address Pool NI to LB association | 2 - 5 | N | A feature used to match NI to LB Backend Address Pool |
Get Packer image | 1 | N | This is a command to load data (the Packer Image) |
Linux VM | 2 - 5 | Y | This is the Linux VM Server to be provisioned. Fixed size: Standard_D2s_v3 |
Managed Disks | 2 - 5 | Y | 100 GB Data Disk to be attached to VM |
Attach Managed Disks | 2 - 5 | N | A feature to attach the Managed Disks to VM |
In the main.tf
file, create one if you haven't already, copy the following code.
provider "azurerm" {
features {}
}
# CREATE RESOURCE
#Resource Already Created on the CLI so we call it
data "azurerm_resource_group" "main" {
name = "${var.vmimagerg}"
}
# CREATE VIRTUAL NETWORK AND SUBNETS
# Virtual Network (VNet)
resource "azurerm_virtual_network" "main" {
name = "${var.prefix}-vnet"
address_space = ["10.0.0.0/16"]
location = var.location
resource_group_name = data.azurerm_resource_group.main.name
tags = {
"${var.tagKey}" ="${var.tagValue}"
}
}
# Backend subnet
resource "azurerm_subnet" "main" {
name = "${var.prefix}-vm-subnet"
resource_group_name = data.azurerm_resource_group.main.name
virtual_network_name = azurerm_virtual_network.main.name
address_prefixes = ["10.0.2.0/24"]
}
# CREATE NETWORK SECURITY GROUP (Deny Access to Internet)
resource "azurerm_network_security_group" "main" {
name = "${var.prefix}-nsg"
location = data.azurerm_resource_group.main.location
resource_group_name = data.azurerm_resource_group.main.name
security_rule {
name = "IN-Allow-only-VM-in-Subnets"
priority = 4096
direction = "Inbound"
access = "Deny"
protocol = "*"
source_port_range = "*"
destination_port_range = "*"
source_address_prefix = "Internet"
destination_address_prefix = "VirtualNetwork"
}
security_rule {
name = "OUT-Allow-only-VM-in-Subnets"
priority = 4096
direction = "Outbound"
access = "Deny"
protocol = "*"
source_port_range = "*"
destination_port_range = "*"
source_address_prefix = "VirtualNetwork"
destination_address_prefix = "Internet"
}
tags = {
"${var.tagKey}" ="${var.tagValue}"
}
}
# CREATE LOAD BALANCER (Including PUBLIC IP (or PRIVATE IP) and Backend Address Pool)
#Public IP (Not needed for this task but will be created)
resource "azurerm_public_ip" "main" {
name = "${var.prefix}-public-ip"
location = var.location
resource_group_name = data.azurerm_resource_group.main.name
allocation_method = "Static"
domain_name_label = data.azurerm_resource_group.main.name
tags = {
"${var.tagKey}" ="${var.tagValue}"
}
}
# Load Balancer (LB)
resource "azurerm_lb" "main" {
name = "${var.prefix}-lb"
location = var.location
resource_group_name = data.azurerm_resource_group.main.name
frontend_ip_configuration {
name = "${var.prefix}-pip"
# for Public Load Balancer, use the "public_ip_address_id" for the Public IP resource.
# public_ip_address_id = azurerm_public_ip.main.id
# for Internal Load Balancer, use "subnet_id" of the subnet resource (configured as backend) on the VNet resource.
subnet_id = azurerm_subnet.main.id
}
tags = {
"${var.tagKey}" ="${var.tagValue}"
}
}
# Backend Address pool
resource "azurerm_lb_backend_address_pool" "main" {
name = "${var.prefix}-BackEndAddressPool"
resource_group_name = data.azurerm_resource_group.main.name
loadbalancer_id = azurerm_lb.main.id
}
# CREATE AVAILABILITY SET
resource "azurerm_availability_set" "main" {
name = "${var.prefix}-avset"
location = data.azurerm_resource_group.main.location
resource_group_name = data.azurerm_resource_group.main.name
tags = {
"${var.tagKey}" ="${var.tagValue}"
}
}
#CREATE NICS and CONNECTION TO LOAD BALANCER BACK END POOL
# Network Interface (NIC)
resource "azurerm_network_interface" "main" {
count = var.vmcount
name = "${var.prefix}-nic${count.index+1}"
location = data.azurerm_resource_group.main.location
resource_group_name = data.azurerm_resource_group.main.name
ip_configuration {
name = "${var.prefix}-nic-ipconfig${count.index+1}"
subnet_id = azurerm_subnet.main.id
private_ip_address_allocation = "Dynamic"
}
tags = {
"${var.tagKey}" ="${var.tagValue}"
}
}
# Address Pool Association from NIC to LB
resource "azurerm_network_interface_backend_address_pool_association" "main" {
count = var.vmcount
network_interface_id = azurerm_network_interface.main[count.index].id
ip_configuration_name = "${var.prefix}-nic-ipconfig${count.index+1}"
backend_address_pool_id = azurerm_lb_backend_address_pool.main.id
}
# GET IMAGE MADE BY PACKER
# Assign resource group name of image
data "azurerm_resource_group" "image" {
name = "${var.vmimagerg}"
}
# Get Packer Image
data "azurerm_image" "image" {
name = "${var.vmimage}"
resource_group_name = data.azurerm_resource_group.image.name
}
# CREATE LINUX VIRTUAL MACHINE (VM)
resource "azurerm_linux_virtual_machine" "main" {
count = var.vmcount
name = "${var.prefix}-vm${count.index+1}"
resource_group_name = data.azurerm_resource_group.main.name
location = data.azurerm_resource_group.main.location
availability_set_id = azurerm_availability_set.main.id
size = "Standard_D2s_v3"
admin_username = var.username
admin_password = var.password
disable_password_authentication = false
network_interface_ids = [ azurerm_network_interface.main[count.index].id, ]
source_image_id =data.azurerm_image.image.id
os_disk {
storage_account_type = "Standard_LRS"
caching = "ReadWrite"
}
tags = {
"${var.tagKey}" ="${var.tagValue}"
}
}
# Managed Disks
resource "azurerm_managed_disk" "main" {
count = var.vmcount
name = "${var.prefix}-md${count.index+1}"
location = data.azurerm_resource_group.main.location
resource_group_name = data.azurerm_resource_group.main.name
storage_account_type = "Standard_LRS"
create_option = "Empty"
disk_size_gb = "100"
tags = {
"${var.tagKey}" ="${var.tagValue}"
}
}
# Attach Managed Disk
resource "azurerm_virtual_machine_data_disk_attachment" "main" {
count = var.vmcount
managed_disk_id = azurerm_managed_disk.main[count.index].id
virtual_machine_id = azurerm_linux_virtual_machine.main[count.index].id
lun = "10"
caching = "ReadWrite"
}
This step must be done only after creating the packer image. If not already created, create it can proceed with this step.
In CLI, go to the folder where you have saved the main.tf
and the var.tf
, and run the following command, terraform init
:
\TerraForm> terraform init
This initializes the TerraForm environment. a .terraform
folder containing is created in the folder with the main.tf
and the var.tf
files which contains necessary plugins.
Once TerraForm has been initialized, the next step is to run the plan, terraform plan
command. This command will cycle through the HCL TerraForm template and try to identify any errors and also investigate if the necessary resources for deploy are available. We can also get an output of the plan for review purposes. For this project, I will save the out put of the plan to solution.plan
by running the following command:
(Note: that the user will be requested to enter the number of VMs he wants to deploy when the command is run)
\TerraForm> terraform plan -out solution.plan
output similar to:
\TerraForm> terraform plan -out solution.plan
var.vmcount
Number of VM to create??
Enter a value: 2
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.
data.azurerm_resource_group.image: Refreshing state...
.......<output abbridged for clarity>................
# azurerm_virtual_network.main will be created
+ resource "azurerm_virtual_network" "main" {
+ address_space = [
+ "10.0.0.0/16",
]
+ guid = (known after apply)
+ id = (known after apply)
+ location = "eastus"
+ name = "myproject-vnet"
+ resource_group_name = "myproject-rg"
+ subnet = (known after apply)
+ tags = {
+ "test" = "myproject"
}
}
Plan: 17 to add, 0 to change, 0 to destroy.
------------------------------------------------------------------------
This plan was saved to: solution.plan
To perform exactly these actions, run the following command to apply:
terraform apply "solution.plan"
As you can see from a snippet of the output, it shows that a number of resource have planned to be added and also informs that the TerraForm plan was saved. Next we apply these actions.
To apply the actions listed on the plan we run the following command as instructed:
\TerraForm> terraform apply "solution.plan"
This command will go through the process of building the required resources.
We can confirm that the resources have actually been created by running the following command to view the resources in the resource group:
> az resource list --resource-group $rg_name --query "[].{Resource_Name: name, Resource_Type:type}" -o table
output similar to:
\TerraForm> az resource list --resource-group $rg_name --query "[].{Resource_Name: name, Resource_Type:type}" -o table
Resource_Name Resource_Type
---------------------------------------------------- ---------------------------------------
myproject-avset Microsoft.Compute/availabilitySets
myproject-md1 Microsoft.Compute/disks
myproject-md2 Microsoft.Compute/disks
myproject-md3 Microsoft.Compute/disks
myproject-md4 Microsoft.Compute/disks
myproject-md5 Microsoft.Compute/disks
myproject-vm1_disk1_2b4d358831d54b57bd0fbd365c43f71c Microsoft.Compute/disks
myproject-vm2_disk1_487bea1a3585497993c924ddefee83e6 Microsoft.Compute/disks
myproject-vm3_disk1_e04583e236b84f289bb7abc662571497 Microsoft.Compute/disks
myproject-vm4_disk1_0fc211a3e4f146c0b938b2a80c7b342f Microsoft.Compute/disks
myproject-vm5_disk1_cb4479c466574f9b953b340043bd40c4 Microsoft.Compute/disks
myproject-vmimage Microsoft.Compute/images
myproject-vm1 Microsoft.Compute/virtualMachines
myproject-vm2 Microsoft.Compute/virtualMachines
myproject-vm3 Microsoft.Compute/virtualMachines
myproject-vm4 Microsoft.Compute/virtualMachines
myproject-vm5 Microsoft.Compute/virtualMachines
myproject-lb Microsoft.Network/loadBalancers
myproject-nic1 Microsoft.Network/networkInterfaces
myproject-nic2 Microsoft.Network/networkInterfaces
myproject-nic3 Microsoft.Network/networkInterfaces
myproject-nic4 Microsoft.Network/networkInterfaces
myproject-nic5 Microsoft.Network/networkInterfaces
myproject-nsg Microsoft.Network/networkSecurityGroups
myproject-public-ip Microsoft.Network/publicIPAddresses
myproject-vnet Microsoft.Network/virtualNetworks
Once we have confirmed that the resources have been created, we will delete it (to avoid incurring costs).
Since TerraForm is a state-based Automation tool it can track the actions it has taken to create resources and reverse them enabling those resources to be destroyed. To destroy, we enter the following command:
After running, it will verify if we want to delete the resources. Since we want to delete, we type yes
and hit enter to continue with the deletion process.
Plan: 0 to add, 0 to change, 32 to destroy.
Do you really want to destroy all resources?
Terraform will destroy all your managed infrastructure, as shown above.
There is no undo. Only 'yes' will be accepted to confirm.
Enter a value: yes
output similar to:
We have successfully destroyed the Resources.
In this project, we learned how to do the following:
- Basic steps in Building Infrastructure-as-Code
- Process client requests on Architecture development.
- Develop a Packer Template for creating Virtual Machine Images.
- Use TerraForm to build Infrastructure while utilizing the Image Created with Packer.
- Automate the process of deploying resources.
- Create a Managed Disk and Attach it to a VM.
- Define and assign an Azure Policies, including the use of conditional logic techniques to determine when to Deny or Grant access to resources.
- Configure a Network Security Group and the use of its policies to restrict or grant access to Network resources on Azure using Inbound and Outbound Rules.
- Configure the Load Balancer to work within an Availability set with Virtual Machines, and how to set up a Private IP for utilizing a Load Balancer Internally.
We learned a lot about what Infrastructure-as-Code entails but what we did not do was so Network related tasks like to test Load Balance Process. The reason is because the Virtual Machines did not have access to the Internet. Two reasons why there was no access was because of these Client requirements: Firstly, Deny Internet Access to VMs and secondly, configure the Load Balancer to strictly Balance the traffic between the VMs. - which means no Public IP address for internet access.
The next steps will be the following:
- Investigate and test ways in which we can access the VMs securely from outside the internet. Currently exploring the latest Azure Offering (Bastion).
- Create a Windows Server Image following similar steps.