De 0 a Cloud en 1 una hora

Si crees que te va a llevar demasiado tiempo el proceso de partir de la idea de una aplicación hasta subirla a la nube, te equivocas. Ya lo demostramos en este meetup del grupo Cloud Computing Spain y ahora te contamos también cómo hacerlo en este post.

Además, no sólo se trata de subir la aplicación a cloud, sino que, además, vamos a ver cómo hacerlo bien, cómo realizar el despliegue de manera óptima. ¿No me crees? Sigue leyendo.

1

¿Qué vamos a ver en este post?

El primer paso es ver cómo crear los tests de la aplicación usando BDD gracias a Cucumber. Para el desarrollo usaremos el framework Spring Boot y lo subiremos todo a nuestro repositorio Git.

Después pasaremos a la parte de Docker, donde veremos cómo es un archivo Dockerfile para una aplicación Spring Boot.

Con todo esto, ya estamos en disposición de subir nuestra imagen al repositorio de imágenes de ECR de AWS. Crearemos el stack de aplicación usando Cloudformation para automatizar el despliegue.

Por último, solo nos quedará probar que todo lo que hemos hecho está funcionando y que nuestro servicio responde correctamente usando Postman o cualquier herramienta para consumir servicios web.

Desarrollando

2

Estamos en casa viendo la tele y de repente se nos ocurre LA IDEA, esa idea que va a hacernos ricos. Lo único que tenemos que hacer es desarrollar la aplicación y subirla a la nube para que todo el mundo pueda usarla y podamos retirarnos a una isla del Caribe.

Cuando se trata de desarrollar aplicaciones, uno de los mejores modos de hacerlo es usando BDD. Este modo de desarrollar el software se apoya en los tests como hace TDD, pero centrándose en el comportamiento de la aplicación más que en las pruebas de los componentes unitariamente.

Para poner en práctica BDD debemos seguir los siguientes pasos:

  1. Definimos un escenario que contemple la funcionalidad de nuestra aplicación, por ejemplo: el servidor tiene que ser capaz de dar la vuelta a cadenas de texto.
  2. Definimos un test unitario para esa funcionalidad que haga que falle nuestro test. Al no tener aún el código desarrollado es sencillo que falle.
  3. Implementamos el código que hace que nuestro test no de error y verificamos que funciona correctamente.
  4. Volvemos al punto 1 y repetimos la iteración hasta que tengamos toda la funcionalidad contemplada.

En BDD tenemos 3 palabras que nos ayudan a crear estos escenarios:

  • Given – Describe el contexto inicial: Dado que… (quiero logarme en mi aplicación).
  • When – Hace referencia a un evento que ocurre: Cuándo… (he introducido el usuario y password correctos).
  • Then – Es la salida esperada: Entonces… (tengo que ver la pantalla de inicio).

3

La ventaja que aporta esta forma de escribir los tests es que cualquier persona es capaz de leerlos y saber que es lo que queremos hacer, por lo tanto, cualquiera sería capaz de crearlos y no necesitaría tener conocimientos técnicos, tan solo conocimientos funcionales de la aplicación.

Cuando usamos Cucumber podemos escribir nuestros test con Gherkin. Este lenguaje ayuda a definir escenarios de una forma estándar y provee de funcionalidad extra a la hora de combinarlo con Cucumber.

Veamos un ejemplo:

Feature: Server reverse message

  Scenario Outline: Server reply with reversed message
    Given I call reverse method with message <message>
    Then the response status is 200
    And the response body must contain message with value <response>

    Examples:
      | message      | response     |
      | Hello World! | !dlroW olleH |
      | Hola Mundo!  | !odnuM aloH  |

Este código tiene que ser implementado para que pueda ejecutarse. Para ello usamos el siguiente código:

import cucumber.api.java.en.And;
import cucumber.api.java.en.Given;
import cucumber.api.java.en.Then;
import com.paradigmadigital.Application;
import com.paradigmadigital.web.request.ReverseRequest;
import com.paradigmadigital.web.response.ReverseResponse;
import org.junit.Assert;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.ResponseEntity;
import org.springframework.test.context.ContextConfiguration;


