Deep Dive Post-Mortem

Technical Issues Resolved

An in-depth look at the engineering challenges faced while deploying the microservices architecture on Oracle Cloud (ARM64). Discover the root causes, the exact error logs, and the terminal commands used to fix them.

πŸ•ΈοΈ

CI/CD Runner Network Isolation (Gitea Actions)

β–Ό

Symptom

CI job containers (via act-runner) failed to git clone or push Docker images, throwing what looked like an authentication error against the internal Gitea service.

remote: Invalid username or password.
fatal: Authentication failed for 'http://git.khalilaliouich.com/...'
curl: (7) Failed to connect to gitea-http.gitea.svc.cluster.local port 3000

Discarded Hypotheses

The error read Invalid username or password, so we first regenerated the Gitea token β€” no change. Then suspected DNS resolution of .svc.cluster.local and patched /etc/hosts in the runner β€” the timeouts softened but never fully cleared, which was the tell that DNS wasn't the real story.

Root Cause

The standard Docker bridge network created by act-runner suffered from MTU fragmentation and NAT translation issues when communicating with K3s Pod IPs and Services on this specific Oracle Cloud virtualized network β€” the auth-shaped error was just how the connection drop surfaced.

Fix

We configured the Gitea act-runner to force all CI job containers onto the host's network namespace, letting ephemeral CI containers resolve .svc.cluster.local domains directly, without NAT overhead.

# /data/gitea-runner/config.yaml
container:
  network: "host"
  options: "--add-host=gitea-http.gitea.svc.cluster.local:10.43.0.10"

Lesson

An authentication-shaped error message doesn't mean an authentication problem. When the same credentials work fine outside the automated environment, check the transport layer β€” MTU, routing, NAT β€” before touching tokens again.

⚑

ArgoCD gRPC Interference with Linkerd

β–Ό

Symptom

ArgoCD became completely inaccessible, returning 502 Bad Gateway. The argocd-server logs were filled with TLS handshake failures.

rpc error: code = Unavailable desc = connection error: desc = "transport: authentication handshake failed"

Discarded Hypotheses

A 502 usually means a routing problem, so we first re-checked the Gateway/HTTPRoute config and the certificate β€” both correct. Then suspected argocd-server itself was crash-looping β€” but every ArgoCD pod was Running and Ready.

Root Cause

Installing the Linkerd service mesh had globally auto-injected sidecars into the ArgoCD namespace. Linkerd aggressively intercepts gRPC traffic, and ArgoCD relies heavily on internal gRPC between its server, repo-server, and application-controller β€” the proxy was breaking their TLS handshakes.

Fix

We disabled Linkerd proxy injection specifically for the ArgoCD namespace and recreated the pods to restore internal communication.

kubectl annotate namespace argocd linkerd.io/inject=disabled --overwrite
kubectl delete pods --all -n argocd

Lesson

Sidecar injection is a namespace-level decision to make before installing anything into that namespace, not a bug to fix afterward. The Ansible role now annotates argocd with linkerd.io/inject=disabled before ArgoCD is ever installed, so this can't recur by ordering alone.

πŸ”

GitOps Manifest Push Authentication

β–Ό

Symptom

Right after a successful image build, the pipeline failed at the manifest-update step with Invalid username or password. fatal: Authentication failed.

Discarded Hypotheses

We first assumed the token had expired and regenerated it β€” same failure. Then suspected a branch-protection rule blocking the push β€” none was configured on that repository.

Root Cause

The default token injected by Gitea Actions was insufficient for pushing back to the repository from within that specific job's context over HTTPS β€” a scope problem, not an expiry or policy one.

Fix

We modified .gitea/workflows/deploy.yaml to inject a dedicated access token directly into the remote URL before executing the push.

# Inside the CI/CD Pipeline step:
git remote set-url origin "http://oauth2:${{ secrets.GITEA_TOKEN }}@git.khalilaliouich.com/khalil/tamagotchi-service.git"
git push origin HEAD:main

Lesson

A token being present and valid isn't the same as a token being scoped for the operation you're about to run. Check what the credential is actually allowed to do, not just whether it exists.

