kill을 넣어놨는데 프로세스가 안 죽었다 — 고아 웹서버 19개를 치운 날

“이 서버를 개발 환경으로 더 쓸 수 있는지 조사해달라”는 요청으로 시작했다. 코드는 건드리지 않는 읽기 전용 감사였다.

그런데 첫 명령에서 전제가 깨졌다.

hostname       DESKTOP-...
Virtualization wsl
Kernel         ...-microsoft-standard-WSL2

조사 대상이라고 알고 있던 원격 서버가 아니라 내 랩탑 위 WSL2였다. “이 서버”라는 한 단어가 두 대의 머신을 가리키고 있었고, 아무도 그걸 구분하지 않은 채 몇 주를 보낸 것이다.

이게 그날 나온 문제들의 축소판이었다. 전부 같은 모양이다 — 만들어놓고 아무도 다시 안 본 것들.


1. 인증 없는 웹서버가 8일째 떠 있었다

포트를 훑다가 0.0.0.0에 바인딩된 파이썬 정적 서버가 무더기로 나왔다. 최장 8일째.

python3 -m http.server 8792   → 한 프로젝트 산출물 디렉토리
python3 -m http.server 8303   → 주간 리포트 (HTML/PDF)

브라우저로 열어봤다. HTTP 200, 인증 없음, 디렉토리 목록이 그대로 나오고 파일도 받아졌다.

전부 이미 종료된 AI 코딩 세션이 리포트 미리보기용으로 띄우고 안 끈 것이었다. 세션은 자기 작업이 끝나면 사라진다. 그때 띄운 프로세스는 사라지지 않는다. 그리고 아무도 “지금 이 머신에 뭐가 떠 있나”를 보지 않는다.

세는 것부터 틀렸다

처음 셌을 때 24개가 나왔다. 그중 두 개는 홈 디렉토리 전체를 서빙 중인 것처럼 보였다. SSH 키와 셸 설정이 웹에 열려 있다는 뜻이라 잠깐 심장이 내려앉았다.

원인은 시시했다. pgrep -f가 그 패턴 문자열을 담고 있는 내 명령어 자신을 잡고 있었다.

pgrep -f 'http.server'                # 내 셸 명령까지 매칭됨 → 24
pgrep -f '^python3 -m http\.server'   # 앵커를 걸어야 함    → 19

같은 실수를 그날 두 번 더 했다. ps -ef | grep puma도 똑같이 자기를 잡았다. 프로세스를 셀 때 앵커를 걸지 않으면 없는 위험을 보고하게 된다. 실제 숫자는 19개, 그중 16개가 부모 없는 고아(PPID=1)였다.

2. kill이 있는데도 프로세스가 남는 이유

고아가 된 경로를 추적하다 주간 리포트 생성 스크립트에서 원인을 찾았다.

(cd "$OUT" && python3 -m http.server "$PORT" >/dev/null 2>&1) &
SRV=$!
sleep 2
"$CHROME" --headless --print-to-pdf=... "http://localhost:$PORT/..."
kill "$SRV" 2>/dev/null

정리 코드가 있다. 그런데 안 듣는다.

(A && B) & 형태에서 $!는 서브셸의 PID다. python3는 그 서브셸의 자식이다. 서브셸을 죽이면 껍데기만 죽고 파이썬은 init에 입양된다. 그게 PPID=1의 정체였다.

추론으로 넘기지 않고 재현해봤다.

(cd . && python3 -m http.server 8399 >/dev/null 2>&1) &
SRV=$!
kill "$SRV"
# → 포트 8399는 여전히 살아 있음

exec를 붙이면 서브셸이 파이썬으로 치환돼 $!가 실제 PID가 된다.

(cd "$OUT" && exec python3 -m http.server "$PORT" >/dev/null 2>&1) &
SRV=$!
trap 'kill "$SRV" 2>/dev/null' EXIT INT TERM   # 중간에 죽어도 정리

trap을 같이 넣은 이유는, 헤드리스 크롬이 멈추면 kill 줄까지 도달하지 못하기 때문이다. 원래 코드에는 그 방어가 없었다.

“정리 코드를 썼다”와 “정리가 된다”는 다른 상태다. 그리고 그 차이는 몇 달 동안 조용하다.

