Python으로 /proc 파일시스템 파싱하기

ps, top, htop 같은 도구는 어떻게 실행 중인 모든 프로세스의 상태와 메모리 사용량을 실시간으로 보여줄까. 답은 의외로 단순한데, 이 도구들은 별도의 커널 API를 호출하는 게 아니라 /proc 아래의 파일들을 읽어서 파싱한 결과를 보여줄 뿐이다. 반대로 말하면 /proc을 직접 읽을 줄 알면 ps나 top을 서브프로세스로 실행하고 그 출력을 문자열 파싱하는 것보다 훨씬 빠르고 안정적으로 프로세스/시스템 정보를 수집할 수 있다. subprocess로 외부 명령을 매번 fork/exec하는 방식은 오버헤드가 크고, 로케일이나 출력 포맷이 바뀌면 파싱 로직이 깨지기도 쉽다. 이 글에서는 procfs의 구조를 살펴보고 Python으로 프로세스 정보와 시스템 리소스 상태를 직접 파싱하는 방법을 정리한다.

procfs 핵심 개념

/proc은 실제 디스크에 저장된 파일이 아니라 커널이 런타임에 생성하는 가상 파일시스템(procfs)이다. 파일을 열어서 읽는 순간 커널 내부 자료구조를 텍스트로 직렬화해서 돌려주는 구조라, 파일 크기가 0으로 표시되더라도 실제로는 내용이 존재한다. 프로세스별 정보는 /proc/<pid>/ 아래에, 시스템 전역 정보는 /proc 바로 아래에 위치한다.

자주 쓰는 파일들은 다음과 같다.

경로내용
/proc/<pid>/status프로세스 이름, 상태, UID/GID, 메모리 사용량(사람이 읽기 쉬운 key: value 형식)
/proc/<pid>/statCPU 사용 시간, 우선순위 등 (공백으로 구분된 단일 라인, 파싱 시 주의 필요)
/proc/<pid>/cmdline실행 시 전달된 명령줄 인자 (NULL 바이트로 구분)
/proc/<pid>/fd/프로세스가 열어둔 파일 디스크립터 심볼릭 링크 목록
/proc/meminfo시스템 전체 메모리/스왑 사용 현황
/proc/loadavg1/5/15분 평균 부하와 실행 중인 프로세스 수
/proc/net/dev네트워크 인터페이스별 송수신 통계

이 파일들은 일반 파일과 달리 매번 open() 할 때마다 그 시점의 스냅샷을 새로 만들어 낸다. 따라서 파일 객체를 열어두고 재사용하며 반복적으로 seek(0) 하는 방식보다는, 필요할 때마다 새로 여는 편이 안전하다.

실전 코드

먼저 현재 실행 중인 모든 PID를 열거한다. /proc 아래에는 프로세스 디렉터리 말고도 meminfo, net 같은 항목이 섞여 있으므로 디렉터리 이름이 숫자로만 이루어졌는지로 필터링한다.

import os

def list_pids():
    pids = []
    for entry in os.listdir('/proc'):
        if entry.isdigit():
            pids.append(int(entry))
    return sorted(pids)

if __name__ == '__main__':
    print(list_pids()[:10])

다음은 특정 PID의 status 파일을 파싱해서 이름, 상태, RSS(Resident Set Size)를 뽑는 함수다. status 파일은 Key:\tValue 형식의 라인으로 구성돼 있어 psstat보다 파싱이 훨씬 쉽다.

def read_status(pid: int) -> dict:
    path = f'/proc/{pid}/status'
    info = {}
    try:
        with open(path, 'r') as f:
            for line in f:
                key, _, value = line.partition(':')
                info[key.strip()] = value.strip()
    except (FileNotFoundError, ProcessLookupError):
        return {}
    return info

def get_process_summary(pid: int) -> dict:
    info = read_status(pid)
    if not info:
        return None
    return {
        'pid': pid,
        'name': info.get('Name', '?'),
        'state': info.get('State', '?'),
        'rss_kb': int(info.get('VmRSS', '0 kB').split()[0]),
    }

CPU 사용 시간처럼 status에 없는 값은 stat 파일에서 가져와야 한다. 문제는 stat의 두 번째 필드(comm, 프로세스 이름)가 괄호로 감싸져 있고 그 안에 공백이나 괄호가 포함될 수 있다는 점이다. 단순히 공백으로 split()하면 필드가 밀려버리므로, 마지막 ) 위치를 기준으로 이름 부분과 나머지를 분리해야 한다.

CLK_TCK = os.sysconf('SC_CLK_TCK')  # 보통 100 (1틱 = 10ms)

def read_cpu_time(pid: int) -> float:
    path = f'/proc/{pid}/stat'
    try:
        with open(path, 'r') as f:
            raw = f.read()
    except (FileNotFoundError, ProcessLookupError):
        return 0.0

    # comm 필드는 '(name)' 형태이며 공백을 포함할 수 있으므로
    # 마지막 ')'를 기준으로 앞뒤를 분리한다.
    rest = raw[raw.rfind(')') + 2:]
    fields = rest.split()

    # rest는 state(3번째 필드)부터 시작하므로 인덱스가 3만큼 밀린다.
    # utime은 원래 14번째 필드 -> fields[14-3] = fields[11]
    utime = int(fields[11])
    stime = int(fields[12])
    return (utime + stime) / CLK_TCK

