정기 점검으로 서버를 전부 내렸다 올렸다. 다 올렸는데 게임 접속이 안 됐다. 로그를 보니 게임 서버가 캐시 서버를 못 찾고 Connection refused 를 내고 있었고 캐시 서버는 나중에 올라왔다.
Table of contents
Open Table of contents
순서가 정해져 있지 않았다
올리는 방법이 사람마다 달랐다. 스크립트가 있긴 한데 서비스별로 따로 있고 어떤 순서로 부르는지는 정해져 있지 않았다.
start-web.sh
start-game.sh
start-worker.sh
start-cache.sh
이번엔 알파벳 순으로 돌렸고 캐시가 마지막이었다. 그동안 문제가 없었던 것은 우연히 순서가 맞았기 때문이다.
게임 서버가 뜰 때 캐시가 아직 없으니 연결에 실패하고 잘못된 상태로 남았다. 프로세스는 살아 있는데 제 역할을 못 하는 상태였다.
의존 관계를 적었다
무엇이 무엇에 기대는지 확인했다. 각 서비스 설정에서 접속하는 대상을 뽑았다.
web → 데이터베이스, 캐시
game → 데이터베이스, 캐시, 채팅
worker → 데이터베이스, 캐시, 외부 API
chat → 캐시
cache → 없음
db → 없음
기대는 것이 없는 것부터 올린다.
1. db, cache (기반)
2. chat (cache 필요)
3. game, worker, web
systemd 라면 이 관계를 After= 로 적는다. 매뉴얼은 After= 가 앞의 유닛이 완전히 올라온 뒤에 다음을 시작하게 한다고 적어 뒀다.
기다리게 만들었다
순서를 정해도 앞의 것이 떴다는 것과 받을 준비가 됐다는 것은 다르다. 프로세스가 fork 된 것과 요청을 받을 준비가 된 것 사이에 초기화 시간이 있다.
시작 스크립트에서 기다리게 했다.
wait_for() {
host=$1; port=$2; name=$3; limit=${4:-60}
i=0
while [ $i -lt "$limit" ]; do
if nc -z "$host" "$port" 2>/dev/null; then
echo "$name 준비됨 (${i}초)"
return 0
fi
i=$((i+1)); sleep 1
done
echo "$name 응답 없음 (${limit}초 초과)"
return 1
}
wait_for cache-01 6379 "캐시" 60 || exit 1
상한을 뒀다. 안 뜨면 영원히 기다리지 않고 exit 1 로 실패한다.
포트가 열린 것과 실제로 응답하는 것도 다르다. nc -z 만으로는 부족해서 캐시는 명령을 하나 보내 봤다.
redis-cli -h cache-01 ping | grep -q PONG || exit 1
PING 을 보내 PONG 이 오는지까지 봐야 실제로 받을 준비가 된 것이다.
재시도와 내리는 순서
시작 순서를 맞춰도 운영 중에 캐시가 재시작될 수 있다. 그때 게임 서버가 죽으면 안 된다.
function connectCache($tries = 5) {
for ($i = 0; $i < $tries; $i++) {
try {
$r = new Redis();
$r->connect(CACHE_HOST, CACHE_PORT, 2.0);
return $r;
} catch (Exception $e) {
log_message('warning', "캐시 접속 실패 (" . ($i+1) . "/{$tries})");
sleep(1 << $i); // 1, 2, 4, 8, 16초
}
}
return null;
}
간격을 늘려 가며 시도한다. 매번 1초로 하면 재시작 중인 상대에게 부담이 간다. 돌려주는 값이 null 이면 부르는 쪽이 판단하는데 캐시 없이도 되는 기능은 계속 가고 안 되는 기능은 오류를 낸다.
내리는 순서는 올리는 순서의 역순이다.
1. web, game, worker
2. chat
3. cache, db
나중에 올린 것을 먼저 내리면 누가 쓰고 있는 것을 내리는 상황이 안 생긴다. systemd 는 After= 를 적어 두면 내릴 때 그 역순을 알아서 적용한다고 매뉴얼에 적혀 있고 스크립트로 짠다면 그 역순을 직접 써야 한다. 내릴 때도 게임 서버에 접속자가 있으면 안내를 보내고 기다리게 했다.
각각을 순서대로 부르는 스크립트로 묶었다.
#!/bin/sh
set -e
echo "[1/3] 기반 서비스"
./start-db.sh && wait_for db-01 3306 "DB"
./start-cache.sh && wait_for cache-01 6379 "캐시"
echo "[2/3] 보조 서비스"
./start-chat.sh && wait_for chat-01 7001 "채팅"
echo "[3/3] 주 서비스"
./start-game.sh
./start-worker.sh
./start-web.sh
echo "완료"
./health-check.sh
set -e 로 중간에 실패하면 멈추고 뒤의 것을 안 올린다. 마지막에 상태 확인을 돌려서 전부 정상인지 본다.
검증 — 실패해야 하는 상태에서 돌리기
상태 확인이 통과만 하는 것인지 확인하려고 캐시를 일부러 내리고 돌렸다.
[FAIL] 캐시 응답 없음
잡는다. PING 이 응답을 못 받아 상한까지 기다렸다가 멈추는 것을 보고 나서야 그것을 믿을 수 있었다. 다시 올리고 돌리니 통과했다.
통과가 나오면 실패해야 하는 상태에서 한 번 돌려 본다. 통과하는 것만 봐서는 그 확인에 판정력이 있는지가 안 갈린다.
정리
- 무엇이 무엇에 기대는지 적어 보면 올리는 순서가 정해진다
- 그동안 문제가 없던 것은 우연히 순서가 맞았기 때문이다
systemd라면After=로 그 관계를 적는다- 프로세스가
fork된 것과 요청을 받을 준비가 된 것은 다르다 - 기다리되 상한을 두고 넘으면
exit 1로 끝낸다 nc -z로 포트만 보지 말고PING에PONG이 오는지 본다- 운영 중 재시작에 대비해 1·2·4초로 늘려 가며 다시 시도한다
- 실패 시 죽는 대신
null을 돌려주면 부르는 쪽이 판단한다 - 내리는 순서는 올리는 순서의 역순이다 —
systemd는 자동이다 - 전체를 한 스크립트로 묶고
set -e로 중간 실패에서 멈추게 한다 - 확인이 실제로 잡는지는 캐시를 안 올린 상태에서 돌려 본다