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()로 목록을 얻은 직후에도 해당 프로세스가 종료됐을 수 있다. 여는 시점과 읽는 시점 사이 프로세스가 사라지면 FileNotFoundError/ProcessLookupError가 나므로 항상 예외 처리해야 한다.
권한 문제다른 사용자 소유 프로세스는 status는 대체로 읽히지만 environ 등 민감한 필드는 동일 UID나 root가 아니면 거부된다. 전체 스캔 도구는 권한 오류도 조용히 건너뛰도록 처리한다.
comm 필드 파싱stat의 프로세스 이름은 괄호로 감싸져 있고 내부에 공백·괄호가 들어갈 수 있다. 단순 split()으로 앞부분을 건너뛰면 깨지므로 반드시 마지막 ) 위치 기준으로 분리해야 한다.
단위 변환/proc/<pid>/statm처럼 페이지 단위인 파일은 os.sysconf('SC_PAGE_SIZE')를 곱해야 바이트가 된다. VmRSS처럼 이미 kB로 나오는 필드와 혼동하지 않는다.
파일 재오픈procfs 파일은 open() 시점마다 새 스냅샷을 만든다. CPU 사용률처럼 델타 계산이 필요한 값은 파일 객체를 캐싱하지 말고 매 측정마다 새로 열어야 한다.

마무리

procfs는 커널이 프로세스와 시스템 상태를 텍스트로 공개해두는 창구이고, ps/top도 결국 이를 읽어서 보여줄 뿐이다. subprocess로 명령을 fork하는 대신 /proc을 직접 읽으면 오버헤드가 줄고 출력 포맷 변화에도 흔들리지 않는다. 다만 위 함정들 때문에 매번 직접 파싱하기보다 프로덕션 수준 안정성이 필요하다면, 이미 이를 처리해둔 psutil 같은 라이브러리를 검토하는 것도 방법이다.

참고

proc(5) man page

Kernel.org – The /proc Filesystem

psutil documentation

답글 남기기