Scaling Windows Azure Roles using Ruby
While Brian started discussing gathering Windows Azure diagnostics from PHP on Monday, I’m going to jump straight into scaling Windows Azure from Ruby and discuss gathering diagnostic information in a later post. Diagnostic information is great if you want to determine when to scale, but I think first we need to talk about how to accomplish scaling.
Scaling out an application hosted on Windows Azure is pretty easy; it’s just updating a field in a configuration file that tells Windows Azure how many instances to create for the role that hosts your application, and then uploading the new configuration file so it takes effect. The service configuration file is documented at https://msdn.microsoft.com/en-us/library/windowsazure/ee758710.aspx, and the following is an example of what one looks like:
<?xml version="1.0" encoding="utf-16"?>
<ServiceConfiguration xmlns:xsi="https://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="https://www.w3.org/2001/XMLSchema" serviceName="" osFamily="1" osVersion="*" xmlns="https://schemas.microsoft.com/ServiceHosting/2008/10/ServiceConfiguration">
<Role name="HelloRole">
<ConfigurationSettings>
<Setting name="Microsoft.WindowsAzure.Plugins.Diagnostics.ConnectionString" value="DefaultEndpointsProtocol=https;AccountName=storageaccountname;AccountKey=storageaccountkey />
</ConfigurationSettings>
<Instances count="1" />
<Certificates />
</Role>
<Role name="ByeRole">
<ConfigurationSettings>
<Setting name="Microsoft.WindowsAzure.Plugins.Diagnostics.ConnectionString" value="UseDevelopmentStorage=true" />
</ConfigurationSettings>
<Instances count="1" />
<Certificates />
</Role>
</ServiceConfiguration>
See the count attribute on the Instances element? That’s what we want to change to control how many instances Windows Azure creates for a given role. There are a couple things we need to know in order to accomplish this though. We need to know the role name, the hosted service name, and the deployment slot that the role is deployed into. You can get this information through the Windows Azure portal, or using a tool such as waz-cmd (https://github.com/smarx/waz-cmd/blob/master/README.md.) Once you have this information, you need to perform the following steps:
- Use the Get Deployment operation to return the configuration for the role you want to modify (https://msdn.microsoft.com/en-us/library/windowsazure/ee460804.aspx discusses Get Deployment)
- Extract the service configuration from the response. It’s base64 encoded, so you have to decode that to obtain the XML file.
- Find the section for the role you want to modify the instance count for and change the value.
- Use the Change Deployment Configuration operation to upload the modified configuration (https://msdn.microsoft.com/en-us/library/windowsazure/ee460809.aspx discusses Change Deployment Configuration)
- At this point, Windows Azure will increase/decrease the number of running instances to match the number you’ve specified.
You might also want to look at the role status to check that all your roles are in a 'Ready' state after changing the instance count, since it may take a few minutes for Windows Azure to spin up a new role.
Here’s an example of what you might do to accomplish this using Ruby:
require 'httparty'
require 'openssl'
require 'base64'
require 'nokogiri'
class Hash
def seek(*_keys_)
last_level = self
sought_value = nil
_keys_.each_with_index do |_key_, _idx_|
if last_level.is_a?(Hash) && last_level.has_key?(_key_)
if _idx_ + 1 == _keys_.length
sought_value = last_level[_key_]
else
last_level = last_level[_key_]
end
else
break
end
end
sought_value
end
end
class AzureRole
def initialize(params)
@subscription = params[:subscription]
@service_name = params[:service_name]
@role_name = params[:role_name]
@deployment_slot = params[:deployment_slot]
@pem_file = File.read(params[:pem_path])
end
# return the status
def status
role_state = query_azure.seek('Deployment','RoleInstanceList','RoleInstance').reject do |entry|
entry['InstanceStatus']=='Ready'
end
if role_state.empty? then
'Ready'
else
'Not Ready'
end
end
def instances
current_config=Nokogiri::XML(Base64.decode64(query_azure["Deployment"]["Configuration"]))
current_config.xpath(role_xpath).attribute('count').value.to_i
end
def instances=(other)
current_config = get_configuration
# set the new value
current_config.xpath(role_xpath).attribute('count').value = other.to_s
new_configuration = Nokogiri::XML::Builder.new(:encoding => 'utf-8') { |xml|
xml.ChangeConfiguration('xmlns' => 'https://schemas.microsoft.com/windowsazure') {
xml.Configuration Base64.encode64(current_config.to_xml(:encoding=>'utf-8')).rstrip
}
}.to_xml
# post the new value
query_azure('post', new_configuration)
end
private
def role_xpath
"//xmlns:Role[@name='#{@role_name}']/xmlns:Instances"
end
# query the azure REST APIs
def query_azure(verb = 'get', body = '')
request_url = "https://management.core.windows.net/#{@subscription}/services/hostedservices/#{@service_name}/deploymentslots/#{@deployment_slot}"
request_options = {:headers => {'x-ms-version' => '2011-08-01',
'Content-Type' => 'application/xml'},
:format => :xml,
:body => body,
:pem => @pem_file
}
request_url = "#{request_url}/?comp=config" if verb == 'post'
req = verb=='get' ? HTTParty.get(request_url, request_options) : HTTParty.post(request_url, request_options)
resp = req.parsed_response
# error or result?
if (200..299).include?(req.code) then
resp
else
raise "#{resp['Error']['Code']}: #{resp['Error']['Message']}"
end
end
end
Here's an explanation of how this works.
Expected Initialization Values
There are a few values that need to be passed in; your subscription ID, the service name. the role name, the deployment name, and the file containing your private key, which is used to authenticate to Windows Azure. The AzureAdmin class expects these parameters as a hash, so creating a new object would look like this: foo=AzureAdmin.new(:subscription=>'subscription guid', :service_name=>'myawesomeservice', :role_name=>'winning', :deployment_slot=>'staging', :pem_path=>'c:\temp\certificate.pem').
You might be asking "what's a pem file?" It's the file that contains the private key for the certificate I'm using to authenticate to Windows Azure. I generated it using the following OpenSSL commands:
openssl req -x509 -nodes -days 365 -newkey rsa:1024 -keyout mycert.pem -out mycert.pem
openssl x509 -inform pem -in mycert.pem -outform der -out mycert.cer
The first line generates the private key .pem file, which you use to authenticate to Windows Azure REST APIs, and the second generates the .cer file that is uploaded to the Windows Azure portal so that it will recognize you when you authenticate.
query_azure
This method builds the URL, queries the service, and returns the result parsed into a hash.
def query_azure(verb = 'get', body = '')
request_url = "https://management.core.windows.net/#{@subscription}/services/hostedservices/#{@service_name}/deploymentslots/#{@deployment_slot}"
request_options = {:headers => {'x-ms-version' => '2011-08-01',
'Content-Type' => 'application/xml'},
:format => :xml,
:body => body,
:pem => @pem_file
}
request_url = "#{request_url}/?comp=config" if verb == 'post'
req = verb=='get' ? HTTParty.get(request_url, request_options) : HTTParty.post(request_url, request_options)
resp = req.parsed_response
# error or result?
if (200..299).include?(req.code) then
resp
else
raise "#{resp['Error']['Code']}: #{resp['Error']['Message']}"
end
end
status
This method pulls out the status from the role instance(s). If all instances are 'Ready' then it returns ready, otherwise 'Not Ready'. I didn't really want to dip into returning an array containing the status of all possible results for the user to deal with, but that might be something to investigate if you need something that gives you more granular information.
def status
role_state = query_azure.seek('Deployment','RoleInstanceList','RoleInstance').reject do |entry|
entry['InstanceStatus']=='Ready'
end
if role_state.empty? then
'Ready'
else
'Not Ready'
end
end
Instances and Instances=
Here I'm just returning and setting the instance value. Returning it is trivial, and just pulls in the value from the decoded XML. Setting it is a bit more involved as you have to build the XML response message, base64 encode the config, and pass it to query_azure as a post.
def instances
current_config=Nokogiri::XML(Base64.decode64(query_azure["Deployment"]["Configuration"]))
current_config.xpath(role_xpath).attribute('count').value.to_i
end
def instances=(other)
current_config = get_configuration
# set the new value
current_config.xpath(role_xpath).attribute('count').value = other.to_s
new_configuration = Nokogiri::XML::Builder.new(:encoding => 'utf-8') { |xml|
xml.ChangeConfiguration('xmlns' => 'https://schemas.microsoft.com/windowsazure') {
xml.Configuration Base64.encode64(current_config.to_xml(:encoding=>'utf-8')).rstrip
}
}.to_xml
# post the new value
query_azure('post', new_configuration)
end
Patch the Hash class
This little piece of code patches the Hash class to add a seek method. This makes it easier to deal with the nested structures returned by HTTParty. For example, instead of doing query_azure['Deployment']['RoleInstanceList']['RoleInstance'].... I can instead do query_azure.seek('Deployment','RoleInstanceList','RoleInstance'). Looks a little cleaner. Much thanks to Corey O'Daniel, who's blog I found this on (https://coryodaniel.com/index.php/2009/12/30/ruby-getting-deeply-nested-values-from-a-hash-in-one-line-of-code/).
class Hash
def seek(*_keys_)
last_level = self
sought_value = nil
_keys_.each_with_index do |_key_, _idx_|
if last_level.is_a?(Hash) && last_level.has_key?(_key_)
if _idx_ + 1 == _keys_.length
sought_value = last_level[_key_]
else
last_level = last_level[_key_]
end
else
break
end
end
sought_value
end
end
Typical use
An example of using this would be:
test=AzureRole.new(:subscription => 'subscription id GUID',
:service_name => 'myservice',
:role_name=> 'myrole',
:deployment_slot => 'staging',
:pem_path=>'c:\temp\mycert.pem')
test.instance=test.instance+1
puts test.status
Summary
As the example code above demonstrates, it's not hard to tell Windows Azure to scale a hosted application; just one entry in a config file. And it's pretty easy to accomplish from a Ruby application using HTTParty for the REST calls, Nokogiri for XML, and OpenSSL for certificates. There are other gems that you can use to accomplish this, and there are probably cleaner ways to accomplish it using Ruby. If you have suggestions on how I might improve this code, or if there's already a better example of how to accomplish this that I've missed, let me know.