🐳

ImagePullPolicy Stale Caching

β–Ό

Symptom

ArgoCD reported the new manifest as Synced, but the running pods kept serving an old image β€” no error, just stale behavior.

Discarded Hypotheses

We first assumed ArgoCD hadn't actually synced and re-triggered it manually β€” no change. Then diffed the image tag in the manifest against what was built β€” they matched exactly.

Root Cause

The registry was configured as the internal service gitea-http.gitea.svc.cluster.local:3000, which K3s's containerd resolved and authenticated inconsistently. With imagePullPolicy: IfNotPresent, once any pull had ever succeeded, later pulls silently kept the cached layer instead of failing loudly.

Fix

We switched the registry target to the external, reliably resolvable domain and set imagePullPolicy: Always to force strict layer validation on every deploy.

# k8s.yaml
spec:
  containers:
    - name: api
      image: git.khalilaliouich.com/khalil/tamagotchi-api:v2
      imagePullPolicy: Always

Lesson

IfNotPresent is an availability optimization, not a correctness guarantee. For anything still iterating fast, the extra pull time Always costs is cheap insurance against an entire class of "it deployed but didn't actually update" incidents.

πŸ”‘

ArgoCD RBAC "Guest" Credentials

β–Ό

Symptom

The showcase site advertised guest / a demo password for ArgoCD, but every login attempt was rejected.

Discarded Hypotheses

We first suspected the argocd-rbac-cm policy mapping itself β€” read it line by line, syntax was correct. Then checked whether the guest account was even enabled β€” it was.

Root Cause

RBAC and the account definition were both fine; the bcrypt password hash for accounts.guest.password in the separate argocd-secret object was missing.

Fix

We generated a bcrypt hash manually via Python, base64-encoded it, and patched argocd-secret directly.

BCRYPT_HASH=$(python3 -c "import bcrypt; print(bcrypt.hashpw(b'<YOUR_GUEST_PASSWORD>', bcrypt.gensalt()).decode())")
BASE64_HASH=$(echo -n "$BCRYPT_HASH" | base64 -w 0)

kubectl patch secret argocd-secret -n argocd -p '{"data": {"accounts.guest.password": "'$BASE64_HASH'"}}'

Lesson

ArgoCD splits "is this account allowed to exist and what can it do" (the RBAC ConfigMap) from "can it prove who it is" (the Secret). A login failure is ambiguous between the two until both are checked β€” RBAC looking correct doesn't clear the second one.

πŸš€

Node.js vs Nginx Port Bindings

β–Ό

Symptom

A routine CSS update brought down the entire site with 502 Bad Gateway, including the live cluster metrics normally embedded on the homepage.

Discarded Hypotheses

Since the change that triggered it was CSS, we first suspected the content itself β€” but a 502 happens before any HTML is served, which ruled that out as soon as we actually looked at where the error originated.

Root Cause

The build had picked up an older Nginx-based Dockerfile (listening on port 80) instead of the Node.js one, while the Kubernetes Service still targeted port 3000 β€” a silent mismatch between what got built and what the Service expected.

Fix

We restored the Node.js server.js proxy architecture, rebuilt via nerdctl, and rolled out the corrected image.

sudo nerdctl build -t showcase-website:latest .
kubectl set image deployment/showcase-website website=showcase-website:latest -n showcase
kubectl rollout restart deployment/showcase-website -n showcase

Lesson

The real fix wasn't restoring the right file once β€” it was removing the alternate Dockerfile so it can't be picked up by accident again. website/nginx.conf still exists in this repo as a reference, but nothing in the build path touches it.

πŸ’Ύ

Grafana Persistence & Legacy Ingress Cleanup

β–Ό

Symptom

Dashboards, users, and settings created by hand in the Grafana UI vanished on every pod restart. Separately, stray Ingress objects from Helm chart defaults were still routing traffic outside the intended Gateway API path.

Discarded Hypotheses

