A Serverless Sky – Generating Wav Files In The Cloud

A project by Antonia Dietz and Patrick Baumann

Introducing WaveBuilder

Did you ever sit at home, during a lonely autumn night, a glass of fine red wine in your hand, asking yourself, the question: What might two sine notes at frequencies 440 and 445 played at the same time sound like? Well, not anymore!

Introducing WaveBuilder™*: A (professional) student project doing exactly that and much more.

Using it is as simple as making a good ol’ cup o’ tea. Simply go to http://cloud-react-website-bucket.s3-website.eu-central-1.amazonaws.com, and enter your favorite frequencies with some other metadata, like bit depth, duration, and sample rate, to design your wav file the way YOU want to.

Afterward, you can listen to it, or share it with your friends (if you have any).

But behind this simple and immersive graphical user interface, is a rigid and in-depth architecture. Let us take you on a journey, to explore the processes and challenges that went into designing WaveBuilder™.

*(WaveBuilder is not an actual trademark)

A Serverless Sky – The Way To A Lambda-Based Cloud Architecture 

We both had experience with Google Cloud and Microsoft Azure and, since AWS is a platform we both hadn’t worked with before, we chose AWS to develop the application.

The architecture of the program is an interplay between different AWS Lambda functions.

Though the use case of our application could be easily realized using a monolith architecture, we chose a serverless approach, due to us already having built monoliths in past projects and wanting to try something new. One of our goals also was to get a general feel of how the two approaches differ from each other. 

Also, Lambda functions abstract most scalability issues away, which might be a problem since we were anticipating millions of requests per hour. (like google not)

The program’s core is a sine generator module written in Rust, which handles calculating frequencies and writing them to a wav file. A Lambda function using this module should receive requests and answer with the wav file. 

We thus came up with a simple diagram, where we would have a small frontend web page, which sends a request to a sine generator Lambda function. Afterward, the sine generator function writes the wav file and stores it into an AWS bucket, from which it then can be downloaded.

One problem was that creating a large wav file can take several seconds, which blocks the request, and might lead to a bad user experience. An early solution was to add a separate Lambda function, which checks if the file is already stored in the bucket.

This first approach, though, didn’t solve our problem. While a separate Lambda to check if a file is in a bucket, might be useful, this Lambda needs to know the id of the file it looks for. But said id is created by the sine generator function. We can not simply reply with an id, since the reply comes in the form of the return value of the function. And, if the function returns, it cannot create a wav file afterward, meaning we still face the issue of blocking the response until the file is complete.

We needed to separate the management of the id and the creation of the wav file. This led us to split the sine generator Lambda into two parts: One (referred to as main Lambda from now on) which takes the request, writes the metadata into a database, and invokes another Lambda (referred to as sine generator Lambda from now on) which creates the wav file. This allowed us to handle the request in a non-blocking way since the front end wouldn’t need to wait for a response on the Lambda which writes the wav file. 

Also, since we didn’t want the user to access the bucket directly via an URL, we chose to add a wave delivery Lambda, which would send the file to the front end.

A database was not strictly necessary, but it provided an easy way to check if a file was already downloaded, and provide other useful information like the creation date of a file.

The following diagram presents the final architecture we came up with:

After some deliberation, we also thought it necessary to add a bucket cleaner Lambda, which deletes already downloaded or old wav files, based on a timed trigger. This was another place where the database proved useful since it gives us a very efficient way to query for files by date.

Components

Rusty Lambdas

The main Lambda, sine generator Lambda and bucket cleaner Lambda are written using the Rust programming language. One of the challenges using Rust for writing Lambdas is that the AWS SDK for Rust is still in developer preview. Luckily, it is pretty well documented, with lots of examples, covering basic use cases. These helped a lot while writing the functions. Also, the API to interact with AWS services is fairly simple, where creating a request to a service follows a simple builder pattern.

One of the challenges in general was fitting Rust’s very strict type system on the Lambda functions, which often needed helper functions to convert data types between each other. 

Example of converting between AWS and JSON types in Rust. 

Building a Rust binary is usually done with the Rusts package manager Cargo. But since we don’t build the binaries for our local machine, but for a Lambda function that will run on Amazon’s VMs, we need to compile it for the correct target. 

Furthermore, the binaries need to be zipped before being uploaded and need to have the name “bootstrap.zip”.

Luckily, there is an extension to Cargo called Cargo Lambda, which takes care of choosing the correct target and naming conventions.

Cargo Lambda also provides a local testing environment, where we can invoke a Lambda function locally. This speeds up development by a significant amount since we don’t need to redeploy the Lambda each time we make a change.

Sine Generator

The single purpose of the sine generator Lambda is to create the wav file and store it into an AWS S3 bucket. It uses a separate module to instantiate a wav writer, which simply takes the request parameters and creates the file. One important lesson here was where the file needed to be written. Lambdas are “Serverless”, but even Lambdas need to run on a machine somewhere and have some access to the filesystem on said machine. Lambdas are only allowed to access a machine’s /tmp folder. Thus, all files had to be written into /tmp. 

After writing the file, it gets stored in the bucket. The file name is the partition key, which was provided by the main Lambda.

Main

The main Lambda has two purposes: Insert the user request into a database and invoke the sine generator Lambda. 

The first thing it does though is validate that the sent request parameters are valid. Otherwise, the sine generator Lambda might throw an error while creating a wav file.

For inserting an entry into the database, we also needed to create a partition key (similar to a primary key in a SQL database). This was done by simply taking the automatically created request id for invoking the Lambda, and adding some parameters of the user request to it. It also takes care of adding the request date into the database, in addition to the metadata of the wav file. We could have also used the date provided by the Lambda request, but this gave us more control over the format of the date string, which would make it easier to query the database later.

After storing the request in the database, the Main Lambda invokes the Sine Generator Lambda, with the request parameters provided by the front end and the partition key. One thing that is important to note here, is that Lambda functions can be invoked with different invocation types. By default, the Lambda function gets invoked synchronously, which means that it would block the main Lambda. Since we wanted the invocation to be non-blocking, we had to manually change it to the invocation type of event, which is asynchronous. One of the downsides of this is that the main Lambda wouldn’t get any feedback if something goes wrong with creating the file.

Specifying the InvocationType. This also shows the builder pattern to build requests to AWS components.

In response, we send the partition key to the front end, which it can use to look for the wav file in the bucket. 

Bucket Cleaner

Since the created wav files are only to be downloaded once, we wanted to periodically delete all files that are already downloaded. To realize this, the Bucket Cleaner function is invoked by a periodical trigger. This works similarly to a cron job, where you specify an interval.

When triggered, the Lambda function queries the database for all files created between the last time it was invoked and deletes all the files that are marked as downloaded from the bucket. Afterward, it looks for older files, which are still in the bucket, but have not been downloaded, and deletes those, too. This is also necessary since a user can request a file, but then not choose to download it, even after it was created. We assume that if a file wasn’t downloaded after two days, the user chose not to download it.

The biggest challenge here was comparing dates since AWS uses its own dateTime module in Rust, which first needed to be converted into a more general format. 

As a short note, it is also possible to simply set a deletion trigger for files in an AWS bucket, which would make this Lambda obsolete. For us though, this was a nice opportunity to interact more with the database, and invoke Lambdas indirectly by triggers. This in turn also led us to learn more about databases, which we’ll talk about in the section about the database.

Wave Delivery Service

As already mentioned in our architecture development, the wave delivery service Lambda’s responsibility should be the delivery of the requested wav file to the client. This allows the wav file storage bucket to be a private object with only the required access rights to the Lambdas. 

Described in the form of a basic input output system, the wave delivery service should receive a GET request from the client with the following URL parameters:

https://path/to/lambda/API?request_id=xxx&file_id=xxx

The output should be a JSON response message with a parameter ‘status’ set either to “in progress” or “ready”, in the second case also somehow containing the file with the given file_id in the body.

Because the Lambda needed to handle HTTP messages, we decided to use a node.js template for the function.

The first challenge was to find a format that worked well for the transmission of a binary wav file. To keep consistency in the response messages and because we were very familiar with it in the HTTP context, we wanted to stick with the JSON format. Also at first, we didn’t know how to even change the message format that also depends on the Lambda’s API Gateway configuration, which will be discussed later.

After some experimentation, we tried converting a binary file to an integer array and transferring it like this as a parameter value, which worked well but was very inefficient. Additionally, even though HTTP typically doesn’t have a content size limit, we found out that an AWS Lambda has a transmission limit of 6 Megabytes and that only a fraction of a default-sized wav file (30 seconds) could be transferred with the described approach. After some research on how to encode binary files as strings, we became aware of the string base64 format.

As the name suggests, it encodes the binary on a 64 basis which means it takes every six bits of the stream and turns it into an ASCII character. This allowed the Lambda to transfer a file with up to 52 seconds duration with a sample rate of 8 bits, which was a huge increase.

A binary in the base64 format: 

Luckily it was easy to test the changes with the AWS console where you can create incoming event messages manually, make test runs of the Lambda and see the corresponding output. This also helped with the other challenges we faced during the development of the function.

Another big question was, how to access the wav file bucket and the database through the Lambda. Besides the issue of access rights in AWS, it was necessary to become familiar with the ‘AWS-SDK’ which is initially a dependency package in the chosen Lambda template. It took a while to find the right documentation and apply the instructions correctly but in the end, we were able to get a wav file from the bucket and change the metadata of a file in the database. This also seemed to be more complicated due to the need for asynchronous programming with the async/await pattern to ensure the program was waiting for the requested data.

In retrospect, the development of the wave delivery Lambda was challenging because a lot of research on AWS Lambdas, the node.js AWS-SDK, and data formats was necessary. We also had to learn about testing, API Gateways, Access Policies, and other tools like CloudWatch for logging and viewing error messages. 

Also, there still is the issue of transmitting files larger than 6 megabytes, which will be discussed in the following sections about the front end.

The Frontend

The most complicated way of deploying a static React website

For the user interface, we decided to use the React Framework. The main reason for that was that one of us already had some experience in that area and we didn’t want our focus to be on learning new frontend development tools but on the interplay between the individual components in the cloud.

The development of the frontend application itself was quite easy. It consists of two react components, the main App and the WaveForm component. While the WaveForm component is an HTML form element that contains all the required wav file parameters like sample rate, frequencies, and so on, the App component is responsible for the communication with both the main lambda and the wave delivery service. 

The first big question we had was: What is the best and simplest way to host a website in the cloud? To answer that, it is important to understand the differences between static and dynamic website hosting. Static websites don’t support server-side scripting in contrast to dynamic websites, which doesn’t mean that there can’t be dynamic content on the client side as long as it isn’t related to requests to the hosting server. Although React is a framework allowing dynamic change of components, it is still by default based on client-side scripting and rendering, thus alone saying nothing about the website being static or dynamic.

Confusingly, in the beginning, we thought we had to run the website on an AWS EC2 instance which in retrospect could be seen as unnecessary effort because it is a heavy VM instance that needs to be configured. However, it is still a hosting possibility and proved a good learning experience for us.

First, the selection of a suiting VM image confronts you with a whole new set of questions. We wanted to go with a Linux-based system, but there was still the possibility to choose between a lot of distributions. Besides the common and known ones like Ubuntu or RedHat, there is also an AWS-owned distribution called Amazon Linux (AL) which is the default. We chose the AL image, directly leading to other issues.

For running our website, packages like npm, node and Nginx are required. Different distributions of Linux use different packaging systems, package managers, and repositories to install packages. AL doesn’t provide the needed node package in its configured repository, so you have to install it via curl and the node version manager. Although Nginx is provided, it has a slightly different configuration structure than in Ubuntu, e. g. missing the sites-enabled and sites-available directories.

Despite the AL image causing some new problems that we might not have had with another distribution, the following aspects list some advantages of the use of AL:

  • optimized for use in the AWS cloud (e.g. comes with the AWS-CLI, optimized performance)
  • long term support
  • by default more secure e.g. by disabling remote root login

Another issue was the transport of the react production build from our local system to the remote server. Luckily, the SCP protocol which is based on the ssh protocol for secure connection to the server provides exactly the functionality needed. It allows you to transfer files via ssh with only one command by copying a local directory to a remote directory. Requirements are an ssh-key pair that you can easily create with the AWS console and access rights to the directories you want to upload your files. 

