Computer >> 컴퓨터 >  >> 프로그래밍 >> Ruby

Ruby 프로젝트 아이디어: /proc 파일시스템으로 나만의 리눅스 도구 만들기

함께 프로젝트를 만들어 볼까요?

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 같은 유명한 리눅스 도구를 직접 재현하는 방법도 익혔습니다.

아래 뉴스레터를 구독하면 다음 글을 놓치지 않을 수 있습니다. 🙂