Serverless Rust-ing with Oracle Cloud Functions

Zeroing in on Serverless

Serverless computing is a cloud computing execution model in which the cloud provider allocates machine resources on demand, taking care of the servers on behalf of their customers (). Serverless should not be confused with a common misinterpretation “lack of a server”. Serverless is a way of deploying and running cloud applications where the cloud service provider takes the responsibility of creating VMs, CI/CD, and even running servers, etc., from the user and does not “reserve” resources beforehand. There are multiple components of serverless computing, this story talks about one of the core components — Functions.

Function - The cornerstone of every modular and beautifully designed piece of software.

The era of cloud-native computing has influenced the way we write and ship applications. These can be web applications, batch processing pipelines, data processing pipelines, event handling jobs; the list goes on. One of the rapidly adopted strategies is to write Functions — just the Functions! The idea of writing Functions is not new to developers. We all have been writing Functions one way or the other, but they need some driver code, typically a Server, Cron job, etc. Here’s where managed services like come into picture. Essentially developer gives a function to the service and tells it when to invoke it. The service takes it from there — starting a server, adding an event handlers, managing IO and type conversion, etc.

Um.. then what magic tells service to talk to my code?

Magic? But aren’t we all muggles here? No and Yes. To run on any cloud provider, Functions need to follow a contract enforced by the service. This is typically done via lightweight SDKs provided by the service provider. The SDKs are language specific. Here’s a of currently supported SDKs (or as they are called on Github, FDKs) supplied for Oracle Cloud Functions. Beyond these SDKs, there’s a shining feature of Oracle Cloud Functions — supporting Docker based Functions. This enables developers to use any language of their choice as long as they follow the Functions contract. Since Rust is not an officially supported language on Oracle Cloud Functions yet, we’ll be using Docker as our runtime for running our function.

Why Rust and running Rust Functions?

Glad you thought so, I love the way you think. Rust is a secure systems programming language which is blazing fast and memory efficient. It gives ridiculously high control over resources, similar to languages like C and C++, but with the guarantee of secure software resulting in significantly less runtime bugs. because of its safety, speed, and resource utilization. Rust functions can result in significantly more performance and better resource utilization.

Oracle provides a managed service for running functions in Cloud. I will be using the shorthand Oracle Fn for Oracle Cloud Functions from here on. At the time of writing this story, Oracle Fn supports 6 languages officially. These are Go, Java, Kotlin, Python, Node.js, and Ruby. The key thing to notice is that all the code written in these languages gets built and placed in a docker image. This is interesting to the developer because a docker based deployment is supported by the project which means as long as you are following the contract, you can run any language of your choice! Once the docker image is created, it is pushed to Oracle Container Registry. There are a few steps a developer needs to take in order to interact with Oracle Cloud which are explained in detail . For brevity, we are going to keep things locally and use a development server.

…and now that we have most answers, let’s start coding, shall we?

Prerequisites

There are 2 prerequisites for running Functions locally.

  • Docker server
  • Latest version of . Fnproject is the base project of Oracle Fn and is maintained by the core team at the time of writing this story.

I will be using the Rust tool chain for brevity and ease of usage, but it is completely optional. I would strongly recommend using the tool chain for managing dependencies and do pre-deployment testing with ease.

Now that we have prerequisites, let’s see what are the steps to run functions locally.

Steps

  • Using the init image to generate project and boilerplate code.
  • Create a Rust project.
  • Create a Dockerfile for deploying our code.
  • Running Fnproject locally.
  • Create an app and function using Fn CLI.
  • Build and deploy our function.
  • 10,000 feet testing via cURL.

Using the init image

Fn project has a construct called init-images which lets the user run any project using docker as the runtime and generated boilerplate code. The init image takes care of setting up the project, adding the func.yaml file, and creating a Dockerfile. If you use the init image method, feel free to move directly to adding code in main.rs (code present in next section) and then to “Running Fnproject locally.” section.

To generate the project using init image, create a directory named simple-calc:

mkdir simple-calc && cd simple-calc

