단명 프로세스 수만큼 늘어나던 프로세스 핸들 누수 — Stop을 못 받으면 영구 면제되던 회수 로직

단명 프로세스가 쏟아지는 PC에서 에이전트의 Process 핸들이 시간당 수백 개씩 늘고 돌아오지 않았다. 「Stop 메시지를 못 받은 항목은 핸들이 있으면 회수 면제」라는 전제가 범인이었다. 10시간 부하 재현과 GetExitCodeProcess로 죽음을 확정하는 수정을 정리한다.

현상

orange.exe의 Process 핸들이 단명 프로세스 수에 비례해 늘고 되돌아오지 않았다.

사내 QA의 Windows Server 2016 가상 머신(orange.sys 0.3.1.50, Driver Verifier 할당 실패 흉내 켬)에서 10시간 부하(cmd.exe를 초당 20개 생성)를 걸자 핸들이 771 → 4,512로 늘었다(시간당 약 370개, 한 번도 줄지 않음). 앞서 09-16 개발 PC 조사에서는 시간당 190220개였고, 남은 핸들은 전부 GrantedAccess 0x1C50(에이전트가 프로세스 생성 때 연 핸들)이었으며 생성과 종료가 1초 미만인 프로세스였다. 수백 대 규모의 고객사 PC는 평탄했고, 구형 빌드만 시간당 517개로 나타났다.

원칙

프로세스가 끝났으면 Stop 메시지를 받았든 못 받았든 정상 종료 경로와 똑같이 회수해야 한다. 「핸들이 있음 = 살아 있음」이 아니다. 핸들은 프로세스가 죽어도 커널에서 유효하게 남는다(종료 코드를 받아 갈 수 있도록). 그래서 "핸들이 있으니 아직 살아 있다"고 판단해 회수를 미루면, 이미 죽은 프로세스의 핸들이 영원히 쌓인다.

원인

회수 루프 ReapProcessCache에 이런 조건이 있었다.

if (!bStopped && (ptr->bSystem || ptr->Handle())) continue;

Stop 기록이 없는 항목(!bStopped)은 핸들만 있으면 회수에서 영구 면제됐다. 문제는 Stop을 잃는 길이 여럿이라는 점이다.

  • 드라이버 풀 할당 실패(앞의 가상 머신은 Verifier가 10시간에 281만 번 할당을 실패시켰다)
  • 수집 기능(nRun)이 꺼진 경우
  • 프로세스 표에 시작이 저장되지 않은 경우
  • 포트가 끊긴 경우
  • Stop이 Add보다 먼저 도착하는 레이스

게다가 Stop 메시지 전송에는 시한이 없다(FltSendMessage의 Timeout이 NULL). 즉 Stop을 못 받는 것은 드문 사고가 아니라 구조적으로 일어날 수 있는 일인데, 그 경우 전부 회수가 막혔다.

수정

회수 틱(3분)에서 Stop이 없는 항목에 대해 그 핸들로 GetExitCodeProcess를 불러, 결과가 STILL_ACTIVE가 아니면 죽음으로 확정(bExited)하고 더 이상 면제하지 않으며 3분 문턱으로 회수한다.

핸들 권한 0x1C50에는 SYNCHRONIZE가 없어 WaitForSingleObject를 쓸 수 없으므로, 대기 대신 종료 코드 조회로 생사를 가린다. bStopped 자체는 손대지 않았다(루프 끝의 m_stopped.erase(s)가 그 값을 믿기 때문이다). ZOMBIE 로그에 exited: 표시를 붙여 이 경로로 회수된 것을 구분할 수 있게 했다.

비용은 3분마다 Stop 없는 항목(대략 실행 중 프로세스 수)당 커널 조회 한 번, 수 밀리초다.

확인은 같은 부하(cmd.exe 초당 20개 10분)를 고치기 전후 같은 가상 머신에서 돌려, 폭주 뒤 30분의 핸들 저점을 비교하는 방식으로 한다. Verifier를 켠 Server 2016과 Verifier 없는 QA 가상 머신(빌드 1507) 양쪽에서 잰다.

한계

  • 09-16 덤프에서 본 "프로세스 표 밖에서 shared_ptr을 쥔 118개"는 이 경로가 아니다(원인 미확정, 별건).
  • 종료 코드가 우연히 259(STILL_ACTIVE와 같은 값)인 프로세스는 가려내지 못한다. 이것은 Windows API의 오래된 함정으로, 수정 전과 같다.

곁가지: 스레드 핸들 누수

같은 조사에서 작은 누수를 하나 더 확인했다. common/Run.cpp의 YAgent::Run()은 CreateProcess 성공 시 pi.hProcess만 돌려주고 pi.hThread를 닫지 않았다. RunInSession()의 CreateProcessAsUser 분기(서비스 → 사용자 세션)도 processInfo.hThread를 닫지 않았다. 호출 1회마다 스레드 핸들 1개가 영구히 샜다.

다만 이 호출처는 전부 사건성이다(분석기 실행, 압축·해제, 사용자 트레이 띄우기, 재시작·업데이트). 그래서 위의 시간당 수백 개와는 무관하다. 원칙은 단순하다 — CreateProcess가 돌려준 핸들 가운데 쓰지 않는 스레드 핸들은 그 자리에서 닫는다. 프로세스 핸들은 호출자가 받아 쓰므로 그대로 둔다. 각 분기에 CloseHandle 한 줄씩을 더했다.


Orange Platform 에이전트의 핸들 누수 분석 리포트입니다.