<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Nnamdi Felix Ibe</title>
    <description>The latest articles on DEV Community by Nnamdi Felix Ibe (@ndcodes).</description>
    <link>https://dev.to/ndcodes</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3994627%2F3c1e9fc3-98cd-45c4-9e34-3ef72ece6604.jpg</url>
      <title>DEV Community: Nnamdi Felix Ibe</title>
      <link>https://dev.to/ndcodes</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ndcodes"/>
    <language>en</language>
    <item>
      <title>Day 67: A Guest Book and a Public Container</title>
      <dc:creator>Nnamdi Felix Ibe</dc:creator>
      <pubDate>Mon, 19 Oct 2026 17:00:00 +0000</pubDate>
      <link>https://dev.to/ndcodes/day-67-a-guest-book-and-a-public-container-3mef</link>
      <guid>https://dev.to/ndcodes/day-67-a-guest-book-and-a-public-container-3mef</guid>
      <description>&lt;p&gt;Day 67 of DevOps, Day 17 of Azure. Both tasks today turned on exactly which word sat in one field. In Kubernetes, &lt;code&gt;redis-master&lt;/code&gt; is not a label I chose, it is a hostname compiled into the application. In Azure, the difference between &lt;code&gt;blob&lt;/code&gt; and &lt;code&gt;container&lt;/code&gt; decides whether a stranger can list your files or merely read the ones they can already name.&lt;/p&gt;

&lt;p&gt;One Kubernetes task, one Azure task. Deploy a three-tier Guest Book, then create a storage account and a deliberately public container. The tasks come from the KodeKloud Engineer platform.&lt;/p&gt;

&lt;h2&gt;
  
  
  Task 1: A Guest Book in three tiers
&lt;/h2&gt;

&lt;p&gt;The task: a &lt;code&gt;redis-master&lt;/code&gt; Deployment with one replica behind a ClusterIP Service, a &lt;code&gt;redis-slave&lt;/code&gt; Deployment with two replicas behind a ClusterIP Service, and a &lt;code&gt;frontend&lt;/code&gt; Deployment with three replicas behind a NodePort Service on 30009. Clicking the app button before any of it existed returned 502.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: The write tier
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;apps/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deployment&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;redis-master&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;redis-master&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;replicas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
  &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;redis-master&lt;/span&gt;
  &lt;span class="na"&gt;strategy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{}&lt;/span&gt;
  &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;redis-master&lt;/span&gt;
    &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;redis:alpine&lt;/span&gt;
        &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;master-redis-devops&lt;/span&gt;
        &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;100m"&lt;/span&gt;
            &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;100Mi"&lt;/span&gt;
        &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;containerPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;6379&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Service&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;redis-master&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;redis-master&lt;/span&gt;
  &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;targetPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;6379&lt;/span&gt;
      &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;6379&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No &lt;code&gt;type&lt;/code&gt; on the Service, which makes it ClusterIP: "the default that is used if you don't explicitly specify a &lt;code&gt;type&lt;/code&gt; for a Service", and one that "makes the Service only reachable from within the cluster". That is the entire isolation story for the Redis tier, and it is a default rather than a decision.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: The read tier, two replicas
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;      &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gcr.io/google_samples/gb-redisslave:v3&lt;/span&gt;
        &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;slave-redis-devops&lt;/span&gt;
        &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;100m"&lt;/span&gt;
            &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;100Mi"&lt;/span&gt;
        &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;containerPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;6379&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;GET_HOSTS_FROM&lt;/span&gt;
          &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dns"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 3: The frontend, three replicas, on a node port
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;      &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gcr.io/google-samples/gb-frontend@sha256:a908df84...&lt;/span&gt;
        &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;php-redis-devops&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;GET_HOSTS_FROM"&lt;/span&gt;
          &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dns"&lt;/span&gt;
        &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;containerPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;80&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Service&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;frontend&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;NodePort&lt;/span&gt;
  &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;frontend&lt;/span&gt;
  &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;80&lt;/span&gt;
      &lt;span class="na"&gt;targetPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;80&lt;/span&gt;
      &lt;span class="na"&gt;nodePort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;30009&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;30009 sits in the static band of the node port range, 30000 to 30085, which is where a hand-picked number belongs. Automatic assignment draws from the dynamic band, 30086 to 32767, first.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: Apply and check
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; backend-deploy.yaml
kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; backend-svc.yaml
kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; redis-slave-deploy.yaml
kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; redis-slave-svc.yaml
kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; frontend-deploy.yaml
kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; frontend-svc.yaml

kubectl get deploy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NAME           READY   UP-TO-DATE   AVAILABLE   AGE
frontend       3/3     3            3           41s
redis-master   1/1     1            1           2m18s
redis-slave    2/2     2            2           90s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Why the Service names are not mine to choose
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;GET_HOSTS_FROM: dns&lt;/code&gt; looks like boilerplate. It is the switch that decides how the frontend finds Redis, and the guestbook PHP shows what the other side of it looks like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$host&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'redis-master'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;getenv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'GET_HOSTS_FROM'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s1"&gt;'env'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nv"&gt;$host&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;getenv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'REDIS_MASTER_SERVICE_HOST'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The hostnames are hardcoded. Setting the variable to anything other than &lt;code&gt;env&lt;/code&gt; leaves them as literal strings, and the application relies on cluster DNS to resolve them. A Service gets a DNS record "with a name of the form &lt;code&gt;my-svc.my-namespace.svc.cluster-domain.example&lt;/code&gt;", and "DNS queries that don't specify a namespace are limited to the Pod's namespace", so a bare &lt;code&gt;redis-master&lt;/code&gt; resolves inside the pod's own namespace.&lt;/p&gt;

&lt;p&gt;Which means a Service name here is an address, not a label. Rename the Deployment to anything you like and nothing breaks. Rename the Service and the application stops finding its database, with every object still reporting itself healthy.&lt;/p&gt;

&lt;p&gt;The alternative branch is worth noticing too. &lt;code&gt;REDIS_MASTER_SERVICE_HOST&lt;/code&gt; is the Docker-link-style variable the kubelet injects, and it only exists for Services created before the pod. That is the ordering trap from Day 62's notes, and &lt;code&gt;dns&lt;/code&gt; is how you avoid it.&lt;/p&gt;

&lt;h3&gt;
  
  
  The 502 I cannot explain
&lt;/h3&gt;

&lt;p&gt;The app returned 502 before the backend existed, and that is where my account stops. Nothing in the Kubernetes documentation describes what a client sees when a Service's selector matches no pods. The EndpointSlice page covers what EndpointSlices contain; the debugging guide goes as far as "If the &lt;code&gt;ENDPOINTS&lt;/code&gt; column is &lt;code&gt;&amp;lt;none&amp;gt;&lt;/code&gt;, you should check that the &lt;code&gt;spec.selector&lt;/code&gt; field".&lt;/p&gt;

&lt;p&gt;A 502 is a gateway error, so it came from whatever proxy fronted the lab's app button, not from kube-proxy or the Service. I am recording it as something I saw rather than something Kubernetes does.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the docs recommend
&lt;/h3&gt;

&lt;p&gt;Know that these images are retired. The current guestbook manifests use &lt;code&gt;us-docker.pkg.dev/google-samples/containers/gke/gb-redis-follower:v2&lt;/code&gt; and &lt;code&gt;gb-frontend:v5&lt;/code&gt;. The &lt;code&gt;gcr.io&lt;/code&gt; paths above match what the Kubernetes docs shipped around v1.19.&lt;/p&gt;

&lt;p&gt;Do not assume the underscore is a typo. &lt;code&gt;google_samples&lt;/code&gt; with an underscore and &lt;code&gt;google-samples&lt;/code&gt; with a hyphen both appear upstream, in the same manifest set. It is not even a stable per-image rule: at the v1.26 docs branch, the frontend was also &lt;code&gt;gcr.io/google_samples&lt;/code&gt;. Both images have used both spellings at different times.&lt;/p&gt;

&lt;p&gt;And the tutorial this descends from has moved on. kubernetes.io still publishes a guestbook, and since the v1.21 docs branch, its objects are &lt;code&gt;redis-leader&lt;/code&gt; and &lt;code&gt;redis-follower&lt;/code&gt;, with the prose reading "Start up a Redis leader." The master and slave naming belongs to the older branch and to this lab.&lt;/p&gt;

&lt;p&gt;Redis made the same change, and only halfway. Its &lt;code&gt;SLAVEOF&lt;/code&gt; page says the command "is regarded as deprecated" as of 5.0.0 and that "the Redis project no longer uses the word slave", pointing to &lt;code&gt;REPLICAOF&lt;/code&gt;. The current replication page still writes "master" for the other side, so replica replaced slave while primary did not replace master.&lt;/p&gt;

&lt;p&gt;One resource note. Every container sets requests and no limits, which makes each pod &lt;code&gt;Burstable&lt;/code&gt;. The class belongs to the pod, not the container: Burstable means the pod "does not meet the criteria for QoS class &lt;code&gt;Guaranteed&lt;/code&gt;" while "At least one Container in the Pod has a memory or CPU request or limit". Day 63 was the mirror image, where limits and no requests produced &lt;code&gt;Guaranteed&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Task 2: A deliberately public container
&lt;/h2&gt;

&lt;p&gt;The task: a storage account and a container with anonymous read access for containers and blobs. Day 16 inverted.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Set the account flag at creation
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az storage account create &lt;span class="nt"&gt;-g&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="nv"&gt;$SA&lt;/span&gt; &lt;span class="nt"&gt;--sku&lt;/span&gt; Standard_LRS &lt;span class="nt"&gt;--kind&lt;/span&gt; StorageV2 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--allow-blob-public-access&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Order matters here. A new account defaults the flag to false, and while it is false, Microsoft says "you can't configure the access setting for a container to permit anonymous access". Doing it the other way round costs a failed command and an &lt;code&gt;az storage account update&lt;/code&gt; before retrying.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Choose the level deliberately
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;az storage account keys list &lt;span class="nt"&gt;-g&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="nv"&gt;$SA&lt;/span&gt; &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s2"&gt;"[0].value"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; tsv&lt;span class="si"&gt;)&lt;/span&gt;
az storage container create &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="nv"&gt;$CONTAINER&lt;/span&gt; &lt;span class="nt"&gt;--account-name&lt;/span&gt; &lt;span class="nv"&gt;$SA&lt;/span&gt; &lt;span class="nt"&gt;--account-key&lt;/span&gt; &lt;span class="nv"&gt;$KEY&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--public-access&lt;/span&gt; container
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three values, and the requirement's wording picked one:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;th&gt;Read a known blob&lt;/th&gt;
&lt;th&gt;List the container&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;off&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;blob&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;container&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Microsoft's wording for the two public levels: "Blobs within the container can be read by anonymous request, but container data isn't available anonymously", against "Container and blob data can be read by anonymous request".&lt;/p&gt;

&lt;p&gt;The task said "anonymous read access for containers and blobs", naming both, which is &lt;code&gt;container&lt;/code&gt;. &lt;code&gt;blob&lt;/code&gt; would have satisfied half the requirement while looking correct in the portal. The distinction is not cosmetic: with &lt;code&gt;blob&lt;/code&gt;, a caller has to know a blob's exact name, and with &lt;code&gt;container&lt;/code&gt; they can enumerate everything and take the lot.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Prove it from outside
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"http_code=%{http_code}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"https://&lt;/span&gt;&lt;span class="nv"&gt;$SA&lt;/span&gt;&lt;span class="s2"&gt;.blob.core.windows.net/&lt;/span&gt;&lt;span class="nv"&gt;$CONTAINER&lt;/span&gt;&lt;span class="s2"&gt;?restype=container&amp;amp;comp=list"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;http_code=200
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No key, no login, no SAS token. &lt;code&gt;?restype=container&amp;amp;comp=list&lt;/code&gt; is the Blob REST List Blobs operation, so this tests the exact capability the requirement names without uploading anything first. A configuration read tells you the settings are right. This tells you the behaviour is correct.&lt;/p&gt;

&lt;h3&gt;
  
  
  The failure code I had wrong
&lt;/h3&gt;

&lt;p&gt;I have been repeating that a private container returns 404 rather than 403, and that Azure does this deliberately so as not to confirm the container exists. Checking it, neither half survives.&lt;/p&gt;

&lt;p&gt;Microsoft documents three outcomes keyed to the client's service version. For "a service version that supports the bearer challenge", the service "returns error code 401 (Unauthorized)". For an older version where anonymous access is disallowed on the account, 409 Conflict. For an older version where it is allowed, 404 Not Found. And separately: "After you disallow anonymous access for a storage account, clients that use the anonymous bearer challenge find that Azure Storage returns a 403 error (Forbidden) rather than a 401 error."&lt;/p&gt;

&lt;p&gt;So 401, 403, 404 and 409 are all documented, depending on which version the client negotiates and what the account permits. The "will not confirm it exists" rationale appears nowhere in Microsoft's documentation at all. It is a tidy story that explains one of the four codes, and I had been treating it as the rule.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the docs recommend
&lt;/h3&gt;

&lt;p&gt;Understand exactly how far anonymous access reaches, because it is narrower than it sounds and wider than it feels. The documented operation list is all reads, and clients "can enumerate blobs within the container via anonymous request, but can't enumerate containers within the storage account". No writes, no deletes, no visibility into other containers.&lt;/p&gt;

&lt;p&gt;Then understand who pays. Egress leaves the owner holding the bill, and Microsoft's bluntest statement of it is in the SAS guidance: "If you've given them read access as well, they may choose to download it 10 times, incurring 2 TB in egress costs for you." Every anonymous download is also a read transaction, and cool or archive tiers add a retrieval fee on top.&lt;/p&gt;

&lt;p&gt;Expect to be flagged for it. Microsoft Defender for Cloud carries a recommendation named "Storage account public access should be disallowed", describing anonymous read access as "a convenient way to share data, but might present security risks."&lt;/p&gt;

&lt;p&gt;And know that you cannot qualify it. An S3 bucket policy can condition public read on a prefix, a source IP or a referrer, through &lt;code&gt;s3:prefix&lt;/code&gt;, &lt;code&gt;aws:SourceIp&lt;/code&gt; or &lt;code&gt;aws:Referer&lt;/code&gt;. Azure's container access is a single enum with no condition syntax: on for the whole container, or off. The only qualifier that still applies is the storage firewall, since "any firewall rules that you configure for the storage account remain in effect".&lt;/p&gt;

&lt;p&gt;Which is why the production answer is a shared access signature rather than a public container. Microsoft notes a SAS "can be issued on a container or a blob regardless of whether container data is available for anonymous read access" and "provides a greater measure of control", and recommends a user delegation SAS "when possible for superior security", because it is signed with Entra credentials rather than the account key.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I learned
&lt;/h2&gt;

&lt;p&gt;The Kubernetes half was a lesson in which names are addresses. A Deployment name is a label for humans. A Service name is a hostname the application has compiled into it, so the one field that looks most like a free choice is the one that is not.&lt;/p&gt;

&lt;p&gt;The Azure half was a lesson about a story I had been repeating. The 404 explanation was plausible, it matched something I had seen, and it turned out to be one of four documented codes with none of the reasoning I had attached to it.&lt;/p&gt;

&lt;p&gt;So here is the Day 67 question. Which of your configuration values is a free label, and which one is something else quietly depending on?&lt;/p&gt;

&lt;p&gt;Day 67 down. Thirty-three to go.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>kubernetes</category>
      <category>azure</category>
      <category>containers</category>
    </item>
    <item>
      <title>Day 66: MySQL and a Private Container</title>
      <dc:creator>Nnamdi Felix Ibe</dc:creator>
      <pubDate>Sun, 18 Oct 2026 16:00:00 +0000</pubDate>
      <link>https://dev.to/ndcodes/day-66-mysql-and-a-private-container-48le</link>
      <guid>https://dev.to/ndcodes/day-66-mysql-and-a-private-container-48le</guid>
      <description>&lt;p&gt;Day 66 of DevOps, Day 16 of Azure. Both tasks today came down to a name being exactly right. I created a PersistentVolumeClaim under the wrong name, and the pod never got scheduled, with no container and no logs to look at. And an Azure storage account name has to be unique across the whole of Azure, because it becomes a hostname.&lt;/p&gt;

&lt;p&gt;One Kubernetes task, one Azure task. Run MySQL on a PersistentVolume with its credentials in Secrets, then create a private blob container. The tasks come from the KodeKloud Engineer platform.&lt;/p&gt;

&lt;h2&gt;
  
  
  Task 1: MySQL on a PersistentVolume
&lt;/h2&gt;

&lt;p&gt;The task: a 250Mi PersistentVolume and a claim, three Secrets for the root password, the application user and password, and the database name, a MySQL Deployment reading all four from those Secrets, and a NodePort Service on 30007.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: The volume and the claim
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;PersistentVolume&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mysql-pv&lt;/span&gt;
  &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;local&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;storageClassName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;manual&lt;/span&gt;
  &lt;span class="na"&gt;capacity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;storage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;250Mi&lt;/span&gt;
  &lt;span class="na"&gt;accessModes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;ReadWriteOnce&lt;/span&gt;
  &lt;span class="na"&gt;hostPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/mnt/dba"&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;PersistentVolumeClaim&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mysql-pv-claim&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;storageClassName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;manual&lt;/span&gt;
  &lt;span class="na"&gt;accessModes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;ReadWriteOnce&lt;/span&gt;
  &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;storage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;250Mi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; pv.yaml
kubectl get pv
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NAME       CAPACITY   ACCESS MODES   RECLAIM POLICY   STATUS      CLAIM   STORAGECLASS
mysql-pv   250Mi      RWO            Retain           Available           manual
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Retain&lt;/code&gt; is the documented default for a volume created by hand, as opposed to &lt;code&gt;Delete&lt;/code&gt; for a dynamically provisioned one.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Three Secrets, split by what rotates together
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl create secret generic mysql-user-pass &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--from-literal&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'username=kodekloud_tim'&lt;/span&gt; &lt;span class="nt"&gt;--from-literal&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'password=8FmzjvFU6S'&lt;/span&gt;
kubectl create secret generic mysql-root-pass &lt;span class="nt"&gt;--from-literal&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'password=YUIidhb667'&lt;/span&gt;
kubectl create secret generic mysql-db-url &lt;span class="nt"&gt;--from-literal&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'database=kodekloud_db6'&lt;/span&gt;
kubectl get secrets
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NAME              TYPE     DATA   AGE
mysql-db-url      Opaque   1      5s
mysql-root-pass   Opaque   1      42s
mysql-user-pass   Opaque   2      81s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The user Secret holds two keys, so its &lt;code&gt;DATA&lt;/code&gt; column reads 2. Rotating the application password then touches one object rather than three.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: The Deployment reads all four from Secrets
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;      &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mysql:8.0&lt;/span&gt;
        &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mysql-container&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;MYSQL_ROOT_PASSWORD&lt;/span&gt;
          &lt;span class="na"&gt;valueFrom&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;secretKeyRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mysql-root-pass&lt;/span&gt;
              &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;password&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;MYSQL_DATABASE&lt;/span&gt;
          &lt;span class="na"&gt;valueFrom&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;secretKeyRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mysql-db-url&lt;/span&gt;
              &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;database&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;MYSQL_USER&lt;/span&gt;
          &lt;span class="na"&gt;valueFrom&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;secretKeyRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mysql-user-pass&lt;/span&gt;
              &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;username&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;MYSQL_PASSWORD&lt;/span&gt;
          &lt;span class="na"&gt;valueFrom&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;secretKeyRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mysql-user-pass&lt;/span&gt;
              &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;password&lt;/span&gt;
        &lt;span class="na"&gt;volumeMounts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mysql-pvc&lt;/span&gt;
          &lt;span class="na"&gt;mountPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/var/lib/mysql&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;/var/lib/mysql&lt;/code&gt; is not an arbitrary choice. The official image documents it as "where MySQL by default will write its data files".&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: The pod that would not schedule
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Warning  FailedScheduling  0/1 nodes are available: persistentvolumeclaim "mysql-pv-claim" not found.
Warning  FailedScheduling  0/1 nodes are available: pod has unbound immediate PersistentVolumeClaims.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I had created the claim under the volume's name rather than its own, so &lt;code&gt;kubectl get pvc&lt;/code&gt; listed &lt;code&gt;mysql-pv&lt;/code&gt; while the Deployment asked for &lt;code&gt;mysql-pv-claim&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The useful part is where that showed up. There was no container, so no logs. There was no image pull to fail, so nothing in the pod's state. The pod simply never got scheduled, and the only account of why is in &lt;code&gt;kubectl describe pod&lt;/code&gt; under Events. That is a different debugging path from a container that starts and crashes, and the first place to look is different too.&lt;/p&gt;

&lt;p&gt;The two messages are also distinct. The first means the claim does not exist. The second means a claim exists and has not bound to anything yet, which is what you would see if the class or the capacity did not match.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 5: Watch the entrypoint build the database
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl logs &amp;lt;pod&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[Note] [Entrypoint]: Initializing database files
[Note] [Entrypoint]: Starting temporary server
[Note] [Entrypoint]: Creating database kodekloud_db6
[Note] [Entrypoint]: Creating user kodekloud_tim
[Note] [Entrypoint]: Giving user kodekloud_tim access to schema kodekloud_db6
[Note] [Entrypoint]: Stopping temporary server
[Note] [Entrypoint]: MySQL init process done. Ready for start up.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The image starts a temporary server, applies the environment variables, shuts it down, then starts the real one. That only happens on an empty data directory, so pointing a second pod at the same claim runs none of it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-it&lt;/span&gt; &amp;lt;pod&amp;gt; &lt;span class="nt"&gt;--&lt;/span&gt; mysql &lt;span class="nt"&gt;-u&lt;/span&gt; root &lt;span class="nt"&gt;-p&lt;/span&gt;
kubectl &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-it&lt;/span&gt; &amp;lt;pod&amp;gt; &lt;span class="nt"&gt;--&lt;/span&gt; mysql &lt;span class="nt"&gt;-u&lt;/span&gt; kodekloud_tim &lt;span class="nt"&gt;-p&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both logins work, which tests the credentials rather than the pod's status field.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Secret that is no longer a secret
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;kubectl describe pod&lt;/code&gt; is careful:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;MYSQL_ROOT_PASSWORD:  &amp;lt;set to the key 'password' in secret 'mysql-root-pass'&amp;gt;  Optional: false
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The container is not:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-it&lt;/span&gt; &amp;lt;pod&amp;gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="nb"&gt;env&lt;/span&gt; | &lt;span class="nb"&gt;grep &lt;/span&gt;MYSQL
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;MYSQL_PASSWORD=8FmzjvFU6S
MYSQL_ROOT_PASSWORD=YUIidhb667
MYSQL_DATABASE=kodekloud_db6
MYSQL_USER=kodekloud_tim
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Putting a Secret in an environment variable means anything that can run a command in that container can read it, and so can anything that reads &lt;code&gt;/proc/&amp;lt;pid&amp;gt;/environ&lt;/code&gt;. The Secret object did its job in transit and stopped protecting anything on arrival.&lt;/p&gt;

&lt;p&gt;Day 62 mounted a Secret as a file instead, which keeps the value out of the environment and lets the kubelet update it in place. The environment variable form has the opposite property: a change "will not be seen by the container unless it is restarted".&lt;/p&gt;

