이 글은 ‘폰 서버 안정화 일지’ 시리즈의 첫 번째다. 재부팅만 하면 절반쯤의 서비스가 안 올라오던 시절, 원인이 부팅 스크립트의 한 줄이었다는 이야기와, 그걸 고치려고 만든 첫 워치독이 거꾸로 건강한 봇을 매번 죽이던 이야기다.
증상: 재부팅 뒤 Claude 세션이 안 뜬다
폰을 재부팅하면 카카오톡 봇은 살아나는데 Claude Code 세션과 터널 몇 개가 안 올라오는 날이 반복됐다. 재부팅 직후 tmux 세션 목록을 보면 iris만 있고 claude, cloudflare, blogadmin 같은 세션이 없었다. 하루는 수동으로 띄우고 넘어갔지만, 같은 일이 세 번째 반복되자 부팅 스크립트를 처음부터 끝까지 읽었다.
원인: 동기로 실행되는 한 줄
start-iris.sh 앞부분에 이런 줄이 있었다.
proot-distro login ubuntu -- bash -lc 'service cron start; pm2 resurrect'
Ubuntu 컨테이너 안의 cron 데몬을 올리고 pm2 프로세스를 복구하는 줄이다. 문제는 이 줄이 끝날 때까지 bash가 다음 줄로 내려가지 않는다는 점이었다. 재부팅 직후엔 기기가 불안정해서 이 명령이 가끔 멈춘다. 실제로 재부팅 직후 멈춰 있는 인스턴스를 ps로 직접 확인했다. 기기가 안정된 뒤 같은 명령을 치면 정상 종료되니 재현은 안 되는 경합이다. 그런데 이 줄이 멈추면 그 아래 적힌 iris, claude, cloudflare, remote, hts, blogadmin, blogcaddy, blogtunnel, phoneserver, rummycube 기동 줄이 통째로 한 번도 실행되지 않는다. 단일 장애점이 스크립트 18번째 줄에 있었다.
고친 건 글자 하나다.
proot-distro login ubuntu -- bash -lc 'service cron start; pm2 resurrect' >/dev/null 2>&1 &
끝에 &를 붙여 백그라운드로 보냈다. 이제 이 줄이 멈추더라도 그날 cron과 pm2 복구만 안 되고, 나머지 세션은 전부 정상 기동한다. 교훈은 단순하다. 부팅 스크립트의 각 단계는 비동기여야 하고, 멈출 수 있는 명령에는 타임아웃을 둬야 한다.
같은 날 발견한 두 번째 버그: PORT가 빠졌다
같은 스크립트에서 체결 기록기 웹앱을 띄우는 줄에 환경변수 PORT가 없었다. 바로 위 주석에는 어느 포트를 쓴다고 적혀 있는데 실제 명령엔 지정이 빠져 있었다. 그래서 이 서버는 재부팅마다 기본 포트, 즉 봇 브리지가 이미 쓰는 포트에 바인딩을 시도했다. 수동으로 재시작해 보니 로그에 EADDRINUSE가 찍혔고, 거기서 거슬러 올라가 부팅 스크립트를 찾았다. 터널이 그 포트로 연결되는 외부 주소는 재부팅마다 깨져 있었던 것이다. 기동 줄에 PORT= 지정을 넣어 영구적으로 고쳤다.
첫 워치독, 그리고 오탐
재부팅 때마다 사람이 확인하는 건 지속 가능하지 않아서 장치 수준 워치독을 만들었다. Ubuntu 안 cron에 */5 * * * * /root/watchdog.sh를 걸고, 서비스마다 curl로 HTTP 응답을 보거나(502, 503, 504, 530, 000만 비정상으로 판정) pgrep과 pm2 jlist로 프로세스를 확인한 뒤, 죽었으면 부팅 스크립트와 똑같은 명령을 tmux send-keys로 해당 창에 다시 쏘는 구조다. 로그는 /root/watchdog.log에 2000줄까지만 남긴다.
그런데 첫 버전이 매번 카카오톡 봇을 재시작했다. 봇은 멀쩡했다. 원인은 Iris 데몬을 확인하는 조건 pgrep -f "iris_control_termux"였다. iris_control_termux는 실제 데몬을 띄운 뒤 종료하는 런처 스크립트라, 정상 상태에서는 이 이름의 프로세스가 아예 없다. 워치독은 매 5분마다 “죽었다"고 판정하고 건강한 봇을 재시작한 것이다. 이 조건을 통째로 지우고 pgrep -f "python3 irispy\.py" 하나만 남겼다. 나중에 이 판정도 “프로세스는 있는데 서비스는 죽은” 상태를 못 잡는 걸 알게 되지만, 그건 시리즈 세 번째 글에서 다룬다.
정리
- 부팅 스크립트에서 동기로 실행되는 외부 명령은 단일 장애점이다.
&로 분리하고, 가능하면 타임아웃을 건다. - 환경변수 하나가 빠진 기동 줄은 재부팅마다 같은 장애를 만든다. 로그의
EADDRINUSE는 “누가 먼저 썼나"보다 “왜 기본값으로 떴나"를 먼저 묻게 한다. - 워치독의 판정 조건은 “정상 상태에서 그 조건이 참인가"를 먼저 확인해야 한다. 런처처럼 종료하는 프로세스를
pgrep으로 찾으면 영원히 오탐이다.
🤖 이 포스팅은 AI의 도움을 받아 작성되었습니다.


💬 댓글