역추적하는 프로세스 계보 추적기witr(Why Is This Running)는 시스템 위에서 뭔가가 돌고 있을 때 "그게 무엇인가"가 아니라 "그게 왜 거기 있는가"를 답한다. 대상은 네 가지 형태로 지정할 수 있다 — 프로세스 이름·PID, 네트워크 포트, 열린 파일, 컨테이너. witr는 그 대상을 우선 하나의 PID로 환원한 뒤, 그 PID의 부모의 부모의 부모…를 PID 1(init)까지 거슬러 올라가 "누가 이걸 낳았는지"의 족보를 만든다.
ps·top·lsof·ss는 지금 이 순간의 상태를 한 장의 스냅샷으로 보여준다. "8080 포트를 node가 쓰고 있다"까지는 알려주지만, 그 node를 누가 띄웠는지는 여러 도구의 출력을 머릿속에서 이어 붙여야 겨우 짐작한다.
witr는 그 이어 붙이는 일을 대신 해준다. "이 node는 pm2가 띄웠고, 그 pm2는 systemd가 관리하는 서비스"라는 계보를 자동으로 그려, 되살아나는 유령 프로세스의 진범을 한 번에 지목한다.
표준 출력은 이렇게 생겼다. 대상(node)이 무엇인지, 언제 시작됐는지, 그리고 Why It Exists 줄에 인과 사슬이 그대로 찍힌다.
Target : node
Process : node (pid 14233)
User : pm2
Command : node index.js
Started : 2 days ago (Mon 2026-02-02 11:42:10 +05:30)
Why It Exists :
systemd (pid 1) → pm2 (pid 5034) → node (pid 14233)
Source : pm2
Working Dir : /opt/apps/expense-manager
Git Repo : expense-manager (main)
Sockets : 127.0.0.1:5001 (TCP | LISTENING)
설계 철학은 한 문장으로 요약된다 — "모든 것을 프로세스 질문으로 취급하라(treat everything as a process question)". 포트든 파일이든 컨테이너든 결국 PID로 환원되고, PID만 정해지면 나머지(계보·근원·경고)는 같은 파이프라인으로 처리된다. 이 단순한 환원이 witr의 코드 구조 전체를 지탱한다.
witr가 8개월 만에 별 2만 개를 모은 건 대단한 신기술이라서가 아니다. "이 포트 누가 쓰지?", "이 좀비는 왜 안 죽지?" 같은, 개발자라면 매주 겪는 통증을 명령 한 줄로 끝내 주기 때문이다. 여기에 배포·마케팅 완성도가 붙어 폭발했다.
"8080 포트가 이미 쓰이고 있습니다"라는 에러를 보고 lsof -i :8080 → PID 확인 → ps -ef | grep → 부모 추적… 하던 3~4단계를 witr --port 8080 한 줄로 대체한다. 정체불명의 프로세스, 되살아나는 서비스, 누가 잡고 있는지 모를 파일 락 — 전부 같은 방식으로 근원을 짚는다.
리눅스 전용 /proc 트릭에 머물지 않고 macOS·Windows·FreeBSD까지 각 OS의 네이티브 방식으로 지원한다. 컨테이너도 Docker 하나가 아니라 Podman·nerdctl·CRI(crictl)·Incus·LXC·LXD·FreeBSD jail까지 8종을 인식해, 컨테이너 안 프로세스의 계보를 호스트 관점에서 이어 준다.
공식 사이트(pranshuparmar.github.io/witr)는 witr의 출력 계층을 JavaScript로 충실히 재구현한 인터랙티브 플레이그라운드다. 실제 바이너리가 찍을 바이트(ANSI 색 코드까지)를 그대로 브라우저에서 보여준다. "설치부터 하고 판단"이 아니라 "먼저 만져 보고 결정"하게 만드는 이 훅이 유입을 크게 키웠다.
Homebrew·apt·conda·AUR·winget·npm·nix·scoop·choco 등 20종이 넘는 패키지 매니저에 등재돼 있어 "어떻게 깔지" 고민이 없다. 게다가 종료 코드(exit code)가 의미를 갖는다 — 정상 0, 경고 1, 못 찾음 2, 권한 부족 3… 그래서 CI·헬스체크 스크립트에 그대로 물릴 수 있다.
| 도구 | 답하는 질문 | witr와의 차이 |
|---|---|---|
| ps / top | 무엇이 돌고 있나 (상태 스냅샷) | 부모-자식 계보를 "근원"으로 해석해 주지는 않음 |
| lsof / ss | 이 포트·파일을 어떤 PID가 쓰나 | PID까지만. 그 PID를 누가 띄웠는지는 별도 추적 |
| systemctl status | 이 서비스의 상태·로그 | systemd가 띄운 것만. 셸·컨테이너·pm2 기원은 모름 |
| witr | 이게 왜 돌고 있나 (근원+맥락) | 위 도구들의 출력을 하나의 인과 사슬로 통합 |
witr는 Go 100%다(go 1.25). 놀라운 점은 의존성이 겨우 9개라는 것 — 프로세스·네트워크·컨테이너를 들여다보는 핵심 로직은 gopsutil 같은 범용 라이브러리에 기대지 않고 전부 자체 구현했다. 외부 의존성은 "화면 그리기"와 "systemd 대화"에만 쓴다. 그래서 이 저장소는 시스템 프로그래밍을 날것으로 배우기에 대단히 좋은 교재가 된다.
| 패키지 (버전) | 역할 |
|---|---|
| spf13/cobra v1.10.2 | CLI 프레임워크. 명령·플래그·셸 자동완성·man 페이지 생성. |
| charmbracelet/bubbletea v1.3.10 | TUI 프레임워크. Elm식 Model/Update/View 메시지 루프. |
| charmbracelet/bubbles v1.0.0 | TUI 위젯 모음 — table(표), textinput(검색창), viewport(스크롤 영역). |
| charmbracelet/lipgloss v1.1.0 | 터미널 스타일링 — 색·테두리·패딩, 밝은/어두운 배경 자동 적응. |
| muesli/reflow v0.3.1 | 터미널 폭에 맞춘 텍스트 줄바꿈(reflow). |
| mattn/go-isatty v0.0.20 | 출력이 실제 터미널인지(TTY) 파이프인지 감지 → 색·인터랙티브 여부 결정. |
| 패키지 (버전) | 역할 |
|---|---|
| coreos/go-systemd/v22 v22.7.0 | systemd의 D-Bus API. 유닛 속성·재시작 횟수(NRestarts)·타이머 스케줄 조회. |
| godbus/dbus/v5 v5.1.0 | D-Bus 프로토콜 저수준 구현(go-systemd의 백엔드). |
| golang.org/x/sys v0.38.0 | 저수준 시스템콜. Windows Win32 API, 유닉스 syscall 직접 호출. |
보통 이런 도구는 프로세스·네트워크 정보를 gopsutil 하나로 퉁친다. witr는 일부러 그러지 않았다. "OS를 직접 파싱하는 코드"가 곧 이 프로젝트의 본체이고, 외부 라이브러리는 그 결과를 예쁘게 보여주는 껍데기(Charm 3종)와 systemd와 대화하는 통역기(D-Bus)뿐이다.
크로스컴파일과 패키징은 GoReleaser가 맡는다(4 OS × 2 아키텍처, .deb/.rpm/.apk 생성, SHA256 체크섬). Nix flake로 재현 가능한 빌드를 제공하고, golangci-lint로 정적 분석, //go:generate로 man 페이지·마크다운 문서를 소스에서 자동 생성한다. CGO_ENABLED=0 정적 링크라 실행에 별도 런타임이 필요 없다.
witr의 데이터 흐름은 네 정거장을 지난다. 사용자가 준 대상(이름·포트·파일·컨테이너)을 ①PID로 해석하고, 그 PID를 ②계보(ancestry)로 역추적하고, 계보를 훑어 ③근원(source)을 판별한 뒤, ④출력 렌더러가 표준/짧게/트리/JSON 중 하나로 찍는다.
internal/proc/ancestry.go)witr의 심장이다. 대상 PID에서 시작해 부모 PID(PPID)를 따라 위로 걸어 올라가며 사슬을 쌓고, 순환을 막으려 방문한 PID를 seen 맵에 기록한다. PID 1이나 PPID 0에 닿으면 멈추고, 마지막에 사슬을 뒤집어 "뿌리(init)가 맨 앞"에 오게 만든다.
func ResolveAncestry(pid int) ([]model.Process, error) {
var chain []model.Process
seen := make(map[int]bool)
current := pid
for current > 0 {
if seen[current] { break } // 순환 방지
seen[current] = true
p, err := ReadProcess(current) // OS별 구현 (리눅스: /proc 파싱)
if err != nil { break }
chain = append(chain, p)
if p.PPID == 0 || p.PID == 1 { break }
current = p.PPID
}
// 뿌리가 앞에 오도록 뒤집기
for i, j := 0, len(chain)-1; i < j; i, j = i+1, j-1 {
chain[i], chain[j] = chain[j], chain[i]
}
return chain, nil
}
process_linux.go)리눅스에서 한 PID의 모든 정보를 /proc/<pid>/ 아래 파일에서 직접 읽는다. 읽는 중 프로세스가 사라지는 경합(TOCTOU)을 의식해 stat을 먼저 잡는다. 주요 소스는 아래와 같다.
| 파일 | 얻는 것 |
|---|---|
| /proc/<pid>/stat | comm(괄호 안 이름), PPID, 상태, 시작 시각(starttime), CPU 시간, RSS |
| /proc/<pid>/cmdline | 전체 명령행(널 문자 구분) |
| /proc/<pid>/cwd | 작업 디렉토리(심링크 readlink) |
| /proc/<pid>/exe | 실행 바이너리 경로. " (deleted)" 접미사로 삭제된 바이너리 탐지 |
| /proc/<pid>/cgroup | 컨테이너·systemd 유닛 식별(docker/kubepods/.service 문자열 매칭) |
| /proc/<pid>/environ | 환경변수(SSH_CLIENT, TMUX 등 근원 판별에 활용) |
프로세스 이름이 (my app)처럼 공백·괄호를 품으면 필드를 앞에서부터 쪼갤 수 없다. 그래서 witr는 마지막 )를 찾아 그 뒤부터 필드를 나눈다. 코드 주석도 "stat format is evil"이라 적혀 있다. 시작 시각은 부팅 시각 + starttime/CLK_TCK로 환산한다.
internal/source/detect.go)역추적한 계보를 받아 우선순위 순서로 검사해 단 하나의 근원을 고른다. 검사 순서가 곧 정책이다 — 컨테이너가 가장 강한 맥락이고, 그 다음이 원격 접속, 셸, 서비스 매니저 순이다.
func Detect(ancestry []model.Process) model.Source {
if s := detectContainer(ancestry); s != nil { return *s } // 1. 컨테이너
if s := detectSSH(ancestry); s != nil { return *s } // 2. SSH 세션
if s := detectShell(ancestry); s != nil { return *s } // 3. 대화형 셸/유저툴
if s := detectSystemd(ancestry); s != nil { return *s } // 4. systemd
if s := detectLaunchd(ancestry); s != nil { return *s } // 5. launchd(macOS)
if s := detectBsdRc(ancestry); s != nil { return *s } // 6. rc.d(FreeBSD)
if s := detectSupervisor(ancestry);s != nil { return *s } // 7. pm2/supervisord
if s := detectCron(ancestry); s != nil { return *s } // 8. cron
if s := detectWindowsService(ancestry); s != nil { return *s } // 9. Win 서비스
if s := detectInit(ancestry); s != nil { return *s } // 10. PID1 총괄
return model.Source{Type: model.SourceUnknown}
}
각 판별기는 계보에서 단서를 캔다. detectSSH는 사슬에 sshd가 있고 환경변수 SSH_CLIENT가 있으면 원격 IP·TTY를 뽑아 "SSH session from 1.2.3.4"로 만든다. detectSystemd는 cgroup에서 유닛명을 공짜로 얻은 뒤 D-Bus로 재시작 횟수·타이머 스케줄까지 보강한다(예전엔 systemctl show를 매번 fork했는데 단일 D-Bus 연결로 대체해 성능을 개선한 흔적이 코드에 남아 있다).
net_linux.go)포트→PID는 두 단계다. 먼저 /proc/net/tcp를 파싱해 해당 포트와 일치하는 소켓의 inode를 모으고(포트는 4자리 16진수로 인코딩돼 있다), 그다음 /proc/*/fd/* 심볼릭 링크를 전부 훑어 socket:[inode]를 가진 PID를 찾는다.
targetHex := fmt.Sprintf("%04X", port) // 포트를 4자리 대문자 16진수로
// /proc/net/tcp 한 줄: fields[1]="로컬IP:PORT"(hex), fields[3]=상태, fields[9]=inode
if state == "0A" /* TCP_LISTEN */ && localParts[1] == targetHex {
inodes[fields[9]] = true // 이 포트를 LISTEN 중인 소켓 inode 수집
}
// 이후 /proc/<pid>/fd 의 각 심링크를 readlink → "socket:[inode]" 대조 → PID 확정
이 소켓 테이블은 2초 TTL 캐시로 감싸 계보를 훑는 동안 반복 파싱을 피한다. IPv6는 /proc/net/tcp6가 리틀엔디언 워드로 저장하므로 바이트 재배열이 필요하고, :: 리스너가 IPv4까지 받는 듀얼스택 상황도 합성해 보여준다.
이 저장소의 가장 큰 학습 포인트다. ReadProcess·ResolvePort 같은 동일한 함수를 Go 빌드 태그(//go:build linux 등)로 OS별 파일에 각각 구현했다. internal/proc/ 한 폴더 안에 process_linux.go·process_darwin.go·process_windows.go·process_freebsd.go 네 벌이 공존한다.
| 관심사 | Linux | macOS | Windows | FreeBSD |
|---|---|---|---|---|
| 프로세스 | /proc 직접 파싱 | ps shellout | Win32 ToolHelp32 | procstat/ps |
| 네트워크 | /proc/net/tcp* | lsof/netstat | Win32 iphlpapi | sockstat |
| 서비스 매니저 | systemd (D-Bus) | launchd | SCM | rc.d |
특히 Windows는 PowerShell·WMI를 피하고(hang·느린 시작 회피) golang.org/x/sys/windows로 순수 Win32 API를 직접 호출한다. 지원하지 않는 OS에서는 unsupported.go가 안내 메시지를 찍고 종료한다.
app.go가 모드를 가른다. 대상 인자가 있으면 cobra 기반 CLI 1회 실행(결과를 찍고 의미 있는 종료 코드 반환), 인자가 없거나 -i면 Bubble Tea TUI가 뜬다. TUI는 Processes/Ports/Containers/Locks 네 탭을 table 위젯으로 보여주고, 3초에서 시작해 부하에 따라 간격을 늘리는 적응형 자동 새로고침, 마우스·정렬·검색, 그리고 유닉스에서는 프로세스에 시그널을 보내는 액션(KILL/TERM/STOP/CONT/renice)까지 지원한다.
internal/과 pkg/의 구분이 핵심이다. Go는 internal/ 아래 코드를 외부 모듈이 import하지 못하도록 언어 차원에서 막는다. witr는 앱의 구현 전부를 internal/에 숨기고, 재사용할 가치가 있는 순수 데이터 타입만 pkg/model/에 공개했다. "이건 우리 앱 내부 사정, 이건 남들이 써도 되는 계약"을 폴더로 못 박은 셈이다. 실제 코드량이 가장 많은 곳은 internal/proc/ — OS별 4벌 구현이 몰려 있는 이 도구의 심장이다.
witr는 "터미널 도구를 어떻게 만드는가"의 모범 답안이자, 리눅스가 프로세스·네트워크를 어떻게 노출하는지 배우는 실물 교재다. 배울 것을 계층별로 정리하면 이렇다.
리눅스는 커널 내부 상태를 /proc라는 가짜 파일시스템으로 노출한다. witr가 읽는 /proc/<pid>/stat·cmdline·cgroup·fd와 /proc/net/tcp는 ps·ss·lsof가 내부에서 하는 일과 정확히 같다. 이 도구들의 "마법"이 사실은 텍스트 파일 파싱임을 몸으로 이해하게 된다.
모든 프로세스는 부모를 갖고, 부모가 먼저 죽으면 자식은 PID 1(init/systemd)에 입양(reparenting)된다. witr의 계보 역추적은 이 트리를 거꾸로 오르는 것 — 트리 자료구조·재귀·순환 방지가 실제 문제에 쓰이는 사례다.
"어떤 프로세스가 이 포트를 쓰나"의 답은 /proc/net/tcp의 소켓 inode와 /proc/*/fd의 socket:[inode] 링크를 이어 붙이는 것이다. TCP 상태 머신(LISTEN/ESTABLISHED/TIME_WAIT), 16진수 인코딩, IPv6 바이트 순서까지 — 네트워크의 밑바닥을 실습으로 배운다.
빌드 태그 + 파일명 규칙(_linux.go/_darwin.go)으로 같은 인터페이스를 OS별로 구현하는 방식은 Go 크로스플랫폼 코드의 정석이다. #ifdef 없이 파일 단위로 분기하는 이 패턴은 그대로 자기 프로젝트에 옮겨 쓸 수 있다.
cobra로 서브명령·플래그·자동완성을, Bubble Tea의 Elm 아키텍처(Model/Update/View)로 상태 기반 TUI를 짜는 법을 실전 규모로 볼 수 있다. lipgloss의 배경 밝기 적응, bubbles의 table/viewport 조합도 좋은 참고가 된다.
output/sanitize.go는 프로세스 이름·명령행에 숨은 악성 ANSI 이스케이프를 무력화한다. 신뢰할 수 없는 데이터(남이 띄운 프로세스의 메타데이터)를 터미널에 그릴 때 반드시 필요한 방어 — 로그 뷰어·모니터링 도구를 만들 때 놓치기 쉬운 포인트를 정확히 짚는다.
witr는 CGO_ENABLED=0으로 정적 링크된 바이너리 파일 하나다(수 MB). 런타임 의존성도, 특별한 하드웨어도 필요 없다. 다만 두 가지를 알아야 한다 — 남의 프로세스를 자세히 보려면 권한이 필요하고, 기능은 OS마다 다르다.
| 항목 | 요구 / 참고 |
|---|---|
| 설치 | 패키지 매니저 20종+(brew·apt·winget·npm·nix…) 또는 원라인 스크립트. 소스 빌드는 go install …/cmd/witr@latest |
| 빌드 시 Go | Go 1.25 (실행에는 불필요, 빌드할 때만) |
| 권한 | Linux/FreeBSD에서 타 유저 프로세스 상세는 sudo, Windows는 관리자 PowerShell 필요. macOS는 SIP로 환경변수 등 일부 제한 |
| 컨테이너 조회 | 해당 런타임 CLI(docker/podman/nerdctl/crictl/incus/lxc/jls)가 PATH에 있어야 함 |
| systemd 보강 | 재시작 횟수·타이머 스케줄은 systemd가 init인 리눅스에서만(D-Bus) |
witr <이름>(이름으로), witr --port 8080(포트로), witr --file /var/log/app.log(파일로), witr --container myapp(컨테이너로). 출력 스위치는 -s 짧게(한 줄), -t 트리, --json JSON, --warnings 경고만, --verbose 상세, -x 정확히 일치, -i 인터랙티브 TUI.
witr $$로 지금 이 셸의 계보를 보고, -t를 붙여 트리로도 본다. 그다음 개발 서버를 띄운 뒤 witr --port 3000, witr node --json | jq로 같은 정보를 세 형태로 출력해 비교한다. 목표: 대상 지정 방식(PID/포트/이름)과 출력 포맷의 감을 잡기.
cat /proc/self/stat을 실행해 2번째 필드(PPID)와 22번째 필드(starttime)를 눈으로 찾아본다. ls -l /proc/self/fd로 파일 디스크립터가 심볼릭 링크임을, cat /proc/self/cgroup로 cgroup 경로를 확인한다. 배우는 것: witr의 "마법"이 실은 텍스트 파싱임을 체감.
Python이나 Go로 "PID를 받아 /proc/<pid>/stat에서 PPID를 읽고, PID 1까지 올라가며 이름을 이어 붙이는" 스크립트를 작성한다. 순환 방지용 seen 집합도 넣는다. 배우는 것: 프로세스 트리 순회 + witr 핵심 로직의 본질.
포트 번호를 4자리 16진수로 바꿔 /proc/net/tcp에서 LISTEN(상태 0A) 소켓의 inode를 찾고, /proc/*/fd/*를 훑어 socket:[inode]를 가진 PID를 역으로 찾는 스크립트를 만든다. 배우는 것: 소켓 inode ↔ FD 매핑, TCP 상태·hex 인코딩.
ContainerRuntime 인터페이스(Name/Available/List/HostPID 4개 메서드)를 참고해 새 어댑터를 흉내 내 보거나, Bubble Tea로 /proc를 읽어 프로세스 목록을 실시간 표시하는 미니 뷰어를 만든다. 배우는 것: 인터페이스 기반 플러그인 설계 + MVU 아키텍처 실전.
| 주차 | 주제 | 무엇을 · 왜 |
|---|---|---|
| 1주 | /proc 파일시스템 | stat·status·cmdline·cgroup·fd를 직접 파싱. ps·lsof의 내부를 재현. |
| 2주 | 프로세스 트리·시그널 | PPID·reparenting·좀비·PID 1. 시그널(TERM/KILL/STOP/CONT)과 renice까지. |
| 3주 | 네트워크 소켓 밑바닥 | TCP 상태 머신, 소켓 inode↔FD, /proc/net/tcp hex 인코딩, IPv6·듀얼스택. |
| 4주 | 서비스 매니저 · cgroup | systemd 유닛/타이머/D-Bus, launchd, cgroup v1/v2, 컨테이너·네임스페이스. |
| 5주 | Go 시스템 프로그래밍 | 빌드 태그로 OS 분기, golang.org/x/sys syscall, cobra CLI, 인터페이스 설계. |
| 6주 | Bubble Tea TUI | Elm(MVU) 아키텍처, bubbles 위젯, lipgloss 스타일링, 적응형 새로고침·마우스. |
처음부터 TUI 렌더링을 파려 하면 정작 witr의 본질(OS를 파싱하는 internal/proc/)을 놓친다. 1~3주는 리눅스가 정보를 어떻게 노출하는지에 집중하고(여기서 witr의 90%가 이해된다), 4주부터 systemd·컨테이너로 맥락을 넓힌 뒤, 5~6주에 Go 구현 기법과 TUI를 얹는 순서가 가장 지속 가능하다.
/proc/<pid>/ 아래에 그 프로세스의 stat·cmdline·fd·cgroup 등이 들어 있다. witr의 리눅스 백엔드 전체가 여기 위에 서 있다.PID 1까지 따라 올라가며 만든 사슬이 ancestry다. witr가 "왜 이게 돌지"에 답하는 뼈대./proc/<pid>/fd에 socket:[inode] 심링크로 나타난다. 이 둘을 이어 붙이면 "포트→PID"가 풀린다.//go:build linux 주석이나 _linux.go 파일명 규칙으로 "이 파일은 이 OS에서만 컴파일"을 지정하는 방식. 같은 인터페이스를 OS별로 다르게 구현하는 크로스플랫폼의 열쇠.Name/Available/List/HostPID 4개 메서드로 추상화한 Go 인터페이스. 각 구현이 init()에서 레지스트리에 자동 등록된다(플러그인 패턴).sanitize.go). 종료 코드는 0 정상·1 경고·2 못 찾음·3 권한… 처럼 의미를 가져 스크립트에 물릴 수 있다.