poster

IDM/JUGTAAS Java Developer Meetings: AWS Lambda

June 7 2018 - Chris Mair - www.1006.org


Intro

IAAS - PAAS - SAAS; Function As A Service (FAAS); AWS Lambda; beware of vendor lock-in.

AWS gives you a web console, a command line and a SDK, we'll be mostly using the CLI.

AWS Documentation for Lambda.


lambda1: hello world

Programming model, programming model for Java and request and response parameters.

Code:

package net.chrismair;

import com.amazonaws.services.lambda.runtime.Context;
import com.amazonaws.services.lambda.runtime.RequestHandler;

/**
 * @author chris@1006.org
 */
public class Lambda1 implements RequestHandler<String, String> {

    @Override
    public String handleRequest(String input, Context context) {
        return "ciao " + input;
    }
    
    public static void main(String[] args) {
        // just for local tests
        System.out.println((new Lambda1()).handleRequest("Chris", null));
    }
    
}

Libs:

I'm using Netbeans with ant as build tool and use the following target to bundle the deployment zip. Note 1: the aws command line tool on my computer is installed under /opt/aws/installation/bin/aws. Note 2: the last line will fail before the function is defined in AWS (see below).

<target name="-post-jar">
    <exec command = "rm -rf deploy"/>
    <exec command = "mkdir deploy"/>
    <exec command = "cp -r dist/lib deploy"/>
    <exec command = "cp -r build/classes/net deploy/"/>
    <exec dir="deploy" command = "zip -r l.zip net lib"/>
    <exec dir="deploy" command = "/opt/aws/installation/bin/aws lambda update-function-code --function-name lambda1 --zip-file fileb://l.zip"/>
</target>