&lt;p&gt;Note too that &lt;code&gt;MYSQL_MAJOR&lt;/code&gt; and &lt;code&gt;MYSQL_VERSION&lt;/code&gt; are in that same output and came from the image. A grep across the environment does not distinguish what you injected from what was already there.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the docs recommend
&lt;/h3&gt;

&lt;p&gt;Set &lt;code&gt;storageClassName&lt;/code&gt; on both objects, and understand why omitting it is not the same as setting it empty. The docs separate three cases. &lt;code&gt;storageClassName: ""&lt;/code&gt; on a PVC "is always interpreted to be requesting a PV with no class". A PVC with no &lt;code&gt;storageClassName&lt;/code&gt; at all "is treated differently by the cluster, depending on whether the &lt;code&gt;DefaultStorageClass&lt;/code&gt; admission plugin is turned on", and where a default class exists, "All PVCs that have no &lt;code&gt;storageClassName&lt;/code&gt; can be bound only to PVs of that default".&lt;/p&gt;

&lt;p&gt;So on a cluster with a default StorageClass, a hand-made class-less PV and a PVC that just leaves the field out will never bind to each other. The claim gets dynamically provisioned somewhere else, and the PV sits &lt;code&gt;Available&lt;/code&gt; forever. Naming &lt;code&gt;manual&lt;/code&gt; on both, which is what the task asked for, avoids the question.&lt;/p&gt;

&lt;p&gt;This matters on k3s in particular, which ships the Local Path Provisioner as a packaged component deployed by default. Whether its &lt;code&gt;local-path&lt;/code&gt; class is annotated as the default is not something the k3s documentation states, and its own example sets the class explicitly rather than relying on one. So I can say the provisioner is there, and I cannot say from the docs that it is the default.&lt;/p&gt;

&lt;p&gt;Treat &lt;code&gt;hostPath&lt;/code&gt; as a lab-only arrangement. The example in the Kubernetes docs carries the warning in a code comment, "for single node testing only; WILL NOT WORK in a multi-node cluster", and the Volumes page is blunter about the type: "Using the &lt;code&gt;hostPath&lt;/code&gt; volume type presents many security risks. If you can avoid using a &lt;code&gt;hostPath&lt;/code&gt; volume, you should."&lt;/p&gt;

&lt;p&gt;Give a database some resources. This pod is &lt;code&gt;BestEffort&lt;/code&gt;, because nothing sets requests or limits, which makes it the first thing evicted when the node comes under memory pressure.&lt;/p&gt;

&lt;p&gt;And check what the Service actually reaches. Mine listens on 80, targets 80, and &lt;code&gt;describe svc&lt;/code&gt; shows &lt;code&gt;Endpoints: 10.22.0.9:80&lt;/code&gt;, while MySQL is bound to 3306. The task specified those numbers, so it passes, and the Service routes to a closed port. That is Day 64's fault from the other direction: there I found a wrong &lt;code&gt;targetPort&lt;/code&gt; by fixing the thing that hid it, and here nothing hides it at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Task 2: A private blob container
&lt;/h2&gt;

&lt;p&gt;The task: a storage account &lt;code&gt;xfusionst744158879&lt;/code&gt;, and a private container &lt;code&gt;xfusion-blob-744158879&lt;/code&gt; inside it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Two names, two rule sets
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Storage account&lt;/th&gt;
&lt;th&gt;Container&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Uniqueness&lt;/td&gt;
&lt;td&gt;Across Azure&lt;/td&gt;
&lt;td&gt;Within the account&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Length&lt;/td&gt;
&lt;td&gt;3 to 24&lt;/td&gt;
&lt;td&gt;3 to 63&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Characters&lt;/td&gt;
&lt;td&gt;Lowercase letters and digits&lt;/td&gt;
&lt;td&gt;Lowercase, digits, hyphens&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hyphens&lt;/td&gt;
&lt;td&gt;Not allowed&lt;/td&gt;
&lt;td&gt;Allowed, not consecutive, each between alphanumerics&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Microsoft: "Storage account names must be between 3 and 24 characters in length. They can contain only numbers and lowercase letters", and "Your storage account name must be unique within Azure."&lt;/p&gt;

&lt;p&gt;The two names the task gave me look inconsistent and each is the only legal form for its resource.&lt;/p&gt;

&lt;p&gt;Uniqueness across Azure is a DNS requirement, because the account name is a subdomain:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;xfusionst744158879.blob.core.windows.net
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Which is why the lab appended a random-looking number. &lt;code&gt;xfusionst&lt;/code&gt; alone would collide with somebody else's account somewhere in the world, in a subscription you cannot see.&lt;/p&gt;

&lt;p&gt;I had been calling that failure &lt;code&gt;StorageAccountAlreadyTaken&lt;/code&gt;. It is not an error code Microsoft documents. Account creation is a control-plane operation, where the name check returns a reason of &lt;code&gt;AlreadyExists&lt;/code&gt;; the data-plane error table has &lt;code&gt;AccountAlreadyExists&lt;/code&gt; with a 409. Neither is spelled the way I had it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Create the account and the container
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az storage account create &lt;span class="nt"&gt;-g&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="nv"&gt;$SA&lt;/span&gt; &lt;span class="nt"&gt;--sku&lt;/span&gt; Standard_LRS &lt;span class="nt"&gt;--kind&lt;/span&gt; StorageV2

&lt;span class="nv"&gt;KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;az storage account keys list &lt;span class="nt"&gt;-g&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="nv"&gt;$SA&lt;/span&gt; &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s2"&gt;"[0].value"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; tsv&lt;span class="si"&gt;)&lt;/span&gt;
az storage container create &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="nv"&gt;$CONTAINER&lt;/span&gt; &lt;span class="nt"&gt;--account-name&lt;/span&gt; &lt;span class="nv"&gt;$SA&lt;/span&gt; &lt;span class="nt"&gt;--account-key&lt;/span&gt; &lt;span class="nv"&gt;$KEY&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 3: Confirm it is private on both layers
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az storage account show &lt;span class="nt"&gt;-g&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="nv"&gt;$SA&lt;/span&gt; &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s2"&gt;"{AllowBlobPublic:allowBlobPublicAccess}"&lt;/span&gt;
az storage container show &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="nv"&gt;$CONTAINER&lt;/span&gt; &lt;span class="nt"&gt;--account-name&lt;/span&gt; &lt;span class="nv"&gt;$SA&lt;/span&gt; &lt;span class="nt"&gt;--account-key&lt;/span&gt; &lt;span class="nv"&gt;$KEY&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s2"&gt;"{PublicAccess:properties.publicAccess}"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"AllowBlobPublic"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"PublicAccess"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two independent switches, and either one blocking is enough. The account-level default is documented as "The default interpretation is false for this property", and the word interpretation is doing real work there: the stored value can legitimately be null, and Microsoft's own remediation script treats null and true alike.&lt;/p&gt;

&lt;p&gt;At the container level, private is the absence of a value rather than a value named private: "If this header isn't returned in the response, the container is private to the account owner."&lt;/p&gt;

&lt;p&gt;Worth knowing that one state has five spellings across Azure's own surfaces. The portal calls it Private, PowerShell calls it Off, the CLI accepts &lt;code&gt;blob, container, off&lt;/code&gt;, the data plane omits the header, and Azure Resource Manager returns the string &lt;code&gt;"None"&lt;/code&gt;. The one thing consistently true is that no API has a value called "private", so a check for &lt;code&gt;publicAccess == 'private'&lt;/code&gt; matches nothing anywhere.&lt;/p&gt;

&lt;h3&gt;
  
  
  The permission system that is two permission systems
&lt;/h3&gt;

&lt;p&gt;I created the container with the account key rather than my signed-in identity, and that was not laziness. Microsoft: "Built-in roles such as Owner, Contributor, and Storage Account Contributor permit a security principal to manage a storage account, but don't provide access to the blob data within that account via Microsoft Entra ID."&lt;/p&gt;

&lt;p&gt;So Owner can create the account, read its keys and change its configuration, and still be refused when listing blobs with &lt;code&gt;--auth-mode login&lt;/code&gt;. Data access needs its own roles: Storage Blob Data Owner, Contributor or Reader.&lt;/p&gt;

&lt;p&gt;There is a caveat that spoils the tidy version, and I had missed it. Owner and Contributor include &lt;code&gt;Microsoft.Storage/storageAccounts/listKeys/action&lt;/code&gt;, and Microsoft spells out what follows: a user with such a role "can access data in the storage account via Shared Key authorisation by using the account access keys". The separation is real at the authorisation layer and leaky in practice, unless shared key access is switched off.&lt;/p&gt;

&lt;p&gt;Role assignments also take time, and Microsoft publishes more than one number for it: "up to 30 minutes to propagate" in one callout, "up to 10 minutes for changes to take effect" in another on the same page, and up to 12 hours for data actions at management group scope. Any figure quoted for this needs its source named.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the docs recommend
&lt;/h3&gt;

&lt;p&gt;Prefer identity to keys. The account key is documented as 512-bit, there are two, so one can rotate while the other is live, and "Storage account access keys provide full access to the storage account data and the ability to generate SAS tokens". No expiry, no identity, so the access log records the operation and not the person. Microsoft's order of preference is Entra ID with data roles, then a user delegation SAS, then keys.&lt;/p&gt;

&lt;p&gt;If you disable keys, know what else stops working. Setting &lt;code&gt;allowSharedKeyAccess&lt;/code&gt; to false means "Azure Storage rejects all subsequent requests to that account that are authorised with the account access keys", and that includes service SAS and account SAS, since both are signed with the shared key. A user delegation SAS keeps working, because it is signed with an Entra ID key. The default is permissive: "The default value is null, which is equivalent to true."&lt;/p&gt;

&lt;p&gt;Turn on soft delete before anything real goes in, and check the default rather than assuming it. Blob soft delete "is enabled by default when you create a new storage account with the Azure portal", and Microsoft says just as directly that it "isn't enabled when you create a new storage account with Azure CLI". This account was made with the CLI, so it has none. Container soft delete's default is not documented either way.&lt;/p&gt;

&lt;p&gt;One correction to my own redundancy table. I had written LRS as three copies in one data centre. The managed disk documentation does say three copies, but the storage account redundancy page no longer gives a count for LRS: it says "a single physical datacenter located in the primary region of your choice" and states durability as "at least 99.999999999% (11 9s)" instead. ZRS is "three or more Azure availability zones", not exactly three. If a post states several copies for a storage account, it is ahead of the documentation.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I learned
&lt;/h2&gt;

&lt;p&gt;The two halves disagreed about how much a name is worth. In Kubernetes, a wrong name costs you a pod that never schedules, with a clear message waiting in the events. In Azure, a storage account name is a hostname in a namespace shared with everyone, so a name that is merely sensible is a name somebody else already has.&lt;/p&gt;

&lt;p&gt;The other thing I am taking from today is the environment variable output. The Secret objects worked exactly as designed, and three seconds with &lt;code&gt;env&lt;/code&gt; inside the container printed every value back in plain text. The protection was in the transport, not in the destination.&lt;/p&gt;

&lt;p&gt;So here is the Day 66 question. Where in your system does a secret stop being handled as a secret, and does anything in your tooling tell you where that line is?&lt;/p&gt;

&lt;p&gt;Day 66 down. Thirty-four to go.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>kubernetes</category>
      <category>azure</category>
      <category>containers</category>
    </item>
    <item>
      <title>Day 65: An Unread ConfigMap and an Unattached NSG</title>
      <dc:creator>Nnamdi Felix Ibe</dc:creator>
      <pubDate>Sat, 17 Oct 2026 17:00:00 +0000</pubDate>
      <link>https://dev.to/ndcodes/day-65-an-unread-configmap-and-an-unattached-nsg-3m5k</link>
      <guid>https://dev.to/ndcodes/day-65-an-unread-configmap-and-an-unattached-nsg-3m5k</guid>
      <description>&lt;p&gt;Day 65 of DevOps, Day 15 of Azure. Both tasks today produced an object that looks configured and enforces nothing. A ConfigMap mounted into a Redis container that Redis never opens, and a network security group with two carefully ordered rules and nothing attached to apply them to.&lt;/p&gt;

&lt;p&gt;Both passed. Neither does what its shape suggests.&lt;/p&gt;

&lt;p&gt;One Kubernetes task, one Azure task. Deploy Redis with a ConfigMap, then create an NSG with two inbound rules. The tasks come from the KodeKloud Engineer platform.&lt;/p&gt;

&lt;h2&gt;
  
  
  Task 1: Redis with a ConfigMap
&lt;/h2&gt;

&lt;p&gt;The task: a ConfigMap &lt;code&gt;my-redis-config&lt;/code&gt; with the key &lt;code&gt;redis-config&lt;/code&gt; carrying a &lt;code&gt;maxmemory&lt;/code&gt; setting, and a Deployment &lt;code&gt;redis-deployment&lt;/code&gt; running &lt;code&gt;redis:alpine&lt;/code&gt; with one replica, port 6379, a CPU request of 1, an &lt;code&gt;emptyDir&lt;/code&gt; at &lt;code&gt;/redis-master-data&lt;/code&gt;, and the ConfigMap mounted at &lt;code&gt;/redis-master&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: The ConfigMap
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ConfigMap&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;my-redis-config&lt;/span&gt;
&lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;redis-config&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;maxmemory=2mb&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 2: Generate the Deployment, then fill it in
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl create deploy redis-deployment &lt;span class="nt"&gt;--replicas&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 &lt;span class="nt"&gt;--image&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;redis:alpine &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--dry-run&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;client &lt;span class="nt"&gt;-o&lt;/span&gt; yaml &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; deployment.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;    &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;data&lt;/span&gt;
          &lt;span class="na"&gt;emptyDir&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{}&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;redis-config&lt;/span&gt;
          &lt;span class="na"&gt;configMap&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;my-redis-config&lt;/span&gt;
      &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;redis:alpine&lt;/span&gt;
        &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;redis-container&lt;/span&gt;
        &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;containerPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;6379&lt;/span&gt;
        &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1"&lt;/span&gt;
        &lt;span class="na"&gt;volumeMounts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;data&lt;/span&gt;
          &lt;span class="na"&gt;mountPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/redis-master-data&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;redis-config&lt;/span&gt;
          &lt;span class="na"&gt;mountPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/redis-master&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 3: Apply and check
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; config.yaml
kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; deployment.yaml
kubectl get deploy
kubectl get po
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NAME               READY   UP-TO-DATE   AVAILABLE   AGE
redis-deployment   1/1     1            1           21s

NAME                                READY   STATUS    RESTARTS   AGE
redis-deployment-6b4f7dfc48-c629j   1/1     Running   0          33s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every requirement is met. &lt;code&gt;describe deploy&lt;/code&gt; lists the CPU request, both mounts and the ConfigMap volume. &lt;code&gt;describe po&lt;/code&gt; shows &lt;code&gt;QoS Class: Burstable&lt;/code&gt;, which follows from setting a request and no limits.&lt;/p&gt;

&lt;h3&gt;
  
  
  The part that does not work
&lt;/h3&gt;

&lt;p&gt;Nothing in this manifest passes the mounted file to Redis.&lt;/p&gt;

&lt;p&gt;There is no &lt;code&gt;command&lt;/code&gt; and no &lt;code&gt;args&lt;/code&gt;, so the container runs the image's entrypoint, which starts &lt;code&gt;redis-server&lt;/code&gt; with no configuration file argument. Redis is explicit about what that requires: "Note that to read the configuration file, Redis must be started with the file path as first argument." And about what happens otherwise: "Redis is able to start without a configuration file using a built-in default configuration, however this setup is only recommended for testing and development purposes."&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;maxmemory&lt;/code&gt; keeps its default, which the docs give as zero: "Set &lt;code&gt;maxmemory&lt;/code&gt; to zero to specify that you don't want to limit the memory for the dataset. This is the default behavior for 64-bit systems."&lt;/p&gt;

&lt;p&gt;I did not run &lt;code&gt;CONFIG GET maxmemory&lt;/code&gt; on this pod, so that is reasoning from three documented facts rather than something I watched. The Kubernetes tutorial for this exact setup publishes the transcript, and it shows &lt;code&gt;maxmemory&lt;/code&gt; as &lt;code&gt;"0"&lt;/code&gt; and &lt;code&gt;maxmemory-policy&lt;/code&gt; as &lt;code&gt;"noeviction"&lt;/code&gt; before it recreates the pod with a config-reading command, then &lt;code&gt;"2097152"&lt;/code&gt; and &lt;code&gt;"allkeys-lru"&lt;/code&gt; afterwards.&lt;/p&gt;

&lt;p&gt;There is a second problem waiting behind the first. A ConfigMap volume names each file after its key, so the mount produces &lt;code&gt;/redis-master/redis-config&lt;/code&gt;. Anyone who adds a &lt;code&gt;command&lt;/code&gt; copied from the tutorial would point Redis at &lt;code&gt;/redis-master/redis.conf&lt;/code&gt;, which does not exist here.&lt;/p&gt;

&lt;p&gt;And a third. redis.conf directives are space-separated. The value in my ConfigMap reads &lt;code&gt;maxmemory=2mb&lt;/code&gt; with an equals sign, which is not the directive syntax. A grader can match that string; Redis cannot parse it.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the official example does differently
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;redis-server&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/redis-master/redis.conf"&lt;/span&gt;
&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;config&lt;/span&gt;
  &lt;span class="na"&gt;configMap&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;example-redis-config&lt;/span&gt;
    &lt;span class="na"&gt;items&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;redis-config&lt;/span&gt;
      &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;redis.conf&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two additions, both necessary. The &lt;code&gt;command&lt;/code&gt; hands Redis the path. The &lt;code&gt;items&lt;/code&gt; block renames the key to &lt;code&gt;redis.conf&lt;/code&gt; so that path exists. Drop either one and the file goes unread.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the docs recommend
&lt;/h3&gt;

&lt;p&gt;If a container takes a config file, give it the path. The mount is only half of it, and nothing in Kubernetes will tell you the other half is missing, because from the cluster's point of view the volume mounted correctly and the container is running.&lt;/p&gt;

&lt;p&gt;Get the units right while you are there. redis.conf's table: "1mb =&amp;gt; 1024*1024 bytes", so &lt;code&gt;2mb&lt;/code&gt; is 2097152. &lt;code&gt;2m&lt;/code&gt; would be 2000000, since "1m =&amp;gt; 1000000 bytes". The suffix carries the difference between binary and decimal.&lt;/p&gt;

&lt;p&gt;Know what &lt;code&gt;allkeys-lru&lt;/code&gt; is and is not. The docs define it as "Evict the least recently used (LRU) keys" and call it "a good default option if you have no reason to prefer any others". That is advice, not the default. The default policy is &lt;code&gt;noeviction&lt;/code&gt;, so an unconfigured Redis under memory pressure returns errors on writes rather than evicting anything.&lt;/p&gt;

&lt;p&gt;Do not reach for &lt;code&gt;CONFIG SET&lt;/code&gt; as a fix. It changes the running server and not the file: "modifying the configuration on the fly does not affect the &lt;code&gt;redis.conf&lt;/code&gt; and &lt;code&gt;redis-full.conf&lt;/code&gt; files, so at the next restart of Redis, the old configuration will be used instead." The command that persists it is &lt;code&gt;CONFIG REWRITE&lt;/code&gt;, and on this pod it would fail, because "if the server was started without a configuration file at all, the CONFIG REWRITE will just return an error."&lt;/p&gt;

&lt;p&gt;Size the request against the node. A CPU request of &lt;code&gt;1&lt;/code&gt; is a whole core, since "1 CPU unit is equivalent to 1 physical CPU core". On a single-node lab cluster, that is most of what exists. Worth noting the docs never actually write that 1 equals 1000m; they give the fractional form as &lt;code&gt;0.1&lt;/code&gt; equaling &lt;code&gt;100m&lt;/code&gt; and leave the rest to arithmetic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Task 2: An NSG with two rules
&lt;/h2&gt;

&lt;p&gt;The task: create &lt;code&gt;devops-nsg&lt;/code&gt; with &lt;code&gt;Allow-HTTP&lt;/code&gt; on port 80 and &lt;code&gt;Allow-SSH&lt;/code&gt; on port 22, both from &lt;code&gt;0.0.0.0/0&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: A failure that described the wrong problem
&lt;/h3&gt;

&lt;p&gt;My first attempt skipped creating the NSG and went straight to the rule:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;(RequestDisallowedByPolicy) Resource 'Allow-HTTP' was disallowed by policy.
Reasons: 'Global: This resource location is not allowed. Please use one of the
approved locations: eastus, southcentralus, centralus, westus.'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing in that message is about the real problem, which is that the parent did not exist. The fix was &lt;code&gt;az network nsg create&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I had an explanation for the shape of the error: a security rule is a child resource with no location of its own, so with nothing to inherit from, ARM evaluated it as &lt;code&gt;Global&lt;/code&gt;, and the subscription's Azure Policy rejects that.&lt;/p&gt;

&lt;p&gt;It fits, and it is not documented. What the documentation supports is narrower. The ARM reference for &lt;code&gt;securityRules&lt;/code&gt; lists four top-level properties and no &lt;code&gt;location&lt;/code&gt;, while the parent network security group does have one. So the schema omits location for the child. No Microsoft page states that a child inherits its parent's location, and the general guidance says you "must provide a location for each resource". Microsoft's own page on this error attributes it entirely to policy and never mentions child resources or missing parents.&lt;/p&gt;

&lt;p&gt;So the lesson is the observation: create the parent first, and a &lt;code&gt;Global&lt;/code&gt; policy error on a child is what a missing parent looked like. The mechanism is my reconstruction.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Create the NSG, then the rules
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az network nsg create &lt;span class="nt"&gt;-g&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; devops-nsg

az network nsg rule create &lt;span class="nt"&gt;-g&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;--nsg-name&lt;/span&gt; devops-nsg &lt;span class="nt"&gt;-n&lt;/span&gt; Allow-HTTP &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--priority&lt;/span&gt; 100 &lt;span class="nt"&gt;--direction&lt;/span&gt; Inbound &lt;span class="nt"&gt;--access&lt;/span&gt; Allow &lt;span class="nt"&gt;--protocol&lt;/span&gt; Tcp &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--source-address-prefixes&lt;/span&gt; 0.0.0.0/0 &lt;span class="nt"&gt;--destination-port-ranges&lt;/span&gt; 80

az network nsg rule create &lt;span class="nt"&gt;-g&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;--nsg-name&lt;/span&gt; devops-nsg &lt;span class="nt"&gt;-n&lt;/span&gt; Allow-SSH &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--priority&lt;/span&gt; 110 &lt;span class="nt"&gt;--direction&lt;/span&gt; Inbound &lt;span class="nt"&gt;--access&lt;/span&gt; Allow &lt;span class="nt"&gt;--protocol&lt;/span&gt; Tcp &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--source-address-prefixes&lt;/span&gt; 0.0.0.0/0 &lt;span class="nt"&gt;--destination-port-ranges&lt;/span&gt; 22
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Priority has no default, and the CLI lists it as required. Microsoft: "A number between 100 and 4096. Rules are processed in priority order, with lower numbers processed before higher numbers because lower numbers have higher priority. Once traffic matches a rule, processing stops."&lt;/p&gt;

