Skip to content

Commit cfc5c23

Browse files
authored
fix(server): preserve container exit status (#3311)
The Server container watched a daemon JVM with tail --pid and returned 1 whenever it stopped. A normal JVM exit 0 and an application exit 42 therefore looked identical. The entrypoint now owns the existing foreground launcher and returns its status after readiness, including 137 for SIGKILL.
1 parent 3cc8f61 commit cfc5c23

6 files changed

Lines changed: 346 additions & 10 deletions

File tree

‎docker/README.md‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,18 @@ Standalone stores RocksDB data at `/hugegraph-server/rocksdb-data`. The HStore t
234234

235235
### Server logs and crash files
236236

237+
The Server container owns its foreground launcher and returns the JVM's exit status after startup,
238+
including 0 for a normal exit and 137 when the JVM is killed with SIGKILL. TERM and INT request
239+
shutdown through the launcher using TERM, which shuts down and reaps the JVM; the entrypoint returns
240+
143 and 130 respectively.
241+
The startup REST check still uses `HG_SERVER_STARTUP_TIMEOUT_S`, and HStore partition checks still
242+
run after readiness. Shutdown interrupts a stalled readiness probe or pending partition check and
243+
reaps its polling processes.
244+
A startup timeout requests termination and returns 1 without waiting for an
245+
unresponsive JVM beyond the startup deadline; container termination cleans up remaining processes.
246+
Graceful shutdown requires the JVM to respond to TERM; forceful OOM and orchestrator shutdown testing
247+
needs a Linux environment.
248+
237249
This section describes images built from current master. Images from 1.7.0 and earlier do not set `STDOUT_MODE`: `docker logs` shows only the entrypoint's output, and the service logs stay in `logs/` inside the container.
238250

239251
Both Server images (`hugegraph/hugegraph` and `hugegraph/server`) write the HugeGraph log to container stdout. WARN and above from Hadoop, ZooKeeper, SOFA, Netty and Commons also reaches stdout. INFO from Hadoop, Netty and Commons, the audit log and the slow-query log stay in files only; ZooKeeper and SOFA log nothing below WARN. Errors that `hugegraph-server.sh` reports before Java starts, such as an unsupported JDK or too little free memory, and fatal bootstrap errors go to stderr and, when `logs/` can be written, also to `hugegraph-server.log`. Earlier entrypoint steps, such as `init-store.sh` in the HStore image, report to stderr only. Either way, when a Server exits during startup, `docker logs` or `kubectl logs --previous` shows why. When the JVM starts, it also prints `Picked up JAVA_TOOL_OPTIONS: ...` with the crash-file defaults below; a start that fails its preflight checks exits before that.

‎hugegraph-server/hugegraph-dist/docker/docker-entrypoint-test.sh‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,11 @@ if IFS= read -r password; then
4848
printf '%s' "${password}" > ./docker/init-store-password
4949
fi
5050
EOF
51+
cat > "${TEST_HOME}/bin/util.sh" <<'EOF'
52+
# Configuration tests isolate readiness; process lifecycle is tested separately.
53+
read_property() { sed -n "s/^$2=//p" "$1"; }
54+
wait_for_startup() { return 0; }
55+
EOF
5156
cat > "${TEST_HOME}/bin/enable-auth.sh" <<'EOF'
5257
#!/usr/bin/env bash
5358
printf 'called\n' >> ./docker/enable-auth-calls
@@ -325,3 +330,4 @@ done
325330
assert_start_timeout 120
326331

327332
echo "PASS: Docker entrypoint configures HStore discovery and authentication"
333+
bash "${SCRIPT_DIR}/test/test-docker-entrypoint-process.sh"

‎hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh‎

Lines changed: 71 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -252,19 +252,83 @@ else
252252
./bin/init-store.sh
253253
fi
254254

255-
./bin/start-hugegraph.sh -j "${JAVA_OPTS:-}" -t "${SERVER_STARTUP_TIMEOUT_S}"
255+
# Own the foreground launcher so wait can recover the JVM's actual status.
256+
# A daemon JVM is not our child; watching its PID with tail loses that status.
257+
source ./bin/util.sh
258+
LAUNCHER_PID=""
259+
READINESS_PID=""
260+
PARTITION_PID=""
261+
stop_server_and_exit() {
262+
local exit_code="$1"
263+
# dumb-init can signal the group before we forward to its children.
264+
# Ignore duplicate signals while those children finish shutting down.
265+
trap '' TERM INT
266+
if [[ -n "$READINESS_PID" ]]; then
267+
kill -TERM "$READINESS_PID" 2>/dev/null || true
268+
fi
269+
if [[ -n "$PARTITION_PID" ]]; then
270+
kill -TERM "$PARTITION_PID" 2>/dev/null || true
271+
fi
272+
if [[ -n "$LAUNCHER_PID" ]]; then
273+
kill -TERM "$LAUNCHER_PID" 2>/dev/null || true
274+
fi
275+
if [[ -n "$PARTITION_PID" ]]; then
276+
wait "$PARTITION_PID" 2>/dev/null || true
277+
fi
278+
if [[ -n "$READINESS_PID" ]]; then
279+
wait "$READINESS_PID" 2>/dev/null || true
280+
fi
281+
if [[ -n "$LAUNCHER_PID" ]]; then
282+
wait "$LAUNCHER_PID" 2>/dev/null || true
283+
fi
284+
exit "$exit_code"
285+
}
286+
trap 'stop_server_and_exit 143' TERM
287+
trap 'stop_server_and_exit 130' INT
288+
289+
./bin/start-hugegraph.sh -d false -j "${JAVA_OPTS:-}" -t "${SERVER_STARTUP_TIMEOUT_S}" &
290+
LAUNCHER_PID=$!
291+
REST_SERVER_URL=$(read_property "$REST_SERVER_CONF" "restserver.url")
292+
# GNU timeout owns the probe group, including a stalled curl. Background it
293+
# so wait is interruptible; a foreground probe would defer our signal traps.
294+
timeout "${SERVER_STARTUP_TIMEOUT_S}s" bash -c '
295+
source ./bin/util.sh
296+
wait_for_startup "$@"
297+
' bash "$LAUNCHER_PID" 'HugeGraphServer' "$REST_SERVER_URL/graphs" \
298+
"$SERVER_STARTUP_TIMEOUT_S" &
299+
READINESS_PID=$!
300+
READY_STATUS=0
301+
wait "$READINESS_PID" || READY_STATUS=$?
302+
READINESS_PID=""
303+
if [[ "$READY_STATUS" -ne 0 ]]; then
304+
log "ERROR: HugeGraphServer did not become ready; see the container logs"
305+
if kill -0 "$LAUNCHER_PID" 2>/dev/null; then
306+
# Keep the startup deadline even if the JVM ignores TERM. Container
307+
# termination cleans up any remaining processes; don't wait forever.
308+
kill -TERM "$LAUNCHER_PID" 2>/dev/null || true
309+
exit 1
310+
fi
311+
# Preserve an early failure, but a normal exit before readiness is still
312+
# an unsuccessful start.
313+
STATUS=0
314+
wait "$LAUNCHER_PID" || STATUS=$?
315+
[[ "$STATUS" -ne 0 ]] || STATUS=1
316+
exit "$STATUS"
317+
fi
256318

257319
# Post-startup cluster stabilization check (hstore only — rocksdb has no partitions)
258320
ACTUAL_BACKEND=$(grep -E '^[[:space:]]*backend[[:space:]]*=' "${GRAPH_CONF}" | head -n 1 | sed 's/.*=//' | tr -d '[:space:]' || true)
259321
if [[ "${ACTUAL_BACKEND}" == "hstore" ]]; then
260322
STORE_REST="${STORE_REST:-store:8520}"
261323
export STORE_REST
262-
./bin/wait-partition.sh || log "WARN: partitions not assigned yet"
324+
./bin/wait-partition.sh &
325+
PARTITION_PID=$!
326+
wait "$PARTITION_PID" || log "WARN: partitions not assigned yet"
327+
PARTITION_PID=""
263328
fi
264329

265-
PID=$(cat ./bin/pid 2>/dev/null || true)
266-
if [[ -n "$PID" ]]; then
267-
trap 'kill -TERM "$PID" 2>/dev/null; while kill -0 "$PID" 2>/dev/null; do sleep 1; done; exit 0' TERM INT
268-
tail --pid="$PID" -f /dev/null
269-
exit 1
330+
if wait "$LAUNCHER_PID"; then
331+
exit 0
332+
else
333+
exit $?
270334
fi
Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
#!/bin/bash
2+
#
3+
# Licensed to the Apache Software Foundation (ASF) under one or more
4+
# contributor license agreements. See the NOTICE file distributed with
5+
# this work for additional information regarding copyright ownership.
6+
# The ASF licenses this file to You under the Apache License, Version 2.0
7+
# (the "License"); you may not use this file except in compliance with
8+
# the License. You may obtain a copy of the License at
9+
#
10+
# http://www.apache.org/licenses/LICENSE-2.0
11+
#
12+
# Unless required by applicable law or agreed to in writing, software
13+
# distributed under the License is distributed on an "AS IS" BASIS,
14+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
# See the License for the specific language governing permissions and
16+
# limitations under the License.
17+
#
18+
set -euo pipefail
19+
20+
REPO_ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../.." && pwd)
21+
# Real shell parents and children, with only REST readiness and Java replaced.
22+
# Stalled-probe cases use one temporary localhost socket; no JVM, Docker or DB.
23+
python3 - "$REPO_ROOT" <<'PY'
24+
import os
25+
import pathlib
26+
import shutil
27+
import signal
28+
import socket
29+
import subprocess
30+
import sys
31+
import tempfile
32+
import threading
33+
import time
34+
35+
repo = pathlib.Path(sys.argv[1])
36+
static = repo / 'hugegraph-server/hugegraph-dist/src/assembly/static/bin'
37+
entrypoint = repo / 'hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh'
38+
39+
40+
def await_file(path, process):
41+
deadline = time.monotonic() + 5
42+
while not path.exists():
43+
assert process.poll() is None, 'entrypoint exited before ' + str(path)
44+
assert time.monotonic() < deadline, 'fixture did not reach ' + str(path)
45+
time.sleep(0.02)
46+
47+
48+
with tempfile.TemporaryDirectory(prefix='hugegraph-entrypoint-process-') as directory:
49+
for mode, expected in [('exit0', 0), ('exit42', 42), ('kill137', 137),
50+
('TERM', 143), ('INT', 130), ('groupTERM', 143),
51+
('early42', 42), ('early0', 1), ('timeout', 1),
52+
('timeout-ignore', 1), ('partition42', 42),
53+
('partitionTERM', 143), ('partitionINT', 130),
54+
('partitiongroupTERM', 143), ('readinessTERM', 143),
55+
('readinessINT', 130), ('readinessgroupTERM', 143)]:
56+
work = pathlib.Path(directory) / mode
57+
for name in ['bin', 'tools', 'conf/graphs', 'docker', 'logs']:
58+
(work / name).mkdir(parents=True)
59+
shutil.copy(entrypoint, work / 'entrypoint.sh')
60+
for name in ['start-hugegraph.sh', 'util.sh']:
61+
shutil.copy(static / name, work / 'bin' / name)
62+
(work / 'docker/init_complete').touch()
63+
backend = 'hstore' if mode.startswith('partition') else 'rocksdb'
64+
(work / 'conf/graphs/hugegraph.properties').write_text('backend=' + backend + '\n')
65+
(work / 'conf/rest-server.properties').write_text(
66+
'restserver.url=http://127.0.0.1:1\ngremlinserver.url=http://127.0.0.1:2\n')
67+
listener = None
68+
accepted, stop_http = threading.Event(), threading.Event()
69+
if mode.startswith('readiness'):
70+
listener = socket.socket()
71+
listener.bind(('127.0.0.1', 0))
72+
listener.listen(1)
73+
port = listener.getsockname()[1]
74+
(work / 'conf/rest-server.properties').write_text(
75+
'restserver.url=http://127.0.0.1:%d\ngremlinserver.url=http://127.0.0.1:2\n' % port)
76+
# The fixture HTTP listener replaces the JVM listener. Skip only
77+
# launcher port preflight; readiness still runs the real helper.
78+
with (work / 'bin/util.sh').open('a') as utility:
79+
utility.write('\ncheck_port() { :; }\n')
80+
81+
def hold_http():
82+
connection, _ = listener.accept()
83+
connection.recv(8192)
84+
accepted.set()
85+
stop_http.wait(10)
86+
connection.close()
87+
88+
threading.Thread(target=hold_http, daemon=True).start()
89+
(work / 'bin/init-store.sh').write_text('#!/bin/bash\nexit 0\n')
90+
(work / 'bin/wait-partition.sh').write_text(
91+
'#!/bin/bash\ntouch ./partition-ran\nexit 7\n')
92+
if mode.startswith('partition') and mode != 'partition42':
93+
shutil.copy(static / 'wait-partition.sh', work / 'bin/wait-partition.sh')
94+
(work / 'tools/curl').write_text('''#!/bin/bash
95+
case "$TEST_CHILD_MODE" in
96+
early* | timeout*) printf '000'; exit 28 ;;
97+
esac
98+
if [[ "$TEST_CHILD_MODE" == readiness* ]]; then
99+
ancestor=$$
100+
for index in 1 2 3 4; do
101+
echo "$ancestor" > "./readiness-owned${index}.pid"
102+
name=$(ps -o comm= -p "$ancestor")
103+
if [[ "${name##*/}" == *timeout ]]; then
104+
echo "$ancestor" > ./readiness-timeout.pid
105+
break
106+
fi
107+
ancestor=$(ps -o ppid= -p "$ancestor" | tr -d ' ')
108+
done
109+
exec "$TEST_REAL_CURL" "$@"
110+
fi
111+
if [[ "$1" != '-I' && "$TEST_CHILD_MODE" == partition* ]]; then
112+
echo "$PPID" > ./partition-shell.pid
113+
timeout_pid=$(ps -o ppid= -p "$PPID" | tr -d ' ')
114+
echo "$timeout_pid" > ./partition-timeout.pid
115+
ps -o ppid= -p "$timeout_pid" | tr -d ' ' > ./partition-wrapper.pid
116+
touch ./partition-ran
117+
printf '{"partitionCount":0}'
118+
exit 0
119+
fi
120+
touch ./ready-probe
121+
printf '200'
122+
''')
123+
(work / 'bin/hugegraph-server.sh').write_text('''#!/usr/bin/env python3
124+
import os, pathlib, signal, sys, time
125+
work = pathlib.Path.cwd()
126+
mode = os.environ['TEST_CHILD_MODE']
127+
def stop(signum, frame):
128+
signal.signal(signal.SIGTERM, signal.SIG_IGN)
129+
(work / 'term-observed').touch()
130+
# Expose a duplicate group signal killing a wrapper before it reaps us.
131+
time.sleep(0.2)
132+
sys.exit(143)
133+
signal.signal(signal.SIGTERM, signal.SIG_IGN if mode == 'timeout-ignore' else stop)
134+
(work / 'child-running').touch()
135+
if mode.startswith('early'):
136+
sys.exit(int(mode[5:]))
137+
while not (work / 'child-exit').exists():
138+
time.sleep(0.02)
139+
sys.exit(int((work / 'child-exit').read_text()))
140+
''')
141+
for folder in ['bin', 'tools']:
142+
for script in (work / folder).iterdir():
143+
script.chmod(0o755)
144+
env = {key: value for key, value in os.environ.items()
145+
if not key.startswith('HG_SERVER_') and key not in ['PASSWORD', 'BACKEND', 'PD_PEERS']}
146+
env.update(PATH=str(work / 'tools') + ':' + os.environ['PATH'],
147+
TEST_CHILD_MODE=mode, HG_SERVER_STARTUP_TIMEOUT_S='3',
148+
TEST_REAL_CURL=shutil.which('curl'), NO_PROXY='127.0.0.1', no_proxy='127.0.0.1')
149+
if mode.startswith('readiness'):
150+
env['HG_SERVER_STARTUP_TIMEOUT_S'] = '60'
151+
started = time.monotonic()
152+
with (work / 'output').open('wb') as output:
153+
process = subprocess.Popen(['bash', './entrypoint.sh'], cwd=work, env=env,
154+
stdout=output, stderr=subprocess.STDOUT,
155+
start_new_session=True)
156+
try:
157+
if mode.startswith('timeout'):
158+
await_file(work / 'child-running', process)
159+
if mode.startswith('readiness'):
160+
await_file(work / 'child-running', process)
161+
assert accepted.wait(5), 'real curl did not reach stalled HTTP fixture'
162+
if mode == 'readinessgroupTERM':
163+
os.killpg(process.pid, signal.SIGTERM)
164+
else:
165+
signum = signal.SIGINT if mode.endswith('INT') else signal.SIGTERM
166+
os.kill(process.pid, signum)
167+
elif not mode.startswith(('early', 'timeout')):
168+
await_file(work / 'child-running', process)
169+
await_file(work / 'ready-probe', process)
170+
if mode.startswith('partition'):
171+
await_file(work / 'partition-ran', process)
172+
if mode in ['TERM', 'INT', 'partitionTERM', 'partitionINT']:
173+
signum = signal.SIGINT if mode.endswith('INT') else signal.SIGTERM
174+
os.kill(process.pid, signum)
175+
elif mode in ['groupTERM', 'partitiongroupTERM']:
176+
os.killpg(process.pid, signal.SIGTERM)
177+
elif mode == 'kill137':
178+
os.kill(int((work / 'bin/pid').read_text()), signal.SIGKILL)
179+
else:
180+
status = 42 if mode == 'partition42' else int(mode[4:])
181+
(work / 'child-exit').write_text(str(status))
182+
actual = process.wait(timeout=5)
183+
assert actual == expected, (mode, expected, actual, (work / 'output').read_text())
184+
if mode.startswith('timeout'):
185+
assert time.monotonic() - started < 5, 'startup cleanup exceeded the deadline'
186+
if mode in ['TERM', 'INT', 'groupTERM', 'partitionTERM', 'partitionINT',
187+
'partitiongroupTERM', 'readinessTERM', 'readinessINT',
188+
'readinessgroupTERM']:
189+
assert (work / 'term-observed').exists(), 'TERM did not reach the JVM substitute'
190+
if not mode.startswith('timeout'):
191+
owned = ([work / 'bin/pid'] + list(work.glob('partition-*.pid')) +
192+
list(work.glob('readiness-*.pid')))
193+
for pid_file in owned:
194+
child = int(pid_file.read_text())
195+
try:
196+
os.kill(child, 0)
197+
except ProcessLookupError:
198+
pass
199+
else:
200+
raise AssertionError(str(pid_file) + ' survived entrypoint exit')
201+
if mode == 'partition42':
202+
assert 'WARN: partitions not assigned yet' in (work / 'output').read_text()
203+
print('PASS: ' + mode + ' -> ' + str(actual))
204+
finally:
205+
# Timeout intentionally permits an unresponsive process until
206+
# container termination. The fixture owns and kills that group.
207+
try:
208+
os.killpg(process.pid, signal.SIGKILL)
209+
except ProcessLookupError:
210+
pass
211+
# GNU timeout owns a separate process group. Clean it too if
212+
# a regression prevents the wrapper from forwarding shutdown.
213+
for pid_file in [work / 'partition-timeout.pid', work / 'readiness-timeout.pid']:
214+
if pid_file.exists():
215+
try:
216+
os.killpg(int(pid_file.read_text()), signal.SIGKILL)
217+
except ProcessLookupError:
218+
pass
219+
for pid_file in list(work.glob('partition-*.pid')) + list(work.glob('readiness-*.pid')):
220+
try:
221+
os.kill(int(pid_file.read_text()), signal.SIGKILL)
222+
except ProcessLookupError:
223+
pass
224+
process.wait(timeout=2)
225+
if listener is not None:
226+
stop_http.set()
227+
listener.close()
228+
PY

‎hugegraph-server/hugegraph-dist/src/assembly/static/bin/start-hugegraph.sh‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,9 @@ function forward_signal_and_wait() {
103103
local signal="$1"
104104
local exit_code="$2"
105105

106-
trap - SIGHUP SIGINT SIGQUIT SIGTERM
106+
# A process-group signal and the entrypoint's forwarding can arrive twice.
107+
# Keep the wrapper alive until it has reaped its child in either case.
108+
trap '' SIGHUP SIGINT SIGQUIT SIGTERM
107109
if kill -0 "$PID" 2>/dev/null; then
108110
kill "-$signal" "$PID" 2>/dev/null || true
109111
# The foreground wait is interrupted before the trap runs, so retry

0 commit comments

Comments
 (0)