From 71a7afab1706301e08faa1c4f9321906d1cecd5c Mon Sep 17 00:00:00 2001 From: Michael Kirsch Date: Wed, 20 May 2020 15:17:58 +0200 Subject: [PATCH] Autopause functionality (#531) --- Dockerfile | 21 +++++-- README.md | 34 ++++++++++ files/autopause/autopause-daemon.sh | 97 +++++++++++++++++++++++++++++ files/autopause/autopause-fcns.sh | 38 +++++++++++ files/autopause/knockd-config.cfg | 12 ++++ files/autopause/pause.sh | 19 ++++++ files/autopause/resume.sh | 6 ++ files/sudoers-mc | 2 + health.sh | 11 ++++ start | 8 +++ start-configuration | 59 ++++++++++++++++++ start-finalSetup04ServerProperties | 9 +++ start-utils | 18 ++++-- 13 files changed, 324 insertions(+), 10 deletions(-) create mode 100644 files/autopause/autopause-daemon.sh create mode 100644 files/autopause/autopause-fcns.sh create mode 100644 files/autopause/knockd-config.cfg create mode 100755 files/autopause/pause.sh create mode 100755 files/autopause/resume.sh create mode 100644 files/sudoers-mc create mode 100644 health.sh diff --git a/Dockerfile b/Dockerfile index 6204c854..cee2e0ed 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,15 +15,17 @@ RUN apk add --no-cache -U \ mysql-client \ tzdata \ rsync \ - nano - -HEALTHCHECK --start-period=1m CMD mc-monitor status --host localhost --port $SERVER_PORT + nano \ + sudo \ + knock RUN addgroup -g 1000 minecraft \ && adduser -Ss /bin/false -u 1000 -G minecraft -h /home/minecraft minecraft \ && mkdir -m 777 /data \ && chown minecraft:minecraft /data /home/minecraft +COPY files/sudoers* /etc/sudoers.d + EXPOSE 25565 25575 # hook into docker BuildKit --platform support @@ -63,14 +65,21 @@ COPY server.properties /tmp/server.properties COPY log4j2.xml /tmp/log4j2.xml WORKDIR /data -ENTRYPOINT [ "/start" ] - ENV UID=1000 GID=1000 \ JVM_XX_OPTS="-XX:+UseG1GC" MEMORY="1G" \ TYPE=VANILLA VERSION=LATEST FORGEVERSION=RECOMMENDED SPONGEBRANCH=STABLE SPONGEVERSION= FABRICVERSION=LATEST LEVEL=world \ PVP=true DIFFICULTY=easy ENABLE_RCON=true RCON_PORT=25575 RCON_PASSWORD=minecraft \ LEVEL_TYPE=DEFAULT SERVER_PORT=25565 ONLINE_MODE=TRUE SERVER_NAME="Dedicated Server" \ - REPLACE_ENV_VARIABLES="FALSE" ENV_VARIABLE_PREFIX="CFG_" + REPLACE_ENV_VARIABLES="FALSE" ENV_VARIABLE_PREFIX="CFG_" \ + ENABLE_AUTOPAUSE=false AUTOPAUSE_TIMEOUT_EST=3600 AUTOPAUSE_TIMEOUT_KN=120 AUTOPAUSE_TIMEOUT_INIT=600 AUTOPAUSE_PERIOD=10 COPY start* / +COPY health.sh / +ADD files/autopause /autopause + RUN dos2unix /start* && chmod +x /start* +RUN dos2unix /health.sh && chmod +x /health.sh +RUN dos2unix /autopause/* && chmod +x /autopause/*.sh + +ENTRYPOINT [ "/start" ] +HEALTHCHECK --start-period=1m CMD /health.sh diff --git a/README.md b/README.md index 13e2a4d1..0488795d 100644 --- a/README.md +++ b/README.md @@ -179,6 +179,40 @@ You can also query the container's health in a script friendly way: healthy ``` +## Autopause + +### Description + +> There are various bug reports on [Mojang](https://bugs.mojang.com) about high CPU usage of servers with newer versions, even with few or no clients connected (e.g. [this one](https://bugs.mojang.com/browse/MC-149018), in fact the functionality is based on [this comment in the thread](https://bugs.mojang.com/browse/MC-149018?focusedCommentId=593606&page=com.atlassian.jira.plugin.system.issuetabpanels%3Acomment-tabpanel#comment-593606)). + +An autopause functionality has been added to this image to monitor whether clients are connected to the server. If for a specified time no client is connected, the Java process is stopped. When knocking on the server port (e.g. by the ingame Multiplayer server overview), the process is resumed. The experience for the client does not change. + +Of course, even loaded chunks are not ticked when the process is stopped. + +From the server's point of view, the pausing causes a single tick to take as long as the process is stopped, so the server watchdog might intervene after the process is continued, possibly forcing a container restart. To prevent this, ensure that the `max-tick-time` in the `server.properties` file is set correctly. + +On startup the `server.properties` file is checked and, if applicable, a warning is printed to the terminal. When the server is created (no data available in the persistent directory), the properties file is created with the Watchdog disabled. + +The autopause functionality is not compatible with docker's host network_mode, as the `knockd` utility cannot properly listen for connections in that mode. + +### Enabling Autopause + +Enable the Autopause functionality by setting: + +``` +-e ENABLE_AUTOPAUSE=TRUE +``` + +There are 4 more environment variables that define the behaviour: +* `AUTOPAUSE_TIMEOUT_EST`, default `3600` (seconds) +describes the time between the last client disconnect and the pausing of the process (read as timeout established) +* `AUTOPAUSE_TIMEOUT_INIT`, default `600` (seconds) +describes the time between server start and the pausing of the process, when no client connects inbetween (read as timeout initialized) +* `AUTOPAUSE_TIMEOUT_KN`, default `120` (seconds) +describes the time between knocking of the port (e.g. by the main menu ping) and the pausing of the process, when no client connects inbetween (read as timeout knocked) +* `AUTOPAUSE_PERIOD`, default `10` (seconds) +describes period of the daemonized state machine, that handles the pausing of the process (resuming is done independently) + ## Deployment Templates and Examples ### Helm Charts diff --git a/files/autopause/autopause-daemon.sh b/files/autopause/autopause-daemon.sh new file mode 100644 index 00000000..b4896d43 --- /dev/null +++ b/files/autopause/autopause-daemon.sh @@ -0,0 +1,97 @@ +#!/bin/bash + +exec 1>/tmp/terminal-mc + +. /autopause/autopause-fcns.sh + +sudo /usr/sbin/knockd -c /autopause/knockd-config.cfg -d +if [ $? -ne 0 ] ; then + while : + do + if [[ -n $(ps -o comm | grep java) ]] ; then + break + fi + sleep 0.1 + done + echo "[Autopause loop] Failed to start knockd daemon." + echo "[Autopause loop] Possible cause: docker's host network mode." + echo "[Autopause loop] Recreate without host mode or disable autopause functionality." + echo "[Autopause loop] Stopping server." + killall -SIGTERM java + exit 1 +fi + +STATE=INIT + +while : +do + case X$STATE in + XINIT) + # Server startup + if mc_server_listening ; then + TIME_THRESH=$(($(current_uptime)+$AUTOPAUSE_TIMEOUT_INIT)) + echo "[Autopause loop] MC Server listening for connections - stopping in $AUTOPAUSE_TIMEOUT_INIT seconds" + STATE=K + fi + ;; + XK) + # Knocked + if java_clients_connected ; then + echo "[Autopause loop] Client connected - waiting for disconnect" + STATE=E + else + if [[ $(current_uptime) -ge $TIME_THRESH ]] ; then + echo "[Autopause loop] No client connected since startup / knocked - stopping" + /autopause/pause.sh + STATE=S + fi + fi + ;; + XE) + # Established + if ! java_clients_connected ; then + TIME_THRESH=$(($(current_uptime)+$AUTOPAUSE_TIMEOUT_EST)) + echo "[Autopause loop] All clients disconnected - stopping in $AUTOPAUSE_TIMEOUT_EST seconds" + STATE=I + fi + ;; + XI) + # Idle + if java_clients_connected ; then + echo "[Autopause loop] Client reconnected - waiting for disconnect" + STATE=E + else + if [[ $(current_uptime) -ge $TIME_THRESH ]] ; then + echo "[Autopause loop] No client reconnected - stopping" + /autopause/pause.sh + STATE=S + fi + fi + ;; + XS) + # Stopped + if rcon_client_exists ; then + /autopause/resume.sh + fi + if java_running ; then + if java_clients_connected ; then + echo "[Autopause loop] Client connected - waiting for disconnect" + STATE=E + else + TIME_THRESH=$(($(current_uptime)+$AUTOPAUSE_TIMEOUT_KN)) + echo "[Autopause loop] Server was knocked - waiting for clients or timeout" + STATE=K + fi + fi + ;; + *) + echo "[Autopause loop] Error: invalid state: $STATE" + ;; + esac + if [[ "$STATE" == "S" ]] ; then + # before rcon times out + sleep 2 + else + sleep $AUTOPAUSE_PERIOD + fi +done diff --git a/files/autopause/autopause-fcns.sh b/files/autopause/autopause-fcns.sh new file mode 100644 index 00000000..aea37a6f --- /dev/null +++ b/files/autopause/autopause-fcns.sh @@ -0,0 +1,38 @@ +#!/bin/bash + +current_uptime() { + echo $(awk '{print $1}' /proc/uptime | cut -d . -f 1) +} + +java_running() { + [[ $( ps -a -o stat,comm | grep 'java' | awk '{ print $1 }') =~ ^S.*$ ]] +} + +rcon_client_exists() { + [[ -n "$(ps -a -o comm | grep 'rcon-cli')" ]] +} + +mc_server_listening() { + [[ -n $(netstat -tln | grep "0.0.0.0:$SERVER_PORT" | grep LISTEN) ]] +} + +java_clients_connected() { + local connections + connections=$(netstat -tn | grep ":$SERVER_PORT" | grep ESTABLISHED) + if [[ -z "$connections" ]] ; then + return 1 + fi + IFS=$'\n' + connections=($connections) + unset IFS + # check that at least one external address is not localhost + # remember, that the host network mode does not work with autopause because of the knockd utility + for (( i=0; i<${#connections[@]}; i++ )) + do + if [[ ! $(echo "${connections[$i]}" | awk '{print $5}') =~ ^\s*127\.0\.0\.1:.*$ ]] ; then + # not localhost + return 0 + fi + done + return 1 +} diff --git a/files/autopause/knockd-config.cfg b/files/autopause/knockd-config.cfg new file mode 100644 index 00000000..86c98d02 --- /dev/null +++ b/files/autopause/knockd-config.cfg @@ -0,0 +1,12 @@ +[options] + logfile = /dev/null +[unpauseMCServer-server] + sequence = 25565 + seq_timeout = 1 + command = /sbin/su-exec minecraft:minecraft /autopause/resume.sh + tcpflags = syn +[unpauseMCServer-rcon] + sequence = 25575 + seq_timeout = 1 + command = /sbin/su-exec minecraft:minecraft /autopause/resume.sh + tcpflags = syn diff --git a/files/autopause/pause.sh b/files/autopause/pause.sh new file mode 100755 index 00000000..3acc5bd6 --- /dev/null +++ b/files/autopause/pause.sh @@ -0,0 +1,19 @@ +#!/bin/bash + +if [[ $( ps -a -o stat,comm | grep 'java' | awk '{ print $1 }') =~ ^S.*$ ]] ; then + # save world + rcon-cli save-all >/dev/null + + # wait until mc-monitor is no longer connected to the server + while : + do + if [[ -z "$(netstat -nt | grep "127.0.0.1:$SERVER_PORT" | grep 'ESTABLISHED')" ]]; then + break + fi + sleep 0.1 + done + + # finally pause the process + echo "[$(date -Iseconds)] [Autopause] Pausing Java process" >/tmp/terminal-mc + killall -q -STOP java +fi diff --git a/files/autopause/resume.sh b/files/autopause/resume.sh new file mode 100755 index 00000000..dcf0a1dd --- /dev/null +++ b/files/autopause/resume.sh @@ -0,0 +1,6 @@ +#!/bin/bash + +if [[ $( ps -a -o stat,comm | grep 'java' | awk '{ print $1 }') =~ ^T.*$ ]] ; then + echo "[$(date -Iseconds)] [Autopause] Knocked, resuming Java process" >/tmp/terminal-mc + killall -q -CONT java +fi diff --git a/files/sudoers-mc b/files/sudoers-mc new file mode 100644 index 00000000..419c352e --- /dev/null +++ b/files/sudoers-mc @@ -0,0 +1,2 @@ +%minecraft ALL=(ALL) NOPASSWD:/usr/bin/killall +%minecraft ALL=(ALL) NOPASSWD:/usr/sbin/knockd diff --git a/health.sh b/health.sh new file mode 100644 index 00000000..eab0f5c0 --- /dev/null +++ b/health.sh @@ -0,0 +1,11 @@ +#!/bin/bash + +. /start-utils + +if isTrue "${ENABLE_AUTOPAUSE}" && [[ "$( ps -a -o stat,comm | grep 'java' | awk '{ print $1 }')" =~ ^T.*$ ]]; then + echo "Java process suspended by Autopause function" + exit 0 +else + mc-monitor status --host localhost --port $SERVER_PORT + exit $? +fi diff --git a/start b/start index 3a919292..37c422c2 100644 --- a/start +++ b/start @@ -36,6 +36,14 @@ if [ $(id -u) = 0 ]; then chown -R ${runAsUser}:${runAsGroup} /data fi + if [[ $(stat -c "%u" /autopause) != $UID ]]; then + log "Changing ownership of /autopause to $UID ..." + chown -R ${runAsUser}:${runAsGroup} /autopause + fi + + ln -fs $(tty) /tmp/terminal-mc + chmod 777 /tmp/terminal-mc + if [[ ${SKIP_NSSWITCH_CONF^^} != TRUE ]]; then echo 'hosts: files dns' > /etc/nsswitch.conf fi diff --git a/start-configuration b/start-configuration index 0d6290c9..842a87b7 100644 --- a/start-configuration +++ b/start-configuration @@ -68,6 +68,65 @@ cd /data || exit 1 export ORIGINAL_TYPE=${TYPE^^} +if isTrue "${ENABLE_AUTOPAUSE}"; then + log "Autopause functionality enabled" + + # update server port to listen to + regseq="^\s*sequence\s*=\s*$SERVER_PORT\s*$" + linenum=$(grep -nm1 sequence /autopause/knockd-config.cfg | cut -d : -f 1 | tail -n1) + if ! [[ $(awk "NR==$linenum" /autopause/knockd-config.cfg) =~ $regseq ]]; then + sed -i "${linenum}s/sequence.*/sequence = $SERVER_PORT/" /autopause/knockd-config.cfg + log "Updated server port in knockd config" + fi + # update rcon port to listen to + regseq="^\s*sequence\s*=\s*$RCON_PORT\s*$" + linenum=$(grep -nm2 sequence /autopause/knockd-config.cfg | cut -d : -f 1 | tail -n1) + if ! [[ $(awk "NR==$linenum" /autopause/knockd-config.cfg) =~ $regseq ]]; then + sed -i "${linenum}s/sequence.*/sequence = $RCON_PORT/" /autopause/knockd-config.cfg + log "Updated rcon port in knockd config" + fi + + if ! [[ $AUTOPAUSE_PERIOD =~ ^[0-9]+$ ]]; then + AUTOPAUSE_PERIOD=10 + export AUTOPAUSE_PERIOD + log "Warning: AUTOPAUSE_PERIOD is not numeric, set to 10 (seconds)" + fi + if [ "$AUTOPAUSE_PERIOD" -eq "0" ] ; then + AUTOPAUSE_PERIOD=10 + export AUTOPAUSE_PERIOD + log "Warning: AUTOPAUSE_PERIOD must not be 0, set to 10 (seconds)" + fi + if ! [[ $AUTOPAUSE_TIMEOUT_KN =~ ^[0-9]+$ ]] ; then + AUTOPAUSE_TIMEOUT_KN=120 + export AUTOPAUSE_TIMEOUT_KN + log "Warning: AUTOPAUSE_TIMEOUT_KN is not numeric, set to 120 (seconds)" + fi + if ! [[ $AUTOPAUSE_TIMEOUT_EST =~ ^[0-9]+$ ]] ; then + AUTOPAUSE_TIMEOUT_EST=3600 + export AUTOPAUSE_TIMEOUT_EST + log "Warning: AUTOPAUSE_TIMEOUT_EST is not numeric, set to 3600 (seconds)" + fi + if ! [[ $AUTOPAUSE_TIMEOUT_INIT =~ ^[0-9]+$ ]] ; then + AUTOPAUSE_TIMEOUT_INIT=600 + export AUTOPAUSE_TIMEOUT_INIT + log "Warning: AUTOPAUSE_TIMEOUT_INIT is not numeric, set to 600 (seconds)" + fi + + if [[ -n $MAX_TICK_TIME ]] ; then + log "Warning: MAX_TICK_TIME is non-default, for autopause to work properly, this check should be disabled (-1 for versions >= 1.8.1)" + else + if versionLessThan 1.8.1; then + # 10 years + MAX_TICK_TIME=315360000000 + else + MAX_TICK_TIME=-1 + fi + export MAX_TICK_TIME + fi + + /autopause/autopause-daemon.sh & +fi + log "Resolving type given ${TYPE}" case "${TYPE^^}" in *BUKKIT|SPIGOT) diff --git a/start-finalSetup04ServerProperties b/start-finalSetup04ServerProperties index bdc02108..ea92f88e 100644 --- a/start-finalSetup04ServerProperties +++ b/start-finalSetup04ServerProperties @@ -180,4 +180,13 @@ else log "server.properties already created, skipping" fi +if isTrue "${ENABLE_AUTOPAUSE}"; then + current_max_tick=$( grep 'max-tick-time' "$SERVER_PROPERTIES" | sed -r 's/( )+//g' | awk -F= '{print $2}' ) + if (( $current_max_tick > 0 && $current_max_tick < 86400000 )); then + log "Warning: The server.properties for the server doesn't have the Server Watchdog (effectively) disabled." + log "Warning (cont): Autopause functionality resuming the process might trigger the Watchdog and restart the server completely." + log "Warning (cont): Set the max-tick-time property to a high value (or disable the Watchdog with value -1 for versions 1.8.1+)." + fi +fi + exec /start-finalSetup05EnvVariables $@ diff --git a/start-utils b/start-utils index 9641256e..03b4c562 100644 --- a/start-utils +++ b/start-utils @@ -75,10 +75,20 @@ function versionLessThan { return 1 fi - if (( activeParts[0] < givenParts[0] )) || \ - (( activeParts[0] == givenParts[0] && activeParts[1] < givenParts[1] )); then - return 0 + if (( ${#activeParts[@]} == 2 )); then + if (( activeParts[0] < givenParts[0] )) || \ + (( activeParts[0] == givenParts[0] && activeParts[1] < givenParts[1] )); then + return 0 + else + return 1 + fi else - return 1 + if (( activeParts[0] < givenParts[0] )) || \ + (( activeParts[0] == givenParts[0] && activeParts[1] < givenParts[1] )) || \ + (( activeParts[0] == givenParts[0] && activeParts[1] == givenParts[1] && activeParts[2] < givenParts[2] )); then + return 0 + else + return 1 + fi fi } \ No newline at end of file