3. 뿌리는 “아무도 안 본다”였다

프로세스를 다 정리하고 스크립트를 고쳐도, 다음 세션이 또 하나 띄우면 원점이다. 진짜 문제는 만든 게 지금 어떤 상태인지 보는 사람이 없다는 것이었다. 매일 도는 자동화 작업이 5개 있었는데, 그게 어제 돌았는지 아는 방법조차 없었다.

감시자는 감시 대상과 같은 머신에 있으면 안 된다

작업들은 랩탑에서 돈다. 감시도 랩탑에 두면, 랩탑이 꺼졌을 때 작업도 안 돌고 감시도 안 돌아서 아무 일 없는 것처럼 보인다. 가장 나쁜 실패 방식이다.

그래서 감시자는 항상 켜져 있는 원격 서버에 뒀다.

[랩탑]  작업 끝 → 신호 전송 (하루 5번, 일방향)
                        ↓
[원격]  10분마다 혼자 판정 ← 랩탑 상태와 무관
        예정 시간 + 유예가 지났는데 신호 없음 → 알림

이러면 “랩탑이 꺼져 있어서 작업이 통째로 건너뛴 것”까지 잡힌다. 이전에는 그게 조용히 넘어갔다.

&&가 아니라 ;

신호를 붙일 때 이게 갈림길이었다.

작업 && ssh ... "job-id"        # 성공했을 때만 신호가 간다
작업  ; ssh ... "job-id $?"     # 실패도 신호가 간다

&&를 쓰면 “실패”와 “미실행”을 구분할 수 없다. 둘 다 신호가 없으니까. ; + $?로 바꾸니 상태가 셋이 됐다 — 성공 / 실패(종료코드 포함) / 미실행.

파이프를 물려도 $?가 유지되는지 헷갈려서 이것도 재봤다.

(exit 3); tail -c 3000 log | ssh ... "job $?"
# → rc=3 정확히 기록됨

셸은 파이프라인을 실행하기 전에 $?를 확장한다. 그래서 앞 명령의 코드가 살아남는다.

감시자는 일부러 멍청하게

알림 채널을 대화형 에이전트로 만들지, 단방향 알림봇으로 만들지 고민했다. 이미 메신저에 붙어 있는 에이전트가 있어서 기술적으로는 어렵지 않았다.

단방향을 골랐다. 감시자가 감시 대상보다 복잡하면 감시자가 먼저 고장난다. 그러면 알림이 안 오는데 “다 잘 돌고 있나 보다”라고 착각하게 된다. 대신 알림 문자에 실패 로그 마지막 몇 줄과 상태 페이지 링크를 넣었다. 대부분은 그걸로 폰에서 판단이 끝난다.

여기서 한 번 더 걸렸다. 알림에 “마지막 로그”를 넣도록 만들었는데 테스트하면 계속 (로그 없음)이 나왔다. 당연했다 — 로그 파일은 랩탑에 있고 감시자는 원격에 있다. 원격이 랩탑 파일을 끌어오는 건 불가능했고(NAT 뒤에 있다), 그래서 신호를 보낼 때 로그를 같이 실어 보내기로 했다. 표준입력으로 넘기면 명령줄 길이 제한도 안 걸리고 받는 쪽에서 상한을 걸 수 있다.

4. 통로를 늘리지 않고 권한을 좁혔다

신호를 보내려면 랩탑 → 원격 경로가 필요한데, 새 포트를 여는 건 하고 싶지 않았다. 방금 인증 없는 포트 열몇 개를 치운 참이었으니까.

기존 SSH를 쓰되, 그 열쇠로는 딱 한 가지만 되게 했다.

command="/opt/.../beat.sh",no-port-forwarding,no-agent-forwarding,no-pty <공개키>

어떤 명령을 보내도 그 스크립트만 실행된다. 확인해봤다.

$ ssh -i <신호키> user@host "whoami"
bad job id          # ← 사용자명이 아니라 스크립트 응답

작업 이름도 등록된 것만 받도록 화이트리스트를 걸었다. 이 열쇠가 유출돼도 “작업이 돌았다”고 거짓말하는 것 외엔 아무것도 못 한다. 상태 페이지는 사설 메시 VPN 주소에만 바인딩했다. 로그인 화면도, 인증서도, 공개 도메인도 필요 없다.

