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

유니콘(Unicorn)은 nginx와 어떻게 통신할까? — Ruby 유닉스 소켓 완전 정복

Ruby 애플리케이션 서버는 보통 nginx 같은 웹 서버와 함께 사용됩니다. 사용자가 Rails 앱의 페이지를 요청하면 nginx가 그 요청을 애플리케이션 서버에 전달하죠. 그런데 이 과정은 정확히 어떻게 동작하는 걸까요? nginx는 unicorn과 어떤 방식으로 대화하는 걸까요?

가장 효율적인 방법 중 하나는 바로 유닉스 소켓(unix socket)입니다. 이 글에서는 소켓의 기본 개념부터 시작해, 마지막에는 nginx가 프록시 역할을 맡아주는 나만의 간단한 애플리케이션 서버까지 직접 만들어 보겠습니다.

유니콘(Unicorn)은 nginx와 어떻게 통신할까? — Ruby 유닉스 소켓 완전 정복

소켓을 활용하면 프로그램끼리 마치 파일에 쓰고 읽듯이 서로 대화할 수 있습니다. 위 예시에서 Unicorn이 소켓을 생성하고 연결을 감시하면, nginx가 그 소켓에 접속해 Unicorn과 통신하는 구조입니다.

유닉스 소켓이란 무엇인가?

유닉스 소켓은 한 프로그램이 다른 프로그램과 파일을 다루듯 대화할 수 있게 해주는 기술로, IPC(Inter-Process Communication, 프로세스 간 통신)의 한 종류입니다.

소켓을 통해 접근 가능한 프로그램이 되려면, 먼저 소켓을 생성한 뒤 파일처럼 디스크에 "저장"해야 합니다. 이후 들어오는 연결을 계속 감시하다가, 연결 요청이 도착하면 표준 IO 메서드를 사용해 데이터를 읽고 씁니다.

Ruby는 몇 가지 클래스를 통해 유닉스 소켓 작업에 필요한 모든 기능을 제공합니다.

  • UNIXServer — 소켓을 생성하고 디스크에 저장한 뒤, 새로운 연결을 감시할 수 있게 해줍니다.
  • UNIXSocket — 이미 존재하는 소켓을 열어 IO 작업을 수행합니다.

참고: 소켓에는 여러 종류가 있으며, 가장 대표적인 것이 TCP 소켓입니다. 다만 이 글에서는 유닉스 소켓만 다룹니다. 두 소켓을 구별하는 방법은 의외로 간단한데, 유닉스 소켓은 파일 이름을 가진다는 점입니다.

가장 간단한 소켓 예제

두 개의 작은 프로그램을 살펴보겠습니다.

첫 번째는 "서버"입니다. UNIXServer 클래스의 인스턴스를 생성한 후 server.accept로 연결을 기다리고, 연결이 수신되면 인사말을 주고받습니다.

여기서 acceptreadline 메서드는 기다리던 값을 받을 때까지 프로그램 실행을 차단(blocking)한다는 점도 눈여겨볼 만합니다.

require "socket"

server = UNIXServer.new('/tmp/simple.sock')

puts "==== Waiting for connection"
socket = server.accept

puts "==== Got Request:"
puts socket.readline

puts "==== Sending Response"
socket.write("I read you loud and clear, good buddy!")

socket.close

서버가 준비됐으니 이제 클라이언트만 있으면 됩니다.

아래 예제에서는 서버가 생성한 소켓을 열고, 일반적인 IO 메서드로 인사말을 주고받습니다.

require "socket"

socket = UNIXSocket.new('/tmp/simple.sock')

puts "==== Sending"
socket.write("Hello server, can you hear me?\n")

puts "==== Getting Response"
puts socket.readline

socket.close

직접 확인해 보려면 먼저 서버를 실행한 다음 클라이언트를 실행하면 됩니다. 결과는 아래와 같습니다.

유니콘(Unicorn)은 nginx와 어떻게 통신할까? — Ruby 유닉스 소켓 완전 정복

nginx와 연동하기

이제 유닉스 소켓 "서버"를 만드는 방법을 알았으니, nginx와도 손쉽게 연동할 수 있습니다.

믿기 어렵나요? 간단한 개념 증명(proof-of-concept)을 진행해 보겠습니다. 앞서 작성한 코드를 살짝 수정해, 소켓으로 수신한 모든 내용을 출력하도록 만들겠습니다.

require "socket"

# Create the socket and "save it" to the file system
server = UNIXServer.new('/tmp/socktest.sock')

# Wait until for a connection (by nginx)
socket = server.accept

# Read everything from the socket
while line = socket.readline
  puts line.inspect
end

socket.close

이제 nginx가 요청을 /tmp/socktest.sock 소켓으로 전달하도록 설정하면, nginx가 보내는 데이터를 그대로 들여다볼 수 있습니다. (걱정하지 마세요, 설정 방법은 곧 설명합니다.)