We first assumed a Helm values change had been silently reverted β€” reviewed release history, values were consistent across upgrades. Then suspected the dashboards-as-code sidecar (the same grafana_dashboard: "1" ConfigMap pattern used for Tamagotchi and Loki) was overwriting manually-created dashboards β€” but the provisioned ones always survived restarts fine; only the hand-made ones vanished, which pointed at storage, not provisioning.

Root Cause

Grafana's chart defaults to an ephemeral emptyDir volume: every pod restart started from a blank data.db, wiping anything not defined as versioned config.

Fix

We deployed a dedicated hostPath PersistentVolume and PVC for Grafana and pointed the Helm release at it (helm upgrade --reuse-values), then separately deleted the ghost Ingress objects to fully hand routing to HTTPRoute.

Lesson

Dashboards-as-code never needed this fix at all β€” it doesn't depend on Grafana's own storage. This was the first of what turned out to be a recurring pattern on this cluster: a stateful workload quietly running on ephemeral storage. The Valkey/Gitea outage of 2026-08-08 was the same bug in a different pod β€” see the runbook.

🌐

UI Translation Race Condition (i18n Bug)

β–Ό

Symptom

Switching language on the site sometimes rendered raw keys like issues_title instead of actual text.

Discarded Hypotheses

The name we gave the bug shaped the first hypothesis: a load-order race between setLang() and the dictionaries being defined. We added a DOMContentLoaded guard β€” the affected strings still broke. Then suspected stale cached JS β€” hard-refreshed, same result.

Root Cause

There was no timing bug at all. The keys were simply never defined in one or both dictionaries for that section of the page, so setLang() had nothing to find and fell back to printing the raw key.

Fix

We added the missing keys to both the en and fr dictionaries in app.js.

Lesson

The incident's own name was the first, misleading hypothesis, and it stuck as the title even after the real cause turned out to be simpler than a race. Worth naming a postmortem after the root cause is known, not before.

πŸ“¦

K3s ErrImageNeverPull & Local Containerd Sockets

β–Ό

Symptom

After rebuilding the website image locally to ship a frontend change, the deployment failed with ErrImageNeverPull β€” as if the image had never been built at all.

Discarded Hypotheses

We first assumed the build itself had silently failed β€” reran it, watched it complete, confirmed the tag existed locally. Then diffed the build tag against the Deployment's image: field β€” they matched exactly.

Root Cause

nerdctl build had run against the default containerd socket and namespace. K3s runs its own isolated containerd, at /run/k3s/containerd/containerd.sock in the k8s.io namespace β€” the kubelet simply couldn't see an image built anywhere else.

Fix

We rebuilt the image directly into K3s's containerd, using the isolated socket and namespace explicitly.

sudo nerdctl --address /run/k3s/containerd/containerd.sock \
  --namespace k8s.io build -t showcase-website:v39 .

Lesson

"The image doesn't exist" and "the image exists somewhere the kubelet can't see" produce the exact same error. Worth checking which containerd socket a build actually landed in before assuming the build is broken β€” this is the exact command now used for every website rebuild on this cluster.

πŸ”

Git Commit Hanging Indefinitely (GPG Signing)

β–Ό

Symptom

Automated git commit calls from a headless terminal session hung indefinitely β€” no error, no visible prompt, until the surrounding task eventually timed out.

Discarded Hypotheses

We first suspected a network hang β€” a pre-commit hook reaching out somewhere. There were no hooks installed. Then checked whether the process was deadlocked or spinning β€” it was idle, just blocked waiting on input.

Root Cause

commit.gpgsign=true was set globally. Git was waiting on a GPG passphrase prompt that had nowhere to render in that headless context.

Fix

Documented as --no-gpg-sign per commit, to unblock the immediate incident.

git commit --no-gpg-sign -m "fix: ..."

Lesson

This is a workaround, not a fix, and worth naming as such: bypassing signing per-command trades a hang for a silent gap in provenance. The durable version of this fix is a signing setup built for non-interactive contexts β€” an agent-cached key or SSH-based commit signing β€” or an explicit decision that automated commits on this repo are unsigned, made once, not re-decided on every hang.