Skip to main content
RunBook Academy

← All break/fix scenarios in Secrets, PKI & Certificates

intermediatepki-trust-store~30 min

A base image change removed the private root and the worker fleet stopped trusting anything

Reported symptoms

  • From 14:10 UTC the ledger-sync workers begin failing every outbound call to the internal platform service, and the failure count rises in step with the rolling update rather than all at once
  • Workers still running the previous image tag continue to succeed, at full rate, calling the same endpoint over the same network path
  • The platform service reports no change in error rate, because the failing workers never complete a handshake and never send a request
  • An engineer on the same node reaches the endpoint successfully from a shell outside the container, which is read as proof that the network and the service are both fine
  • The failure message names a certificate problem and an issuer that cannot be found, and it is identical to a chain incident the team resolved a week earlier
  • The team applies the fix from that earlier incident, rebuilding the server certificate bundle, and nothing improves for any worker
  • Rolling back the workload to the previous image tag restores every worker within two minutes, which points the investigation at the application code rather than at the image contents

Evidence

  • · curl from inside a failing worker container reports error 60 with the message SSL certificate problem unable to get local issuer certificate
  • · curl to the same URL from a shell on the node, outside the container, returns HTTP 200 and the diagnostic client reports a verify return code of 0 and the word ok
  • · openssl s_client from inside the failing container lists two certificates in the presented chain, the leaf at index 0 issued by the issuing CA and the issuing CA at index 1 issued by the root, so the server is sending a complete path
  • · The private anchor directory inside the container is empty and the generated bundle contains only public certificate authorities, while the same directory on the node holds the private root
  • · openssl verify against the served leaf, with the private root supplied as the trust anchor and the issuing CA supplied as an untrusted intermediate, prints OK
  • · openssl verify against the same leaf with the issuing CA supplied as the trust anchor prints error 2 at 1 depth lookup unable to get issuer certificate, so promoting the intermediate is not a substitute for the root
  • · The set of failing workers matches the set running the new image tag exactly, with no failures on the previous tag and no successes on the new one
  • · The image build changed its base image in a size-reduction merge request the previous afternoon, and the internal base image it replaced is where the trust anchor layer was applied
Diagnosis and resolutionclick to reveal

Root cause

The workers no longer hold the private root, so they cannot terminate the certification path the platform service presents. The server is sending everything it should: the leaf and the issuing CA that signed it, in the correct order. The client builds that path, arrives at the issuing CA, looks for a trust anchor that signed it, and finds a store containing public certificate authorities and nothing else. It reports that it cannot find a local issuer and refuses the connection, which is correct behaviour and the only safe behaviour available to it. The first defect is the base image change. The image previously derived from an internal base that applied the private root into the anchor directory and regenerated the bundle as part of its build. A merge request aimed at reducing image size replaced that base with an upstream minimal image, and the anchor layer went with it. Nothing in the build failed, because a missing trust anchor is not a build error, and the resulting image is smaller and starts perfectly. The second defect is that the anchor was distributed exclusively as a side effect of a base image, with no assertion anywhere that it was present. The continuous integration suite exercises the worker against a mock endpoint over plain HTTP, so it has never validated a certificate at all, and no startup check confirms that the anchor the workload depends on exists. The first defect caused the outage. The second is why a one-line change to a FROM directive could reach production, and it is the one that will otherwise recur with a different base image next year.

Remediation

