Minecraft 磐石伺服器架設技術剖析 使用 GCP

本文是希望可以從軟體工程師的角度出發
紀錄一下是如何一步一步的架設伺服器的
希望也幫助到其他想架設 Minecraft 伺服器的人

https://rock-mc.github.io/

磐石伺服器是一個邀請制原味生存 Minecraft 伺服器

目標是提供一個長期穩定流暢的伺服器
目前由數位軟體工程師鎮守,務求提供優質的服務給各位

我們沒有商店、沒有領地,專注在提供最流暢的遊玩環境
盡可能保持最少的插件,有新版本的時候可以用最快的速度升級
更採用邀請制,讓每個玩家都可以一起維持玩家品質

如果你沒有邀請碼,可以透過面試管道取得邀請碼

目前正在 Java 1.17.1 營運,歡迎加入我們

伺服主 CodingMan

下面會分成幾個部分解說

  • 自動安裝腳本
    • 快速在新環境部署你的 Minecraft 環境的腳本
  • 啟動腳本
    • 設定 Java option 啟動你的 Minecraft 伺服器
  • 管理程式
    • 在伺服器上一層,一邊讀取 Minecraft 伺服器的系統訊息,一邊可以自動對 Minecraft 伺服器下指令
  • Linux service
    • 將管理程式包裝成 Linux service,讓系統監看你的程式是否有持續運作
  • 自動開機

自動安裝腳本

一開始在 GCP 上面測試,為了可以快速切換機器
所以寫了一個自動安裝腳本,可以在幾分鐘內在全新環境建置好你的伺服器

在這裡我們選用 Ubuntu

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
# set your java installer here, must with tar.gz
java_installer=https://cdn.azul.com/zulu/bin/zulu16.30.15-ca-jre16.0.1-linux_x64.tar.gz
installer_name_full="$(basename -- $java_installer)"
installer_name="${installer_name_full%.*}"
# installer_name will be zulu16.30.15-ca-jre16.0.1-linux_x64
installer_name="${installer_name%.*}"

sudo apt-get update && sudo apt-get -y upgrade
sudo apt-get -y install git screen
sudo timedatectl set-timezone Asia/Taipei

# put your github private key here
sudo chmod 600 ./github

# add your github private key to ssh
eval `ssh-agent -s`
ssh-add ./github

cd ..

curl -O $java_installer
tar -xf $installer_name_full

# clone your minecraft server repo
if [ -d "./server" ]
then
rm -r ./server
fi
git clone [email protected]:rock-mc/server.git

cd server
git config core.filemode false
git config --global user.email "[email protected]"
git config --global user.name "PttCodingMan"

mchome="$(pwd)"

if [ -f "startup.sh" ]; then
rm startup.sh
fi
cp startup_sample.sh startup.sh
# replace java path in startup.sh
sed -i "s/THIS_VALUE_WILL_REPLACE_WITH_JAVA_PATH/$installer_name/" startup.sh

# clone map repo in github
# or somewhere else
git clone [email protected]:rock-mc/world.git
git clone [email protected]:rock-mc/world_nether.git
git clone [email protected]:rock-mc/world_the_end.git

sudo chmod -R 777 .

cd ..

if [ -d "./__MACOSX" ]
then
sudo rm -r __MACOSX/
fi

cd init

sed -i --expression "[email protected][email protected]$mchome@" minecraft.service
sudo cp minecraft.service /etc/systemd/system/
sudo systemctl enable minecraft
sudo systemctl start minecraft

啟動腳本

啟動腳本除了負責紀錄 Java option 啟動伺服器以外,我也希望可以把之前當掉的 Minecraft 伺服器紀錄可以保存下來,以方便追蹤錯誤

一開始我發現如果只是單純的使用以下指令,會看不到日誌輸出到螢幕上

1
SomeCommand > system.log  

經過一番追尋之後,我發現了 tee 指令,更棒的是可以搭配日期輸出
於是乎我們就得到了,具備日期的輸出日誌的啟動腳本!

Java option 則可以參考 Aikar 的文
https://aikar.co/2018/07/02/tuning-the-jvm-g1gc-garbage-collector-flags-for-minecraft/

啟動腳本範例如下

1
2
3
mkdir -p run_log

./$JAVA_PATH/java <java option> -jar paper.jar nogui | tee "run_log/$(date +%m-%d_%H-%M).log"

管理程式

現在我們可以透過啟動腳本啟動 Minecraft 伺服器了,但如果是遇到錯誤需要自動重啟,或者是任何有需要自動化對伺服器下指令的時候,光靠啟動腳本無法滿足我們的需求

