A small, static GraalVM native binary called lifecycle, built to run
inside distroless Kubernetes images. It handles the three most common
lifecycle needs - preStop, health, shutdown - by calling a Micronaut
(or Spring Boot Actuator) management endpoint over plain HTTP.
Distroless images have no shell, no curl, no wget - so an
httpGet probe on a separate management port (commonly 8082) can't be
replicated with exec: ["curl", ...] the way it could on a normal image.
That alone would be a minor inconvenience. The real problem shows up with an Istio (or similar) sidecar:
- Istio does automatically rewrite
livenessProbe/readinessProbehttpGetcalls (viasidecar.istio.io/rewriteAppHTTPProbers, routed through the Istio agent on port 15020) so they work despite mTLS. - Istio does not rewrite the
lifecycle.preStophook the same way. If your app enforces strict mTLS, anhttpGetpreStop hook gets rejected by Envoy, Kubernetes gives up on it immediately, and sends SIGTERM straight away - defeating the entire point of a preStop hook. (istio/istio#26099, still open.)
exec probes/hooks sidestep this completely: the kubelet runs them
inside the container via the container runtime, never touching the
network - so Envoy's mTLS enforcement never comes into play. The catch is
that exec needs something to execute, and a distroless image has
nothing capable of speaking HTTP. lifecycle is that something: a single
static binary with no dependencies, callable from exec in preStop,
livenessProbe, and readinessProbe alike.
| Command | Method | Default path | Use case |
|---|---|---|---|
lifecycle health |
GET | /health |
livenessProbe / readinessProbe |
lifecycle shutdown |
POST | /shutdown |
manually trigger graceful shutdown |
lifecycle pre-stop |
POST | /shutdown |
lifecycle.preStop hook (wait + shutdown) |
Each command defaults to the HTTP method Micronaut's built-in endpoint
expects (GET for /health, POST for /shutdown - it's annotated
@Write, since killing the process is a side effect, not a safe GET).
Override with --method if your target uses something else, e.g. a
custom endpoint that accepts GET for shutdown too:
lifecycle shutdown --port 8082 --method GET
lifecycle pre-stop --port 8082 --method GET --wait 5
lifecycle health --host 127.0.0.1 --port 8082 --path /health
lifecycle shutdown --host 127.0.0.1 --port 8082 --path /shutdown
lifecycle pre-stop --port 8082 --wait 5 --shutdown-path /shutdownCommon options (all three subcommands): --host (default 127.0.0.1),
--port (default 8082), --timeout (seconds, default 5), --verbose.
Run lifecycle <command> --help for the full list.
Exit codes: 0 = success, 1 = error/unhealthy - ready to use directly
as an exec command in K8s probes and hooks.
Only plain http is supported (no TLS built into this binary - see
ManagementEndpointClient.java). Since it only ever talks to a
management endpoint in the same pod, that's not a real limitation.
Works against Spring Boot Actuator too - just pass
--path /actuator/health/--path /actuator/shutdown.
If your app's /shutdown holds the connection open while it drains
in-flight work (this can legitimately take minutes), two separate
timeouts need to agree:
--timeoutonlifecycleitself - an upper bound on the HTTP wait, safe to set generously, e.g.--timeout 900for 15 minutes.terminationGracePeriodSecondson the pod spec - defaults to just 30 seconds. If the preStop hook is still running when this elapses, Kubernetes SIGKILLs the whole pod regardless of--timeout.
See k8s/example-deployment.yaml (example-app-slow-shutdown) for both
set consistently.
Enable the relevant endpoints on the app you want to manage. For
Micronaut (e.g. in application.yml):
endpoints:
health:
enabled: true
shutdown:
enabled: true
sensitive: falseRequires JDK 25.
gradle run --args="health --host localhost --port 8082" # JVM, fast dev loop
gradle nativeCompile # native binary (needs GraalVM)
./build/native/nativeCompile/lifecycle health --port 8082
gradle testVerified working on Gradle 8.14.5 running directly under JDK 25 (earlier Gradle 8.x releases may not parse JDK 25 bytecode correctly - if you hit
Unsupported class file major version, update Gradle). Gradle 9.x is deliberately avoided for now:org.graalvm.buildtools.native(as of 1.1.9) isn't yet compatible with it (Gradle 9.0 removedorg.gradle.util.VersionNumber, which the plugin still references).
docker build -t lifecycle-cli:local .
docker run --rm lifecycle-cli:local health --host host.docker.internal --port 8082Multi-stage build (GraalVM -> gcr.io/distroless/static-debian12), fully
static binary (--static --libc=musl) - works in any distroless image,
not just this one.
host.docker.internal only resolves on Docker Desktop. On plain Linux
Docker Engine, use --add-host=host.docker.internal:host-gateway or
--network host instead - or just run ./scripts/extract-binary.sh to
pull the binary out and run it directly on the host.
This repo doesn't publish a Docker image - only raw binaries and
.tar.gz archives, both attached to GitHub Releases. Pick whichever
suits your Dockerfile better.
ARG LIFECYCLE_VERSION=v1.0.0
ADD --chmod=755 https://github.com/rjaros87/lifecycle-cli/releases/download/${LIFECYCLE_VERSION}/lifecycle-linux-amd64 /usr/local/bin/lifecycle
FROM gcr.io/distroless/java25-debian12
COPY --from=0 /usr/local/bin/lifecycle /usr/local/bin/lifecycle
COPY target/your-app.jar /app.jar
ENTRYPOINT ["java", "-jar", "/app.jar"]Docker's ADD fetches the file and sets its permissions in one layer -
no curl/tar/chmod needed. Note: ADD from a remote URL does
not auto-extract archives (that only happens for local tarballs in
the build context), so this only works cleanly with the raw binary, not
the .tar.gz.
gh release download <tag> --repo rjaros87/lifecycle-cli --pattern '*.tar.gz'
tar -xzf lifecycle-linux-amd64.tar.gzFROM gcr.io/distroless/java25-debian12
COPY lifecycle /usr/local/bin/lifecycle
COPY target/your-app.jar /app.jar
ENTRYPOINT ["java", "-jar", "/app.jar"]amd64 and arm64 binaries have different runtime requirements -
see "Container Deployment" above. distroless/static only works for
amd64; for arm64 (or a multi-arch image), use a glibc-providing base
like gcr.io/distroless/base-debian12 or chainguard/glibc-dynamic
instead, and swap the binary/tag per TARGETARCH if you build multi-arch.
See k8s/example-deployment.yaml for the matching pod spec.
gradle testUnit tests use a real embedded com.sun.net.httpserver.HttpServer (no
mocks) and construct command objects directly - no DI framework in play.
Both CI workflows run gradle test as a gate before building anything.
scripts/health_stub.py is a dependency-free Python stub server for
manual end-to-end testing (GET /health / POST /shutdown, plain status
codes, no body needed):
python scripts/health_stub.py
./build/native/nativeCompile/lifecycle health --port 8082.github/workflows/ci.yml- on push/PR tomain:gradle test, then a Dockerfile build-check (push: false, nothing published)..github/workflows/release.yml- on a published GitHub Release or av*.*.*tag:gradle test, then native binaries forlinux/amd64andlinux/arm64(built on real arm64 runners -native-imagedoesn't cross-compile), uploaded as Release assets.
The project produces native binaries for two architectures:
- Linux amd64: Statically linked (using
musl). Can be used indistroless/staticimages. - Linux arm64: Dynamically linked (using
glibc) - see issue. Requires an image withglibc(e.g.,distroless/base,chainguard/glibc-dynamic, ordebian).
build.gradle application + GraalVM Native Image plugins
src/main/java/.../
LifecycleCommand.java root "lifecycle" command (plain picocli)
CommonOptions.java shared options (--host, --port, --timeout, ...)
ManagementEndpointClient.java HTTP client (java.net.HttpURLConnection)
HealthCommand.java "health" subcommand
ShutdownCommand.java "shutdown" subcommand
PreStopCommand.java "pre-stop" subcommand
src/test/java/.../ unit tests
scripts/health_stub.py stub server for manual testing
scripts/extract-binary.sh pulls the compiled binary out of the Docker build
Dockerfile multi-stage: GraalVM -> distroless/static
.github/workflows/ci.yml test -> Dockerfile build-check (no push)
.github/workflows/release.yml test -> amd64+arm64 binaries as Release assets
k8s/example-deployment.yaml example Deployment usage