Hace algunas semanas me puse a investigar un poco sobre el despliegue de aplicaciones hechas con Ruby on Rails, ya que en mi laburo vamos a comenzar a desarrollar algunas apps en esta plataforma, y quería tener cocinado el tema de infraestructura antes que sea necesario ponerlas en producción.

Leyendo variados artículos en la blogósfera, he visto que una buena dupla como software “plataforma base de Rails” es el uso de Mongrel y Nginx, siendo el primero el servidor de aplicaciones y el último el frontend HTTP. Esta combinación nos permite instalar una aplicación web en un servidor de recursos no muy abundantes, y aún así servir a una buena cantidad de usuarios concurrentes. Obvio que no he hecho métricas propias, pero Internet está lleno de este tipo de análisis.

El problema entonces se divide en dos:

  • Instalar el software base necesario en los servidores
  • Realizar el despliegue (deploy) de la aplicación en si

En este artículo vamos a ver cómo hago yo para mantener el software base (mongrel, nginx, gemas, …) instalado y configurado en los servidores que correspondan. Esta tarea la hago con Puppet, una herramienta que en un artículo anterior he comentado que me permite administrar una gran cantidad de servidores sin tener que volverme un esclavo.A modo de preámbulo, les voy a contar que mis servidores son todos Debian Lenny (stable) y usamos Rails 2.3.x.

Como primera medida, vamos a definir en Puppet lo necesario para que Ruby y Rails estén instalados, ya que son el sistema operativo de nuestra aplicación web.

Para poder instalar Rails 2.3.x desde el instalador de gemas, tenemos primero que instalar rubygems y rubygems1.8, pero Debian Lenny no trae la versión 1.3.1 que es la necesaria, por lo tanto acá es el único lugar donde vamos a hacer un poco de trampa, y vamos a instalar el paquete que podemos bajar desde la versión experimental acá y acá.

¿Cómo instalar un paquete que no está en la distribución actual? Mi solución seguro no fue la mejor, pero anduvo! Lo que hago es vía Puppet envío el paquete al servidor en cuestión y después le digo que lo instale usando dpkg, de esta manera:

# Instalamos rubygems 1.3.1 desde paquetes de experimental, ya que Lenny no lo tiene
# La versión 1.3.1 es necesaria para rails 2.3.x

class rubygems {
    file {
        "/usr/src/rubygems1.8_1.3.1-1_all.deb":
            source => "puppet://puppet.marketingsur.com/files/packages/rubygems1.8_1.3.1-1_all.deb",
            owner => root,
            group => root,
            mode => 0644;
        "/usr/src/rubygems_1.3.1-1_all.deb":
            source => "puppet://puppet.marketingsur.com/files/packages/rubygems_1.3.1-1_all.deb",
            owner => root,
            group => root,
            mode => 0644;
    }

    package {
        "ruby1.8":
            ensure => installed;
        "ruby1.8-dev":
            ensure => installed;
        "make":
            ensure => installed;
        "rubygems1.8_1.3.1":
            source => "/usr/src/rubygems1.8_1.3.1-1_all.deb",
            provider => dpkg,
            ensure => installed,
            require => File["/usr/src/rubygems1.8_1.3.1-1_all.deb"];
        "rubygems_1.3.1":
            source => "/usr/src/rubygems_1.3.1-1_all.deb",
            provider => dpkg,
            ensure => installed,
            require => [ Package["ruby1.8"], Package["rubygems1.8_1.3.1"],
                         File["/usr/src/rubygems_1.3.1-1_all.deb"] ];
    }
}

Con esto ya puedo definir la clase que me va a mantener instalado rails desde gemas:

# Instalamos rails desde gems porque la versión Debian es muy vieja
class rails {

    include rubygems

    package {
        "rails":
            provider => gem,
            ensure => installed,
            require => Package["rubygems_1.3.1"];
    }
}

Nótese la libertad que Puppet no provee al mantenerse independiente del sistema de paquetes, mediante la palabra clave provider puedo decirle que instale paquetes desde otras fuentes, permitiendonos seguir tratando a ese recurso como un paquete abstracto.

Hasta aquí tenemos entonces el lenguaje ruby 1.8 instalado, y rails 2.3.x listo para ser usado.

Mongrel Cluster

