docker ps says health: starting. It said that ninety seconds ago too. The service is a large Spring application, those take a while to boot, so you wait.
You will wait forever, because the container is not booting. It is crashing and restarting, and each restart resets the health check to starting. The status column shows you a fresh boot every time, which is exactly what a slow boot looks like.
This post is how to tell the two apart in one command, the specific class of failure that produces the most convincing version of it, and why your CI passed anyway. For whoever is on call.
The one command
docker ps shows the current attempt. You want the history:
docker inspect <name> --format \
'restarts={{.RestartCount}} exit={{.State.ExitCode}} started={{.State.StartedAt}}'
restarts=0 with a recent started is a genuine slow boot. Wait.
restarts=8 is a crash loop, and exit=1 tells you the process decided to die rather than being killed. An exit of 137 is out of memory or a SIGKILL, and 143 is a SIGTERM, which usually means something else stopped it deliberately.
The equivalent on Kubernetes is kubectl get pod showing a restart count that nobody reads because the phase says Running, plus kubectl logs --previous to see the instance that died rather than the one currently dying.
The tell in the logs is the loop itself. If the last forty lines end at the same startup line every time, you are watching the same forty lines repeatedly, not a boot making progress.
Why the health check makes it worse
A start period exists so a slow application is not killed before it is ready. The side effect is that during the start period the container reports starting rather than unhealthy, and a container that dies inside its start period and restarts never leaves that state.
So a crash loop with a boot time shorter than the start period is invisible in the status column, indefinitely. The longer your start period, the more convincing the disguise. Large JVM services often set it to two or three minutes, which is precisely the range where a human gives up checking and goes to look at something else.
The failure that produces the most convincing version
Container orchestration is not usually the culprit. Application startup is, and one class of bug produces a crash loop that survives every test you have.
Two branches, developed the same week, each adding a class:
// branch A
package com.example.config;
@Component
public class TokenVerifier { }
// branch B
package com.example.service.sandbox;
@Component
public class TokenVerifier { }
Both are correct. Both compile. Both pass their own tests, because on each branch only one of them exists.
Merge them and the code still compiles, because the packages differ and Java is perfectly happy. Then Spring's component scan derives a bean name from the simple class name, both classes ask to be registered as tokenVerifier, and the context refuses to start:
ConflictingBeanDefinitionException: Annotation-specified bean name 'tokenVerifier'
for bean class [com.example.service.sandbox.TokenVerifier] conflicts with existing,
non-compatible bean definition of same name and class [com.example.config.TokenVerifier]
The fix is one annotation argument:
// Named explicitly: config.TokenVerifier already takes the derived name
// 'tokenVerifier'. Do not remove this, the context will not start.
@Component("sandboxTokenVerifier")
public class TokenVerifier { }
The comment matters as much as the name. Without it, someone tidying up unnecessary annotation arguments reintroduces the outage in six months, and they will be confident they are right.
This family is worth knowing generally: it is a runtime conflict created by a merge of two individually valid branches. The same shape appears with duplicate @ConfigurationProperties prefixes, two @Primary beans of one type, two Flyway migrations numbered the same, and duplicate entries in a Set.of(...) which compiles cleanly and throws IllegalArgumentException in a static initialiser.
Why CI did not catch it
Because in most pipelines nothing starts the application.
Unit tests instantiate classes directly. A @WebMvcTest slices the context and loads a fraction of it. Compilation proves the code is well-formed, not that a context can be built from it. If the merge result never refreshes a full application context, a whole family of conflicts reaches production intact.
The cheap fix is one test:
@SpringBootTest
class ContextLoadsTest {
@Test void contextLoads() { }
}
It does nothing except build the full context, and it fails on every conflict above. It is slow, perhaps thirty seconds, and it is the highest value thirty seconds in the suite because it is the only thing testing the merged whole.
For anything containerised, add a smoke step that starts the image and polls the health endpoint with a timeout shorter than the deployment's patience. If the process exits, fail the build. A CI job that starts the real artifact catches what no unit test can.
The trap during the incident
One more, because it costs the most time under pressure. When the build is broken and people are redeploying repeatedly, check what the deployed artifact was actually built from:
docker inspect <name> --format '{{.Config.Image}}'
If the image tag is a commit hash, compare it to the commit containing your fix. Repeated deploys that produce an identical broken result usually mean the build source never advanced: the fix was committed but not pushed, or pushed after the build fetched, or the build host's checkout is behind. Nine deploys of the same commit look exactly like nine failed fixes.
The concession
Everything here argues for starting the real thing in CI, and that has a genuine cost. Full context tests are slow, and container smoke tests need infrastructure that a unit test does not. On a large service this can add minutes to every pipeline run, which people resent, reasonably.
The counter is the arithmetic of a single incident. One context test at thirty seconds a run, several times a day, is a few minutes of machine time. One crash-looping deploy is an outage plus however long it takes somebody to notice that starting was never going to change.
The implication
The reason this failure takes so long to diagnose is not that it is subtle. The stack trace names both classes and the conflicting bean. It takes long because the first signal, a status column reading starting, is indistinguishable from patience being the right answer.
So make the first check the restart count, not the status. It takes one command and it converts an ambiguous situation into a definite one, which is most of the work.