배운 것

  1. 프로세스를 셀 때 앵커를 걸어라. grep/pgrep은 자기 명령어를 잡는다. 없는 위험을 보고하게 된다.
  2. (A && B) & 다음의 $!는 서브셸 PID다. exec를 붙이거나 trap으로 방어하라.
  3. 감시자는 감시 대상과 다른 머신에. 같이 죽으면 침묵이 정상처럼 보인다.
  4. ; + $?로 신호를 보내라. &&는 실패와 미실행을 뭉갠다.
  5. 감시자는 단순하게. 복잡한 감시자는 자기가 먼저 고장난다.
  6. 통로를 늘리지 말고 있는 통로의 권한을 좁혀라. forced command + 화이트리스트.

그리고 이 전부를 관통하는 하나 — AI 에이전트에게 작업을 시키면 결과물뿐 아니라 잔여물도 쌓인다. 각 세션은 자기 작업만 최적화하고 머신 전체 상태는 기억하지 못한다. 뒷정리는 어느 작업의 일부도 아니어서, 아무도 맡지 않으면 계속 쌓인다.

한계 · 다음 계획

  • 고친 건 스크립트 한 곳뿐이다. 고아를 만든 패턴은 그 스크립트에서 제거했지만, 다음 세션이 임시로 서버를 띄우는 것 자체를 막는 장치는 아직 없다. 지금은 감사 때 사람이 발견하는 구조다.
  • 감시자는 “돌았는지”만 안다. 신호에 담기는 건 종료코드와 로그 꼬리뿐이라, 작업이 0으로 끝났지만 결과가 비어 있는 경우는 못 잡는다. 산출물 자체를 검사하는 층이 따로 필요하다.
  • 상태 페이지는 VPN 안에서만 보인다. 이건 의도한 트레이드오프지만, VPN이 안 붙는 상황에서는 알림 문자만 남는다. 그래서 문자에 로그를 넣은 것이다.
  • 며칠치 데이터밖에 없다. 유예 시간이 적절한지, 오탐이 얼마나 나는지는 몇 주 더 봐야 안다.

FAQ

Q. kill "$SRV"가 왜 자식 프로세스까지 안 죽이나?
kill은 지정한 PID 하나에만 시그널을 보낸다. 프로세스 그룹 전체를 원하면 kill -- -$PGID 형태로 음수 PID를 써야 하는데, 그러려면 대상이 별도 프로세스 그룹의 리더여야 한다. 스크립트 하나를 고치는 수준에서는 exec로 서브셸을 치환해 $!가 실제 대상을 가리키게 만드는 쪽이 간단하고 실수도 적다.

Q. exec 없이 trap만 걸면 안 되나?
trap은 “언제 정리할 것인가”를 해결하고 exec는 “무엇을 정리할 것인가”를 해결한다. 둘은 다른 문제다. trap만 걸면 스크립트가 중간에 죽어도 정리 루틴이 돌긴 하지만, 그 루틴이 여전히 서브셸 PID를 죽이므로 파이썬은 남는다. 우리는 둘 다 넣었다.

Q. 하트비트 방식 말고 원격에서 직접 폴링하면 안 되나?
랩탑이 NAT 뒤에 있어서 원격이 먼저 접속할 수 없었다. 그리고 폴링은 “응답 없음”이 네트워크 문제인지 작업 실패인지 구분이 어렵다. 작업 쪽에서 종료코드를 실어 보내는 push 방식이 상태를 셋(성공·실패·미실행)으로 나눠준다.

Q. cron 작업이 실패했을 때 메일로 받으면 되지 않나?
로컬 MTA가 설정돼 있어야 하고, 무엇보다 작업이 아예 실행되지 않은 경우에는 메일도 오지 않는다. 이 글의 핵심 실패 모드가 정확히 그거였다 — 랩탑이 꺼져서 통째로 건너뛰는 상황. “예정 시간이 지났는데 신호가 없다”를 판정하려면 감시 주체가 대상 밖에 있어야 한다.

Leave a Comment