&lt;p&gt;100 and 110 rather than 100 and 101, so a rule can be inserted between them later without renumbering everything.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Tcp&lt;/code&gt; rather than &lt;code&gt;*&lt;/code&gt;, because &lt;code&gt;*&lt;/code&gt; would also open UDP on 80 and 22, and neither HTTP nor SSH uses it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: List the rules, including the ones you did not write
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az network nsg rule list &lt;span class="nt"&gt;-g&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;--nsg-name&lt;/span&gt; devops-nsg &lt;span class="nt"&gt;--include-default&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; table
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every NSG ships with defaults that custom rules have to beat:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Priority&lt;/th&gt;
&lt;th&gt;Inbound rule&lt;/th&gt;
&lt;th&gt;Effect&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;65000&lt;/td&gt;
&lt;td&gt;&lt;code&gt;AllowVnetInBound&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Traffic within the VNet&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;65001&lt;/td&gt;
&lt;td&gt;&lt;code&gt;AllowAzureLoadBalancerInBound&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Azure health probes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;65500&lt;/td&gt;
&lt;td&gt;&lt;code&gt;DenyAllInBound&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Everything else&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;DenyAllInBound&lt;/code&gt; at 65500 is why an NSG with no custom rules blocks everything from outside the VNet. The defaults sit outside the 100 to 4096 custom range on purpose, so your rules always run first, and "You can't remove the default rules, but you can override them by creating rules with higher priorities."&lt;/p&gt;

&lt;p&gt;They are also in a different place in the API. &lt;code&gt;securityRules&lt;/code&gt; holds yours; &lt;code&gt;defaultSecurityRules&lt;/code&gt; holds these. A query against the first returns two rules and looks complete.&lt;/p&gt;

&lt;h3&gt;
  
  
  The source that is not what I thought
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"Src"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"0.0.0.0/0"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Azure stored exactly what I sent rather than normalising it, which matters for a graded task where a checker may be matching the literal string.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;*&lt;/code&gt; would have been identical: "0.0.0.0/0 in the Source and Destination columns represents all IP addresses. Clients like the Azure portal, Azure CLI, or PowerShell can use * or Any for this expression."&lt;/p&gt;

&lt;p&gt;I had been treating the service tag &lt;code&gt;Internet&lt;/code&gt; as a third spelling of the same thing. It is not. Microsoft defines it as "The IP address space that's outside the virtual network and reachable by the public internet." Outside is the operative word: &lt;code&gt;Internet&lt;/code&gt; excludes the virtual network's own address space, and &lt;code&gt;0.0.0.0/0&lt;/code&gt; does not. Substituting one for the other changes which traffic matches.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the docs recommend
&lt;/h3&gt;

&lt;p&gt;Remember that an unattached NSG enforces nothing. This one is associated with no NIC and no subnet, so its two rules are inert configuration. The task asked only for the NSG and its rules, so I left it that way, and the moment it is attached to anything, &lt;code&gt;Allow-SSH&lt;/code&gt; from &lt;code&gt;0.0.0.0/0&lt;/code&gt; exposes SSH to the whole internet.&lt;/p&gt;

&lt;p&gt;Mind the ordering when adding rules later, because this is where Azure differs from AWS in a way that bites. An AWS security group cannot contradict itself: "You can specify allow rules, but not deny rules", and it "Evaluates all rules before deciding whether to allow traffic". An Azure NSG has Deny rules and stops at the first match, so a new Deny at priority 100 silently disables an Allow at 200.&lt;/p&gt;

&lt;p&gt;AWS does have an equivalent construct, and it is the network ACL rather than the security group: "Each rule has a number from 1 to 32766. We evaluate the rules in order, starting with the lowest numbered rule... If the traffic matches a rule, the rule is applied and we do not evaluate any additional rules." That is much closer to an NSG, with the difference that network ACLs are stateless.&lt;/p&gt;

&lt;p&gt;One correction to something I had written about the API. Azure keeps singular and plural properties for source and port, &lt;code&gt;sourceAddressPrefix&lt;/code&gt; and &lt;code&gt;sourceAddressPrefixes&lt;/code&gt;, and I had said that exactly one is populated while the other returns an empty array. The REST reference does not say that, and its own sample response omits the plural keys entirely rather than returning &lt;code&gt;[]&lt;/code&gt;. The empty array is what the Azure CLI renders for an unset list. The practical warning survives and now points at the right layer: a query written against one form breaks when someone adds a second CIDR.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I learned
&lt;/h2&gt;

&lt;p&gt;Both halves ended with an object that satisfies its requirements and changes nothing. The ConfigMap is mounted at the right path with the right key, and Redis never opens it. The NSG has the right rules in the right order and is attached to nothing.&lt;/p&gt;

&lt;p&gt;What makes the first one worse is that every signal available inside Kubernetes says it worked. The volume mounted, the container is running, &lt;code&gt;describe&lt;/code&gt; lists everything the task asked for. The only way to find out is to ask Redis what its configuration actually is, which is a step outside the system that was doing the reporting.&lt;/p&gt;

&lt;p&gt;So here is the Day 65 question. What in your setup is mounted, loaded, attached or deployed, and has never been asked whether it is actually in effect?&lt;/p&gt;

&lt;p&gt;Day 65 down. Thirty-five to go.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>kubernetes</category>
      <category>azure</category>
      <category>containers</category>
    </item>
    <item>
      <title>Day 64: A Bad Image and a Small Disk</title>
      <dc:creator>Nnamdi Felix Ibe</dc:creator>
      <pubDate>Fri, 16 Oct 2026 17:00:00 +0000</pubDate>
      <link>https://dev.to/ndcodes/day-64-a-bad-image-and-a-small-disk-1m4</link>
      <guid>https://dev.to/ndcodes/day-64-a-bad-image-and-a-small-disk-1m4</guid>
      <description>&lt;p&gt;Day 64 of DevOps, Day 14 of Azure. Today I had to withdraw an explanation. The Kubernetes half went fine, but the Azure half rested on a tidy theory I had built from one observation on Day 8, and when I checked it against the source, it was wrong.&lt;/p&gt;

&lt;p&gt;One Kubernetes task, one Azure task. Fix a Flask app whose pod will not start and whose Service points at the wrong port, then create a managed disk. The tasks come from the KodeKloud Engineer platform.&lt;/p&gt;

&lt;h2&gt;
  
  
  Task 1: Two faults, one application
&lt;/h2&gt;

&lt;p&gt;The task: &lt;code&gt;python-deployment-devops&lt;/code&gt; is not running, and &lt;code&gt;python-service-devops&lt;/code&gt; does not reach it. Find out why and fix both.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Ask the Deployment what it thinks it is running
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get deploy
kubectl describe deployments.apps
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NAME                       READY   UP-TO-DATE   AVAILABLE   AGE
python-deployment-devops   0/1     1            0           4m35s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Containers:
 python-container-devops:
  Image:         poroko/flask-app-demo
  Port:          5000/TCP
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two facts worth holding on to: the image name, and that the container declares port 5000.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Ask the pod what went wrong
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get po
kubectl describe po
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NAME                                        READY   STATUS         RESTARTS   AGE
python-deployment-devops-7dd8f6ddf8-4z6qd   0/1     ErrImagePull   0          5m52s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;State:          Waiting
  Reason:       ImagePullBackOff

Events:
  Warning  Failed   Failed to pull image "poroko/flask-app-demo": failed to resolve reference
           "docker.io/poroko/flask-app-demo:latest": pull access denied, repository does not
           exist or may require authorisation: server message: insufficient_scope
  Warning  Failed   Error: ErrImagePull
  Warning  Failed   Error: ImagePullBackOff
  Normal   BackOff  Back-off pulling image "poroko/flask-app-demo"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The existing image is &lt;code&gt;poroko/flask-demo-app&lt;/code&gt;. The Deployment asks for &lt;code&gt;poroko/flask-app-demo&lt;/code&gt;. Two words transposed.&lt;/p&gt;

&lt;p&gt;The registry error offers three explanations and only one applies: "pull access denied, repository does not exist or may require authorisation". A registry will not tell an anonymous client whether a private repository exists, so a typo and a missing credential produce the same message. That is why the docs' first check is the dull one: "Make sure that you have the name of the image correct."&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Fix the image in place
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl edit deployment
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NAME                       READY   UP-TO-DATE   AVAILABLE   AGE
python-deployment-devops   1/1     1            1           12m
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;kubectl edit&lt;/code&gt; is enough here, and for a specific documented reason: "A Deployment's rollout is triggered if and only if the Deployment's Pod template (that is, &lt;code&gt;.spec.template&lt;/code&gt;) is changed, for example if the labels or container images of the template are updated." The image is in the template, so editing it rolls out new pods by itself.&lt;/p&gt;

&lt;p&gt;The alternative some walkthroughs take, deleting the Deployment and recreating it from an exported file, also deletes its ReplicaSets, and with them the revision history &lt;code&gt;kubectl rollout undo&lt;/code&gt; needs. On an app that is already down, it costs nothing. On a live one, it is an outage.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: Now the second fault becomes visible
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get svc
kubectl describe svc python-service-devops
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NAME                    TYPE       CLUSTER-IP      EXTERNAL-IP   PORT(S)          AGE
python-service-devops   NodePort   10.43.140.241   &amp;lt;none&amp;gt;        8080:32345/TCP   13m

Port:        &amp;lt;unset&amp;gt;  8080/TCP
TargetPort:  8080/TCP
NodePort:    &amp;lt;unset&amp;gt;  32345/TCP
Endpoints:   10.22.0.10:8080
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The node port is 32345, which the task required. The target port is 8080, and the application listens on 5000. The Endpoints line is pointing at a port nothing is bound to.&lt;/p&gt;

&lt;p&gt;This fault was there the whole time and could not be seen, because a Service with no running pods and a Service aimed at the wrong port look the same from outside. Fixing the image is what made it observable.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 5: Fix the target port
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl edit svc python-service-devops
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Changing &lt;code&gt;targetPort&lt;/code&gt; from 8080 to 5000 took effect without touching the pod. Whether that is guaranteed is not something the docs say; the Service page ties EndpointSlice updates to the set of pods changing, not to a port change. What I can report is that it worked, which is consistent with a Service being a routing rule rather than part of the pod.&lt;/p&gt;

&lt;h3&gt;
  
  
  The status kubectl shows you that nobody documented
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;kubectl get po&lt;/code&gt; said &lt;code&gt;ErrImagePull&lt;/code&gt;. &lt;code&gt;kubectl describe po&lt;/code&gt; said &lt;code&gt;Reason: ImagePullBackOff&lt;/code&gt;. Same container, seconds apart.&lt;/p&gt;

&lt;p&gt;They are two stages of one failure: the pull is attempted and fails, then the kubelet waits before trying again. &lt;code&gt;ImagePullBackOff&lt;/code&gt; is documented, including the fact that "Kubernetes raises the delay between each attempt until it reaches a compiled-in limit, which is 300 seconds (5 minutes)".&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ErrImagePull&lt;/code&gt; is not. I checked the Images page, the Pod Lifecycle page and the Debug Pods page, and the string appears on none of them. It exists in the kubelet source as &lt;code&gt;ErrImagePull = errors.New("ErrImagePull")&lt;/code&gt; and nowhere in the documentation. So one of the two statuses you will see most often during an image failure cannot be looked up.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the docs recommend
&lt;/h3&gt;

&lt;p&gt;Read the port the container declares rather than guessing the framework default. The Deployment said &lt;code&gt;Port: 5000/TCP&lt;/code&gt;, which is the &lt;code&gt;containerPort&lt;/code&gt; field, and that field is informational: "Not specifying a port here DOES NOT prevent that port from being exposed." Declaring it opens nothing and omitting it closes nothing. It is documentation that happens to be machine-readable, and on this task it was the fastest route to the answer.&lt;/p&gt;

&lt;p&gt;5000 is also Flask's default, and the default is conditional. Flask's reference gives the port as "the port of the webserver. Defaults to &lt;code&gt;5000&lt;/code&gt; or the port defined in the &lt;code&gt;SERVER_NAME&lt;/code&gt; config variable if present", with the host defaulting to &lt;code&gt;127.0.0.1&lt;/code&gt; on the same condition. Two conditions are one more than most people remember, which is an argument for reading the manifest.&lt;/p&gt;

&lt;p&gt;Work through the Waiting checklist in order. The debugging guide: a pod stuck in &lt;code&gt;Waiting&lt;/code&gt; "has been scheduled to a worker node, but it can't run on that machine", and "The most common cause of &lt;code&gt;Waiting&lt;/code&gt; pods is a failure to pull the image." Its three checks are the image name, whether the image was pushed, and whether you can pull it by hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  Task 2: Create a managed disk
&lt;/h2&gt;

&lt;p&gt;The task: create &lt;code&gt;xfusion-disk&lt;/code&gt;, Standard_LRS, 2 GiB. The title says "Create and Attach Managed Disks", and the numbered requirements mention no VM.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Check whether there is anything to attach to
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az vm list &lt;span class="nt"&gt;-g&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s2"&gt;"[].{Name:name}"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; table
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;(empty)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No VM exists in the resource group, so attaching was not merely unrequested, it was impossible. The requirements are the contract and the title describes a topic area. Day 11 said "Using Console" and was done with the CLI for the same reason.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Create it
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az disk create &lt;span class="nt"&gt;-g&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; xfusion-disk &lt;span class="nt"&gt;--size-gb&lt;/span&gt; 2 &lt;span class="nt"&gt;--sku&lt;/span&gt; Standard_LRS
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"diskSizeGB"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"diskSizeBytes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2147483648&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"diskState"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Unattached"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;2147483648 is 2 × 1024³, which confirms the Day 8 finding that Azure labels disk sizes GB and provisions GiB.&lt;/p&gt;

&lt;h3&gt;
  
  
  The explanation I had to withdraw
&lt;/h3&gt;

&lt;p&gt;Querying the size gave two different answers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az disk create ... &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s2"&gt;"{GB:diskSizeGB}"&lt;/span&gt;   -&amp;gt;  &lt;span class="s2"&gt;"GB"&lt;/span&gt;: null
az disk show   ... &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s2"&gt;"{GB:diskSizeGB}"&lt;/span&gt;   -&amp;gt;  &lt;span class="s2"&gt;"GB"&lt;/span&gt;: 2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On Day 8 I had found that &lt;code&gt;az disk show&lt;/code&gt; prints &lt;code&gt;diskSizeGB&lt;/code&gt; while &lt;code&gt;az vm show&lt;/code&gt; printed &lt;code&gt;diskSizeGb&lt;/code&gt;, and I generalised: the response shape belongs to the command, not the resource type, and &lt;code&gt;create&lt;/code&gt; and &lt;code&gt;show&lt;/code&gt; disagree even within &lt;code&gt;az disk&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;It is a neat rule, and it is not true. Both commands are generated from the same schema, and in both generated files the line is identical:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;properties&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;disk_size_gb&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;AAZIntType&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;serialized_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;diskSizeGB&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the &lt;code&gt;null&lt;/code&gt; was not caused by the two commands naming the field differently. The likeliest remaining explanations are the shape of the object my query ran against, since &lt;code&gt;create&lt;/code&gt; returns the result of a long-running operation, or a nesting difference between the top level and &lt;code&gt;properties&lt;/code&gt;. I did not work out which, and I am not going to publish a second tidy theory to replace the first one.&lt;/p&gt;

&lt;p&gt;What survives is the habit, which never depended on the theory being right:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az disk show &lt;span class="nt"&gt;-g&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; xfusion-disk &lt;span class="nt"&gt;-o&lt;/span&gt; json | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; disksize
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;grep -i&lt;/code&gt; matches whatever the casing and whatever the nesting. When a value you know exists comes back &lt;code&gt;null&lt;/code&gt;, look at the raw object rather than reasoning about why.&lt;/p&gt;

&lt;h3&gt;
  
  
  2 GiB costs the same as 32
&lt;/h3&gt;

&lt;p&gt;This is the part that actually matters for a 2 GiB disk, and Microsoft states it plainly: "managed disks are billed according to their provisioned size. Azure maps the provisioned size (rounded up) to the nearest offered disk size." The disks FAQ works an example: "A standard managed disk created from an 80-GiB VHD is treated as the next available standard disk size, which is an S10 disk."&lt;/p&gt;

&lt;p&gt;The Standard HDD tiers start at S4:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tier&lt;/th&gt;
&lt;th&gt;S4&lt;/th&gt;
&lt;th&gt;S6&lt;/th&gt;
&lt;th&gt;S10&lt;/th&gt;
&lt;th&gt;S15&lt;/th&gt;
&lt;th&gt;S20&lt;/th&gt;
&lt;th&gt;S30&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Size in GiB&lt;/td&gt;
&lt;td&gt;32&lt;/td&gt;
&lt;td&gt;64&lt;/td&gt;
&lt;td&gt;128&lt;/td&gt;
&lt;td&gt;256&lt;/td&gt;
&lt;td&gt;512&lt;/td&gt;
&lt;td&gt;1,024&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A 2 GiB disk lands on S4 and is billed as 32 GiB, with 30 GiB of unused space you have already paid for. Asking for less than the smallest tier saves nothing.&lt;/p&gt;

&lt;p&gt;It is specific to Standard HDD. Premium SSD starts at P1 with 4 GiB and Standard SSD at E1 with 4 GiB, so the floor is much lower there.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the docs recommend
&lt;/h3&gt;

&lt;p&gt;Find the orphans. The disk exists, is fully provisioned, is fully billed and is attached to nothing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az disk list &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s2"&gt;"[?diskState=='Unattached'].{Name:name,RG:resourceGroup,GB:diskSizeGB,Sku:sku.name}"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; table
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Microsoft's warning is about what survives a VM: "After a VM is deleted, you will continue to pay for unattached disks." It is the storage equivalent of the unattached public IP from Day 7.&lt;/p&gt;

&lt;p&gt;Pass the region explicitly when it matters. Leaving &lt;code&gt;--location&lt;/code&gt; off worked because the task named no region, and the CLI documents the fallback per command: "If location is not specified and no default location specified, location will be automatically set as same as the resource group." That is a statement about &lt;code&gt;az disk create&lt;/code&gt;, not a general Resource Manager rule.&lt;/p&gt;

&lt;p&gt;Snapshot before deleting. "Deleting an unattached managed disk is permanent. You can't recover its data after deletion." Azure Advisor's version is the one to act on: "Deleting a disk is irreversible. Create a snapshot before deletion and confirm the data is no longer needed."&lt;/p&gt;

&lt;p&gt;And know that &lt;code&gt;Standard_LRS&lt;/code&gt; means different things in different places. On a disk, it is "Standard HDD locally redundant storage", with LRS replicating "your data three times within a single data centre in the selected region". On a storage account, which is Day 66, the same string describes a performance tier and a redundancy option with no HDD involved.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I learned
&lt;/h2&gt;

&lt;p&gt;The Kubernetes half was ordinary: two typos in two objects, one hiding the other, found by reading what the system already said about itself. The Azure half was the useful one, because it made me take a rule out of my notes rather than add one.&lt;/p&gt;

&lt;p&gt;The rule was plausible, it explained something I had actually seen, and it was wrong. What saved the note was that the practical advice sitting on top of it, look at the raw object when a query returns null, works regardless of the cause.&lt;/p&gt;

&lt;p&gt;So here is the Day 64 question. Which rule of thumb are you carrying that explains something you really observed, and has never been checked against the source?&lt;/p&gt;

&lt;p&gt;Day 64 down. Thirty-six to go.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>kubernetes</category>
      <category>azure</category>
      <category>containers</category>
    </item>
    <item>
      <title>Day 63: Two Tiers and a Root Key</title>
      <dc:creator>Nnamdi Felix Ibe</dc:creator>
      <pubDate>Thu, 15 Oct 2026 17:00:00 +0000</pubDate>
      <link>https://dev.to/ndcodes/day-63-two-tiers-and-a-root-key-33d</link>
      <guid>https://dev.to/ndcodes/day-63-two-tiers-and-a-root-key-33d</guid>
      <description>&lt;p&gt;Day 63 of DevOps, Day 13 of Azure. Both tasks today had something already written before I arrived. Kubernetes filled in resource requests I never typed, which changed the pod's QoS class. And cloud-init had already put a line in root's &lt;code&gt;authorized_keys&lt;/code&gt; on the Azure VM, which is why adding my key to it did not work on its own.&lt;/p&gt;

&lt;p&gt;One Kubernetes task, one Azure task. Deploy a frontend and a database into their own namespace, then install a root SSH key on a VM with no SSH access to start from. The tasks come from the KodeKloud Engineer platform.&lt;/p&gt;

&lt;h2&gt;
  
  
  Task 1: A two-tier app in its own namespace
&lt;/h2&gt;

&lt;p&gt;The task: a namespace, &lt;code&gt;iron-namespace-nautilus&lt;/code&gt;, holding a frontend Deployment, a MariaDB Deployment, a ClusterIP Service for the database on 3306, and a NodePort Service for the frontend on 32678.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Create the namespace
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl create ns iron-namespace-nautilus
kubectl get ns
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NAME                      STATUS   AGE
default                   Active   96m
iron-namespace-nautilus   Active   23m
kube-node-lease           Active   96m
kube-public               Active   96m
kube-system               Active   96m
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The docs: "namespaces provide a mechanism for isolating groups of resources within a single cluster", and "Names of resources need to be unique within a namespace, but not across namespaces."&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: The frontend Deployment
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;apps/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deployment&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;iron-gallery-deployment-nautilus&lt;/span&gt;
  &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;iron-gallery&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;iron-namespace-nautilus&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;replicas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
  &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;iron-gallery&lt;/span&gt;
  &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;iron-gallery&lt;/span&gt;
    &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;config&lt;/span&gt;
        &lt;span class="na"&gt;emptyDir&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;images&lt;/span&gt;
        &lt;span class="na"&gt;emptyDir&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{}&lt;/span&gt;
      &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;iron-gallery-container-nautilus&lt;/span&gt;
        &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kodekloud/irongallery:2.0&lt;/span&gt;
        &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
           &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;100Mi"&lt;/span&gt;
           &lt;span class="na"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;50m"&lt;/span&gt;
        &lt;span class="na"&gt;volumeMounts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;config&lt;/span&gt;
          &lt;span class="na"&gt;mountPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/usr/share/nginx/html/data&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;images&lt;/span&gt;
          &lt;span class="na"&gt;mountPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/usr/share/nginx/html/uploads&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every object needs &lt;code&gt;namespace:&lt;/code&gt; in its metadata, or it lands in &lt;code&gt;default&lt;/code&gt;. Every &lt;code&gt;kubectl&lt;/code&gt; call afterwards needs &lt;code&gt;-n&lt;/code&gt;. That is the cost of the isolation, and it is easy to forget halfway through a task.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: The database Deployment
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;apps/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deployment&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;iron-db-deployment-nautilus&lt;/span&gt;
  &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mariadb&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;iron-namespace-nautilus&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;replicas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
  &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mariadb&lt;/span&gt;
  &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mariadb&lt;/span&gt;
    &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;db&lt;/span&gt;
        &lt;span class="na"&gt;emptyDir&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{}&lt;/span&gt;
      &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;iron-db-container-nautilus&lt;/span&gt;
        &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kodekloud/irondb:2.0&lt;/span&gt;
        &lt;span class="na"&gt;volumeMounts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;db&lt;/span&gt;
          &lt;span class="na"&gt;mountPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/var/lib/mysql&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;MYSQL_DATABASE&lt;/span&gt;
          &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;database_apache&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;MYSQL_ROOT_PASSWORD&lt;/span&gt;
          &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;testroot123&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;MYSQL_PASSWORD&lt;/span&gt;
          &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;test123&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;MYSQL_USER&lt;/span&gt;
          &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nileena&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those are the task's credentials, in the task's plain-text form. They are also the anti-pattern, and Day 66 does the same job with Secrets instead.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: Two Services, two types
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Service&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;iron-db-service-nautilus&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;iron-namespace-nautilus&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mariadb&lt;/span&gt;
  &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;protocol&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;TCP&lt;/span&gt;
      &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3306&lt;/span&gt;
      &lt;span class="na"&gt;targetPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3306&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Service&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;iron-gallery-service-nautilus&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;iron-namespace-nautilus&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;NodePort&lt;/span&gt;
  &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;iron-gallery&lt;/span&gt;
  &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;80&lt;/span&gt;
      &lt;span class="na"&gt;targetPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;80&lt;/span&gt;
      &lt;span class="na"&gt;nodePort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;32678&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The database Service has no &lt;code&gt;type&lt;/code&gt;, so it is ClusterIP, which the docs describe as making the Service "only reachable from within the cluster". That is the whole security model for the database here: it has no route in from outside.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 5: Apply, then check the endpoints
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; deploy-1.yaml
kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; deploy-2.yaml
kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; svc-1.yaml
kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; svc-2.yaml