At this point I can use the CLI to define "lambda1" (note I've already made a role "lambdarole" in my account)...

aws lambda create-function --function-name lambda1                                                  \
                           --runtime java8                                                          \
                           --role arn:aws:iam::073956392929:role/lambdarole                         \
                           --handler net.chrismair.Lambda1::handleRequest                           \
                           --memory-size 128                                                        \
                           --zip-file fileb:///Users/chris/prj/netbeans/lambda1/deploy/l.zip

... and I can call the function with:

aws lambda invoke --function-name lambda1  \
                  --payload '"Chris"'      \
                  outfile

Now "outfile" contains "ciao Chris". Note "lambda1" at this point is a private, hidden function. I can call it using "aws" because the command line ist authenticated as my user.

Now, whenever the project is build in Netbeans, the function will be automatically deployed and updated on AWS.


lambda2: calling the function over HTTPS

You can define triggers for AWS Lambda functions, for example using Cloudwatch (this includes cron-like invocations!) or the API-Gateway. The later is very complex, but the Web-UI has a nice shortcut to create an API-Gateway proxy to your Lambda function (see below).

Important: when using the API-Gateway proxy, the input and output of the function must be JSON, following a predefined structure (see API-Gateway docs).

Here is the code for lambda2:

package net.chrismair;

import com.amazonaws.services.lambda.runtime.Context;
import com.amazonaws.services.lambda.runtime.RequestHandler;

import org.json.simple.JSONObject;
import org.json.simple.parser.ParseException;
import org.json.simple.parser.JSONParser;


/**
 * @author chris@1006.org
 */
public class Lambda2 implements RequestHandler<JSONObject, JSONObject> {

    @Override
    public JSONObject handleRequest(JSONObject request, Context context) {
    
        // get request.body.name somehow...
        
        String name = "";
        try {
            JSONObject obj = (JSONObject)(new JSONParser()).parse(request.get("body").toString());
            name = obj.get("name").toString();
        } catch (ParseException e) {
        } catch (NullPointerException e) {
            name = "unknown";
        }
        
        // AWS API Gateway requires the response to follow a certain schema:
        
        JSONObject responseBody = new JSONObject();
        responseBody.put("message", "Hello " + name);

        JSONObject header = new JSONObject();
        header.put("Access-Control-Allow-Origin", "*");
        
        JSONObject responseJson = new JSONObject();
        responseJson.put("isBase64Encoded", false);
        responseJson.put("statusCode", 200);
        responseJson.put("headers", header);
        responseJson.put("body", responseBody.toString());

        return responseJson;

    }

    public static void main(String[] args) {
        // just for local tests
        JSONObject data = new JSONObject();
        data.put("name", "Chris");
        JSONObject request = new JSONObject();
        request.put("body", data);
        System.out.println((new Lambda2()).handleRequest(request, null));
    }
    
}

Libs:

The ant post-build target is identical to the one for lambda1, with the exception that the update command would name the function "lambda2", of course.

Again the function can be created with the CLI:

aws lambda create-function --function-name lambda2                                                  \
                           --runtime java8                                                          \
                           --role arn:aws:iam::073956392929:role/lambdarole                         \
                           --handler net.chrismair.Lambda2::handleRequest                           \
                           --memory-size 128                                                        \
                           --zip-file fileb:///Users/chris/prj/netbeans/lambda2/deploy/l.zip

Now, using the Web-UI shortcut, quickly set up the API-Gateway proxy integration: Pick API-Gateway as the function trigger, then choose "Create a new API" → "lambda2api" → "stage" → "Open". Here, "Open" means the function is public (can be invoked via HTTPS by anyone).

This gives you the (public, open) invokation URL:

    https://yyyyyyyyyy.execute-api.eu-west-1.amazonaws.com/stage/lambda2

where yyyyyyyyyy is an identifier and eu-west-1 is the AWS region (Dublin) that I had preconfigured in the CLI.

This URL can be POSTed to, for example using curl:

curl -X POST -d '{"name":"Chris"}' https://yyyyyyyyyy.execute-api.eu-west-1.amazonaws.com/stage/lambda2

or via AJAX from a web page:

<!DOCTYPE html>
<html lang="en">

  <head>
    <meta charset="utf-8" />
    <title>Lambda2 Frontend</title>
  </head>

  <body>

    <input type="text" id="name">
    <br>
    <button>go</button>
    <br>
    <span id="message"></span>

    <script>

      "use strict";

      const $ = document.querySelector.bind(document);

      const URL = "https://yyyyyyyyyy.execute-api.eu-west-1.amazonaws.com/stage/lambda2";

      $("button").addEventListener("click", () => {
          postData(URL,{ name: $("#name").value })
            .then( data => $("#message").textContent = data.message) 
            .catch(error => console.error(error))
      });
          
      // https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch
      function postData(url, data) {
        return fetch(url, {
          body: JSON.stringify(data),
          method: 'POST'
        })
        .then(response => response.json())
      }

    </script>

  </body>

</html>

This is a simple page where you would submit a name, click the button and get the greeting back.

We can copy this file to AWS S3 and make it public:

aws s3 cp --acl public-read lambda2fe.html s3://1006.org/

This way we get a serverless Hello-World "application", that can be called as:

https://s3-eu-west-1.amazonaws.com/1006.org/lambda2fe.html

Here, "1006.org" is my pre-made S3 bucket.


lambda3: throwing persistency into the mix

So far this setup is perfectly server and stateless. Very often, persistency is needed. Let's throw Postgres into the mix :)

I've prepared an instance of Postgres running on AWS RDS.

The lamdba3-code adds a few lines to persist the names into the Postgres database:

package net.chrismair;

import com.amazonaws.services.lambda.runtime.Context;
import com.amazonaws.services.lambda.runtime.RequestHandler;

import org.json.simple.JSONObject;
import org.json.simple.parser.ParseException;
import org.json.simple.parser.JSONParser;


/**
 * @author chris@1006.org
 */
public class Lambda3 implements RequestHandler<JSONObject, JSONObject> {

    @Override
    public JSONObject handleRequest(JSONObject request, Context context) {
    
        // get request.body.name somehow...
        
        String name = "";
        try {
            JSONObject obj = (JSONObject)(new JSONParser()).parse(request.get("body").toString());
            name = obj.get("name").toString();
        } catch (ParseException e) {
        } catch (NullPointerException e) {
            name = "unknown";
        }
    
        try {
            DB db = new DB();
            db.execute("insert into things (name) values (?)", name);            
            db.close();
        } catch (Exception ex) {
        }
                
        // AWS API Gateway requires the response to follow a certain schema:
        
        JSONObject responseBody = new JSONObject();
        responseBody.put("message", "Hello " + name);

        JSONObject header = new JSONObject();
        header.put("Access-Control-Allow-Origin", "*");
        
        JSONObject responseJson = new JSONObject();
        responseJson.put("isBase64Encoded", false);
        responseJson.put("statusCode", 200);
        responseJson.put("headers", header);
        responseJson.put("body", responseBody.toString());

        return responseJson;

    }

    public static void main(String[] args) {
        // just for local tests
        JSONObject data = new JSONObject();
        data.put("name", "Chris");
        JSONObject request = new JSONObject();
        request.put("body", data);
        System.out.println((new Lambda3()).handleRequest(request, null));
    }
    
}

Here is the DB class:

package net.chrismair;

import java.sql.*;

/**
 * @author chris
 */
public class DB {

    private Connection conn;

    public DB() throws Exception {

        final String url = "jdbc:postgresql://IP.IP.IP.IP/lamda3db";
        final String user = "lambda3";
        final String pass = "yyyyyyyyyyyyy";
        Class.forName("org.postgresql.Driver");
        conn = DriverManager.getConnection(url, user, pass);
        conn.setAutoCommit(true);
    }

    public void close() {
        try {
            conn.close();
        } catch (SQLException e) {
        }
    }

    public void execute(String query, String... params) throws Exception {
        
        int i;
        
        if (params.length == 0) {
            Statement st = conn.createStatement();
            st.executeUpdate(query);
        } else {
            PreparedStatement pst = conn.prepareStatement(query);
            for (i = 0; i < params.length; i++) {
                pst.setString(i + 1, params[i]);
            }
            pst.executeUpdate();
        }
    }

}

Libs:

The ant post-build target and the command to set up the function lambda3 can be done analogous to lambda2 case.

After setting up lambda3, including the API-Gateway proxy and the HTML page to perform AJAX requests, the names given in the web form are stored in the database:

lamda3db=> select * from things;
 id |  name   
----+---------
 32 | Chris
 33 | Patrick
 34 | Davide
 35 | Stefano
 36 | Chris
(5 rows)

Concluding, lambda3 is a small serverless Hello World application that uses three AWS components: Lambda (to host the Java code), S3 (to host the frontend) and RDS (to host the database) without too much lock-in (S3 and RDS could easily swapped out with some other vendor, Lambda a bit less so).