Day 83 of DevOps, Day 33 of Azure. Both tasks today were made of parts that are each correct on their own and do nothing until something joins them.
An Ansible inventory and an Ansible playbook are two files, neither of which references the other. A load balancer has four kinds of components, and three of them are inert until a rule names all three together.
One Ansible task, one Azure task. Create an inventory so an existing playbook runs, then put a load balancer in front of a VM. The tasks come from the KodeKloud Engineer platform.
Task 1: An inventory for a playbook that already exists
The task: create an inventory at /home/thor/playbook/inventory so the playbook sitting next to it can run against stapp03.
Step 1: Keys before the file
The security point is worth making before writing anything, and it is the Day 82 finding: storing a password in a plain-text inventory is what Ansible's own documentation tells you not to do, in the description of the variable itself. "Never store this variable in plain text; always use a vault."
So the key goes in first, and the inventory describes who to connect as rather than what password to use.
[app_server]
stapp03 ansible_host=stapp03 ansible_user=banner
One thing in that line is redundant. ansible_host is documented as "The name of the host to connect to, if different from the alias you wish to give to it", and the alias on the left is already stapp03. Harmless, and it hides what the parameter is actually for, which is pointing an inventory name at a different address.
Step 2: Two files, joined at the command line
thor@jump-host ~/playbook$ ansible-playbook -i inventory playbook.yml
This is the bit worth sitting with, because it is the shape of the whole tool.
The playbook says which group it targets. The inventory says which machines are in that group. Neither file mentions the other, and the command line is where they meet. Change the inventory and the identical playbook deploys to somewhere else entirely.
That separation is the reason Ansible scales to more than one environment without templating anything. It is also the reason a playbook that works in staging can hit production by mistake, because the only thing that decided which was a flag.
Step 3: Read the recap
TASK [Gathering Facts] *************** ok: [stapp03]
TASK [Install httpd package] *************** changed: [stapp03]
TASK [Start service httpd] *************** changed: [stapp03]
PLAY RECAP *************** stapp03 : ok=3 changed=2 unreachable=0 failed=0
Three tasks ran, two of them altered the system. The one that did not is Gathering Facts, which collects information and changes nothing by design.
What the docs recommend
Run this playbook a second time and it reports changed=0.
The package task finds httpd installed and reports ok. The service task finds it running and reports ok. Both modules describe a desired state, and a system already in that state needs no action. That is the property Ansible is usually sold on, and it is what makes the changed number in a recap worth reading: on a stable system it should be zero, so anything non-zero means something actually moved.
Worth establishing deliberately now, because tomorrow's playbook breaks it. file: state=touch reports a change on every single run, by design, and once one task does that the recap stops being a signal.
One correction to my own notes while I am here. For this day and the next three, I wrote <Enter-your-passphrase> at the ssh-keygen prompt, as a placeholder, and then showed a successful Ansible run. Day 82's notes record the prompt as it actually appeared, with an empty passphrase, which is the version that works: Ansible "does not expose a channel to allow communication between the user and the ssh process to accept a password manually to decrypt an ssh key", so a passphrase-protected key cannot be decrypted during a run without an agent, and none of my notes mention one.
A placeholder in a write-up reads as an instruction. Anyone following it literally ends up with a key Ansible cannot use, so it should be the real prompt.
Task 2: A load balancer in front of a VM
The task: a load balancer, a frontend IP, a backend pool containing the VM, a health probe on port 80, and a rule.
Step 1: The title is AWS vocabulary
There is no "Azure Application Load Balancer". The products split differently:
| Layer | AWS | Azure |
|---|---|---|
| 4, TCP and UDP | Network Load Balancer | Azure Load Balancer |
| 7, HTTP | Application Load Balancer | Application Gateway |
What this task describes, a frontend, a pool, a probe and a port-to-port rule, is Azure Load Balancer, which is layer 4. It forwards TCP and knows nothing about HTTP. No paths, no host headers, no cookie affinity, no TLS termination.
Worth being precise about, because the AWS Day 36 ALB took --health-check-path / and this probe cannot have one.
Step 2: Standard is not a choice, and it changes the inbound rules
"On September 30, 2025, Basic Load Balancer was retired."
So Standard brings a constraint worth checking before building: "When using a Public Standard Load Balancer, the SKU of the public IP must be Standard. Basic Public IPs are not supported on Standard LB." A public IP's SKU cannot be changed in place, so a Basic one means detach, delete, recreate, reattach.
The part my notes had as an aside, and that the documentation makes central, is the inbound posture:
| Standard | Basic | |
|---|---|---|
| Secure by default | "Closed to inbound flows unless allowed by a network security group." | "Open by default. Network security group optional." |
| Outbound Rules | "Declarative outbound NAT configuration." | "Not available." |
So the Allow-HTTP rule I added at priority 900 is not extra hardening. It is required by the SKU. The same deployment on a Basic load balancer would have served traffic with no NSG rule at all, which is exactly the kind of difference that breaks a migration nobody thinks of as a change.
The probe is the exception, and the service tag says why. AzureLoadBalancer "translates to the virtual IP address of the host (168.63.129.16) where the Azure health probes originate", and then the sentence I was missing: "This only includes probe traffic, not real traffic to your backend resource."
Which is the whole answer. The default rule at 65001 admits the probe and nothing else. Omit the client rule, and you get a load balancer whose backend is marked healthy and whose visitors get refused.
Step 3: The rule is what makes the other three real
az network lb probe create ... -n datacenter-health-probe --protocol tcp --port 80
az network lb rule create ... --frontend-port 80 --backend-port 80 \
--frontend-ip-name datacenter-lb-ip --backend-pool-name datacenter-backend-pool \
--probe-name datacenter-health-probe
| Component | Role |
|---|---|
| Frontend IP configuration | Where traffic arrives |
| Backend pool | Where traffic goes |
| Health probe | Which members are eligible |
| Rule | Binds the other three together |
The rule names all three. Build the frontend, the pool and the probe without one and you have an address that accepts connections and forwards nothing, which is the trap Day 36 described for an ALB with no listener.
Same shape as the Ansible half, with fewer parts. An inventory and a playbook each describe half of a deployment, and the command line is the rule.
Step 4: Pool membership lives on the NIC
az network nic ip-config address-pool add -g $RG --nic-name $NIC --ip-config-name $IPCFG \
--lb-name datacenter-lb --address-pool datacenter-backend-pool
Nothing is added from the load balancer's side. Membership is a property of the NIC's IP configuration, which is why the command lives under az network nic and needs the IP config name as well as the NIC.
Same pattern as attaching a public IP on Day 10. The thing that looks like it belongs to the network resource turns out to be a field on the NIC. And the names came from az vm create defaults, datacenter-vmVMNic and ipconfigdatacenter-vm, so both were derived rather than guessed. ipconfig1 would have failed.
What the docs recommend
The probe is the thing I would change.
--protocol tcp --port 80
| Probe | Healthy means |
|---|---|
| TCP on 80 | Something accepted a TCP connection |
HTTP on 80 with --path /
|
The application returned 200 |
A TCP probe passes happily against an nginx that is listening and returning 500 to every request. The task said only "on port 80" and TCP is the portal's default, so it satisfied the requirement. In a real deployment, the probe decides whether a broken instance keeps receiving traffic, so it should test the application rather than the socket.
Which is Day 81's Test stage all over again, two days later and one layer down. There, a curl proved the web server answered and not that the deployment had happened. Here, a probe proves a socket is open and not that the application works.
Two smaller things. az network lb create returns the load balancer at the top level with no wrapping object, where vnet create wraps in newVNet and public-ip create wraps in publicIp. Three conventions in one command group, no rule to learn, and the fix is Day 32's: run an unfamiliar command with plain -o json once before writing a query. The failure mode is nastier than usual here, because a blank output from a create reads as a failed command.
And the VM kept its own public IP, so nginx is now reachable two ways, directly and through the load balancer. Fine for the task and wrong for anything real: the point of a load balancer is to be the only entrance, because that is where health checking and traffic policy live.
What I learned
Both halves came down to the same question. Which of these things is doing the joining?
With Ansible it is the command line, and that is a design decision rather than an accident. Two files that know nothing about each other, bound at the moment you run them, which is what lets one playbook serve several environments and also what lets it hit the wrong one.
With the load balancer, it is the rule, and three resources sit there looking configured until it exists. Then the thing I would not have guessed: pool membership is not on the load balancer at all, it is a field on the network card.
The Standard SKU finding is the one I would act on. I had written the NSG rule up as sensible hardening. It is mandatory because Standard is closed to inbound unless an NSG opens it, and Basic was open by default. Anyone migrating from Basic inherits a load balancer whose backend reads healthy and whose users cannot reach it.
So here is the Day 83 question. In your setup, what is the single thing that joins the pieces, and what happens the day someone changes it?
Day 83 down. Seventeen to go.
Top comments (0)