所以我另外使用 Python 開發了管理程式,除了遇到當掉重啟外,也可以偵測沒玩家在線上之後,自動將伺服器關閉,更可以偵測到一些錯誤時,可以快速對伺服器下指令做緊急的處置

我的管理程式實作如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
# start_service.py
import datetime
import os
import sys
import threading
import time
import uuid
from os import path

from SingleLog.log import Logger
from watchdog.events import FileSystemEventHandler, FileModifiedEvent
from watchdog.observers import Observer

import server_util


def detect_server(test: bool = False) -> dict:
global lock
global next_check_time
global is_stopping

if test:
random_id = uuid.uuid4().hex[:5]
logger.info('id', random_id)

joined_count = 0
left_count = 0
is_duplicate_uuid = False
system_start_time = None
emergency_restart = False

tolerance_range = 2

if next_check_time is not None:
if time.time() < next_check_time:
next_check_time = time.time() + tolerance_range
if test:
logger.info('wait next check time', random_id)
return {'emergency_restart': emergency_restart,
'system_start_time': system_start_time,
'joined_count': joined_count,
'left_count': left_count,
'is_duplicate_uuid': is_duplicate_uuid}

# logger.info('read server log')
with lock:
next_check_time = time.time() + tolerance_range
time.sleep(tolerance_range)

while time.time() < next_check_time:
time.sleep(int(tolerance_range / 10))

if test:
logger.info('read file', random_id)
else:
run_log = server_util.get_server_run_log()
for i in range(len(run_log)):
if '>' not in run_log[i]:
if 'Done' in run_log[i] and 'For help' in run_log[i]:
if system_start_time is None:
system_start_time = time.time()
if 'joined the game' in run_log[i]:
joined_count += 1
if 'left the game' in run_log[i]:
left_count += 1
if 'UUID of added entity already exists' in run_log[i]:
is_duplicate_uuid = True
if 'Can\'t keep up!' in run_log[i]:
emergency_restart = True
if 'Stopping the server' in run_log[i]:
is_stopping = True
if 'Stopping server' in run_log[i]:
is_stopping = True

if system_start_time is None:
next_check_time = time.time() + 10

return {'emergency_restart': emergency_restart,
'system_start_time': system_start_time,
'joined_count': joined_count,
'left_count': left_count,
'is_duplicate_uuid': is_duplicate_uuid}


class LogHandler(FileSystemEventHandler):

def on_modified(self, event: FileModifiedEvent):
global is_stopping
global is_wait_stop

if is_stopping:
if not is_wait_stop:
is_wait_stop = True
server_util.wait_server_stop()
return

if not str(event.src_path).endswith('.log'):
return

# logger.info("on_modified", event.src_path)

detect_log_result = detect_server()
if detect_log_result['is_duplicate_uuid']:
for count_down in reversed(range(0, 6)):
server_util.send_server_command(f'say 偵測到問題實體,緊急重新啟動,倒數...{count_down}')
time.sleep(1)
server_util.send_server_command('stop')
server_util.wait_server_stop()
if detect_log_result['emergency_restart']:
for count_down in reversed(range(0, 6)):
server_util.send_server_command(f'say 偵測到系統問題,緊急重新啟動,倒數...{count_down}')
time.sleep(1)
server_util.send_server_command('stop')
server_util.wait_server_stop()


wait_player_max_min = 10
check_time_sec = 10
check_server_status_time_sec = 60
next_check_time = None
is_stopping = False
is_wait_stop = False

lock = threading.Lock()
logger = Logger('service')

if __name__ == '__main__':

event_handler = LogHandler()
observer = Observer()
observer.schedule(event_handler, path='./run_log', recursive=False)
observer.start()

server_util.reset_command()

log_check = list()
log_count = None
check_time_start = time.time()
save_map = False
update_list = []

no_player_time = None
while True:
time.sleep(check_time_sec)

check_result = os.popen('ps aux | grep jar').read()

if path.exists('/tmp/server_close'):
logger.info('stop service')
server_util.send_server_command('stop')
server_util.wait_server_stop()
sys.exit()
if 'Xms' in check_result and 'Xmx' in check_result:
if time.time() - check_time_start < check_server_status_time_sec:
continue

this_year = datetime.datetime.now().year
this_month = datetime.datetime.now().month
this_day = datetime.datetime.now().day

start_time = datetime.datetime(year=this_year, month=this_month, day=this_day, hour=2, minute=30)
end_time = datetime.datetime(year=this_year, month=this_month, day=this_day, hour=5, minute=30)

if not server_util.is_in_time_range(start_time, end_time):
continue

