Background

Why Java

A CSI driver is systems software: It runs as root on every node, it starts before the workloads do, and its memory is charged to the cluster rather than the application. Here is what Java 25 brings to that, what it costs, and where another language would have been the easier choice.

Summary#

Kubernetes infrastructure is written in Go by default, and that default is a reasonable one: The CSI spec ships Go bindings, every sidecar is Go, and the ecosystem's examples are Go. This driver is Java. Modern Java answers the three usual objections, and the reasons in its favour apply to this problem.

The objectionThe answer
"A JVM on every node costs hundreds of MiB"GraalVM native image. The binary idles at 69 MiB RSS and peaks at 156 MiB under load, measured. There is no JVM on the node.
"Startup takes seconds"The native binary is serving on its socket in milliseconds. A CSI driver that starts slowly delays every pod on the node.
"Concurrency means thread pools and tuning"Virtual threads. Every upload, every RPC and every peer fetch is a blocking call on its own thread, and the code reads like it.

What is left is a language with a mature S3 SDK, exhaustive pattern matching that the compiler enforces, and records that make the domain model unambiguous. The rest of this page is the detail.

Virtual threads#

An archiver is almost entirely blocking I/O: Stat a file, read a range, PUT it, wait. The alternatives are a thread pool sized by guesswork, or an async framework and the colour-of-your-function problem that comes with it. Virtual threads make the obvious code correct.

// One virtual thread per upload. Blocking, sequential, readable, and
// cheap enough that the only bound is a semaphore expressing a policy
// rather than a resource.
Executors.newThreadPerTaskExecutor(
    Thread.ofVirtual().name("csi-upload-", 0).factory());

The consequence shows up in the code, not the benchmarks. The upload pipeline retries with backoff by calling Thread.sleep. The final sweep waits for the queue to drain by joining futures. The cluster view fetches every peer concurrently with invokeAll and a timeout. None of that needs a reactive library, and all of it can be read top to bottom.

The native image#

The shipped artefact is a statically linked binary in a FROM scratch image: No JVM, no shell, no package manager, no libc. That is a genuine operational benefit for a DaemonSet running as root on every node, and it is not free.

What you getWhat it costs
69 MiB idle RSS, milliseconds to startA build that needs Docker and takes minutes
A container image with no shell and no CVE surface from a base OSReachability metadata to keep current, regenerated by a tracing agent
No JIT warm-up, so the first upload is as fast as the thousandthReflection has to be declared. In practice: The AWS SDK, and nothing this project wrote
One file to ship per architectureGraalVM cannot cross-compile, so each architecture builds on its own runner

The failure mode to plan for is divergence: Code that works on the JVM and not in the image. A missing java.net.URL protocol handler, for instance, breaks every custom S3 endpoint while mvn verify stays green. The answer is to make the difference testable rather than to distrust the image: The binary self-checks its upload path, its statistics UI and its CSI conformance in CI, so a divergence fails a build rather than a cluster.

Language features used#

Sealed interfaces make the compiler check the modes

Credentials are one of four things: Static keys, the AWS default chain, a signer endpoint, or a static POST policy. As a sealed interface, every switch over them must handle all four, and adding a mode is a compile error in each place that has to change.

return switch (credentials) {
    case Credentials.Static s        -> StaticCredentialsProvider.create(...);
    case Credentials.DefaultChain d  -> DefaultCredentialsProvider.create();
    case Credentials.Presigned p     -> throw new IllegalArgumentException(...);
    case Credentials.PresignedPost p -> throw new IllegalArgumentException(...);
    // no default: adding a fifth mode breaks the build here, on purpose
};

That is the feature earning its place: The compiler, not a reviewer, finds every site that a new mode affects.

Records make the domain model unambiguous

A volume's configuration is a record with two dozen components. It is immutable, its equals is structural (which is what makes "is this republish the same configuration?" a one-liner), and its components are named in one place. Credentials are records too, with hand-written toString that redacts, because these objects reach log statements by accident far more often than by design.

Text blocks, and knowing when not to use them

The statistics page is a real HTML file that Maven inlines into a text block at build time. Text blocks suit content that has no editor of its own; beyond that they get in the way, because they strip trailing whitespace and interpret backslashes. The build therefore refuses HTML containing either, and a test asserts the served page is byte-identical to the file.

The foreign-function API is not used

Worth saying because it is the obvious place to reach for it. The driver performs no mount(2), which is why it runs unprivileged. That is a design decision rather than a limitation of the language.

Libraries used#

DependencyWhyWhat it costs
AWS SDK v2SigV4, multipart, retries, endpoint handling, and the long tail of S3-compatible store quirks. Reimplementing it would be the project.Most of the image size, and most of the reachability metadata
grpc-java + NettyThe CSI transport. Uses the NIO Unix-domain-socket channel, so no native .so ships.A dependency tree, and one silenced log line about a socket option
Jackson streaming onlyState files and the statistics API. Streaming, not databind, so no reflective mapping.Hand-written codecs, which is the point: Every field is read deliberately
slf4j + an in-project bindingStructured single-line logs. The binding is ~200 lines.Written rather than chosen, for a reason worth reading

Four dependencies, one of which is the S3 SDK. There is no dependency-injection container: The object graph is a handful of singletons wired by hand in one file, which reads better than annotations and adds no reflection for the image to configure.

Where Go fits better#

This page would be less useful without the other column.

  • The CSI ecosystem is Go. The spec's reference bindings, every sidecar, csi-sanity, and every example a maintainer will search for. The Java side of that is a vendored .proto and one local patch to it, because protoc's Java generator cannot emit a message with both a parameters and a mutable_parameters map.
  • Static binaries are the default, not a build mode with a toolchain image, a tracing agent and metadata to keep current.
  • Cross-compilation is one environment variable. GraalVM cannot cross-compile at all, so multi-architecture images are stitched from per-architecture runners.
  • A smaller binary. An equivalent Go driver would be a fraction of csi-s3-archiver's 78 MiB image, which is dominated by the AWS SDK.

The trade the project made: A heavier build and a larger image, for a language its authors write faster and more safely, with a mature S3 client and a compiler that enforces the domain's invariants. If the deciding factor for you is image size or fitting the Go ecosystem, those are good reasons, and this page is not an argument against them.