and run command:

fn init --init-image=metamemelord/fdk-rust:init

Running init-image: metamemelord/fdk-rust:init
Executing docker command: run --rm -e FN_FUNCTION_NAME=simple-calc metamemelord/fdk-rust:init
func.yaml created.

Upon looking in the dir, we see that func.yaml, Cargo.toml, Dockerfile, and main.rs have been generated for us.

tree .

.
├── Cargo.toml
├── Dockerfile
├── func.yaml
└── src
└── main.rs
1 directory, 4 files

Great! Add the contents of main.rs from below to the generate main.rs and proceed to Running Fnproject locally.

Create a Rust project

We are going to build a RESTful API based ultra complicated calculator which can Add, Subtract, Multiply, and Divide two numbers. Get it? It’s more complicated than a Hello World program. Okay sorry, let’s keep it dumb for brevity. Please be informed that a function can do far more interesting things than doing arithmetic operations on 2 numbers. Let’s begin:

cargo new --bin simple-calc

You should now see a directory simple-calc containing the project. Let’s add the required dependencies and code of our calculator.

Let’s add the required dependencies in Cargo.toml. We need:

  • Tokio — The runtime to provide an event loop for our server.
  • Serde — For serialization and deserialization.
  • FDK — We will be using git directly, alternatively you can use a dependency from crates.io.

Okay, here are the contents of Cargo.toml

…sweet!

The time to write code has come!

We need to write our function, input, output, and main function in main.rs.

For the inputs, I will be representing the operation with an enum and the operands and operation sit in a struct. Also, since we will be deserializing this data from bytes to struct, I’ll add derive macro to these. Let’s call this CalculatorInput. Here’s how it looks:

#[derive(serde::Deserialize)]
enum Operation {
Add,
Sub,
Mul,
Div,
}
#[derive(serde::Deserialize)]
struct CalculatorInput {
operand1: f64,
operand2: f64,
operation: Operation,
}

For the output, let’s define the CalculatorResult struct with ability to serialize and a helper to create a new value.

#[derive(Serialize)]
struct CalculatorResult {
result: f64,
}
impl CalculatorResult {
fn new(result: f64) -> Self {
Self { result }
}
}

Now that we have our input and output in place, let’s create the functions itself. The logic of function is pretty straight forward — we take the input and based on the operation field, decide which operation to do and create an output with result value.

fn simple_calc(input: CalculatorInput) -> CalculatorResult {
match input.operation {
Operation::Add => CalculatorResult::new(input.operand1 + input.operand2),
Operation::Sub => CalculatorResult::new(input.operand1 - input.operand2),
Operation::Mul => CalculatorResult::new(input.operand1 * input.operand2),
Operation::Div => CalculatorResult::new(input.operand1 / input.operand2),
}
}

While this gets our work done, the SDK enforces a specific format to be used. The SDK injects an additional argument called RuntimeContext defined in SDK which contains the metadata including but not limited to Request Headers, Response Headers, ability to add HTTP Status code, etc. Since we are not using this context object for this example, I am gonna ignore the context argument. Also, the user function should report a Result enum containing the output struct and fdk::FunctionError error type. For ease, the FDK contains a Result type such that fdk::Result<CalculatorResult>is same as core::result::Result<CalculatorResult, fdk::FunctionError> Let’s make the changes to our function. Here’s how it looks:

fn simple_calc(
_: &mut fdk::RuntimeContext,
input: CalculatorInput,
) -> fdk::Result<CalculatorResult> {
Ok(match input.operation {
Operation::Add => CalculatorResult::new(input.operand1 + input.operand2),
Operation::Sub => CalculatorResult::new(input.operand1 - input.operand2),
Operation::Mul => CalculatorResult::new(input.operand1 * input.operand2),
Operation::Div => CalculatorResult::new(input.operand1 / input.operand2),
})
}

We have really written the core. Let’s add the main function now.

The SDK uses Futures in order to execute the code, hence we will make use of tokio for making our lives easy to handle the future for us in an async main function.

