page-archive에 주기 아카이빙 기능을 붙였어요. 같은 URL을 시간 간격을 두고 여러 번 떠두는 기능이에요. 한 번 박제하는 게 아니라, 시간에 따라 변하는 모습 자체를 추적할 수 있게요.
채용공고가 어떻게 바뀌어왔는지, 정책 페이지에 어떤 문구가 슬쩍 들어가고 빠지는지. 그런 게 보고 싶어졌거든요.
그런데 이걸 붙이면서, 평소엔 슬쩍 넘기던 문제가 표면으로 올라왔어요.
좀비
원래 page-archive는 즉시 아카이빙 요청이 들어오면 메인 앱 컨테이너 안에서 직접 puppeteer를 띄워 처리했어요. 사람이 가끔 한 번씩 누르는 정도라 별 문제가 없었는데, 시간이 지나면서 자식 크롬 프로세스가 가끔 정리 안 되고 남는 게 보이기 시작하더라고요. 며칠에 한두 개씩, 천천히. “언젠가 청소 코드 더 단단하게 짜야지” 하고 미뤄두던 거였어요.
그러다 주기 아카이빙이 들어왔어요. 사람이 누르는 게 아니라, 1분마다 자동으로 puppeteer가 켜지는 환경. 어느 날 ssh로 들어가서 ps를 쳤다가 멈칫했어요. 좀비가 한참 쌓여 있더라고요. “이거 더는 못 두겠다.”
일단 워커로 떼어보기
처음 시도는 단순했어요. 크롤링 작업을 별도 워커 컨테이너로 분리. 메인 앱은 깔끔해질 거고, 좀비는 워커 쪽에만 쌓일 테니 적어도 격리는 되겠지 싶었거든요.
그런데 결국 똑같았어요. 워커 컨테이너 안에서도 좀비가 같은 속도로 쌓이더라고요. 24시간 떠 있는 컨테이너에서 puppeteer를 계속 돌리는 한, 어디서 돌리든 같은 자리로 돌아오는 거였어요. 자리만 옮겼지 문제는 그대로였어요.
집을 매번 새로 짓자
청소 코드를 더 단단하게 만드는 길도 있었어요. puppeteer 옵션을 손보고, browser.close() 호출을 try/finally로 감싸고. 그런데 그건 “이번엔 잘 잡혔으면 좋겠다”는 도박이고, 다음 버전에서 또 새로운 누수가 안 생긴다는 보장도 없었어요.
그래서 방향을 바꿨어요. 좀비를 청소하는 대신, 집을 매번 새로 짓기로.
워커를 1회 실행 모드로 만들었어요. 컨테이너가 뜨고, 처리할 스케줄을 한 번 훑고, 끝나면 그대로 종료. 다음 회차는 다음 컨테이너가 새로 시작. 컨테이너가 사라지면 그 안의 좀비도 같이 사라져요. PID 1이 죽으면 그 밑에 딸린 자식들은 커널이 알아서 정리해주니까요.
원리는 간단한데, 막상 만들려니 한 가지가 걸렸어요.
그럼 누가 1분마다 그 컨테이너를 띄워줄 거예요?
후보는 몇 개 있었어요. 호스트의 cron, systemd 타이머, 별도의 스케줄러 컨테이너. 그런데 다 마음에 안 들었어요. 호스트에 뭔가 박는 건 피하고 싶었어요(b7g 서버는 7개 프로젝트가 같이 사는 곳이라 호스트는 가능한 한 깔끔하게요). 별도 스케줄러 컨테이너는 결국 또 하나 24시간 떠 있어야 하니, 좀비를 한 칸 옆으로 옮긴 셈이고요.
그래서 결국 앱 컨테이너 안에 스케줄러를 두기로 했어요. 어차피 SvelteKit 앱은 항상 떠 있으니까요. Bree로 1분마다 트리거를 쏘고, 그 트리거가 워커 컨테이너를 새로 띄우게요.
문제는, 앱은 컨테이너 안에 있는데 어떻게 컨테이너 밖에 새 컨테이너를 띄울까?
DooD
답은 DooD(Docker out of Docker)였어요. 호스트의 도커 소켓(/var/run/docker.sock)을 앱 컨테이너에 마운트해주면, 앱 안에서 docker run 한 줄로 호스트의 도커 데몬에게 일을 부탁할 수 있어요. 컨테이너 안에서 도커 명령을 호스트에게 위임하는 셈이에요.
세팅은 단순했어요. Dockerfile에 docker CLI를 하나 박고, 소켓을 마운트하고, 앱 안의 launcher.js가 매 분 docker run --rm 한 줄을 부르게 했어요. 앱 이미지를 그대로 쓰니까 워커는 따로 빌드되지 않고, --rm 덕에 끝나면 통째로 사라져요. 좀비고 뭐고요.
1회 실행이라더니, 또 안 죽네요
DooD로 첫 워커 컨테이너를 돌려봤더니, 처리는 멀쩡히 다 끝났는데도 컨테이너가 종료를 안 했어요. Bree에 걸어둔 6분 타임아웃에 걸려서 SIGKILL로 강제 종료되는 패턴이 반복됐어요.
원인은 또 크롬이었어요. browser.close() 이후에도 자식 크롬이 깔끔히 떨어지지 않는 경우가 있어서, 워커 Node 입장에선 “자식이 아직 살아있다 = 끝낼 수 없다”였던 거예요. 좀비를 막으려고 컨테이너로 분리해놨는데, 그 컨테이너가 또 같은 이유로 못 끝나고 있던 셈이죠.
해결은 단순했어요. run().then(() => process.exit(0)). 일 끝났으면 명시적으로 죽어버리기. PID 1이 죽으면 자식 크롬도 커널이 같이 정리해주고, --rm이 컨테이너를 회수해요.
좀비를 잊었어요
전환하고 나서, 좀비 프로세스를 잊었어요. puppeteer가 크롬 자식을 깔끔히 정리하든 말든, 1분 안에 컨테이너 자체가 사라지니까요.
문제를 푼 게 아니라, 문제가 닿을 수 없는 환경을 만든 거예요. 가끔은 그게 더 나은 답인 것 같아요.