scp -r -i "path/to/ssh/key" build/* ec2-user@ec2-3-73-73-52.eu-central-1.compute.amazonaws.com:/var/www/3.73.73.52/

While all of this functioned well, in the end after seeing the methods used by other students in the course, we chose a different approach:

The final deployed frontend is a React website production build stored on an AWS S3 bucket. A nice feature of the bucket is that you can enable static website hosting, which automatically delivers the stored files. As already indicated above, the main reason for choosing a bucket over a VM to host a static website is that it is far easier to configure. This also proved highly effective when we added infrastructure as code to our project, which is discussed later.

It is a give-and-take –  communication between stateful and stateless components

The biggest challenges besides the deployment issues of the website were related to the communication between the frontend and the Lambdas. The procedure of the communication from a frontend point of view can be described like this: 

  1. send a POST request to the main Lambda with the wav specifications in the body of the message
  2. wait for JSON response with the properties request_id and file_id
  3. send GET request to the wave delivery service Lambda with the query parameters request_id and file_id
  4. wait for JSON response with the property status and depending on the value also the property file holding the file as base64 string:
  1. repeat from 3. (status = “in_progress”)
  2. break the loop, convert the file string and make it ready to download  (status = “ready”)

As you can see, the frontend is the active part, triggering the communication process and being responsible for keeping stateful information like IDs of requests and files. It hereby implements the busy waiting pattern by frequently asking for the file to be ready. This is necessary because the Lambda functions are not only designed to be serverless but also to be stateless, meaning they should not store any information beyond their runtime. Every instance of a wave delivery Lambda should respond the same way when triggered by the same request and just deliver what is asked for.

Coming back to the content size limit of the Lambdas, this creates a new challenge when we want to transfer files that are larger than 6 megabytes. To solve this we implemented a buffer mechanism that should download a file exceeding the size limit in several steps.

Because of the stateful implementation of the frontend, it would also have to be the one to hold the state of the fragments being downloaded and specify what part it needs next.

This resulted in adding parameter offset_num, counting the parts of the file that are already downloaded and a buffer array outside of the request loop, storing the fragments as buffers:

  1. send GET request to the wave delivery service Lambda with the query parameters request_id, file_id and offset_num
  2. wait for JSON response with the property status and depending on the value also the property file holding the file as base64 string:
  1. repeat from 3. (status = “in_progress”)
  2. convert the file string and add it to the buffer array. (status = “ready”) Then, depending on the isLast property:
    1. repeat from 3.  (isLast = false)
    2. break the loop, make a file out of the array and make it ready to download  (isLast = true)

On the Lambda side, the buffering mechanism is implemented by the isLast parameter which is sent with each response. To figure out its value, the Lambda has to make a request to the wav file bucket with every invocation and get the metadata of the file that also contains the ContentLength in bytes (as you can also see in the code picture of the wave delivery service section). In combination with the offset_num value in the request query and a hard-coded fragment length, the isLast field can be set and the byte offset can be calculated:

The S3 getObject() method accepts an argument specifying a certain range of bytes, which is very useful in this case.

To summarize, it was a lot of fun to find a solution to the 6-megabyte limit, requiring having a good overview of what each of the individual components is doing and how they interact. There were often problems that were repeatedly caused by something external like insufficient access rights. Logging helped a lot to find out where the errors occurred. In the future though, investing more time in some kind of testing infrastructure would probably solve a lot of the issues faster.

API Gateways & CORS

Another relevant component type in our application is the API Gateway. To simplify the component structure and the interaction between the instances, we have been leaving the API Gateways out of the picture up to this point. But actually, it is quite an important entity in the communication between the frontend and the Lambdas. API Gateways take HTTP requests and can manage and prepare data before forwarding them to e. g. a Lambda or an EC2 instance. 

For example, an issue when invoking the Main Lambda via HTTP, was that the metadata for a wav file was sent as a string, even though it was in JSON format. After adding an API Gateway, it converted the metadata to JSON, which made it a lot easier to be parsed by the Main Lambda.

In the case of the wave delivery Lambda gateway, the URL query parameters are put as key-value pairs into a JSON event message which is then forwarded to the Lambda.

Though it seemed to work testing the gateways via curl, it didn’t show the expected results in the browser. We always had this error:

In our case, we needed it to enable Cross-Origin-Resource-Sharing (CORS) on the API gateway since no Access-Control-Allow-Origin header was present on the requested resource. But because it worked well via curl without this error we thought it had to work as well in the browser and just set the request’s mode to no-cors, hoping we wouldn’t have to deal with the CORS policy. Unfortunately, this leads to an opaque response, which is only useful if you are not interested in any content of the response, for example when only using it for caching purposes.

So we had to find out how to enable CORS via the AWS console, which solved the problem. But we still didn’t understand what exactly it was doing and what it had to do with the browser. So we refreshed our knowledge on the CORS topic:

  • the frontend makes a request to the wave delivery Lambda, which has a different origin
  • since the browser enforces the CORS protocol per default, the browser always sends an origin header, containing the base URL of the frontend 
  • the requested resource (our Lambda) needs to explicitly state, which other origins are allowed to access it, it does so by sending the Access-Control-Allow-Origin header
  • if this header is not present, the browser will block the response from the requested resource

For some requests (e.g. PUT requests, which can cause side effects on the requested resource), the browser needs to make a preflight request, a so-called OPTIONS request,  to find out which types of requests are permitted by the resource. For example, if we made a PUT request on the wave delivery Lambda, this request would fail if PUT wouldn’t be listed in the Access-Control-Allow-Methods header in the response to the OPTIONS request.

The OPTIONS method in the API gateway is realized through a mocked endpoint, just sending a back message with the required headers:

In the end, we knew why an API gateway was necessary in our case and that it had big advantages, although it takes some time to understand and configure the component.

Database

We store all requests in AWS DynamoDB, a NoSQL database. Entries in the database have the following format:

{
	id: string,
	date: string,
	is_downloaded: bool,
	request_id: string,
	specs: {
		wav_data: {
			volume: number,
			duration: number,
			frequencies: Array[number],
		},
		wav_spec: {
			sample_rate: number,
			number_of_channels: number,
			bits_per_sample: number,
		}
	},
	time: string
}

Data regarding the wav file (held by wav_data and wav_spec) might be useful if we want to create statistics about user requests in the future. But at the moment, they serve no specific purpose. 

The partition key (id) is important since it is used by the wave delivery service Lambda to request files from a bucket (it is the actual filename as it is stored in the bucket).

The is_downloaded field is used by both the wave delivery Lambda and the bucket cleaner Lambda. The wave delivery Lambda uses it to verify that the file for the request wasn’t already downloaded. The bucket cleaner uses it to determine which files need to be deleted. 

The creation_date field is important for the bucket cleaner Lambda: When the bucket cleaner Lambda makes a request to the database, we don’t want to query for all entries every time, since this will lead to larger and larger query result sets. We needed a way to make those queries as efficient as possible. 

DynamoDB lets you define a so-called global index. Simply put, this gives us the opportunity to efficiently query for files that were created on a certain date. Without this, we would need to query for a file, then check the date and repeat this process for all files, leading to slow performance. Global indices also let us specify which parts of the entry we want to return. Since we only need the id, date, and is_downloaded fields for deleting a file, this provides another opportunity to query more efficiently, since the size of an entry will be smaller.

Deployment

Infrastructure As Code

For automating the setup of our infrastructure later and to bring everything we had done manually together, we decided to go with an Infrastructure as Code approach. Terraform was the obvious choice because both of us had used it before and we knew it had good documentation. An alternative would have been the AWS CloudFormation service, where you can declare your resources in a JSON or YAML syntax. Though you don’t have to worry about managing a shared state like you have to in Terraform, both approaches require other tools to automate applying changes to the infrastructure. With Terraform, we had an idea of how to do that.

Though it was a time-consuming process to turn all the components we had and their configuration into code, luckily it wasn’t that complicated. It also helped us get an even better overview of our system and the dependencies of the resources. While AWS access rights and roles have been a cross-sectional topic that we came up against all the time, it became even more relevant when declaring our resources. 

Every Lambda has its own service role that can hold access rights to perform specific actions on a specific resource. While creating a Lambda via the AWS console automatically created a service role for your Lambda, you had to declare it explicitly in Terraform. We had also created custom tailored access rights for e.g. getting a file out of the wav file bucket or writing to the wav file database table, which now had to be written as code.

Here you can see the assignment of several access policies to the wave delivery service Lambda. During the coding process, we learned that we also had to attach basic execution rights to the service roles, which allows them to publish their logs to the CloudWatch service. This was also done automatically when creating a Lambda via the AWS console.

While the most errors during the setup of our infrastructure via Terraform occurred in the access rights section, there was one error due to misconfiguration that we first didn’t even associate with Terraform. 

When using the user interface to create a large wav file, it randomly happened that the file wouldn’t be delivered to the client. To find a pattern in the behavior of the application we tried creating the same large file multiple times and found out that sometimes the requested file was missing in the bucket, even though we got a file_id back. So we traced it back to the sine generator Lambda, which sometimes did not finish its process. We could establish that through missing logging messages that should confirm the successful storage of a wav file in the bucket.

After some sleepless nights, one of us had the idea that the Lambdas automatically time out after a few seconds. That assumption was right, the Lambdas had a timeout of 3 seconds by default. But the probability of that causing the error seemed still absurd because, for that, the execution time of the Lambda for a file with the requested specification had to be exactly around the 3 seconds to fail about 50% of the time. But in fact, that was the case. The sine generator function has a wav file duration limit of 60 seconds, so the testing of large file creation was also basically limited to duration values between 50 and 60 seconds. That unfortunately always took around 3 seconds of execution time (with a sample rate of 8 bit). By setting the timeout to 30 seconds we solved the problem accordingly.

This was set before manually when creating the function via the AWS console. If you don’t specify the value in Terraform at all, it is set to the default value. Many of the errors showed us how important it is to read the documentation carefully. 

Putting It All Together

The final step was to put everything together and deploy the application. We worked with CI/CD pipelines before using GitLab. But since this was a student project, we had to create our own problems and thus decided to try out something new by using GitHub Actions for our deployment.

GitHub Actions

GitHub Actions use so-called Workflows, which are run on triggers. 

A trigger might be something general, like a push on a certain branch, or something more specific, e.g. changing a file with a certain name. As a simple example, we could specify a workflow to run only, when pushing on main and only files with the file name ending in *rs are changed. (You wouldn’t want to trigger a redeployment when updating a readme file).

A workflow runs several jobs, which may be dependent on one another or not. Each job then can have a series of steps, where you first set up some environment variables or install some programs in one step, in order to run those programs in a later step. 

It is also possible to specify for each job individually if it is run on a VM provided by Github or on a local machine.

In order to run a job in a workflow on Github, a runner is needed. We had two options: Use a runner provided by GitHub or use a local runner, hosted on our own machine. There are advantages and disadvantages to each approach:

Using a runner on GitHub means that we don’t need to install anything on our own machine. Furthermore, the GitHub runner can be run at any time. A local runner needs to manually be started and only runs if our own machine is actually running. One of the advantages of using a local runner is that it has access to programs installed on the local machine. For example, building a Rust application requires Rust’s package manager Cargo. Using a GitHub runner, we would need to use a so-called action to install cargo. An action can be thought of similarly to a subroutine used in a workflow, which installs programs on the runner. 

A quick side note, actions can be easily confused with GitHub Actions. The former refers to things that can be run as a step in a workflow’s job while the latter refers to the name of pipelines in Github.

This in turn slows down the deployment process, since we always need to install everything new, when starting the runner. Also, there might not be an action available for some use cases, which means we need to manually install the software in the workflow using commands like apt-get, etc.

When using a local machine, we just install everything we require once (and since we also develop everything on our own machines, the programs needed for deployment are most likely already installed). This simplifies the deployment process quite a bit, at the cost of flexibility.

For our case, we chose simplicity. (Although by the time this blog is online, we might have changed this, just out of curiosity 😉 )

Building The Rust Binaries

Our first goal was to build the Rust Lambdas. Building the functions itself is fairly easy, and can be simply done by changing into the directory of the Rust program and running the command “cargo lambda build”. 

The hard part was building them fast. 

Compile times with Rust can take a significant amount of time. Compiling a Lambda function from scratch could take up to 5-10 minutes, so doing this for three functions separately would have been really slow. Also, most Lambdas used the same dependencies, which get to be recompiled for every Lambda. If we used a dependency for the Lambda runtime in each Lambda function, we should only need to compile it once.

A solution to this was creating a Cargo workspace. This is fairly simple, as we only needed to specify the names of the Lambdas that need to be built in a cargo.toml file in the parent directory of the project. The big effect was that cargo figured out all the dependencies needed by all the Lambdas, and built each individual dependency only once. This change caused the compile process to only take roughly one-third of the time.

But it still took up to 10 minutes to compile everything which was still too long. The main thing slowing down the compile times was building the dependencies for the AWS SDK. But those should actually rarely change. What we wanted to do was to build those dependencies once and only rebuild them when we update to a new SDK version. Basically, most of the time, we only made a change to the Lambda functions, so those were the only ones that should be rebuilt.

After doing some research, we found out about a feature in Github Actions called caches. Caches allow us to store already built dependencies. A cache can be identified by a unique key, which needs to be created manually. Rust stores all information of its dependencies in a file called cargo.lock, which only gets updated when dependencies change. Our idea was to create a hash over the contents of the cargo.lock file and use it as part of our unique key. If no dependencies changed, the hash would be the same, resulting in a cache hit and downloading the cache onto the runner. Then, when starting to compile the Lambdas, Cargo would see the downloaded dependencies and not recompile them. Applying this change, the average compile times were as low as 1-2 minutes.

Sometimes though, the Lambda functions also wouldn’t change when a workflow was triggered. So we decided to add an additional hash over the contents of the three Lambdas to the key. The result looks like this:

The main key is built from the hashes over the cargo.lock and main.rs files. If a cache miss occurs for that key, we provide several restore keys: One where we first check if a cache is found for the dependencies. If we get a cache hit here, it means we don’t have to rebuild any of the dependencies. If that fails, we just check if any cache for the build process is available. This will cause some dependencies to be recompiled, but also only those, which have changed (cargo also detects which of the dependencies are different from the ones specified in its configuration). Afterward, we upload the built binaries as artifacts, so we can use them later in the Terraform job. 

With all this in place, we were finally satisfied with the build process.

Terraform

The next step was the deployment of the whole AWS infrastructure using Terraform. 

As mentioned, both of us had experience with Terraform before but had only used it in the context of GitLab. Terraform always needs to store a state of the specified infrastructure somewhere, in order to figure out which components have changed or need to be updated. Gitlab for example provides that sort of functionality itself, but when using GitHub, you have to find a different approach to state management. Fortunately, Terraform provides its own Terraform Cloud. There are also good instructions online on how to set up everything properly with GitHub, so this wasn’t a big problem. 

One thing that caused us to trip here though was that for some reason when deploying the application, Terraform was unable to find any of the artifacts and directories needed. After some research, we found out that this was due to Terraform running its process separately in its own cloud. Meaning it was not our runner executing the deployment, and thus, all the files needed were missing. Luckily, this could be changed in the configuration of the Terraform Cloud. After setting it to run on our local runner, everything worked smoothly.

The Frontend

One of the issues when deploying had to do with the deployment of the frontend. The frontend files were to be stored in an AWS bucket, but we could only upload them into the bucket after the bucket was deployed. 

Also, the frontend needed the URLs in order to invoke the main Lambda and wave delivery Lambda. Those URLs were only known after we deployed the infrastructure and they needed to be inserted when building the frontend, via a small build script. A neat thing is, that Terraform is able to know the URLs, so we can simply provide the whole script as an output from Terraform.

This meant that we first had to deploy the whole infrastructure with Terraform and then afterward build the frontend and upload it into the bucket.

Tests

The last thing to do was to run the tests when deploying the application (even though this is the first stage of the deployment process, we chose to add it last). Running the tests was similar to building the application, so we just had to make small adjustments to the build process to refactor it to a testing job. 

A downside here was that we needed to recompile everything again since when deploying the application, we didn’t want to have any code regarding tests in the binaries, which could lead to binary bloat. 

This also required us to add a separate cache for the test job.

Dealing With Component Names

Another thing that was important for us when deploying the application, was that we wanted to have a central place where the names for the Lambdas, buckets, databases, etc. would be stored. Since you can declare variables in the workflow file this seemed to be a suitable place to do so. Rust, React and Terraform allow for reading in environment variables during compile/build time. Thus, we simply specified variable names which would then be read in by each application. This helps avoid small bugs, where the name of a resource might be misspelled.

Some Pearls Of Wisdom

In conclusion, it has been a very exciting experience to work on this project. As we had planned it consisted mainly of architecture work, considerations about the communication between the individual components, and deployment concepts. Besides the scalability possibilities, it is an additional big advantage of the cloud component architecture that it becomes less important which tools you prefer to use to develop a component, as long as you agree on the interfaces.

In the end, our Lambdas are written in very different programming languages like Rust and Node.js, but everything works just fine due to joint resolution of how e.g. a database entry of a wav file has to look like, generic naming, or what information a request to a Lambda should contain. This has proved very effective for our collaborative work. Also, Being a team of only two people made communication quite simple. In addition, we benefited from very good documentation like for example Terraform but also recording our own considerations and decisions.

What would we do differently in the future? We guess it could increase the efficiency of our development process to invest a lot more time into testing. While learning how to do adequate testing would have been an additional effort for the project, with our afterward knowledge of the cloud concepts it might be worth the expense. It would make debugging easier and assure the quality of our code. Admittedly, we have been using the cloud as kind of a playground to try out new approaches in the cloud environment.

From an architectural view, we are quite happy with our solution. Even though we know, there isn’t just one way of doing it, especially when considering the variety of cloud concepts that exist. Every architecture decision has its advantages and disadvantages and you always have to balance the effects depending on the particulars of the application. Though this is a very vague response to the question, it is an important guideline to keep the solution as simple as possible.

Google Geodata Visualizer

Ein Projekt von Kai Kustermann, Michael Litschko, Sarah Mauff und Sebastian Köpp

Einleitung

Im Sommersemester 2022 haben wir uns als 4-köpfige Gruppe dazu entschlossen, einen Google Geodata Visualizer zu erstellen. Das Projekt ist aus der Idee einer McDonald’s-Achievement-Card entstanden. Die Idee war eine Website, die dem Benutzer anzeigt, welche McDonald’s Filialen der Nutzer, gemäß den über seinen Google Account gesammelten Standortdaten, jemals besucht hat. Im weiteren Verlauf wurde die Kernidee, Standortdaten aus Google Accounts zu verarbeiten, insoweit verallgemeinert, als dass das Endprodukt daraus besteht, an einem bestimmten Tag den zurückgelegten Weg, die besuchten Orte und die gesamte Strecke – u. A. unterteilt in Verkehrsmittel – anzuzeigen. 

Zunächst lädt der Benutzer dazu seine lokal gespeicherten Standortdaten aus seinem Google Account hoch. Hier ist zu erwähnen, dass die Einstellung, Standortdaten zu erheben, über einen bestimmten Zeitraum eingeschaltet gewesen sein muss, sodass überhaupt verwendbare Daten existieren. Nach Hochladen der Standortdaten hat der Nutzer nun die Möglichkeit über Klick auf das linke Hamburger-Menu, seine Strecken und besuchte Orte nachzuverfolgen. Um die Auflistung abzurufen, klickt der Nutzer auf das rechte Hamburger-Menu. Dort sieht er nun auch die hauptsächlich genutzten Verkehrsmittel mit dem dazugehörigen Streckenanteil. Auch kann durch die Karte sowohl navigiert, als auch hinein- und herausgezoomt werden.  

Ein Kartenausschnitt mit Routen und Locations in Mannheim.
In diesem Menü kann man das Datum auswählen und ob Routen und Locations angezeigt werden sollen.
Es werden sowohl die besuchten Locations aufgelistet, als auch die Gesamtstrecke abhängig vom genutzten Transportmittel.

Die Website kann über https://miclit131.github.io/ aufgerufen werden.

Architektur

Die obige Grafik stellt unseren ersten Plan einer Architektur dar, den wir nach und nach geändert haben. Im Folgenden gehen wir darauf ein, warum wir uns für welche Technologien entschieden haben.

Vergleich von Technologien die zur Auswahl standen

  • BW Cloud vs Serverless (FaaS)
    BW Cloud ist ein IaaS System, basierend auf Openstack. Die Idee war es, dort unser Backend zu hosten, da wir bereits Erfahrungen damit im Team hatten. Allerdings ist der Aufwand, ein Backend in der BW Cloud zu hosten, sehr groß. Die IBM Functions sind dagegen sehr leicht einzurichten und es muss kein Server gehostet werden. Deswegen haben wir uns für die Functions entschieden.
  • Big Query vs Cloudant
    Wir haben ein Tutorial gefunden, in dem Google Location Daten in Big Query gehostet wurden. An diesem wollten wir uns orientieren und unsere Datenbank auch in Big Query hosten. Wir haben uns schließlich dagegen entschieden, da wir keine relationalen Daten haben und somit eine nicht relationale Datenbank völlig ausreicht. Als nicht relationale Datenbank haben wir Cloudant genommen, da es ein IBM Service ist und somit mit den anderen Services, die wir nutzen, gut kombinierbar ist.
  • IBM Object Storage vs Github Pages
    Github Pages ermöglicht es, statische Websites direkt aus einem Git Repository zu verwalten bzw. zu hosten. Jedoch gibt es hier Limits: Die Website darf nicht größer als 1 GB sein und die Bandbreite ist auf 100 GB pro Monat limitiert.
    IBM Cloud Object Storage ermöglicht das Hosten von statischen Websites per Cloud Object Storage und den Build von cloud-nativen und serverlosen Apps mit Website.
    Wir haben uns für Github Pages entschieden, da es die einfachere der beiden Optionen war und die Limitationen für unsere Zwecke voraussichtlich nicht relevant sind.
  • ArcGis vs react leaflet
    ArcGis ist ein cloudbasiertes Tool zur Bereitstellung interaktiver Karten – allerdings ist es nicht kostenlos.
    Leaflet ist eine funktionsreiche Open Source JavaScript Library – in unserem Fall für OpenStreetMap (inklusive Open Source Routing Machine).
    Wir haben uns für Leaflet und OpenStreetMap entschieden, da es kostenlos ist.

Obige Grafik stellt die endgültige Architektur unseres Projekt dar.

Hochladen der Daten

  1. In das Frontend werden die Daten hochgeladen
  2. Die Daten werden vom Frontend an eine API gesendet
  3. Die API spricht IBM Functions an, welche die Daten verarbeiten und in die Datenbank laden

Lesen von Daten

  1. Das Frontend sendet einen Request an die API
  2. Die API führt Functions aus
  3. Die Functions laden Daten aus der Datenbank mithilfe einer Query
  4. Die Daten werden von der API zurück an das Frontend gesendet
  5. Das Frontend stellt diese dar

Frontend

Im Frontend haben wir verschiedene Technologien verwendet. Zunächst basiert unser Programm auf React. Als CSS Framework haben wir uns für Bootstrap entschieden, da es einfache Dokumentationen bietet und schlussendlich als erstes funktioniert hat 😀 (siehe Punkt “Probleme”). 

Um überhaupt eine Karte einbinden zu können, haben wir Leaflet verwendet – speziell react-leaflet. Leaflet ist eine Open Source Library für interaktive Karten – also sehr passend für unseren Use Case.

Da Leaflet selbst aber kein Kartenmaterial bereitstellt, haben wir eine Karte von OpenStreetMap eingebunden, was zu unserer Erleichterung sehr einfach ablief. Kleiner Funfact: OpenStreetMap ist ein Projekt aus dem Jahr 2004, welches zum Ziel hat, eine freie Weltkarte zu erstellen und allen zur Verfügung zu stellen.

Unser Anwendungsfall beinhaltet die Darstellung der zurückgelegten Strecken auf der Karte, weshalb wir einen Service für das Routing in Leaflet benötigten. Wir haben uns für Leaflet Routing Machine entschieden, eine Bibliothek für Leaflet, welche speziell für diesen Anwendungsfall erstellt wurde. In die Leaflet Routing Machine ist die Open Source Routing Machine als Standard integriert, es können aber viele weitere Routing Engines, wie TomTom Online Routing API, Esri oder GraphHoppper verwendet werden. Diese Routing Engines werden hauptsächlich dazu verwendet, um kürzeste Wege zwischen zwei Standorten zu bestimmen.

Aktuell verwenden wir den Demoserver der Open Source Routing Machine. Hier kann man in Zukunft Verbesserungen vornehmen und einen eigenen Server aufsetzen.

Zusammenfassend hatten wir trotz kleiner Anfangsschwierigkeiten schlussendlich Glück mit der Wahl unserer verwendeten Technologien im Frontend.

Backend

Zur Umsetzung unseres Backends haben wir uns für IBM Cloud Functions entschieden.

IBM Cloud Functions ist ein Service, über den man Code dezentral auf den Servern von IBM ausführen lassen kann (FaaS). Es gibt auch alternative FaaS Anbieter wie zum Beispiel AWS Lambdas. Wir haben uns aber für IBM entschieden, da unsere Datenbank auch ein IBM Service ist und somit die Kompatibilität der Funktionen und der Datenbank gewährleistet ist.

Unsere Applikation soll ein Frontend haben, welches Daten in eine Datenbank schreibt und diese liest. Das Backend könnte man klassisch über ein Backend lösen, das zusammen mit dem Frontend deployed wird. Stattdessen haben wir uns aber für ein Backend entschieden, welches in der Cloud gehostet wird. Dadurch können wir das Frontend komplett vom Backend abkoppeln. Wir müssen uns auch nicht um das Deployment kümmern, da dies bereits von IBM gemanagt wird. Zudem ist die Skalierung sehr gut.

Mit IBM Cloud Functions kann man den Code direkt in eine HTTPS-API integrieren, welche auch über die IBM Cloud gemanagt wird. Es gibt auch schon vorgefertigte API Calls zu anderen IBM Services, wie z.B. Cloudant, welches wir für unsere Datenbank benutzen. Somit müssen wir diese nicht selbst schreiben.

Unser Backend muss folgende Dinge können:

Auslesen der Google Location Data und umwandeln in Datenbankobjekte
Die JSON-Datei, die wir von Google bekommen, beinhaltet mehr Informationen als die, die wir benötigen. Wir werden nur die relevanten Daten in die Datenbank hochladen. Wir unterscheiden zwischen zwei Typen:  

  • Location: Ein einzelner Ort, der an einem Datum besichtigt wurde.

    Die latitudeE7 und longitudeE7 Felder sind die aufgezeichneten Koordinaten, über die wir die Location auf der Karte darstellen können
  • Path: Eine Route, die an einem Datum zurückgelegt wurde. Enthält auch Informationen über die Wahrscheinlichkeit des Transportmittels.

    Die Felder startLocation und endLocation werden genutzt um eine Route über leaflet-routing-machine zu generieren. In den activities sind alle möglichen Fortbewegungsmittel mit ihrer jeweiligen Wahrscheinlichkeit enthalten. Dadurch können bei der Routenfindung unterschiedliche Wege bevorzugt werden.

Einzelne Objekte in die Datenbank hochladen
Dazu muss eine Verbindung mit der Datenbank hergestellt werden. Das Datenbankobjekt bekommt außerdem noch eine SessionID über die wir es später einem Nutzer zuordnen können. Zum Hochladen einzelner Objekte gibt es bereits eine von IBM bereitgestellte Funktion für Cloudant Datenbanken.

Hinzufügen von SessionID zum Objekt
Wenn wir Objekte in die Datenbank speichern, müssen wir immer eine SessionID mitgeben über die wir später auf die Objekte eines Nutzers zugreifen können. In dieser Aktion wird eine SessionID dem Objekt hinzugefügt.

Mehrere Objekte auf einmal in die Datenbank hochladen
Dies verläuft ähnlich wie die vorherige Aktion, mit dem Unterschied, dass mehrere Objekte auf einmal hochgeladen werden. Dafür mussten wir eine eigene Funktion schreiben, da wir die bereits bereitgestellte Funktion für Cloudant nicht verwenden konnten. Dazu nutzen wir die von IBM bereitgestellte Javascript Library für Cloudant.

Datenbankabfrage von Objekten anhand von Datum und SessionID
Unser Frontend benötigt alle Locations und Paths für einen bestimmten Tag. Dazu können wir wieder eine bereits bereitgestellte Funktion verwenden. Dieser geben wir das Datum und die SessionID vom betroffenen Nutzer mit.

Aufbauen eines Query Objekts
Damit wir die vorherige Abfrage tätigen können, müssen wir ein Query-Objekt mitgeben. Dieses muss aus SessionID und Datum aufgebaut werden.

Bei den IBM Functions werden diese einzelnen Abschnitte als Aktionen definiert. Das ist einfach der Code mit einem Input und mit einem Output. Diese Aktionen können dann einzeln ausgeführt werden oder aber auch in Sequenzen hintereinander. Dabei ist der Output der vorherigen Aktion der Input der darauffolgenden.

Aus den obigen Aktionen können wir folgende Sequenzen erstellen:

  • Auslesen der Google JSON → Hochladen mehrere DB Objekte
  • Hinzufügen von SessionID → Hochladen eines einzelnen DB Objekten
  • Aufbauen eines Query Objektes → Datenabfrage von DB Objekten

Um auf unsere Datenbank zugreifen zu können, müssen wir einen API Key erstellen.
Dieser wird entweder über die Cloudant Aktion mitgegeben, oder wir müssen den Schlüssel beim manuellen Aufbau der Verbindung mitgeben.

Mitgabe der Keys durch Cloudant Action
Manuelles mitgeben der Keys

Das Ausführen von Aktionen in Sequenzen erleichtert das Austauschen von Code-Abschnitten. Wenn z.B. ein neuer Datenbank Service genutzt werden soll, tauscht man einfach die Aktion, die die Objekte auf die Datenbank schreibt, aus.

API

Damit wir unsere Sequenzen aus dem Frontend einfach über HTTP-Requests ansprechen können, müssen wir diese einem API Endpunkt zuordnen. Diesem API-Endpunkt können Parameter mitgegeben werden, die der Input für die erste Aktion in der angesprochenen Sequenz sind.

Blau umrandete Aktionen sind von IBM bereitgestellte Aktionen.

Die Aktion bulk-db-upload haben wir selbst geschrieben, da wir keine vorgefertigte Funktion gefunden haben. Allerdings gibt es in der Javascript Cloudant API eine Möglichkeit, mehrere Objekte auf einmal hochzuladen.

Testing

Durch das Testen von Software soll sichergestellt werden, dass diese die zuvor definierten Anforderungen erfüllt. Testing von Software kann auf verschiedenen Ebenen erfolgen. Neben E2E-Tests, welche die Software auf einem hohen Level aus Nutzersicht testen sollen, gibt es Integrationstests und Unit-Tests, welche die Qualität der Software auf tieferen Ebenen sicherstellen. Während auf allen Ebenen Testautomatisierungen umgesetzt werden können, sind diese vor allem auf den unteren Ebenen, also im Integrations- und Unit-Testing, zu finden. Im Zuge der vermehrten Nutzung von automatisierten Tests, sowie dem Aufstieg der agilen Entwicklungsmethodik ist in den letzten Jahren der Ansatz der testgetriebenen Entwicklung entstanden. Anders als in der klassischen Softwareentwicklung, werden dabei die Tests vor dem Programmcode geschrieben. Dies hat den Vorteil, dass eine sehr hohe Testabdeckung erreicht werden kann und Fehler frühzeitig in der Entwicklung entdeckt werden können. Die Vorteile von automatisierten Tests im Vergleich zu manuellen Tests sind, dass eine geringere Fehlerquote und eine höhere Testabdeckung mit demselben Aufwand erreicht werden können. Im Rahmen des DevOps-Gedankens kann durch die Integration automatisierter Tests in eine CI/CD-Pipeline zudem eine höhere Auslieferungsgeschwindigkeit erreicht werden. Im Umfeld von Cloud Projekten ist es von besonderer Bedeutung externe Cloud-Services, zu welchen eine Abhängigkeit besteht, zu testen.

In unserem Projekt haben wir sowohl Unit-Tests als auch Integrationstests in Form von API-Tests umgesetzt, um die Qualität der Software sicherzustellen. Das Backend, also die Cloud Functions, sowie die Interaktion der Functions mit der Cloudant Datenbank, wurden über API-Tests mithilfe der Open Source Javascript-Library Frisby getestet. Für die einzelnen Cloud Functions wurden keine Unit-Tests entworfen, da die Qualität dieser über das Testen der einzelnen API-Endpunkte sichergestellt wird. Mithilfe von Frisby ist es möglich, über den Testrunner Jest API-Tests durchzuführen. Die PUT-Methode sowie die GET-Methoden können mithilfe von Testdaten, die gleich wie die realen Daten aufgebaut sind, automatisiert getestet werden. Die Antworten der Endpunkte werden mithilfe von assertions der Bibliothek Frisby sowie der Bibliothek joi für die Datenvalidierung überprüft.

In der Abbildung sind beispielhaft zwei Testfälle für einen PUT-Endpunkt dargestellt. Im ersten Testfall wird überprüft, ob der HTTP-Statuscode 401 zurückgegeben wird, wenn im HTTP-Header kein valider API-Key angegeben wird. Im zweiten Testfall wird überprüft, ob bei einer validen PUT-Anfrage der Statuscode 200 zurückgegeben wird.

Neben API-Tests wurden zudem Unit-Tests für das Frontend entworfen, um einzelne UI-Komponenten zu testen. Diese wurden mit react-testing-library implementiert. React-Testing-Library ist eine leichtgewichtige Bibliothek, um React-Komponenten zu testen. Ziel ist es, die Komponenten auf ähnliche Art und Weise zu testen, wie der Endnutzer mit ihnen interagieren würde. Hierzu bietet die Bibliothek die Möglichkeit, direkt auf DOM-Elemente zuzugreifen.

Beispielhaft sind zwei Testfälle der Komponente HomeComponent dargestellt. In der beforeEach-Funktion wird ein HTML-Element erzeugt, in welchem später die HomeComponent gerendert wird. Daraufhin wird der erste Testfall definiert, in welchem sichergestellt wird, dass kein Text „View your data“ dargestellt wird. Im zweiten Testfall wird überprüft, ob der Upload Button mit der TestId „uploadButton“ korrekt angezeigt wird. Tests ähnlicher Art wurden für verschiedene, wichtige Komponenten des Frontends entworfen, um das korrekte Verhalten der Komponenten sicherzustellen.

Deployment

Die grundlegende Idee war zunächst, dass Deployment und App getrennt voneinander betrachtet werden. Während sich ein Team um das Frontend kümmerte, befasste sich eine andere Gruppe um die Kubernetes-Umgebung. Hierzu wollten wir zu Beginn die bwCloud verwenden, welche auf OpenStack basiert, einem Tool zum Aufsetzen von VM-Maschinen. Hierbei mussten grundlegende Infrastruktur-Bestandteile eingerichtet werden. Hierzu gehörten Router, welche Floating-IP-Adressen freigeben, welche von der Cloud aus der Uni Mannheim heraus unseren Service öffentlich machen sollten. Zudem zählte dazu eine Verbindung zum externen Ingress Controller der VM von Kubernetes aus, welche nicht von der Firewall blockiert wurde. Des Weiteren mussten mehrere Nodes aufgesetzt werden, um die Struktur von Master und Worker Nodes umzusetzen. Zu all dem kam dann noch das Problem, dass wir mit OpenStack noch nicht gearbeitet hatten, aber Kubernetes aus einem bereits eingerichteten Netzwerk kannten, was zur Folge hatte, dass einfache Dinge Probleme bereiteten. Hierzu zählte zum Beispiel das Einrichten eines SSH-Zugriffs auf eine Openstack VM oder das Aufsetzen von Monitoring Tools und Kubernetes mit kubectl und CUDA GPU Support. Auch beim Debugging auf einer externen Maschine ohne Interface oder eingerichtetem Ingress mussten wir uns über Portforwarding, Kubernetes Event Logs und Curl-Befehle aushelfen. Die Fehlermeldungen von Kubernetes waren für uns teilweise ein wenig schwierig zu interpretieren. So kann ein ImagePullBackOff Fehler zum Beispiel sowohl an Credentials, falschen Settings oder Firewall-Regeln liegen.

Je mehr wir uns mit dem Thema auseinandersetzen, desto eher merkten wir, wie viel eigentlich zu einem funktionierenden Cluster bis hin zum bare metal Bereich gehört, welches wir aus anderen Vorlesungen kaum bis gar nicht kennengelernt hatten. 

Da immer mehr Schwierigkeiten anfielen, entschieden wir uns vorerst dazu, das Frontend direkt über eine PaaS-Lösung, IBM Cloud, zu deployen. Das Backend und die Datenbank benötigen kein Deployment, da beides in der IBM Cloud läuft.
Der Plan war es, das react-basierte Frontend in eine statische Website mit einer index.html zu konvertieren und alle Ordner in ein Bucket im IBM Cloud Object Storage hochgeladen. Danach kann der Entrypoint für die Webseite eingetragen und die Website in der IBM Cloud gehostet werden. In den Einstellungen können nun die Sichtbarkeit und Sicherheitsvorkehrungen vorgenommen werden.
Bei dem Deployment des Frontends in IBM Cloud Object Storage kam es jedoch leider zu Problemen, da React-Anwendungen nicht ohne weiteres in eine statische Website umgewandelt werden können. IBM unterstützt im Object Storage nur Node.js und anderweitige Backend Frameworks, weshalb wir uns dann letztendlich nach dem Ausprobieren unterschiedlicher Lösungen für GitHub Pages entschieden haben.

GitHub Pages lässt nicht kommerzielle, statische Webseiten schnell deployen, in unserem Fall über npm-Befehle. Github Pages unterstützt neben React zum Beispiel Docker, Jerkyl und html. Es sollte beachtet werden, dass das Repository auf public gestellt werden muss, damit Github Pages funktioniert. Für das Deployment haben wir uns an diesen Guide gehalten https://www.c-sharpcorner.com/article/how-to-deploy-react-application-on-github-pages/ .
Bis auf die Struktur des Repository-Namen, mussten nur einige kleine Änderungen in der package.json vorgenommen werden, um die Webseite über npm run deploy, mit einer funktionierenden CI/CD zu veröffentlichen. Für Deployments für kommerzielle Zwecke sollte man jedoch die größere Cloud-Anbietern verwenden. Unsere Anwendung wird voraussichtlich aus Sicherheitsgründen nur bis zum 10.10.2022 unter https://miclit131.github.io/ erreichbar sein, da jede Webanwendung verwaltet werden sollte falls Schwachstellen auftreten (zum Beispiel in verwendeten Libraries und npm-Packages).

https://www.ibm.com/cloud/blog/static-websites-cloud-object-storage-cos

Über die Struktur sparen wir nun den ganzen Aufwand, eine Infrastruktur aufzubauen und erhalten mit nur wenigen Klicks eine öffentliche Webseite. Gerade für Start-ups ist ein Cloud Modell geeignet, da keine Serverkosten anfallen und nur die tatsächliche Nutzung gezahlt wird. Zudem wird kein größeres Infrastruktur Team benötigt, welches das Monitoring, Security und Einrichten übernimmt. 

Probleme

  • Material UI als CSS Framework: Wir planten, Material UI einzusetzen, da es eine gute Integration in React Apps versprach,  jedoch funktioniert diese aus unbekannten Gründen nicht.
  • Grenzen des kostenlosen Cloudant Plans:
    Wir benutzen für unsere Datenbank den kostenlosen Plan von Cloudant, da es sich um ein Studentenprojekt handelt. Leider hat dieser Plan eine Limitierung von 5 Queries pro Sekunde. Damit stoßen wir an unsere Grenzen, da im schlechtesten Fall nur 5 Nutzer parallel unsere Seite nutzen können.
  • Frontend Deployment in IBM Cloud für dynamische Webseiten, kein React index.html support

Fazit / Lessons Learned

Während dem Projekt haben wir viele verschiedene Dinge gelernt. Durch den Architekturentwurf zu Beginn des Projektes konnten wir uns mit verschiedenen Technologien und Architekturmustern im Cloud-Umfeld auseinandersetzen. Während wir zuerst durch das Aufsetzen eines Kubernetes Clusters auf der IaaS-Ebene arbeiten wollten, entschieden wir uns letztendlich dafür, das Backend serverless zu gestalten. Hierbei lernten wir den Umgang mit der IBM Cloud, speziell den IBM Cloud Functions, sowie der nicht relationalen, verteilten Datenbank Cloudant kennen, welche von IBM als cloud-basierter Service bereitgestellt wird. Des Weiteren konnten wir den Umgang mit aktuellen Web-Technologien und Frameworks wie React und Bootstrap vertiefen und einiges in Richtung Routing und Kartendarstellung im Web-Umfeld lernen. 

Multiplayer Game with AWS | StadtLandFluss

Dieser Blogbeitrag soll einen Einblick in die Entwicklung unserer Webanwendung mit den oben definierten Funktionen geben sowie unsere Lösungsansätze, Herausforderungen und Probleme aufzeigen. 

Cloud Computing Vorlesung

Ziel der Vorlesung “Software Development for Cloud Computing” ist es, aktuelle Cloud Technologien kennen zu lernen und diese im Rahmen von Übungen und kleinen Projekten anzuwenden. Unser Team hat sich im Rahmen dieser als Prüfungsleistung zu erbringenden Projektarbeit dazu entschieden, das bekannte Spiel „Stadt, Land, Fluss“ als Multiplayer-Online Game umzusetzen. 

Projekt Idee & Inspiration

Zu Beginn der Vorlesung war sich unsere Projektgruppe noch sehr unsicher, was wir als Projekt mit Cloudkomponenten umsetzen wollten, da wir noch keine bis sehr geringe Vorerfahrung in der Cloud-Entwicklung hatten. Erste Brainstormings hatten ergeben, dass wir gerne eine Webanwendung entwerfen wollten. Jedoch war es gar nicht so leicht Zugriff zu interessanten Daten zu bekommen. 

Letztendlich hat sich unsere Gruppe dazu entschieden, sich nicht von Daten abhängig zu machen, sondern etwas Eigenes zu kreieren. 

Die Inspiration für unsere finale Idee (Stadt-Land-Fluss) war das Online-Spiel Skribbl IO, ein kostenloses Multiplayer-Zeichen- und Ratespiel. Dabei wird in jeder Runde ein Spieler ausgewählt, der etwas zeichnet, das die anderen erraten sollen. Skribbl ermöglicht es dem Spieler auch, einen eigenen Raum zu erstellen und Freunde einzuladen, die einen Link zu diesem Raum teilen.

Im Rahmen unseres Projektes hat uns die Idee gefallen etwas zu entwickeln, was man danach mit Freunden zusammen nutzen kann. Den Multiplayer Ansatz fanden wir spannend, da wir so etwas noch nie umgesetzt haben. Da wir alle Stadt-Land-Fluss Fans sind, fiel unsere Wahl auf dieses Spiel. 

Ziel

Primäres Ziel des Projektes war es für uns, erste Erfahrungen in Cloud-Computing zu sammeln und gleichzeitig unsere Fähigkeiten im Software-Engineering auszubauen. 

Konkret war es die Idee ein Stadt-Land-Fluss Spiel mit den folgenden Funktionalitäten zu entwickeln: 

  • Schritt 1: Raumerstellung
    • Spieler kann einen neuen Raum erstellen, oder über eine Raum-Id einem Raum beitreten
  • Schritt 2: Spieldaten bestimmen
    • Der Spieler, welcher einen Raum erstellt, soll die Kategorien selber bestimmen können, sowie die Zeit, welche man für das Ausfüllen einer Spielrunde hat, ebenfalls sollen Mitspieler- und Rundenanzahl bestimmt werden können
  • Schritt 3: Waiting Room 
    • Nach Erstellen oder Beitreten eines Raumes, kommt der Spieler in einem Warteraum, wo er die festgelegten Parameter der Spielrunden sieht und informiert wird, welche Spieler der Runde schon beigetreten sind 
  • Schritt 4: Letter Generator 
    • Der Buchstabe für eine Runde soll zufällig generiert werden, sich aber nicht wiederholen innerhalb eines Spiels
  • Schritt 5: Spielrunde
    • Auf der Seite der Texteingaben, soll ein Spieler die Runde stoppen können, sobald er alles ausgefüllt hat, dies triggert den Stopp bei allen Mitspielern
  • Schritt 6: Kontrollieren der Eingaben 
    • Alle Spieler sehen nach einer Runde ihre eigenen, aber auch alle anderen Eingaben der Mitspieler sowie die Punkte, die dabei erreicht wurden
    • Dabei werden die Punkte nach folgendem Schema berechnet:
      • Hat ein Spieler als Einziger in dieser Kategorie eine Eingabe und ist diese auch gültig (beginnt mit dem generierten Buchstaben), dann erhält er für dieses Feld 20 Punkte
      • Haben andere in diesem Feld auch Eingaben, erhält der Spieler für eine gültige Eingabe 10 Punkte
      • Hat ein anderer Spieler in der gleichen Kategorie die gleiche Angabe, erhält der Spieler 5 Punkte
      • Ist die Eingabe leer oder beginnt sie nicht mit dem generierten Buchstaben, werden keine Punkte vergeben
  • Schritt 7 : Hall of Fame  
    • Darstellung der Spieler-Ränge und ihrer Punkte nach Abschließen aller Runden

Das erste Mockup der zu erstellenden Webanwendung entsprach folgendem Design und war unser Leitfaden für die Entwicklungsphase: 

Skizze der groben Web Anwendung zu Beginn

Einblick in das Spiel – Demo 

Frameworks – Cloud Services – Infrastructure

Frontend

Aufgrund von vorhandenen Vorerfahrungen wurde die zweite Entscheidung getroffen, das Frontend mit Hilfe des Angular Frameworks umzusetzen. Angular ist ein TypeScript-basiertes Front-End-Webapplikationsframework. Das Backend wurde mit Python als Programmiersprache umgesetzt. Zum einen war hier mehr Vorerfahrung vorhanden bei einigen Teammitgliedern und zum anderen haben wir mehr Beispiele zur Anwendung von Websockets und AWS im Zusammenhang mit Angular gefunden, was uns sehr geholfen hat. 

Backend

Wie zu Beginn schon erwähnt, hat uns die parallel zum Projekt laufende Vorlesung gleich zu Beginn den großen Funktionsumfang von AWS aufgezeigt.  Besonders interessant fanden wir die Einsatzmöglichkeiten von Lambda Funktionen. Im Zusammenhang damit hat uns die Funktion gefallen ein API Gateway aufzubauen zu können. Da man bei der Programmiersprache völlig frei wählen kann, haben wir uns für Python entschieden. In der Python Programmierung hatten wir als Team zwar wenig Erfahrung, haben aber in dem Projekt eine Chance gesehen, uns in dieses Thema weiter einarbeiten zu können und unsere Fähigkeiten zu verbessern. 

Architektur

Architektur

Cloud Komponenten 

Vor dem Projektstart hatten wir zu Beginn die Schwierigkeit zu entscheiden, welchen Cloud-Anbieter wir für die Entwicklung nutzen wollen. Voraussetzungen für die Entscheidungen waren, dass es eine ausführliche Dokumentation der Möglichkeiten und Funktionen gibt (aufgrund der mangelnden Vorerfahrung), ebenfalls wollten wir nicht eine Kreditkarte als Zahlungsoption hinterlegen müssen und auch keine bis sehr wenig Kosten verursachen. 

Zu Beginn der Vorlesung hieß es noch, dass wir eventuell ein Konto bei der IBM-Cloud oder über AWS von der Hochschule bekommen würden. Allerdings war dies leider doch nicht der Fall, weswegen wir nach erstem Warten selbst eine Entscheidung treffen mussten. Wir haben uns schlussendlich für AWS (Amazon Web Services) entschieden, da es einer der führenden Anbieter im Cloud Computing ist. Hierbei hat uns gefallen, dass es sehr viele Tutorials und gute Dokumentation zu den einzelnen AWS Services gab. Ein Nachteil war, dass man beim Anlegen eines Kontos eine Kreditkarte hinterlegen musste. Vorteil war andererseits, dass man mit einem Gratis-Kontingent (Free Tier) an Funktionsaufrufen, Rollen, und DB Kapazitäten etc. startet, weswegen im Rahmen des Projektes dahingehend keine Kosten entstehen sollten. Im späteren Verlauf haben wir herausgefunden, dass man allerdings für die Funktionalität von AmazonCloudWatch, welches ein Service zur Einsicht der Logs ist, zahlen muss. Die Kosten waren nicht hoch, weswegen es kein Problem darstellte, allerdings sollte man sich eindeutig über die Kosten, welche bei der Entwicklung entstehen können, im Klaren sein, um nicht böse überrascht zu werden. 

Amazon Free Tier

Übersicht Free Tier : https://aws.amazon.com/de/free/?all-free-tier.sort-by=item.additionalFields.SortRank&all-free-tier.sort-order=asc&awsf.Free%20Tier%20Types=tier%23always-free&awsf.Free%20Tier%20Categories=*all

AWS Services 

Serverless 

Für das Anlegen unseres Projektes und auch der späteren Möglichkeit einer übersichtlichen Vorlage des Codes zur Bewertung, haben wir nach einer Möglichkeit gesucht, alle für AWS benötigten Konfigurationen sowie das Anlegen der verschiedenen Lambda-Funktionen in unserem GitLab Reporsitory hinterlegen zu können und direkt im Code Editor bearbeiten und ändern zu können. 

Nach einiger Recherche sind wir dabei auf das Serverless Framework gestoßen. Vorteile des Serverless Frameworks gegenüber der AWS Grafikoberfläche sind, dass alle man alle Elemente wie Datenbanken, Buckets, API-Routen und Aufrufe sowie Lambda Functions über eine serverless.yaml Datei verwalten kann. Zudem ist es nicht nötig, AWS-Kontoschlüsseln oder anderen Kontoanmeldeinformationen in Skripte oder Umgebungsvariablen zu kopieren oder einzufügen. Über die serverless.yaml können alle Ressourcen und Funktionen übersichtlich und schnell angelegt und bearbeitet werden. 

Functions

In der serverless.yaml angelegte Lambda Funktionen entsprechen dem folgenden Schema : 

Funktion Definition in serverless.yml 

Die in der Datei definierten Funktionen werden jeweils über einen eigenen „handler“ referenziert. Das bedeutet, dass sie über ein „Event“ aufgerufen werden können. Ein Event entspricht dabei beispielsweise einem API-Aufruf. 

broadcast_to_room Lambda-Funktion

Resources 

Neben unseren Funktionen sind in der serverless.yml ebenfalls alle Tabellen sowie Buckets definiert. Dies erleichtert die Einrichtung neuer Ressourcen, welche benötigt werden und gibt eine gute Übersicht. 

iamRoleStatements

Zudem können IAM-Rollen und Berechtigungen, die auf Lambda-Funktionen angewendet werden, konfiguriert werden. 

Allgemeine Konfiguration 

Es können ganz einfach allgemeine Settings angegeben werden, wie zum Beispiel: 

Übersicht der Möglichkeiten: https://www.serverless.com/framework/docs/providers/aws/guide/serverless.yml/

Dynamo DB 

Um unser Spiel umsetzen zu können, waren wir darauf angewiesen, bestimmte Daten in einer Datenbank zu speichern, dies waren beispielsweise Elemente wir eine Raum-ID, die Spielernamen, die gewählten Spielparameter, sowie die benutzen Buschstaben und eingetragene Ergebnisse der Spieler. Da die Einträge in der Datenbank immer variieren und keinem starren Muster folgen, beispielsweise ist es möglich eine unterschiedliche Zahl an Kategorien pro Spielraum zu haben, haben wir nach einer NoSQL Lösung gesucht. Wir wollten eine Datenbank mit nicht-relationalen Ansatz, um nicht von einem festen Tabellenschemata abhängig zu sein. Unsere Wahl fiel dabei auf Amazon DynamoDB, welches eine schnelle NOSQL-Schlüssel-Wert-Datenbank für beliebig große Datenmengen ist. 

Es gibt eine Tabelle „game_data“, welche alle relevanten Informationen enthält. 

Datenstruktur

Ein Beispiel unserer Datenstruktur ist in der folgenden Abbildung zu erkennen:

Diese JSON-Datei zeigt ein laufendes Spiel mit insgesamt zwei Spielern. Ganz oben ist die Room-Id gespeichert, welche einen einzigartigen Wert aufweist. Diese ist für die Spielrunde besonders wichtig, da so mehrere Räume generiert und gleichzeitig laufen können. Der darunter gespeicherte Array „categories“ beinhaltet alle Kategorien als String-Elemente, die der Spieler, der den Raum erstellt hat, ausgewählt hat. Das Dictionary „game_players“ enthält die Informationen zu allen Spielern. Dazu gehört bspw. der Username, der als Key im Dictionary agiert, die jeweilige Raum-Id, die Punkteanzahl sowie die Eingaben, die der Spieler für eine Runde eingegeben hat. Daneben werden zwei weitere Werte gespeichert, „next_round“ und „status“, welche dazu dienen, zu erkennen, dass die nächste Runde beginnen kann, also alle Spieler bereit sind, oder ob der Spieler noch aktiv ist, also das Spiel nicht bereits verlassen hat.

Des Weiteren werden die Rundenanzahl, die Spiellänge für eine Runde, sowie in einem Array die generierten Buchstaben der Runden gespeichert. Dieser wird pro Runde immer um den nächsten zufällig generierten Buchstaben erweitert.

Datenbank-Interaktionen

Während eines Spiels müssen immer wieder Spielerdaten abgefragt, verändert, hinzugefügt oder gelöscht werden. Dazu gehören bspw. das Anlegen eines neuen Spiels, das Hinzufügen neuer Spieler zu der jeweiligen Raum-Id, das Aktualisieren der erreichten Punkte etc. Diese Interaktionen haben wir in den Lambda-Funktionen, die später noch einmal genauer erklärt werden, implementiert und sie werden aufgerufen, sobald diese benötigt werden. Da diese in Python implementiert wurden, haben wir die AWS SDK für Python, Boto3 verwendet, die eine Integration von Python-Anwendungen und Bibliotheken in AWS-Services, darunter DynamoDB, ermöglicht.

Quelle: https://aws.amazon.com/de/sdk-for-python/

Abfragen von Daten

Auch wenn Boto3 als AWS-SDK für Python nützliche Methoden bezüglich DynamoDB bereitstellt, war es nicht immer einfach, diese zu verwenden. Da unsere Datenstruktur durch die Verschachtelungen recht kompliziert war, war es aufwendig, die benötigten Informationen abzufragen. 

Die obige Abbildung zeigt einen Ausschnitt aus einer Lambda-Funktion und stellt die Abfrage von Spieldaten dar. Zunächst wird die Verbindung zu der gewünschten Datenbank (game_data) hergestellt, indem Endpoint-Url sowie die Region angegeben werden muss. Mit dieser Datenbank-Instanz kann nun mit Methoden wie „.get()“ auf die gewünschte Information in der Datenbank zugegriffen werden. Das Verwenden dieser Funktionen war demnach einfach, jedoch wurden die Daten in der DynamoDB sehr oft in Dictionaries gespeichert, welches an vielen Stellen gar nicht nötig war, was in der Abbildung durch das sich wiederholende „.get(‚L‘)“, „.get(‚M‘)“ etc. zu erkennen ist.

In Wirklichkeit waren die Daten nämlich so aufgebaut:

Man erkennt, dass die Daten durch DynamoDB zusätzlich in Dictionaries und Arrays geschachtelt werden und es war schwierig zu erkennen, um was es sich nun handelt. Das Abfragen wurde dadurch viel komplizierter gestaltet, als es eigentlich ist und war auch sehr fehleranfällig.

Anlegen von Daten

Das Anlegen neuer Daten in die Datenbank erfolgt mithilfe der Methode „put_item“, welcher ein JSON-Objekt mitgegeben werden muss. Die Abbildung zeigt das Anlegen eines neuen Raumes.

Löschen von Daten

Das Löschen von Daten erfolgt ähnlich wie das Updaten von Daten, da der jeweiligen Methode „delete_item“ mehrere Parameter mitgegeben werden können, die den Befehl spezifizieren. In unserem Projekt haben wir komplizierte Aufrufe jedoch nicht gebraucht, weshalb das Löschen im Vergleich zum Updaten von Daten einfach war.

Update von Daten

Das Updaten von Daten erfolgt mithilfe der Methode „update_item“, dessen übergebene Parameter komplexer sind als die anderen und auch abhängig davon sind, welche Typen (Array, Dictionaries, ein einfacher Wert) aktualisiert werden sollen. Um zu bestimmen, welches Objekt in der Datenbank bearbeitet werden soll, kann ein sogenannter „Key“, welcher in der obigen Abbildung die Raum-Id ist, mitgegeben werden. Zusätzlich können die übergebenen Parameter überprüft (ConditionExpression) und schließlich bestimmt werden, wie das Objekt aktualisiert werden soll. In diesem Beispiel wird dem Array, der die generierten Buchstaben eines Spiels speichert, um einen Wert erweitert, weshalb als Wert für den Parameter „UpdateExpression“ ein „list_append“ verwendet wird.

S3

AWS S3 (Simple Storage Service) ist ein Service für das Speichern von Objekten. In unserem Projekt haben wir S3 benutzt, um unsere Anwendung zu hosten. Hierfür haben wir einen S3-Bucket erstellt und dort unter „Static website hosting“ die Einstiegs- und Fehlerseite unserer Anwendung angegeben. Damit Benutzer über die dabei erzeugte URL auf unsere Seite zugreifen können, haben wir diese anschließend noch öffentlich zugreifbar gemacht und eine Policy hinzugefügt, um im öffentlichen Modus den Inhalt des Buckets lesen zu können.

Über die AWS CLI können danach immer die aktuellen Files der gebauten Anwendung in S3 hochgeladen werden, wenn die Anwendung deployt werden soll (siehe auch CI/CD Kapitel):

Ausschnitt aus der CI/CD Pipeline zum Hochladen der Dateien in S3

Lambda

„AWS Lambda ist ein Serverless-, ereignisgesteuerter Computing-Service, mit dem Sie Code für praktisch jede Art von Anwendung oder Backend-Service ausführen können, ohne Server bereitzustellen oder zu verwalten. Sie können Lambda in über 200 AWS-Services und Software-as-a-Service (SaaS)-Anwendungen auslösen und Sie zahlen nur für das, was Sie nutzen.“

https://aws.amazon.com/de/lambda/?c=ser&sec=srv

Amazon API- Gateway 

Für die definierten Lambda Funktionen hat man bei AWS die Möglichkeit eine eigene Web-API mit einem http-Endpunkt zu erstellen. Dafür kann man das Amazon API Gateway verwenden. Die Funktion des API-Gateway ist es Tools für die Erstellung und Dokumentation von Web-APIs, die HTTP-Anforderungen an Lambda-Funktionen weiterleiten, zu erstellen. 

Websocket API Routen 

Api RouteBeschreibung
broadcast_to_roomSendet eine Nachricht an alle Spieler in einem Raum anhand der Raum-Id und den in der Dynamo DB gespeicherten Connection-Ids der Spieler.
check_roundsetzt den Status der nächsten Runde des jeweiligen Benutzers und sendet die Nachricht für die nächste Runde an die Spieler im Raum, wenn alle Benutzer die nächste Runde angeklickt haben.
create_room Erstellt einen neuen Raum in der Datenbank mit den angegebenen Werten oder Standardwerten, wenn keine Werte angegeben werden.
enter_roomFügt einen Benutzer zu einem Raum hinzu, indem sein Username sowie die entsprechende Connection-Id in der Dynamo DB unter dem Schlüssel der angegeben Raum-Id gespeichert werden. 
get_current_playersPrüft, ob der Spieler den Raum betreten darf und sendet alle Spielernamen des Raumes an den neuen Spieler.
get_results_for_roomSendet Raumdaten und Spielerdaten an alle Spieler des Raums mit der angegebenen Raum-Id.
load_user_inputslädt alle Benutzereingaben aus der Datenbank und sendet alle Werte (z.B. [[“Stuttgart”, “Rhein”, “Deutschland”]]) an die Spieler des Raums mit der angegebenen Raum-Id.
navigate_players_to_next_roomNavigiert alle Spieler in einem Raum zum Spielraum und wird aufgerufen, wenn der Hauptakteur die Taste “Spiel starten” drückt.
play_roundFragt Timer, Kategorien und Rundeneinstellungen für den Spielraum über die Room-Id aus der Datenbank ab. 
remove_player_from_roomSetzt den Spielerstatus auf inaktiv in der Dynamo DB und löscht die Raumdaten aus der Datenbank, wenn alle Spieler dieses Raums inaktiv sind.
save_roundSpeichert Benutzereingaben aus Kategoriefeldern in der Datenbank.
start_roundBeginnt die nächste Runde, indem er einen neuen Buchstaben erzeugt und prüft, ob dieser Buchstabe bereits ausgewählt wurde und speichert den erzeugten Buchstaben in der Datenbank.
stop_roundStoppt eine Spielrunde, wenn jemand die Stopptaste gedrückt hat. 

Amazon CloudWatch 

Amazon CloudWatch entspricht einem Überwachungs- und Beobachtungsservice für Entwickler.  Wir wollten über CloudWatch die Verwendung unserer API protokollieren. Dabei gibt es die Möglichkeit der Ausführungsprotokollierung. Hierbei verwaltet das API-Gateway die CloudWatch-Protokolle. Es können verschiedene Protokollgruppen erstellt werden, welche dann die verschiedenen Aufrufe, Abfragen und Antworten an den Protokollstrom melden. Zu den protokollierten Daten gehören beispielsweise Fehler. 

Protokollgruppen

Logs der Methode load_user_inputs

Testing 

Beim Testing haben wir uns hauptsächlich auf das Testen der Lambda-Funktionen mit Unittests fokussiert. Dafür haben wir die Bibliothek Moto benutzt, mit der man AWS Services mocken kann. Dadurch konnten wir in den Tests unsere Datenbank mocken und beispielsweise auch testen, ob beim Aufruf der Lambdas Datenbankeinträge richtig angelegt oder Daten richtig aktualisiert werden. Allgemein muss für das Mocken der Datenbank nur die Annotation @mock_dynamodb2 über der Testklasse eingefügt werden und anschließend kann in der setUp-Methode die Datenbank definiert werden, die für die Tests benutzt werden soll. Dadurch können auch Testdaten in die Datenbank eingefügt werden, um bestimmte Testfälle zu testen.

Neben Moto haben wir die Bibliothek unittest.mock benutzt, mit der zum Beispiel das Senden einer Nachricht über die Websocket-Verbindung gemockt werden kann, oder auch der Aufruf einer Lambda-Funktion. Zudem kann man mit Methoden wie assert_called() oder assert_called_with(…) überprüfen, ob und mit welchen Argumenten die gemockte Methode aufgerufen wurde. Allgemein war dies bei unserem Projekt sehr hilfreich, da wir in fast jeder Lambda-Funktion Nachrichten über die Websocket-Verbindung schicken und somit auch testen konnten, ob die richtigen Nachrichten geschickt werden.

Für manuelle Tests oder zum kurzen Testen von bestimmten Eingabewerten war auch die Seite https://www.piesocket.com/websocket-tester sehr hilfreich, da man dort die verschiedenen Lambda-Funktionen über eine Websocket-Verbindung aufrufen kann.

CI/CD Pipeline 

CI/CD Pipeline in GitLab auf dem master-Branch

Um unsere Tests automatisiert ablaufen zu lassen und auch andere Schritte wie das Deployen der Lambda-Funktionen nicht immer manuell ausführen zu müssen, haben wir in GitLab eine CI/CD Pipeline erstellt. Da wir alle vor diesem Projekt noch nie eine CI/CD Pipeline angelegt hatten, konnten wir somit durch das Projekt auch Erfahrungen in diesem Bereich sammeln. Allgemein ist unsere Pipeline unterteilt in verschiedene Stages: In der Testing-Stage werden die Unittests für unsere Lambda-Funktionen ausgeführt. In der Build-Stage werden die aktuellen Versionen der Lambda-Funktionen über das serverless-Framework deployt und anschließend wird unsere Angular-Anwendung gebaut. Am Ende wird unsere Anwendung in der Deploy-Stage deployt, sodass sie danach über eine öffentliche Url verfügbar ist. Je nachdem, auf welchen Branch ein Entwickler in unserem Repository pusht, werden unterschiedliche Jobs ausgeführt. So werden zum Beispiel die Unittests auf jedem Branch ausgeführt, das Deployen der Lambda-Funktionen jedoch nur beim Pushen auf den develop- oder master-Branch und das Deployen der Anwendung nur beim Pushen auf den master-Branch.

Insgesamt gibt es in GitLab unter Settings -> CI/CD die Möglichkeit, Variablen anzulegen, die in der CI/CD Pipeline benutzt werden. Wir haben daher in IAM (Identity and Access Management) bei AWS einen Benutzer für die Pipeline angelegt, der nur die Rechte hat, die in der Pipeline benötigt werden. Die Keys dieses Benutzers haben wir anschließend als Variablen in GitLab angelegt, sodass zum Beispiel das Deployen der Lambda-Funktionen in der Pipeline mit diesem Benutzer durchgeführt werden kann.

Schwierigkeiten 

Während unserer Arbeit am Projekt sind uns einige Schwierigkeiten begegnet, die im Folgenden näher beschrieben werden.

Datenstruktur

Wie bereits beschrieben hatte sich das Abfragen von Daten in der DynamoDB als sehr aufwendig und komplex dargestellt, auch wenn der Methodenaufruf an sich leicht zu verstehen und einfach ist. Durch die weiteren Verschachtelungen, die AWS zusätzlich zu unserer bereits komplexen Datenstruktur hinzugefügt hat, wurde das Abfragen von Daten schwieriger dargestellt, als es eigentlich ist und auch der Code, den wir dafür geschrieben haben, wurde durch dieses sehr unleserlich. Dadurch kam es in unserer Gruppe oft zu Fehlern bei den Abfragen, da der Fehler nicht direkt erkannt wurde und die endgültige Datenstruktur in der Datenbank für unsere Gruppe unklar wurde. 

Wir haben diesbezüglich, auch nachdem wir die Struktur festgelegt hatten, mehrmals gelesen, dass DynamoDB für komplexe Datenstrukturen und Interaktionen (auch das Updaten von Daten) schlichtweg nicht geeignet ist. Dennoch haben wir es dabei belassen, da wir bereits im Projekt weit fortgeschritten waren und keine Zeit mehr hatten, eine Alternative zu finden.

Deployen von Lambdas

Damit die Lambdas, die wir in der serverless.yaml definiert und in den jeweiligen Handler-Dateien implementiert hatten, in unserer Anwendung aufgerufen werden konnten, war es nötig, den Befehl „serverless deploy“ aufzurufen, der alle definierten Lambdas in den Handler-Dateien deployed. Das Problem war, dass dadurch bestehende Funktionen, an denen andere Gruppenmitglieder arbeiteten, anschließend überschrieben wurden, was das Arbeiten sehr behinderte, wenn man an dem Projekt gleichzeitig arbeitete.

Um diese Situation weitestgehend zu verhindern, haben wir beschlossen, die Methoden auf dem Development Branch allesamt mit dem Befehl „serverless deploy“ zu deployen. Anschließend wird auf unterschiedlichen Branches gearbeitet und anschließend nur die Funktion, an der man gerade arbeitet, mit dem Befehl „serverless deploy function –function [Name der Funktion]“ deployed. Dieses hat nur teilweise funktioniert, da das Deployen einer einzigen Funktion nur möglich war, wenn diese bereits existiert, also durch „Serverless deploy“ deployed wurde.

Fazit

Alles in allem haben wir durch das Projekt einen Einblick in die Möglichkeiten von Cloud Computing bekommen und konnten verschiedene Dinge in diesem Bereich ausprobieren. Wir konnten vor allem Erfahrungen mit AWS Lambda, API Gateway und dem serverless Framework sammeln, da dies der Schwerpunkt unseres Projektes war. Zudem haben wir einige grundlegende Dinge gelernt, zum Beispiel dass es sinnvoll ist, schon früh im Projekt eine CI/CD Pipeline aufzubauen oder auch CloudWatch zu aktivieren, um Fehler in den Lambda Funktionen schneller zu erkennen und beheben zu können.

Allgemein haben wir durch das Projekt auch gelernt, dass es sehr wichtig ist, sich am Anfang Gedanken zu Themen wie Security oder auch dem generellen Aufbau des Projektes zu machen. Aus Zeitgründen verfolgt man sonst oft die am schnellsten funktionierende Lösung und kann dann später nur mit viel Aufwand grundlegende Dinge ändern. Für das nächste Projekt würden wir daher mehr Zeit für die Einarbeitung und Recherche einplanen, um dies zu vermeiden. Bei unserem Projekt wäre es vor allem sinnvoll gewesen, schon von Beginn an die verschiedenen Umgebungen einzurichten, sodass alle Entwickler lokal unabhängig voneinander entwickeln und testen können und man auf der Entwicklungsumgebung Änderungen durchführen kann, ohne die Produktivumgebung zu beeinflussen.

Migration einer REST API in die Cloud

Artikel von Cedric Gottschalk und Raphael Kienhöfer

Im Rahmen der Endabgabe der Vorlesung “Software Development für Cloud Computing” haben wir uns zum Ziel gesetzt, eine bereits bestehende REST API eines vorherigen Projektes in die Cloud zu migrieren. Dabei haben wir uns dafür entschieden, die Google Cloud zu verwenden. Im Zuge dieses Projektes haben wir uns auch mit Infrastructure as Code mittels Terraform beschäftigt.

Architektur

Vor dem Umzug in die Cloud lebte die API als Container auf einem einzelnen Server, der mittels Docker Compose verwaltet wurde. Hier wurde nginx als Reverse-Proxy eingesetzt, um die Übertragung mittels TLS zu sichern. MariaDB wurde als SQL-Datenbank eingesetzt. Die Verknüpfung der einzelnen Dienste gestaltete sich hier durch den gemeinsamen technischen Unterbau (Docker) sehr simpel.

Continue reading

MealDB Chatbot mit Google Dialogflow

Ein Projekt von Ronja Brauchle, Julia Cypra und Lara Winter

Einleitung

Gab es bereits drei Tage hintereinander Pasta mit Pesto? Ist das einzig abwechslungsreiche in deinem Speiseplan die Entscheidung ob die klassische Tiefkühlpizza mit Salami oder die extravagantere Wahl des Flammenkuchens nach Bauernart in den Ofen geschoben wird?  Unser MealBot hilft genau bei diesen monotonen und inspirationslosen Phasen: Er schlägt Rezepte basierend auf Lebensmitteln vor, die man gerade zuhause liegen hat; er kann nach Länderküchen und Essensvorlieben Mahlzeiten heraussuchen oder einfach zu einer dir bereits bekannten Mahlzeit das Rezept ausgeben.

Wir haben für das Projekt Dialogflow von Google verwendet, das eine Engine basierend auf NLU (Natural Language Understanding) ist und mit der man intelligente Chatbots erstellen kann. Durch das Anbinden an die MealDB (https://www.themealdb.com/) mithilfe der MealAPI kann unser Chatbot dem User hunderte von Rezepten ausgeben. Der MealBot wurde in Telegram integriert und ist somit dort für jeglichen Chitchat anzutreffen.

Entwurf und Architektur

https://cloud.google.com/static/dialogflow/es/docs/images/fulfillment-flow.svg

Die obige Abbildung visualisiert die Auftragsausführung nach einer Benutzereingabe. Wie bereits erwähnt, ist unser Chatbot in den Messengerdienst Telegram integriert, von dort wird die Benutzereingabe weiter an Dialogflow geleitet. Dank der in Dialogflow integrierten KI kann die Benutzereingabe einem der passenden individuell erstellten Intents zugeordnet werden. Dabei werden außerdem auch bestimmte Parameter aus der Eingabe extrahiert. Ein Intent kann eine vorgefertigte statische Antwort beinhalten, es kann aber auch unter dem Punkt „Fulfillment“ ein Webhook Aufruf aktiviert und somit passender Code getriggert werden. Im Falle eines Webhook Aufrufes werden die extrahierten Parameter, sowie weitere Informationen zu dem zugeordneten Intent in einer JSON HTTPS POST Anfrage, dem Fulfillment Request, an den Webhhokdienst geschickt. Der Webhook kann selbst gehostet werden, dieser muss dann HTTPS POST Anfragen in Form von JSON verarbeiten. In unserem Fall haben wir den Code zur Auftragsausführung im Inline-Editor von Dialogflow erstellt und somit in einer Cloud Function bereitgestellt. Die Cloud Function wird dann durch den Dialogflow getriggert und erhält die Daten aus der JSON Anfrage. Die Cloud Function besteht lediglich aus einer index.js, die Funktionen für die jeweiligen Intents enthält. Durch das passende Intent Matching wird dann auch die jeweilige Funktion ausgeführt. In dieser Funktion wird unsere externe API, die MealDB, abgefragt. Dabei wird je nach Intent ein zugehöriger Endpoint mit den passenden Parametern angesprochen. Die Antwort der MealDB Abfrage wird dann zusammen mit anderen Daten in der Webhook Antwort wieder an Dialogflow zurückgeschickt. Dialogflow extrahiert aus der Antwort die benötigten Daten und sendet diese letztendlich an Telegram, sodass der Endbenutzer die Antwort erhält.

Die untenstehende Abbildung verdeutlicht nochmal den Ablauf mit den von uns benutzten Technologien.

Der Vorteil von Cloud Functions ist, dass diese serverless, günstig und skalierbar sind. Das heißt die Cloud Function ist ereignisgesteuert und wird nur bei einem Event getriggert. Dadurch und wegen der Skalierbarkeit halten sich die Kosten gering.

Basics aka die Bausteine von Dialogflow

Intents

Intents ermöglichen es dem Agenten die Motivation hinter einer bestimmten Benutzereingabe zu verstehen. Jeder Intent stellt eine Aufgabe oder Handlung dar, die der Benutzer ausführen möchte. Im folgenden Bild sieht man die von uns für den MealBot erstellten Intents inklusive der Erklärung dieser.

Ausdrücke, die dasselbe bedeuten, aber auf unterschiedliche Weise konstruiert werden, werden demselben Intent zugeordnet. Somit kommen wir direkt zum ersten Bestandteil (von dreien) eines Intents, und zwar zu den Trainingsformulierungen:

1.Trainingsformulierungen

Trainingsformulierungen sind Beispielsätze für das, was der Endnutzer sagen könnte. Wenn der Endnutzerausdruck einer dieser Sätze ähnelt, stimmt Dialogflow mit dem Intent überein. Jedoch muss man nicht jede denkbare Formulierung angeben, da das integrierte maschinelle Lernen die Liste automatisch um ähnliche Äußerungen erweitert.

2. Parameter

Parameter sind die aus dem Benutzerausruck extrahierten Werte (in der ersten Trainingsformulierung wären es “garlic” und “salt”). Jeder Parameter hat einen Entitätstyp (siehe Abschnitt Entities; hier “Ingredients”). Der bzw. die Parameter werden je nach Intent an den passenden Endpoint geschickt. Aus dem von der Meal API zurückgeschickten Datensatz formt man dann eine Antwort, die dem User ausgegeben wird.

3. Antwort

Bei den meisten Intents konstruieren und formatieren wir die Antwort im Inline-Editor, wo wir mit dem von der API zurückgeschickten Datensatz direkt weiterarbeiten können. Bei Smalltalk bezogenen Intents, die keine API-Anfrage benötigen, wird die Antwort per GUI angegeben:

Beispiel Intent “thanks”

Man unterscheidet bei Antworten zwischen zwei Arten:

1.Standard-Antwort: Bot antwortet auf die Frage und ist anschließend bereit weitere unspezifische Fragen zu beantworten. 

Bsp: Intent “random-meal” : Der User fragt, ob der Bot irgendein Rezept vorschlagen könnte -> Der Bot gibt direkt eins aus und ist bereit, andere Fragen zu beantworten.

2. follow-up-intents: Bot antwortet auf die erste Frage, erwartet dann aber vom User weitere Eingaben, die sachlich auf die erste Frage aufbauen.

Bsp: alle “filter-by” Intents: Der User fragt beispielsweise nach chinesischem Essen, woraufhin der Bot ihm 3 Rezepte vorschlägt und er vom User erwartet, eins auszuwählen.

Entities

Entities werden verwendet, um bestimmte Informationen herauszufiltern, die der Benutzer erwähnt. Sie nehmen nützliche Daten auf und stellen sie zur weiteren Verarbeitung bereit. Man unterscheidet zwischen zwei Arten:

1.System entities, die bereits in Dialogflow enthalten sind und mit denen der Agent ohne zusätzliche Konfiguration Informationen zu einer Vielzahl von Konzepten extrahieren kann.

Bsp: Dialogflow erkennt direkt eine Zeitangabe, ein Datum oder eine Zahl (sys.time, sys.date, sys.number)

2. Developer entities definiert der Entwickler anhand seines individuellen Datensatzes selbst. Bezüglich der Parameter, die wir an die Endpoints der Meal API schicken, haben wir folgende Entities definiert:

“Ingredients”-Entität: Lebensmittel, die in der MealDB vorhanden sind und die der Agent bei einem Benutzerausdruck identifizieren sowie extrahieren könnte

Entity Dilemma

Um eine Benutzereingabe richtig zu verstehen und die passenden Parameter herauszufiltern, muss Dialogflow die zugehörigen Entitys kennen. Denn ist ein Entity vorher nicht bekannt, kann Dialogflow dies auch nicht erkennen und somit eine Eingabe auch nicht dem passenden Intent zuweisen. Sollte der Benutzer zum Beispiel eingeben: „I want to cook pancakes.“, Dialogflow aber nicht weiß was pancakes sind, kann er damit nichts anfangen. Aus diesem Grund mussten wir alle möglichen Gerichte, Länder, Kategorien und Zutaten als Entitys abspeichern. Bei den Kategorien und Ländern ging dies auch relativ schnell. Bei dem Entity „AreaAdjective“ mussten wir ein Adjektiv herausfiltern, da der API Endpoint nur diese akzeptiert hat. So funktioniert zum Beispiel eine Abfrage mit „italian“, wohingeben „Italy“ keine Ergebnisse liefert. Durch das Hinzufügen von Synonymen konnten wir das Problem aber schnell lösen, denn so wird „Italy“ automatisch zu „italian“ umgewandelt. Bei den Gerichten und Zutaten hat es sich aber ein bisschen schwieriger gestaltet. Denn es gibt keinen Endpoint bei der MealDB, mit welchem man alle vorhandenen Rezepte abfragen kann, man kann lediglich alle Rezepte mit einem bestimmten Anfangsbuchstaben abfragen. Deshalb haben wir uns eine kleine Hilfsfunktion erstellt, die in einer Schleife Abfragen mit allen möglichen Buchstaben macht. Die einzelnen Zwischenergebnisse haben wir dann in ein Array gepushed, sodass wir zum Schluss alle möglichen Rezepte zusammen hatten. Diese Rezepte haben wir uns dann ausgeben lassen über die Konsole und als CSV Format in dem Entity „Ingredients“ hochgeladen. Dazu der passende Code, der vielleicht nicht der schönste ist, aber funktioniert hat.

Die Zutaten konnten wir zwar alle über einen Endpoint abfragen, da es aber viel zu vielen waren, um sie von Hand als Enitiy einzugeben, haben wir uns dafür auch eine Hilfsfunktion erstellt. Mit dieser Funktion haben wir die Abfrage formatiert und wieder ausgegeben, sodass wir alle Zutaten auf einmal im CSV Format hochladen konnten. Der Code dazu funktioniert ähnlich wie der obere.

Kontext

Schon mal an einer regen Diskussion von Fremden vorbeigelaufen und Phrasen aufgeschnappt, die für einen als Außenstehender überhaupt keinen Sinn ergeben haben? 

So wie Menschen einen Kontext brauchen, um herauszufinden, was ein bestimmtes Wort oder bestimmter Satz bedeutet, benötigen auch Chatbots sie. Ohne Kontext könnte das Wort bzw. der Satz absolut keine Bedeutung haben oder etwas völlig anderes bedeuten als die ursprüngliche Absicht des Benutzers

Unser persönliches (philosophisches) Kontext-Dilemma

Wie bei den Intents oben bereits kurz skizziert, ist unser Rezept-Vorschlag-Konzept wie folgt aufgebaut: Der Benutzer fragt idealerweise nach einem Rezept basierend auf einer Länderküche, Essenskategorie etc. Darauffolgend schlägt der Agent ihm bis zu 3 Rezepte vor, von denen sich der Benutzer für eins entscheiden kann. Wie man bereits schon ahnen könnte, ist hier ein Kontext erforderlich, damit der Chatbot Aussagen wie “Option 2”,“Ich will das erste” auch zuordnen kann.

Mit voller Motivation haben wir für jeden Intent einen follow-up intent zusammengeklickt, der genau diese Auswahl anhand der Nummerierung der vorgeschlagenen Rezepte erkennen sollte. Somit wurde logischerweise für jeden Intent ein eigener Output-Kontext erstellt, der bei dem jeweiligen follow-up Intent als Input-Kontext wieder eingespeist wurde. Schnell wurde uns beim Code-Schreiben bewusst, dass eigentlich jeder follow-up intent die selbe Logik implementiert, und zwar zu erkennen welches Rezept ausgewählt wurde und dieses formatiert an den User zurückzugeben. Die Idee schlich sich herein, alle individuellen Kontexte der Intents in einen zusammenzufassen und diesen einen bequem in einer Funktion zu verwalten und für alle follow-up intents aufzurufen. Wir gaben der Idee nach, da alles nach unseren Erwartungen richtig ausgegeben wurde. Trotzdem fühlt sich die Lösung nicht ganz “clean” an, da sprachwissenschaftlich gesehen eigentlich jeder Intent vom Inhalt her einen eigenen Kontext verdienen sollte: Ob man gerade nach einer Essenskategorie oder Länderküche gefragt hat, sollte in einer echten Konversation schon einen Unterschied machen. Programmatisch hat es uns die Sache jedoch wesentlich erleichtert.

API Routen

axios.get(“https://www.themealdb.com/api/json/v1/1/categories.php “)

Auflisten aller meal categories. Wird genutzt, um die Beschreibung einer vom Endnutzer eingegebenen Kategorie herauszufiltern und auszugeben.

axios.get(“https://www.themealdb.com/api/json/v1/1/random.php“)

Ausgabe des Rezeptes eines random meals.

axios.get(“https://www.themealdb.com/api/json/v1/1/filter.php”, { params: { a: inputArea } })

Filtern der Gerichte nach einem vom Endnutzer eingegeben Land. Wird genutzt um drei random Gerichte dieses Landes als Optionen dem Nutzer zur Verfügung zu stellen

axios.get(“https://www.themealdb.com/api/json/v2/9973533/filter.php”, { params: { i: paramsIngredients } })

Filtern der Gerichte nach einer oder mehreren vom Endnutzer eingegeben Zutaten. Wird genutzt um drei random Gerichte mit diesen Zutaten als Optionen dem Nutzer zur Verfügung zu stellen.

axios.get(“https://www.themealdb.com/api/json/v1/1/search.php”, { params: { s: inputMeal } })

Filtern der Gerichte nach einem vom Endnutzer eingegeben Gericht. Wird genutzt um bis zu drei Arten dieses Gerichtes als Optionen dem Nutzer zur Verfügung zu stellen.

axios.get(“https://www.themealdb.com/api/json/v1/1/filter.php”, { params: { c: inputCategory } })

Filtern der Gerichte nach einer vom Endnutzer eingegeben Kategorie. Wird genutzt um bis zu drei Optionen aller Gerichte aus dieser Kategorie als Optionen dem Nutzer zur Verfügung zu stellen.

axios.get(“https://www.themealdb.com/api/json/v2/9973533/lookup.php”, { params: { i: mealId } })

Suche nach einem bestimmten Gericht durch den Parameter id und Ausgabe des Rezeptes.

Beispiel Cloud Function for Firebase – FilterByArea:

In der Funktion wird als Parameter der Webhook Client agent übergeben. Über diesen bekommt die Variable InputArea als Wert den Parameter des Entitätstyps “AreaAdjective”, der vom Nutzer eingeben und von Dialgogflow erkannt  und extrahiert wurde. Mit Hilfe der Bibliothek axios wird auf die MealDB zugegriffen. Dabei wird die URL mit inputArea übergeben.  Als Antwort kommen bei res nun die Daten aus der MealDB zurück. Diese werden an die Funktion handleResult() übergeben, welche die Daten filtert. Letztendlich werden die gefilterten Daten über agent.add() zur Antwort, die dem Endnutzer dann angezeigt wird.

Chatbot in Action

1. Intents und Entitäten: Die Nutzereingabe wird mit Hilfe der Trainingssätze und dem Parameter mit dem Entitätstyp dem passenden Intent zugeordnet, dem “filter-by-ingredients” Intent. Somit kommt die passende Antwort zurück. Die Nutzereingabe carrot wird als Parameter “Carrots” des Entitätstypen “ingredients” erkannt. Dialogflow kann dies zuordnen, auch mit anderer Groß – und Kleinschreibung bzw. Ein – oder Mehrzahl.

2. FollowUpIntent: Option 0: Dialogflow erkennt die 0 als passend zu den Trainingssätzen vom Intent “filter-by-ingredients – zero”. Dieser ist ein FollowUp Intent auf “filter-by-ingredients”.

3. Kontext:  Die Nutzereingabe wurde dem “filter-by-area” Intent mit dem Parameter “areaAdjective” = thai zugeordnet. Außerdem wurde der Kontext “user-data” hergestellt. Der Nutzer gibt nun die Option 2 an und der Chatbot befindet im FollowUpIntent “filter-by-area – options” mit dem Parameter value = 2. Das passende Rezept wird ausgegeben. Gibt der Nutzer nun die Option 3 ein, so wird wieder das passende Rezept ausgegeben. Der Chatbot kann die 3 deswegen immer noch als number Parameter des FollowUpIntent “filter-by-area – options” erkennen, da er sich noch im Kontext  “user-data” befindet. Für solche Zusammenhänge ist also der Kontext nötig.

4. Problem: Lasagna Problem: Unter der Option Lasagna kann der Chatbot nichts finden. Unter der Option Lasagna Sandwich jedoch schon. Die Erwartung des Endnutzers könnte allerdings sein, bei der Suche nach Lasagna alle Rezepte die Lasagna im Namen haben zu finden, u.a. Lasagna Sandwich. Die Meal Optionen im Chatbot sind allerdings an die in der MealDB gebunden und in der MealDB gibt es kein Gericht Lasagna. Demnach gibt es in der “Meal” Entitäten Liste auch keine Lasagna. Dialogflow sucht die Entitäten nach Lasagna ab, kann aber keinen genauen Treffer finden und den Wortteil “Lasagna” aus “Lasagna Sandwich” nicht extrahieren und als richtig erkennen. Der Nutzer muss also den gesamten Namen des Gerichts angeben.

Testing

Zum Testing haben wir leider nur wenig gefunden, vor allem zu automatischen Tests, um den Dialogflow als Ganzes zu testen. Zwar gibt es in der Dialogflow GUI die Möglichkeit einzelne Sätze zu testen, wobei auch der zugehörige Intent, Context sowie die extrahierten Parameter angezeigt werden, was auch sehr hilfreich zum Debuggen war. Der nebenstehende Screenshot zeigt, wie dies dann aussieht. So werden zum Beispiel unter dem Punkt „Contexts“ die passenden Contexte angezeigt, da es sich um einen follow-up Intent handelt. Außerdem wurde der passende Intent „filter-by-area“ zugeordnet, sowie der Parameter „italian“. Unter dem Punkt „Diagnostic Info“ kann man noch zusätzlich die JSON Anfrage und Antwort vom Webhook anschauen.

Jedoch kann man in der Google Cloud Console die Cloud Function testen, wie der untenstehende Screenshot zeigt.

Dazu kopiert man die JSON Webhook Anfrage aus der obigen „Diagnostic Info“ und startet den Test. Wie man sehen kann, funktioniert die Anfrage und die passende fulfillmentMessage mit der MealDB Antwort kommt zurück. Außerdem werden darunter auch die Logs angezeigt.

Unser Fazit mit Dialogflow

Vorteile

  1. Dialogflow basiert auf einem sehr intuitiven Konzept; Intents, Entities etc. versuchen die Charakteristiken der natürlichen Sprache nachzustellen, sodass es einem leicht fällt die Funktionsweise eines Chatbots nachzuvollziehen.
  2. Die GUI ist sehr einfach und simpel gehalten; man konnte sich auch ohne Hilfe der Dokumentation schnell zurechtfinden.
  3. Da die KI bereits zur Verfügung gestellt wird, sind keine tieferen Kenntnisse in dem Gebiet notwendig.
  4. Der 90 Tage Testzeitraum war mit circa 300$ Guthaben mehr als ausreichend für einen Chatbot in unserem Rahmen

Nachteile

  1. Der größte Nachteil von Dialogflow war das sehr lange Deployen der cloud functions; ein Durchgang hat circa drei Minuten gedauert. Von daher war es ziemlich nervig, wenn auf Anhieb was nicht funktioniert hat und man bei jedem Rumprobieren, selbst wenn man nur eine Kleinigkeit geändert hat, drei Minuten warten musste. Bei einem tiefergehenden/umfangreicheren Problem, dass von mehreren Codestellen verursacht wurde, kann man sich die Frustration bei dem Fixen mit dieser Wartezeit wahrscheinlich gut vorstellen.
  2. Alle Funktionen mussten in einer einzigen file geschrieben werden. Es kam nicht selten vor, dass paar von uns parallel an der Datei gearbeitet haben, sodass wenn einer das Deployen gestartet hat, die Änderung des anderen verworfen wurden. Wenn eine Person das Deployen gestartet hat, konnten die anderen währenddessen auch nichts abspeichern, da es sofort gecancelled wurde.
  3. Der Inline-Editor ist eher rudimentär aufgebaut und weit von einer vollwertigen IDE entfernt. Es fängt bei banalen Kleinigkeiten an, wie dass man nicht erkennt, welche öffnende Klammer zu welcher schließenden gehört. Aber auch, dass keine Liste an verfügbaren Funktionen vorgeschlagen wird und dass viele Syntaxfehler toleriert werden, die beim Deployen aber zu Errors führen.
  4. Die Dokumentation von Dialogflow ist unvollständig und bleibt sehr oberflächlich. Wir haben viel Stackoverflow benutzt aber mussten letztendlich auch viel Herumprobieren.

Ausblick

Rezepte

Unser Zeitplan ist gut aufgegangen. Da unser Zeitraum – u.a. wegen der limitierten Zeit der Testversion – jedoch begrenzt war, konnten  wir nicht alle erdenklichen Intents in den Chatbot einbauen. Eine Möglichkeit den Chatbot zu erweitern wäre beispielsweise das Erstellen eines dritten FollowUpIntents auf die FilterBy Intents. Dieser würde daraus bestehen, dass der Endnutzer die Option wählt, drei weitere random Meals heraus zu filtern, sofern es genug weitere Gerichte gibt. Im Code müsste ein Teil eingebaut werden, der garantiert, dass die ersten drei Gerichte sich nicht wiederholen und nicht noch einmal mit ausgegeben werden. Der Intent würde außerdem voraussetzen, dass vorher mindestens drei Gerichte ausgegeben wurden. 

Webhook

Um Code nach der Zuordnung zu einem passenden Intent auszuführen, haben wir in Dialogflow unter dem Punkt „Fulfillment“ den Inline-Edior benutzt. Dort gibt es eine index.js und eine index.json, in welchen der gesamte benötigte Code erstellt wird. Der Code wird dann automatisch in Form von einer Cloud Function bereitgestellt und wenn nötig getriggert. Wie aber bereits erwähnt, hat sich das Arbeiten an einer einzigen Datei schwer schwierig gestaltet und das lange Deployen die Arbeit zusätzlich erschwert. Deshalb wäre es bei größeren Projekten mit mehr Intents durchaus eine Überlegung wert, den Webhook selbst zu hosten und die JSON Anfragen zu verarbeiten. So hätte man mehr Kontrolle und kann außerdem besser zusammenarbeiten und debuggen, da das lange Deployen entfällt. Um selbst den Webhook zu hosten, muss dieser ein paar Anforderungen erfüllen:

·   Der Webhook muss HTTPS POST Anfragen verarbeiten, HTTP wird nicht unterstützt

·   Die URL für Anfragen muss öffentlich zugänglich sein

·   JSON Anfragen müssen verarbeitet werden und eine JSON Antwort muss zurückkommen

Recall Trainer – Eine serverless Web-App mit AWS

Einleitung

Im Rahmen der Vorlesung “Software Development for Cloud Computing” habe ich im vergangenen Semester eine Einführung in die Welt des Cloud Computings incl. der relevanten Konzepte und Technologien erhalten. Einige dieser Konzepte habe ich versucht in meinem Abschlussprojekt umzusetzen, das ich im Nachfolgenden vorstellen möchte. 

Idee/Projekt

Die Idee war es eine Webanwendung zu konzipieren und zu entwickeln, welche dem Nutzer beim persönlichen Wissensmanagement unterstützt. Die Applikation hilft dem Lernenden sich jeden Tag mit seinen Wissensgebieten auseinander zu setzen, indem diese ihm täglich eine Email mit einem Link zu seiner personalisierten Wissensabfrage sendet.

Mit einem Klick auf den Link landet der Nutzer bei einer zufälligen Auswahl von bis zu 25 Fragen seiner bisher erfassten Lerninhalte. Die Anwendung zeigt nun eine Frage und einen Timer an. Der Nutzer soll nun innerhalb von 10 Sekunden sich die Antwort ins Gedächtnis rufen. Wenn er eine Antwort im Kopf hat, klickt er den Button „Reveal“, welcher die korrekte Antwort aufdeckt. Nun muss der Nutzer angeben, ob seine Antwort im Kopf mit der tatsächlichen Antwort übereinstimmt. Schaft der Nutzer es nicht innerhalb von 10 Sekunden zu antworten wird automatisch eine neue Frage angezeigt. 

Das Ziel ist es das Gedächtnis zu trainieren. Die jeweiligen Fragen werden so lange per Zufallsauswahl wiederholt, bis der User alle Fragen einmal richtig mental abgerufen hat. 

Konzept Skizze

Ziele für die Umsetzung

Mein Ziel war es eine derartige fachliche Problemstellung als Serverless Architektur zu realisieren. Zugleich wollte ich erste Erfahrungen mit der AWS Infrastruktur sammeln. Um eine zentrale und automatisierte Verwaltung aller Ressourcen sicherzustellen, die Lösung skalierbar zu gestalten und um Cloud Ressourcen zu minimieren sowie das Risiko von versteckten Kosten zu reduzieren, war das Ziel das Projekt als Infrastructure as Code zu implementieren.

Implementierung

Architektur

Als Infrastructure as Code -Tool wurde AWS Sam (AWS Serverless Application Model) verwendet. Dieses baut auf Amazon’s Cloudformation Infrastructure as Code Syntax auf und liefert zusätzlich noch ein Command Line Interface, welches das Deployment und lokale Testing von Lambda Funktionen ermöglicht.

Die Frontend-Applikation wurde mit Hilfe des Frontend-Frameworks Angular erstellt, in welchem ich im Rahmen dieses Projektes erste Erfahrungen sammeln konnte. Deployed wird das Frontend mithilfe einer  AWS Amplify Build Pipeline, welche durch Commits auf den in SAM spezifizierten Branch ausgelöst wird.

Eine besondere Herausforderung hierbei war es,  dass der Link, den die Angular Anwendung nutzt um auf das Backend zuzugreifen dem aktuellen Link des API Gateways entsprechen muss. Dazu wird dieser durch SAM der Amplify Ressource als Environmental Variablen übergeben. Diese wird dann in der Build Pipeline verwendet um den Link in der Index.js des Angular Builds mit der Variablen zu substituieren.

Das Backend der Anwendung setzt basiert auf mehreren AWS Services.

Der Zugriff des Frontends auf das Backend erfolgt über API Gateway mit 2 Routen.

  • post /subscribe: Leitet den Request an SubscribeEndpoint Lambda weiter
  • get /daily-prompt:  Leitet den Request an DailyPromptsEnpoint Lambda weiter

Die Funktionalität der App ist über 3 Lambda Funktionen realisiert.

  • DailyEMailGenerator: Wird täglich von einer CloudWatch Schedule aufgerufen und generiert für jeden registrierten Nutzer eine Selektion an täglichen Fragen aus der Menge aller hinterlegten Fragen.
  • DailyPromptsEndpoint: Liefert die in der Datenbank hinterlegte Menge an täglichen Fragen für einen Nutzer
  • SubscribeEndpoint: Fügt den Nutzer der Subscriber Tabelle hinzu und speichert das mitgelieferte Fragen / Antwort Paar

Die Datenbank ist als DynamoDb mit den 3 Tabellen Subscribers, SubscriberData und DailyPrompts realisiert. 

Alle Ressourcen und notwendigen Rollen und Policies sind in einem SAM Template File erfasst und können bequem per SAM CLI deployt werden. Dabei wurde Wert darauf gelegt sensible Daten wie den GitHub Access Token, den Amplify benötigt, als Environment Variables abzufragen, anstelle diese hart zu coden.

Probleme

Als Hauptproblem hat sich für mich der riesige Umfang von AWS herausgestellt, da es eine Vielzahl an Dokumentationen zu lesen gibt, so dass es für einen Einsteiger schwierig ist, sich direkt zurecht zu finden. Das Angebot an Informationen zu Cloud (Patterns, Best Practices, etc.) und Cloud Services ist gefühlt unendlich. Es ist deshalb schwierig das für die Problemstellung wirklich relevante Wissen zu finden und die Qualität der verfügbaren Informationen ist stark unterschiedlich. Da das User Interface von AWS-Diensten von Service zu Service variiert, ist auch die Navigation innerhalb der Dienste nicht immer einfach. 

Ursprünglich war geplant Terraform als plattformunabhängiges Infrastructure as Code-Tool einzusetzen. Jedoch stellte sich dessen Dokumentation bezüglich spezieller AWS Services teilweise als unvollständig heraus und hat somit nach vielen erfolglosen Versuchen dazu geführt, dass AWS SAM als Infrastructure as Code-Tool adaptiert wurde.

Des Weiteren gestaltet sich das Debugging schwieriger wie auf dem eigenen Rechner. Dies hängt mit dem komplexen Zusammenspiel der vielen AWS Services zusammen und macht eine Fehlersuche oft sehr schwierig. AWS bietet hierfür CloudWatch als zentrale Logging-Plattform an. Es kann aber vorkommen, dass Fehler auftreten ohne dass diese erfasst werden. Ich hatte beispielsweise das Problem, dass eine Lambda-Funktion plötzlich nicht mehr in die Datenbank geschrieben hat, ohne Fehler zu loggen, obwohl andere Funktionen denselben Code ohne Probleme auf dieselbe Datenbank anwenden konnten. Der Fehler hat sich nach einigen Stunden von selbst erledigt, es blieb allerdings intransparent, wodurch die Lösung erfolgt ist. 

Lessons Learned

Ich habe in diesem Projekt sehr viel spannendes über die Entwicklung für AWS und die Cloud gelernt, sodass ich durch diese Veranstaltung einiges an theoretischem als auch praktischem Wissen im Bereich Cloud Computing aufbauen konnte. Das Projekt war gezeichnet von sehr viel ausprobieren was sich häufig auch nicht in der finalen Lösung niedergeschlagen hat.

Darüber hinaus konnte ich praktische Erfahrungen in der Implementierung von Webanwendungen aufbauen. Ein weiteres Lessons Learned für mich ist, dass ich ein solches Projekt in einem komplexen Themengebiet, in welchem ich keinerlei Vorkenntnisse hatte zukünftig wohl nicht mehr alleine angehen würde da ich mir sicher bin das ich mir viele Stunden recherchieren und Trial und Error durch eine weitere Person hätte ersparen können. 

Ynstagram – Cloud Computing mit AWS & Serverless

Im Rahmen der Vorlesung “Software Development for Cloud Computing” haben wir uns hinsichtlich des dortigen Semesterprojektes zum Ziel gesetzt einen einfachen Instagram Klon zu entwerfen um uns die Grundkenntnisse des Cloud Computings anzueignen.

Grundkonzeption / Ziele des Projektes

Da wir bereits einige Erfahrung mit React aufgrund anderer studentischer Projekte sammeln konnten, wollten wir unseren Fokus weniger auf die Funktionalität und das Frontend der Applikation richten und ein größeres Augenmerk auf die Cloud spezifischen Funktionen und Vorgehensweisen legen. 

Konkret planten wir die Umsetzung eines Instagram Klons mit den grundlegenden Funktionalitäten:

  • Bilder bzw. Beiträge hochladen
  • Titel & Beschreibung von Beiträgen anlegen
  • Liken von Beiträgen 
  • Kommentieren von Beiträgen
  • Accountmanagement

Entwurfsentscheidung

Frontend

Aufgrund von bereits existierenden Vorkenntnissen und guter Erfahrungen entschieden wir uns für die Umsetzung des Frontends mit Hilfe des React Frameworks. Mit der Gestaltung als Web App ergibt sich zudem der Vorteil, dass “Ynstagram” Plattform übergreifend erreichbar ist.

 

Backend – von Firebase zu AWS

Zunächst starteten wir unser Projekt mit Firebase umzusetzen. Zum einen verlor dies jedoch seinen Reiz hinsichtlich des Lerneffekts, da wir parallel unser Softwareprojekt mit Firebase verwirklichten. Gleichzeitig wurde uns durch die Einblicke in den Vorlesungen ein Bewusstsein für den Funktionsumfang von AWS geschaffen.

Interessant war für uns hierbei beispielsweise die wesentlich umfangreicheren Einsatzmöglichkeiten von Lambda Funktionen. Während diese in Firebase nur durch Einträge / Trigger geschehen kann, konnten wir hier auf API Aufrufe zurückgreifen. Auch die umsetzbaren Funktionalitäten gestalteten sich als wesentlich umfangreicher. So bot sich uns unter anderem die Möglichkeit Bilder beim Upload automatisiert zu skalieren und perspektivisch ließe sich auch recht einfach eine Analyse von Inhalten mit Hilfe von Künstlicher Intelligenz umsetzen. Während man bei Firebase in all dem schnell an gewisse Grenzen kommt, gibt es bei AWS einen viel breiteren Horizont an Möglichkeiten. 

Dennoch gestaltete sich dieser Umstieg keineswegs als einfach, denn Firebase bietet eine wesentlich bessere Übersichtlichkeit und Dokumentation.

Serverless

Da uns die AWS Web Oberfläche und die Online Erstellung von Lambda Funktionen keineswegs ansprachen suchten wir nach Lösungen um alle Konfigurationen wenn möglich auch auf Github hinterlegt zu haben und im Code Editor anlegen zu können. 

Dabei sind wir letztlich auf Serverless gestoßen. Hier werden alle Buckets, Tabellen und API Aufrufe über ein serverless.yaml File verwaltet. So lassen sich neue Elemente viel übersichtlicher / schneller anlegen und Konfigurationen können einfach von bereits erstellten Elementen übernommen werden. 

Postman

Um einen Überblick über die erstellten API Routen zu behalten und um diese einfach testen zu können, haben wir uns für Postman entschieden. Über ein in Github geteiltes File können so alle am Projekt beteiligten die aktuellen API Routen sehen und neue Aufrufe anlegen.

Umsetzung / Architektur

AWS Services

DynamoDB

Da wir bereits in Firebase auf die dortige NoSQL Datenbank “Firestore Database” zurückgegriffen hatten, entschieden wir uns hier für eine Beibehaltung dieser Datenbankstruktur. Der Vorteil liegt dabei gegenüber SQL Datenbanken in einfacheren Abfragen bedingt durch eine flachere Datenstruktur. 

Wir verwenden DynamoDB Tabellen um die zu den Bildern gehörenden Informationen wie z.B. Titel, Beschreibung, Autor etc. zu speichern. Die Verknüpfung der Bilder mit den Datensätzen in den Tabellen erfolgt dabei durch eine einzigartige ID.

Es gibt dabei 2 Tabellen, eine in der zunächst die Eingaben gespeichert werden, und eine weitere in die verarbeitete Datensätze übertragen werden. 

Beide Tabellen sind dabei strukturell gleich aufgebaut. Zentral sind hier eine eindeutige ID, Datum der Erstellung, Account Name des Erstellers, sowie Beschreibung und Titel des Beitrags. Kommentare und Likes werden über Arrays verwaltet. 

S3

In S3 Buckets sind die zu den Beiträgen hinterlegten Bilder gespeichert. Dabei gibt es einen Bucket mit den Originaldateien, sowie einen mit verringerter Auflösung. Der Name der Bilder entspricht dabei stets der den Beiträgen zugehörigen einzigartigen ID.

Cognito

Mit AWS Cognito konnten wir in wenigen Schritten unser Account Management einrichten. Cognito bietet dabei die Unterstützung aktueller Identitäts und Zugriffsmanagement Standards wie zB. Oauth 2.0 und SAML 2.0 und bietet gleichzeitig auch die Möglichkeit Multifaktor Authentifizierung zu implementieren. 

Amplify

Wir nutzen AWS Amplify um das Frontend unserer Applikation zu hosten und um hierfür eine CI/CD Pipeline mit Development und Master Umgebung zu realisieren. Eine genauere Erklärung hierzu findet sich im Abschnitt “CI/CD Pipeline”.

Lambda 

API Gateway

Ein großteil unsere Lambda Funktionen wird über API Aufrufe genutzt. Eine Übersicht dieser haben wir wie eingangs erwähnt in einem Postman File hinterlegt.

API-Routen

POST /image-upload

Hochladen eines Bildes in den S3 Bucket. Wird sowohl mit Bilddaten als auch mit dazugehörigen Informationen wie Beschreibung und Titel aufgerufen (JSON-Format). 

POST /image-info

Erstellt einen Eintrag in die DynamoDB Tabelle mit sämtlichen Informationen zu einem Beitrag, wird im Body als JSON übermittelt. 

POST /create-file

Erstellt eine Datei im S3 Bucket. Der Dateiname entspricht dem URL Parameter.

GET /get-all-images

Gibt alle als “valid” markierten Beiträge als Array im JSON-Format zurück.

GET /get-file

Gibt eine Datei anhand des Dateinamens zurück. 

GET /image-info

Gibt die Informationen zu einem einzelnen Beitrag im JSON-Format zurück.

PUT /update-image-info

Genutzt um Kommentare zu Beiträgen hinzuzufügen. Updated Einträge in der DynamoDB Tabelle.

PUT /update-likes

Verwendet um neue Likes hinzuzufügen.

DynamoDB / S3 Trigger

Neben direkten API Aufrufen verwenden wir auch Trigger auf DynamoDB Tabellen, sowie S3 Buckets. 

Exemplarischer Ablauf eines Image Uploads

Dies lässt sich am Besten am Ablauf einer Beitragserstellung darstellen.

Per Post-Request wird zunächst die Lambda Funktion “imageUpload” aufgerufen, welche das Bild in dem S3 Bucket hinterlegt. Dann wird über einen Trigger automatisch die Lambda Funktion “imageResize” aufgerufen, welche die Bilder auf eine Auflösung von 400 x 400 Pixeln skaliert. Diese Bilder werden dann im Bucket für skalierte Bilder gespeichert. So können die Bilder im Feed gerade bei Mobilen Geräten schneller geladen werden.

Parallel dazu wird in der DynamoDB Tabelle ein Eintrag angelegt. Auch hier wird ein Trigger aufgerufen der seinerseits die Funktion “changeText” aufruft. Diese ersetzt in Anlehnung an den Namen “Ynstagram” alle “i” in Beschreibung und Titel durch “y”. Hierbei handelt es sich lediglich um eine Spielerei die aus unserem Interesse entstand verschiedenste Trigger und Einsatzmöglichkeiten von Lambda Funktionen auszuprobieren.

CI/CD Pipeline

Interessant war es für uns zudem erstmals wirklich Erfahrung mit einer CI/CD Pipeline zu sammeln. Wir planten dabei die strikte Unterteilung in eine Entwicklungsumgebung und einer dieser prinzipiell gleichen finalen Umgebung. So dass der aktuelle Stand schon unter realistischen Bedingungen getestet werden kann bevor er letztlich veröffentlicht wird.

Diese CI/CD Pipeline haben wir mit AWS Amplify und Github Actions umgesetzt. Dabei wird zunächst stets auf einen Development Branch gepusht, welcher dann automatisch auf eine Entwicklungsumgebung auf Amplify hochgeladen wird. So können zunächst alle Tests durchgeführt werden, bevor dann mit einem Pull request die Änderung auf den Master Branch übertragen werden. Wenn dies geschehen ist, werden diese ebenfalls automatisch in die Produktionsumgebung übernommen bzw. deployed.

Hier wird neben den durch Github Actions durchgeführten Tests auch überprüft ob die Web Anwendung auch auf verschiedenen Geräten richtig skaliert und damit überprüft ob die UI für den Nutzer funktionsfähig angezeigt wird. Der aktuelle Stand wird dabei selbstverständlich nur übernommen, wenn alle Tests erfolgreich abgeschlossen werden.

Serverless

Um der Unübersichtlichkeit der AWS Weboberflächen aus dem Weg zu gehen und um Elemente leichter und reproduzierbar, über Git verwaltet anlegen zu können haben wir uns für Serverless entschieden. Hier werden alle AWS Komponenten in einem “serverless.yaml” File angelegt. 

Variablen

Es gibt dabei zum Beispiel auch die Möglichkeit unkompliziert Environment Variablen anzulegen:

Welche wir wiederum über eigene custom Variablen, welche an verschiedenen Stellen genutzt werden definiert haben:

Dies bringt den Vorteil mit sich, dass Namen flexibel verändert und direkt überall übernommen werden, sprich sowohl in AWS als auch im Code über die Environment Variablen.

Functions

Gleichermaßen einfach lassen sich auch die Lambda Functions anlegen. Diese werden jeweils über einen “handler” referenziert und werden dann durch ein “event” aufgerufen, was entweder API Aufrufe oder eben bspw. DynamoDB / S3 Trigger sein können.

Resources

Auch alle Buckets und Tabellen sind im .yaml File definiert. So können insbesondere neue Elemente sehr einfach angelegt werden, da man direkt auf zuvor definierte Konfigurationen zurückgreifen kann.

Testing

Beim Testen haben wir uns vor allem auf die API Aufrufe und die grundsätzlichen Funktionen fokussiert. Grundsätzlich werden unsere Tests über die im Abschnitt “CI/CD Pipeline” dargestellte Pipeline mit Github Actions ausgeführt. Diese sind auch Bestandteil des Amplify Deployment Prozesses. Zusätzlich dazu haben wir CircleCi implementiert um die Serverless Komponenten automatisch zu deployen. Für das Testing nutzen wir allgemein ein lokales Mock-Up unserer DynamoDB da wir hier schnell auf das Problem gestoßen sind, dass unsere freien AWS Kontigente aufgebraucht waren.

Ausblick / Fazit

Die größten Schwachstellen des Projektes liegen aktuell in nicht abgesicherten API Aufrufen, diese ließen sich über die Verwendung von API Keys schützen. Dabei sollten perspektivisch auch die Zugriffe auf DynamoDB sowie S3 über IAM Role verwaltet werden. 

Für die Accountverwaltung wäre es sinnvoll die Multifaktor Authentifizierung einzurichten. Der Funktionsumfang ließe sich selbstverständlich noch deutlich ausbauen, wobei besonders die Nutzung von KI Komponenten für uns interessant wäre.  

Insgesamt konnten wir, ausgehend von keinerlei Grundkenntnissen im Bereich Cloud Computing, uns mit Hilfe der Umsetzung des Projektes im Rahmen der Vorlesung einen Überblick und ein Grundverständnis für die Welt des Cloud Computings erarbeiten, welche eine solide Basis bieten um zukünftig die vorliegenden Ansätze noch wesentlich weiter zu vertiefen.

Deploying Random Chat Application on AWS EC2 with Kubernetes

1. Introduction

For the examination of the lecture “Software Development for Cloud Computing”, I want to build a simple Random Chat Application. The idea of this application is based on the famous chat application called Omegle. Omegle is where people can meet random people in the world and can have a one-on-one chat. With Omegle people can have a conversation with not only normal chat but also a video chat. Not like Omegle, my application has only a normal texting function.

2. Technologies for the development of application

a. Frontend

React

For Frontend Development there are a great number of open-source libraries. React is recently one of the most popular and widely used libraries. There are many reasons for a developer to choose and use React. It is one of the most popular front-end technologies in the market. Compared to other libraries out there React seems to be easier to learn. As it doesn’t take much time to learn this technology, the developers can rapidly practice and build their own very first project. React helps increase productivity by using reusable components and development tools. There are many development tools available for React that speech up the project. The most important reason is that it has very strong community support. There are thousands of free React tutorial videos and blog posts on the internet which is very helpful for the developer. Therefore, I decided to learn this library during previous semesters. This project gives me a chance to have real practice.

b. Backend

Node.js

Node.js has become one of the most popular JavaScript tools. Node.js is a JavaScript runtime environment, which allows companies to improve their efficiency of the web development process. The frontend and Backend teams can now work more easily together. Since Node.js is written in JavaScript and bases on Google V8 Engine, everything is done very quickly. Node.js can create an Event Loop, which can cover all asynchronous input-output operations.  And the best part is that it can increase the speed of any other framework as well. By allowing developers to write JavaScript code for both the Frontend and the Backend, Node.js makes it easy to send data between the server and the client, which makes it easier to synchronize data immediately.

Socket.io

When I decided to develop a chat application, I already thought about Socket.io. I had a chance to know this JavaScript library during the course Web Development 2. To build a real-time application we should use Socket.io. Socket.io will help parties in different locations connect with each other, transmitting data instantly through an intermediary server. Socket.io can be used in many applications such as chat, online games, updating the results of an ongoing match, … It is used a lot by the developer community, because of its speed and convenience. Socket.io provides us with many methods as well as outstanding features such as security, auto-connect, disconnection detection, multiplexing, room creation, …

3. Application explanation

a. Client

As I mentioned, for the client-side I use React. Therefore, I have a chance to know the concept of a Single Page Application, whose content is loaded only once and updated dynamically. For the interaction with the page or with subsequent pages, we don’t need another server, which means that the page is not reloaded. To apply this concept of the web application, React offer a packet names “react-router-dom”. My application is very simple, so it only has two paths to be loaded. The root path is where the user inputs his name, and it will load the Join component. The other path is the Chat component, in which the user sends messages after getting a room.

Socket.io library for the client is imported because it is not provided by JavaScript. This will expose the ‘io’ namespace.

Endpoint URL will be given for ‘io’ to connect with the socket.io server.

Now users can send and receive messages from the server after the room is created.

b. Server

To setup server, some packets need to be imported:

The server is set up and listen on port 5000

The server can also save users temporally, so it will know which user’s name already existed. And then it can remove users after they terminate their chat. To execute all those actions, I write a users.js file, which will have some functions such as, addUser, removeUser, getUser, …

I want to create a chat application where users don’t have to have an already known friend and a Chat room will be automatically created for them. With this application, they can meet a new friend and the server will get them a chat room.

I created a variable queue, which is an array. It will save a user, who has not had any partner yet, temporarily. Every user, who already inputted their names, will be connected to the socket. Socket knows that he wants to join a room. In the callback function of the socket, the name and socket ID of the user will be saved by function ‘addUser’, which is in users.js. Then socket will check if any other user is waiting for a partner in a queue. If someone is waiting for a partner, he will be popped from the queue. His socket and partner’s socket will be connected. And their room ID will be a combination of the 2 socket IDs. If no one is waiting for a room, the current socket is pushed to a queue and wait for another user to join.

c. Problem

CORS: 

It is an abbreviation for Cross-Origin Request Sharing, which means that all data should come from the same resource. They use it as a security measure because JavaScript can load content from other servers without the knowledge of the user. This problem can be solved when both websites are aware of the data exchange, then the process will be allowed.

I installed the CORS package on my server. Origin will configure the Access-Control-Allow-Origin CORS header. Now client and server can communicate without error.

4. Testing

Testing is very important during the development of the application. Testing helps developers to discover existing errors/bugs before releasing the application. Therefore, the quality of the application would be enhanced. I decided to test only the server-side because it is more complicated than the client-side. Two tests are being created.

  • A single user testing: tests if he can connect to a server and receive a welcome message from the server.
  • Two users testing: tests if a room can be created when there are two users and both of them can receive the same welcome message from the same room.

5. Deployment

a. Docker Swarm and Kubernetes

When deploying this project, I create a container for each of the client and server sites. Docker is the most popular solution for the container platform. I want to learn to write a DockerFile and a docker-compose to create a container.

For the cloud development environment, I choose Amazon Web Service. It is currently one of the most comprehensive platforms for cloud computing services. I use an EC2 virtual server to make my project online. I would like to work with Kubernetes to manage the containers. I choose EKS, which is a service from Amazon web service.

If you work with a lot of containers, you have to be able to manage them efficiently. An orchestration tool enables exactly that. With the orchestration tool, you can integrate containers that you created with Docker. Then you use orchestration to manage, scale, and move the containers.

Although Kubernetes and Docker can work well together, there is competition when it comes to Docker Swarm. I have considered some features of Docker Swarm and Kubernetes.

  • Scaling: The load on our application is too high, Kubernetes can add more nodes to our cluster. Of course, we have to configure Kubernetes correctly so that it can create a new virtual machine. Then a node is added to the cluster.
  • Installation: with Docker Swarm, it is easy to create a new node, then integrate it with Swarm. On the other hand, to configure Kubernetes you have to determine the size of the node, how many master nodes, and worker nodes.
  • Load balancing: Docker swarm offers application auto load balancing. However, Kubernetes gives the flexibility to configure load balancing manually.
  • Storage volume participation: since the docker swarm manages Docker containers, containers find it easy to share data. Not just data, as well as other things. Kubernetes puts the container in a pod so the container cannot simply communicate with another. You need other components from Kubernetes, e.g., Service to create the connection.
  • Monitoring: While Swarm requires additional resources for monitoring and keeping a log, these tasks are already provided for in Kubernetes.

b. Amazon EKS and Kops

When deploying Kubernetes on AWS, you can configure and manage the deployment yourself for full flexibility and control. There are a few options for self-management: Amazon Elastic Kubernetes Service and Kops.

EKS is a managed service offered by AWS. EKS uses automatically provided instances and offers a managed control plane for deployment.

Kops is an open-source tool that can be used to automate the deployment and management of clusters on AWS. It is officially supported by AWS.

c. Docker File

To work with Kubernetes, I need to create all necessary containers. Containers are created by writing docker files. These docker files contain all information about the container, e.g.: name of the image, directory store our application, port, … Docker will follow this information, then step by step, create containers. Besides, I use Docker Compose to start the process of creating containers.

d. Kubernetes architecture on AWS cloud

I choose Kubernetes, Amazon EC2, EKS, ECR for the deployment of my project. What is showed below is the architecture of Kubernetes on AWS cloud.

Source: https://blogs.tensult.com/2019/08/14/guide-to-setup-kubernetes-in-aws-eks-using-terraform-and-deploy-sample-applications/

Kubernetes server is a control panel. It creates a cubic cluster. In the cluster, there are master nodes that create and manage worker nodes. When you call deployment commands, the Kubernetes server sends messages to EKS, then EKS sends the tasks to the worker nodes.

Worker node contains some pods, in which the docker container will be run. I choose controller Deployment to keep these pods running and observe them. For the worker node, I create a pod for the client, 2 pods for the server, and a pod for Redis. The load balancer can be used to communicate with the application from outside. 

I decided to have 2 pods Server because I want to scale my application. In case when more people try to connect to my application, the request will be handled faster when we have 2 pods instead of 1 pod. The picture below shows a horizontal scaling, which means that it has more copies of the application and these copies can work with each other at the same time.

For example: for the pod client I write a client-deployment.yaml file

  • A deployment named client is created, indicated by the .metadata.name field.
  • The .spec.selector field defines how the deployment finds the pods to be managed.
  • The deployment creates one replica pod, indicated by the .spec.replicas field.
  • the .template.spec field indicates that the pod is running a container. The container is created by docker image, which has been saved in ECR (Elastic Container Registry)
  • The container is created using the .spec.template.spec.containers.name field which is called client.

To enable network access to the set of pods I have to create a Service, which is written in client-service.yaml.

This specification creates a new service object called “client” that targets TCP port 3000 on each pod, which is labeled as app = random-chat.

For pods Server and Redis, I also create Deployment and Service for each.

e. Problem

Service of pod Server:

Pods can usually send requests with each other by using a normal type of Service, which means, in my case, that the pod Client can send a request to the pod Server without having an attribute ‘type’ in server-service.yaml. The Endpoint of the Client will be ‘server:5000’, which is the combination of the name of the service and the targetPort. But after many attempts, it still does not work. So, I decided to make the Service of pod Server as type Load Balancer, which is shown in the picture above. Now the Endpoint of the client will be the address of this Load Balancer.

6. Conclusion

During the course ‘Software Development for Cloud Computing’ and this project, I have a chance to know the concept of Docker containers and how to manage them with Kubernetes. I gain not only theoretical knowledge but also practical experience by developing and deploying the application. Moreover, working with cloud computing is new and interesting for me. Cloud computing is nowadays applied in the development of applications a lot. What I applied in my project is just a small part of cloud computing and I want to learn more about it in the future.

“Studidash” | A serverless web application

by Oliver Klein (ok061), Daniel Koch (dk119), Luis Bühler (lb159), Micha Huhn (mh334)

Abstract

You are probably familiar with the HdM SB-Funktionen. After nearly four semesters we were tired of the boring design and decided to give it a more modern look with a bit more functionality then it currently has. So we created “Studidash” in the course “Software Development for Cloud Computing”. “Studidash” shows your grades and automatically calculates the sum of your ECTS and also an average of your grades. 

Since this is a project for SD4CC it runs as a serverless web application at Amazon Web Services, or AWS for short. Our tech stack for this project consists of Angular, Python, Terraform and some AWS Services like Lambda or S3.

While developing this Web-App we encountered some difficulties but also learned a lot of stuff and we hope that this blog post can give you a quick overview of what we did, what we learned, what problems we had and how we solved them so you have it easier for your next project.

What did we do? 

As mentioned in the abstract, we developed a serverless Web-App called “Studidash” because of said boring design of the SB-Funktionen. First of all, we decided that we wanted to learn a new tech stack and came to the conclusion that Angular as our frontend would be the most modern frontend framework. For our backend we decided to use Python since it’s lightweight and easy to learn. From another course we learned about Terraform so this was something we were already somewhat familiar with and decided to use it for our deployment to AWS. We also used AWS to host the Web-App since we got access to AWS Student Accounts.

After we settled for a project and our tech stack we had to think about a way to make it “cloud native” and started to research some information and came across serverless. We dug a bit deeper and found some useful information. So we came to realize that serverless might be the way to go. Serverless means that our (or maybe your application) isn’t running completely on a “on-prem”-server but is running in the cloud instead. That means the application itself isn’t coupled to the server. Servers are still there but you don’t have to think about the administrative stuff around that. This is all going to be handled by your cloud service provider. The serverless approach brings scalability, high availability and efficient resource usage and management with it. As mentioned, you can focus more on the development itself rather than thinking about servers. A connection to a CI/CD pipeline makes it easy and fast to release a new version of your application. But serverless also has its downsides. The functions have to be as small as possible to only fit one purpose and some Web-Apps can have higher latency due to a cold start (When a function isn’t used for quite some time it gets destroyed and needs to be instantiated again, which takes time). You are also going to have a bad time debugging your application since it isn’t as easy as you might be used to. In the end we went with a static frontend in a S3-Bucket, a backend running as AWS Lambda Functions and AWS API Gateway to connect them. 

Architecture

Our architecture is fully hosted on AWS and our code repositories are hosted on the HdM GitLab server. The clients can access our frontend via their favourite web browser. Our frontend application is hosted in an AWS S3-Bucket. The good thing here is that we don’t have to manage or deploy any web server by ourselves. This reduces the management overhead and in the end the costs. After the frontend is served to the client, the user can input their user credentials to access their grades from the third party service (HdM SB-Funktionen). A HTTP-Request will then be sent to a Lambda Function with an API-Gateway to receive the request. This Lambda Function contains a Python script which will parse the user credentials provided in the received HTTP-Request and use them to make a login at the SB-Funktionen platform and scrape the necessary grades and lecture data from the user. This scraped data will then be preprocessed and returned as a JSON-Object to the frontend.

From the developer side we used Git/GitLab for the version control of our code. In GitLab we created a CI/CD pipeline to build the frontend, the Python grade scraper and a Terraform image to deploy all our neccessary AWS resources. Thanks to the CI/CD pipeline the developer can just push the newest code base to the repository and it will be deployed automatically to AWS.

Architecture overview

Frontend

For our frontend we decided to build an Angular single page application. We made this decision because it’s an up-to-date framework to build fast and easy web applications.

When the user loads the website the header only displays a login component for the HdM SB-Funktionen credentials. This component triggers a POST request to the Lambda Function containing the login data. The Lambda Function then responds and returns several grade objects to the frontend which are identically defined in front- and backend. The grade object exactly maps the table structure of the HdM page. The response then triggers the rendering of the table and you will receive a login message. Also there is an error handling if the login failed. The table can be sorted according to the different values, the grade average and ECTS are calculated and displayed in the header of the page.

Screenshot of our frontend after successful login

Backend

Our backend consists of a Python script which is hosted in a Lambda Function with an API-Gateway to receive HTTP-Requests. The frontend sends a HTTP-Request with the user credentials in the request body to the API-Gateway. The request is then forwarded to the Lambda Function which then injects the HTTP-Request into our Python grade scraper script. The Python script then parses the request body and performs a login at the SB-Funktionen website of the HdM where all the student grades and lectures are stored.

Backend workflow

In our code example the event variable is the received HTTP-Request from the frontend. The received request body is a string, so the content of the body has to be parsed to JSON again. When there is no login data provided, the script will send a HTTP-Response with the status code 401 and a corresponding message.

In the next step our script scrapes all the data we need and parses them into a JSON format which our frontend can handle easily. This JSON data is then sent as response to the Lambda Function which will forward this response to the API-Gateway. The API-Gateway then also forwards this response back to our frontend application where the received data will be processed and displayed.

Code snippet – try-except

We also had to keep some other things in mind. For example what should happen when our backend throws an exception or the third-party-service isn’t available? In our backend we created an error handler which takes a HTTP-Status Code and an error message as parameter, converts the data in the right format for our frontend and then sends the response.

Code snippet – error handling

Our main lambda_handler function is then divided into different parts. Each part is surrounded by a try-except clause to catch exceptions. For example if the third party service is down or if there were no credentials provided by the frontend. This makes our backend more reliable and also gives the user enough feedback to know what’s going on. Since we use an external service we need to think of a solution for the case when the third party service is down, for example for maintenance reasons. A possible solution to this would be to implement a caching mechanism which we don’t provide in the current state.

CI/CD

To make our application as cloud native as possible we implemented a CI/CD pipeline in our project. This pipeline builds our Web-App as well as our Lambda Functions, tests our Python script and deploys them to AWS. For that we are using different stages (build, test, deploy) in our .gitlab-ci.yml file. The build_webapp stage first pulls a Node-image and runs a few lines of script to install all dependencies and then builds the Angular based frontend. While this part is running, a second instance is pulling an Alpine image and is also running a few lines of script to package our Lambda Function(s) into a ZIP file.

After that, the test stage is invoked to test the application before deployment. This is a crucial part in the pipeline since it can reveal mistakes that we made during development before going “live” with the application. When the tests succeed, the next stage is invoked.

In our case, we made the deployment stage manually since we didn’t want to push every small change to AWS and also the Student Accounts had some time limits that would forbid us doing that anyway. But what happens in the deploy stage is fairly simple. Like in the stages before we are pulling an image for Terraform to run the usual Terraform commands like init, validate, plan and apply. This initializes Terraform, validates our main.tf in the root of the repository, creates a plan for creating the different resources in this main.tf and finally applies it. 

But what exactly is in this main.tf file? This file contains every resource we need in AWS and creates it. First of all, we declared variables for our different buckets, one for the Lambda Function and one on which the Angular app is going to be hosted at. After that, we created the S3-Bucket for the Lambda Function and uploaded the ZIP file with the function to the bucket. From there, it gets deployed to AWS Lambda. We also needed to create a role and policy to give the bucket the correct access rights to execute their task properly. After that, the S3-Bucket for the Angular app is created and the needed files are uploaded. This bucket hosts the frontend as a static website which we also configured in our main.tf to do that.

.gitlab-ci.yml file for our pipeline (1/2)
.gitlab-ci.yml file for our pipeline (2/2)

Testing

Testing is one of the most important things when implementing a CI/CD pipeline and with automated deployment. When you don’t implement tests you don’t really know if your application works before deployment and after the deployment, it is too late. So implementing a stage for testing in our project was the way to go. For our Python backend we wrote some basic Unit-Tests to test functionality and also added a test stage for the backend to our CI/CD pipeline.

We also managed to write an End-To-End-Test for our frontend which checks if the Error-Snackbar is shown when the user puts in wrong credentials. The harder part in this scenario was to get it running in the CI/CD pipeline, which we unfortunatly didn’t manage to do.

What problems did we have and how did we solve them?

One of the biggest problems we encountered was due to the fact that we only had access to an AWS Student Account. It ensured that we only had restricted access to AWS. For example we needed to create different kinds of roles to deploy our Lambda Function with the correct set of rights to be executed. Due to the restrictions we were not allowed to give the roles the needed permissions which caused our CI/CD pipeline to fail and our project didn’t get fully deployed. This could only be solved by getting a “real” AWS Account which gives you all the permissions you would need.

Another problem we faced was CORS (Cross-Origin Resource Sharing). In the first steps of our development we always got a CORS-Error when our frontend was requesting the grades and lecture data from our backend service. The reason for that was because in our Python backend script we just sent back the JSON-Object containing all the data but without any HTTP-Headers to our frontend. The frontend then failed to receive the response because the URL of the API-Gateway was different from the URL that our frontend had. To fix this problem we had to set the Access-Control-Allow-Origin HTTP-Header in the response from our backend. 

Code snippet – http-headers (CORS)

After that, the request worked and our frontend could receive the scraped data.

Another problem we had was to integrate our End-to-End-Test in our CI/CD-pipeline, which we unfortunately didn’t manage to fix in time. It would’ve required us to have a runner that has a browser available but we weren’t able to set that up. We managed to implement an E2E-Test which is running locally without any problems. So at least we have a bit of code quality assurance here. Having to run the tests manually isn’t what you want to do for a fully automated cloud native approach.

Conclusion

It was quite a long way from where we started, but in the end we managed to get our Web-App running on AWS as we liked. We made it a bit difficult in the beginning because we said we wanted to learn some new technologies like Python and Angular, so we first had to learn those. But we also had to learn about serverless-architecture. It is also something to look forward to working with in the future.

At the presentations we found out about AWS Amplify, which is basically a tool by AWS to get serverless Web-Apps running as fast as possible without the need of S3-Buckets. It showed us that there isn’t really the “one and only” way to get something running in the cloud. There are many possible solutions. 

In our opinion we learned a lot about AWS, serverless-architecture and cloud in general. But also about developing an application where you don’t have to think about renting and maintaining a server. Maybe we can continue with this project in the near future and give the HdM SB-Funktionen a new look 🙂

Application Updater mit Addon-Verwaltung

von Mario Beck (mb343) und Felix Ruh (fr067)

Einleitung

Unser Ziel war es, einen Programm Updater für Entwickler zu erstellen, den diese einfach in ihre CI/CD-Pipeline integrieren können. Für die Umsetzung haben wir die IBM Cloud und eine Serverless Architektur verwendet, um eine unbegrenzte Skalierbarkeit zu erreichen. Zu den verwendeten Serverless Services zählen die Cloud Functions, DB2 und ein Object Storage.

Das Projekt besteht aus einem Uploader, mit dem der Entwickler sein Programm in den Object Storage hochladen kann. Und einem Downloader für den Benutzer, mit dem automatisch die aktuelle Version heruntergeladen wird.

Verwendung aus der Entwicklersicht:

  • Programm wird registriert und man bekommt die dazugehörigen API-Keys
  • Erstellen der Config für den Downloader
  • Mit dem Uploader kann das Programm hochgeladen werden, dies kann einfach in eine CI/CD Pipeline eingebunden werden

Verwendung aus der Benutzersicht:

  • Herunterladen des Downloaders und der Config
  • Starten des Downloaders
  • Vor Programmstart wird nach neuen Updates gesucht und diese falls vorhanden heruntergeladen
  • Nach dem Update wird das eigentliche Programm gestartet
Continue reading