Tiny Server for Microservices
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 JakartaHttpServletclass, 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:
/healthyreturns the result of all registered health checks as JSON, responding with HTTP 200 if all checks pass, or HTTP 500 if any fail./readyreturns the result of all registered readiness checks as JSON, following the same status logic. During startup and shutdown, the built-intiny-serverreadiness check reports an unhealthy status./draintriggers a graceful shutdown sequence. It runs theonStop()lifecycle hook and marks the service as not ready, but keeps the admin HTTP server alive. TheafterStop()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()
-
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. -
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.
-
The admin HTTP server is started. A default
/readycheck is registered that reports failing status ("Server is starting."), so kubernetes will not route traffic to the service yet. -
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 viacontext.addManaged()), itsonStart()is called immediately upon registration. -
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.
-
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, bothonStart()andafterStart()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
/drainon the admin port, which runs only the first half of the sequence (throughonStop()), 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.
-
The service becomes not ready. A default
/readycheck 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. -
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/drainendpoint stops here. -
The admin HTTP server is stopped. After this point, calls to
/readyand/healthywill fail with connection refused. This step is skipped by/drain. -
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 duringonStop(), for example closing HTTP clients, shutting down thread pools, or releasing shared resources. This step is skipped by/drainand runs only during final process shutdown.
Starting a Simple Server
Notes:
- Configuration helpers are found in
net.morimekta.utils:config. - Extra info on the argument parser is found in
net.morimekta.utils:terminal. - Metrics with Prometheus can be added with
io.prometheus:simpleclient_httpserver.
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>