kubectl get po &lt;span class="nt"&gt;-n&lt;/span&gt; iron-namespace-nautilus
kubectl describe svc &lt;span class="nt"&gt;-n&lt;/span&gt; iron-namespace-nautilus
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NAME                                               READY   STATUS    RESTARTS   AGE
iron-db-deployment-nautilus-6cbdbfbb4d-tgvkz       1/1     Running   0          30s
iron-gallery-deployment-nautilus-87cf5796b-9m2l2   1/1     Running   0          3m26s

Endpoints:                10.22.0.10:3306
Endpoints:                10.22.0.9:80
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Endpoints lines are the real check. Neither Service names a Deployment anywhere; they match on &lt;code&gt;db: mariadb&lt;/code&gt; and &lt;code&gt;run: iron-gallery&lt;/code&gt;. Two pod IPs in the Endpoints means the labels lined up. An empty list would mean they did not, and the Service would still look perfectly healthy.&lt;/p&gt;

&lt;h3&gt;
  
  
  The QoS class I did not ask for
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Limits:
  cpu:     50m
  memory:  100Mi
Requests:
  cpu:        50m
  memory:     100Mi
QoS Class:    Guaranteed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I wrote limits. I did not write requests. Kubernetes wrote them for me, and the docs say so: when a container specifies a limit and no request, "Kubernetes copies the limit you specified and uses it as the requested value for the resource."&lt;/p&gt;

&lt;p&gt;Equal requests and limits on every resource in every container is the definition of &lt;code&gt;Guaranteed&lt;/code&gt;. So the shortest path to the strongest QoS class is to specify less, not more, which is the reverse of what Day 50 led me to expect.&lt;/p&gt;

&lt;p&gt;The database pod in the same namespace is &lt;code&gt;BestEffort&lt;/code&gt;, because its manifest has no &lt;code&gt;resources&lt;/code&gt; block at all. One application, two tiers, two QoS classes, and the difference is four lines of YAML.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the docs recommend
&lt;/h3&gt;

&lt;p&gt;Give the database real storage. Both deployments use &lt;code&gt;emptyDir&lt;/code&gt;, and the docs are unambiguous about what that means: "When a Pod is removed from a node for any reason, the data in the &lt;code&gt;emptyDir&lt;/code&gt; is deleted permanently." The schema, the user and every row go with the pod. That is acceptable for a task that only asks whether the page loads, and it is the thing to change first.&lt;/p&gt;

&lt;p&gt;The docs also point at the workload type. StatefulSets exist for applications needing "Stable, unique network identifiers" and "Stable, persistent storage", which is a database in two bullets.&lt;/p&gt;

&lt;p&gt;Pick the node port deliberately. 32678 falls in the dynamic band of the default range, 30086 to 32767, which is where Kubernetes allocates automatic ports from first. The static band below it is where a hand-picked number is least likely to collide with one the cluster assigned to somebody else.&lt;/p&gt;

&lt;p&gt;Do not assume a third-party image honours the usual variables. &lt;code&gt;kodekloud/irondb:2.0&lt;/code&gt; has no entry in the official image documentation. The &lt;code&gt;MYSQL_*&lt;/code&gt; variables show up in &lt;code&gt;kubectl describe pod&lt;/code&gt; because I set them, which proves they were delivered to the container and nothing more. Even the official &lt;code&gt;mariadb&lt;/code&gt; image only documents &lt;code&gt;MARIADB_DATABASE&lt;/code&gt; among these and defers the rest elsewhere.&lt;/p&gt;

&lt;p&gt;One honest note about my own transcript. &lt;code&gt;kubectl get svc&lt;/code&gt; printed &lt;code&gt;80/TCP&lt;/code&gt; for the database Service while &lt;code&gt;kubectl describe svc&lt;/code&gt; printed &lt;code&gt;3306/TCP&lt;/code&gt; for the port, the target port and the endpoint. The manifest says 3306 and three of the four agree, so the &lt;code&gt;get&lt;/code&gt; line is almost certainly a copy error on my part rather than a real state. I did not re-run it, so I am flagging it rather than quietly picking the convenient one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Task 2: Install a root SSH key without SSH
&lt;/h2&gt;

&lt;p&gt;The task: put the client host's root public key into &lt;code&gt;nautilus-vm&lt;/code&gt;'s root &lt;code&gt;authorized_keys&lt;/code&gt;, and prove password-less root login works.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Get a shell without having a shell
&lt;/h3&gt;

&lt;p&gt;The obvious route needs credentials for &lt;code&gt;azureuser&lt;/code&gt;, which the task did not give me. Azure has a way round that:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az vm run-command invoke &lt;span class="nt"&gt;-g&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; nautilus-vm &lt;span class="nt"&gt;--command-id&lt;/span&gt; RunShellScript &lt;span class="nt"&gt;--scripts&lt;/span&gt; &lt;span class="s2"&gt;"..."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Microsoft: "The Run Command feature uses the virtual machine (VM) agent to run shell scripts within an Azure Linux VM", and "Scripts run by default as an elevated user on Linux." No SSH, no key, no open port. It runs over the Azure control plane through the agent already inside the VM.&lt;/p&gt;

&lt;p&gt;That is the shape of the whole task: use a channel that does not depend on SSH to set up SSH.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Look before writing
&lt;/h3&gt;

&lt;p&gt;The script printed root's existing &lt;code&gt;authorized_keys&lt;/code&gt; first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;BEFORE:
no-port-forwarding,no-agent-forwarding,no-X11-forwarding,command="echo 'Please login as the user \"azureuser\"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That line is cloud-init's doing. It copies the admin user's key into root's file behind a forced command, so a root login authenticates successfully, prints the message, and disconnects.&lt;/p&gt;

&lt;p&gt;It is identical on AWS and Azure, because it comes from cloud-init rather than from either cloud. Day 35 on the AWS track hit exactly this.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Filter rather than append
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="s1"&gt;'Please login as'&lt;/span&gt; /root/.ssh/authorized_keys &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-vF&lt;/span&gt; &lt;span class="s1"&gt;'$KEYBODY'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /tmp/ak
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'$PUBKEY'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; /tmp/ak
&lt;span class="nb"&gt;mv&lt;/span&gt; /tmp/ak /root/.ssh/authorized_keys
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The reason not to simply append is that the banner line probably contains this same key already. The lab likely built the VM using &lt;code&gt;/root/.ssh/id_rsa.pub&lt;/code&gt; from the client, and cloud-init copied it across with the forced command in front.&lt;/p&gt;

&lt;p&gt;I had been carrying an explanation for why that blocks a clean duplicate: sshd uses the first matching line. When I went to check it, the sshd manual does not say that. Its AUTHORIZED_KEYS FILE FORMAT section describes the line format and says a forced command "is executed whenever this key is used for authentication", with no statement anywhere about ordering or precedence between two lines carrying the same key.&lt;/p&gt;

&lt;p&gt;So the forced command certainly applies when its line is the one that authenticates. Which line wins is undocumented, and I did not test it. The script above sidesteps the question: drop every banner line, drop any existing copy of this key, append it once. That is correct either way, and it is closer to what adding a key ought to mean than overwriting the file.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;-rw------- 1 root root  399 Sep 22 07:33 authorized_keys
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 4: Fix the permissions before testing, not after
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;chmod &lt;/span&gt;700 /root/.ssh
&lt;span class="nb"&gt;chmod &lt;/span&gt;600 /root/.ssh/authorized_keys
&lt;span class="nb"&gt;chown&lt;/span&gt; &lt;span class="nt"&gt;-R&lt;/span&gt; root:root /root/.ssh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;StrictModes&lt;/code&gt; is on by default and "Specifies whether sshd(8) should check file modes and ownership of the user's files and home directory before accepting login."&lt;/p&gt;

&lt;p&gt;When that check fails, sshd tells the client nothing. The login quietly falls through to the next authentication method, and the reason goes to the server's auth log, which you cannot read if you cannot log in. Wrong permissions look exactly like a wrong key from the outside.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 5: Ask sshd what it will actually do
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;sshd &lt;span class="nt"&gt;-T&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; permitrootlogin
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;permitrootlogin without-password
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;sshd -T&lt;/code&gt; is "Extended test mode. Check the validity of the configuration file, output the effective configuration to stdout and then exit." It resolves every include and default, which reading &lt;code&gt;/etc/ssh/sshd_config&lt;/code&gt; by hand does not, and cloud images put their overrides in &lt;code&gt;/etc/ssh/sshd_config.d/&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;without-password&lt;/code&gt; is the legacy spelling. The manual: root may log in with a key "If this option is set to prohibit-password (or its deprecated alias, without-password)", with password authentication disabled for root. Had this printed &lt;code&gt;no&lt;/code&gt;, the key would have been irrelevant.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 6: Prove it was the key
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;BatchMode&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;yes&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;StrictHostKeyChecking&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;no &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;ConnectTimeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;10 root@&lt;span class="nv"&gt;$VM_IP&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s1"&gt;'whoami; hostname; stat -c "%a %U %n" /root/.ssh /root/.ssh/authorized_keys'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;root
nautilus-vm
700 root /root/.ssh
600 root /root/.ssh/authorized_keys
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;BatchMode&lt;/code&gt; is what turns this from "login worked" into "login worked without a password". The manual: "If set to yes, user interaction such as password prompts and host key confirmation requests will be disabled."&lt;/p&gt;

&lt;p&gt;Worth reading that precisely. It names password prompts and host key confirmation. It says nothing about passphrase prompting for a local key, so it is narrower than "disables all prompting".&lt;/p&gt;

&lt;h3&gt;
  
  
  What the docs recommend
&lt;/h3&gt;

&lt;p&gt;Do not enable root SSH. The cloud-init banner exists to stop exactly what this task asked for, and a compromised root key is a compromised machine with no &lt;code&gt;sudo&lt;/code&gt; trail in between. Microsoft's alternatives, in rough order: log in as &lt;code&gt;azureuser&lt;/code&gt; and use &lt;code&gt;sudo&lt;/code&gt;; use Microsoft Entra ID login for Linux VMs, which lets you "use Microsoft Entra ID as a core authentication platform and a certificate authority to SSH into a Linux VM" with no keys to distribute; or use Azure Bastion, after which "your virtual machines don't need a public IP address, agent, or special client software".&lt;/p&gt;

&lt;p&gt;Know what the convenient command does besides the obvious. &lt;code&gt;az vm user update --ssh-key-value&lt;/code&gt; is the other way to install a key, and it "appends the new public key text to the ~/.ssh/authorized_keys file for the admin user on the VM. This does not replace or remove any existing SSH keys." But the VMAccess extension behind it also "grants sudo permission to account specified in the username field", and it "changes &lt;code&gt;ChallengeResponseAuthentication&lt;/code&gt; to &lt;code&gt;no&lt;/code&gt; and &lt;code&gt;PasswordAuthentication&lt;/code&gt; to &lt;code&gt;yes&lt;/code&gt;". Turning on password authentication is an odd side effect of a command you reached for to install a key.&lt;/p&gt;

&lt;p&gt;Check both NSG levels, and remember the rule is not symmetric. For inbound, "both &lt;em&gt;NSG1&lt;/em&gt; and &lt;em&gt;NSG2&lt;/em&gt; must include a rule that allows port 80 from the internet". For outbound, "either one of or both of the network security groups must have a rule that denies port 80". Microsoft's current advice is to sidestep it: "avoid associating network security groups with both a subnet and its network interfaces at the same time."&lt;/p&gt;

&lt;h2&gt;
  
  
  What I learned
&lt;/h2&gt;

&lt;p&gt;Both tasks had state in them before I started. Kubernetes supplied requests to match my limits, which quietly upgraded the pod's QoS class. cloud-init had written a line into root's &lt;code&gt;authorized_keys&lt;/code&gt; that would have beaten anything I appended after it.&lt;/p&gt;

&lt;p&gt;The second one is where I had to change my mind about something I thought I knew. The behaviour I was relying on turns out not to be documented anywhere in the sshd manual, and the fix was to write the script so the answer does not matter.&lt;/p&gt;

&lt;p&gt;So here is the Day 63 question. What is already configured in your environment that you have never read, because it was put there before you got the machine?&lt;/p&gt;

&lt;p&gt;Day 63 down. Thirty-seven to go.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>kubernetes</category>
      <category>azure</category>
      <category>containers</category>
    </item>
    <item>
      <title>Part 1: Why NHS Trusts Need an HL7 v2 to FHIR Layer, and How This One Is Built</title>
      <dc:creator>Nnamdi Felix Ibe</dc:creator>
      <pubDate>Wed, 14 Oct 2026 13:31:36 +0000</pubDate>
      <link>https://dev.to/ndcodes/part-1-why-nhs-trusts-need-an-hl7-v2-to-fhir-layer-and-how-this-one-is-built-kkm</link>
      <guid>https://dev.to/ndcodes/part-1-why-nhs-trusts-need-an-hl7-v2-to-fhir-layer-and-how-this-one-is-built-kkm</guid>
      <description>&lt;p&gt;Hi I'm Nnamdi.&lt;/p&gt;

&lt;p&gt;This series walks through a project I built around one question: how does a patient's data get from a hospital system that speaks HL7 v2 to services that expect FHIR? Part 1 covers the problem and the architecture. The next six parts go deeper, one area at a time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;Two standards meet here.&lt;/p&gt;

&lt;p&gt;HL7 version 2 is the older one. HL7 International calls it "the workhorse of electronic data exchange in the clinical domain". A v2 message is plain text. Each segment is a line of fields separated by pipes, and the standard says "the segment terminator is always a carriage return". An admission is sent as an ADT^A01 message, which the standard describes as "sent as a result of a patient undergoing the admission process". Its segments include MSH for the message header, PID for patient identification and PV1 for the patient visit.&lt;/p&gt;

&lt;p&gt;FHIR is the newer one. It models data as resources, such as Patient and Encounter, and exchanges them as JSON or XML over a REST API.&lt;/p&gt;

&lt;p&gt;National NHS services are moving to FHIR. The NHS service manual says the PDS FHIR API "meets the latest standards". It also says that if you want to start using a PDS API, "this is the version to use". The older HL7 V3 version is under review.&lt;/p&gt;

&lt;p&gt;So a hospital system that sends HL7 v2 needs something in between. That layer has to translate each message, check the patient against national records, and store the result somewhere that speaks FHIR. This project builds that layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I built
&lt;/h2&gt;

&lt;p&gt;The NHS Interop Platform is a small, production-shaped reference implementation. It isn't running in a hospital. It runs on a laptop with one command, &lt;code&gt;make demo&lt;/code&gt;, using example data only.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6n076lkm4wo7xrgj9qtt.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6n076lkm4wo7xrgj9qtt.png" alt="One admission through the pipeline: a trust engine sends HL7 v2 to ingest, which forwards to transform; transform enriches through pds-client and writes a FHIR Bundle to HAPI FHIR, or saves the raw message to a dead-letter folder" width="800" height="512"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Five parts do the work:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;ingest&lt;/code&gt; is a FastAPI service that receives HL7 v2 over HTTP. It checks the MSH header, gives the message a trace ID and passes it on.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;transform&lt;/code&gt; parses the message and maps it to a FHIR R4 transaction Bundle.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;pds-client&lt;/code&gt; looks the patient up in the Personal Demographics Service (PDS), the national database of NHS patient details. In the local demo it runs in stub mode by default and returns a canned record, so nothing leaves the laptop. It can call NHS England's PDS FHIR sandbox instead.&lt;/li&gt;
&lt;li&gt;A HAPI FHIR server stores the result in Postgres.&lt;/li&gt;
&lt;li&gt;Prometheus scrapes the services and Grafana shows the pipeline. Five alert rules cover traffic, latency, errors, dead letters and the PDS circuit breaker.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The repo also holds Terraform for AWS (VPC, EKS, RDS, S3, KMS and IAM), Helm charts and Argo CD definitions. They describe where the platform would run, and I've checked them with &lt;code&gt;terraform plan&lt;/code&gt;. Everything in this article runs locally. Part 2 covers the AWS side.&lt;/p&gt;

&lt;h2&gt;
  
  
  Following one admission through
&lt;/h2&gt;

&lt;p&gt;This is the sample admission from the repo's test fixtures:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;MSH|^~\&amp;amp;|CERNER|GENERAL_HOSP|INTEROP|GENERAL_HOSP|20260101120000||ADT^A01|MSG00001|P|2.5
EVN|A01|20260101120000
PID|1||9000000009^^^NHS^NH~L12345^^^GENERAL_HOSP^MR||DOE^JOHN^A||19800101|M|||1 NHS WAY^^LONDON^^SW1A 1AA^GBR
PV1|1|I|WARD1^BED1^ROOM1||||12345^SMITH^JANE|||GEN
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;PID carries the patient. PID-3 holds two identifiers, separated by &lt;code&gt;~&lt;/code&gt;: the NHS number, with &lt;code&gt;NHS&lt;/code&gt; as its assigning authority, and the hospital's own number. PID-5 is the name, PID-7 the date of birth and PID-8 the sex.&lt;/p&gt;

&lt;p&gt;This is the FHIR Patient the mapper builds from that segment. I ran the mapper on the fixture to get it, so it's the real output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"resourceType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Patient"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"identifier"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"system"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://fhir.nhs.uk/Id/nhs-number"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"9000000009"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"system"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://fhir.nhs.uk/Id/local-patient-identifier"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"L12345"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"use"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"official"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"family"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"DOE"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"given"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"JOHN"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"gender"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"male"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"birthDate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1980-01-01"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuooqg99y3s2lr2nrosx5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuooqg99y3s2lr2nrosx5.png" alt="The same patient as an HL7 v2 PID segment and as a FHIR R4 Patient resource, with the matching fields numbered" width="800" height="886"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The middle initial, &lt;code&gt;A&lt;/code&gt;, isn't mapped. PID-5 has a component for it and FHIR takes more than one entry in &lt;code&gt;given&lt;/code&gt;, so that is a real gap rather than a limitation of either format. Part 3 is about the mapping, and it's on the list there.&lt;/p&gt;

&lt;p&gt;PV1 becomes an Encounter, and the &lt;code&gt;I&lt;/code&gt; in PV1-2 becomes the FHIR class code &lt;code&gt;IMP&lt;/code&gt;, an inpatient encounter.&lt;/p&gt;

&lt;p&gt;Three things happen next:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Enrichment. &lt;code&gt;transform&lt;/code&gt; asks &lt;code&gt;pds-client&lt;/code&gt; for the patient by NHS number. The PDS values for name, gender, date of birth, address and telephone replace the ones from the message, and the hospital's own identifier is kept. In the local demo the stub's canned record wins, which is why the stored patient is called Paul Smith rather than John Doe.&lt;/li&gt;
&lt;li&gt;The write. &lt;code&gt;transform&lt;/code&gt; sends the Patient, the Encounter and a MessageHeader to HAPI FHIR as one transaction Bundle.&lt;/li&gt;
&lt;li&gt;The failure path. If mapping or the write fails, the raw message is saved to a dead-letter folder and a counter feeds the DLQRateHigh alert. Replaying it is still a manual step.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Three design decisions
&lt;/h2&gt;

&lt;p&gt;Fail open on enrichment. If PDS is slow or down, the pipeline carries on with the demographics from the message. A circuit breaker stops calling PDS after five failures in a row, and lets one call through again after 30 seconds. A slow lookup shouldn't stop an admission being recorded.&lt;/p&gt;

&lt;p&gt;Fail closed on writes. A failed FHIR write is never quietly dropped. The raw message is saved to the dead-letter folder so it can be replayed. More on this one below, because writing this article is how I found out it wasn't true.&lt;/p&gt;

&lt;p&gt;One transaction per message. The FHIR specification says that in a transaction, "all interactions or operations either succeed or fail together". An admission is saved whole or not at all, never as a Patient without its Encounter.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug I found writing this article
&lt;/h2&gt;

&lt;p&gt;I wanted a screenshot of a dead-lettered message for the section above, so I pointed &lt;code&gt;transform&lt;/code&gt; at a FHIR server that wasn't there and sent the fixture in. Nothing arrived in the dead-letter folder, and the service answered 500 rather than 502.&lt;/p&gt;

&lt;p&gt;The write path looked fine:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@retry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stop&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;stop_after_attempt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;wait&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;wait_exponential&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;multiplier&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;max&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_persist_bundle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bundle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;trace_id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;FHIR_BASE_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;bundle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...)&lt;/span&gt;
    &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and the caller catches the error the way you would expect:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;_persist_bundle&lt;/span&gt;&lt;span class="p"&gt;(...)&lt;/span&gt;
&lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HTTPError&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;_dlq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;trace_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fhir_write_error&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;HTTPException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;502&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The retry decorator is the problem. Tenacity's documentation says that when a function fails its final time, "a RetryError is raised", and that the original exception shows up "somewhere in the middle of the stack trace". &lt;code&gt;RetryError&lt;/code&gt; is not an &lt;code&gt;httpx.HTTPError&lt;/code&gt;, so the &lt;code&gt;except&lt;/code&gt; never matched. Every retried write failure escaped the handler, skipped the dead-letter call and came back as an unhandled 500.&lt;/p&gt;

&lt;p&gt;The fix is one argument. Tenacity's &lt;code&gt;reraise=True&lt;/code&gt; raises the underlying exception instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@retry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stop&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;stop_after_attempt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;wait&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;wait_exponential&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;multiplier&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;max&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;reraise&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The test is the more useful half. It mocks HAPI FHIR with &lt;code&gt;respx&lt;/code&gt;, so it needs no running stack, and it asserts the three things the rule actually promises: the retries ran, the raw message is on disk, and the counter went up.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;respx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;assert_all_called&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;hapi&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;route&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;hapi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;FHIR_URL&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;mock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;return_value&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;503&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nc"&gt;TestClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;main&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/transform&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;ADT_A01&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                               &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;X-Trace-Id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;write-fails&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;route&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;call_count&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;
&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;502&lt;/span&gt;
&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nf"&gt;_dlq_count&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;before&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;

