How a Swallowed Exit Code Let a Cert Renewal Job Report Success for Three Days Straight
← Back
August 26, 2026Security7 min read

How a Swallowed Exit Code Let a Cert Renewal Job Report Success for Three Days Straight

Published August 26, 20267 min read

03:14 UTC. PagerDuty: "high error rate: order-svc to fulfillment-svc, 41% of calls failing." No deploy in the last six hours. No infrastructure change flagged in the last day. Whatever broke did it on its own.


the setup

order-svc and fulfillment-svc talk to each other over gRPC, routed through Envoy sidecars inside our Istio mesh. Every call between them is mutually authenticated: each side presents a short-lived certificate signed by an internal CA, and Istio's PeerAuthentication policy rejects any connection where that handshake fails. This pair of services still runs on the original certificate pipeline from before we adopted cert-manager for the rest of the mesh, a bash script in a CronJob that issues a fresh 24-hour certificate every night and pushes it into the two services' Kubernetes secrets.

cert-ops/renew-mtls.sh (before)
#!/bin/bash
CA_CERT=/etc/pki/ca/intermediate.pem
CA_KEY=/etc/pki/ca/intermediate.key
CERT=/tmp/fulfillment-svc.crt
KEY=/tmp/fulfillment-svc.key
LOG=/var/log/cert-renew.log

openssl genrsa -out $KEY 2048 | tee -a $LOG
openssl req -new -key $KEY -subj "/CN=fulfillment-svc.mesh" -out /tmp/csr.pem | tee -a $LOG
openssl x509 -req -in /tmp/csr.pem -CA $CA_CERT -CAkey $CA_KEY \
  -CAcreateserial -out $CERT -days 1 | tee -a $LOG

kubectl create secret tls fulfillment-svc-mtls \
  --cert=$CERT --key=$KEY -n mesh --dry-run=client -o yaml | kubectl apply -f -

echo "$(date -u) cert renewed, notAfter: $(openssl x509 -enddate -noout -in $CERT)" >> $LOG

It's the kind of script that gets written once, works, and never gets looked at again. Ours had been running nightly at 20:00 UTC for over a year.


the scramble

On-call pulled up Grafana. The error rate for order-svc's calls into fulfillment-svc had gone from a steady 0.1% to 41% starting at roughly 03:11 UTC, three minutes before the page fired. First theory: a canary rollout for an unrelated service had touched shared Istio VirtualService routing rules two hours earlier. It was the only change anywhere near the mesh config in the last day, and routing changes are a familiar way to break exactly one service pair without touching anything else.

kubectl -n mesh get virtualservice fulfillment-svc -o yaml, diffed against the last known-good version in git: no drift. The rollback would have been a no-op even if applied, so nobody bothered forcing it through.

Second theory: DNS. gRPC connection failures inside a mesh often look like resolution problems before they look like anything else. kubectl exec -it order-svc-7d9f4 -- nslookup fulfillment-svc.mesh.svc.cluster.local resolved cleanly, sub-millisecond, correct cluster IP. CoreDNS wasn't the problem either.

Twenty minutes in, with two plausible theories ruled out and the error rate holding steady at 41%, not climbing and not recovering, someone finally looked at what the actual failing connections said instead of guessing from the outside.


the hunt

Exec'd into an order-svc pod's Envoy sidecar and ran the handshake directly against fulfillment-svc's mesh address.

from inside order-svc's Envoy sidecar
$ openssl s_client -connect fulfillment-svc.mesh.svc.cluster.local:15006 -showcerts 2>&1 | head -20

CONNECTED(00000003)
...
verify error:num=10:certificate has expired
40802000:error:0A000086:SSL routines:tls_process_server_certificate:certificate verify failed

Expired, not misconfigured, not revoked. Pulling the secret directly and checking its notAfter confirmed it.

checking the live secret
$ kubectl -n mesh get secret fulfillment-svc-mtls -o jsonpath='{.data.tls\.crt}' \
  | base64 -d | openssl x509 -noout -enddate

notAfter=Aug23 23:58:11 2026 GMT

That certificate had expired at 23:58 UTC the previous night, over three hours before anything actually broke. If it died at 23:58, why did nothing fail until 03:11? gRPC connections between order-svc and fulfillment-svc are long-lived and pooled; the mTLS handshake only happens when a connection is first established, not on every request. The existing pool of open connections kept working straight through the expiry, since nothing forced a fresh handshake. What forced it was a routine autoscaling event around 03:10 that cycled several fulfillment-svc pods, which meant order-svc's connection pool had to open fresh connections to the new pods, and every one of those tried to handshake against a certificate that had already been dead for hours.

That explained the timing. It didn't explain why the nightly renewal hadn't caught it. The CronJob's own logs said otherwise.

cert-renew.log, last three nights
2026-08-21 20:00:04 UTC cert renewed, notAfter: Aug22 20:00:03 2026 GMT
2026-08-22 20:00:03 UTC cert renewed, notAfter: Aug22 20:00:03 2026 GMT
2026-08-23 20:00:04 UTC cert renewed, notAfter: Aug22 20:00:03 2026 GMT

Three consecutive nights, three log lines claiming success, and the exact same notAfter timestamp reported all three times. No new certificate had actually been generated since August 21st. The script had been failing silently for three nights and reporting success every time.


the find

