Ruby application servers are typically used together with a web server like nginx. When user requests a page from your Rails app, nginx delegates the request to the application server. But how exactly does that work? How does nginx talk with unicorn?
One of the most efficient options is to use unix sockets. Let's see how they work! In this post we'll start with the basics of sockets, and end by creating our own simple application server that is proxied by nginx.
Sockets allow programs to talk with each other as if they were writing to or reading from a file. In this example, Unicorn creates the socket and monitors it for connections. Nginx can then connect to the socket and talk with Unicorn.
What's a unix socket?
Unix sockets let one program talk to another in a way that kind of resembles working with files. They're a type of IPC, or inter-process communication.
To be accessible via a socket, your program first creates a socket and "saves" it to disk, just like a file. It monitors the socket for incoming connections. When it receives one, it uses standard IO methods to read and write data.
Ruby provides everything you need to work with unix sockets via a couple of classes:
UNIX*Server* - It creates the socket, saves it to disk, and lets you monitor it for new connections.
UNIX*Socket* - Open existing sockets for IO.
NOTE: Other kinds of sockets exist. Most notably TCP sockets. But this post only deals with unix sockets. How do you tell the difference? Unix sockets have file names.
The Simplest Socket
We're going to look at two little programs.
The first is is the "server." It simply creates an instance of the UnixServer
class, then uses server.accept
to wait for a connection. When it receives a connection, it exchanges a greeting.
It's worth noting that both the accept
and readline
methods block program execution until they receive what they're waiting for.
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
So we have a server. Now all we need is a client.
In the example below, we open the socket created by our server. Then we use normal IO methods to send and receive a greeting.
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
To demonstrate, we first need to run the server. Then we run the client. You can see the results below:
Example of a simple UNIX socket client/server interaction. The client is on the left. The server is on the right.
Interfacing with nginx
Now that we know how to create a unix socket "server" we can easily interface with nginx.
Don't believe me? Let's do a quick proof-of-concept. I'm going to adapt the code above to make it print out everything it receives from the socket.
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
Now if I configure nginx to forward requests to the socket at /tmp/socktest.sock
I can see what data nginx is sending. (Don't worry, we'll discuss configuration in a minute)
When I make a web request, nginx sends the following data to my little server:
Pretty cool! It's just a normal HTTP request with a few extra headers added. Now we're ready to build a real app server. But first, let's discuss nginx configuration.
Installing and Configuring Nginx
If you don't already have nginx installed on your development machine, take a second and do that now. It's really easy on OSX via homebrew:
brew install nginx
Now we need to configure nginx to forward requests on localhost:2048 to a upstream server via a socket named /tmp/socktest.sock
. That name isn't anything special. It just needs to match the socket name used by our web server.
You can save this configuration to /tmp/nginx.conf
and then run nginx with the command nginx -c /tmp/nginx.conf
to load it.
# 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 http://app;
}
}
}
This configuration causes nginx to run like a normal terminal app, not like a daemon. It also writes all logs to stdout. When you run nginx it should look something like so:
Nginx running in non-daemon mode.
A DIY Application Server
Now that we've seen how to connect nginx to our program, it's a pretty simple matter to build a simple application server. When nginx forwards a request to our socket it's a standard HTTP request. After a little tinkering I was able to determine that if the socket returns a valid HTTP response, then it'll be displayed in the browser.
The application below takes any request and displays a timestamp.
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
Now if I fire up nginx as well as my script, I can go to localhost:2048. Requests are sent to my app. And responses are rendered by the browser. Pretty Cool!
HTTP requests are logged to STDOUT by our simple app server
And here is the glorious fruit of our labors. Behold! A Timestamp!
The server returns a timestamp which is displayed in the browser