Establish which side of the path is broken first, because a missing anchor and a missing intermediate produce the same client message and the same error code and are repaired in opposite places. Read the chain the server presents from inside a failing container. Two entries means the server is sending a complete path and the fault is in the client trust store. One entry means the server is at fault and no client change will help. Confirm it by verifying the served leaf with the private root supplied by hand, since a leaf that verifies once the anchor is provided has named what is missing. Then restore the anchor properly. Place the private root into the anchor source directory inside the image and run the trust store update tool as part of the build, so that the generated bundle is regenerated from source rather than edited. Rebuild, roll the new image to one worker first, prove it, and only then continue. Keep the previous tag deployable throughout, since it is a known good state. Do not append the anchor to the generated bundle file directly, because that file is rewritten by the update tool and the change disappears at the next rebuild. Do not disable verification in the worker, whether by an insecure client flag, an environment variable that turns off certificate rejection, or a library setting that skips verification. Each of those removes the only check that distinguishes the real platform service from anything else that can occupy that address, and it removes it for every call the worker makes, not only the one that is failing. Do not promote the issuing CA to an anchor either: the evidence shows it does not verify, and if it did it would make an online key a trust root.

Verification

Verify from inside a freshly started container of the rebuilt image rather than from the node, because the node was never affected and answering from it is what cost the team the first twenty minutes. Request the platform endpoint with verification enabled and confirm HTTP 200 with the expected body, then confirm the diagnostic client reports a verify return code of zero on the same connection. Confirm the anchor is present and is the right one, by taking the SHA-256 digest of the root certificate inside the image and comparing it with the digest the certificate authority team publishes, which distinguishes a correct anchor from any other certificate that happens to be sitting in the directory. Confirm the generated bundle was rebuilt from source, so that the anchor survives the next image build rather than existing only in this one. Then confirm the workload itself: the ledger-sync error rate must return to zero across a full sync cycle, not merely for a single probe, and the platform service must record requests arriving from the new pods. Finally, roll one worker forward and leave one on the previous tag for a full cycle so that both are observed succeeding, which proves the repair rather than the rollback is what is holding.

Prevention

Distribute the anchor as a declared dependency, not as an inherited side effect. A build that must have the private root should install it explicitly in its own Dockerfile or mount it from a managed source at runtime, so that changing a base image cannot silently remove it. Assert the anchor at container start and fail fast: a startup check that the expected root is present in the trust store, and that its digest matches the published value, turns this incident into a container that refuses to start with a clear message instead of a fleet that fails silently under load. Test against real TLS in continuous integration. A suite that talks to a mock over plain HTTP cannot detect any trust problem, so point at least one integration test at an endpoint presenting the real private chain and require it to verify. Pin base images by digest and require an explicit review when a FROM directive changes, because the entire contents of an image change with it and the review that approved this one was reading a diff about size. Alert on client-side verification failures with a threshold of zero over five minutes, since a worker that cannot verify never sends a request and therefore contributes nothing to the server error rate that most alerting watches. Finally, audit trust stores across images and hosts each quarter and reconcile them against the approved anchor list, reporting both anchors that are missing and anchors that nobody authorised.

Reported symptoms

The ledger-sync workers are a fleet of forty containers that call app.lab.example, an internal platform service on the private two-tier PKI. The workers were rebuilt yesterday afternoon under a merge request whose entire stated purpose was to reduce image size, and the new tag begins rolling out at 14:08 UTC on 2 September.

Two minutes later the fleet starts failing, and the shape of the failure is what confuses everybody:

  • Failures climb steadily rather than arriving all at once, tracking the rolling update pod by pod. By 14:26 every replaced worker is failing continuously and every remaining one is fine.
  • The platform service reports no change at all. Its error rate is flat, because a worker that cannot verify a certificate never sends a request for the service to fail.
  • 14:19, an engineer opens a shell on the node and calls the same endpoint. It returns 200 immediately, and the network, DNS and the service are all struck off the list.
  • The client error names a certificate problem and an issuer that cannot be found. It reads almost word for word like the broken chain incident this same team resolved eight days ago.
  • 14:31, acting on that resemblance, the platform team rebuilds the server certificate bundle and reloads. Not one worker improves.
  • 14:44, the workload is rolled back to the previous image tag and the entire fleet recovers inside two minutes.

The rollback is what sends the investigation sideways. A change that is fixed by reverting the application image looks like an application regression, so the next forty minutes are spent reading a diff that contains no application code at all, only a smaller base image and a shorter list of packages.