웹 요청을 하나 보내면, nginx는 제 작은 서버에 다음과 같은 데이터를 전송합니다.

유니콘(Unicorn)은 nginx와 어떻게 통신할까? — Ruby 유닉스 소켓 완전 정복

꽤 흥미롭죠? 몇 개의 헤더가 추가된 일반적인 HTTP 요청일 뿐입니다. 이제 진짜 애플리케이션 서버를 만들 준비가 됐습니다. 다만 그 전에 nginx 설정부터 짚고 넘어가겠습니다.

nginx 설치 및 설정

개발 환경에 아직 nginx가 설치되어 있지 않다면 지금 설치해 보세요. macOS에서는 homebrew를 이용하면 아주 간단합니다.

brew install nginx

이제 localhost:2048로 들어오는 요청을 /tmp/socktest.sock이라는 이름의 소켓을 통해 업스트림(upstream) 서버로 전달하도록 nginx를 설정해야 합니다. 이 이름 자체에는 특별한 의미가 없으며, 단지 우리 웹 서버가 사용하는 소켓 이름과 일치하기만 하면 됩니다.

아래 설정을 /tmp/nginx.conf로 저장한 뒤, nginx -c /tmp/nginx.conf 명령으로 nginx를 실행하면 해당 설정이 적용됩니다.

# Run nginx as a normal console program, not as a daemon
daemon off;

# Log errors to stdout
error_log /dev/stdout info;

events {} # Boilerplate

http {

  # Print the access log to stdout
  access_log /dev/stdout;

  # Tell nginx that there's an external server called @app living at our socket
  upstream app {
    server unix:/tmp/socktest.sock fail_timeout=0;
  }

  server {

    # Accept connections on localhost:2048
    listen 2048;
    server_name localhost;

    # Application root
    root /tmp;

    # If a path doesn't exist on disk, forward the request to @app
    try_files $uri/index.html $uri @app;

    # Set some configuration options on requests forwarded to @app
    location @app {
      proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
      proxy_set_header Host $http_host;
      proxy_redirect off;
      proxy_pass https://app;
    }

  }
}

이 설정은 nginx를 데몬이 아니라 일반 터미널 프로그램처럼 실행하게 만들고, 모든 로그를 stdout에 출력합니다. nginx를 실행하면 대략 다음과 같은 화면이 나타납니다.

유니콘(Unicorn)은 nginx와 어떻게 통신할까? — Ruby 유닉스 소켓 완전 정복

직접 만들어보는 DIY 애플리케이션 서버

nginx를 우리 프로그램에 연결하는 방법을 파악했으니, 이제 간단한 애플리케이션 서버를 만드는 일은 비교적 쉽습니다. nginx가 소켓으로 요청을 전달하면 그것은 표준 HTTP 요청입니다. 몇 번의 시행착오 끝에 알게 된 사실은, 소켓이 유효한 HTTP 응답만 반환하면 브라우저에 그대로 표시된다는 점입니다.

아래 애플리케이션은 모든 요청을 받아 현재 시각(타임스탬프)을 표시합니다.

require "socket"

# Connection creates the socket and accepts new connections
class Connection

  attr_accessor :path

  def initialize(path:)
    @path = path
    File.unlink(path) if File.exists?(path)
  end

  def server
    @server ||= UNIXServer.new(@path)
  end

  def on_request
    socket = server.accept
    yield(socket)
    socket.close
  end
end


# AppServer logs incoming requests and renders a view in response
class AppServer

  attr_reader :connection
  attr_reader :view

  def initialize(connection:, view:)
    @connection = connection
    @view = view
  end

  def run
    while true
      connection.on_request do |socket|
        while (line = socket.readline) != "\r\n"
          puts line
        end
        socket.write(view.render)
      end
    end
  end

end

# TimeView simply provides the HTTP response
class TimeView
  def render
%[HTTP/1.1 200 OK

The current timestamp is: #{ Time.now.to_i }

]
  end
end


AppServer.new(connection: Connection.new(path: '/tmp/socktest.sock'), view: TimeView.new).run

이제 nginx와 위 스크립트를 함께 실행한 뒤 localhost:2048에 접속해 보세요. 요청이 우리 앱으로 전달되고, 응답이 브라우저에 렌더링됩니다. 정말 멋지지 않나요!

유니콘(Unicorn)은 nginx와 어떻게 통신할까? — Ruby 유닉스 소켓 완전 정복

그리고 마침내, 우리 노력의 빛나는 결실이 여기 있습니다. 보십시오! 바로 타임스탬프입니다!

유니콘(Unicorn)은 nginx와 어떻게 통신할까? — Ruby 유닉스 소켓 완전 정복