Day 57 of DevOps, Day 7 of Azure. Both tasks today had values in them that I did not put there myself. In the pod, Kubernetes fills in $(GREETING) before the shell ever sees it. In Azure, a public IP created with nothing but a name still arrives with a SKU, an allocation method and a zone setting.
One Kubernetes task, one Azure task. Print three environment variables from a pod, then allocate a public IP address. The tasks come from the KodeKloud Engineer platform.
Task 1: Print environment variables from a pod
The task: a pod called print-envars-greeting, with one container, print-env-container, on the bash image. Three environment variables, a fixed echo command that prints them, and a restart policy of Never.
Step 1: Write the manifest
apiVersion: v1
kind: Pod
metadata:
name: print-envars-greeting
spec:
restartPolicy: Never
containers:
- name: print-env-container
image: bash
command: ["/bin/sh", "-c", 'echo "$(GREETING) $(COMPANY) $(GROUP)"']
env:
- name: GREETING
value: "Welcome to"
- name: COMPANY
value: "xFusionCorp"
- name: GROUP
value: "Group"
Each entry under env is a name and a value. The command runs through /bin/sh -c, and restartPolicy: Never tells Kubernetes to leave the container alone once the echo has finished.
Step 2: Apply it and read the output
kubectl apply -f k3s-pod.yml
kubectl logs -f print-envars-greeting
Welcome to xFusionCorp Group
Step 3: Check how the pod ended
kubectl get pods
kubectl describe pod print-envars-greeting
get pods shows Completed. describe prints the pod's spec, so it lists the three variables under Environment, next to the command exactly as I wrote it.
Completed is what kubectl displays. The pod's phase is Succeeded, which the docs define as: "All containers in the Pod have terminated in success, and will not be restarted." The Kubernetes docs warn against mixing the two up, calling Status "a kubectl display field for user intuition".
That ending depends on restartPolicy: Never. The default is Always, which restarts a container "after any termination", a successful exit included. Without the setting, this echo would be restarted over and over, with a delay that starts at 10 seconds and is capped at 5 minutes.
Who expands $(GREETING)
This is the part my notes had wrong. I had written that $(VARNAME) is shell variable syntax, processed by the shell at runtime.
It is the other way round. In a shell, $(name) is command substitution, which the Bash manual says "allows the output of a command to replace the command itself". A shell would try to run a command called GREETING.
Kubernetes gets there first. The API reference says of a container's command: "Variable references $(VAR_NAME) are expanded using the container's environment." So the three references are replaced with the values from env before the container starts, and the shell is handed a finished line:
echo "Welcome to xFusionCorp Group"
The shell's own forms are $GREETING and ${GREETING}. They would have worked here too, because the variables are in the container's environment. The difference is who does the substitution, and it matters when something is misspelt. The API reference again: "If a variable cannot be resolved, the reference in the input string will be unchanged." A typo such as $(GRETING) is passed through untouched, and the shell then treats it as a command to run.
To print the literal text, double the dollar sign: $$(GREETING).
One command that cannot work here
My notes included this as a way to see the variables:
kubectl exec print-envars-greeting -- env
It cannot work on this pod. kubectl checks the pod's phase first and refuses a pod that has finished. Going by the message in kubectl's source code, the error for this pod reads:
cannot exec into a container in a completed pod; current phase is Succeeded
kubectl describe pod is the way to see the variables afterwards. To inspect the live environment, the pod has to still be running.
What the docs recommend
A pod that runs once and exits is really a Job. The Jobs page recommends "a Job rather than a bare Pod, even if your application requires only a single Pod", because a bare pod is not restarted if its node reboots or fails.
Real configuration should not be hardcoded in the pod spec. A ConfigMap holds non-confidential values, a Secret holds confidential ones, and env points at them:
env:
- name: DATABASE_HOST
valueFrom:
configMapKeyRef:
name: app-config
key: db_host
- name: DATABASE_PASSWORD
valueFrom:
secretKeyRef:
name: db-secret
key: password
Two cautions from the docs go with that. ConfigMaps consumed as environment variables "are not updated automatically and require a pod restart". And Secrets are "by default, stored unencrypted in the API server's underlying data store (etcd)", so a Secret is only as protected as the cluster makes it.
Last, image: bash has no tag, and "If you don't specify a tag, Kubernetes assumes you mean the tag latest." The same page advises against :latest in production, because it is harder to track which version is running and harder to roll back.
Task 2: Allocate a public IP
The task: one public IP address named nautilus-pip. Nothing else was specified.
Step 1: Find the resource group
RG=$(az group list --query "[0].name" -o tsv)
The lab provides one resource group with a generated name, so I read it from the API instead of pasting it.
Step 2: Create the address
az network public-ip create -g "$RG" -n nautilus-pip
One thing to know before querying the response: create wraps its result in an object called publicIp, while show returns the same resource flat. A query written for one returns null for every field on the other.
Step 3: Check what was created
az network public-ip show -g "$RG" -n nautilus-pip --query "{IP:ipAddress,Sku:sku.name,State:provisioningState}"
The address was 20.84.50.139, the SKU was Standard, and the provisioning state was Succeeded. The create response had also shown the allocation method as Static. I had asked for none of it except the name.
What the defaults chose for me
The address is static, and it is already assigned. Nothing is attached to this IP, and it still has a real routable address. That follows from the SKU. Microsoft lists the allocation method for Standard as Static, and describes static assignment as: "The resource is assigned an IP address at the time it's created." Dynamic allocation only existed on the Basic SKU, and "On September 30, 2025, Basic SKU public IPs were retired."
It is closed to inbound traffic. Microsoft describes Standard as a "Secure by default model", closed to inbound traffic until a network security group allows it. Attach this address to a VM with no NSG rule, and the VM is unreachable, in a way that looks like a network fault and is really a policy.
It has a zone setting I did not choose. The CLI printed a warning on create:
[Coming breaking change] In the coming release, the default behavior will be changed as follows
when sku is Standard and zone is not provided: For zonal regions, you will get a zone-redundant IP
indicated by zones:["1","2","3"]; For non-zonal regions, you will get a non zone-redundant IP
indicated by zones:null.
My notes read that as a change still to come. Microsoft's documentation says it has already happened on the platform: "All formerly Standard non-zonal IPs are now zone-redundant in all regions that support availability zones". An address that shows no zones and one that shows zones 1, 2 and 3 "are equivalently zone-redundant". The meaning of a value I never set changed underneath an unchanged command.
It probably has a cost. Microsoft prices a Standard static IPv4 address by the hour, and its docs say "Public IPv4 addresses have a nominal charge". My notes said the charge applies whether or not the address is attached. I could not find a Microsoft page that says so in those words, so I would treat an idle address as billable until the bill says otherwise. AWS does say it outright: since 1 February 2024, it charges "for all public IPv4 addresses, whether attached to a service or not".
What the docs recommend
Pass what matters instead of inheriting it:
az network public-ip create -g "$RG" -n nautilus-pip --sku Standard --zone 1 2 3
The zone deserves the care, because Microsoft says a public IP cannot change its availability zone once created, and a zonal IP "shares fate with the health of the zone".
Find the addresses nothing is using:
az network public-ip list --query "[?ipConfiguration==null].{Name:name,IP:ipAddress,RG:resourceGroup}" -o table
And know the order for removing one. A public IP can only be deleted when it "isn't associated to any IP configuration or virtual machine network interface", so dissociate first, then delete. A static address that is deleted "can't be recovered", so anything holding the old address in DNS or a firewall allowlist breaks.
What I learned
Neither task had a hard step. The work was in noticing who supplied each value. Kubernetes substituted three variables before the shell started. Azure chose a SKU, an allocation method and a zone behaviour, and one of those has changed its meaning since the warning about it was written.
So here is the Day 57 question. Which default in your last deployment would surprise you if it changed tomorrow?
Day 57 down. Forty-three to go.
Top comments (6)
The defaults that scare me most are the ones that quietly become part of the architecture. π
A default port changing is annoying. A default affecting security, networking, storage, or persistence can ruin your afternoon.
Thatβs why I increasingly treat defaults as borrowed decisions: perfectly fine for a lab, but if a value matters in production, Iβd rather make it explicit even when Iβm choosing the default value anyway.
The Azure public IP example is a great illustration of that. Same command, but the meaning of something you never specified can evolve underneath you.
Also, great catch on $(GREETING). Correcting your own notes and explaining why they were wrong makes this series much more useful than simply showing commands.
Day 57 already. Youβre moving suspiciously fast, man. π
"Borrowed decisions" I like the phrase, and it explains why the Azure one bothered me more than a plain bad default would. I never made that decision at all. Microsoft made it, then changed it, and my command stayed identical through both.
Writing the default out explicitly even when you're picking the default anyway sounds like busywork until exactly that happens.
And haha, the pace is less impressive than it looks. I've got a free stretch at the moment for personal things, so I'm doing a lab a day and writing it up the same evening. That's the whole trick. No clever system, just time I won't have in a few months.
The note corrections are becoming my favourite part, honestly. Three posts running now where the lab was easy and the thing I believed going in was wrong. π
Haha, βno clever system, just timeβ is exactly what someone with a suspiciously effective system would say. π
But seriously, I think the note corrections are becoming one of the strongest parts of the series.
A successful lab tells me the commands worked. Finding out that something you believed beforehand was wrong tells me why it worked β and thatβs usually the part that sticks.
And enjoy that free stretch while you have it. A lab a day + writing it up the same evening is a pretty damn good way to spend it. By Day 100 you wonβt just have finished 100 tasks; youβll have built a searchable record of 100 things you actually investigated.
Keep going, man. π
Hahaha, you've caught me. The system is a calendar with nothing in it. π
And the searchable record is exactly what I'm after. Not the hundred tasks, the hundred corrections. Those are the ones I'd otherwise have carried around for years, confident and wrong, because being over-cautious or slightly off never throws an error at you.
Forty-three to go. π
Exactly. The dangerous mistakes arenβt always the ones that break things β theyβre the ones that work perfectly while reinforcing the wrong mental model. π
Those can survive for years because nothing ever forces you to question them.
So β100 tasksβ is nice, but β100 things I was confident about and actually verifiedβ is a much better series.
43 more chances to discover weβve all been confidently lying to ourselves. ππ
β€οΈ