&lt;span class="n"&gt;written&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tmp_path&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;write-fails.hl7&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;read_bytes&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;written&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;ADT_A01&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;written&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last pair of lines is a second lesson. My first version of the test read the file back with &lt;code&gt;read_text()&lt;/code&gt; and failed on a message that was written correctly, because Python's universal newline handling turns the HL7 carriage returns into line feeds on the way in. Reading the bytes is the only way to see what is really on disk.&lt;/p&gt;

&lt;p&gt;Both changes are in the code this article is tagged against.&lt;/p&gt;

&lt;p&gt;I'd had the "fail closed on writes" rule written down in &lt;code&gt;docs/architecture.md&lt;/code&gt; since the first week of the project. It read like a property of the system. It was a property of the documentation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Worth stealing
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Normalise line endings before parsing. HL7 v2 ends segments with a carriage return, but files and editors add line feeds. The parser turns both into carriage returns first, and the repo's &lt;code&gt;.gitattributes&lt;/code&gt; stops Git converting the HL7 test fixtures.&lt;/li&gt;
&lt;li&gt;Give every message a trace ID at the front door. &lt;code&gt;ingest&lt;/code&gt; creates one and passes it on in an &lt;code&gt;X-Trace-Id&lt;/code&gt; header, so one admission can be followed through every service's logs.&lt;/li&gt;
&lt;li&gt;Test the failure path, not just the happy one. A retry wrapper, a circuit breaker and a dead-letter folder all look right in a diagram. The test is what tells you whether they are wired together.&lt;/li&gt;
&lt;li&gt;Make the demo one command. &lt;code&gt;make demo&lt;/code&gt; builds and starts the whole stack and waits until the FHIR server answers, so anyone can check the work quickly.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where AI helps here, and where it doesn't
&lt;/h2&gt;

&lt;p&gt;I built this project with Claude Code, Anthropic's coding assistant. It drafted the scaffolding for all three services, the Helm charts, the Prometheus rules and the Grafana dashboard, and the first pass of most of the docs. I made the mapping and architecture decisions, and I reviewed everything that went in.&lt;/p&gt;

&lt;p&gt;It was quickest where the shape was clear. The Helm charts show that best: three services, seven templates each, and the only real differences between them are the name, the port, the environment variables and the resource requests. Once the first chart is right, writing the third one by hand is just typing.&lt;/p&gt;

&lt;p&gt;It didn't replace checking, and the failure above is the clearest case. The retry decorator, the &lt;code&gt;except&lt;/code&gt; clause and the dead-letter helper are each individually what you would write. Nothing in the code looks wrong until something runs it. Two other passes over the repo turned up the same kind of thing: two AWS statements in the docs that no longer matched the Terraform, a description of an in-memory queue that the code doesn't have, and an alert rule, PDSCircuitOpen, that watches a metric called &lt;code&gt;pybreaker_state&lt;/code&gt; which none of the services export. That last one can't fire at all. Part 4 is about observability, and fixing it belongs there.&lt;/p&gt;

&lt;p&gt;The rule I'm keeping: AI drafts, and the specification, the tests and the running system decide.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;p&gt;I'd use SQS from the start, not a synchronous HTTP hand-off. Right now, if &lt;code&gt;transform&lt;/code&gt; is down, &lt;code&gt;ingest&lt;/code&gt; logs the error and still replies 200, marked &lt;code&gt;forwarded: false&lt;/code&gt;, and nothing retries the message. That is the same failure as the one above, one service earlier, and the dead-letter folder doesn't help because the message never reached the service that owns it. A queue makes the hand-off durable instead of hopeful. It's the first item on the roadmap, and Part 6 shows the fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  Acronyms used in this article
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Acronym&lt;/th&gt;
&lt;th&gt;Stands for&lt;/th&gt;
&lt;th&gt;What it means here&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;ADT&lt;/td&gt;
&lt;td&gt;Admission, Discharge and Transfer&lt;/td&gt;
&lt;td&gt;The family of HL7 v2 messages a hospital sends when a patient is admitted, moved or sent home&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A01&lt;/td&gt;
&lt;td&gt;Admit/Visit Notification&lt;/td&gt;
&lt;td&gt;The ADT event for a patient being admitted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AI&lt;/td&gt;
&lt;td&gt;Artificial intelligence&lt;/td&gt;
&lt;td&gt;Here, the coding assistant used to draft parts of the project&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;API&lt;/td&gt;
&lt;td&gt;Application programming interface&lt;/td&gt;
&lt;td&gt;A defined way for one piece of software to ask another for data&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AWS&lt;/td&gt;
&lt;td&gt;Amazon Web Services&lt;/td&gt;
&lt;td&gt;Amazon's cloud platform, where the project is designed to run&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CD&lt;/td&gt;
&lt;td&gt;Continuous delivery&lt;/td&gt;
&lt;td&gt;Shipping changes automatically once they pass checks (as in Argo CD)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DLQ&lt;/td&gt;
&lt;td&gt;Dead-letter queue&lt;/td&gt;
&lt;td&gt;A holding place for messages that couldn't be processed, so they aren't lost. This project uses a folder&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;EKS&lt;/td&gt;
&lt;td&gt;Elastic Kubernetes Service&lt;/td&gt;
&lt;td&gt;Amazon's managed service for running containers with Kubernetes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;EVN&lt;/td&gt;
&lt;td&gt;Event Type segment&lt;/td&gt;
&lt;td&gt;The line of an HL7 v2 message that says what happened and when&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;FHIR&lt;/td&gt;
&lt;td&gt;Fast Healthcare Interoperability Resources&lt;/td&gt;
&lt;td&gt;The modern health data standard, built on web technology&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HAPI&lt;/td&gt;
&lt;td&gt;HL7 application programming interface&lt;/td&gt;
&lt;td&gt;An open-source project; HAPI FHIR is the FHIR server used here&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HL7&lt;/td&gt;
&lt;td&gt;Health Level Seven&lt;/td&gt;
&lt;td&gt;The organisation behind both standards. The "Seven" refers to the top layer of a standard networking model, where applications talk to each other&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HTTP&lt;/td&gt;
&lt;td&gt;Hypertext Transfer Protocol&lt;/td&gt;
&lt;td&gt;The protocol web browsers and web services use to talk&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IAM&lt;/td&gt;
&lt;td&gt;Identity and Access Management&lt;/td&gt;
&lt;td&gt;AWS's system for who and what is allowed to do what&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IMP&lt;/td&gt;
&lt;td&gt;Inpatient&lt;/td&gt;
&lt;td&gt;The FHIR code for a patient admitted to a ward&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JSON&lt;/td&gt;
&lt;td&gt;JavaScript Object Notation&lt;/td&gt;
&lt;td&gt;A common text format for structured data&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;KMS&lt;/td&gt;
&lt;td&gt;Key Management Service&lt;/td&gt;
&lt;td&gt;AWS's service for managing encryption keys&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MSH&lt;/td&gt;
&lt;td&gt;Message Header segment&lt;/td&gt;
&lt;td&gt;The first line of every HL7 v2 message: who sent it, to whom, and what type it is&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;NHS&lt;/td&gt;
&lt;td&gt;National Health Service&lt;/td&gt;
&lt;td&gt;The UK's public health service&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PDS&lt;/td&gt;
&lt;td&gt;Personal Demographics Service&lt;/td&gt;
&lt;td&gt;The national database of NHS patient details, such as name, address and NHS number&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PID&lt;/td&gt;
&lt;td&gt;Patient Identification segment&lt;/td&gt;
&lt;td&gt;The line of an HL7 v2 message that describes the patient&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PV1&lt;/td&gt;
&lt;td&gt;Patient Visit segment&lt;/td&gt;
&lt;td&gt;The line of an HL7 v2 message that describes the hospital stay&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;R4&lt;/td&gt;
&lt;td&gt;Release 4&lt;/td&gt;
&lt;td&gt;The version of FHIR this project uses&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RDS&lt;/td&gt;
&lt;td&gt;Relational Database Service&lt;/td&gt;
&lt;td&gt;Amazon's managed database service&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;REST&lt;/td&gt;
&lt;td&gt;Representational State Transfer&lt;/td&gt;
&lt;td&gt;A common style for building web APIs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;S3&lt;/td&gt;
&lt;td&gt;Simple Storage Service&lt;/td&gt;
&lt;td&gt;Amazon's file storage service&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SQS&lt;/td&gt;
&lt;td&gt;Simple Queue Service&lt;/td&gt;
&lt;td&gt;Amazon's message queue service, which holds messages until they're processed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;v2, V3&lt;/td&gt;
&lt;td&gt;Version 2, Version 3&lt;/td&gt;
&lt;td&gt;Versions of the HL7 standard. v2 is the older, widely used one&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;VPC&lt;/td&gt;
&lt;td&gt;Virtual Private Cloud&lt;/td&gt;
&lt;td&gt;A private, isolated network inside AWS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;XML&lt;/td&gt;
&lt;td&gt;Extensible Markup Language&lt;/td&gt;
&lt;td&gt;An older text format for structured data&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.hl7.org/implement/standards/product_brief.cfm?product_id=185" rel="noopener noreferrer"&gt;HL7 International: HL7 Version 2 product brief&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.hl7.eu/HL7v2x/v251/std251/ch02.html" rel="noopener noreferrer"&gt;HL7 v2.5.1, Chapter 2: Control (hosted by HL7 Europe)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.hl7.eu/HL7v2x/v251/std251/ch03.html" rel="noopener noreferrer"&gt;HL7 v2.5.1, Chapter 3: Patient Administration (hosted by HL7 Europe)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.hl7.eu/HL7v2x/v27/std27/ch01.html" rel="noopener noreferrer"&gt;HL7 v2.7, Chapter 1: Introduction, on the name "Level 7" (hosted by HL7 Europe)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.hl7.eu/HL7v2x/v22/std22/HL7CHP3.html" rel="noopener noreferrer"&gt;HL7 v2.2, Chapter 3: Admission, Discharge, and Transfer (hosted by HL7 Europe)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://hl7.org/fhir/R4/summary.html" rel="noopener noreferrer"&gt;HL7 FHIR R4: Summary&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://hl7.org/fhir/R4/http.html" rel="noopener noreferrer"&gt;HL7 FHIR R4: RESTful API, batch and transaction&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://service-manual.nhs.uk/standards-and-technology/technology/personal-demographics-service-PDS" rel="noopener noreferrer"&gt;NHS service manual: Personal Demographics Service&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://digital.nhs.uk/developer/api-catalogue/personal-demographics-service-fhir" rel="noopener noreferrer"&gt;NHS England: Personal Demographics Service FHIR API&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://tenacity.readthedocs.io/en/latest/" rel="noopener noreferrer"&gt;Tenacity documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://pypi.org/project/pybreaker/" rel="noopener noreferrer"&gt;pybreaker on PyPI&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/ND-codes/nhs-interop-platform/tree/part-1" rel="noopener noreferrer"&gt;The code for this article, tag part-1&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Next
&lt;/h2&gt;

&lt;p&gt;Part 2, on 21 October, goes from &lt;code&gt;docker compose up&lt;/code&gt; to Terraform on AWS.&lt;/p&gt;

&lt;p&gt;What does the integration engine in your organisation still speak: HL7 v2, FHIR, or both?&lt;/p&gt;




&lt;p&gt;This is a personal project. It is not affiliated with or endorsed by NHS England or any NHS organisation. It uses the public PDS FHIR sandbox and synthetic test data only. Views are my own.&lt;/p&gt;

</description>
      <category>discuss</category>
      <category>ai</category>
      <category>community</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Day 62: Secrets and Tags</title>
      <dc:creator>Nnamdi Felix Ibe</dc:creator>
      <pubDate>Mon, 12 Oct 2026 23:27:36 +0000</pubDate>
      <link>https://dev.to/ndcodes/day-62-secrets-and-tags-1e8h</link>
      <guid>https://dev.to/ndcodes/day-62-secrets-and-tags-1e8h</guid>
      <description>&lt;p&gt;Day 62 of DevOps, Day 12 of Azure. Both tasks today turned on a name I did not choose. Kubernetes named the file inside my Secret after the file on disk, and Azure's tag commands disagree among themselves about what setting a tag means.&lt;/p&gt;

&lt;p&gt;One Kubernetes task, one Azure task. Mount a Secret into a pod as a file, then add a single tag to a VM. The tasks come from the KodeKloud Engineer platform.&lt;/p&gt;

&lt;h2&gt;
  
  
  Task 1: Mount a Secret as a file
&lt;/h2&gt;

&lt;p&gt;The task: create a generic Secret called &lt;code&gt;official&lt;/code&gt; from &lt;code&gt;/opt/official.txt&lt;/code&gt;, then run a pod, &lt;code&gt;secret-xfusion&lt;/code&gt;, with one container, &lt;code&gt;secret-container-xfusion&lt;/code&gt;, on &lt;code&gt;debian:latest&lt;/code&gt;, that mounts the Secret at &lt;code&gt;/opt/demo&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Look at what is going in
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; /opt/official.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;5ecur3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 2: Create the Secret from the file
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl create secret generic official &lt;span class="nt"&gt;--from-file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/opt/official.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;secret/official created
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;generic&lt;/code&gt; is the subcommand for arbitrary data, and it produces a Secret of type &lt;code&gt;Opaque&lt;/code&gt;, which the docs describe as "arbitrary user-defined data". The other types are for specific jobs: registry credentials, TLS material, SSH keys, service account tokens.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Find out what the key is called
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get secret
kubectl describe secret
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NAME       TYPE     DATA   AGE
official   Opaque   1      4s

Data
====
official.txt:  7 bytes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the detail the whole task rests on. The key is &lt;code&gt;official.txt&lt;/code&gt;, not &lt;code&gt;official&lt;/code&gt;. The docs: "The default key name is the file name." It matters because the key is what becomes the filename inside the container, so the path you mount at is only half the address.&lt;/p&gt;

&lt;p&gt;Seven bytes is &lt;code&gt;5ecur3&lt;/code&gt; plus the trailing newline. Note what &lt;code&gt;describe&lt;/code&gt; does not print: the value. It gives you the key and a size, and nothing else.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: Mount it in a pod
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Pod&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;secret-xfusion&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;secret-volume&lt;/span&gt;
      &lt;span class="na"&gt;secret&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;secretName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;official&lt;/span&gt;
  &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;secret-container-xfusion&lt;/span&gt;
      &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;debian:latest&lt;/span&gt;
      &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;sleep'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;6000'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;volumeMounts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;secret-volume&lt;/span&gt;
          &lt;span class="na"&gt;readOnly&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
          &lt;span class="na"&gt;mountPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/opt/demo"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; po.yaml
kubectl get po
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NAME             READY   STATUS    RESTARTS   AGE
secret-xfusion   1/1     Running   0          4s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;sleep 6000&lt;/code&gt; is there because &lt;code&gt;debian:latest&lt;/code&gt; has nothing to run. Without it the container exits immediately and there is nothing to inspect.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 5: Read it from inside the container
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-it&lt;/span&gt; secret-xfusion &lt;span class="nt"&gt;-c&lt;/span&gt; secret-container-xfusion &lt;span class="nt"&gt;--&lt;/span&gt; bash
&lt;span class="nb"&gt;ls&lt;/span&gt; /opt/demo/
&lt;span class="nb"&gt;cat&lt;/span&gt; /opt/demo/official.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;official.txt
5ecur3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One key, one file. The docs: "Each key in the Secret &lt;code&gt;data&lt;/code&gt; map becomes a file name in this directory." A Secret with four keys would have put four files in &lt;code&gt;/opt/demo&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  The read-only flag that is not doing the work
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;kubectl describe pod&lt;/code&gt; shows the mount like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Mounts:
  /opt/demo from secret-volume (ro)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I had written &lt;code&gt;readOnly: true&lt;/code&gt; in the manifest, so the &lt;code&gt;ro&lt;/code&gt; looks like my doing. It is not. Kubernetes mounts Secret volumes read-only whatever the manifest says, and the volume plugin in the Kubernetes source reports &lt;code&gt;ReadOnly: true&lt;/code&gt; unconditionally.&lt;/p&gt;

&lt;p&gt;The documentation does not settle this for Secrets in either direction. What it does say, on the ConfigMap page, is to set the flag, which is probably where most of us picked up the habit. So the line is worth keeping as a statement of intent. It just is not the thing producing the result.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the docs recommend
&lt;/h3&gt;

&lt;p&gt;Prefer a volume to an environment variable. Both work, and they behave differently when the Secret changes. Mounted files are updated by the kubelet on their own. A Secret consumed as an environment variable "will not be seen by the container unless it is restarted". Day 66 uses the environment variable form, and that is the trade it makes.&lt;/p&gt;

&lt;p&gt;Do not promise a one-minute update. The figure that gets repeated is the kubelet sync period, and the docs are careful to make it a sum: the total delay "can be as long as the kubelet sync period + cache propagation delay, where the cache propagation delay depends on the chosen cache type". The sync period defaults to one minute. The second term has no documented default, so there is no documented ceiling.&lt;/p&gt;

&lt;p&gt;Know what the storage actually is. The caution is blunt: "Kubernetes Secrets are, by default, stored unencrypted in the API server's underlying data store (etcd). Anyone with API access can retrieve or modify a Secret, and so can anyone with access to etcd." Encryption at rest and tight RBAC are the two steps the docs put first.&lt;/p&gt;

&lt;p&gt;Keep them small. "Individual Secrets are limited to 1MiB in size. This is to discourage creation of very large Secrets that could exhaust the API server and kubelet memory."&lt;/p&gt;

&lt;p&gt;And watch out for the update that is not an update. This looks like it refreshes the Secret in place:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl create secret generic official &lt;span class="nt"&gt;--from-file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/tmp/new-official.txt &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--dry-run&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;client &lt;span class="nt"&gt;-o&lt;/span&gt; yaml | kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; -
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It does not. The key still defaults to the base name, so the manifest carries &lt;code&gt;new-official.txt&lt;/code&gt;, and &lt;code&gt;data&lt;/code&gt; is a map that apply merges key by key. The pod ends up with a second file while &lt;code&gt;/opt/demo/official.txt&lt;/code&gt; keeps its old contents. The form that updates in place names the key explicitly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl create secret generic official &lt;span class="nt"&gt;--from-file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;official.txt&lt;span class="o"&gt;=&lt;/span&gt;/tmp/new-official.txt &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--dry-run&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;client &lt;span class="nt"&gt;-o&lt;/span&gt; yaml | kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; -
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I did not run the first version, so that outcome is reasoning from the documented key naming and merge rules rather than something I watched break.&lt;/p&gt;

&lt;h2&gt;
  
  
  Task 2: Add one tag to a VM
&lt;/h2&gt;

&lt;p&gt;The task: put &lt;code&gt;Environment=dev&lt;/code&gt; on the VM &lt;code&gt;devops-vm&lt;/code&gt;. One tag, one command, and a choice of command that matters more than the tag does.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Check what is already there
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az vm show &lt;span class="nt"&gt;-g&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; devops-vm &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s2"&gt;"tags"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An empty object. Not &lt;code&gt;null&lt;/code&gt;, not an empty string. That is the fourth distinct way these labs have spelled "nothing" so far, after a missing field, an unset field and a computed empty string, and it decides how you test for it: &lt;code&gt;length(tags)&lt;/code&gt; returns 0 for &lt;code&gt;{}&lt;/code&gt;, while a null check matches nothing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Choose the command that says what it means
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az tag update &lt;span class="nt"&gt;--resource-id&lt;/span&gt; &lt;span class="nv"&gt;$VM_ID&lt;/span&gt; &lt;span class="nt"&gt;--operation&lt;/span&gt; Merge &lt;span class="nt"&gt;--tags&lt;/span&gt; &lt;span class="nv"&gt;Environment&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Azure has several ways to set a tag and they do not mean the same thing. &lt;code&gt;az tag update --operation Merge&lt;/code&gt; "allows adding tags with new names and updating the values of tags with existing names". &lt;code&gt;Replace&lt;/code&gt; "replaces the entire set of existing tags with a new set". &lt;code&gt;Delete&lt;/code&gt; removes the ones you name.&lt;/p&gt;

&lt;p&gt;The one to be careful with is &lt;code&gt;az resource tag&lt;/code&gt;, which reads like "add this tag" and whose reference describes it only as "Tag a resource."&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Verify
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az vm show &lt;span class="nt"&gt;-g&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; devops-vm &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s2"&gt;"{Tags:tags,Power:powerState}"&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Tags"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Environment"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"dev"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Power"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"VM running"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;VM running&lt;/code&gt; before and after. Nothing restarted.&lt;/p&gt;

&lt;p&gt;I went looking for a Microsoft sentence confirming that tagging never restarts a VM and could not find one, so that is an observation from this run rather than a guarantee. The closest related statement points the other way: "you can only update tags on virtual machine extensions when the virtual machine is running."&lt;/p&gt;

&lt;h3&gt;
  
  
  The warning the docs decline to give
&lt;/h3&gt;

&lt;p&gt;I wanted to write that &lt;code&gt;az resource tag&lt;/code&gt; replaces all tags unless you pass &lt;code&gt;--is-incremental&lt;/code&gt;. The reference will not quite support it.&lt;/p&gt;

&lt;p&gt;What the reference actually contains is the description of the flag, "The option to add tags incrementally without deleting the original tags", and an example captioned "It doesn't empty the existing tags." Both sentences are only meaningful if the default does delete and does empty. The behaviour is a sound inference. It is not a documented statement, and on a VM carrying &lt;code&gt;Owner&lt;/code&gt;, &lt;code&gt;CostCentre&lt;/code&gt; and &lt;code&gt;Project&lt;/code&gt; the difference between those two things is three tags.&lt;/p&gt;

&lt;p&gt;That is enough reason to use the command that names its operation. It is not enough to assert the other command's behaviour as fact without running it.&lt;/p&gt;

&lt;p&gt;It also made no difference here, which is worth admitting. The VM had no tags, so Merge and Replace would have produced identical output. The command has to be chosen before you know whether it matters.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the docs recommend
&lt;/h3&gt;

&lt;p&gt;Expect no inheritance. "Resources don't inherit the tags you apply to a resource group or a subscription." Tag a VM and its disks, NIC and public IP stay untagged, which quietly splits a cost report: compute under &lt;code&gt;dev&lt;/code&gt;, storage under nothing.&lt;/p&gt;

&lt;p&gt;Azure Policy is the fix, and the policy names are close enough to misread. "Inherit a tag from the resource group" uses a &lt;code&gt;modify&lt;/code&gt; effect and "Adds or replaces the specified tag and value from the parent resource group". "Inherit a tag from the resource group if missing" leaves an existing value alone. "Append a tag and its value from the resource group" does not touch resources created before the assignment. Picking the wrong one either overwrites deliberate values or silently skips everything that already exists.&lt;/p&gt;

&lt;p&gt;Standardise the casing of values. "Tag names are case-insensitive for operations", but "Tag values are case-sensitive". So &lt;code&gt;Environment&lt;/code&gt; and &lt;code&gt;environment&lt;/code&gt; reach the same tag, while &lt;code&gt;Dev&lt;/code&gt; and &lt;code&gt;dev&lt;/code&gt; are two different answers in a cost report.&lt;/p&gt;

