5편에서 3층 자동 시작과 10초 주기 감시 데몬을 만들었다. 하루 뒤 상태를 점검한 결과는 이렇다.
=== Galaxy Note FE Service Status ===
Blog Server: [STOPPED]
ngrok Tunnel: [STOPPED]블로그도 터널도 꺼져 있었고, 열려 있는 포트는 SSH(8022) 하나뿐이었다. 이 글은 원인 분석과 재설계 기록이다.
원인 1: proot 세션과 함께 사라진 프로세스
proot-distro login은 proot를 --kill-on-exit로 실행한다. 처음 실행한 명령(대화형 셸)이 끝나면 그 안에 남은 프로세스를 모두 정리한다는 뜻이다(3편 참고).
전날의 서비스들은 작업 도중 proot 안에서 띄운 것이었다. 세션이 닫히는 순간 함께 사라졌고, 감시 데몬도 같은 세션 안에 있었으므로 같이 죽었다. 아무도 다시 띄우지 않았다.
감시자가 감시 대상과 같은 운명을 공유하면 감시 구조가 아니다.
원인 2: 터미널 프로세스 그룹에 묶인 서비스
점검 중 SSH 접속으로 자동 시작 훅이 걸려 서비스가 다시 떴다. 그런데 프로세스 정보가 이랬다.
PID PPID PGID SID TTY CMD
21945 21944 21945 21945 pts/1 bash -l ← 사람이 쓰는 SSH 로그인 셸
21961 21957 21945 21945 pts/1 start_services.sh start-daemon
21963 21961 21945 21945 pts/1 python3 serve_blog.py
22040 21961 21945 21945 pts/1 ngrok http 8080감시 데몬과 두 서비스가 모두 사람이 쓰는 터미널(pts/1)과 같은 프로세스 그룹에 있다. 터미널에서 Ctrl+C를 누르면 포그라운드 프로세스 그룹 전체에 SIGINT가 전달된다.
실제로 누가 죽는지는 /proc/<pid>/status의 시그널 마스크로 확인할 수 있다.
| 프로세스 | SIGHUP | SIGINT | 결과 |
|---|---|---|---|
| 감시 데몬 (bash) | 무시 | 무시 | 생존 |
| 블로그 서버 (python) | 무시 | 무시 | 생존 |
| ngrok (Go) | 무시 | 처리 | 종료 |
nohup으로 띄우면 SIGINT가 무시로 설정되어 자식에게 상속된다. 그러나 Go 런타임은 프로그램이 signal.Notify로 신호를 구독하면 무시 설정을 되돌린다. ngrok은 정상 종료 처리를 위해 SIGINT를 구독하므로 ngrok만 죽는다.
로그에 흔적이 남아 있었다.
t=2026-09-17T13:39:44+0000 lvl=info msg="started tunnel" url=https://daringly-marrow-penny.ngrok-free.dev
t=2026-09-17T13:39:54+0000 lvl=info msg="INFO received stop request obj=app stopReq=\"{err:<nil> restart:false}\""터널이 붙은 지 10초 만에 “정상 종료 요청"을 받고 멈췄다. 크래시가 아니라 신호다. 1초 뒤 감시 데몬이 이렇게 기록했다.
[2026-09-17 13:39:55] [CRASH DETECTED] Restarting ngrok Tunnel...5편에서 자가 치유의 증거로 인용한 줄이다. 실제로는 설계 결함을 10초마다 덮고 있던 흔적이다.
원인 3: pgrep -f 패턴의 오탐
자동 시작 훅과 감시 루프는 이렇게 판정했다.
pgrep -f "serve_blog.py"-f는 명령줄 전체에서 문자열을 찾는다. nano serve_blog.py로 파일을 열어 두기만 해도, 그 이름이 포함된 명령을 실행 중이어도 “서버가 살아 있다"고 판정한다. 서버가 죽어 있어도 자동 시작이 건너뛰어진다.
원인 4: 대시보드 데이터 출처
CPU 사용률이 항상 0.0%였다. 코드가 아니라 데이터 출처의 문제다. proot 환경의 /proc/stat은 내용이 변하지 않는 가짜 파일이다. 가동 시간은 늘 “2분”, 부하는 늘 “0.12"였다. 서버가 멀쩡한지 보려고 만든 대시보드가 생사와 무관한 숫자를 표시하고 있었다.
같은 계열의 문제가 배터리에도 있었다. 배터리 값은 termux-battery-status로 읽고 있었는데, 이 명령은 Termux:API 앱에 브로드캐스트를 보내고 응답을 기다린다. 터미널을 닫아 앱이 정리되면 응답이 오지 않아 배터리 표시가 사라진다. 게다가 이 호출은 4초마다 app_process로 안드로이드 VM을 새로 띄운다. 배터리를 보려고 배터리를 쓰는 구조였다.
세 값 모두 커널이 직접 노출하는 경로로 바꿨다.
| 값 | 기존 | 교체 |
|---|---|---|
| 코어별 사용률 | /proc/stat (가짜) | /sys/devices/system/cpu/cpu*/cpuidle/state*/time |
| 가동 시간 | /proc/uptime (가짜) | clock_gettime(CLOCK_BOOTTIME) |
| 부하 | /proc/loadavg (가짜) | sysinfo(2) |
| 배터리 | Termux:API 앱 | /sys/class/power_supply/battery/* |
배터리 sysfs 값은 Termux:API가 주던 값과 정확히 일치했다(용량 79%, Discharging, temp 300 = 30.0°C, Good). 앱이 떠 있든 말든 읽히고, 프로세스를 하나도 만들지 않는다.
재설계 원칙
부검 결과를 두 문장으로 줄이면 이렇다.
- 서비스는 proot 세션 밖에서 태어나야 한다.
- 서비스는 어떤 터미널에도 매여 있으면 안 된다.
Termux 쪽 런처 하나로 입구 통일
서비스를 띄우는 입구를 Termux(= proot 밖)에서 도는 스크립트 하나로 모았다.
#!/data/data/com.termux/files/usr/bin/sh
# 이미 돌고 있으면 아무것도 하지 않고, 아니면 분리된 세션으로 감시 데몬을 띄운다
[ -e "$ROOTFS/root/.services_disabled" ] && exit 0
... PID 파일 확인 ...
setsid nohup proot-distro login ubuntu -- /root/start_services.sh start-daemon \
</dev/null >>"$LOG" 2>&1 &핵심은 setsid다. 새 세션을 만들어 제어 터미널을 떼어 낸다. 터미널이 없으면 Ctrl+C도 로그아웃 신호도 도달하지 못한다. 교체 후 확인한 값이다.
pid=24335 sid=24321 tty_nr=0 tpgid=-1tty_nr=0, tpgid=-1은 연결된 터미널이 없다는 뜻이다.
proot 안에서 밖으로 나가는 방법
문제가 하나 남는다. 작업은 proot 안에서 한다. 안에서 실행한 모든 것은 proot의 자식이 되므로, 안에서는 “밖에서 태어난 프로세스"를 만들 수 없다.
통로는 Termux:API의 작업 예약이었다.
termux-job-scheduler --job-id 4242 --script <런처 경로>안드로이드 JobScheduler가 Termux 앱 프로세스에서 스크립트를 실행한다. 정말 밖에서 실행되는지 확인하려고 자기 상태를 파일로 적는 스크립트를 예약해 봤다.
ran_at=23:31:01
TracerPid: 0 ← proot가 추적하지 않음 = 바깥
sid=0 tty_nr=0예약 후 약 4초 만에 실행됐고 TracerPid가 0이었다. 이 경로 덕분에 proot 안에서 실행하는 start_services.sh start도 데몬을 바깥에서 띄울 수 있다.
함정이 하나 있다. 제약 조건을 모두 끄고(--network none --battery-not-low false) 일회성 작업을 예약하면 영원히 실행되지 않는다. 안드로이드 JobScheduler는 조건이 하나도 없는 작업을 거부한다. 네트워크 조건을 기본값으로 두면 정상 실행된다.
감시 데몬 재작성
pgrep 의존을 걷어내고 다시 만들었다.
- 단일 실행 보장:
flock으로 잠근다. 두 번째 인스턴스는 스스로 물러난다. 잠금 파일 디스크립터는 자식에게 물려주지 않는다(9>&-). 그러지 않으면 데몬이 죽어도 자식이 잠금을 쥐고 있어 새 데몬이 뜨지 못한다. - 자식 추적은 PID로: 직접 띄운 프로세스의 PID를 기억하고
/proc/<pid>/stat으로 생존과 부모를 확인한다. 이름 매칭이 아니므로 편집기에 속지 않는다. - 크래시 루프 완충: 5분 안에 다시 죽으면 재시작 간격을 10초 → 20초 → … → 최대 5분으로 늘린다.
- 요청 한 번으로 교체:
restart명령이 남긴 표시를 보면 두 서비스를 갈아 끼운다. 처음에는SIGUSR1신호로 만들었다가 갈아엎었다. 사연은 아래에 있다. - 정지의 의미 보존:
stop은.services_disabled표시를 남긴다. 이 표시가 있으면 부팅도, 로그인도, 감시 작업도 서버를 되살리지 않는다. - 로그 순환: 10MB를 넘으면 잘라 낸다.
명령줄 패턴은 전부 시작을 고정했다.
BLOG_PATTERN='^python3 (-u )?/root/serve_blog\.py'
NGROK_PATTERN='^/usr/local/bin/ngrok http '복구 계층
| 층 | 주기 | 복구 대상 |
|---|---|---|
| 감시 데몬 루프 | 10초 | 블로그·ngrok |
| JobScheduler 감시 작업 | 15분 | 감시 데몬 자신 |
| Termux 로그인 훅 | 로그인마다 | 감시 데몬 |
| Termux:Boot | 재부팅 시 | 전부 + wake-lock + sshd |
5편과 비교하면 한 층이 늘었다. 감시자를 감시하는 층이다. 전에는 감시 데몬이 죽으면 아무도 몰랐다.
테스트에서 드러난 문제 세 가지
하나. 명령어가 자기 자신을 죽였다.
테스트 서버를 정리하려고 쓴 명령이다.
pkill -f 'BLOG_PORT'명령은 성공했고 동시에 셸이 종료됐다. pkill -f는 명령줄 전체를 보는데, 그 문자열이 포함된 실행 중인 명령 자체가 조건에 걸렸다. 원인 3에서 지적한 함정을 몇 시간 뒤에 그대로 밟았다. 실서비스는 무사했다. 패턴이 ^python3 …로 고정돼 있었기 때문이다.
둘. PID가 한 바퀴 돌았다.
실서비스를 건드리지 않으려고 가짜 서비스를 띄우는 사본으로 시험했는데, 테스트용 감시 데몬의 로그가 이랬다.
[2026-09-17 15:00:54] [DAEMON] Supervisor started (pid 32767)
[2026-09-17 15:00:57] [START] blog (pid 329)
[2026-09-17 15:00:57] [START] ngrok (pid 331)부모가 32767, 자식이 329다. 이 기기의 PID 상한이 32768이라 번호가 한 바퀴 돌았다. 따라서 “죽은 자식의 PID로 kill -9를 보내는” 코드는 실제 위험이다. 몇 초 사이에 그 번호를 다른 프로세스가 가져갈 수 있다. 살아 있는 자기 자식에게만 신호를 보내도록 고쳤다.
셋. 재시작 신호가 배달되지 않았다.
restart 명령은 감시 데몬에게 SIGUSR1을 보내 서비스를 교체하게 했다. 처음에는 동작했지만 어느 시점부터 이렇게 나왔다.
[RESTART] Asking supervisor (PID 24335) to restart the blog server and ngrok...
[WARN] Blog server not back after 30s; see /root/daemon.log and /root/blog_server.log데몬은 살아 있고 트랩도 설치돼 있는데 아무 일도 일어나지 않았다. 신호 상태를 직접 읽었다.
SigBlk: 0000000000011204 ← USR1(0x200)이 차단 목록에 있음
ShdPnd: 0000000000000200 ← 보낸 USR1이 배달되지 못하고 대기 중신호는 도착했지만 차단된 채 대기열에 쌓여 있었다. 차단 마스크의 출처는 상위 프로세스다. 부모인 proot의 마스크에도 같은 값이 있고, 그 proot는 Termux 앱 프로세스에서 태어났다. 안드로이드 런타임은 SIGQUIT, SIGUSR1, SIGPIPE를 자기 용도로 쓰는데, 신호 마스크는 fork와 exec을 넘어 상속된다. 즉 안드로이드 앱에서 시작된 프로세스 계보 전체가 USR1을 받지 못한다.
신호 대신 파일을 쓰는 방식으로 바꿨다.
# 요청하는 쪽
: > /root/.restart_requested
# 감시 데몬: 10초마다 도는 루프에서 확인
if [ -n "$RESTART_REQUESTED" ] || [ -e "$RESTART_FLAG" ]; then
rm -f "$RESTART_FLAG"
...서비스 교체...
fi즉시성은 잃었다(최대 10초). 대신 플랫폼이 무엇을 예약해 두었든 상관없이 동작한다. SIGTERM은 차단되지 않으므로 stop 경로는 신호를 그대로 쓴다.
제어 경로를 플랫폼이 손대는 자원 위에 올리면 안 된다. 일반 리눅스에서 오래 쓰던 관례가 여기서는 조용히 실패하고, 에러 메시지도 남지 않는다.
교차 검증
이 폰에는 Antigravity CLI(agy)도 설치돼 있다. 같은 기기에서 도는 다른 회사 모델(Gemini)에게 읽기 전용으로 변경분 리뷰를 맡겼다.
agy -p "<리뷰 프롬프트>" --mode plan6건이 돌아왔고 5건을 반영했다. 배터리가 부족할 때 기동 요청이 보류되는 문제, 떠돌이 프로세스 종료를 확인하지 않는 문제, 크래시 판정(60초)과 백오프 리셋(300초) 기준이 어긋난 문제, PID 파일 쓰기의 경쟁 조건, 로그인 훅이 백그라운드로 보낸 요청이 세션 종료와 함께 사라지는 문제였다.
1건은 반박했다. “요청 타임아웃마다 트레이스백이 쌓인다"는 지적이었는데, 파이썬 3.14의 http/server.py는 읽기·쓰기 타임아웃을 모두 내부에서 처리했고 30초 유휴 연결 테스트에서도 로그가 깨끗했다.
다른 모델에게 같은 코드를 읽히는 것은 값싼 검증이다. 다만 상대에게 파일 수정 권한을 주지 않고, 끝난 뒤 git status로 아무것도 건드리지 않았는지 확인해야 한다.
결과
Supervisor: [RUNNING] (PID 31737) - detached
Blog Server: [RUNNING] (PID 1192) - port 8080, HTTP 200
ngrok Tunnel: [RUNNING] (PID 1194) - https://daringly-marrow-penny.ngrok-free.dev
Auto-start: [ENABLED]- 구조 교체 중 블로그 중단: 약 7초
restart명령으로 코드 반영: 약 10초 (플래그를 감시 루프가 집어가는 시간)stop후start로 데몬 교체: 약 8초- 교체 후 9시간 무인 운영, 재시작 0회 (PID가 그대로였다)
- 대시보드는 코어별로 다른 실제 사용률을 표시한다
웹 서버도 함께 고쳤다. 요청을 한 번에 하나씩 처리하던 구조를 스레드 방식으로 바꾸고 30초 요청 타임아웃을 넣었다. 응답을 받다 멈춘 방문자 하나가 전체를 막던 문제가 사라졌다.
남은 과제
- 배터리. 충전기가 빠진 채로 돌고 있으면 시간당 2.6%씩 줄어 하루 만에 꺼진다. 지금까지 만든 복구 장치 전부가 케이블 하나를 이기지 못한다.
- ngrok 경고 페이지. 무료 플랜인 이상 브라우저 방문자는 한 번 더 클릭해야 한다.
- 기기 밖 백업. 코드와 글을 git으로 관리하지만 저장소가 이 폰 안에 있다. 폰이 사라지면 함께 사라진다.
정리
- 감시자를 감시하는 층이 없으면 감시 구조가 아니다. 감시 데몬이 죽는 경우가 가장 먼저 검증할 시나리오다.
- 프로세스가 어디에서 태어나는지가 얼마나 튼튼한지보다 중요하다. 잘못된 세션에서 태어난 프로세스는 세션과 함께 사라진다.
pkill -f는 자기 자신도 잡는다. 패턴은 항상 시작을 고정한다.- 관측 값이 가짜면 없느니만 못하다. 0%를 계속 표시하던 대시보드는 서버가 죽은 순간에도 0%였다.
- 테스트는 “죽여 보기"에서 끝나면 안 된다. 로그아웃, Ctrl+C, 감시자 종료, 재부팅까지 시험해야 한다.
스마트폰으로 서버를 만드는 일은 쉬웠고, 그것이 보지 않는 동안에도 살아 있게 만드는 일은 어려웠다.