You’d Think I’d Worry About Baking Bread…
Recall from Part 1 of this post — Serverless Computing with Swift: Swift and AWS Lambda — that I have decided to form a startup, It’s The Yeast I Can Do, an online bakery specializing in gourmet bread. My CLO (Chief Leavening Officer) is willing to go along with my prioritizing cloud infrastructure over actually baking product. But she’s concerned that we haven’t fully explored our options. In this article, I’ll re-write the code from Part 1 to run under Apache OpenWhisk.
OpenWhisk is an open source platform for serverless computing. You can host it on your own servers, with or without containers (such as Docker). You can even run it on AWS. For this article, I will use IBM Cloud, the cloud services infrastructure formerly known as Bluemix.
(If you’re following along at home, you’ll need an IBM account. (You can sign up for a free trial here) After registering for an account, install the CLI and its Cloud Functions plugin; follow the instructions here and here. In order to avoid cryptic error messages later on, make sure to create an organization and space in the same region.)
OpenWhisk provides out-of-the-box, direct support for several languages including JavaScript, Python, Java, Go, and Swift. OpenWhisk also supports other languages via Docker containers; you can write actions in Lua, Forth, Rust, or pretty much any language, even Bash scripts.
Not only does OpenWhisk let you program in Swift, but the infrastructure supports Codable directly. In the AWS code from Part 1, I had to read the data from standard input and use JSONDecoder to deserialize it. With OpenWhisk, however, the main function is handed a deserialized object.
Since Swift support is baked in, compiling the code should be easier than what we had to do with AWS. Let’s see if that holds true.
In Part 1, I created a serverless function that would receive a list of items the customer ordered and return a receipt. Although the code for the new version will be very similar to the original, I’ll start it from scratch (baking pun intended) for simplicity.
To begin, I’ll do something wrong, but easy. First, I create a directory for my code and switch to it:
mkdir yeast
cd yeast
Then, I combine, Item.swift, Order.swift, and Receipt.swift into one file, call it Yeast.swift. These are unchanged from the gist in Part 1.
curl -o Item.swift https://git.io/fh9Kg
curl -o Order.swift https://git.io/fh9K2
curl -o Receipt.swift https://git.io/fh9Ka
cat Item.swift Order.swift Receipt.swift > Yeast.swift
I need to create the main function at the bottom of the file, Yeast.swift. Here’s the difference from the AWS version: the OpenWhisk infrastructure does the input decoding, but the action’s main function must have the following signature:
main(input: Codable, completion: (Codable?, Error?) -> Void) -> Void
(Actually, there are a couple of alternative signatures that are not currently documented. You can read the OpenWhisk source code for details.)
I edit Yeast.swift adding a main function along with a couple of helper structs:
public struct Output: Codable {let receipt: String}
public struct Input: Codable {let items: [Item]}
func main(param: Input, completion: (Output?, Error?) -> Void) -> Void {let receipt = Receipt(with: param.items)completion(Output(receipt: “\(receipt)”), nil)}
Note — Input and Output are necessary because strings aren’t allowed as top-level JSON objects, so I wrap the strings in simple structs.
Finally, I create an OpenWhisk action (equivalent to an AWS Lambda function) using IBM’s command line tool. The incantation is as follows:
ibmcloud fn action create yeast Yeast.swift — kind swift:4.1
Which results in the message “ok: created action yeast”
And now to test it, I create a parameter file, named params.json, with contents:
{“items”: [{“amount”: 3, “style”: “rye”},{“amount”: 4, “style”: “naan”}]}
And invoke the action like so:
ibmcloud fn action invoke -r yeast — param-file params.json
I get the following JSON printed to my console:
{“receipt”: “Receipt for Order on 2018–08–02 19:44:37 +0000\n — — — — -\n3 RYE @ 0.62 = 1.86\n4 NAAN @ 0.87 = 3.48\n — — — — -\nTotal: 5.34\n\n”}
The -r switch performs a blocking invocation and limits the response to just the output of the action. If, instead, I had typed the following:
ibmcloud fn action invoke -b yeast — param-file params.json
I also get a blocking invocation, but in addition to the action’s output, I get a lot of metadata such as start and end timestamps and duration. Invoking the action without either the -r or -b switches results in a non-blocking invocation.
What’s the difference between a blocking invocation and a non-blocking invocation? It’s analogous to a synchronous versus asynchronous method invocation. When you use a blocking invocation, you get your result (with or without metadata). When you use a non-blocking invocation, you get an activation ID returned to you immediately. At some point in the future, you can use this activation ID to look up the result and other information about the activation. Read the documentation if you want more detail.
I said above, what I did was wrong…
There are two problems here.
● First, unless you are writing a dirt-simple action, you’re not going to want to put all the code in one file.
● Second, with the approach we’ve just taken, the action will have a cold-start delay.
Notice that the above command to create an action uses Swift source. But, of course, Swift is a compiled language, so before your action can be run, the Swift source has to be compiled. Compilation does not occur until the first time your action is invoked; this is called a cold-start and causes a delay in the running of your action. After the initial invocation, the compiled program is cached so subsequent invocations usually do not have a delay. “Usually,” because as your application horizontally scales, there will be occasional cold starts when additional containers are spun up.
If possible, it would be preferable to mitigate cold start delays. In fact, I can avoid both problems by re-organizing the code into a more modular structure and creating the action with a pre-compiled Swift program.
First, I’ll re-organize the code. Make sure you are in your yeast directory. Here are the steps:
Create a SPM Package.
swift package init — type=executable
In the Sources directory, add YeastModels as a subdirectory. Move the files Item.swift, Order.swift, and Receipt.swift to YeastModels.
mkdir Sources/YeastModelsmv Item.swift Sources/YeastModelsmv Order.swift Sources/YeastModelsmv Receipt.swift Sources/YeastModels
Edit main.swift in Sources/yeast to read as follows:
import YeastModels
public struct Output: Codable {let receipt: String}
public struct Input: Codable {let items: [Item]}
func main(param: Input, completion: (Output?, Error?) -> Void) -> Void {let receipt = Receipt(with: param.items)completion(Output(receipt: “\(receipt)”), nil)}
Update Package.swift to include a target for YeastModels and add YeastModels as a dependency for the main target. Package.swift will look as follows:
// swift-tools-version:4.0import PackageDescription
let package = Package(name: “yeast”,targets: [.target(name: “yeast”,dependencies: [“YeastModels”]),.target(name: “YeastModels”,dependencies: []),])
Delete Yeast.swift) in the yeast directory.
Now I have to compile it. But there are two small hitches: the OpenWhisk infrastructure needs to inject a small bit of code into the Swift program, and cross-compilation.
To be fair, cross-compilation is only an issue if you are not developing on Ubuntu. I typically code on a Mac so cross-compilation is an issue for me and probably for you as well. So, I need to somehow build a Linux binary. The easiest way to do this is to use Docker. Make sure you have the latest version of docker installed and that the daemon is running.
Here we go:
docker run — rm -it -v “$(pwd):/owexec” ibmfunctions/action-swift-v4.1 bash
cp /swift4Action/spm-build/Sources/Action/_Whisk.swift /owexec/Sources/yeast
cat swift4Action/epilogue.swift >> owexec/Sources/yeast/main.swift
echo ‘_run_main(mainFunction:main)’ >> owexec/Sources/yeast/main.swift
echo ‘_ = _whisk_semaphore.wait(timeout: .distantFuture)’ >> owexec/Sources/yeast/main.swift
cd owexec/
swift build -c release
mv .build/release/yeast .build/release/Action
zip yeast.zip .build/release/Action
Now, quit the shell (type Control-D) which stops the Docker container.
What did I do? The first command starts up a Docker instance and launches the Bash shell. The instance makes use of an image, ibmfunctions/action-swift-v4.1, that IBM provides. This image contains the Swift compiler and the necessary bits of infrastructure to build the action. Docker’s documentation gives you more details, but the flags make it possible to interact with the container from your host’s shell and they make your action’s directory visible from inside the Docker container.
The next four commands inject the infrastructure code into our Swift project. Finally, the last four commands build the project and package it according to how OpenWhisk expects it. In particular, OpenWhisk assumes the Swift executable is named Action.
Update the action to use the newly-compiled binary:
ibmcloud fn action update yeast yeast.zip — kind swift:4.1
Test it again:
ibmcloud fn action invoke -r yeast — param-file params.json
You should get the same results as before (again, modulo the timestamp). I’ll leave it as an exercise to the reader to explore the speed-up of a warm-start versus a cold-start.
Now, of course, you will want to automate the build process. In fact, if you read through the IBM documentation, they provide a shell script to do the compilation for you. However, the script makes a few assumptions about the directory structure for your actions that may not mesh with how you organize your code. More importantly, it does not properly handle modern SPM file structure; it expects your code to be directly under the Sources directory, whereas SPM wants your code to be in a sub-directory of Sources.
I recommend building a few by hand, and then writing a script once you get a feel for how you want to set up your environment.
Now that the basic idea works, the same next steps arise as we had with Part 1. Namely, integrating this into the rest of the infrastructure. IBM Cloud provides an API Gateway along with several other mechanisms for triggering your action. Since I wrote Part 1, server-side (and serverless) Swift continue to advance. For example, the Smoke framework seems to be getting some traction for writing services in Swift.
In the meantime, I will continue to put off actually running my bakery in favor of exploring new technology. Perhaps my bakery needs a Swift blockchain?