DEV Community

Cover image for Day 67: A Guest Book and a Public Container
Nnamdi Felix Ibe
Nnamdi Felix Ibe

Posted on AI-assisted

Day 67: A Guest Book and a Public Container

Day 67 of DevOps, Day 17 of Azure. Both tasks today turned on exactly which word sat in one field. In Kubernetes, redis-master is not a label I chose, it is a hostname compiled into the application. In Azure, the difference between blob and container decides whether a stranger can list your files or merely read the ones they can already name.

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.

Task 1: A Guest Book in three tiers

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

Step 1: The write tier

apiVersion: apps/v1
kind: Deployment
metadata:
  labels:
    app: redis-master
  name: redis-master
spec:
  replicas: 1
  selector:
    matchLabels:
      app: redis-master
  strategy: {}
  template:
    metadata:
      labels:
        app: redis-master
    spec:
      containers:
      - image: redis:alpine
        name: master-redis-devops
        resources:
          requests:
            cpu: "100m"
            memory: "100Mi"
        ports:
        - containerPort: 6379
---
apiVersion: v1
kind: Service
metadata:
  name: redis-master
spec:
  selector:
    app: redis-master
  ports:
    - targetPort: 6379
      port: 6379
Enter fullscreen mode Exit fullscreen mode

No type on the Service, which makes it ClusterIP: "the default that is used if you don't explicitly specify a type 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.

Step 2: The read tier, two replicas

      containers:
      - image: gcr.io/google_samples/gb-redisslave:v3
        name: slave-redis-devops
        resources:
          requests:
            cpu: "100m"
            memory: "100Mi"
        ports:
        - containerPort: 6379
        env:
        - name: GET_HOSTS_FROM
          value: "dns"
Enter fullscreen mode Exit fullscreen mode

Step 3: The frontend, three replicas, on a node port

      containers:
      - image: gcr.io/google-samples/gb-frontend@sha256:a908df84...
        name: php-redis-devops
        env:
        - name: "GET_HOSTS_FROM"
          value: "dns"
        ports:
        - containerPort: 80
---
apiVersion: v1
kind: Service
metadata:
  name: frontend
spec:
  type: NodePort
  selector:
    app: frontend
  ports:
    - port: 80
      targetPort: 80
      nodePort: 30009
Enter fullscreen mode Exit fullscreen mode

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.

Step 4: Apply and check

kubectl apply -f backend-deploy.yaml
kubectl apply -f backend-svc.yaml
kubectl apply -f redis-slave-deploy.yaml
kubectl apply -f redis-slave-svc.yaml
kubectl apply -f frontend-deploy.yaml
kubectl apply -f frontend-svc.yaml

kubectl get deploy
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode

Why the Service names are not mine to choose

GET_HOSTS_FROM: dns 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:

$host = 'redis-master';
if (getenv('GET_HOSTS_FROM') == 'env') {
  $host = getenv('REDIS_MASTER_SERVICE_HOST');
}
Enter fullscreen mode Exit fullscreen mode

The hostnames are hardcoded. Setting the variable to anything other than env 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 my-svc.my-namespace.svc.cluster-domain.example", and "DNS queries that don't specify a namespace are limited to the Pod's namespace", so a bare redis-master resolves inside the pod's own namespace.

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.

The alternative branch is worth noticing too. REDIS_MASTER_SERVICE_HOST 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 dns is how you avoid it.

The 502 I cannot explain

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 ENDPOINTS column is <none>, you should check that the spec.selector field".

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.

What the docs recommend

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

Do not assume the underscore is a typo. google_samples with an underscore and google-samples 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 gcr.io/google_samples. Both images have used both spellings at different times.

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 redis-leader and redis-follower, with the prose reading "Start up a Redis leader." The master and slave naming belongs to the older branch and to this lab.

Redis made the same change, and only halfway. Its SLAVEOF 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 REPLICAOF. The current replication page still writes "master" for the other side, so replica replaced slave while primary did not replace master.

One resource note. Every container sets requests and no limits, which makes each pod Burstable. The class belongs to the pod, not the container: Burstable means the pod "does not meet the criteria for QoS class Guaranteed" 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 Guaranteed.

Task 2: A deliberately public container

The task: a storage account and a container with anonymous read access for containers and blobs. Day 16 inverted.

Step 1: Set the account flag at creation

az storage account create -g $RG -n $SA --sku Standard_LRS --kind StorageV2 \
  --allow-blob-public-access true
Enter fullscreen mode Exit fullscreen mode

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 az storage account update before retrying.

Step 2: Choose the level deliberately

KEY=$(az storage account keys list -g $RG -n $SA --query "[0].value" -o tsv)
az storage container create --name $CONTAINER --account-name $SA --account-key $KEY \
  --public-access container
Enter fullscreen mode Exit fullscreen mode

Three values, and the requirement's wording picked one:

Value Read a known blob List the container
off No No
blob Yes No
container Yes Yes

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".

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

Step 3: Prove it from outside

curl -s -o /dev/null -w "http_code=%{http_code}\n" \
  "https://$SA.blob.core.windows.net/$CONTAINER?restype=container&comp=list"
Enter fullscreen mode Exit fullscreen mode
http_code=200
Enter fullscreen mode Exit fullscreen mode

No key, no login, no SAS token. ?restype=container&comp=list 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.

The failure code I had wrong

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.

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."

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.

What the docs recommend

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.

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.

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."

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 s3:prefix, aws:SourceIp or aws:Referer. 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".

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.

What I learned

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.

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.

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?

Day 67 down. Thirty-three to go.

Top comments (1)

Collapse
 
merbayerp profile image
Mustafa ERBAY •

Another thoughtful write-up, Nnamdi! 😊👏

What I appreciate most is how openly you revisit earlier assumptions when the documentation tells a different story. That’s something we can all learn from. The Service naming example is a great reminder that configuration values can become hidden application dependencies. One small nuance I’d add: ClusterIP limits how a Service is exposed, but it doesn’t provide network isolation between pods on its own. That’s where NetworkPolicies become important. And to answer your question, I’d say DNS names, database identifiers, and environment variable names are all good examples of values that look harmless until something quietly depends on them. 😄

Really enjoying the series, my friend. Keep going! 🚀❤️