Title: Using systemd to make a Minecraft server to start on-demand and | |
stop when it has no player | |
Author: Solène | |
Date: 20 August 2022 | |
Tags: minecraft nixos systemd automation | |
Description: This article explains how to use systemd to start a | |
network daemon upon connection and make it stop when it's not needed | |
anymore, using Minecraft as a real world example. | |
# Introduction | |
Sometimes it feels I have specific use cases I need to solve alone. | |
Today, I wanted to have a local Minecraft server running on my own | |
workstation, but only when someone needs it. The point was that | |
instead of having a big java server running all the system, Minecraft | |
server would start upon connection from a player, and would stop when | |
no player remains. | |
However, after looking a bit more into this topic, it seems I'm not the | |
only one who need this. | |
on-demand-minecraft: a project to automatically start a remote cloud server for… | |
minecraft-server-hibernation: a wrapper that starts and stop a Minecraft server… | |
As often, I prefer not to rely on third party tools when I can, so I | |
found a solution to implement this using only systemd. | |
Even better, note that this method can work with any daemon given you | |
can programmatically get the information whether to let it running or | |
stop. In this example, I'm using Minecraft and the server stop is | |
decided based on the player connecting fetch through rcon (a remote | |
administration protocol). | |
# The setup | |
I made a simple graph to show the dependencies, there are many systemd | |
components used to build this. | |
systemd dependency graph | |
The important part is the use of the systemd proxifier, it's a command | |
to accept a connection over TCP and relay it to another socket, | |
meanwhile you can do things such as starting a server and wait for it | |
to be ready. This is the key of this setup, without it, this couldn't | |
be possible. | |
Basically, listen-minecraft.socket listens on the public TCP port and | |
runs listen-minecraft.service upon connection. This service needs | |
hook-minecraft.service which is responsible for stopping or starting | |
minecraft, but will also make listen-minecraft.service wait for the TCP | |
port to be open so the proxifier will relay the connection to the | |
daemon. | |
Then, minecraft-server.service is started alongside with | |
stop-minecraft.timer which will regularly run stop-minecraft.service to | |
try to stop the server if possible. | |
# Configuration | |
I used NixOS to configure my on-demand Minecraft server. This is | |
something you can do on any systemd capable system, but I will provide | |
a NixOS example, it shouldn't be hard to translate to a regular systemd | |
configuration files. | |
```nix | |
{ config, lib, pkgs, modulesPath, ... }: | |
let | |
# check every 20 seconds if the server | |
# need to be stopped | |
frequency-check-players = "*-*-* *:*:0/20"; | |
# time in second before we could stop the server | |
# this should let it time to spawn | |
minimum-server-lifetime = 300; | |
# minecraft port | |
# used in a few places in the code | |
# this is not the port that should be used publicly | |
# don't need to open it on the firewall | |
minecraft-port = 25564; | |
# this is the port that will trigger the server start | |
# and the one that should be used by players | |
# you need to open it in the firewall | |
public-port = 25565; | |
# a rcon password used by the local systemd commands | |
# to get information about the server such as the | |
# player list | |
# this will be stored plaintext in the store | |
rcon-password = "260a368f55f4fb4fa"; | |
# a script used by hook-minecraft.service | |
# to start minecraft and the timer regularly | |
# polling for stopping it | |
start-mc = pkgs.writeShellScriptBin "start-mc" '' | |
systemctl start minecraft-server.service | |
systemctl start stop-minecraft.timer | |
''; | |
# wait 60s for a TCP socket to be available | |
# to wait in the proxifier | |
# idea found in https://blog.developer.atlassian.com/docker-systemd-socket-ac… | |
wait-tcp = pkgs.writeShellScriptBin "wait-tcp" '' | |
for i in `seq 60`; do | |
if ${pkgs.libressl.nc}/bin/nc -z 127.0.0.1 ${toString minecraft-port} > /… | |
exit 0 | |
fi | |
${pkgs.busybox.out}/bin/sleep 1 | |
done | |
exit 1 | |
''; | |
# script returning true if the server has to be shutdown | |
# for minecraft, uses rcon to get the player list | |
# skips the checks if the service started less than minimum-server-lifetime | |
no-player-connected = pkgs.writeShellScriptBin "no-player-connected" '' | |
servicestartsec=$(date -d "$(systemctl show --property=ActiveEnterTimestamp… | |
serviceelapsedsec=$(( $(date +%s) - servicestartsec)) | |
# exit if the server started less than 5 minutes ago | |
if [ $serviceelapsedsec -lt ${toString minimum-server-lifetime} ] | |
then | |
echo "server is too young to be stopped" | |
exit 1 | |
fi | |
PLAYERS=`printf "list\n" | ${pkgs.rcon.out}/bin/rcon -m -H 127.0.0.1 -p 255… | |
if echo "$PLAYERS" | grep "are 0 of a" | |
then | |
exit 0 | |
else | |
exit 1 | |
fi | |
''; | |
in | |
{ | |
# use NixOS module to declare your Minecraft | |
# rcon is mandatory for no-player-connected | |
services.minecraft-server = { | |
enable = true; | |
eula = true; | |
openFirewall = false; | |
declarative = true; | |
serverProperties = { | |
server-port = minecraft-port; | |
difficulty = 3; | |
gamemode = "survival"; | |
force-gamemode = true; | |
max-players = 10; | |
level-seed = 238902389203; | |
motd = "NixOS Minecraft server!"; | |
white-list = false; | |
enable-rcon = true; | |
"rcon.password" = rcon-password; | |
}; | |
}; | |
# don't start Minecraft on startup | |
systemd.services.minecraft-server = { | |
wantedBy = pkgs.lib.mkForce []; | |
}; | |
# this waits for incoming connection on public-port | |
# and triggers listen-minecraft.service upon connection | |
systemd.sockets.listen-minecraft = { | |
enable = true; | |
wantedBy = [ "sockets.target" ]; | |
requires = [ "network.target" ]; | |
listenStreams = [ "${toString public-port}" ]; | |
}; | |
# this is triggered by a connection on TCP port public-port | |
# start hook-minecraft if not running yet and wait for it to return | |
# then, proxify the TCP connection to the real Minecraft port on localhost | |
systemd.services.listen-minecraft = { | |
path = with pkgs; [ systemd ]; | |
enable = true; | |
requires = [ "hook-minecraft.service" "listen-minecraft.socket" ]; | |
after = [ "hook-minecraft.service" "listen-minecraft.socket"]; | |
serviceConfig.ExecStart = "${pkgs.systemd.out}/lib/systemd/systemd-socket-p… | |
}; | |
# this starts Minecraft is required | |
# and wait for it to be available over TCP | |
# to unlock listen-minecraft.service proxy | |
systemd.services.hook-minecraft = { | |
path = with pkgs; [ systemd libressl busybox ]; | |
enable = true; | |
serviceConfig = { | |
ExecStartPost = "${wait-tcp.out}/bin/wait-tcp"; | |
ExecStart = "${start-mc.out}/bin/start-mc"; | |
}; | |
}; | |
# create a timer running every frequency-check-players | |
# that runs stop-minecraft.service script on a regular | |
# basis to check if the server needs to be stopped | |
systemd.timers.stop-minecraft = { | |
enable = true; | |
timerConfig = { | |
OnCalendar = "${frequency-check-players}"; | |
Unit = "stop-minecraft.service"; | |
}; | |
wantedBy = [ "timers.target" ]; | |
}; | |
# run the script no-player-connected | |
# and if it returns true, stop the minecraft-server | |
# but also the timer and the hook-minecraft service | |
# to prepare a working state ready to resume the | |
# server again | |
systemd.services.stop-minecraft = { | |
enable = true; | |
serviceConfig.Type = "oneshot"; | |
script = '' | |
if ${no-player-connected}/bin/no-player-connected | |
then | |
echo "stopping server" | |
systemctl stop minecraft-server.service | |
systemctl stop hook-minecraft.service | |
systemctl stop stop-minecraft.timer | |
fi | |
''; | |
}; | |
} | |
``` | |
# Conclusion | |
I'm really happy to have figured out this smart way to create an | |
on-demand Minecraft, and the design can be reused with many other kinds | |
of daemons. |