Wherever you go, there you are.

Running Minecraft with a managed console (STDIN) using systemd, tail, and mkfifo+pipes

0

This post shows you how to control a systemd-managed Minecraft server by connecting its console (STDIN) to a named pipe.

If you have tried to setup a Minecraft server on linux using standard systemd service units you have likely run into the same pain I have. A Minecraft server is a process that receives control commands via console input (STDIN). There is no control application you can use to send messages to the server via IPC; even the simple act of stopping a vanilla Minecraft server requires a "stop" console command sent via STDIN rather than the traditional SIGTERM that you would expect to work for a linux service.

a crane lifting a pipe

There are many possible work-arounds to this problem. The most common I've seen, and the one I've used in the past, is to wrap the Minecraft Java process using a console "window manager" such as screen or tmux. But this solution has some downsides. These tools are not commonly installed by default, for one. Another issue is that trying to share a screen or tmux session with other users, or automate sending commands to that console, can be difficult and potentially brittle. Yet another problem is that screen or tmux controls the process' output. This means systemd cannot grab STDOUT and direct it as desired. If you prefer journalctl for your logging, for instance, losing access to the STDOUT dump is a bit of a bummer.

Perhaps most importantly, though, is that using a screen or tmux wrapper simply doesn't feel like the UNIX way. When we hear that we want to control a process via STDIN, the UNIX mind immediately jumps to pipes. Instinctually it feels like any solution not using pipes is somehow missing a better approach.

With these issues in mind, I was able to formulate a set of goals for managing my Minecraft servers that were not being met by my original screen-based solution:

  • server console input should be handled through a named pipe (mkfifo); this makes it easy to apply access controls and script commands, and feels appropriately UNIX-y
  • systemd should know that the "main process" is the Java process so it properly tracks when it dies
  • as few non-default tools as possible should be required (ideally, none)
  • the server output should remain unmodified on STDOUT so it can be sent wherever desired

I will first provide the files that enable the new solution, then go over the details of how and why it meets our criteria.

The necessary files

This method works to control any vanilla, Spigot, or PaperMC Minecraft server. It will also work on a BungeeCord server, though you will need to modify the stop command to send "end" instead.

Console commands can be issued to the server through the named pipe "/run/mcsrv/stdin". For example, running sudo -u minecraft sh -c 'echo tps > /run/mcsrv/stdin' will execute the tps command on the server console.

The systemd service unit file

[Unit]
Description=Minecraft Server
After=syslog.target network.target

[Install]
WantedBy=multi-user.target

[Service]
Type=forking
User=minecraft
Group=minecraft
Restart=no
KillMode=control-group
WorkingDirectory=/srv/minecraft
RuntimeDirectory=mcsrv
StandardOutput=journal
PIDFile=/run/mcsrv/server.pid
KillSignal=SIGCONT
FinalKillSignal=SIGTERM
ExecStartPre=mkfifo --mode=0660 /run/mcsrv/stdin
ExecStart=/bin/bash start.sh /run/mcsrv
ExecStop=/bin/sh -c 'echo stop > /run/mcsrv/stdin'

The shell script to start the server

Based on the service definition above this script should be saved as "/srv/minecraft/start.sh":

#!/bin/bash

MCDIR=$1
MCJAR=minecraft.jar
MCARG="-Xms2G -Xmx2G"

{ coproc JAVA (/usr/bin/java -server ${MCARG} -jar ${MCJAR} nogui) 1>&3; } 3>&1
echo ${JAVA_PID} > ${MCDIR}/server.pid
{ coproc TAIL (/usr/bin/tail -n +1 -f --pid=${JAVA_PID} ${MCDIR}/stdin 1>&${JAVA[1]}) } 2>/dev/null

The magic explained

Our service unit file specifies that a runtime directory should be created for this service. We create a named pipe inside the rundir using mkfifo as defined in the ExecStartPre directive. The --mode parameter for this call can be modified as needed to control the access permissions that are set on this pipe. This pipe is where we will hook the server's STDIN; anything sent to this named pipe will be received by the server console. Running sudo -u minecraft sh -c 'echo tps > /run/mcsrv/stdin', for instance, would execute the tps command via the server console.

We then start the server by executing the start.sh script. This script has what is probably our most specific requirement; it must execute with a shell that understands the coproc command. Luckily this includes bash, zsh, ksh, and a number of other very common default shell processors.

The startup script uses coproc to capture the STDIN handle when we launch the Java server process. Because this also ordinarily eats the process' STDOUT, we apply a pair of redirects to make sure that the STDOUT for the server remains attached to this script's STDOUT (which is, ultimately, what systemd will use as its interpretation of STDOUT for the overall process group). If you don't want the server output going into the systemd journal then change the StandardOutput directive to your preferred logging method.

Next we dump the server's PID into a pidfile that is also stored in the runtime directory. In our service unit we specify this file using the PIDFile directive. This allows systemd to properly associate the Java process as the main process for this service unit. If the Java process flat out dies for any reason, systemd will know. Change the Restart directive to your preferred failure behavior.

a graph of nodes

Now we need to hook our named pipe to the server's console. If you have investigated this before you may have realized how difficult it is to do this in the context of a systemd service. This is mainly owing to the semantics of a pipe being automatically closed after a single succesful writer closes its handle. A bit of research into the problem of keeping the pipe open puts us on the tail of, well, tail.

The script starts and backgrounds a tail instance that handles our pipe magic. While our server lives this tail instance constantly reads one line at a time from our named pipe and redirects this output into STDIN for the server. It is also set to "wait" on the PID of the Java process using the --pid parameter. This ensures it will clean itself up when the server stops.

To stop the service we tell systemd to send a "stop" command to the named pipe to shutdown the server cleanly. It will also send SIGCONT to the process group to make sure both tail and Java processes are not suspended. If the server fails to cleanly stop in the allowed time period systemd will then send SIGTERM to the processes. If you are running a vanilla server then this will basically force-stop the server. If you run spigot or papermc these servers will attempt to shutdown gracefully on receiving SIGTERM.

Note that the above signal methods can be modified to your preference. If you prefer to guarantee absolutely no chance of a forcibly stopped server causing data loss then you may want to change the KillMode directive to "none". This ensures that the server processes are left alone if the "stop" command doesn't do its job. The downside is the potential to leave orphaned processes that require manual intervention and cleanup. Also, if your server takes longer than the default service timeout to cleanly stop (90s) you will want to add the TimeoutStopSec directive to tell systemd to wait longer before sending the final kill signals.