Tiny Server for Microservices

GitLab Docs Pipeline Coverage License
A minimal application wrapper for non-web microservices. It provides the essential features needed for a service running on Kubernetes, including health and readiness checks, graceful shutdown, and structured logging.

See morimekta.net/utils for procedures on releases. It is managed in the same group as the utilities, even though it is a more specialized utility. See here for a small example server using gRPC.

Getting Started

To add to maven, add these to the dependencies section in pom.xml:

<dependencies>
    <dependency>
        <groupId>net.morimekta.tiny</groupId>
        <artifactId>tiny-http</artifactId>
        <version>0.8.0</version>
    </dependency>
    <dependency>
        <groupId>net.morimekta.tiny</groupId>
        <artifactId>tiny-logback</artifactId>
        <version>0.8.0</version>
    </dependency>
    <dependency>
        <groupId>net.morimekta.tiny</groupId>
        <artifactId>tiny-server</artifactId>
        <version>0.8.0</version>
    </dependency>
</dependencies>

To add to gradle, add these to the dependencies block in build.gradle:

implementation 'net.morimekta.tiny.server:tiny-http:0.8.0'
implementation 'net.morimekta.tiny.server:tiny-logback:0.8.0'
implementation 'net.morimekta.tiny.server:tiny-server:0.8.0'

Modules

The project is split into four modules:

  • tiny-http provides a simple HTTP handler abstraction (TinyHttpHandler) similar to the Jakarta HttpServlet class, along with HTTP status constants, query parsing utilities, and CORS annotation support.
  • tiny-health provides health and readiness check interfaces (TinyHealthCheck, TinyReadyCheck) and HTTP handlers that expose them as JSON endpoints.
  • tiny-logback provides a JSON logging layout (JsonLayout) for use with logback, as well as logging utility methods for initializing log forwarding and flushing appenders.
  • tiny-server provides the application wrapper (TinyApplication) and lifecycle management (TinyManaged) that ties everything together.

Admin Endpoints

The admin HTTP server is started automatically and provides the following default endpoints:

  • /healthy returns the result of all registered health checks as JSON, responding with HTTP 200 if all checks pass, or HTTP 500 if any fail.
  • /ready returns the result of all registered readiness checks as JSON, following the same status logic. During startup and shutdown, the built-in tiny-server readiness check reports an unhealthy status.
  • /drain triggers a graceful shutdown sequence. It runs the onStop() lifecycle hook and marks the service as not ready, but keeps the admin HTTP server alive. The afterStop() hook runs later when the process actually shuts down.

Application Lifecycle

A tiny server application goes through a well-defined sequence of stages from startup through normal operation to shutdown. The TinyManaged interface provides hooks for each stage, and all managed objects are called in registration order during startup and in reverse order during shutdown.

Startup Sequence

initialize()  →  parse args  →  start admin HTTP  →  onStart()  →  ready  →  afterStart()
  1. initialize(ArgParser.Builder, TinyApplicationContext.Builder) is called on the application. This is where the application registers command line arguments (for example --config, --port) and configures the context builder (for example adding custom HTTP handlers or managed objects). No external resources should be created at this point, as command line arguments have not been parsed yet.

  2. Command line arguments are parsed and value setters are called. This is where config files are loaded, ports are resolved, and other argument-driven setup happens.

  3. The admin HTTP server is started. A default /ready check is registered that reports failing status ("Server is starting."), so kubernetes will not route traffic to the service yet.

  4. onStart(TinyApplicationContext) is called on all managed objects in registration order. This is where the main setup happens: starting gRPC servers, connecting to databases, registering health and readiness checks, and similar. If any managed object throws an exception during this phase, the entire startup fails and the application exits. If a managed object is added after this phase (dynamically via context.addManaged()), its onStart() is called immediately upon registration.

  5. The service becomes ready. The built-in failing readiness check is removed, leaving only user-registered readiness checks. Kubernetes will now start routing traffic to the service.

  6. afterStart(TinyApplicationContext) is called on all managed objects. This is useful for starting background processes that depend on the service already being operational and receiving traffic, or for running post-startup validation. If a managed object is added after this phase, both onStart() and afterStart() are called immediately.

Shutdown Sequence

not ready  →  onStop()  →  stop admin HTTP  →  afterStop()  →  exit

The shutdown sequence can be triggered in three ways:

  • A shutdown signal (for example kill $pid), which is the normal way kubernetes terminates a pod.
  • An HTTP call to /drain on the admin port, which runs only the first half of the sequence (through onStop()), keeping the admin HTTP server alive.
  • Internal code calling stop() on the application.

Regardless of how it is triggered, the shutdown only runs once. If /drain has already run, a subsequent shutdown signal will wait for it to finish and then continue from where it left off.

  1. The service becomes not ready. A default /ready check is added with failing status ("Server is stopping."), signalling to kubernetes that the service should stop receiving traffic. Kubernetes typically needs a few seconds to propagate this change, so in-flight requests may still arrive briefly.

  2. onStop(TinyApplicationContext) is called on all managed objects in reverse registration order. This is where the active part of shutting down happens: stopping gRPC servers, closing connections that are actively serving traffic, flushing buffers, and similar. The admin HTTP server is still running at this point, so health and readiness checks remain accessible. The /drain endpoint stops here.

  3. The admin HTTP server is stopped. After this point, calls to /ready and /healthy will fail with connection refused. This step is skipped by /drain.

  4. afterStop(TinyApplicationContext) is called on all managed objects in reverse registration order. This is meant for stopping subsystems that other parts of the application may depend on during onStop(), for example closing HTTP clients, shutting down thread pools, or releasing shared resources. This step is skipped by /drain and runs only during final process shutdown.