now = datetime.datetime.now()

backup_folder = now.strftime('%Y-%m-%d')
backup_path = f'/data/backup/{backup_folder}'

if backup_path in update_list:
continue

if os.path.exists(backup_path):
update_list.append(backup_path)
continue

check_time_start = time.time()

detect_result = detect_server()

if detect_result['system_start_time'] is None:
# 伺服器還沒啟動
logger.info('Server is startup')
continue

current_last_time = None
if detect_result['joined_count'] == 0 and detect_result['left_count'] == 0:
# 都還沒有人加入伺服器
if log_count != 'count from system_start':
log_count = 'count from system_start'
logger.info('count from system_start')
current_last_time = detect_result['system_start_time']
elif detect_result['left_count'] < detect_result['joined_count']:
# 表示還有人在伺服器裡
save_map = True
no_player_time = None
if log_count != 'player online':
log_count = 'player online'
logger.info('player online')

must_backup_time = datetime.datetime(year=this_year, month=this_month, day=this_day, hour=5, minute=00)
# must_backup_time 與 end_time 之間
# 開始強制備份
if server_util.is_in_time_range(must_backup_time, end_time):
server_util.backup_map()
os.system('sudo reboot')

time.sleep(30)

elif detect_result['joined_count'] == detect_result['left_count']:
save_map = True
# 如果登出數量等於登入數量
# 就是伺服器沒人了
if log_count != 'count from player left':
log_count = 'count from player left'
logger.info('count from player left')
if no_player_time is None:
no_player_time = time.time()
current_last_time = no_player_time

current_elapsed_time = time.time() - current_last_time

# 沒人在線上超過 wait_player_max_min 分鐘
# 開始備份
if current_elapsed_time >= wait_player_max_min * 60:
server_util.backup_map()
os.system('sudo reboot')

time.sleep(30)
else:
logger.info('server is down!')

logger.info('start server')
os.system('screen -d -m sh startup.sh')
time.sleep(10)
check_time_start = time.time()

Linux service

現在我們可以透過管理程式配合啟動腳本,持續的處理伺服器程式本身發生的錯誤,但如果我們遇到系統上的錯誤,更甚者是停電後重新開機的情況,還是無法應對的

所以我們的最後一步是把管理程式打包成 Linux service,如此一來就可以讓作業系統來管理與監視管理程式是否有正確的執行

而打包成 Linux service 之前,我們還有些事情要做。需要先把操作管理程式的腳本先寫好
實作如下

啟動

1
python3 start_service.py

關閉

1
2
3
4
5
6
7
8
9
10
# stop_service.py
import os
import time
import server_util

if __name__ == '__main__':
os.system('touch /tmp/server_close')
time.sleep(3)

server_util.wait_service_stop()

接下來就是把開關的 Python 腳本整合到 service 裡面
在這裡會建議你用使用者權限來啟動伺服器

1
2
3
4
5
6
7
8
9
10
11
12
13
[Unit]
Description=Minecraft service
After=network.target

[Service]
User=your_username
WorkingDirectory=THIS_VALUE_WILL_REPLACE_WITH_MCHOME
ExecStart=/usr/bin/python3 THIS_VALUE_WILL_REPLACE_WITH_MCHOME/start_service.py
ExecStop=/usr/bin/python3 THIS_VALUE_WILL_REPLACE_WITH_MCHOME/stop_service.py
Restart=on-failure

[Install]
WantedBy=multi-user.target

使用以下指令註冊你剛寫好的 Linux service

1
2
3
sudo cp minecraft.service /etc/systemd/system/
sudo systemctl enable minecraft
sudo systemctl start minecraft

註冊之後,就可以透過以下指令來開關伺服器

1
sudo systemctl start minecraft
1
sudo systemctl stop minecraft

完成!

自動開機

由於雲端服務掛著就會需要費用,所以沒人的時候就關機
因此也需要有一個自動開機的功能,可以讓玩家自行把伺服器打開

在這裡我們用的是 Could Functions 官方教學

實作如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
var Compute = require('@google-cloud/compute');

var compute = new Compute();

exports.startInstance = function startInstance(req, res) {

// your server time zone
var zone = compute.zone('asia-south1-c');
// your server name
var vm = zone.vm('your server name');

vm.start(function(err, operation, apiResponse) {

console.log('instance start successfully');

});

res.status(200).send('server has Started!!');
};

這樣玩家就可以透過 cloud functions 的網址,來啟動伺服器了!
如此一來,Minecraft 伺服器在 GCP 上基本的維持運作架構就完成了
玩家跟伺服主從此過著幸福快樂的日子 並不會