Integrate Spring Boot and EC2 using Cloudformation

On a previous blog we integrated a spring boot application with elastic beanstalk.
The application was a servlet based application responding to requests.

On this tutorial we are going to deploy a spring boot application, which executes some scheduled tasks on an ec2 instance.
The application will be pretty much the same application taken from the official spring guide with some minor differences on packages.

The name of our application will be ec2-deployment

rootProject.name = 'ec2-deployment'

Then we will schedule a task to our spring boot application.

package com.gkatzioura.deployment.task;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

/**
 * Created by gkatzioura on 12/16/16.
 */
@Component
public class SimpleTask {

    private static final Logger LOGGER = LoggerFactory.getLogger(SimpleTask.class);

    @Scheduled(fixedRate = 5000)
    public void reportCurrentTime() {
        LOGGER.info("This is a simple task on ec2");
    }

}


Next step is to build the application and deploy it to our s3 bucket.

gradle build
aws s3 cp build/libs/ec2-deployment-1.0-SNAPSHOT.jar s3://{your bucket name}/ec2-deployment-1.0-SNAPSHOT.jar 

What comes next is a bootstrapping script in order to run our application once the server is up and running.

#!/usr/bin/env bash
aws s3 cp s3://{bucket with code}/ec2-deployment-1.0-SNAPSHOT.jar /home/ec2-user/ec2-deployment-1.0-SNAPSHOT.jar
sudo yum -y install java-1.8.0
sudo yum -y remove java-1.7.0-openjdk
cd /home/ec2-user/
sudo nohup java -jar ec2-deployment-1.0-SNAPSHOT.jar > ec2dep.log

This script is pretty much self explanatory. We download the application from the bucket we uploaded it previously, we install the java version needed and then we run the application (this script serves us for example purposes, there are certainly many ways to set up you java application running on linux).

Next step would be to proceed to our cloudformation script. Since we will download our application from s3 it is essential to have an IAM policy that will allow us to download items from the s3 bucket we used previously. Therefore we will create a role with the policy needed

"RootRole": {
      "Type": "AWS::IAM::Role",
      "Properties": {
        "AssumeRolePolicyDocument": {
          "Version" : "2012-10-17",
          "Statement": [ {
            "Effect": "Allow",
            "Principal": {
              "Service": [ "ec2.amazonaws.com" ]
            },
            "Action": [ "sts:AssumeRole" ]
          } ]
        },
        "Path": "/",
        "Policies": [ {
          "PolicyName": "root",
          "PolicyDocument": {
            "Version" : "2012-10-17",
            "Statement": [ {
              "Effect": "Allow",
              "Action": [
                "s3:Get*",
                "s3:List*"
              ],
              "Resource": {"Fn::Join" : [ "", [ "arn:aws:s3:::", {"Ref":"SourceCodeBucket"},"/*"] ] }
            } ]
          }
        } ]
      }
    }

Next step is to encode our bootstrapping script to Base64 in order to be able to pass it as user data.
Once the ec2 instance is up and running it will run the shell commands previously specified.

Last step is to create our instance profile and specify the ec2 instance to be launched

    "RootInstanceProfile": {
      "Type": "AWS::IAM::InstanceProfile",
      "Properties": {
        "Path": "/",
        "Roles": [ {
          "Ref": "RootRole"
        } ]
      }
    },
    "Ec2Instance":{
      "Type":"AWS::EC2::Instance",
      "Properties":{
        "ImageId":"ami-9398d3e0",
        "InstanceType":"t2.nano",
        "KeyName":"TestKey",
        "IamInstanceProfile": {"Ref":"RootInstanceProfile"},
"UserData":"IyEvdXNyL2Jpbi9lbnYgYmFzaA0KYXdzIHMzIGNwIHMzOi8ve2J1Y2tldCB3aXRoIGNvZGV9L2VjMi1kZXBsb3ltZW50LTEuMC1TTkFQU0hPVC5qYXIgL2hvbWUvZWMyLXVzZXIvZWMyLWRlcGxveW1lbnQtMS4wLVNOQVBTSE9ULmphcg0Kc3VkbyB5dW0gLXkgaW5zdGFsbCBqYXZhLTEuOC4wDQpzdWRvIHl1bSAteSByZW1vdmUgamF2YS0xLjcuMC1vcGVuamRrDQpjZCAvaG9tZS9lYzItdXNlci8NCnN1ZG8gbm9odXAgamF2YSAtamFyIGVjMi1kZXBsb3ltZW50LTEuMC1TTkFQU0hPVC5qYXIgPiBlYzJkZXAubG9n"
      }
    }

KeyName stands for the ssh key name, in case you want to login to the ec2 instance.

So we are good to go and create our cloudformation stack. You have to add the CAPABILITY_IAM flag.

aws s3 cp ec2spring.template s3://{bucket with templates}/ec2spring.template
aws cloudformation create-stack --stack-name SpringEc2 --parameters ParameterKey=SourceCodeBucket,ParameterValue={bucket with code} --template-url https://s3.amazonaws.com/{bucket with templates}/ec2spring.template --capabilities CAPABILITY_IAM

That’s it. Now you have your spring application up and running on top of an ec2 instance.
You can download the source code from GitHub.

Configure hazelcast with EC2

Hazelcast is hands down a great caching tool when it comes to a JVM based application. If you use Amazon Web Services Hazelcast integrates wonderfully.