#[tokio::main]
async fn main() {
let function = Function::run(simple_calc);
if let Err(e) = function.await {
eprintln!("{}", e);
};
}

That was the last piece. After placing use statements for more readability, the final code looks like this:

Create a Dockerfile

I will be using Rust 1.53 on Alpine for this project

Running Fnproject locally

Fn CLI is shipped with built in capability to do this. We just need to run the command:

fn start

2021/05/29 23:26:13 ¡¡¡ 'fn start' should NOT be used for PRODUCTION !!! see 
...
... // And a few more lines

One nice way to see if things are working correctly is to look at running containers:

docker container ls --filter "name=fnserver"

CONTAINER ID   IMAGE                       COMMAND        CREATED         STATUS         PORTS                                                 NAMES
249baaec435c fnproject/fnserver:latest "./fnserver" 2 minutes ago Up 2 minutes 2375/tcp, 0.0.0.0:8080->8080/tcp, :::8080->8080/tcp fnserver

Note that “fnserver” is running on port 8080, that’s the port to call our functions on localhost.

Create an app and function using Fn CLI.

The first class citizens of Oracle Functions are apps. An app can have any number of functions. Let’s create an app:

fn create app calculators

Successfully created app:  calculators

Now that we have our app, we should create the function.

fn init --runtime docker

Dockerfile found. Using runtime 'docker'.
func.yaml created.

We see that a func.yaml has been created in the directory. The func.yaml file contains the configuration of the function.

Build and deploy our function

The Fn CLI can build the function code using fn build command, but we will use the fn deploy command which builds and deploys the function. One thing to note here is that we need to pass the app name as an argument using the --app flag. Additionally, we will also tell the CLI that we are working locally by supplying the --local flag.

fn deploy --app calculators --local

Deploying simple-calc to app: calculators
Bumped to version 0.0.2
Building image simple-calc:0.0.2 ..............................................................................................................................
Updating function simple-calc using image simple-calc:0.0.2...
Successfully created function: simple-calc with simple-calc:0.0.2

All done!

Complete project is available on .

10,000 feet testing via cURL

To start testing our function, we need an endpoint to invoke. We can obtain the endpoint from the Fn CLI via the fn inspect command.

fn inspect function calculators simple-calc

{
"annotations": {
"fnproject.io/fn/invokeEndpoint": "http://localhost:8080/invoke/<Function ID>"
},
"id": "<Function ID>",
// ... some metadata
}

There is our endpoint. Time for some cURLing.

Add test with JSON body

curl --location --request POST 'http://localhost:8080/invoke/<Function ID>' \
--header 'Content-Type: application/json' \
--data-raw '{"operand1": 1.12,"operand2": 23.5,"operation": "Add"}'

Response: {"result":24.62}

Subtraction test with XML body

curl --location --request POST 'http://localhost:8080/invoke/<Function ID>' \
--header 'Content-Type: application/xml' \
--data-raw '<CalculatorInput><operand1>2.23</operand1><operand2>1.21</operand2><operation>Sub</operation></CalculatorInput>'

Response: {"result":1.02}

Multiplication test with JSON body and XML response

curl --location --request POST 'http://localhost:8080/invoke/<Function ID>' \
--header 'Accept: application/xml' \
--header 'Content-Type: application/json' \
--data-raw '{"operand1": 1.1,"operand2": 23.5,"operation": "Mul"}'

Response: <CalculatorResult><result>25.85</result></CalculatorResult>

Division test with URL encoded input

curl --location --request POST 'http://localhost:8080/invoke/<Function ID>' \
--header 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'operand1=10' \
--data-urlencode 'operand2=4' \
--data-urlencode 'operation=Div'

Response: {"result":2.5}

As I mentioned previously, functions can do much more. Especially with Content-Type and Accept headers, they can power data interoperability from in and out of other applications.

…and it’s a wrap!

Further reading

Further exploration:

SMTS • Web Services • Rust/Go/Py/JS • Cloud & Distributed Systems