Intro
I finally managed to play the hybrid cloud the other day! True story. We’ve inherited a farm of virtual machines in a French cloud provider I’ve never heard before. Naturally, our initial task was to begin monitoring what’s happening within this setup. Is CPU high or low, what’s the memory consumption, are there any interesting activities happening, etc. I’ve blogged about a number of monitoring and logging tools before, but this time the simplest approach seemed to be to just bring those machines under the Azure umbrella. Since we already spend most of our time there, using Azure’s native security and monitoring tools to manage these instances seemed like the most practical choice.
It was surprisingly easy. Azure Arc, which consists of Azure’s tools for managing a hybrid cloud, offers a rather straightforward onboarding script that handles most of the work. The remaining task involves creating several ‘receiving’ resources within Azure itself, which is even more straightforward
So in this post, I want to share my excitement with the rest of the world and to show how one can onboard an arbitrary Digital Ocean’s virtual machine to Azure with Azure Arc. Why Digital Ocean? Well, I like those guys. They have been hosting my blogs for the last 8 years for 5–6 bucks per month per machine, which I appreciate a lot. Plus, their Terraform support is very decent, and that’s the other piece of technology we’re going to use. After all, it’s not Middle Ages anymore and everything should use Infrastructure as a Code and Terraform.
So, let the fun begins!
Step 1: Create Digital Ocean Droplet
Digital Ocean (hereinafter – DO) calls its virtual machines droplets, which is… OK. My parents called me Pavel, so who am I to judge. Anyway, in addition to Terraform’s digitalocean
provider to create a droplet, we’ll use another one – tls
, to generate a set of SSH keys for later login. By including just a few Terraform resources in main.tf
and digitalocean.tf
, you can have a fully configured virtual machine with SSH access ready in under 42 seconds. Beat that, Azure.
main.tf
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
terraform { required_providers { digitalocean = { source = "digitalocean/digitalocean" version = "~> 2.0" } tls = { source = "hashicorp/tls" version = "4.0.4" } } } provider "digitalocean" { # Pick credentials from env var } provider "tls" { } locals { digital_ocean_region = "tor1" # Toronto } |
digitalocean.tf
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 |
resource "tls_private_key" "this" { algorithm = "RSA" rsa_bits = 4096 } resource "digitalocean_ssh_key" "this" { name = "Arc provisioner's key" public_key = tls_private_key.this.public_key_openssh } resource "digitalocean_droplet" "this" { image = "ubuntu-22-04-x64" name = "digital-ocean-vm" region = local.digital_ocean_region size = "s-1vcpu-1gb" ssh_keys = [ digitalocean_ssh_key.this.fingerprint, ] } resource "terraform_data" "arc-connection" { triggers_replace = [ digitalocean_droplet.this.id, digitalocean_ssh_key.this.fingerprint, ] connection { type = "ssh" user = "root" private_key = tls_private_key.this.private_key_openssh host = digitalocean_droplet.this.ipv4_address agent = false timeout = "30m" } provisioner "remote-exec" { inline = [ "whoami", ] } } |
I’ve already added DO’s API access token to the environment variable – DIGITALOCEAN_ACCESS_TOKEN
, so don’t be surprised why there are no explicit authentication parameters.
For anyone, who wonders, here’s what terraform apply
goes through:
- Creates an SSH key (
tls_private_key.this
), - Registers that SSH key in DO (
digitalocean_ssh_key.this
), - Creates a droplet (
digitalocean_droplet.this
), kindly asking to inject previously registered SSH public key (digitalocean_ssh_key.this.fingerprint
) into it, and finally - Executes
whoami
command (terraform_data.arc-connection
) on newly created machine to confirm that SSH key (tls_private_key.this.private_key_openssh
) is working. Later we’ll replacewhoami
with something more useful.
I did run the deployment, and indeed, the science is solid – DO reported that there’s a new droplet in my project, and Terraform’s output indicated that whoami
responded with root
. Hurray!
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
terraform apply -auto-approve # ... # terraform_data.arc-connection: Still creating... [30s elapsed] # terraform_data.arc-connection (remote-exec): Connecting to remote host via SSH... # terraform_data.arc-connection (remote-exec): Host: ********* # terraform_data.arc-connection (remote-exec): User: root # terraform_data.arc-connection (remote-exec): Password: false # terraform_data.arc-connection (remote-exec): Private key: true # terraform_data.arc-connection (remote-exec): Certificate: false # terraform_data.arc-connection (remote-exec): SSH Agent: false # terraform_data.arc-connection (remote-exec): Checking Host Key: false # terraform_data.arc-connection (remote-exec): Target Platform: unix # terraform_data.arc-connection (remote-exec): Connected! # terraform_data.arc-connection (remote-exec): root # terraform_data.arc-connection: Creation complete after 40s [id=ff294411-b6da-ed3c-d803-fa4405d0a6e5] |
Step 2: Prepare Azure Arc Provisioning Script
Now comes the tricky part: adding a machine to Azure Arc involves running a custom script on it. Azure is even kind enough to provide a template.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
# Add the service principal application ID and secret here servicePrincipalClientId="*********"; servicePrincipalSecret="*********"; export subscriptionId="*********"; export resourceGroup="*********"; export tenantId="*********"; export location="*********"; export authType="principal"; export correlationId="*********"; export cloud="AzureCloud"; # Download the installation package output=$(wget https://aka.ms/azcmagent -O ~/install_linux_azcmagent.sh 2>&1); if [ $? != 0 ]; then wget -qO- --method=PUT --body-data="{\"subscriptionId\":\"$subscriptionId\",\"resourceGroup\":\"$resourceGroup\",\"tenantId\":\"$tenantId\",\"location\":\"$location\",\"correlationId\":\"$correlationId\",\"authType\":\"$authType\",\"operation\":\"onboarding\",\"messageType\":\"DownloadScriptFailed\",\"message\":\"$output\"}" "https://gbl.his.arc.azure.com/log" &> /dev/null || true; fi; echo "$output"; # Install the hybrid agent bash ~/install_linux_azcmagent.sh; # Run connect command sudo azcmagent connect --service-principal-id "$servicePrincipalClientId" --service-principal-secret "$servicePrincipalSecret" --resource-group "$resourceGroup" --tenant-id "$tenantId" --location "$location" --subscription-id "$subscriptionId" --cloud "$cloud" --correlation-id "$correlationId"; |
However, the script does require some editing. To start, we should remove sudo
from line 24, as the script will run as root anyway. Additionally, adding the declaration of the DEBIAN_FRONTEND=noninteractive
variable should prevent the script from attempting unnecessary interactions in a headless environment, thus avoiding potential execution halts due to prompts.
Next, the script requires placeholders to be replaced with actual values. Fortunately, Terraform can help us generate these values. We’ll need the following:
- Service Principal: along with its password (lines 2 and 3), assuming the SP has permissions for registering Arc machines (
Azure Connected Machine Onboarding
role) - Subscription ID, Tenant ID (lines 6 and 8)
- Resource group and its region (lines 7 and 9)
- Correlation ID – is a random GUID (line 11), which might be helpful for debugging, and which I’ve personally never had to use.
Lastly, there’s a subtle requirement: our Azure subscription must have the Microsoft.HybridCompute
provider enabled, which isn’t the default behavior. If it’s not activated, the onboarding script will raise complaints about lacking permissions to enable the provider by itself. This could be confusing, as the documentation doesn’t mention this particular aspect.
So, without any more delay, here’s the arc.tf
file that generates a fully populated onboarding script, ready for execution, along with all the necessary surrounding resources:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 |
resource "azurerm_resource_provider_registration" "this" { name = "Microsoft.HybridCompute" } resource "azuread_application" "this" { display_name = "Arc-Onboarding-App" } resource "azuread_service_principal" "this" { application_id = azuread_application.this.application_id } resource "azuread_service_principal_password" "this" { service_principal_id = azuread_service_principal.this.object_id } resource "azurerm_resource_group" "this" { name = "Arc-Machines" location = local.azure_arc_region } data "azurerm_subscription" "this" { } resource "azurerm_role_assignment" "this" { scope = data.azurerm_subscription.this.id role_definition_name = "Azure Connected Machine Onboarding" principal_id = azuread_service_principal.this.object_id } resource "random_uuid" "this" { } locals { arc_onboarding_script = <<EOT export DEBIAN_FRONTEND=noninteractive # Add the service principal application ID and secret here servicePrincipalClientId="${azuread_service_principal.this.application_id}"; servicePrincipalSecret="${azuread_service_principal_password.this.value}"; export subscriptionId="${data.azurerm_subscription.this.subscription_id}"; export resourceGroup="${azurerm_resource_group.this.name}"; export tenantId="${data.azurerm_subscription.this.tenant_id}"; export location="${local.azure_arc_region}"; export authType="principal"; export correlationId="${random_uuid.this.result}"; export cloud="AzureCloud"; # Download the installation package output=$(wget https://aka.ms/azcmagent -O ~/install_linux_azcmagent.sh 2>&1); if [ $? != 0 ]; then wget -qO- --method=PUT --body-data="{\"subscriptionId\":\"$subscriptionId\",\"resourceGroup\":\"$resourceGroup\",\"tenantId\":\"$tenantId\",\"location\":\"$location\",\"correlationId\":\"$correlationId\",\"authType\":\"$authType\",\"operation\":\"onboarding\",\"messageType\":\"DownloadScriptFailed\",\"message\":\"$output\"}" "https://gbl.his.arc.azure.com/log" &> /dev/null || true; fi; echo "$output"; # Install the hybrid agent bash ~/install_linux_azcmagent.sh; # Run connect command azcmagent connect --service-principal-id "$servicePrincipalClientId" --service-principal-secret "$servicePrincipalSecret" --resource-group "$resourceGroup" --tenant-id "$tenantId" --location "$location" --subscription-id "$subscriptionId" --cloud "$cloud" --correlation-id "$correlationId"; EOT } |
The code is quite self-explanatory and meets all the requirements we’ve outlined above. Ideally, the region of the resource group should be close to the virtual machine’s region. That’s why the azure_arc_region
variable is set to canadacentral
, which, like DO’s tor1
, is located in Toronto
Moving forward, we’ll add registrations for three more Terraform providers (azuread
, azurerm
, random
) and introduce one more variable in main.tf
. By running terraform apply
, we will confirm that the science is still solid, and we are able to create resources in Azure as well.
main.tf
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 |
terraform { required_providers { # ... azurerm = { source = "hashicorp/azurerm" version = "3.69.0" } azuread = { source = "hashicorp/azuread" version = "~> 2.15.0" } random = { source = "hashicorp/random" version = "3.5.1" } } } # ....... provider "azurerm" { # Pick credentials from azure-cli features { resource_group { prevent_deletion_if_contains_resources = false } } } provider "azuread" { # Pick credentials from azure-cli } provider "random" { } locals { digital_ocean_region = "tor1" # Toronto azure_arc_region = "canadacentral" # Toronto } |
Pure, pure witchcraft.
By the way, Terraform picked up Azure credentials implicitly from the environment too – from already authenticated Azure CLI. In a production, you’d probably choose to be more explicit about your configuration.
Step 3: Onboard Digital Ocean Droplet to Azure Arc
And now, the grand event. We already have a VM in Digital Ocean, and Step 2 ended up with Azure Arc onboarding script stored in a local variable, so why not cross-breed these two together? For that, we just need to replace whoami
in our SSH provisioner (terraform_data.arc-connection
) with the reference to the onboarding script. Plus, provisioner really should wait until Microsoft.HybridCompute
provider registration and Azure Connected Machine Onboarding
role assignment are over. Otherwise, nasty race condition might will happen.
digitalocean.tf
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
# .... resource "terraform_data" "arc-connection" { # .... depends_on = [ azurerm_role_assignment.this, azurerm_resource_provider_registration.this, ] provisioner "remote-exec" { inline = [ local.arc_onboarding_script, ] } } |
And now, the drumroll. I removed (terraform destroy
) all previously created resources to make sure that end-to-end deployment doesn’t have any race conditions, and terraform apply -auto-approve
created this beauty in just 2 minutes and 8 seconds:
Behold!
The efforts of countless generations have brought us to this moment. Egyptians built pyramids. Greeks played with logic. Eastern Europeans probably did something useful too. And in our turn, we connected Digital Ocean droplet to Azure Arc, which opens doors to all goodies that Azure has. Automatic updates management, logs collection, monitoring, configuration management, you name it.
In the next post, we’ll enable some of those features. With Terraform, obviously. Despite the frequency of the recent posts, I hope that will happen much sooner than in a year. Probably in a few weeks or so. We’ll configure VM insights, so hybrid cloud concept from the abstract beauty becomes something actually useful. Stay tuned!