First task is to create a policy responsible for describing instances. We shall name this policy as
describe-instances-policy.

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "Stmt1467219263000",
            "Effect": "Allow",
            "Action": [
                "ec2:DescribeInstances"
            ],
            "Resource": [
                "*"
            ]
        }
    ]
}

Applications that have to access amazon resources, should either use a user or a role that has the policies for the resources we need attached. Using an amazon user for your application is a bad practice and It becomes a maintenance headache managing keys, let alone security issues.
Therefore we will focus on hazelcast configuration using IAM roles.

Our role will be called my-ec2-role and will have the policy describe-instances-policy attached.

By doing so an ec2 instance with hazelcast would be able to retrieve the private ip’s of other ec2 instances and therefore would attempt to identify which instances are eligible to establish a distributed cache.

Now we can proceed to the hazelcast configuration.
We can either do a java based configuration or an xml based configuration.

Let us start with the xml configuration.

<hazelcast
        xsi:schemaLocation="https://hazelcast.com/schema/config https://hazelcast.com/schema/config/hazelcast-config-3.7.xsd"
        xmlns="http://www.hazelcast.com/schema/config" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
    <group>
        <field>ec2-group</field>
        <password>ec2-password</password>
    </group>
    <network>
        <join>
            <multicast enabled="false">
            </multicast>
            <tcp-ip enabled="false">
            </tcp-ip>
            <aws enabled="true">
                <!--optional, default is us-east-1 -->
                <region>eu-west-1</region>
                <iam-role>my-ec2-role</iam-role>
                <!-- optional, only instances belonging to this group will be discovered, default will try all running instances -->
                <security-group-field></security-group-field>
                <tag-key></tag-key>
                <tag-value></tag-value>
            </aws>
        </join>
    </network>
</hazelcast>

And the main class to load the xml file.

package com.gkatzioura.hazelcastec2;

import com.hazelcast.config.*;
import com.hazelcast.core.Hazelcast;

/**
 * Created by gkatzioura on 7/26/16.
 */
public class HazelCastXMLExample {

    public static void main(String args[]) {

        Config config = new ClasspathXmlConfig("hazelcast.xml");

        Hazelcast.newHazelcastInstance(config);
    }

}

Pay extra attention that multicast and tcp-ip should be disabled.
Since we specify an IAM role there is no need to provide credentials.
Tag-key and Tag-value represent the tags that you can add on an ec2 machine. In case you specify tag key and value a connection will be established only on machine that have the same tag and value.

You can have the security-group-field empty. Hazelcast uses this information for instance filtering however you must make sure the security group that the ec2 instance uses has ports 5701, 5702, and 5703 open for inbound and outbound traffic.

The java configuration follows the same rules.

package com.gkatzioura.hazelcastec2;

import com.hazelcast.aws.AWSClient;
import com.hazelcast.config.AwsConfig;
import com.hazelcast.config.Config;
import com.hazelcast.config.GroupConfig;
import com.hazelcast.config.JoinConfig;
import com.hazelcast.core.Hazelcast;
import com.hazelcast.core.HazelcastInstance;

/**
 * Created by gkatzioura on 7/25/16.
 */
public class HazelCastJavaExample {

    public static void main(String args[]) {

        Config config = new Config();

        GroupConfig groupConfig = new GroupConfig();
        groupConfig.setName("ec2-group");
        groupConfig.setPassword("ec2-password");

        config.setGroupConfig(groupConfig);

        JoinConfig joinConfig = config.getNetworkConfig().getJoin();
        joinConfig.getTcpIpConfig().setEnabled(false);
        joinConfig.getMulticastConfig().setEnabled(false);

        AwsConfig awsConfig = joinConfig.getAwsConfig();
        awsConfig.setIamRole("my-ec2-role");
        awsConfig.setEnabled(true);
        awsConfig.setRegion("eu-west-1");

        Hazelcast.newHazelcastInstance(config);
    }

}

After uploading your hazelcast apps to ec2 and run them you can see the following log

Jul 26, 2016 6:34:50 PM com.hazelcast.cluster.ClusterService
INFO: [172.31.33.104]:5701 [dev] [3.5.4] 

Members [2] {
	Member [172.31.33.104]:5701 this
	Member [172.31.41.154]:5701
}

I have added a gradle file for some quick testing either with xml or java configuration.

group 'com.gkatzioura'
version '1.0-SNAPSHOT'

apply plugin: 'java'

sourceCompatibility = 1.5

repositories {
    mavenCentral()
}

apply plugin: 'idea'

dependencies {
    testCompile group: 'junit', name: 'junit', version: '4.11'
    compile group: 'com.hazelcast', name:'hazelcast-cloud', version:'3.5.4'
}

task javaConfJar(type: Jar) {
    manifest {
        attributes 'Main-Class': 'com.gkatzioura.hazelcastec2.HazelCastJavaExample'
    }
    baseName = project.name + '-jconf'
    from { configurations.compile.collect { it.isDirectory() ? it : zipTree(it) } }
    with jar
}

task javaXMLJar(type: Jar) {
    manifest {
        attributes 'Main-Class': 'com.gkatzioura.hazelcastec2.HazelCastXMLExample'
    }
    baseName = project.name + '-xmlconf'
    from { configurations.compile.collect { it.isDirectory() ? it : zipTree(it) } }
    with jar
}

You can find the sourcecode on github.