&lt;p&gt;Know the limits before designing a tagging scheme: 50 tag name-value pairs per resource, names up to 512 characters, values up to 256. Storage accounts cap names at 128. Five resource types allow only 15 tags, Azure Automation and the DNS services among them. And tag names cannot contain &lt;code&gt;&amp;lt;&lt;/code&gt;, &lt;code&gt;&amp;gt;&lt;/code&gt;, &lt;code&gt;%&lt;/code&gt;, &lt;code&gt;&amp;amp;&lt;/code&gt;, &lt;code&gt;\&lt;/code&gt;, &lt;code&gt;?&lt;/code&gt; or &lt;code&gt;/&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Query them server-side, since that is the entire point of having them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az resource list &lt;span class="nt"&gt;--tag&lt;/span&gt; &lt;span class="nv"&gt;Environment&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;dev &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s2"&gt;"[].{Name:name,Type:type,RG:resourceGroup}"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; table
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note that &lt;code&gt;--tag&lt;/code&gt; will not combine with &lt;code&gt;--resource-group&lt;/code&gt;, and the CLI's source shows it also refuses &lt;code&gt;--namespace&lt;/code&gt;, &lt;code&gt;--resource-type&lt;/code&gt;, &lt;code&gt;--name&lt;/code&gt; and &lt;code&gt;--location&lt;/code&gt;. The error can even fire on a filter you did not type, because a configured CLI default counts.&lt;/p&gt;

&lt;p&gt;Compare with AWS, which solves this by having one command per intent. &lt;code&gt;aws ec2 create-tags&lt;/code&gt; "Adds or overwrites only the specified tags for the specified Amazon EC2 resource or resources", and removing tags is a separate &lt;code&gt;delete-tags&lt;/code&gt; call. There is no flag to get wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I learned
&lt;/h2&gt;

&lt;p&gt;Both tasks hid their real behaviour behind a name. The Secret's key came from the filename, so the path inside the container was decided by something I typed on the host. The tag command's behaviour came from which of four commands I reached for, and the documentation describes the safe one clearly while leaving the dangerous one's default to be inferred.&lt;/p&gt;

&lt;p&gt;The second case is the one I will remember, because the inference is almost certainly right and I still could not write it down as a fact.&lt;/p&gt;

&lt;p&gt;So here is the Day 62 question. When a tool's documentation describes the safe option carefully and says nothing about the default, which one do you assume you are using?&lt;/p&gt;

&lt;p&gt;Day 62 down. Thirty-eight to go.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>kubernetes</category>
      <category>azure</category>
      <category>containers</category>
    </item>
    <item>
      <title>Day 61: Init Containers and a VM Resize</title>
      <dc:creator>Nnamdi Felix Ibe</dc:creator>
      <pubDate>Sun, 11 Oct 2026 08:00:00 +0000</pubDate>
      <link>https://dev.to/ndcodes/day-61-init-containers-and-a-vm-resize-39ll</link>
      <guid>https://dev.to/ndcodes/day-61-init-containers-and-a-vm-resize-39ll</guid>
      <description>&lt;p&gt;Day 61 of DevOps, Day 11 of Azure. Both tasks today were about something that has to finish before the real work starts. An init container must exit before the application container is allowed to run. And a VM has to restart, sometimes on different hardware, before a new size takes effect.&lt;/p&gt;

&lt;p&gt;One Kubernetes task, one Azure task. Run an init container that prepares a file for the main container, then resize a VM from one size to the next. The tasks come from the KodeKloud Engineer platform.&lt;/p&gt;

&lt;h2&gt;
  
  
  Task 1: An init container that prepares a file
&lt;/h2&gt;

&lt;p&gt;The task: a Deployment called &lt;code&gt;ic-deploy-devops&lt;/code&gt; with one replica. An init container, &lt;code&gt;ic-msg-devops&lt;/code&gt;, writes a message to a file on a shared volume. The main container, &lt;code&gt;ic-main-devops&lt;/code&gt;, prints that file every five seconds.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Write the Deployment
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;apps/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deployment&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ic-deploy-devops&lt;/span&gt;
  &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ic-devops&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;replicas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
  &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ic-devops&lt;/span&gt;
  &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ic-devops&lt;/span&gt;
    &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;initContainers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ic-msg-devops&lt;/span&gt;
        &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;debian:latest&lt;/span&gt;
        &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;/bin/bash'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;-c'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;echo&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Init&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Done&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Welcome&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;to&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;xFusionCorp&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Industries&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;gt;&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;/ic/media'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
        &lt;span class="na"&gt;volumeMounts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ic-volume-devops&lt;/span&gt;
          &lt;span class="na"&gt;mountPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/ic&lt;/span&gt;
      &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ic-main-devops&lt;/span&gt;
        &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;debian:latest&lt;/span&gt;
        &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;/bin/bash'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;-c'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;while&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;true;&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;do&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;cat&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;/ic/media;&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;sleep&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;5;&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;done'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
        &lt;span class="na"&gt;volumeMounts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ic-volume-devops&lt;/span&gt;
          &lt;span class="na"&gt;mountPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/ic&lt;/span&gt;
      &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ic-volume-devops&lt;/span&gt;
        &lt;span class="na"&gt;emptyDir&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There are two lists of containers and one volume. Both containers mount the same &lt;code&gt;emptyDir&lt;/code&gt; at &lt;code&gt;/ic&lt;/code&gt;, and that volume is the only thing connecting them. The init container writes &lt;code&gt;/ic/media&lt;/code&gt; and exits. The main container reads it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Apply and watch the pod start
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; k3s-deployment.yaml
kubectl get deployments.apps
kubectl get pods &lt;span class="nt"&gt;-w&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pod passes through &lt;code&gt;Init:0/1&lt;/code&gt;, then &lt;code&gt;PodInitializing&lt;/code&gt;, then &lt;code&gt;Running&lt;/code&gt;. The Kubernetes docs read &lt;code&gt;Init:N/M&lt;/code&gt; as: "The Pod has &lt;code&gt;M&lt;/code&gt; Init Containers, and &lt;code&gt;N&lt;/code&gt; have completed so far." So &lt;code&gt;Init:0/1&lt;/code&gt; means the one init container has not finished yet.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Confirm the init container finished
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl logs &amp;lt;pod-name&amp;gt; &lt;span class="nt"&gt;-c&lt;/span&gt; ic-msg-devops
kubectl describe pod &amp;lt;pod-name&amp;gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-A&lt;/span&gt; 20 &lt;span class="s2"&gt;"Init Containers"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The logs are empty, and that is correct. The &lt;code&gt;echo&lt;/code&gt; is redirected into the file, so nothing goes to stdout. The evidence is in &lt;code&gt;describe&lt;/code&gt;: State &lt;code&gt;Terminated&lt;/code&gt;, Reason &lt;code&gt;Completed&lt;/code&gt;, Exit Code 0.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: Confirm the main container reads the file
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl logs &amp;lt;pod-name&amp;gt; &lt;span class="nt"&gt;-c&lt;/span&gt; ic-main-devops
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Init Done - Welcome to xFusionCorp Industries
Init Done - Welcome to xFusionCorp Industries
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A new line appears every five seconds. To skip looking up the pod name, kubectl accepts the Deployment instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl logs deployment/ic-deploy-devops &lt;span class="nt"&gt;-c&lt;/span&gt; ic-main-devops
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 5: Delete the pod and watch it run again
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl delete pod &lt;span class="nt"&gt;-l&lt;/span&gt; &lt;span class="nv"&gt;app&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;ic-devops
kubectl get pods &lt;span class="nt"&gt;-w&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Deployment creates a replacement, and the replacement goes through &lt;code&gt;Init:0/1&lt;/code&gt; again. The file was on an &lt;code&gt;emptyDir&lt;/code&gt;, so it went with the old pod, and the init container writes it afresh for the new one.&lt;/p&gt;

&lt;h3&gt;
  
  
  Two things my notes had wrong
&lt;/h3&gt;

&lt;p&gt;My notes said the pod shows &lt;code&gt;Init:Running&lt;/code&gt; while the init container runs. That is not a status the docs list. Their table has &lt;code&gt;Init:N/M&lt;/code&gt;, &lt;code&gt;Init:Error&lt;/code&gt; and &lt;code&gt;Init:CrashLoopBackOff&lt;/code&gt;, and the count form is what kubectl prints while an init container is still going.&lt;/p&gt;

&lt;p&gt;They also said init containers run again "even if main containers are restarted". The Pod Lifecycle page says otherwise: "init containers run only once (if successful), during Pod startup." By default, a main container crashing and restarting does not bring the init container back. A new pod does, which is what Step 5 showed. The same page describes one opt-in exception, a feature-gated restart rule called &lt;code&gt;RestartAllContainers&lt;/code&gt;, which re-runs init containers too.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the docs recommend
&lt;/h3&gt;

&lt;p&gt;The basic contract comes first. "Init containers always run to completion", and "Each init container must complete successfully before the next one starts." If one fails, "the kubelet repeatedly restarts that init container until it succeeds", and the application container never starts in the meantime.&lt;/p&gt;

&lt;p&gt;Write them to be safe to repeat. The docs: "Because init containers can be restarted, retried, or re-executed, init container code should be idempotent." Mine is, because &lt;code&gt;&amp;gt;&lt;/code&gt; overwrites the file. An init container that appended with &lt;code&gt;&amp;gt;&amp;gt;&lt;/code&gt;, or created something that must not already exist, would behave differently on its second run.&lt;/p&gt;

&lt;p&gt;Do not expect probes. Regular init containers do not support &lt;code&gt;livenessProbe&lt;/code&gt;, &lt;code&gt;readinessProbe&lt;/code&gt;, &lt;code&gt;startupProbe&lt;/code&gt; or &lt;code&gt;lifecycle&lt;/code&gt;. They are meant to exit, so there is nothing to keep checking.&lt;/p&gt;

&lt;p&gt;Count their resources. The scheduler does not add init containers to the total. It takes the highest request among them, compares it with the sum of the application containers' requests, and uses the larger. A heavy setup step can make a pod need more room than the application itself ever uses.&lt;/p&gt;

&lt;p&gt;Know the related form from Day 55. A native sidecar is declared in the same &lt;code&gt;initContainers&lt;/code&gt; list, with one extra line, &lt;code&gt;restartPolicy: Always&lt;/code&gt;. That line is the whole difference: with it, the container starts first and keeps running; without it, the container must exit before anything else starts.&lt;/p&gt;

&lt;p&gt;And &lt;code&gt;debian:latest&lt;/code&gt; was the task's choice. Outside a lab, pin the tag.&lt;/p&gt;

&lt;h2&gt;
  
  
  Task 2: Resize a VM
&lt;/h2&gt;

&lt;p&gt;The task: change &lt;code&gt;nautilus-vm&lt;/code&gt; from &lt;code&gt;Standard_B1s&lt;/code&gt; to &lt;code&gt;Standard_B2s&lt;/code&gt; and leave it running.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Ask what this VM can resize to
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az vm list-vm-resize-options &lt;span class="nt"&gt;-g&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; nautilus-vm &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s2"&gt;"[?name=='Standard_B2s']"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; table
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Name          Cores
------------  -------
Standard_B2s  2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A VM runs on a hardware cluster that supports a particular set of sizes. Microsoft: "Deallocation may be required if the new size isn't available on the hardware cluster currently hosting the VM." This query lists the sizes this one VM can be resized to. A row means &lt;code&gt;Standard_B2s&lt;/code&gt; is on that list. An empty result means it is not, and that is the case where deallocating first may be needed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Resize
&lt;/h3&gt;

&lt;p&gt;The query listed &lt;code&gt;Standard_B2s&lt;/code&gt;. I deallocated anyway:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az vm deallocate &lt;span class="nt"&gt;-g&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; nautilus-vm
az vm resize &lt;span class="nt"&gt;-g&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; nautilus-vm &lt;span class="nt"&gt;--size&lt;/span&gt; Standard_B2s
az vm start &lt;span class="nt"&gt;-g&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; nautilus-vm
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The shorter route would have been the middle command by itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az vm resize &lt;span class="nt"&gt;-g&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; nautilus-vm &lt;span class="nt"&gt;--size&lt;/span&gt; Standard_B2s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My notes recorded the three-command version as the slower path, taken when a cheaper one was available. That is fair, with one qualification: Microsoft's own example script does the same. It checks the resize options and then "deallocates the VM, resizes it, and starts it again". The one-command version does not release the VM from its hardware first, so it should mean less downtime, although Microsoft's page gives no timings.&lt;/p&gt;

&lt;p&gt;Neither avoids a restart. Microsoft: "Even when deallocation isn't required, changing the size of a running VM will cause it to restart."&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Verify size and state separately
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az vm show &lt;span class="nt"&gt;-g&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; nautilus-vm &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s2"&gt;"{Size:hardwareProfile.vmSize,Power:powerState,Prov:provisioningState}"&lt;/span&gt;
az vm get-instance-view &lt;span class="nt"&gt;-g&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; nautilus-vm &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s2"&gt;"{Status:instanceView.statuses[].displayStatus,Agent:instanceView.vmAgent.statuses[].displayStatus}"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Size"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Standard_B2s"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Power"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"VM running"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Prov"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Succeeded"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Agent"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"Ready"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"Provisioning succeeded"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"VM running"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There are two requirements, so there are two checks. &lt;code&gt;Size&lt;/code&gt; covers the resize. &lt;code&gt;VM running&lt;/code&gt; with agent &lt;code&gt;Ready&lt;/code&gt; covers the running state, and as on Day 58, the agent is the half that proves the guest came back up.&lt;/p&gt;

&lt;p&gt;One caveat about the first check comes from Microsoft's resize page. If a resize fails, "the VM model will still display the requested size, but the VM will continue running on its previous size until the resize is successfully allocated". So the size field reports what was asked for. A CPU count from inside the guest would settle it, using Day 58's Run Command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az vm run-command invoke &lt;span class="nt"&gt;-g&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; nautilus-vm &lt;span class="nt"&gt;--command-id&lt;/span&gt; RunShellScript &lt;span class="nt"&gt;--scripts&lt;/span&gt; &lt;span class="s2"&gt;"nproc"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On a &lt;code&gt;Standard_B2s&lt;/code&gt; it should print 2.&lt;/p&gt;

&lt;h3&gt;
  
  
  The column that vanished
&lt;/h3&gt;

&lt;p&gt;My first version of the Step 1 query asked for three fields:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s2"&gt;"[?name=='Standard_B2s'].{Name:name,Cores:numberOfCores,MemoryMB:memoryInMb}"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; table
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two columns came back. &lt;code&gt;MemoryMB&lt;/code&gt; was not blank. It was gone.&lt;/p&gt;

&lt;p&gt;Two things combined. The property is spelt &lt;code&gt;memoryInMB&lt;/code&gt; in the REST reference, with a capital B, so my &lt;code&gt;memoryInMb&lt;/code&gt; matched nothing and returned &lt;code&gt;null&lt;/code&gt;. Then &lt;code&gt;-o table&lt;/code&gt; dropped the column, because every value in it was null. A wrong field name produced output that looked complete, with no error anywhere.&lt;/p&gt;

&lt;p&gt;It is Day 58's lesson again, one step worse. Write queries against &lt;code&gt;-o json&lt;/code&gt;, where a &lt;code&gt;null&lt;/code&gt; is at least visible, and switch to &lt;code&gt;-o table&lt;/code&gt; once the field names are confirmed.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the docs recommend
&lt;/h3&gt;

&lt;p&gt;Check before you deallocate. &lt;code&gt;az vm list-vm-resize-options&lt;/code&gt; is scoped to one VM and answered on the server. It is the opposite of the &lt;code&gt;az vm list-skus&lt;/code&gt; call that ended two lab sessions on Day 59.&lt;/p&gt;

&lt;p&gt;Plan for the restart. Anything in memory is lost, and the temporary disk from Day 52 cannot be relied on. If you deallocate, Microsoft adds that it "also releases any dynamic IP addresses assigned to the VM. The OS and data disks are not affected."&lt;/p&gt;

&lt;p&gt;Know what the new size changes. From Microsoft's size table:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Standard_B1s&lt;/th&gt;
&lt;th&gt;Standard_B2s&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;vCPUs&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Memory&lt;/td&gt;
&lt;td&gt;1 GiB&lt;/td&gt;
&lt;td&gt;4 GiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Temporary disk&lt;/td&gt;
&lt;td&gt;4 GiB&lt;/td&gt;
&lt;td&gt;8 GiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Max data disks&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Max NICs&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The limits matter in reverse. A VM using three NICs, or more than two data disks, would not fit back into a B1s.&lt;/p&gt;

&lt;p&gt;I removed one claim. My notes said B-series CPU credits are lost when a VM is deallocated. I could not find that in Microsoft's current B-series pages, which describe the credits a VM starts with and say nothing about stopping it. So it is not in this post as a fact.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I learned
&lt;/h2&gt;

&lt;p&gt;An init container is a rule about order: this must succeed before that may begin. A resize has the same shape, with the restart as the step you cannot skip. In both, the question worth asking early is what has to finish first, and what it costs while it does.&lt;/p&gt;

&lt;p&gt;So here is the Day 61 question. What in your system assumes a setup step runs exactly once, and what happens the day it runs twice?&lt;/p&gt;

&lt;p&gt;Day 61 down. Thirty-nine to go.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>kubernetes</category>
      <category>azure</category>
      <category>containers</category>
    </item>
    <item>
      <title>Day 60: Persistent Volumes and IP Configuration</title>
      <dc:creator>Nnamdi Felix Ibe</dc:creator>
      <pubDate>Sat, 10 Oct 2026 08:00:00 +0000</pubDate>
      <link>https://dev.to/ndcodes/day-60-persistent-volumes-and-ip-configuration-1iap</link>
      <guid>https://dev.to/ndcodes/day-60-persistent-volumes-and-ip-configuration-1iap</guid>
      <description>&lt;p&gt;Day 60 of DevOps, Day 10 of Azure. Both tasks today had an object in the middle that the task never names. A pod does not mount a PersistentVolume; it mounts a claim, and the claim finds the volume. A VM does not hold a public IP; an IP configuration on its network interface does.&lt;/p&gt;

&lt;p&gt;One Kubernetes task, one Azure task. Give a web server pod storage that outlives it, then attach an existing public IP to an existing VM. The tasks come from the KodeKloud Engineer platform.&lt;/p&gt;

&lt;h2&gt;
  
  
  Task 1: Persistent storage for a pod
&lt;/h2&gt;

&lt;p&gt;The task: a 3Gi PersistentVolume called &lt;code&gt;pv-xfusion&lt;/code&gt; on a host path, a claim called &lt;code&gt;pvc-xfusion&lt;/code&gt;, a pod called &lt;code&gt;pod-xfusion&lt;/code&gt; running &lt;code&gt;httpd:latest&lt;/code&gt; with the claim mounted, and a NodePort Service called &lt;code&gt;web-xfusion&lt;/code&gt; on port 30008.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Create the PersistentVolume
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;PersistentVolume&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pv-xfusion&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;capacity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;storage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;3Gi&lt;/span&gt;
  &lt;span class="na"&gt;accessModes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;ReadWriteOnce&lt;/span&gt;
  &lt;span class="na"&gt;storageClassName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;manual&lt;/span&gt;
  &lt;span class="na"&gt;hostPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/mnt/security&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; k3s-pv.yaml
kubectl get pv
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The volume shows STATUS &lt;code&gt;Available&lt;/code&gt;. It exists, and nothing is using it yet.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Claim it
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;PersistentVolumeClaim&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pvc-xfusion&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;accessModes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;ReadWriteOnce&lt;/span&gt;
  &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;storage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;3Gi&lt;/span&gt;
  &lt;span class="na"&gt;storageClassName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;manual&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; k3s-pvc.yaml
kubectl get pvc
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now both show &lt;code&gt;Bound&lt;/code&gt;, and the claim's VOLUME column reads &lt;code&gt;pv-xfusion&lt;/code&gt;. I never told the claim which volume to use. Kubernetes matched them on three things: the same &lt;code&gt;storageClassName&lt;/code&gt;, a compatible access mode, and enough capacity.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Mount the claim in a pod
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Pod&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pod-xfusion&lt;/span&gt;
  &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;xfusion&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;container-xfusion&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;httpd:latest&lt;/span&gt;
    &lt;span class="na"&gt;volumeMounts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;xfusion-volume&lt;/span&gt;
      &lt;span class="na"&gt;mountPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/usr/share/nginx/html/data&lt;/span&gt;
    &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;128Mi"&lt;/span&gt;
        &lt;span class="na"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;500m"&lt;/span&gt;
      &lt;span class="na"&gt;requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;64Mi"&lt;/span&gt;
        &lt;span class="na"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;250m"&lt;/span&gt;
  &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;xfusion-volume&lt;/span&gt;
    &lt;span class="na"&gt;persistentVolumeClaim&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;claimName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pvc-xfusion&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; k3s-pod.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pod names the claim, not the volume. That is the whole design: the pod says what it needs, and what actually provides the storage can change without the pod's spec changing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: Expose the pod
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Service&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;web-xfusion&lt;/span&gt;
  &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;xfusion&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;NodePort&lt;/span&gt;
  &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;80&lt;/span&gt;
    &lt;span class="na"&gt;targetPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;80&lt;/span&gt;
    &lt;span class="na"&gt;nodePort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;30008&lt;/span&gt;
  &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;xfusion&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; k3s-svc.yaml
kubectl get pods
kubectl get svc web-xfusion
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pod is &lt;code&gt;Running&lt;/code&gt;, and the Service shows &lt;code&gt;80:30008/TCP&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 5: Prove the data outlives the pod
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nb"&gt;exec &lt;/span&gt;pod-xfusion &lt;span class="nt"&gt;--&lt;/span&gt; sh &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'echo "Hello from persistent storage" &amp;gt; /usr/share/nginx/html/data/test.html'&lt;/span&gt;

kubectl delete pod pod-xfusion
kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; k3s-pod.yaml

kubectl &lt;span class="nb"&gt;exec &lt;/span&gt;pod-xfusion &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="nb"&gt;cat&lt;/span&gt; /usr/share/nginx/html/data/test.html
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Hello from persistent storage
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It is a new pod, and the file is still there, because it was never in the pod. It sits in &lt;code&gt;/mnt/security&lt;/code&gt; on the node, held by a volume and a claim that both outlived the pod.&lt;/p&gt;

&lt;h3&gt;
  
  
  A path from the wrong image
&lt;/h3&gt;

&lt;p&gt;Look at the mount path again: &lt;code&gt;/usr/share/nginx/html/data&lt;/code&gt;. That sits under &lt;code&gt;/usr/share/nginx/html&lt;/code&gt;, the nginx image's document root. This pod runs &lt;code&gt;httpd&lt;/code&gt;, and the official httpd image serves from &lt;code&gt;/usr/local/apache2/htdocs/&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So the storage worked exactly as the test shows, and Apache was never serving it. A request to the Service on port 30008 would get the image's default page, and nothing from the volume. The persistence test passed because it only tested the volume.&lt;/p&gt;

&lt;p&gt;If the volume is meant to hold the site, the mount for this image is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;volumeMounts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;xfusion-volume&lt;/span&gt;
  &lt;span class="na"&gt;mountPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/usr/local/apache2/htdocs&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That has its own consequence, the one from Day 55: a mount hides what the image had at that path. Apache's default page would be covered, and the site would serve whatever is on the volume.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the docs recommend