Mongrel es una biblioteca Ruby que se usa para armar servidores web, está diseñada para tener buena performance sirviendo contenido dinámico, pero no estático… es por eso que lo último se lo dejamos a Nginx, como explico más abajo.

Volviendo a mongrel, la distribución Debian tiene un paquete llamado mongrel-cluster que nos deja todo bastante listo para usar, con scripts de inicio y apagado de los clusters, por lo que el trabajo adicional que hay que hacer es relativamente poco.

Primero armamos una clase para instalar el software y mantener funcionando el servicio:

class mongrel {
    include rails

    package {
        "mongrel-cluster":
            ensure => installed,
            require => Package["rails"];
    }

    service {
        "mongrel-cluster":
            ensure => running,
            enable => true,
            hasrestart => true,
            hasstatus => true,
            require => Package["mongrel-cluster"];
    }
}

Luego escribimos la definición que va a permitir dar vida a varios clusters mongrel:

define mongrel-cluster-app ( $ipaddr="127.0.0.1",
                             $appdir="",
                             $port=8000,
                             $servers=3) {
    $root = $appdir ? {
        "" => "/var/www/${name}",
        default => "${appdir}",
    }

    include mongrel

    file {
        "/etc/mongrel-cluster/sites-available/${name}.conf":
            owner => root,
            group => root,
            mode => 0644,
            content => template("mongrel/cluster-app.conf.erb"),
            require => Package["mongrel-cluster"],
            notify => Service["mongrel-cluster"];

        "/etc/mongrel-cluster/sites-enabled/${name}.conf":
            ensure => "/etc/mongrel-cluster/sites-available/${name}.conf",
            require => File["/etc/mongrel-cluster/sites-available/${name}.conf"],
            notify => Service["mongrel-cluster"];
    }
}

Esto configura el cluster mongrel en cuestión, activándolo y escribiendo el archivo de configuración a partir del siguiente simple template:

---
address: <%= ipaddr %>
log_file: log/mongrel.log
port: "<%= port %>"
cwd: <%= root %>/current
environment: production
pid_file: tmp/pids/mongrel.pid
servers: <%= servers %>

Con esto, tenemos el software mongrel-cluster instalado, y el cluster específico que va a ejecutar nuestra aplicación configurado y corriendo, listo para atender los requerimientos de los usuarios cuando le lleguen a través de nginx.

Nginx

Primero vamos a empezar con el webserver, el que se encarga de atender a los requests de los usuarios. Éste es un proyecto Ruso que está ganando popularidad por su gran velocidad y poco consumo de memoria. Mi anterior favorito era lighttpd, pero he tenido varios problemas de uso de memoria, y por lo que vi el proyecto está bastante frenado.

Del lado de Puppet, primero defino una clase que mantiene instalado y corriendo el servidor nginx, nada del otro mundo:

class nginx {
    package {
        "nginx":
            ensure => installed;
    }

    service {
        "nginx":
            ensure => running,
            enable => true,
            hasrestart => true,
            hasstatus => false,
            require => Package["nginx"];
    }
}

Luego, tengo una definición que me permite generar varias configuraciones de sitios basados en nginx y mongrel:

define nginx-railsapp ( $rootdir="",
                        $ipaddr="",
                        $port=80,
                        $mongrels=["127.0.0.1, 8000, 3"] ) {
    $root = $rootdir ? {
        "" => "/var/www/${name}",
        default => "${rootdir}",
    }
    $listen = $ipaddr ? {
        "" => "${port}",
        default => "${ipaddr}:${port}",
    }
    $upstream_name = "mongrel-${name}"

    include nginx

    file {
        "${root}":
            ensure => directory,
            owner => www-data,
            group => www-data,
            mode => 0755;
        "/etc/nginx/sites-available/${name}":
            content => template("nginx/railsapp.conf.erb"),
            owner => root,
            group => root,
            mode => 0644,
            require => Package["nginx"],
            notify => Service["nginx"];
        "/etc/nginx/sites-enabled/${name}":
            ensure => "/etc/nginx/sites-available/${name}",
            require => File["/etc/nginx/sites-available/${name}"],
            notify => Service["nginx"];
    }
}

Esta definición acepta 4 parámetros: el directorio raíz donde va a estar la app, la dirección IP en la que va a atender el webserver, su puerto, y las instancias mongrel que van a darle vida a la aplicación. Noten que por defecto todos los parámetros tienen un valor configurado, por lo que son opcionales.

