rjaros87/lifecycle-cli

Native CLI (Picocli + GraalVM) for Kubernetes lifecycle hooks — preStop, health, and shutdown — against a Micronaut management endpoint. Built to run inside distroless images with no shell.

★ 0Forks 0JavaGitHub ↗Compare
clidevopsdistrolessgraalvmjavakuberneteslifecycle-hooksnative-imagepicocli

README

lifecycle-cli

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.

Purpose

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/readinessProbe httpGet calls (via sidecar.istio.io/rewriteAppHTTPProbers, routed through the Istio agent on port 15020) so they work despite mTLS.
  • Istio does not rewrite the lifecycle.preStop hook the same way. If your app enforces strict mTLS, an httpGet preStop 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.

Usage

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 /shutdown

Common 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.

Long graceful shutdowns

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:

  1. --timeout on lifecycle itself - an upper bound on the HTTP wait, safe to set generously, e.g. --timeout 900 for 15 minutes.
  2. terminationGracePeriodSeconds on 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.

Requirements on the target application

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: false

Build

Requires 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 test

Verified 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 removed org.gradle.util.VersionNumber, which the plugin still references).

Docker

docker build -t lifecycle-cli:local .
docker run --rm lifecycle-cli:local health --host host.docker.internal --port 8082

Multi-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.

Using it in your own application image

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.

Option A: ADD a raw binary directly (no RUN, no download step)

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.

Option B: download and extract manually (e.g. in CI, before docker build)

gh release download <tag> --repo rjaros87/lifecycle-cli --pattern '*.tar.gz'
tar -xzf lifecycle-linux-amd64.tar.gz
FROM gcr.io/distroless/java25-debian12
COPY lifecycle /usr/local/bin/lifecycle
COPY target/your-app.jar /app.jar
ENTRYPOINT ["java", "-jar", "/app.jar"]

Pick the right base image for the architecture

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.

Testing

gradle test

Unit 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

CI/CD

  • .github/workflows/ci.yml - on push/PR to main: gradle test, then a Dockerfile build-check (push: false, nothing published).
  • .github/workflows/release.yml - on a published GitHub Release or a v*.*.* tag: gradle test, then native binaries for linux/amd64 and linux/arm64 (built on real arm64 runners - native-image doesn't cross-compile), uploaded as Release assets.

Container Deployment

The project produces native binaries for two architectures:

  • Linux amd64: Statically linked (using musl). Can be used in distroless/static images.
  • Linux arm64: Dynamically linked (using glibc) - see issue. Requires an image with glibc (e.g., distroless/base, chainguard/glibc-dynamic, or debian).

Project structure

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

Contributors

rjaros87

Issues