왜 구동 스크립트가 필요할까
JAR 파일 하나를 실수 없이 운영한다는 건 생각보다 까다롭습니다. java -jar app.jar로 실행하면 터미널이 닫히는 순간 프로세스가 함께 종료되고, nohup만 붙여도 “이미 떠 있는 프로세스를 또 올려서 중복 실행”되는 사고가 흔하게 일어납니다. 운영 서버에서는 배포 자동화, 재기동, 로그 확인, PID 추적까지 한 번에 처리해주는 얇은 구동 스크립트(run script) 가 반드시 필요합니다.
이 글에서는 실무에서 바로 쓰는 run.sh 템플릿을 단계별로 해부합니다. JDK 8+ 기반의 Java 서비스(PMS라는 이름의 백엔드)를 가정하고, start | stop | restart | status | run 5가지 명령을 지원하는 스크립트를 완성합니다.
스크립트 전체 구조
먼저 완성된 run.sh 전체를 살펴보고, 이후 섹션에서 부분별로 쪼개서 설명합니다. 아래 스크립트는 /sw/pms 경로에 배포된 Java 서비스를 관리한다고 가정합니다.
#!/bin/sh
# run.sh — Java 서비스 구동/중지/재기동/상태 스크립트
ulimit -n 65536
SERVICE_NAME="PMS"
SERVICE_PATH="/sw/pms"
RUN_LOG="/sw/pms/logs/pms.run.log"
NULL_LOG="/dev/null"
JAVA_PATH="/sw/jdk8_471/bin/java"
JAVA_CLASS_PATH="${SERVICE_PATH}:${SERVICE_PATH}/bin:${SERVICE_PATH}/conf:${SERVICE_PATH}/lib/*:${SERVICE_PATH}/dependency/*"
JAVA_MAIN_CLASS="moa.service.pms.MainService"
JAVA_OPT="-Dfile.encoding=UTF-8 \
-Dlog4j.configurationFile=${SERVICE_PATH}/conf/log4j2.xml \
-Djava.security.egd=file:/dev/./urandom \
-Xms256m -Xmx512m"
PID_PATH_NAME="${SERVICE_PATH}/proc/pms.pid"
RUN_SUDO=""
SERVICE_LOG="$NULL_LOG"
if [ "$2" = "log" ]; then
SERVICE_LOG="$RUN_LOG"
fi
mkdir -p "$(dirname "$RUN_LOG")"
mkdir -p "$(dirname "$PID_PATH_NAME")"
if [ ! -x "$JAVA_PATH" ]; then
echo "Java not found or not executable: $JAVA_PATH"
exit 1
fi
if [ ! -d "$SERVICE_PATH" ]; then
echo "Service path not found: $SERVICE_PATH"
exit 1
fi
is_running() {
if [ -f "$PID_PATH_NAME" ]; then
PID=$(cat "$PID_PATH_NAME")
if [ -n "$PID" ] && kill -0 "$PID" 2>/dev/null; then
return 0
fi
fi
return 1
}
start_service() {
echo "Starting $SERVICE_NAME ..."
if is_running; then
echo "$SERVICE_NAME is already running ..."
echo "pid = $(cat "$PID_PATH_NAME")"
return 0
fi
rm -f "$PID_PATH_NAME"
cd "$SERVICE_PATH" || exit 1
nohup "$JAVA_PATH" \
-classpath "$JAVA_CLASS_PATH" \
$JAVA_OPT \
"$JAVA_MAIN_CLASS" \
> "$SERVICE_LOG" 2>&1 &
PID=$!
echo "$PID" > "$PID_PATH_NAME"
sleep 1
if kill -0 "$PID" 2>/dev/null; then
echo "$SERVICE_NAME started ..."
echo "pid = $PID"
else
echo "$SERVICE_NAME failed to start. Check log: $SERVICE_LOG"
rm -f "$PID_PATH_NAME"
exit 1
fi
}
stop_service() {
if ! is_running; then
echo "$SERVICE_NAME is not running ..."
rm -f "$PID_PATH_NAME"
return 0
fi
PID=$(cat "$PID_PATH_NAME")
echo "$SERVICE_NAME stopping ..."
kill "$PID"
COUNT=0
while kill -0 "$PID" 2>/dev/null; do
COUNT=$((COUNT + 1))
if [ "$COUNT" -ge 10 ]; then
echo "$SERVICE_NAME did not stop gracefully. Killing forcefully ..."
kill -9 "$PID" 2>/dev/null
break
fi
sleep 1
done
rm -f "$PID_PATH_NAME"
echo "$SERVICE_NAME stopped ..."
}
case "$1" in
run)
cd "$SERVICE_PATH" || exit 1
exec "$JAVA_PATH" -classpath "$JAVA_CLASS_PATH" $JAVA_OPT "$JAVA_MAIN_CLASS"
;;
start)
start_service
;;
stop)
stop_service
;;
restart)
stop_service
start_service
;;
status)
if is_running; then
echo "$SERVICE_NAME is running."
echo "pid = $(cat "$PID_PATH_NAME")"
else
echo "$SERVICE_NAME is not running."
fi
;;
*)
echo "$SERVICE_NAME Service..."
echo "Usage: $0 { run | start | stop | restart | status } { log }"
exit 1
;;
esac
크게 네 덩어리로 나뉩니다. 상단의 설정 블록(경로·JVM 옵션 변수), 사전 점검 블록(디렉토리 생성, Java 실행 파일 존재 확인), 함수 블록(is_running, start_service, stop_service), 그리고 마지막 디스패처 블록(case "$1" in ...)입니다. 새 서버에 옮길 때 손댈 부분은 사실상 상단 변수 몇 줄뿐이며, 나머지는 템플릿 그대로 재사용하면 됩니다. 아래에서 각 블록을 차례로 살펴보겠습니다.
1. 셔뱅과 파일 디스크립터 한도
#!/bin/sh
ulimit -n 65536
#!/bin/sh는 POSIX 호환 셸로 실행하겠다는 선언입니다. #!/bin/bash로 바꿔도 되지만, /bin/sh로 작성해두면 Alpine·BusyBox 같은 경량 이미지에서도 동일하게 동작합니다.
ulimit -n 65536은 프로세스가 열 수 있는 파일 디스크립터 수를 65,536개로 늘립니다. Java 서비스가 동시에 많은 소켓·파일을 열 때 Too many open files 에러를 예방하기 위한 설정입니다. 시스템 전체 한도는 /etc/security/limits.conf에서 따로 조정해야 한다는 점만 기억해두세요.
2. classpath와 JVM 옵션
JAVA_CLASS_PATH="${SERVICE_PATH}:${SERVICE_PATH}/bin:${SERVICE_PATH}/conf:${SERVICE_PATH}/lib/*:${SERVICE_PATH}/dependency/*"
JAVA_MAIN_CLASS="moa.service.pms.MainService"
JAVA_OPT="-Dfile.encoding=UTF-8 \
-Dlog4j.configurationFile=${SERVICE_PATH}/conf/log4j2.xml \
-Djava.security.egd=file:/dev/./urandom \
-Xms256m -Xmx512m"
| 항목 | 설명 |
|---|---|
lib/*, dependency/* | 와일드카드는 JVM이 해석해 디렉토리 내 모든 JAR을 classpath에 추가 |
-Dfile.encoding=UTF-8 | 시스템 로케일과 무관하게 UTF-8로 파일을 읽고 쓴다 |
-Dlog4j.configurationFile | Log4j2 설정 파일 경로 지정 |
-Djava.security.egd=file:/dev/./urandom | /dev/random 블로킹 회피. Tomcat 기동 지연 방지에 필수 |
-Xms256m -Xmx512m | 힙 초기/최대 크기. Xms = Xmx로 맞추면 GC 변동이 줄어듭니다 |
JAVA_OPT는 따옴표로 감싸지 않고 변수 확장 시점에 공백으로 분리되도록 둡니다. 공백이 포함된 옵션이 있다면 배열(JAVA_OPT=(... ))로 바꾸는 편이 안전합니다.
3. PID 기반 중복 실행 방지
PID_PATH_NAME="${SERVICE_PATH}/proc/pms.pid"
is_running() {
if [ -f "$PID_PATH_NAME" ]; then
PID=$(cat "$PID_PATH_NAME")
if [ -n "$PID" ] && kill -0 "$PID" 2>/dev/null; then
return 0
fi
fi
return 1
}
운영 스크립트의 핵심은 “지금 이 서비스가 살아있는가?” 를 한 줄로 판단하는 것입니다. PID 파일만으로는 부족합니다. 프로세스가 비정상 종료되면 PID 파일은 남아도 프로세스는 없는 stale PID 상태가 되기 때문입니다.
kill -0 "$PID"는 실제로 신호를 보내지 않고 프로세스 존재 여부만 확인하는 관용구입니다. 살아있으면 exit 0, 없으면 non-zero를 반환하므로 stale PID를 자동으로 걸러낼 수 있습니다.
4. start: nohup + PID 기록
start_service() {
echo "Starting $SERVICE_NAME ..."
if is_running; then
echo "$SERVICE_NAME is already running ..."
echo "pid = $(cat "$PID_PATH_NAME")"
return 0
fi
rm -f "$PID_PATH_NAME"
cd "$SERVICE_PATH" || exit 1
nohup "$JAVA_PATH" \
-classpath "$JAVA_CLASS_PATH" \
$JAVA_OPT \
"$JAVA_MAIN_CLASS" \
> "$SERVICE_LOG" 2>&1 &
PID=$!
echo "$PID" > "$PID_PATH_NAME"
sleep 1
if kill -0 "$PID" 2>/dev/null; then
echo "$SERVICE_NAME started ..."
echo "pid = $PID"
else
echo "$SERVICE_NAME failed to start. Check log: $SERVICE_LOG"
rm -f "$PID_PATH_NAME"
exit 1
fi
}
핵심 포인트 세 가지입니다.
- nohup + & 조합으로 터미널이 끊겨도 계속 실행되는 백그라운드 프로세스를 만듭니다.
> "$SERVICE_LOG" 2>&1로 표준출력과 표준에러를 한 파일로 묶습니다. $!는 가장 최근에 백그라운드로 띄운 프로세스의 PID입니다. 바로 PID 파일에 저장해야 다음 번is_running판단이 가능합니다.sleep 1후kill -0으로 1초 뒤에도 살아있는지 확인합니다. classpath 오류나 포트 충돌로 즉시 죽는 경우를 잡아내는 최소한의 헬스체크입니다.
$SERVICE_LOG의 기본값은 /dev/null이며, ./run.sh start log로 두 번째 인자를 log로 넘기면 RUN_LOG 파일에 기록합니다. 운영 로그는 Log4j2가 별도 파일로 남기므로, 기본값은 /dev/null로 두고 stdout은 장애 재현 시에만 켜는 패턴입니다.
5. stop: SIGTERM → SIGKILL 단계적 종료
stop_service() {
if ! is_running; then
echo "$SERVICE_NAME is not running ..."
rm -f "$PID_PATH_NAME"
return 0
fi
PID=$(cat "$PID_PATH_NAME")
echo "$SERVICE_NAME stopping ..."
kill "$PID"
COUNT=0
while kill -0 "$PID" 2>/dev/null; do
COUNT=$((COUNT + 1))
if [ "$COUNT" -ge 10 ]; then
echo "$SERVICE_NAME did not stop gracefully. Killing forcefully ..."
kill -9 "$PID" 2>/dev/null
break
fi
sleep 1
done
rm -f "$PID_PATH_NAME"
echo "$SERVICE_NAME stopped ..."
}
kill PID는 기본적으로 SIGTERM(15) 을 보냅니다. JVM은 이 신호를 받으면 셧다운 훅을 실행하고 정상 종료합니다. 아직 처리 중인 DB 트랜잭션, 메시지 큐 ACK, Log4j 버퍼 플러시가 모두 이 타이밍에 마무리됩니다.
하지만 셧다운 훅이 블로킹되거나 데드락에 빠지면 프로세스가 영영 내려가지 않을 수 있습니다. 그래서 1초 간격으로 10번까지 대기한 뒤에도 살아있으면 kill -9(SIGKILL)로 강제 종료합니다. 숫자 10은 서비스 특성에 맞게 조정하세요. 큐 처리 시간이 긴 배치 서비스는 30~60 정도로 올리는 게 안전합니다.
6. status와 dispatch
case "$1" in
run)
cd "$SERVICE_PATH" || exit 1
exec "$JAVA_PATH" -classpath "$JAVA_CLASS_PATH" $JAVA_OPT "$JAVA_MAIN_CLASS"
;;
start) start_service ;;
stop) stop_service ;;
restart) stop_service; start_service ;;
status)
if is_running; then
echo "$SERVICE_NAME is running."
echo "pid = $(cat "$PID_PATH_NAME")"
else
echo "$SERVICE_NAME is not running."
fi
;;
*)
echo "$SERVICE_NAME Service..."
echo "Usage: $0 { run | start | stop | restart | status } { log }"
exit 1
;;
esac
명령어 디스패처입니다. 각 케이스의 의미를 정리합니다.
| 명령 | 동작 | 언제 쓰나 |
|---|---|---|
run | 포그라운드에서 exec로 실행 | 도커 컨테이너의 ENTRYPOINT, systemd Type=simple |
start | nohup 백그라운드 실행 | SSH 접속한 VM에서 수동 기동 |
stop | SIGTERM → 10초 후 SIGKILL | 배포 직전 안전 종료 |
restart | stop → start 순차 실행 | 설정 변경 후 재기동 |
status | PID 존재·프로세스 살아있음 확인 | 모니터링, 헬스체크 |
run 케이스에서 쓴 exec는 셸을 새 프로세스로 덮어쓰는 명령입니다. 중간에 셸이 끼어있지 않아야 systemd/Docker가 JVM 프로세스를 직접 관리할 수 있어, docker stop이 JVM에 그대로 SIGTERM을 전달합니다.
7. 사용 예시
# 실행 권한 부여
chmod +x /sw/pms/run.sh
# 기본 시작 (로그는 /dev/null)
/sw/pms/run.sh start
# 표준출력까지 파일로 남기며 시작
/sw/pms/run.sh start log
# 상태 확인
/sw/pms/run.sh status
# PMS is running.
# pid = 23145
# 재기동
/sw/pms/run.sh restart
# 종료
/sw/pms/run.sh stop
systemd와의 조합 전략
run.sh를 이미 작성했다면, systemd에 등록할 때는 run 하위 명령을 ExecStart에 그대로 사용하는 게 가장 깔끔합니다. 포그라운드 실행이 필요하고 PID 관리를 systemd가 맡기 때문에 PID 파일이 이중으로 관리되는 문제가 사라집니다.
# /etc/systemd/system/pms.service
[Unit]
Description=PMS Service
After=network.target
[Service]
Type=simple
User=pms
WorkingDirectory=/sw/pms
ExecStart=/sw/pms/run.sh run
Restart=on-failure
RestartSec=5
LimitNOFILE=65536
[Install]
WantedBy=multi-user.target
반대로 systemd가 없는 환경(구형 SysVinit, 크론, 간단한 VM)에서는 start/stop만으로 충분합니다. 두 가지 운영 방식을 하나의 run.sh로 커버할 수 있다는 점이 이 패턴의 가장 큰 장점입니다.
실전 팁과 주의사항
- root로 실행하지 마세요. 서비스 전용 계정(
pms등)을 만들고chown -R pms:pms /sw/pms후 해당 계정으로 실행합니다. 보안 사고 시 피해 범위가 줄어듭니다. - JDK 경로는 절대경로로 고정합니다.
PATH의java를 쓰면 셸 환경에 따라 다른 버전이 선택될 수 있어 재현 불가능한 버그의 원인이 됩니다. - PID 파일 디렉토리 쓰기 권한을 미리 확인하세요.
/var/run은 재부팅 시 초기화되므로 애플리케이션 디렉토리($SERVICE_PATH/proc)에 두는 편이 운영상 안전합니다. - JVM 옵션 튜닝은
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=$SERVICE_PATH/logs를 기본으로 넣어두면 OOM 사후 분석이 가능합니다. - graceful shutdown 타임아웃은 서비스가 가장 오래 걸릴 수 있는 작업(예: 배치 한 사이클)보다 길게 잡습니다. 너무 짧으면 데이터 유실 위험이 있습니다.
이 템플릿을 복사해서 SERVICE_NAME, SERVICE_PATH, JAVA_PATH, JAVA_MAIN_CLASS만 바꾸면 대부분의 Java 서비스에 그대로 적용할 수 있습니다. 운영 스크립트는 한 번 잘 만들어두면 수년간 재사용되는 자산이니, 팀 표준으로 정착시켜 두는 것을 추천합니다.