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

Ruby로 배우는 유닉스 데몬의 작동 원리

유닉스 데몬(daemon)은 백그라운드에서 실행되는 프로그램입니다. Nginx, Postgres, OpenSSH가 대표적인 예입니다. 데몬은 몇 가지 특별한 기법을 사용해 프로세스를 "분리(detach)"하여, 어떤 터미널에도 종속되지 않고 독립적으로 실행됩니다.

저는 늘 데몬이라는 존재에 묘한 매력을 느껴왔습니다. 아마 이름 때문이기도 할 겁니다. 그래서 데몬이 어떻게 동작하는지, 그리고 Ruby에서 어떻게 만들 수 있는지 소개하는 글을 써보면 재미있겠다고 생각했습니다.

...하지만 그 전에 먼저 짚고 넘어갈 것이 있습니다.

집에서 따라 하지 마세요!

사실 여러분은 데몬을 직접 만들 필요가 없습니다. 같은 일을 훨씬 쉽게 처리할 방법이 이미 존재하기 때문입니다.

백그라운드에서 실행되는 프로그램이 필요하다면 문제없습니다. 운영체제(OS)는 일반 프로그램을 백그라운드에서 실행할 수 있는 체계를 기본으로 제공합니다.

Ubuntu에서는 Upstart나 systemd가 이 역할을 담당하고, macOS에서는 launchd가 그렇습니다. 이 외에도 여러 도구가 있지만 모두 같은 개념으로 동작합니다. 오래 실행되는 프로그램을 시작하고 중지하는 방법을 설정 파일에 기술하면 끝입니다. 이후 service my_app start 같은 시스템 명령어로 프로그램을 실행하면 백그라운드에서 알아서 돌아갑니다.

요약하자면, Upstart 같은 도구는 간단하고 안정적인 반면, 전통적인 방식의 데몬은 난해하고 정확히 구현하기가 무척 까다롭습니다.

...그렇다면 왜 굳이 데몬을 공부해야 할까요? 재미있으니까요! 게다가 그 과정에서 유닉스 프로세스에 관한 흥미로운 사실들도 함께 배울 수 있습니다.

가장 단순한 데몬

"데몬은 절대 만들지 마세요"라고 말씀드렸지만, 이제 데몬을 만들어 보겠습니다! Ruby 1.9부터는 놀랄 만큼 간단합니다. Process.daemon 메서드만 호출하면 됩니다.

# 선택 사항: 입력하기 쉬운 이름으로 프로세스명 설정
$PROGRAM_NAME = "rubydaemon"

# 현재 프로세스를 데몬으로 변환
Process.daemon()

# 1초마다 현재 시간을 파일에 기록
loop do
  File.open("/tmp/rubydaemon.log", "a") { |f| f.puts(Time.now) }
  sleep(1)
end

이 스크립트를 실행하면 곧바로 콘솔로 제어권이 돌아옵니다. 로그 파일을 tail로 확인해 보면 예상대로 매초 타임스탬프가 추가되는 것을 볼 수 있습니다.

Ruby로 배우는 유닉스 데몬의 작동 원리

정말 쉽죠? 하지만 이것만으로는 데몬이 어떻게 동작하는지 설명해 주지 못합니다. 진정으로 이해하려면 데몬화 과정을 직접 손으로 구현해 봐야 합니다.

부모 프로세스 변경하기

bash로 일반 프로그램을 실행하면 그 프로그램의 프로세스는 bash의 자식 프로세스가 됩니다. 하지만 데몬은 어떻게 실행했느냐와 무관하게 항상 OS가 제공하는 "루트(root)" 프로세스를 부모로 가집니다.

데몬의 부모 ID(PPID)를 확인하면 이를 알 수 있습니다. 데몬의 부모 ID는 항상 1입니다. 아래 예제에서는 pstree로 이를 확인해 봅니다.

$ pstree
-+= 00001 root /sbin/launchd
 |--- 72314 snhorne rubydaemon

흥미롭게도 이 모습은 "고아 프로세스(orphaned process)"와도 같습니다. 고아 프로세스란 부모 프로세스가 먼저 종료되어 버린 자식 프로세스를 말합니다.

따라서 데몬을 만들려면 의도적으로 프로세스를 고아로 만들어야 합니다. 아래 코드가 바로 그 작업을 수행합니다.

# 선택 사항: 입력하기 쉬운 이름으로 프로세스명 설정
$PROGRAM_NAME = "rubydaemon"

# 새 자식 프로세스를 생성하고 부모를 종료한다.
# 이렇게 하면 프로세스가 "고아"가 되며 데몬이 된다.
exit if fork()

