함께 프로젝트를 만들어 볼까요?
ps, top, netstat 같은 리눅스 도구들은 정말 훌륭합니다.
이 도구들은 시스템에서 무슨 일이 일어나고 있는지에 대한 방대한 정보를 알려주죠.
- 그런데 이 도구들은 어떻게 동작할까요?
- 정보는 어디서 가져오는 걸까요?
- 이 원리를 활용해서 우리만의 도구를 만들 수는 없을까요?
이 글에서는 세 가지 인기 있는 리눅스 도구를 함께 다시 만들어 봅니다.
한 번에 두 마리 토끼를 잡는 셈입니다. Ruby의 유용한 기법도 익히고, 실질적인 리눅스 지식까지 얻을 수 있으니까요! 🙂
상태 정보는 어디에 있을까?
먼저 이 도구들이 정보를 어디서 가져오는지 답해 보겠습니다.
정답은 바로 proc 파일시스템입니다!
/proc 디렉터리 안을 들여다보면 다른 디렉터리처럼 여러 개의 디렉터리와 파일이 보일 겁니다.
하지만 이것들은 실제 파일이 아닙니다. 리눅스 커널이 사용자에게 데이터를 노출하기 위해 사용하는 일종의 통로일 뿐입니다.
일반 파일처럼 다룰 수 있기 때문에 매우 편리하며, 특별한 도구 없이 그냥 읽으면 됩니다.
리눅스 세계에서는 많은 것이 이런 방식으로 동작합니다.
다른 예시가 궁금하다면 /dev 디렉터리를 살펴보세요.
이제 무엇을 다루고 있는지 이해했으니, /proc 디렉터리의 내용물을 확인해 보겠습니다.
1 10 104 105 11 11015 11469 11474 11552 11655
일부만 발췌한 것인데도 패턴이 바로 눈에 들어옵니다.
이 숫자들은 대체 뭘까요?
네, 맞습니다. 바로 PID(프로세스 ID)입니다.
각 항목에는 특정 프로세스에 대한 정보가 담겨 있습니다.
ps 명령어를 실행하면 모든 프로세스가 고유한 PID를 가지고 있다는 것을 확인할 수 있습니다.
PID TTY TIME CMD 15952 pts/5 00:00:00 ps 22698 pts/5 00:00:01 bash
여기서 우리는 ps가 하는 일이 단순히 /proc 디렉터리를 순회하면서 찾은 정보를 출력하는 것임을 추론할 수 있습니다.
그럼 숫자로 된 디렉터리 하나 안에는 어떤 것들이 들어있는지 볼까요?
attr autogroup auxv cgroup clear_refs cmdline comm cpuset cwd environ exe fd
공간 절약을 위해 일부만 보여드렸지만, 전체 목록을 직접 확인해 보시길 추천합니다.
특히 흥미로운 항목들은 다음과 같습니다:
| 항목 | 설명 |
|---|---|
| comm | 프로그램 이름 |
| cmdline | 프로세스를 실행할 때 사용한 명령어 |
| environ | 프로세스 시작 시 적용된 환경 변수 |
| status | 프로세스 상태(실행 중, 대기 중 등) 및 메모리 사용량 |
| fd | 파일 디스크립터가 담긴 디렉터리(열린 파일, 소켓 등) |
이제 이 정보들을 알았으니 도구를 작성해 볼 준비가 되었습니다!
실행 중인 프로그램 목록 출력하기
먼저 /proc 아래의 모든 디렉터리 목록을 가져오는 것부터 시작해 보겠습니다.
Ruby의 Dir 클래스를 사용하면 간단하게 처리할 수 있습니다.
예제:
Dir.glob("/proc/[0-9]*")
숫자 범위를 지정한 이유가 궁금하실 겁니다. /proc 아래에는 지금 당장 관심 없는 다른 파일들도 존재하기 때문에, 숫자로 된 디렉터리만 골라내기 위함입니다.
이제 이 목록을 순회하면서 PID와 프로그램 이름 두 개의 열을 출력해 보겠습니다.
예제:
pids = Dir.glob("/proc/[0-9]*")
puts "PID\tCMD"
puts "-" * 15
pids.each do |pid|
cmd = File.read(pid + "/comm")
pid = pid.scan(/\d+/).first
puts "#{pid}\t#{cmd}"
end
실행 결과는 다음과 같습니다:
PID CMD --------------- 1 systemd 2 kthreadd 3 ksoftirqd/0 5 kworker/0 7 migration/0 8 rcu_preempt 9 rcu_bh 10 rcu_sched
어때요? 우리가 방금 ps를 만든 것입니다! 물론 원본의 화려한 옵션들은 지원하지 않지만, 동작하는 무언가를 만들어냈습니다.
누가 포트를 사용하고 있을까?
이번에는 netstat를 재현해 보겠습니다. -ant 플래그를 붙였을 때의 출력은 다음과 같습니다.
Active Internet connections (servers and established) Proto Recv-Q Send-Q Local Address Foreign Address State tcp 0 0 127.0.0.1:5432 0.0.0.0:* LISTEN tcp 0 0 192.168.1.82:39530 182.14.172.159:22 ESTABLISHED
이 정보는 어디서 찾을 수 있을까요? "/proc 안에 있겠군!"이라고 답했다면 정답입니다. 더 정확히는 /proc/net/tcp에서 찾을 수 있습니다.
그런데 문제가 하나 있습니다. 내용물이 netstat 출력과 전혀 닮지 않았다는 점이죠!
0: 0100007F:1538 00000000:0000 0A 00000000:00000000 00:00000000 00000000 1001 0 9216 1: 2E58A8C0:9A6A 9FBB0EB9:0016 01 00000000:00000000 00:00000000 00000000 1000 0 258603
즉, 정규식(regular expression)으로 파싱 작업을 해야 한다는 뜻입니다. 우선 로컬 주소와 상태 부분만 처리해 보겠습니다.
작성한 정규식은 다음과 같습니다:
LINE_REGEX = /\s+\d+: (?<local_addr>\w+):(?<local_port>\w+) \w+:\w+ (?<status>\w+)/
이 정규식으로 얻은 값은 16진수이므로 10진수로 변환해야 합니다. 이 작업을 대신 해줄 클래스를 만들어 보죠.
class TCPInfo
def initialize(line)
@data = parse(line)
end
def parse(line)
line.match(LINE_REGEX)
end
def local_port
@data["local_port"].to_i(16)
end
# 16진수를 일반 IP 표기법으로 변환
def local_addr
decimal_to_ip(@data["local_addr"].to_i(16))
end
STATUSES = {
"0A" => "LISTENING",
"01" => "ESTABLISHED",
"06" => "TIME_WAIT",
"08" => "CLOSE_WAIT"
}
def status
code = @data["status"]
STATUSES.fetch(code, "UNKNOWN")
end
# 이 부분은 크게 신경 쓰지 않아도 됩니다. 단순한 비트 연산입니다.
def decimal_to_ip(decimal)
ip = []
ip << (decimal >> 24 & 0xFF)
ip << (decimal >> 16 & 0xFF)
ip << (decimal >> 8 & 0xFF)
ip << (decimal & 0xFF)
ip.join(".")
end
end
이제 남은 일은 결과를 예쁜 표 형태로 출력하는 것뿐입니다.
require 'table_print' tp connections
출력 예시:
STATUS | LOCAL_PORT | LOCAL_ADDR ------------|------------|-------------- LISTENING | 5432 | 127.0.0.1 ESTABLISHED | 39530 | 192.168.88.46
정말 멋진 젬(gem)이 아닐 수 없습니다!
이제야 알게 되었지만, 덕분에 앞으로 ljust/rjust로 고생할 일이 없겠네요 🙂
내 포트에서 비켜줘!
다음과 같은 에러 메시지를 본 적이 있나요?
Address already in use - bind(2) for "localhost" port 5000
음...
대체 어떤 프로그램이 저 포트를 점유하고 있는 걸까요?
직접 찾아봅시다:
fuser -n tcp -v 5000 PORT USER PID ACCESS CMD 5000/tcp rubyguides 30893 F.... nc
아하, 범인을 찾았습니다!
이제 이 프로그램을 종료하면 포트를 되찾을 수 있습니다. 그런데 'fuser'는 어떻게 포트를 사용 중인 프로세스를 찾아낸 걸까요?
맞혔나요?
역시 /proc 파일시스템입니다.
사실 fuser는 우리가 앞서 다룬 두 가지 기술을 조합한 것입니다. 프로세스 목록을 순회하는 것과 /proc/net/tcp에서 활성 연결 정보를 읽는 것이죠.
여기에 한 단계만 추가하면 됩니다:
포트 정보와 PID를 매칭하는 방법을 찾는 것입니다.
/proc/net/tcp에서 얻은 TCP 데이터에는 PID가 없습니다. 하지만 inode 번호를 활용하면 됩니다.
"inode는 파일시스템 객체를 표현하기 위해 사용되는 자료구조이다." – 위키피디아
inode로 어떻게 일치하는 프로세스를 찾을 수 있을까요? 포트가 열려 있다는 것을 아는 프로세스의 fd 디렉터리를 살펴보면 다음과 같은 항목을 발견할 수 있습니다.
/proc/3295/fd/5 -> socket:[12345]
대괄호 사이의 숫자가 바로 inode 번호입니다. 따라서 모든 파일을 순회하기만 하면 일치하는 프로세스를 찾을 수 있습니다.
그 한 가지 방법은 다음과 같습니다:
x =
Dir.glob("/proc/[0-9]*/fd/*").find do |fd|
File.readlink(fd).include? "socket:[#{socket_inode}]" rescue nil
end
pid = x.scan(/\d+/).first
name = File.readlink("/proc/#{pid}/exe")
puts "Port #{hex_port.to_i(16)} in use by #{name} (#{pid})"
출력 예시:
Port 5432 in use by /usr/bin/postgres (474)
주의할 점은, 이 코드를 root 또는 해당 프로세스 소유자 권한으로 실행해야 한다는 것입니다.
그렇지 않으면 /proc 안의 프로세스 상세 정보를 읽을 수 없습니다.
마무리
이 글에서 여러분은 리눅스가 가상 /proc 파일시스템을 통해 방대한 데이터를 제공한다는 사실을 배웠습니다. 또한 /proc 아래의 데이터를 활용하여 ps, netstat, fuser 같은 유명한 리눅스 도구를 직접 재현하는 방법도 익혔습니다.
아래 뉴스레터를 구독하면 다음 글을 놓치지 않을 수 있습니다. 🙂