@SpringBootTest(classes = Application.class, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ContextConfiguration
public class TestSteps {


    @Autowired
    private TestRestTemplate restTemplate;


    private ResponseEntity<ReverseResponse> reverseResponse;


    @Given("^I call reverse method with message (.*)$")
    public void iCallReverseMethodWithMessage(String message) throws Throwable {
        ReverseRequest request = new ReverseRequest(message);
        reverseResponse = this.restTemplate.postForEntity("/reverse", request, ReverseResponse.class);
    }


    @Then("^the response status is (\\d+)$")
    public void theResponseStatusIs(int status) throws Throwable {
        Assert.assertEquals(status, reverseResponse.getStatusCode().value());
    }


    @And("^the response body must contain message with value (.*)$")
    public void theResponseBodyMustContainMessageWithValue(String value) throws Throwable {
        Assert.assertEquals(value, reverseResponse.getBody().getMessage());
    }
}

Lo siguiente que tenemos que hacer es implementar el código real. Usando Spring Boot solo necesitamos 4 clases:

  • @SpringBootAplication – Es la clase que se encarga de arrancar el servidor.
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;


@SpringBootApplication
public class Application {


    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}
  • @RestController – Es la clase donde se definen los endpoints de nuestro servicio REST.
import com.paradigmadigital.web.request.ReverseRequest;
import com.paradigmadigital.web.response.ReverseResponse;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;


import java.util.stream.Collectors;
import java.util.stream.IntStream;


@RestController(value = "/")
public class TestController {


    @RequestMapping(path = "/reverse", method = RequestMethod.POST, consumes = MediaType.APPLICATION_JSON_VALUE)
    public @ResponseBody ReverseResponse reverse(@RequestBody ReverseRequest request) {
        String message = request.getMessage();
        int length = message.length();
        String reversed = IntStream.range(0, length)
                .map(i -> length - i - 1)
                .mapToObj(i -> Character.toString(message.charAt(i)))
                .collect(Collectors.joining());
        return new ReverseResponse(reversed);
    }
}
  • Clase Bean para el request.
import org.hibernate.validator.constraints.NotBlank;


public class ReverseRequest {


    @NotBlank
    private String message;


    public ReverseRequest() {
    }


    public ReverseRequest(String message) {
        this.message = message;
    }


    public String getMessage() {
        return message;
    }


    public void setMessage(String message) {
        this.message = message;
    }
}
  • Clase Bean para el response.
public class ReverseResponse {


    private String message;


    public ReverseResponse() {}


    public ReverseResponse(String message) {
        this.message = message;
    }


    public String getMessage() {
        return message;
    }


    public void setMessage(String message) {
        this.message = message;
    }
}

Ya tenemos todo creado, testeado y funcionando. Lo que tenemos que hacer ahora es subir este código a un repositorio Git.

4

El código que desarrollemos debe residir únicamente en un sitio, una única fuente de la verdad donde podamos saber en cualquier momento que lo que tenemos allí es lo que queremos subir a nuestros entornos de desarrollo o producción. Aunque una de las ventajas de Git es que es descentralizado, debemos tener un repositorio principal donde tengamos todo el código listo para ser construido, testeado y desplegado.

Se pueden usar muchos flujos de trabajo con Git, pero personalmente me decanto por Git Flow. Con este flujo de trabajo, donde el código está centralizado en un único punto, crearemos una rama master donde solo estará el código de las releases y otra rama develop, donde integramos las nuevas funcionalidades.

A parte de estas ramas abriremos tantas ramas como sean necesarias para las nuevas funcionalidades que integraremos en la rama develop.

Una vez tengamos suficientes funcionalidades crearemos la rama release. A partir de este punto ya no se pueden añadir más funcionales a esa versión. Esta rama nos servirá como ayuda para ultimar las cosas de esa versión que no son funcionalidades (ya sean bugs, documentación, ficheros de configuración…). Cuando esté todo listo esta rama se integrará en nuestra rama master.

Por último, tenemos las ramas hotfix, que son ramas donde podemos hacer pequeñas tareas de mantenimiento en caso de encontrar bugs o fallos en nuestra aplicación. Una vez que tengamos el código listo se integrará tanto en la rama master como en la rama develop.

5

Hay muchas más formas de trabajar con Git. Elige una con la que tu equipo esté cómodo y que se adapte a vuestro proyecto.

A la hora de subir tu código, las opciones son muchas. Sitios como Github, Gitlab o la solución de Amazon CodeCommit pueden ser una alternativa si no quieres tener un servidor propio donde almacenarlo.

Aunque tengas tu código subido, el trabajo no termina ahí. La última parte es la automatización. Para que un proceso no falle lo mejor siempre es automatizarlo. Mi consejo es que automatices tus tests, tus merges en Git, tus builds de las imágenes Docker, las releases del código, los despliegues a tus servidores, en definitiva, automatiza TODO.

Hay múltiples herramientas que te pueden ayudar a ello. Tienes Jenkins, que puedes usarlo para cualquier plataforma o si usas Gitlab puedes probar sus CI Runner. Si te decantas por AWS tienes a tu disposición AWS CodePipeline.

Dockerizando

6

En este post no quiero ahondar en el tema de Docker, tan solo quiero mostrar cómo es un Dockerfile de una aplicación Spring Boot:

FROM openjdk:8

VOLUME /tmp

RUN mkdir -p /usr/app
WORKDIR /usr/app
COPY target/0aCloud-1.0-SNAPSHOT.jar /usr/app

EXPOSE 8080
CMD ["java","-Djava.security.egd=file:/dev/./urandom","-jar","0aCloud-1.0-SNAPSHOT.jar"]

Tan sencillo como eso, escoger una imagen Java desde la que partir, montar un volumen para tus ficheros temporales o logs, copiar el jar que se genera en la compilación y exponer el puerto 8080 que por defecto es el que se usa en Spring Boot.

El comando para ejecutarlo es un simple java -jar aunque en este caso le añadimos el parámetro de java.security.egd para resolver problemas con los SecureRandom de Java.

Desplegando

7

Lo único que nos queda para tener nuestra aplicación lista es subirla a la nube. En este caso, la nube de AWS.

AWS nos ofrece el servicio EC2 Container Registry donde podemos almacenar las imágenes de Docker que creemos para luego desplegarlas en nuestras instancias de EC2.

Para realizar esta tarea usaremos el servicio EC2 Container donde podemos crear clusters y definir nuestros servicios con las tareas que los componen para automáticamente desplegar, escalar si queremos y realizar el balanceo de los mismos.

8

Subir la imagen Docker es tan sencillo como usar estos comandos teniendo ya instalado el sdk de AWS:

$ aws ecr get-login

Usar el comando devuelto para logarnos contra nuestro Registry:

$ docker build -t zerotocloud .

$ docker tag zerotocloud:latest xxx.dkr.ecr.eu-west-1.amazonaws.com/zerotocloud:latest

$ docker push xxx.dkr.ecr.eu-west-1.amazonaws.com/zerotocloud:latest

Las xxx son nuestro usuario de AWS y la URL entera nos la proporcionará el Registry al crear el repositorio.

Para crear los clusters y desplegar los servicios podemos hacerlo manualmente pero lo mejor es usar otra de las herramientas que nos proporciona AWS: Cloudformation.

Gracias a esta herramienta podemos crear un stack de aplicación en formato JSON o YAML y automatizar la creación y despliegue de nuestros recursos.

Para nuestro ejemplo vamos a usar el siguiente fichero en formato JSON:

{
  "AWSTemplateFormatVersion": "2010-09-09",
  "Parameters": {
    "ClusterName": {
      "Description": "Name of your Amazon ECS Cluster",
      "Type": "String",
      "ConstraintDescription": "must be a valid Amazon ECS Cluster.",
      "Default": "zerotocloud"
    },
    "KeyName": {
      "Type": "AWS::EC2::KeyPair::KeyName",
      "Description": "Name of an existing EC2 KeyPair to enable SSH access to the ECS instances."
    },
    "InstanceType": {
      "Description": "The EC2 instance type",
      "Type": "String",
      "Default": "t2.micro",
      "AllowedValues": [
        "t2.micro"
      ],
      "ConstraintDescription": "You can specify only t2.micro."
    }
  },
  "Resources": {
    "EC2Instance": {
      "Type": "AWS::EC2::Instance",
      "DependsOn" : "ECSCluster",
      "Properties": {
        "AvailabilityZone": "eu-west-1a",
        "ImageId": "ami-175f1964",
        "InstanceType": {
          "Ref": "InstanceType"
        },
        "IamInstanceProfile": {
          "Ref": "EC2InstanceProfile"
        },
        "KeyName": {
          "Ref": "KeyName"
        },
        "UserData": {
          "Fn::Base64": {
            "Fn::Join": [
              "",
              [
                "#!/bin/bash -xe\n",
                "echo ECS_CLUSTER=",
                {
                  "Ref": "ClusterName"
                },
                " >> /etc/ecs/ecs.config\n"
              ]
            ]
          }
        }
      }
    },
    "ECSCluster": {
      "Type": "AWS::ECS::Cluster",
      "Properties": {
        "ClusterName": {
          "Ref": "ClusterName"
        }
      }
    },
    "taskdefinition": {
      "Type": "AWS::ECS::TaskDefinition",
      "Properties": {
        "ContainerDefinitions": [
          {
            "Name": "zerotocloud",
            "Cpu": "1",
            "Essential": "true",
            "Image": "102841065480.dkr.ecr.eu-west-1.amazonaws.com/zerotocloud:latest",
            "Memory": "256",
            "PortMappings": [
              {
                "HostPort": 8080,
                "ContainerPort": 8080
              }
            ]
          }
        ]
      }
    },
    "service": {
      "Type": "AWS::ECS::Service",
      "Properties": {
        "Cluster": {
          "Ref": "ECSCluster"
        },
        "DesiredCount": "1",
        "TaskDefinition": {
          "Ref": "taskdefinition"
        }
      }
    },
    "EC2Role": {
      "Type": "AWS::IAM::Role",
      "Properties": {
        "AssumeRolePolicyDocument": {
          "Statement": [
            {
              "Effect": "Allow",
              "Principal": {
                "Service": [
                  "ec2.amazonaws.com"
                ]
              },
              "Action": [
                "sts:AssumeRole"
              ]
            }
          ]
        },
        "Path": "/",
        "Policies": [
          {
            "PolicyName": "ecs-service",
            "PolicyDocument": {
              "Statement": [
                {
                  "Effect": "Allow",
                  "Action": [
                    "ecs:CreateCluster",
                    "ecs:DeregisterContainerInstance",
                    "ecs:DiscoverPollEndpoint",
                    "ecs:Poll",
                    "ecs:RegisterContainerInstance",
                    "ecs:StartTelemetrySession",
                    "ecs:Submit*",
                    "ecr:*",
                    "logs:CreateLogStream",
                    "logs:PutLogEvents"
                  ],
                  "Resource": "*"
                }
              ]
            }
          }
        ]
      }
    },
    "EC2InstanceProfile": {
      "Type": "AWS::IAM::InstanceProfile",
      "Properties": {
        "Path": "/",
        "Roles": [
          {
            "Ref": "EC2Role"
          }
        ]
      }
    }
  },
  "Outputs": {
    "ecsservice": {
      "Value": {
        "Ref": "service"
      }
    },
    "ecscluster": {
      "Value": {
        "Ref": "ECSCluster"
      }
    },
    "taskdef": {
      "Value": {
        "Ref": "taskdefinition"
      }
    }
  }
}

En este fichero definimos los parámetros de nuestro despliegue, como puede ser el nombre del cluster que vamos a crear con la propiedad ClusterName, la KeyPair que usaremos para conectarnos a nuestras máquinas o el tipo de instancia de AWS que queremos crear.

También definimos los servicios y tareas del cluster, así como el cluster en sí. Es importante que creemos el rol con los permisos de ecr y ecs para poder dar permisos a nuestra máquina EC2 a descargar imágenes del repositorio privado y poder usarla como parte del cluster.

Estos ficheros también podemos definirlos de forma visual usando AWS Cloudformation Designer. Usando la herramienta visual lo que obtendremos será esto:

9

Si usamos este fichero que hemos generado o hemos creado desde Cloudformation, AWS se encargará de todo el trabajo por nosotros y ya tendremos nuestra aplicación lista para usarla.

Puedes encontrar el código de este post en el repositorio de Github de Paradigma.

¡Y listo! Nuestra aplicación ya está en la nube. Y todo en una hora, ahora lo único que tienes que hacer es comprobarlo tú mismo.

Llevo programando desde los 8 años cuando mis padres se compraron un Amstrad CPC 6128. Desde entonces no he parado y me encanta aprender lenguajes nuevos y aplicar las 3 R (Reutilizar, Reducir, Refactorizar) a los proyectos donde estoy. Actualmente trabajo como Arquitecto de Soluciones en Paradigma.

Ver toda la actividad de Rubén García Becerro

Escribe un comentario