Four days earlier, an unrelated project to consolidate three internal certificate authorities into a single directory structure had moved /etc/pki/ca/intermediate.pem to a new path. Every other service using cert-manager's native CA integration picked up the change automatically. This one script, still pointing at the old hardcoded path, did not.

When the script ran that first night after the move, its openssl x509 -req ... -CA $CA_CERT step failed with unable to load certificate: No such file or directory. That should have stopped the script cold. It didn't, because of how the pipeline was written.

the actual bug
openssl x509 -req -in /tmp/csr.pem -CA $CA_CERT -CAkey $CA_KEY \
  -CAcreateserial -out $CERT -days 1 | tee -a $LOG

$? after a pipeline reflects the exit status of the last command in it, tee, not openssl. tee almost never fails. So this line always returned 0, no matter what openssl did upstream, and the script had no set -o pipefail to change that. Because the failing openssl command never overwrote $CERT, the file on disk was still the certificate generated three nights earlier. kubectl apply then re-applied that same, aging certificate into the secret, unchanged, and the script logged a clean success on every one of those three runs.

Root cause: an exit code that mattered got thrown away by a pipe, so a broken renewal job kept reapplying the same expiring certificate for three nights while reporting that it was working. Nothing paged on any of those nights because nothing about a "successful" cron run looked wrong from the outside. It looked exactly like a healthy one.


the fix

Immediate mitigation, to get order-svc and fulfillment-svc talking again without waiting for a proper certificate: flip Istio's PeerAuthentication for that namespace to PERMISSIVE, which accepts both mTLS and plaintext, while a fresh certificate was generated by hand and pushed into the secret manually.

temporary mitigation
$ kubectl -n mesh patch peerauthentication default \
  --type merge -p '{"spec":{"mtls":{"mode":"PERMISSIVE"}}}'

Real traffic recovered within two minutes of that patch landing. Fixing the actual pipeline took longer, and had three parts. First, the script fails loudly now instead of quietly:

cert-ops/renew-mtls.sh (after)
#!/bin/bash
set -euo pipefail

CA_CERT=/etc/pki/ca/current/intermediate.pem
CA_KEY=/etc/pki/ca/current/intermediate.key
CERT=/tmp/fulfillment-svc.crt
KEY=/tmp/fulfillment-svc.key
LOG=/var/log/cert-renew.log

prev_not_after=$(openssl x509 -enddate -noout -in "$CERT" 2>/dev/null | cut -d= -f2 || echo "none")

openssl genrsa -out "$KEY" 2048
openssl req -new -key "$KEY" -subj "/CN=fulfillment-svc.mesh" -out /tmp/csr.pem
openssl x509 -req -in /tmp/csr.pem -CA "$CA_CERT" -CAkey "$CA_KEY" \
  -CAcreateserial -out "$CERT" -days 1

new_not_after=$(openssl x509 -enddate -noout -in "$CERT" | cut -d= -f2)
if [ "$new_not_after" = "$prev_not_after" ]; then
  echo "$(date -u) FATAL: renewed cert has same notAfter as previous, refusing to apply" >> "$LOG"
  exit 1
fi

kubectl create secret tls fulfillment-svc-mtls \
  --cert="$CERT" --key="$KEY" -n mesh --dry-run=client -o yaml | kubectl apply -f -
echo "$(date -u) cert renewed, notAfter: $new_not_after" >> "$LOG"

set -euo pipefail means any failing command anywhere in the script now stops it and returns a non-zero exit code, and the CronJob's own failure gets surfaced through Kubernetes as a failed job instead of a quiet success. The explicit notAfter comparison is the second line of defense: even if a future failure somehow still exits clean, reapplying an unchanged certificate now fails the run on purpose.

Second, every CronJob script in this repo now runs through shellcheck in CI, with SC2086 and pipefail-related warnings treated as errors rather than suggestions.

Third, a synthetic probe now runs every five minutes from a canary pod, opening a fresh mTLS connection to each service pair in the mesh and pushing the resulting certificate's remaining lifetime as a metric. An alert fires at 48 hours before expiry, well before any renewal job's own self-reported success could hide a real failure again.


the aftermath

3 nights Renewal job reported success while doing nothing
48 min From page to mitigation
9,700 Fulfillment reservation calls failed and retried
2,100 Orders with delayed shipment creation

No orders were lost and no customer was charged incorrectly. order-svc's own retry queue held every failed fulfillment reservation and replayed them once mTLS came back, though 2,100 orders had their shipment record created a median of 41 minutes late, and a handful of same-day delivery orders missed their cutoff window entirely.

  • A pipeline like command | tee log.txt hides the exit code of everything upstream of tee unless pipefail is set. This is one of the most common ways a shell script lies about whether it succeeded, and it stays invisible until the exact night it matters.
  • A cron job's own "success" log line is not evidence that it did anything. Ours proved that by reporting the same result three nights running. The only trustworthy check on a renewal job is comparing what it actually produced against what existed before it ran.
  • Long-lived connection pools can hide a broken dependency for hours after the dependency actually breaks. The gap between when our certificate expired and when anything paged was entirely explained by connections that hadn't needed to re-handshake yet.
  • Certificate expiry monitoring belongs outside the renewal process that's supposed to prevent it. A renewal job checking its own work isn't a second opinion, it's the same opinion twice.

The old script still exists in git history as a reminder. Every CronJob touching a certificate now gets its exit code checked by something other than itself.

Share this
← All Posts7 min read