# 1초마다 현재 시간을 파일에 기록
loop do
  File.open("/tmp/rubydaemon.log", "a") { |f| f.puts(Time.now) }
  sleep(1)
end

fork를 호출하면 같은 코드를 실행하는 두 개의 프로세스가 생깁니다. 원래 프로세스가 부모이고 새로 생긴 프로세스가 자식입니다. fork는 부모에게는 참(truthy) 값을, 자식에게는 거짓(falsy) 값을 반환합니다. 따라서 exit if fork()는 부모 프로세스만 종료시킵니다.

현재 세션에서 분리하기

위의 "데몬화" 코드에는 몇 가지 문제가 남아 있습니다. 프로세스를 고아로 만드는 데는 성공했지만, 여전히 터미널의 세션에 속해 있습니다. 즉, 터미널을 종료하면 데몬도 함께 죽습니다. 이를 해결하려면 새 세션을 만들고 다시 한번 fork해야 합니다. 유닉스 세션 그룹이 익숙하지 않다면 StackOverflow의 관련 글을 참고하시길 권합니다.

# 선택 사항: 입력하기 쉬운 이름으로 프로세스명 설정
$PROGRAM_NAME = "rubydaemon"

# 새 자식 프로세스를 생성하고 부모를 종료한다.
# 이렇게 하면 프로세스가 "고아"가 되며 데몬이 된다.
exit if fork

# 새 세션을 만들고, 그 안에서 새 자식 프로세스를 생성한 뒤
# 현재 프로세스를 종료한다.
Process.setsid
exit if fork

# 1초마다 현재 시간을 파일에 기록
loop do
  File.open("/tmp/rubydaemon.log", "a") { |f| f.puts(Time.now) }
  sleep(1)
end

STDIN, STDOUT, STDERR 재연결하기

위 코드의 또 다른 문제는 기존 STDOUT 등이 그대로 유지된다는 점입니다. 터미널에서 데몬을 실행하면 데몬이 STDOUT에 출력하는 내용이 모두 터미널로 전달됩니다. 좋지 않겠죠.

다행히 STDIN, STDOUT, STDERR는 원하는 경로로 다시 연결(reopen)할 수 있습니다. 여기서는 /dev/null로 리디렉션하겠습니다.

# 선택 사항: 입력하기 쉬운 이름으로 프로세스명 설정
$PROGRAM_NAME = "rubydaemon"

# 새 자식 프로세스를 생성하고 부모를 종료한다.
exit if fork

# 새 세션을 만들고, 그 안에서 새 자식 프로세스를 생성한 뒤
# 현재 프로세스를 종료한다.
Process.setsid
exit if fork

# 표준 입출력 스트림을 /dev/null로 재연결
STDIN.reopen "/dev/null"
STDOUT.reopen "/dev/null", "a"
STDERR.reopen '/dev/null', 'a'

# 1초마다 현재 시간을 파일에 기록
loop do
  File.open("/tmp/rubydaemon.log", "a") { |f| f.puts(Time.now) }
  sleep(1)
end

작업 디렉터리 변경하기

마지막 문제는 데몬의 작업 디렉터리가 스크립트를 실행한 당시의 디렉터리 그대로라는 점입니다. 나중에 그 디렉터리를 삭제해 버릴 수도 있으니 좋은 생각이 아니죠. 그래서 작업 디렉터리를 /(루트)로 변경하겠습니다.

# 선택 사항: 입력하기 쉬운 이름으로 프로세스명 설정
$PROGRAM_NAME = "rubydaemon"

# 새 자식 프로세스를 생성하고 부모를 종료한다.
exit if fork

# 새 세션을 만들고, 그 안에서 새 자식 프로세스를 생성한 뒤
# 현재 프로세스를 종료한다.
Process.setsid
exit if fork

# 표준 입출력 스트림을 /dev/null로 재연결
STDIN.reopen "/dev/null"
STDOUT.reopen "/dev/null", "a"
STDERR.reopen '/dev/null', 'a'

# 작업 디렉터리를 루트로 변경
Dir.chdir("/")

# 1초마다 현재 시간을 파일에 기록
loop do
  File.open("/tmp/rubydaemon.log", "a") { |f| f.puts(Time.now) }
  sleep(1)
end

어디서 많이 본 듯한 코드 아닌가요?

이 일련의 단계는 Process.daemon 메서드가 Ruby 코어에 추가되기 전까지 모든 Ruby 데몬이 반드시 수행해야 했던 절차 그 자체입니다. 실제로 저는 Rails 4.x에서 제거된 Process 모듈용 ActiveSupport 확장에서 이 코드를 거의 그대로 가져왔습니다. 해당 메서드는 링크된 저장소에서 확인할 수 있습니다.