Otro detalle a tener en cuenta es el parámetro mongrels, se puede ver que es una lista de strings, esa string define una tupla de 3 valores separados por coma, el primero corresponde al IP donde los mongrels atienden, el segundo es el puerto inicial, y el tercer valor la cantidad de instancias.

La receta configura un par de directorios y además el archivo de configuración de la aplicación, que obtiene a partir de un template (railsapp.conf.erb) que incluyo acá abajo:

###
# ATENCION: Archivo de configuración manejado por Puppet!
###

upstream <%= upstream_name %> {
    #fair;
<% mongrels.each do |mongrel| -%>
<% mongrel_ip, mongrel_port, mongrel_qty = mongrel.gsub(' ', '').split(',') -%>
<% mongrel_qty.to_i.times do |n| -%>
    server <%= mongrel_ip %>:<%= mongrel_port.to_i + n %>;
<% end -%>
<% end -%>
}

server {
    listen <%= listen %>;
    server_name <%= name %> www.<%= name %>;
    charset off;

    # this rewrites all the requests to the maintenance.html
    # page if it exists in the doc root. This is for capistrano’s
    # disable web task
    if (-f <%= root %>/system/maintenance.html) {
        rewrite ^(.*)$ /system/maintenance.html last;
        break;
    }

    location / {
        root <%= root %>/current;
        index index.html index.htm;
    }

    # / -> first search for local index.html then go to <%= upstream_name %>
    location ~ ^/$ {
        if (-f /index.html) {
            rewrite (.*) /index.html last;
        }
        proxy_pass http://<%= upstream_name %>;
    }

    # rails caching: searching first for $action.html local pages
    location / {
        if (!-f $request_filename.html) {
            proxy_pass http://<%= upstream_name %>;
        }
        rewrite (.*) $1.html last;
    }

    # serve static files directly
    location ~ .html {
        root <%= root %>/current/public;
    }

    location ~* ^.+\.(jpg|jpeg|gif|png|ico|css|zip|tgz|gz|rar|bz2|doc|xls|exe|pdf|ppt|txt|tar|mid|midi|wav|bmp|rtf|js|mov)$ {
        root <%= root %>/current/public;
    }

    # resend everything else to <%= upstream_name %>
    location / {
        proxy_pass  http://<%= upstream_name %>;
        proxy_redirect     off;
        proxy_set_header   Host             $host;
        proxy_set_header   X-Real-IP        $remote_addr;
        proxy_set_header   X-Forwarded-For  $proxy_add_x_forwarded_for;
    }
}

Éste es un archivo de configuración que encontré por ahi y que lo adapté al formato de templates de ERB. Básicamente lo que hace es servir todo lo que sea contenido estático, y para lo dinámico (la aplicación web en si) hace de proxy ruteando los requests a el o los clusters mongrel que hayamos definido. Esto nos permitiría tener varios servidores atendiendo a la aplicación web, para repartir la carga.

El toque final

Luego de definir todos estos recursos, no nos queda más que usarlos. Ésto lo podemos hacer con una clase que defina la aplicación en cuestión, de la siguiente manera:

# midominio.com Web App (proxy balancer + backend)
class midominio-webapp {
    include rails
    include mongrel

    $mongrel_ipaddr = "127.0.0.1"
    $mongrel_port = 8000
    $mongrel_servers = 5

    $needed_gems = ["gema1", "gema2", "gema3"]

    nginx-railsapp {
        "midominio.com":
            ipaddr => "1.2.3.4",
            mongrels => ["${mongrel_ipaddr}, ${mongrel_port}, ${mongrel_servers}"];
    }
    mongrel-cluster-app {
        "midominio.com":
            ipaddr => "${mongrel_ipaddr}",
            port => "${mongrel_port}",
            servers => "${mongrel_servers}";
    }

    package {
        $needed_gems:
            provider => gem,
            ensure => installed,
            require => Package["rails"];
    }
}

Luego esta clase la incluímos en el nodo que corresponda, y listo… al rato tenemos instalada la infraestructura para hostear nuestra aplicación web.

Espero se animen a usar Puppet si aún no lo han hecho. Su sintaxis no es de lo mas hermoso que puede existir, pero los beneficios que trae hacen ese tema algo insignificante.

En la segunda parte vamos a hablar de cómo automatizar la instalación de la aplicación en los servidores, usando Capistrano.