Deploy LAMP stack with Terraform
For architectures with multiple servers and complex software dependencies, an orchestration tool like Terraform can be used to save deployment time and ensure consistency. This is illustrated by installing a LAMP stack on a single server, all created from raw resources in the tenant.
Contents
Terraform creates necessary objects in the tenant, so capacity to create the following resource quotas must be available:
1 Key pair
1 Compute instance of flavor
g1.standard-1-1
1 Network and 1 subnet
1 Non-allocated floating IP
2 Security groups
Install Terraform
The Terraform client by HashiCorp can be installed on Ubuntu through the APT packet manager. To do this, first download the private key for package authentication, and then add the official HashiCorp repository to your local machine with
curl -fsSL https://apt.releases.hashicorp.com/gpg | sudo apt-key add - sudo apt-add-repository "deb [arch=$(dpkg --print-architecture)] https://apt.releases.hashicorp.com $(lsb_release -cs) main"
The version of Terraform client is needed is dependent on the architecture and operating system (Ubuntu distribution release) on your local machine. This information is used in the second command: the APT architecture (such as amd64
) is found by the shell command
dpkg --print-architecture
and the code name of the local distribution release (for example, focal
for Ubuntu 20.04)
lsb_release -cs
It also needs to load the correct provider module for OpenStack. Providers represent the upstream API, responsible for understanding API interactions and exposing resources. The provider registry code is part of the project file provider.tf
.
The operation apt-add-repository
usually runs apt update
to update new packet indices, but there is no harm in repeating the command before installing the the Terraform client from the new repository:
sudo apt update sudo apt install terraform
The installation can be tested with terraform --version
or terraform --help
.
The optional command auto-complete is enabled with
terraform -install-autocomplete
whereby typing the beginning of a command argument (with the required number of letters to avoid any ambiguity) followed by <TAB>
prints the whole word. For example, typing ter
and <TAB>
prints terraform
. A new console window needs to be opened for this function to take effect.
In some projects, a particular version of Terraform is required to match the existing configuration. Currently available Terraform versions in the repository index are listed with apt policy terraform
and a specific version is then installed with sudo apt install terraform=<version>
.
Build project
Like with many other template-based orchestration systems, it is customary to create separate files for variables, login credentials, resource definitions etc. For the simple project of creating a plain, unconfigured compute instance, we create the files
terraform.tfvars
with OpenStack credentialsprovider.tf
specifying the OpenStack provider with reference to the OpenStack credentialsvariables.tf
with the definitions of project-related variablesdeploy.tf
with the template for resource creation
Root module and credentials
Terraform uses the OpenStack tenant credentials to connect and perform operations. Terraform can use the OpenStack environmental variables if set, or specified in the variable definitions file terraform.tfvars
:
openstack_user_name = "<username>" openstack_tenant_name = "<tenant-name>" openstack_password = "<password>" openstack_auth_url = "https://keystone.ic-hrvart3.in.pan-net.eu:5000/v3"
String values are typically given surrounded by double quotes. Comment lines begin with a hash tag.
The first part of the root module file provider.tf
contains the necessary settings to load the OpenStack provider module. The second part sets the needed variables by reference to the variables definitions file or specified directly.
# Define required providers terraform { required_version = ">= 0.14.0" required_providers { openstack = { source = "terraform-provider-openstack/openstack" version = "~> 1.35.0" } } } # Credentials from variables.tf provider "openstack" { user_name = "${var.openstack_user_name}" tenant_name = "${var.openstack_tenant_name}" password = "${var.openstack_password}" auth_url = "${var.openstack_auth_url}" cacert_file = "~/pannet/certs/rootcax1.crt" region = "RegionOne" project_domain_name = "<project-domain-name>" user_domain_name = "users_domain" }
The minimum set of variables used here are:
user_name
- Login username; If omitted, theOS_USERNAME
environment variable is used.tenant_name
- Name of the tenant (Identity v2) or project (Identity v3) to login to; If omitted, theOS_TENANT_NAME
orOS_PROJECT_NAME
environment variable are used.password
- Password to login with; If omitted, theOS_PASSWORD
environment variable is used.auth_url
- Identity authentication URL; If omitted, theOS_AUTH_URL
environment variable is used.cacert_file
- Custom CA certificate for SSL communication; If omitted, theOS_CACERT
environment variable is used.region
- Region of the OpenStack cloud. If omitted, theOS_REGION_NAME
environment variable is used.project_domain_name
- Domain name of the project; If omitted theOS_PROJECT_DOMAIN_NAME
environment variable is used.user_domain_name
- User domain name to scope to (Identity v3). If omitted, theOS_DOMAIN_NAME
environment variable is used.
Resources
Variables are collected in the file variables.tf
in the format shown below. Each input variable is typically declared with a variable name, followed by a variable
block with type and default value within curly brackets. The type and default values are not strictly necessary. The variable value can be defined elsewhere and type can deducted from the default or input value.
Note that the first four variables are declared as variables in this file, but specified in terraform.tfvars
. The remainder of the variables are related to resources (with given example values).
variable "openstack_user_name" {} variable "openstack_tenant_name" {} variable "openstack_password" {} variable "openstack_auth_url" {} variable "image" { default = "ubuntu-20.04-x86_64" } variable "flavor" { default = "g1.standard-1-1" } variable "ssh_key_pair" { default = "my_key" } variable "ssh_user_name" { default = "ubuntu" } variable "availability_zone" { default = "az1" } variable "security_group" { default = "default" } variable "network" { default = "internal_network" }
The file deploy.tf
contains the resource declarations used by Terraform - in this case for the compute instance to be created.
resource "openstack_compute_instance_v2" "server" { count = 1 name = "test-vm-${count.index}" image_name = var.image availability_zone = var.availability_zone flavor_name = var.flavor key_pair = var.ssh_key_pair security_groups = [var.security_group] network { name = var.network } } # Output VM IP Address output "serverip" { value = openstack_compute_instance_v2.server[*].access_ip_v4 }
The declaration is given in a resource block, specifying resource type (“openstack_compute_instance_v2”), a local name (“server”) and its configuration parameters within curly brackets. In addition, the declaration may contain meta arguments, such as count
for creation of multiple identical instances. Unique instance names with a count
value larger than 1 can be created by appending count.index
. Other useful meta arguments in larger projects are for_each
and depends_on
.
The variable security_groups
takes a list of values and the property network
takes as argument either name
or uuid
, corresponding to the respective OpenStack identifiers. The output section takes an output variable name and, as argument, a reference to the created instances in vector notation and displays their IP addresses.
The output section takes a name in double quotes, which is the name of the output variable that is assigned the value evaluated from the expression in curly brackets. In the file deploy.tf
, the value is the private IP address assigned to the compute instances (using a wildcard vector notation) after their creation.
Deployment
The Terraform files should now be located in the same directory. Open a terminal window and move into this directory. The command output from tree
in this directory shows the template files before invoking Terraform (which creates some additional files and folders)
├── deploy.tf ├── provider.tf ├── terraform.tfvars └── variables.tf
Initiate
The first step is to perform
terraform init
which loads the necessary provider module(s). The output should contain the statement Terraform has been successfully initiated!, as shown in Figure 1.
The next step is an execution planning without deploying any actual changes, carried out with
terraform plan
Terraform reads the current state of the existing infrastructure and compares with the resource declarations, and reports any differences. It also proposes necessary changes - if any - to make the infrastructure correspond to the declarations. These changes can only be implemented with the command terraform apply
.
The command can be appended with the argument -out=<file-name>
to save the configuration for use with terraform apply
. The created file is a zipped archive with information internal to terraform and is not intended to be edited manually. The name passed to it should therefore be any proper file name without file extension.
After a successful deployment, and if no changes are made to the templates, a re-execution of terraform plan
will report that no changes are needed to the infrastructure configuration.
Apply
The infrastructure changes created with terraform init
are deployed with
terraform apply
If an output file has been created in the planning step, this can be referenced by adding the file name after the command. It also prompts for approval before starting the deployment. To avoid typing the approval during the ongoing process, the argument -auto-approve
can be passed to the command. The command also accepts arguments to modify variable names in the format -var “<name>:<value>”
. Figure 2 shows the last part of the output from terraform apply
.
The command terraform show
prints a report of the deployment.
Destroy
All components that have been declared in project are deleted with
terraform destroy
It requires manual approval before changing the infrastructure, or else the argument -auto-approve
must be passed to the command.
It is a good idea to verify the deletions with standard OpenStack list
queries. A common situation is that templates are being edited before the previous deployment has been destroyed. This can lead to that not all resources are properly destroyed when the destroy
command is run, and then these need to be deleted manually not to cause errors at the next deployment.
Troubleshooting
Verbose Terraform output is enabled with
TF_LOG=DEBUG OS_DEBUG=true terraform apply
Specific compute faults can be obtained with
openstack server show <server-id> -c fault -f value
where <server-id>
is obtained from openstack server list
. A common error is that some tenant resource quota is exceed during the Terraform execution, which may not be obvious from the output.
In case terraform destroy
does not delete all components, they need to be removed manually. In particular network resources can be a bit tricky, since instances, ports, networks and routers need to be removed in that order, and especially ports are identified by their ID strings rather than a resource name.
Provisioners
Terraform uses provisioners to perform actions on the local or remote hosts, including installation of software packages, configuration and transfer of files.
Using Terraform provisioners for software deployment and configuration is not recommended because not all actions can be part of a Terraform plan, and it requires additional credentials to log in to hosts which are not used directly by Terraform. Nevertheless, for simple node configurations, provisioners are a convenient way to incorporate such steps with the infrastructure creation.
The provisioners need a section for connection-related information. In this project, the SSH connection block looks like
connection { type = "ssh" user = "ubuntu" private_key = file("~/.ssh/${var.ssh_access_key_name}") host = openstack_networking_floatingip_v2.lamp_access_floatip_ip.address }
The variable host
contains the floating IP address, assigned as part of the instance creation process.
File provisioner
The file provisioner is used to upload files to the remote host. It takes as variables the local (source
) and remote (destination
) file paths:
provisioner "file" { source = var.upload_file destination = "/home/ubuntu/info.txt" }
Note however, that the file has to be specified as a variable, that is, be part of the configuration source code, for example in the variables file, like
variable "upload_file" { default = "/home/client/terraform/project/info.txt" }
Remote execution provisioner
The remote execution provisioner takes a list (named inline
) of Linux commands represented as strings, that are to be executed on the remote host.
provisioner "remote-exec" { inline = [ "sudo apt update", "sudo apt install -y apache2", "sudo apt install -y php7.4 php7.4-mysql php-common php7.4-cli php7.4-json php7.4-common php7.4-opcache libapache2-mod-php7.4", "sudo systemctl restart apache2", "sudo apt install mariadb-server mariadb-client", "echo '<?php phpinfo(); ?>' | sudo tee -a /var/www/html/info.php > /dev/null" ] }
Deploying the LAMP stack
The project contains the following files in a dedicated directory, listed with the tree
command within that directory:
├── data_sources.tf ├── deploy.tf ├── output_template.tmpl ├── provider.tf ├── terraform.tfvars └── variables.tf
The project source code can be found in https://pannet.atlassian.net/wiki/spaces/DocEng/pages/524320863/Deploy+LAMP+stack+with+Terraform#Project-files, together with provider.tf
and terraform.tfvars
from https://pannet.atlassian.net/wiki/spaces/DocEng/pages/524320863/Deploy+LAMP+stack+with+Terraform#Root-module-and-credentials
The private IP range is set to 192.168.0.0 and can be changed if desired in variables.tf
.
Key pair
After creating the project files, execute from within the project directory the command lines
ssh-keygen -q -N "" -f ~/.ssh/pan_net_cloud_id_rsa echo -e "variable \"tenant-admin-public-key\" {\n default = \"$(cat ~/.ssh/pan_net_cloud_id_rsa.pu
The second line writes the public key to the variables file. When a new key is generated to replace an existing key, the old one must be manually deleted from that file.
Data sources
A data source allows an existing resource or data (such as node status) to be included in the Terraform configuration. Whereas a resource defines a new infrastructure component to be created and configured by Terraform, a data source is a read-only reference to pre-existing data, or to some value computed within Terraform itself.
The data block has an identifier consisting of a type and a name, where the combination of type and name must be unique. A data instance has one or more attributes in its body, surrounded by curly brackets.
Terraform verifies the availability of any specified data resource before creating a new resource. This can save time and help finding errors while executing terraform plan
, before running the deployment process and thereby avoiding failure during the deployment process.
Output section
In this project, the output section is defined in a separate template, referenced from the deploy.tf
template and with relevant variables passed to it.
Deployment
From the project directory, deploy the project with the following three commands. The terraform plan and apply commands produce a lot of information, so only the end parts of the expected output are shown in the figures below (Figure 3-5).
terraform init
terraform plan -out=lamp_stack
terraform apply lamp_stack
After successful deployment, the output shows instructions for testing. From a browser, the given IP address shows the PHP page (Figure 6).
The command to login to the instance with SSH is also shown in the output, that is
$ ssh -i ~/.ssh/pan_net_cloud_id_rsa ubuntu@188.125.27.5
Please note that the database is not fully configured, since automation of these steps is beyond the scope of this tutorial.
Project files
data_sources.tf
data "openstack_compute_flavor_v2" "compute_instance_flavor" { name = var.compute_flavor } data "openstack_images_image_v2" "compute_instance_image" { name = var.openstack_image } data "openstack_networking_secgroup_v2" "default_group" { name = var.default_security_group } data "openstack_networking_router_v2" "router" { status = "ACTIVE" }
variables.tf
variable "openstack_user_name" {} variable "openstack_tenant_name" {} variable "openstack_password" {} variable "openstack_auth_url" {} variable "lamp_internal_subnet_ipv4" { default = "192.168.0.0/24" } variable "floating-ip-pool" { default = "external_internet_provider" } variable "compute_flavor" { default = "g1.standard-1-1" } variable "openstack_image" { default = "ubuntu-20.04-x86_64" } variable "default_security_group" { default = "default" } variable "ssh_access_key_name" { default = "pan_net_cloud_id_rsa" }
deploy.tf
## SSH key pair in the cloud resource "openstack_compute_keypair_v2" "tenant_admin_keypair" { name = var.ssh_access_key_name public_key = var.tenant-admin-public-key } ## Security groups and rules resource "openstack_networking_secgroup_v2" "lamp_secgroup_ssh" { name = "lamp_secgroup_ssh" description = "Allow inbound ssh traffic" } resource "openstack_networking_secgroup_v2" "lamp_secgroup_http" { name = "lamp_secgroup_http" description = "Allow inbound http traffic" } resource "openstack_networking_secgroup_rule_v2" "lamp_secgroup_rule_http" { direction = "ingress" ethertype = "IPv4" protocol = "tcp" port_range_min = 80 port_range_max = 80 remote_ip_prefix = "0.0.0.0/0" security_group_id = openstack_networking_secgroup_v2.lamp_secgroup_http.id } resource "openstack_networking_secgroup_rule_v2" "lamp_secgroup_rule_ssh" { direction = "ingress" ethertype = "IPv4" protocol = "tcp" port_range_min = 22 port_range_max = 22 remote_ip_prefix = "0.0.0.0/0" security_group_id = openstack_networking_secgroup_v2.lamp_secgroup_ssh.id } ## Network (internal) resource "openstack_networking_network_v2" "lamp_internal" { name = "lamp-stack-internal" admin_state_up = "true" } ## Subnet (pool of IP addresses with associated configuration state) resource "openstack_networking_subnet_v2" "lamp_internal_subnet_ipv4" { name = "lamp-stack-internal-subnet-ipv4" network_id = openstack_networking_network_v2.lamp_internal.id cidr = var.lamp_internal_subnet_ipv4 ip_version = 4 dns_nameservers = [ "8.8.8.8", "8.8.4.4" ] } ## Interface between router and subnet resource "openstack_networking_router_interface_v2" "lamp_internal_router_interface" { router_id = data.openstack_networking_router_v2.router.id subnet_id = openstack_networking_subnet_v2.lamp_internal_subnet_ipv4.id } ## Connection points resource "openstack_networking_port_v2" "lamp_port_1" { name = "lamp_port_1" network_id = openstack_networking_network_v2.lamp_internal.id admin_state_up = "true" fixed_ip { subnet_id = openstack_networking_subnet_v2.lamp_internal_subnet_ipv4.id } security_group_ids = [ data.openstack_networking_secgroup_v2.default_group.id, openstack_networking_secgroup_v2.lamp_secgroup_ssh.id, openstack_networking_secgroup_v2.lamp_secgroup_http.id, ] depends_on = [openstack_networking_network_v2.lamp_internal] } ## Virtual machine resource "openstack_compute_instance_v2" "lamp_stack_iaas_example" { name = "lamp_stack_iaas_example" image_id = data.openstack_images_image_v2.compute_instance_image.id flavor_id = data.openstack_compute_flavor_v2.compute_instance_flavor.id key_pair = openstack_compute_keypair_v2.tenant_admin_keypair.name network { port = openstack_networking_port_v2.lamp_port_1.id } } resource "null_resource" "lamp_config" { provisioner "remote-exec" { inline = [ "sudo apt update", "sudo apt install -y apache2", "sudo apt install -y php7.4 php7.4-mysql php-common php7.4-cli php7.4-json php7.4-common php7.4-opcache libapache2-mod-php7.4", "sudo systemctl restart apache2", "sudo apt install -y mariadb-server mariadb-client", # "sudo mysql_secure_installation", "echo '<?php phpinfo(); ?>' | sudo tee -a /var/www/html/phpinfo.php > /dev/null" ] } connection { type = "ssh" user = "ubuntu" private_key = file("~/.ssh/${var.ssh_access_key_name}") host = openstack_networking_floatingip_v2.lamp_access_floatip_ip.address } } ## Floating IP address - allocate resource "openstack_networking_floatingip_v2" "lamp_access_floatip_ip" { pool = var.floating-ip-pool } ## Floating IP address - make association resource "openstack_networking_floatingip_associate_v2" "lamp_access_floatip_ip_associate" { floating_ip = openstack_networking_floatingip_v2.lamp_access_floatip_ip.address port_id = openstack_networking_port_v2.lamp_port_1.id } ## Output blocks used for returning values created by Terraform output "instance_ip_addr" { value = openstack_networking_floatingip_v2.lamp_access_floatip_ip.address description = "The password for logging in to the database." } output "template" { value = templatefile("./output_template.tmpl", { public-key = openstack_compute_keypair_v2.tenant_admin_keypair.name, ip_address = openstack_networking_floatingip_v2.lamp_access_floatip_ip.address }) }
output_template.tmpl
To validate server configuration open the following link in web-browser: http://${ip_address}/phpinfo.php To access host over ssh copy the following command to command line: ssh -i ~/.ssh/${public-key} ubuntu@${ip_address}