Asynchronous DNS with EventMachine

Author: Samuel Williams When: Saturday, 20 October 2012

DNS resolution in a typical application is a blocking operation. This is especially problematic if your program is event driven as you can cause the run loop to stall while waiting for a response. RubyDNS provides a fully featured asynchronous DNS resolver built on top of EventMachine, and can be used to minimise the latency of name resolution in your program.


require 'rubydns'

resolver = RubyDNS::Resolver.new([[:udp, "8.8.8.8", 53], [:tcp, "8.8.8.8", 53]])

EventMachine::run do
	resolver.query('www.codeotaku.com') do |response|
		case response
		when RubyDNS::Message
			response.answer.each do |answer|
				host = answer[0].to_s
				ttl = answer[1]
				resource_class = answer[2].class.name.split('::', 4)[-1]
				address = answer[2].address.to_s
			
				puts host.ljust(20) + ttl.to_s.ljust(5) + resource_class.ljust(15) + address.to_s
			end
		when RubyDNS::ResolutionFailure
			puts "Error"
		end
		
		EventMachine::stop
	end
end

# Gives the output
# www.codeotaku.com   287  IN::A          108.162.195.101
# www.codeotaku.com   287  IN::A          108.162.196.199

In your own code you'd probably want to use the new resolver.addresses_for method which helpfully returns a list of addresses.

Why?

Event driving programming is pretty straight forward. You essentially have a loop that reads events and responds to them. Processing is kept to a minimum per cycle, otherwise the loop stalls and becomes unresponsive.

Software that uses a network for communication typically relies on name resolution. In particular, RubyDNS::Server is primarily interested in two things - receiving incoming requests and sending out a response - and - sending out requests and waiting for a response.

DNS resolution is one task that is typically done using operating system functions such as gethostbyname or getaddrinfo. The main problem is that these functions cause your process to sleep until a result is available.

(Have you ever noticed how sometimes in games, the entire game stalls when logging in or listing servers? This is very often due to name resolution latency affecting the main event loop)

If RubyDNS uses these functions, no other events can be processed while we are waiting for the operating system to respond. In practice, this means that RubyDNS::Server may perform poorly if many people are using it simultaneously.

To avoid these problems, RubyDNS recently introduced its own RubyDNS::Resolver which provides robust asynchronous DNS resolution built on top of EventMachine. This resolver isn't just for RubyDNS::Server though, it can be used in any EventMachine event driven code that wants high performance name resolution.

Implementation

Given a request, which consists of one or more DNS questions, our resolver firstly checks whether UDP is a suitable transport. DNS packets are typically routed over UDP but if the packet is too big it should use TCP:

<![CDATA[
# `message` is the outgoing DNS request. `servers` is an array of potential
# upstream servers who might be able to provide an answer.
def initialize(message, servers, options = {}, &block)
	@message = message
	@packet = message.encode
				
	@servers = servers.dup
				
	# We select the protocol based on the size of the data:
	if @packet.bytesize > UDP_TRUNCATION_SIZE
		@servers.delete_if{|server| server[0] == :udp}
	end
				
	# Measured in seconds:
	@timeout = options[:timeout] || 5
				
	@logger = options[:logger]
end
	]]>

With this list of candidates, we connect to each one and send the request. In all failure cases, we try the next server if one is available, otherwise if no servers have been successful we signal a resolution failure:

<![CDATA[
def try_next_server!
	if @request
		@request.close_connection
		@request = nil
	end
				
	if @servers.size > 0
		@server = @servers.shift

		# We make requests one at a time to the given server, naturally the servers
		# are ordered in terms of priority.
		case @server[0]
		when :udp
			@request = UDPRequestHandler.open(@server[1], @server[2], self)
		when :tcp
			@request = TCPRequestHandler.open(@server[1], @server[2], self)
		else
			raise InvalidProtocolError.new(@server)
		end
					
		# Setting up the timeout...
		EventMachine::Timer.new(@timeout) do
			try_next_server!
		end
	else
		# Signal that the deferrable has failed and resolution was not possible:
		fail ResolutionFailure.new("No available servers responded to the request.")
	end
end
	]]>

We then wait until EventMachine tells us one of two things: some response was received or there was a timeout. If we receive a response, as long as it wasn't truncated, we are successful:

<![CDATA[
def process_response!(response)
	if response.tc != 0
		# We hardcode this behaviour for now.
		try_next_server!
	else
		succeed response
	end
end
	]]>

In practice, we use EventMachine::Deferrable to handle this signalling. Using deferrables ultiamtely led to concise and reliable code and I was very happy with the results. I'd recommend taking a look at the full source code.

Comments

Leave a comment

Please note, comments must be formatted using Markdown. Links can be enclosed in angle brackets, e.g. <www.codeotaku.com>.