&lt;/h3&gt;

&lt;p&gt;Keep &lt;code&gt;hostPath&lt;/code&gt; for single-node testing. The Kubernetes docs list it as "for single node testing only; WILL NOT WORK in a multi-node cluster", and say of the volume type in general: "If you can avoid using a &lt;code&gt;hostPath&lt;/code&gt; volume, you should." On a cluster with several nodes, my recreated pod could land on a different node, where &lt;code&gt;/mnt/security&lt;/code&gt; is a different directory.&lt;/p&gt;

&lt;p&gt;Let the cluster provision storage. In production nobody writes the PersistentVolume by hand. Dynamic provisioning "automatically provisions storage when users create PersistentVolumeClaim objects", through a StorageClass. In my manifests &lt;code&gt;manual&lt;/code&gt; is only a matching name; no StorageClass object called &lt;code&gt;manual&lt;/code&gt; has to exist.&lt;/p&gt;

&lt;p&gt;Know the fourth access mode. My notes listed three. The docs have four, the newest being ReadWriteOncePod, where "the volume can be mounted as read-write by a single Pod". ReadWriteOnce is looser than it sounds: it is per node, so several pods on the same node can share the volume.&lt;/p&gt;

&lt;p&gt;Decide what happens to the data afterwards. For a PersistentVolume created by hand, the reclaim policy defaults to Retain. Delete the claim, and the volume becomes &lt;code&gt;Released&lt;/code&gt;, which is not the same as available: "it is not yet available for another claim because the previous claimant's data remains on the volume".&lt;/p&gt;

&lt;p&gt;Delete in order. A claim that a pod is still using is not removed straight away; its removal "is postponed until the PVC is no longer actively used by any Pods". Pod first, then claim, then volume.&lt;/p&gt;

&lt;p&gt;One correction to my notes: their cloud volume examples were out of date. The in-tree &lt;code&gt;awsElasticBlockStore&lt;/code&gt; and &lt;code&gt;azureDisk&lt;/code&gt; volume types were removed in Kubernetes v1.27, and the docs' current AWS example uses the CSI provisioner &lt;code&gt;ebs.csi.aws.com&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Task 2: Attach a public IP to a VM
&lt;/h2&gt;

&lt;p&gt;The task: attach the existing public IP &lt;code&gt;devops-pip&lt;/code&gt; to the existing VM &lt;code&gt;devops-vm-pip&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Derive the NIC and IP configuration names
&lt;/h3&gt;

&lt;p&gt;A public IP does not attach to a VM, or even to a NIC. Microsoft: "you associate the public IP address to an IP configuration of a network interface attached to a VM."&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;VM  -&amp;gt;  NIC  -&amp;gt;  IP configuration  -&amp;gt;  public IP
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the command needs two names the task never mentions, and I read both from the API:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;NIC_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;az vm show &lt;span class="nt"&gt;-g&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; devops-vm-pip &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s2"&gt;"networkProfile.networkInterfaces[0].id"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; tsv&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;NIC&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;basename&lt;/span&gt; &lt;span class="nv"&gt;$NIC_ID&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;IPCFG&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;az network nic show &lt;span class="nt"&gt;--ids&lt;/span&gt; &lt;span class="nv"&gt;$NIC_ID&lt;/span&gt; &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s2"&gt;"ipConfigurations[0].name"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; tsv&lt;span class="si"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;basename&lt;/code&gt; works because every Azure resource ID ends in &lt;code&gt;/&amp;lt;name&amp;gt;&lt;/code&gt;. &lt;code&gt;[0]&lt;/code&gt; was safe because I had confirmed the VM had one NIC.&lt;/p&gt;

&lt;p&gt;The IP configuration turned out to be &lt;code&gt;ipconfigdevops-vm-pip&lt;/code&gt;, not the &lt;code&gt;ipconfig1&lt;/code&gt; that most examples use. The CLI's source explains the difference: &lt;code&gt;az vm create&lt;/code&gt; names it &lt;code&gt;ipconfig&lt;/code&gt; plus the VM name, while &lt;code&gt;az network nic create&lt;/code&gt; uses &lt;code&gt;ipconfig1&lt;/code&gt;. Microsoft's own pages are not consistent either. The page on associating a public IP uses &lt;code&gt;ipconfig1&lt;/code&gt; in its example, and the page on dissociating one uses &lt;code&gt;ipconfigmyVM&lt;/code&gt;. Guessing is how this step fails.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Attach the public IP
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az network nic ip-config update &lt;span class="nt"&gt;-g&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;--nic-name&lt;/span&gt; &lt;span class="nv"&gt;$NIC&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="nv"&gt;$IPCFG&lt;/span&gt; &lt;span class="nt"&gt;--public-ip-address&lt;/span&gt; devops-pip
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The VM stayed &lt;code&gt;VM running&lt;/code&gt; before, during and after. Yesterday's NIC needed the VM deallocated. A public IP is a setting on the NIC's IP configuration, so the VM itself is not modified.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Verify from both ends
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az vm show &lt;span class="nt"&gt;-g&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; devops-vm-pip &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s2"&gt;"{PubIPs:publicIps}"&lt;/span&gt;
az network public-ip show &lt;span class="nt"&gt;-g&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; devops-pip &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s2"&gt;"{IP:ipAddress,AttachedTo:ipConfiguration.id}"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;"PubIPs": "172.178.12.220"

"IP": "172.178.12.220"
"AttachedTo": ".../networkInterfaces/devops-vm-pipVMNic/ipConfigurations/ipconfigdevops-vm-pip"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The VM reports the address, and the public IP reports its binding, down to the IP configuration. These are two views of one relationship, and they agree.&lt;/p&gt;

&lt;p&gt;Before the attach, that first query returned &lt;code&gt;""&lt;/code&gt;, an empty string, not &lt;code&gt;null&lt;/code&gt;. &lt;code&gt;publicIps&lt;/code&gt; is not a stored property. The CLI computes it by joining the VM's public addresses with commas, and joining nothing gives an empty string. A script that tests for &lt;code&gt;null&lt;/code&gt; there will never match.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: Test reachability separately
&lt;/h3&gt;

&lt;p&gt;Assigned is not reachable. Microsoft: "Before you can connect to a public IP address from the internet, you must open the necessary ports/protocols in your network security groups." Day 57 covered why: a Standard public IP is closed to inbound traffic by default.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;timeout &lt;/span&gt;5 bash &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"&amp;lt;/dev/tcp/&lt;/span&gt;&lt;span class="nv"&gt;$PIP&lt;/span&gt;&lt;span class="s2"&gt;/22"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo &lt;/span&gt;PORT22_REACHABLE &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo &lt;/span&gt;PORT22_BLOCKED
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PORT22_REACHABLE
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So a rule allowing port 22 was already in place. Had it printed &lt;code&gt;PORT22_BLOCKED&lt;/code&gt;, the attachment would still have been correct, with a VM nobody could reach.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;/dev/tcp&lt;/code&gt; is a Bash feature, which is why this works on a host with no &lt;code&gt;nc&lt;/code&gt; or &lt;code&gt;telnet&lt;/code&gt;. The Bash manual: "Bash attempts to open the corresponding TCP socket." &lt;code&gt;timeout&lt;/code&gt; keeps it from hanging on a port that silently drops packets.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the docs recommend
&lt;/h3&gt;

&lt;p&gt;Do not look for the public IP inside the VM. Microsoft: "a virtual machine's operating system is unaware of any public IP address assigned to it". &lt;code&gt;ip addr&lt;/code&gt; in the guest shows only the private address, and Azure translates between the two.&lt;/p&gt;

&lt;p&gt;My notes said to ask the instance metadata service instead. That needs a qualifier. The metadata service has a &lt;code&gt;publicIpAddress&lt;/code&gt; field, but for a Standard SKU address, which is the only kind left, Microsoft's page points to the Load Balancer metadata endpoint. I have not tested what the plain field returns.&lt;/p&gt;

&lt;p&gt;Dissociate before deleting. Microsoft only allows deleting a public IP that is not associated with any IP configuration, and its page removes the association like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az network nic ip-config update &lt;span class="nt"&gt;-g&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$RG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--nic-name&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$NIC&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$IPCFG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--public-ip-address&lt;/span&gt; null
az network public-ip delete &lt;span class="nt"&gt;-g&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$RG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; devops-pip
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My notes used &lt;code&gt;--remove publicIPAddress&lt;/code&gt; for the first line. Microsoft's current page uses &lt;code&gt;--public-ip-address null&lt;/code&gt;. Either way, the VM loses its address the moment that line runs, so do not run it over an SSH session that arrives through the same address.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I learned
&lt;/h2&gt;

&lt;p&gt;Stated briefly, the tasks were to mount a volume in a pod and to attach a public IP to a VM. Both statements skip a layer. The claim is what lets storage change underneath a pod. The IP configuration is what lets one NIC carry several addresses. And in the first task, the part that was off was neither of those. It was a path, which no status column checks.&lt;/p&gt;

&lt;p&gt;So here is the Day 60 question. When did a test of yours last pass while checking the wrong thing?&lt;/p&gt;

&lt;p&gt;Day 60 down. Forty to go.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>kubernetes</category>
      <category>azure</category>
      <category>containers</category>
    </item>
    <item>
      <title>Day 59: A Broken Deployment and a NIC</title>
      <dc:creator>Nnamdi Felix Ibe</dc:creator>
      <pubDate>Fri, 09 Oct 2026 17:00:00 +0000</pubDate>
      <link>https://dev.to/ndcodes/day-59-a-broken-deployment-and-a-nic-3fmp</link>
      <guid>https://dev.to/ndcodes/day-59-a-broken-deployment-and-a-nic-3fmp</guid>
      <description>&lt;p&gt;Day 59 of DevOps, Day 9 of Azure. Both tasks today came down to what you check first. In Kubernetes, the events showed me one fault while a second sat unread in the same file. In Azure, the first check I ran was so heavy that it ended the lab session, twice.&lt;/p&gt;

&lt;p&gt;One Kubernetes task, one Azure task. Fix a Redis Deployment whose pods will not start, then attach an existing network interface to an existing VM. The tasks come from the KodeKloud Engineer platform.&lt;/p&gt;

&lt;h2&gt;
  
  
  Task 1: Fix a broken Redis Deployment
&lt;/h2&gt;

&lt;p&gt;The task: the pods of &lt;code&gt;redis-deployment&lt;/code&gt; are not running. Find out why and fix it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Look at the pod
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get pods
kubectl describe pod &amp;lt;redis-deployment-pod-name&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Events section at the bottom of &lt;code&gt;describe&lt;/code&gt; had the cause:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;MountVolume.SetUp failed for volume "config" : configmap "redis-cofig" not found
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 2: Compare with what exists
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get cm
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The ConfigMap in the cluster is &lt;code&gt;redis-config&lt;/code&gt;. The Deployment asks for &lt;code&gt;redis-cofig&lt;/code&gt;. One letter is missing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Fix the reference
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get deployment redis-deployment &lt;span class="nt"&gt;-o&lt;/span&gt; yaml &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; redis-deployment.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;config&lt;/span&gt;
  &lt;span class="na"&gt;configMap&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;redis-config&lt;/span&gt;    &lt;span class="c1"&gt;# was redis-cofig&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; redis-deployment.yaml
kubectl get pods
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 4: Find the second fault
&lt;/h3&gt;

&lt;p&gt;The new pod got past the volume and stopped again, this time with &lt;code&gt;ImagePullBackOff&lt;/code&gt;. It was the same file, with a second typo:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;redis-container&lt;/span&gt;
  &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;redis:alpine&lt;/span&gt;    &lt;span class="c1"&gt;# was redis:alpin&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 5: Recreate and verify
&lt;/h3&gt;

&lt;p&gt;I deleted the Deployment and created it again from the fixed file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl delete deployment redis-deployment
kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; redis-deployment.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get pods
kubectl logs &amp;lt;redis-pod-name&amp;gt;
kubectl &lt;span class="nb"&gt;exec&lt;/span&gt; &amp;lt;redis-pod-name&amp;gt; &lt;span class="nt"&gt;--&lt;/span&gt; redis-cli ping
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PONG
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Running&lt;/code&gt; says the container started. &lt;code&gt;PONG&lt;/code&gt; says Redis answers, which is the thing the task was really about.&lt;/p&gt;

&lt;h3&gt;
  
  
  What I would do differently
&lt;/h3&gt;

&lt;p&gt;Read the whole spec before fixing anything. Both typos were in the Deployment from the start. The events only showed the first failure the pod hit, so the image problem stayed hidden until the volume was fixed. One careful read of &lt;code&gt;kubectl get deployment redis-deployment -o yaml&lt;/code&gt; would have found both.&lt;/p&gt;

&lt;p&gt;Skip the delete. A Deployment rolls out new pods whenever its pod template changes, so applying the fixed file was enough. Deleting it also deleted its ReplicaSets, because Kubernetes cascades deletion to dependents by default, and with them the revision history that &lt;code&gt;kubectl rollout undo&lt;/code&gt; relies on, as on Day 52. The app was already down, so it cost nothing here. On a live one, it is an outage.&lt;/p&gt;

&lt;p&gt;Use the direct tool for a one-line fix, then wait for the rollout:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nb"&gt;set &lt;/span&gt;image deployment/redis-deployment redis-container&lt;span class="o"&gt;=&lt;/span&gt;redis:alpine
kubectl rollout status deployment/redis-deployment
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And preview a change before making it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl diff &lt;span class="nt"&gt;-f&lt;/span&gt; redis-deployment.yaml
kubectl apply &lt;span class="nt"&gt;--dry-run&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;server &lt;span class="nt"&gt;-f&lt;/span&gt; redis-deployment.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first shows the live object against the version you are about to apply. The second sends the request to the server without saving anything.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the docs recommend
&lt;/h3&gt;

&lt;p&gt;Start where I started. The Kubernetes debugging guide: "The first step in debugging a Pod is taking a look at it", with &lt;code&gt;kubectl describe pods&lt;/code&gt;. It then splits the early failures in two. A pod stuck in &lt;code&gt;Pending&lt;/code&gt; "can not be scheduled onto a node". A pod stuck in &lt;code&gt;Waiting&lt;/code&gt; has been scheduled and cannot run, and "The most common cause of &lt;code&gt;Waiting&lt;/code&gt; pods is a failure to pull the image." Its three checks for that: is the image name correct, has the image been pushed, and can you pull it by hand.&lt;/p&gt;

&lt;p&gt;A ConfigMap has to exist before anything references it. The docs: "You must create the &lt;code&gt;ConfigMap&lt;/code&gt; object before you reference it in a Pod specification", and unless the reference is marked &lt;code&gt;optional&lt;/code&gt;, "the Pod won't start".&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ImagePullBackOff&lt;/code&gt; means Kubernetes "could not pull a container image", and it keeps retrying with a growing delay, up to 300 seconds.&lt;/p&gt;

&lt;p&gt;Do not read the STATUS column as the pod's state. My notes listed &lt;code&gt;ContainerCreating&lt;/code&gt;, &lt;code&gt;CrashLoopBackOff&lt;/code&gt; and &lt;code&gt;ImagePullBackOff&lt;/code&gt; as pod states. The docs define five phases: Pending, Running, Succeeded, Failed and Unknown. The rest is what kubectl displays, and the docs say not to confuse the two.&lt;/p&gt;

&lt;p&gt;Manage an object one way. I exported a live object and ran &lt;code&gt;kubectl apply&lt;/code&gt; on it, which works, but the docs warn that an object "should be managed using only one technique", and that mixing them "results in undefined behavior". If you do export, strip the server-set fields such as &lt;code&gt;resourceVersion&lt;/code&gt;, &lt;code&gt;uid&lt;/code&gt; and &lt;code&gt;status&lt;/code&gt; first.&lt;/p&gt;

&lt;p&gt;Read events early. The API server keeps them for one hour by default.&lt;/p&gt;

&lt;h2&gt;
  
  
  Task 2: Attach a second NIC to a VM
&lt;/h2&gt;

&lt;p&gt;The task: attach the existing NIC &lt;code&gt;xfusion-nic&lt;/code&gt; to the existing VM &lt;code&gt;xfusion-vm&lt;/code&gt;, and make sure the VM has finished initialising.&lt;/p&gt;

&lt;p&gt;Yesterday's disk went onto a running VM. A NIC cannot. Microsoft: "To add a NIC to an existing VM, first deallocate the VM with az vm deallocate." That makes the order of work matter, because the VM is down from the first command to the last.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Check what is cheap to check
&lt;/h3&gt;

&lt;p&gt;Everything that could make the attach fail, I checked while the VM was still running:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az network nic show &lt;span class="nt"&gt;-g&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; xfusion-nic &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s2"&gt;"{Subnet:ipConfigurations[0].subnet.id,AttachedTo:virtualMachine.id,Loc:location}"&lt;/span&gt;
az network nic show &lt;span class="nt"&gt;-g&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; xfusion-vmVMNic &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s2"&gt;"ipConfigurations[0].subnet.id"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; tsv
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;xfusion-nic:        .../virtualNetworks/xfusion-vmVNET/subnets/xfusion-vmSubnet
xfusion-vmVMNic:    .../virtualNetworks/xfusion-vmVNET/subnets/xfusion-vmSubnet
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same virtual network, &lt;code&gt;AttachedTo&lt;/code&gt; was &lt;code&gt;null&lt;/code&gt;, and both were in eastus. The first of those is a hard rule. Microsoft: "the network interfaces must all be connected to the same virtual network", though they can sit in different subnets.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Deallocate, add, start
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az vm deallocate &lt;span class="nt"&gt;-g&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; xfusion-vm
az vm nic add    &lt;span class="nt"&gt;-g&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;--vm-name&lt;/span&gt; xfusion-vm &lt;span class="nt"&gt;--nics&lt;/span&gt; xfusion-nic
az vm start      &lt;span class="nt"&gt;-g&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; xfusion-vm
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The command is &lt;code&gt;deallocate&lt;/code&gt;, not &lt;code&gt;stop&lt;/code&gt;. &lt;code&gt;az vm stop&lt;/code&gt; powers the VM off but leaves it, in Microsoft's words, "allocated on a host but not running", and the CLI's own help adds: "The VM will continue to be billed." Deallocating is the state in which the VM "has released the lease on the underlying hardware", and it is the one a NIC change needs.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Verify
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az network nic show &lt;span class="nt"&gt;-g&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; xfusion-nic &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s2"&gt;"{AttachedTo:virtualMachine.id,Primary:primary}"&lt;/span&gt;
az vm get-instance-view &lt;span class="nt"&gt;-g&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; xfusion-vm &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s2"&gt;"{Status:instanceView.statuses[].displayStatus,Agent:instanceView.vmAgent.statuses[].displayStatus}"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The NIC pointed at the VM with &lt;code&gt;"Primary": false&lt;/code&gt;, and the VM was back to &lt;code&gt;VM running&lt;/code&gt; with agent &lt;code&gt;Ready&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That &lt;code&gt;false&lt;/code&gt; is new. Before the attach, the VM's only NIC showed &lt;code&gt;"Primary": null&lt;/code&gt;. With one NIC the flag was unset; with two, the original kept the primary role and the new one arrived as secondary. Microsoft: "By default, the first network interface attached to a VM is the primary network interface."&lt;/p&gt;

&lt;h3&gt;
  
  
  The check that cost two lab sessions
&lt;/h3&gt;

&lt;p&gt;There is a fourth constraint: each VM size supports a limited number of NICs. To look it up, I ran:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az vm list-skus &lt;span class="nt"&gt;-l&lt;/span&gt; southcentralus &lt;span class="nt"&gt;--resource-type&lt;/span&gt; virtualMachines &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s2"&gt;"..."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The lab terminal disconnected, twice. Each disconnect meant a new lab with new resource names.&lt;/p&gt;

&lt;p&gt;My notes said the command downloads the SKU catalogue for the region and filters it on the client. The CLI's source shows it is heavier than that. It sends no filter at all, so it fetches the SKU list for the whole subscription, every region, and applies &lt;code&gt;--location&lt;/code&gt; and &lt;code&gt;--resource-type&lt;/code&gt; afterwards. &lt;code&gt;--query&lt;/code&gt; only shrinks what is printed.&lt;/p&gt;

&lt;p&gt;The fix was to drop the check. Azure applies the limit itself when a NIC is added. Microsoft: "A VM can only have as many network interfaces attached to it as the VM size supports." I stayed inside the limit, so I have not seen what that failure looks like. The cheap way to know the number in advance is Microsoft's size table, which gives &lt;code&gt;Standard_B1s&lt;/code&gt; a maximum of 2 NICs. This second NIC was the last one the VM could take.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the docs recommend
&lt;/h3&gt;

&lt;p&gt;Plan a NIC change as downtime. This is where Azure and AWS differ. AWS: "You can attach a network interface to an instance when it's running (hot attach), when it's stopped (warm attach), or when the instance is being launched (cold attach)." Azure has only the deallocated route.&lt;/p&gt;

&lt;p&gt;Expect side effects from deallocating. Microsoft notes that it "also releases any dynamic IP addresses assigned to the VM", and the temporary disk from Day 52 cannot be relied on afterwards. Disks and networking resources are still charged while the VM is deallocated.&lt;/p&gt;

&lt;p&gt;Finish the job in the guest. Attaching the NIC does not make it carry traffic properly. Microsoft's Linux guide: "To send to or from a secondary network interface, you have to manually add persistent routes to the operating system for each secondary network interface." My notes said current Ubuntu images sort this out by themselves. I could not find that in Microsoft's documentation, so I have left it out.&lt;/p&gt;

&lt;p&gt;Know which NIC is primary. By default, a VM "sends all outbound traffic to the IP address that's assigned to the primary IP configuration of the primary network interface". Adding a NIC does not move that role. &lt;code&gt;--primary-nic&lt;/code&gt; on &lt;code&gt;az vm nic set&lt;/code&gt; does.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I learned
&lt;/h2&gt;

&lt;p&gt;In the first task I checked too little before acting, and found the second fault only by hitting it. In the second, I checked too much, and the check itself took the session down. The useful rule sits between the two: read everything that is cheap to read, and let the operation enforce what is expensive to ask about.&lt;/p&gt;

&lt;p&gt;So here is the Day 59 question. When you troubleshoot, do you stop reading at the first error you find, or at the end of the file?&lt;/p&gt;

&lt;p&gt;Day 59 down. Forty-one to go.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>kubernetes</category>
      <category>azure</category>
      <category>containers</category>
    </item>
    <item>
      <title>Day 58: Grafana and a Managed Disk</title>
      <dc:creator>Nnamdi Felix Ibe</dc:creator>
      <pubDate>Thu, 08 Oct 2026 17:00:00 +0000</pubDate>
      <link>https://dev.to/ndcodes/day-58-grafana-and-a-managed-disk-3i1d</link>
      <guid>https://dev.to/ndcodes/day-58-grafana-and-a-managed-disk-3i1d</guid>
      <description>&lt;p&gt;Day 58 of DevOps, Day 8 of Azure. Both tasks today had a gap between "created" and "working". A Deployment reports &lt;code&gt;1/1&lt;/code&gt; before Grafana is serving a single page. A VM reports &lt;code&gt;Provisioning succeeded&lt;/code&gt; before the agent inside it is ready, and a disk reports &lt;code&gt;Attached&lt;/code&gt; while the operating system sees a blank device.&lt;/p&gt;