Evidence provided

Read-only / Safeinside a ledger-sync container running the new image tag
$ curl -sS https://app.lab.example/status
curl: (60) SSL certificate problem: unable to get local issuer certificate

Illustrative output

Read-only / Safea shell on the same node, outside the container, same URL and same second
$ curl -sS -o /dev/null -w 'http_code %{http_code}' https://app.lab.example/status; echo
http_code 200

Illustrative output

Read-only / Saferun from inside the failing container, showing what the server actually sent
$ openssl s_client -connect app.lab.example:443 -servername app.lab.example -showcerts </dev/null 2>/dev/null | grep -E 'Certificate chain|^ [0-9]+ s:|^   i:'
Certificate chain
0 s:CN=app.lab.example
 i:O=RunBook Academy Lab, CN=RunBook Lab Server Issuing CA
1 s:O=RunBook Academy Lab, CN=RunBook Lab Server Issuing CA
 i:O=RunBook Academy Lab, CN=RunBook Lab Root CA

Illustrative output

Read-only / Safethe client side of the path, inspected
$ ls -A /usr/local/share/ca-certificates/; grep -c 'BEGIN CERTIFICATE' /etc/ssl/certs/ca-certificates.crt
Read-only / Safethe served leaf, with the private root fetched by hand during the incident
$ openssl verify -CAfile root.crt -untrusted srv-ca.crt app.crt
app.crt: OK

Illustrative output

Read-only / Safethe same leaf, anchored on the issuing CA instead of the root, and note the depth
$ openssl verify -CAfile srv-ca.crt app.crt
error 2 at 1 depth lookup: unable to get issuer certificate

Illustrative output

Work the evidence before reading on

This incident looks exactly like the one the team fixed eight days ago, produces a client message that is nearly the same sentence, and has its cause at the opposite end of the connection. Separating them is the skill worth acquiring here.

  1. The chain listing taken from inside the failing container has two entries. In the incident eight days ago it had one. What does the count tell you about which end of the connection to work on?
  2. Supplying the private root by hand makes the served leaf verify. What does that single result rule out, and what does it leave?
  3. Anchoring on the issuing CA produces an error at depth 1 rather than depth 0. Walk the path and say what the client was looking for when it gave up, and why one level up is the important detail.
  4. The rollback worked. Explain why a successful rollback is consistent with the image contents being the cause, and why it was read as evidence for an application regression instead.

Before continuing: say what the count of certificates in the presented chain proves, and state the one command that tells a missing anchor apart from a missing intermediate.

Root cause

The server was complete and the client had nowhere to finish

Path validation ends at a trust anchor or it fails. The worker receives the leaf, receives the issuing CA that signed it, links them correctly, and then needs a certificate it already trusts that signed the issuing CA. Its store holds the public certificate authorities that came with the base image and nothing else, so the search ends with no anchor and the connection is refused.

The chain listing is the evidence that settles which end is at fault. Two entries means the server sent a complete path. The complaint about a local issuer is therefore about the last hop, from the issuing CA to the root, and that hop lives in the client trust store. Supplying the root by hand and watching the same leaf verify confirms it from the other direction.

The anchor arrived by inheritance and left the same way

The old image derived from an internal base that copied the private root into the anchor source directory and regenerated the bundle. The merge request replaced that base with an upstream minimal image. The build succeeded, the image shrank as promised, and the trust anchor was simply not there any more.

Nothing could have objected. A missing anchor is not a build error, it is an absence, and absences do not raise exceptions. The image starts correctly, passes its health check, and only fails at the moment it first tries to verify a certificate issued by the private CA, which is after it has been declared ready and put into service.

Nothing asserted that the anchor was present

The workers depend on a trust anchor that no test, no manifest and no startup check ever mentions. The integration suite runs against a mock over plain HTTP, so it verifies no certificates at all and cannot detect a trust store problem by construction.

