Written
on
A HTTP(S) healthcheck script
Problem
I often find myself having to run a simple healthchecking against a webserver, or to verify that a website I’m migrating runs on the new server (before I switch DNS).
And while messing with /etc/hosts
is a way, sometimes it’s nicer to have
a simple tool to simply run some requests without much fuss.
Solution
A relatively simple Ruby script listed below that allows to run HTTP healthchecks against multiple targets.
Examples of use:
$ ./healthcheck.rb -t 0.5 http://wejn.org/dp/ -d sha1
74f4cb313bfc15e832b205f04c36c237e7057222
$ ./healthcheck.rb -t 0.5 http://wejn.org/dp/ -d md5
38556fe7297f9c0a50b0f12d8906a2d8
$ ./healthcheck.rb -t 0.5 http://wejn.org/dp/
SHA1 = 74f4cb313bfc15e832b205f04c36c237e7057222
MD5 = 38556fe7297f9c0a50b0f12d8906a2d8
$ ./healthcheck.rb -t 0.05 http://wejn.org/dp/
Timeout!
$ ./healthcheck.rb -t 0.5 http://wejn.org/tmp/hello.txt -b
world!
$ ./healthcheck.rb http://wejn.org/ http://wejn.cz/ http://web4u.cz/ \
http://google.com/ http://piptopia.com/ -B
369:128:58:378:425
$ healthcheck.rb -c 81.91.81.103 http://ruby.cz/ -b | grep ruby
<p>The document has moved <a href="https://ruby.cz/">here</a>.</p>
Below is the script, or a (plaintext version) to download.
#!/usr/bin/ruby
=begin
Purpose:
Performs HTTP/HTTPs request to check whether some node responds
in given time-frame. You can specify alternate connect IP/port
combination (which is useful to test LVS clusters with VHOST
based webservice) and also whether you want document body as
output or its (sha1 or md5) digest.
In current version also supports request timing (benchmark)
and multiple urls.
Author: Wejn <wejn at box dot cz>
License: GPLv2 (without the "latter" option)
Requires: Ruby >= 1.8
TS: 20080403013000
Background info: https://wejn/org/stuff/healthcheck.rb.html
=end
require 'net/http'
require 'net/https'
require 'optparse'
require 'ostruct'
require 'uri'
require 'timeout'
require 'digest/sha1'
require 'digest/md5'
Settings = OpenStruct.new
Settings.supported_hashes = %w[SHA1 MD5]
# Prepare commandline options parser
opts = OptionParser.new do |opts|
opts.banner = "Usage: #{File.basename($0)} [options] <url>+"
opts.separator "You must specify full URL as url"
opts.separator "Available options:"
opts.on('-c', '--connect-ip IP', String,
'IP to connect to [default: from url]') do |ip|
raise OptionParser::InvalidArgument, \
"invalid ip" unless ip =~ /^(\d+\.){3}\d+$/
Settings.connect_ip = ip
end
opts.on('-p', '--connect-port PORT', Integer,
'Port to connect to [default: from url]') do |port|
Settings.connect_port = port
end
opts.on('-s', '--source-ip IP', String,
'IP to connect from [default: autoselect]') do |ip|
raise OptionParser::InvalidArgument, \
"invalid ip" unless ip =~ /^(\d+\.){3}\d+$/
Settings.source_ip = ip
end
opts.on('-t', '--timeout TO', Float,
'Timeout on connection [default: none]') do |to|
Settings.timeout = to
end
opts.on('-b', '--show-body', 'Show body, not digests') do
raise OptionParser::InvalidArgument, \
"can't show both body and digest(s)" if Settings.digests
raise OptionParser::InvalidArgument, \
"can't show body and run benchmark" if Settings.benchmark
Settings.show_body = true
end
opts.on('-d', '--show-digest DIGEST', String,
"Show digest [default: #{Settings.supported_hashes.join(', ')}]",
"May be given multiple times") do |dig|
raise OptionParser::InvalidArgument, \
"can't show both body and digest(s)" if Settings.show_body
raise OptionParser::InvalidArgument, \
"can't show digest(s) and run benchmark" if Settings.benchmark
dig.upcase!
raise OptionParser::InvalidArgument, \
"invalid digest: #{dig}" unless Settings.supported_hashes.include?(dig)
Settings.digests ||= []
Settings.digests << dig unless Settings.digests.include?(dig)
end
opts.on('-v', '--verbose', "Be verbose, display progress") do
Settings.verbose = true
end
opts.on('-B', '--benchmark', "Display benchmark") do
raise OptionParser::InvalidArgument, \
"can't show both body and digest(s)" if Settings.show_body
raise OptionParser::InvalidArgument, \
"can't show digest(s) and run benchmark" if Settings.benchmark
Settings.benchmark = true
end
opts.on('-E', '--error-text ET', String,
"What to display on benchmark error",
"[default: ERROR|TIMEOUT|BODY]") do |be|
Settings.error_text = be
end
opts.on('-C', '--check-body CB', String,
"What to check body against during benchmark") do |cb|
Settings.check_body = cb
end
opts.on_tail('-h', '--help', 'Show this message') do
$stderr.puts opts
exit 0
end
end
# Parse commandline options
begin
opts.parse!(ARGV)
raise "no url given (--help for help)" if ARGV.empty?
Settings.urls = []
wrong_urls = []
for url in ARGV
uri = URI.parse(url)
if %w[http https].include?(uri.scheme)
Settings.urls << uri
else
wrong_urls << url
end
end
raise "invalid URL(s): " + wrong_urls.join(' ') unless wrong_urls.empty?
rescue
$stderr.puts "Error: #{$!}"
exit 1
end
TestResponse = Struct.new(:status, :body, :digests, :time)
# Main test routine that's responsible for the http(s) request
def run_test(uri)
uri = URI.parse(uri) unless uri.kind_of?(URI)
ret = TestResponse.new
ret.status = :ok
# setup connection
unless %w[http https].include?(uri.scheme)
return [:error, "Unsupported URL scheme: #{uri.scheme}"]
end
http = Net::HTTP.new(Settings.connect_ip || uri.host,
Settings.connect_port || uri.port)
http.local_host = Settings.source_ip if Settings.source_ip
if uri.scheme == "https" # enable SSL/TLS
http.use_ssl = true
http.verify_mode = OpenSSL::SSL::VERIFY_NONE
end
# Prepare worker that will probe via HTTP
worker = proc do
http.start do
http.instance_variable_set("@address", uri.host)
http.instance_variable_set("@port", uri.port)
q = uri.path.empty? ? "/" : uri.path
q += "?" + uri.query unless uri.query.nil?
http.request_get(q) do |res|
ret.body = res.body
ret.digests = {}
for d in Settings.supported_hashes
ret.digests[d] = Digest.const_get(d).hexdigest(res.body)
end
end
end
end
# Execute worker with or without timeout, catching all errors
start_time = nil
end_time = nil
begin
start_time = Time.now
if Settings.timeout
timeout(Settings.timeout, &worker)
else
worker.call
end
end_time = Time.now
rescue Timeout::Error
ret.status = :timeout
ret.body = "Timeout!"
rescue
ret.status = :error
ret.body = "Error: #{$!} (#{$!.class})"
rescue Interrupt
ret.status = :interrupted
ret.body = "Interrupted."
end
if start_time && end_time
ret.time = ((end_time - start_time)*1000).to_i
end
ret
end
bench = []
all_ok = true
# For each url requested ...
for url in Settings.urls
$stderr.puts "Testing url: #{url} ..." if Settings.verbose
# Run test, remember status
resp = run_test(url)
all_ok &&= (resp.status == :ok)
exit 1 if resp.status == :interrupted
# Display results based on config
if Settings.benchmark
if resp.status == :ok
if !Settings.check_body || resp.body.strip == Settings.check_body.strip
bench << resp.time
else
bench << (Settings.error_text || "BODY")
all_ok = false # body not equal, we die in pain
end
else
bench << (Settings.error_text || resp.status.to_s.upcase)
end
elsif resp.status == :ok
if Settings.show_body
print resp.body
else
Settings.digests ||= Settings.supported_hashes
if Settings.digests.size == 1
puts resp.digests[Settings.digests.first]
else
for d in Settings.digests
puts "#{d} = #{resp.digests[d]}"
end
end
end
else
puts resp.body
end
$stdout.flush
if Settings.verbose
$stderr.puts "" unless resp.body[-1] == ?\n || Settings.benchmark
$stderr.puts "Took: #{resp.time}"
$stderr.puts ""
end
end
# Display benchmark at the end
unless bench.empty?
puts bench.map { |x| x.to_s }.join(":")
end
# Exit accordingly
exit(all_ok ? 0 : 1)