&lt;p&gt;One Kubernetes task, one Azure task. Deploy Grafana and expose it on a node port, then attach an existing managed disk to an existing VM. The tasks come from the KodeKloud Engineer platform.&lt;/p&gt;

&lt;h2&gt;
  
  
  Task 1: Deploy Grafana behind a NodePort Service
&lt;/h2&gt;

&lt;p&gt;The task: a Deployment named &lt;code&gt;grafana-deployment-datacenter&lt;/code&gt; running a Grafana image, and a NodePort Service exposing it on port 32000.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Write the Deployment
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;apps/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deployment&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;grafana-deployment-datacenter&lt;/span&gt;
  &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;grafana&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;replicas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
  &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;grafana&lt;/span&gt;
  &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;grafana&lt;/span&gt;
    &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;grafana&lt;/span&gt;
        &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;grafana/grafana:latest&lt;/span&gt;
        &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;containerPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3000&lt;/span&gt;
          &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http-grafana&lt;/span&gt;
          &lt;span class="na"&gt;protocol&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;TCP&lt;/span&gt;
        &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;128Mi"&lt;/span&gt;
            &lt;span class="na"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;100m"&lt;/span&gt;
          &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;256Mi"&lt;/span&gt;
            &lt;span class="na"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;200m"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Port 3000 is Grafana's own default. Its configuration docs say of &lt;code&gt;http_port&lt;/code&gt;: "The port to bind to, defaults to &lt;code&gt;3000&lt;/code&gt;."&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Add the Service to the same file
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Service&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;grafana-service&lt;/span&gt;
  &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;grafana&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;NodePort&lt;/span&gt;
  &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;grafana&lt;/span&gt;
  &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3000&lt;/span&gt;
    &lt;span class="na"&gt;targetPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3000&lt;/span&gt;
    &lt;span class="na"&gt;nodePort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;32000&lt;/span&gt;
    &lt;span class="na"&gt;protocol&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;TCP&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;---&lt;/code&gt; line lets one file hold both objects. Nothing in the Service names the Deployment. As on Day 56, the only link is the label: &lt;code&gt;app: grafana&lt;/code&gt; appears in the Deployment's selector, in the pod template and in the Service's selector, and all three have to agree.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Apply and check both objects
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; k3s-deployment.yaml
kubectl get deployments.apps
kubectl get svc
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Deployment should show READY &lt;code&gt;1/1&lt;/code&gt;, and the Service should show TYPE &lt;code&gt;NodePort&lt;/code&gt; with PORT(S) &lt;code&gt;3000:32000/TCP&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: Open the login page
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get nodes &lt;span class="nt"&gt;-o&lt;/span&gt; wide
&lt;span class="c"&gt;# then browse to http://&amp;lt;node-ip&amp;gt;:32000&lt;/span&gt;

&lt;span class="c"&gt;# or, when the node IP is not reachable from where you sit:&lt;/span&gt;
kubectl port-forward deployment/grafana-deployment-datacenter 3000:3000
&lt;span class="c"&gt;# then browse to http://localhost:3000&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Grafana's sign-in docs give the first login as &lt;code&gt;admin&lt;/code&gt; for both username and password, followed by a prompt to change the password.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 5: Look at the pod
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl logs deployment/grafana-deployment-datacenter
kubectl describe pod &lt;span class="nt"&gt;-l&lt;/span&gt; &lt;span class="nv"&gt;app&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;grafana
kubectl top pod &lt;span class="nt"&gt;-l&lt;/span&gt; &lt;span class="nv"&gt;app&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;grafana
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Selecting by Deployment or by label saves copying the generated pod name. &lt;code&gt;kubectl top&lt;/code&gt; only works where Metrics Server is installed; the reference says it "must be installed and running in the cluster for this command to work".&lt;/p&gt;

&lt;h3&gt;
  
  
  Why &lt;code&gt;1/1&lt;/code&gt; is not the finish line
&lt;/h3&gt;

&lt;p&gt;My manifest has no readiness probe. The Kubernetes docs say that if a container does not provide a particular probe, "the kubelet always considers the result as &lt;code&gt;Success&lt;/code&gt;". So this pod counts as ready the moment its container starts, whether or not Grafana has finished starting inside it. &lt;code&gt;1/1&lt;/code&gt; told me the container was running. Only the login page told me Grafana was.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the docs recommend
&lt;/h3&gt;

&lt;p&gt;Grafana publishes its own Kubernetes manifest, and mine falls short of it in several places:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;My manifest&lt;/th&gt;
&lt;th&gt;Grafana's example&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Memory&lt;/td&gt;
&lt;td&gt;128Mi request, 256Mi limit&lt;/td&gt;
&lt;td&gt;750Mi request&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CPU&lt;/td&gt;
&lt;td&gt;100m request, 200m limit&lt;/td&gt;
&lt;td&gt;250m request&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Readiness probe&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;GET /robots.txt&lt;/code&gt; on port 3000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Liveness probe&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;TCP check on port 3000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Storage&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;1Gi claim mounted at &lt;code&gt;/var/lib/grafana&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;targetPort&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;3000&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;http-grafana&lt;/code&gt;, the port's name&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Resources come first. Grafana's Kubernetes page lists minimums of 750 MiB of memory and 250m of CPU. My memory limit is about a third of that. The pod started in the lab, but a limit under the documented minimum is how you meet the out-of-memory kill from Day 50.&lt;/p&gt;

&lt;p&gt;Storage matters more. Grafana's docs: "By default, Grafana uses an embedded SQLite version 3 database to store configuration, users, dashboards, and other data." In the container that lives under &lt;code&gt;/var/lib/grafana&lt;/code&gt;. With nothing mounted there, every dashboard and user disappears when the pod is replaced.&lt;/p&gt;

&lt;p&gt;The probes are short to add. These are the two from Grafana's example, without their timing settings:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;readinessProbe&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;httpGet&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/robots.txt&lt;/span&gt;
    &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3000&lt;/span&gt;
&lt;span class="na"&gt;livenessProbe&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;tcpSocket&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3000&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The named port is a small thing worth copying. The Kubernetes docs: "Port definitions in Pods have names, and you can reference these names in the &lt;code&gt;targetPort&lt;/code&gt; attribute of a Service." With &lt;code&gt;targetPort: http-grafana&lt;/code&gt;, the number 3000 lives in one place.&lt;/p&gt;

&lt;p&gt;Three smaller points remain. Port 32000 sits in the dynamic band of the node port range from Day 56, the band Kubernetes hands out automatically, so a hand-picked port there can already be taken. &lt;code&gt;grafana/grafana:latest&lt;/code&gt; is what Grafana's example uses too, though Kubernetes' advice is to avoid &lt;code&gt;:latest&lt;/code&gt; in production. And the admin password can come from a Secret through the &lt;code&gt;GF_SECURITY_ADMIN_PASSWORD&lt;/code&gt; environment variable, a name that follows Grafana's &lt;code&gt;GF_&amp;lt;SECTION NAME&amp;gt;_&amp;lt;KEY&amp;gt;&lt;/code&gt; rule. Grafana documents the setting behind it, &lt;code&gt;admin_password&lt;/code&gt;, as "Set once on first-run".&lt;/p&gt;

&lt;h2&gt;
  
  
  Task 2: Attach a managed disk to a VM
&lt;/h2&gt;

&lt;p&gt;The task: attach the existing disk &lt;code&gt;datacenter-disk&lt;/code&gt; to the existing VM &lt;code&gt;datacenter-vm&lt;/code&gt;, and make sure the VM has finished initialising.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Check the two resources fit
&lt;/h3&gt;

&lt;p&gt;Nothing is being created, so the risk is the two not fitting together. Two read-only calls cover it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az vm show &lt;span class="nt"&gt;-g&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; datacenter-vm &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s2"&gt;"{Loc:location,Zones:zones,Size:hardwareProfile.vmSize}"&lt;/span&gt;
az disk show &lt;span class="nt"&gt;-g&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; datacenter-disk &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s2"&gt;"{Loc:location,Zones:zones,State:diskState,Sku:sku.name}"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Check&lt;/th&gt;
&lt;th&gt;VM&lt;/th&gt;
&lt;th&gt;Disk&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Region&lt;/td&gt;
&lt;td&gt;eastus&lt;/td&gt;
&lt;td&gt;eastus&lt;/td&gt;
&lt;td&gt;Match&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Zone&lt;/td&gt;
&lt;td&gt;&lt;code&gt;null&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;null&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Match&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Disk state&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Unattached&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Free to attach&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Premium support&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Standard_B1s&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Standard_LRS&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Not needed&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Each row is a way the attach can fail. Microsoft's disks FAQ: "All managed disks, even shared disks, must be in the same region as the VM they're attaching to." A zonal disk attaches "only to VMs in that zone". A disk already attached elsewhere is not free. And Premium SSDs can be used "only with compatible VM series".&lt;/p&gt;

&lt;p&gt;One state is worth knowing in advance. A disk on a deallocated VM reads &lt;code&gt;Reserved&lt;/code&gt;, not &lt;code&gt;Attached&lt;/code&gt;. It is still bound to that VM, and a filter on &lt;code&gt;Attached&lt;/code&gt; will miss it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Confirm the VM has finished initialising
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az vm &lt;span class="nb"&gt;wait&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; datacenter-vm &lt;span class="nt"&gt;--created&lt;/span&gt;
az vm get-instance-view &lt;span class="nt"&gt;-g&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; datacenter-vm &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s2"&gt;"{Status:instanceView.statuses[].displayStatus,Agent:instanceView.vmAgent.statuses[].displayStatus}"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"Provisioning succeeded"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"VM running"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Agent"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"Ready"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;"Initialised" has two halves. &lt;code&gt;Provisioning succeeded&lt;/code&gt; is Azure's side. Agent &lt;code&gt;Ready&lt;/code&gt; is the guest's side: the Azure Linux Agent inside the VM has started and reported back. &lt;code&gt;az vm wait --created&lt;/code&gt; only waits for the first, so the instance view is the real check.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Attach the disk
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az vm disk attach &lt;span class="nt"&gt;-g&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;--vm-name&lt;/span&gt; datacenter-vm &lt;span class="nt"&gt;--name&lt;/span&gt; datacenter-disk
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The VM stayed &lt;code&gt;VM running&lt;/code&gt; throughout. Azure picked the LUN: the REST reference says "If not specified, lun would be auto assigned", and the CLI takes the lowest free number, here 0.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: Verify from Azure, then from inside the guest
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az vm show &lt;span class="nt"&gt;-g&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; datacenter-vm &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s2"&gt;"storageProfile.dataDisks[].{Name:name,Lun:lun,Caching:caching}"&lt;/span&gt;
az vm run-command invoke &lt;span class="nt"&gt;-g&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; datacenter-vm &lt;span class="nt"&gt;--command-id&lt;/span&gt; RunShellScript &lt;span class="nt"&gt;--scripts&lt;/span&gt; &lt;span class="s2"&gt;"lsblk"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sda   30G  disk     &amp;lt;- OS disk
sdb    4G  disk     &amp;lt;- temporary disk at /mnt
sdc   30G  disk     &amp;lt;- datacenter-disk, raw
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run Command needs no SSH key and no open port. It uses the VM agent to run the script, so a result also proves the agent works. Microsoft documents its limits: about 20 seconds minimum, one script at a time, and only the last 4,096 bytes of output.&lt;/p&gt;

&lt;p&gt;The disk is attached and raw, with no partition or filesystem. The task asked for attachment only, so I left it there.&lt;/p&gt;

&lt;h3&gt;
  
  
  The query that returned null
&lt;/h3&gt;

&lt;p&gt;Checking the disk's size from both sides gave two different answers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az disk show ... &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s2"&gt;"{GB:diskSizeGb}"&lt;/span&gt;     -&amp;gt;  &lt;span class="s2"&gt;"GB"&lt;/span&gt;: null
az vm show   ... &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s2"&gt;"{GB:diskSizeGb}"&lt;/span&gt;     -&amp;gt;  &lt;span class="s2"&gt;"GB"&lt;/span&gt;: 30
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The raw JSON explained it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az disk show &lt;span class="nt"&gt;-g&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; datacenter-disk &lt;span class="nt"&gt;-o&lt;/span&gt; json | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; disksize
  &lt;span class="s2"&gt;"diskSizeBytes"&lt;/span&gt;: 32212254720,
  &lt;span class="s2"&gt;"diskSizeGB"&lt;/span&gt;: 30,
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;az disk show&lt;/code&gt; printed &lt;code&gt;diskSizeGB&lt;/code&gt;. &lt;code&gt;az vm show&lt;/code&gt; printed &lt;code&gt;diskSizeGb&lt;/code&gt;. JMESPath is case-sensitive, and a key that does not exist returns &lt;code&gt;null&lt;/code&gt; instead of an error, so my query quietly found nothing.&lt;/p&gt;

&lt;p&gt;My notes concluded that Azure spells the property differently on the two resource types. It does not. The REST API uses &lt;code&gt;diskSizeGB&lt;/code&gt; in both places. The lowercase &lt;code&gt;b&lt;/code&gt; came from how the Azure CLI formatted &lt;code&gt;az vm show&lt;/code&gt; output, and the CLI's source code suggests that changed in version 2.84.0, which I have not tested. So the same query can work on one CLI version and return &lt;code&gt;null&lt;/code&gt; on the next.&lt;/p&gt;

&lt;p&gt;The fix does not depend on which version you have. When a value you know exists comes back &lt;code&gt;null&lt;/code&gt;, suspect the query before the resource, and look at the raw object with &lt;code&gt;-o json | grep -i&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the docs recommend
&lt;/h3&gt;

&lt;p&gt;Do not reference the disk as &lt;code&gt;/dev/sdc&lt;/code&gt;. Microsoft: "Device paths in Linux aren't guaranteed to be consistent across restarts." Use the filesystem UUID, or the link the Azure Linux Agent creates for the LUN:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/dev/disk/azure/scsi1/lun0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Choose caching when you attach. The disk came up with caching &lt;code&gt;None&lt;/code&gt;, and the REST reference explains that default: "None for Standard storage. ReadOnly for Premium storage." My notes had it as a rule about data disks; it is a rule about the storage type. Microsoft's guidance is &lt;code&gt;None&lt;/code&gt; for write-only and write-heavy disks, and &lt;code&gt;ReadOnly&lt;/code&gt; for read-only and read-write ones. Changing the setting later detaches and reattaches the disk, so it is better set once, with &lt;code&gt;--caching&lt;/code&gt; on the attach command.&lt;/p&gt;

&lt;p&gt;Before detaching, unmount the disk in the guest and remove its &lt;code&gt;/etc/fstab&lt;/code&gt; entry. And remember that detaching does not delete. Microsoft's warning is that a detached disk "is not automatically deleted", and you keep paying for it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az disk list &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s2"&gt;"[?diskState=='Unattached'].{Name:name,RG:resourceGroup,Sku:sku.name}"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; table
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What I learned
&lt;/h2&gt;

&lt;p&gt;Every status today was true and incomplete. &lt;code&gt;1/1&lt;/code&gt; meant a container had started. &lt;code&gt;Provisioning succeeded&lt;/code&gt; meant Azure had finished its half. &lt;code&gt;Attached&lt;/code&gt; meant a blank device had appeared. The checks that settled things were the ones that used the result: the login page, the agent's &lt;code&gt;Ready&lt;/code&gt;, and &lt;code&gt;lsblk&lt;/code&gt; from inside the VM.&lt;/p&gt;

&lt;p&gt;So here is the Day 58 question. Which of your checks proves that something was created, and which proves that it works?&lt;/p&gt;

&lt;p&gt;Day 58 down. Forty-two to go.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>kubernetes</category>
      <category>azure</category>
      <category>containers</category>
    </item>
    <item>
      <title>Day 57: Env Vars and a Public IP</title>
      <dc:creator>Nnamdi Felix Ibe</dc:creator>
      <pubDate>Wed, 07 Oct 2026 17:00:00 +0000</pubDate>
      <link>https://dev.to/ndcodes/day-57-env-vars-and-a-public-ip-3dn6</link>
      <guid>https://dev.to/ndcodes/day-57-env-vars-and-a-public-ip-3dn6</guid>
      <description>&lt;p&gt;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 &lt;code&gt;$(GREETING)&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Task 1: Print environment variables from a pod
&lt;/h2&gt;

&lt;p&gt;The task: a pod called &lt;code&gt;print-envars-greeting&lt;/code&gt;, with one container, &lt;code&gt;print-env-container&lt;/code&gt;, on the &lt;code&gt;bash&lt;/code&gt; image. Three environment variables, a fixed &lt;code&gt;echo&lt;/code&gt; command that prints them, and a restart policy of &lt;code&gt;Never&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Write the manifest
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Pod&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;print-envars-greeting&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;restartPolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Never&lt;/span&gt;
  &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;print-env-container&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bash&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/bin/sh"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-c"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;echo&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"$(GREETING)&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;$(COMPANY)&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;$(GROUP)"'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;GREETING&lt;/span&gt;
      &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Welcome&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;to"&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;COMPANY&lt;/span&gt;
      &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;xFusionCorp"&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;GROUP&lt;/span&gt;
      &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Group"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each entry under &lt;code&gt;env&lt;/code&gt; is a name and a value. The command runs through &lt;code&gt;/bin/sh -c&lt;/code&gt;, and &lt;code&gt;restartPolicy: Never&lt;/code&gt; tells Kubernetes to leave the container alone once the &lt;code&gt;echo&lt;/code&gt; has finished.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Apply it and read the output
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; k3s-pod.yml
kubectl logs &lt;span class="nt"&gt;-f&lt;/span&gt; print-envars-greeting
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Welcome to xFusionCorp Group
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 3: Check how the pod ended
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get pods
kubectl describe pod print-envars-greeting
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;get pods&lt;/code&gt; shows &lt;code&gt;Completed&lt;/code&gt;. &lt;code&gt;describe&lt;/code&gt; prints the pod's spec, so it lists the three variables under Environment, next to the command exactly as I wrote it.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Completed&lt;/code&gt; is what kubectl displays. The pod's phase is &lt;code&gt;Succeeded&lt;/code&gt;, 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".&lt;/p&gt;

&lt;p&gt;That ending depends on &lt;code&gt;restartPolicy: Never&lt;/code&gt;. The default is &lt;code&gt;Always&lt;/code&gt;, which restarts a container "after any termination", a successful exit included. Without the setting, this &lt;code&gt;echo&lt;/code&gt; would be restarted over and over, with a delay that starts at 10 seconds and is capped at 5 minutes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Who expands &lt;code&gt;$(GREETING)&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;This is the part my notes had wrong. I had written that &lt;code&gt;$(VARNAME)&lt;/code&gt; is shell variable syntax, processed by the shell at runtime.&lt;/p&gt;

&lt;p&gt;It is the other way round. In a shell, &lt;code&gt;$(name)&lt;/code&gt; 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 &lt;code&gt;GREETING&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Kubernetes gets there first. The API reference says of a container's &lt;code&gt;command&lt;/code&gt;: "Variable references $(VAR_NAME) are expanded using the container's environment." So the three references are replaced with the values from &lt;code&gt;env&lt;/code&gt; before the container starts, and the shell is handed a finished line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Welcome to xFusionCorp Group"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The shell's own forms are &lt;code&gt;$GREETING&lt;/code&gt; and &lt;code&gt;${GREETING}&lt;/code&gt;. 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 &lt;code&gt;$(GRETING)&lt;/code&gt; is passed through untouched, and the shell then treats it as a command to run.&lt;/p&gt;

&lt;p&gt;To print the literal text, double the dollar sign: &lt;code&gt;$$(GREETING)&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  One command that cannot work here
&lt;/h3&gt;

&lt;p&gt;My notes included this as a way to see the variables:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nb"&gt;exec &lt;/span&gt;print-envars-greeting &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="nb"&gt;env&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cannot exec into a container in a completed pod; current phase is Succeeded
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;kubectl describe pod&lt;/code&gt; is the way to see the variables afterwards. To inspect the live environment, the pod has to still be running.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the docs recommend
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Real configuration should not be hardcoded in the pod spec. A ConfigMap holds non-confidential values, a Secret holds confidential ones, and &lt;code&gt;env&lt;/code&gt; points at them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;DATABASE_HOST&lt;/span&gt;
  &lt;span class="na"&gt;valueFrom&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;configMapKeyRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app-config&lt;/span&gt;
      &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;db_host&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;DATABASE_PASSWORD&lt;/span&gt;
  &lt;span class="na"&gt;valueFrom&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;secretKeyRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;db-secret&lt;/span&gt;
      &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;password&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Last, &lt;code&gt;image: bash&lt;/code&gt; has no tag, and "If you don't specify a tag, Kubernetes assumes you mean the tag &lt;code&gt;latest&lt;/code&gt;." The same page advises against &lt;code&gt;:latest&lt;/code&gt; in production, because it is harder to track which version is running and harder to roll back.&lt;/p&gt;

&lt;h2&gt;
  
  
  Task 2: Allocate a public IP
&lt;/h2&gt;

&lt;p&gt;The task: one public IP address named &lt;code&gt;nautilus-pip&lt;/code&gt;. Nothing else was specified.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Find the resource group
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;RG&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;az group list &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s2"&gt;"[0].name"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; tsv&lt;span class="si"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The lab provides one resource group with a generated name, so I read it from the API instead of pasting it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Create the address
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az network public-ip create &lt;span class="nt"&gt;-g&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$RG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; nautilus-pip
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One thing to know before querying the response: &lt;code&gt;create&lt;/code&gt; wraps its result in an object called &lt;code&gt;publicIp&lt;/code&gt;, while &lt;code&gt;show&lt;/code&gt; returns the same resource flat. A query written for one returns &lt;code&gt;null&lt;/code&gt; for every field on the other.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Check what was created
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az network public-ip show &lt;span class="nt"&gt;-g&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$RG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; nautilus-pip &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s2"&gt;"{IP:ipAddress,Sku:sku.name,State:provisioningState}"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The address was &lt;code&gt;20.84.50.139&lt;/code&gt;, the SKU was Standard, and the provisioning state was &lt;code&gt;Succeeded&lt;/code&gt;. The create response had also shown the allocation method as Static. I had asked for none of it except the name.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the defaults chose for me
&lt;/h3&gt;

&lt;p&gt;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."&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;It has a zone setting I did not choose. The CLI printed a warning on create:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[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.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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".&lt;/p&gt;

&lt;h3&gt;
  
  
  What the docs recommend
&lt;/h3&gt;

&lt;p&gt;Pass what matters instead of inheriting it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az network public-ip create &lt;span class="nt"&gt;-g&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$RG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; nautilus-pip &lt;span class="nt"&gt;--sku&lt;/span&gt; Standard &lt;span class="nt"&gt;--zone&lt;/span&gt; 1 2 3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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".&lt;/p&gt;

&lt;p&gt;Find the addresses nothing is using:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az network public-ip list &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s2"&gt;"[?ipConfiguration==null].{Name:name,IP:ipAddress,RG:resourceGroup}"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; table
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I learned
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;So here is the Day 57 question. Which default in your last deployment would surprise you if it changed tomorrow?&lt;/p&gt;

&lt;p&gt;Day 57 down. Forty-three to go.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>kubernetes</category>
      <category>azure</category>
      <category>containers</category>
    </item>
  </channel>
</rss>