마지막으로 /proc/meminfo를 파싱해서 시스템 전체 메모리 사용률을 계산하고, 위 함수들을 조합해 메모리 사용량 기준 상위 프로세스를 뽑는 간단한 ps 대체 스크립트를 만들어본다.

def read_meminfo() -> dict:
    info = {}
    with open('/proc/meminfo', 'r') as f:
        for line in f:
            key, _, value = line.partition(':')
            info[key.strip()] = int(value.split()[0])  # kB 단위
    return info

def memory_usage_percent() -> float:
    mem = read_meminfo()
    total = mem['MemTotal']
    available = mem['MemAvailable']
    used = total - available
    return used / total * 100

def top_memory_processes(n: int = 5) -> list:
    results = []
    for pid in list_pids():
        summary = get_process_summary(pid)
        if summary:
            results.append(summary)
    results.sort(key=lambda p: p['rss_kb'], reverse=True)
    return results[:n]

if __name__ == '__main__':
    print(f'시스템 메모리 사용률: {memory_usage_percent():.1f}%')
    for proc in top_memory_processes():
        print(f'{proc["pid"]:>7}  {proc["rss_kb"]:>10} kB  {proc["name"]}')

프로세스가 어떤 명령줄 인자로 실행됐는지, 파일 디스크립터를 몇 개나 열어두고 있는지도 procfs로 바로 확인할 수 있다. cmdline은 인자 사이가 공백이 아니라 NULL 바이트(\x00)로 구분돼 있다는 점만 유의하면 된다. fd 개수는 too many open files 같은 장애를 사전에 감지할 때 유용하다.

def read_cmdline(pid: int) -> list:
    path = f'/proc/{pid}/cmdline'
    try:
        with open(path, 'rb') as f:
            raw = f.read()
    except (FileNotFoundError, ProcessLookupError):
        return []
    # 인자는 NULL 바이트로 구분되며 마지막에 빈 문자열이 남을 수 있다.
    return [arg for arg in raw.decode(errors='replace').split('\x00') if arg]

def count_open_fds(pid: int) -> int:
    path = f'/proc/{pid}/fd'
    try:
        return len(os.listdir(path))
    except (FileNotFoundError, PermissionError):
        return -1

실행하면 다음과 비슷한 결과를 얻는다.

시스템 메모리 사용률: 42.3%
   1284     412800 kB  chrome
   2931     198432 kB  gnome-shell
    980     153216 kB  Xorg
   4102      98304 kB  python3
   1102      87040 kB  systemd-journald

주의사항

/proc을 직접 파싱할 때 흔히 놓치는 함정들이 있다.

레이스 컨디션: list_pids()로 PID 목록을 얻은 직후에도 해당 프로세스가 이미 종료됐을 수 있다. /proc/<pid>/를 여는 시점과 실제 읽는 시점 사이에 프로세스가 사라지면 FileNotFoundError 또는 ProcessLookupError가 발생하므로 항상 예외 처리를 해야 한다. 짧은 시간에 대량의 PID를 순회하는 스크립트일수록 이 문제를 자주 만난다.

권한 문제: 다른 사용자 소유의 프로세스는 status는 대체로 읽히지만 environ이나 일부 민감한 필드는 동일 UID이거나 root가 아니면 접근이 거부된다. 시스템 전체를 스캔하는 도구를 만든다면 권한 오류도 조용히 건너뛰도록 처리하는 게 좋다.

comm 필드 파싱: 위에서 다뤘듯 stat의 프로세스 이름은 괄호로 감싸져 있고 내부에 공백·괄호가 들어갈 수 있다(예: 브라우저 탭 프로세스 이름). 단순 split()으로 첫 몇 필드를 건너뛰는 코드는 실제 환경에서 종종 깨지므로, 반드시 마지막 ) 위치 기준으로 분리해야 한다.

단위 변환: /proc/<pid>/statm처럼 페이지 단위로 값을 주는 파일은 os.sysconf('SC_PAGE_SIZE')를 곱해야 바이트 단위가 된다. statusVmRSS처럼 이미 kB 단위로 사람이 읽기 쉽게 나오는 필드와 혼동하지 않도록 주의한다.

파일 재오픈: procfs 파일은 open() 시점마다 새 스냅샷을 만들어내므로, 반복 측정이 필요한 코드(예: CPU 사용률을 델타로 계산하는 경우)는 파일 객체를 캐싱하지 말고 매 측정마다 새로 열어야 한다.

마무리

procfs는 커널이 프로세스와 시스템 상태를 텍스트로 공개해두는 창구이고, ps/top 같은 표준 도구들도 결국 이 창구를 읽어서 보여주는 것뿐이다. 서브프로세스를 fork해서 명령 출력을 파싱하는 대신 /proc을 직접 읽으면 오버헤드도 줄고 출력 포맷 변화에 흔들리지 않는 모니터링 스크립트를 만들 수 있다. 다만 레이스 컨디션과 comm 필드처럼 실전에서만 드러나는 함정들이 있으니 예외 처리를 꼼꼼히 해야 한다. 매번 이런 파싱 로직을 직접 짜기보다 프로덕션 수준의 안정성이 필요하다면 psutil 같은 크로스플랫폼 라이브러리를 검토하는 것도 방법이다. psutil 내부도 결국 procfs를 읽는 방식으로 동작하지만, 앞서 다룬 함정들을 이미 처리해뒀다.

참고

proc(5) man page

Kernel.org – The /proc Filesystem

psutil documentation

답글 남기기