Starting a Simple Server

Notes:

Example code for java:

package net.morimekta.test;

import net.morimekta.config.ConfigSupplier;
import net.morimekta.terminal.args.ArgParser;
import net.morimekta.terminal.args.Option;
import net.morimekta.tiny.server.TinyApplication;
import net.morimekta.tiny.server.TinyApplicationContext;
import io.grpc.Server;
import io.grpc.ServerBuilder;

import java.util.concurrent.atomic.AtomicInteger;

import static net.morimekta.terminal.args.ValueParser.i32;
import static net.morimekta.terminal.args.ValueParser.path;

public class MyApplication extends TinyApplication {
    private final ConfigSupplier<MyConfig> config = ConfigSupplier.yamlConfig(MyConfig.class);

    // My gRPC server.
    private Server server;

    @Override
    protected void initialize(ArgParser.Builder argParser, TinyApplicationContext.Builder context) {
        argParser.add(Option.optionLong("--config", "The config", path(config::loadUnchecked))
                            .required());
        context.addHttpHandler("/metrics", new HTTPMetricHandler(CollectorRegistry.defaultRegistry));
    }

    @Override
    protected void onStart(TinyApplicationContext context) {
        var myService = new MyServiceImpl();
        // start my gRPC service.
        server = ServerBuilder
                .forPort(config.get().grpcPort)
                .addService(myService)
                .build();
        server.start();

        context.addHealthCheck("grpc-server", new GrpcHealthCheck(server))
               .addReadyCheck("my-service", new MyServiceReadyCheck(myService));
    }

    @Override
    protected void afterStop(TinyApplicationContext context) {
        server.stop();
    }

    public static void main(String[] args) {
        TinyApplication.start(new MyApplication(), args);
    }
}

Example code for kotlin:

package net.morimekta.test

import io.grpc.Server
import io.grpc.ServerBuilder
import net.morimekta.config.ConfigSupplier
import net.morimekta.terminal.args.ArgParser
import net.morimekta.terminal.args.Option.optionLong
import net.morimekta.terminal.args.ValueParser.path
import net.morimekta.tiny.server.TinyApplication
import net.morimekta.tiny.server.TinyApplicationContext

class MyApplication : TinyApplication() {
    val config = ConfigSupplier.yamlConfig(MyConfig::class.java) {
        it.registerModule(KotlinModule.Builder().build())
    }

    lateinit var server: Server

    override fun initialize(argParser: ArgParser.Builder, context: TinyApplicationContext.Builder) {
        argParser.add(optionLong("--config", "The application config.", path { config.load(it) }).required())
        context.addHttpHandler("/metrics", HTTPMetricHandler(CollectorRegistry.defaultRegistry))
    }

    override fun onStart(context: TinyApplicationContext) {
        val myService = MyServiceImpl()
        // start my gRPC service.
        server = ServerBuilder
                .forPort(config.get().grpcPort)
                .addService(/* my service */)
                .build()
        server.start()

        context.addReadyCheck("my-service", MyServiceReadyCheck(myService))
        context.addHealthCheck("grpc-server", GrpcHealthCheck(server))
    }

    override fun afterStop(context: TinyApplicationContext) {
        server.stop()
    }
}

fun main(args: Array<String>) {
    TinyApplication.start(MyApplication(), args)
}

Using JSON Logging Layout

The JsonLayout class formats log messages as JSON, which is useful for integration with log aggregation systems like Stackdriver, Elasticsearch with Kibana, or similar tools.

The layout supports configurable time zones, MDC property inclusion, and stack trace formatting with customizable filtering. Stack trace format can be set to "full" (including module information), "normal" (default), or "short" (only the first frame).

Example logback.xml configuration:

<configuration>
    <statusListener class="ch.qos.logback.core.status.NopStatusListener"/>
    <appender name="JSON-OUT" class="ch.qos.logback.core.ConsoleAppender">
        <encoder class="ch.qos.logback.core.encoder.LayoutWrappingEncoder">
            <layout class="net.morimekta.tiny.logback.JsonLayout">
                <zoneId>UTC</zoneId>
                <includeMdc>true</includeMdc>
                <stackTraceFormat>full</stackTraceFormat>
                <stackTraceIncludeShort>true</stackTraceIncludeShort>
                <stackTraceFilter>
                    com.sun.net.httpserver,
                    java.lang.reflect,
                    java.util.ArrayList.forEach,
                    java.util.concurrent,
                    java.util.stream,
                    jdk.httpserver,
                    jdk.internal.reflect,
                    sun.net.httpserver,
                </stackTraceFilter>
            </layout>
        </encoder>
    </appender>

    <root level="INFO">
        <appender-ref ref="JSON-OUT"/>
    </root>
</configuration>

Note that if you use the maven-shade-plugin with the minimize option, you need to make sure the JsonLayout class is not excluded, as it is only referenced from the logback.xml configuration file and not from code. Add a filter for this in the shade plugin <configuration> block (you will most likely also need a similar rule for ch.qos.logback:*):

<filters>
    <filter>
        <artifact>org.qos.logback:*</artifact>
        <includes>
            <include>**</include>
        </includes>
    </filter>
    <filter>
        <artifact>net.morimekta.tiny:tiny-logback</artifact>
        <includes>
            <include>**</include>
        </includes>
    </filter>
</filters>