That is the defect worth fixing. A dependency that exists only as a side effect of a base image is invisible to review, invisible to testing, and removable by a change whose diff mentions nothing but package counts.

Resolution

  1. Count the certificates the server presents, from inside a failing container. Two entries against a two-tier PKI means the server is complete and the fault is on the client. One entry would mean the opposite, and this single number decides which team owns the next hour.
  2. Confirm the reading by verifying the served leaf with the private root supplied by hand. A leaf that verifies as soon as the anchor is provided has named the missing component precisely.
  3. Install the anchor explicitly in the worker Dockerfile rather than relying on a base image to provide it. Copy the root into the anchor source directory and run the trust store update tool in the same build stage, so that the bundle is regenerated from source.
  4. Do not append the root to the generated bundle file. That file is rewritten by the update tool, so the change survives until something regenerates it and then disappears without leaving a diff behind.
  5. Rebuild and roll the image to a single worker first. Prove that one before continuing, because a rollout that fails on all forty replicas is not more informative than one that fails on one, only more expensive.
  6. Keep the previous image tag deployable throughout. It is a proven good state, and until the rebuilt image is verified it remains the fastest route back to a working fleet.
  7. Add a startup assertion in the same change, so the container refuses to start when the expected anchor is absent or its digest does not match. Fixing the image without adding the assertion leaves the next base image change free to repeat this exactly.
  8. Do not reach for an insecure client flag, an environment variable that disables certificate rejection, or a per-request verification switch. Each disables verification process-wide, for every dependency the worker talks to, and none of them is limited to the call that is failing.
  9. Do not promote the issuing CA to a trust anchor. The evidence shows it does not verify, and if it did it would make a continuously online signing key into a root of trust that no rotation could contain.

Verification

  1. From a freshly started container of the rebuilt image, not from the node, request the platform endpoint with verification enabled and confirm HTTP 200 with the expected body.
  2. On that same connection, confirm the diagnostic client reports a verify return code of 0 and the word ok, which is the observation that was failing inside every worker.
  3. Confirm the anchor present in the image is the correct one by taking the SHA-256 digest of the root certificate and comparing it against the value the certificate authority team publishes. A certificate in the right directory is not the same as the right certificate.
  4. Confirm the generated bundle was rebuilt from the anchor source directory during the build, so the anchor survives the next image rebuild instead of existing only in this one.
  5. Watch the ledger-sync error rate across a full sync cycle rather than a single probe, and require it to be zero for the whole cycle.
  6. Confirm the platform service is now recording requests arriving from the new pods, which is the independent observation that traffic is flowing rather than merely that a handshake completed.
  7. Run one worker on the rebuilt image and one on the previous tag through a full cycle and confirm both succeed, so that the repair rather than the rollback is demonstrably what is holding.
  8. Confirm the startup assertion actually fails: build a throwaway image without the anchor and require the container to refuse to start. An assertion that has never fired has not been tested.

Prevention

  • Declare the anchor, never inherit it. A build that depends on the private root should install it in its own Dockerfile or mount it from a managed source at runtime. A dependency carried silently by a base image is one FROM directive away from disappearing.
  • Assert the anchor at container start and fail fast. Checking that the expected root is present and that its digest matches turns a fleet failing under load into a container that refuses to start with a message naming the problem.
  • Put real TLS into at least one integration test. A suite that talks to a mock over plain HTTP cannot detect any trust fault. One test that verifies against the real private chain would have failed in the merge request that caused this.
  • Pin base images by digest and review FROM changes explicitly. The entire contents of an image change when its base does, and the review that approved this one was reading a diff about size.
  • Alert on client-side verification failures with a threshold of zero over five minutes. A client that cannot verify never sends a request, so it contributes nothing to the server error rate that most alerting is built on. The signal exists only at the caller.
  • Reconcile trust stores against an approved anchor list each quarter, across images as well as hosts. Report both directions: anchors that should be present and are missing, and anchors that nobody ever authorised.