Azure Functions is a managed service for serverless applications in the Azure cloud. More broadly, Azure Functions is a runtime with multiple hosting possibilities.KEDA (Kubernetes-based Event-Driven Autoscaling) is an emerging option to host this runtime inKubernetes.
In the first part of this post, I compare KEDA with cloud-based scaling and outline the required components. In the second part, I define infrastructure as code to deploy a sample KEDA application to an Azure Kubernetes Service (AKS) cluster.
The result is a fully working example and a high-level idea of how it works. Kubernetes expertise is not required!
When you deploy an Azure Function, it runs within the Azure Functions runtime. The runtime is a host process which knows how to pull events from anevent source (defined by the function trigger) and pass those to your function:
However, one instance of runtime rarely provides adequate processing capacity. If you only get one message per day, having an instance always running is wasteful. If you get thousands of events per second, one instance won’t be able to process all of them.
Automatichorizontal scaling solves the problem. At any point in time, several identicalworkers are crunching the events. The number N is optimized continuously to fit the current workload by adding new workers and removing underutilized ones.
Instance Provisioner is an extra component in the auto-scaled system. It monitors the stream of metrics from the event source and decides to add or remove workers. A massive standby pool of idle generic workers provides the workforce. Such a generic worker pulls theartifact of the assigned function, plugs it into the runtime, and starts processing events.
Consumption Plan is the serverless hosting option where Azure manages all the scaling components internally. Let’s consider an example of an Azure Function triggered by aStorage Queue:
The deployment artifact is just the code packaged as a zip archive and uploaded to Blob Storage.Scale Controller is an internal Azure component that observes the target queue and allocates Function App instances based on the queue length fluctuations. Each instance bootstraps itself with a zip file, connects to the queue, and pulls messages to process.
The cloud provider manages all the components of the system, so developers can focus on writing business logic code. It can beas simple as a JavaScript callback:
queue.onEvent("MyHandler",async (context, msg) => { console.log("New message: " + JSON.stringify(msg));});
Over the last few years, Kubernetes has gained traction across many industries. KEDA is provides a way to design and run event-driven applications inside a Kubernetes cluster. KEDA implements the autoscaling components in terms of Kubernetes tools.
The target Function App is packaged together with the Azure Functions runtime into a customDocker image and published to aregistry. A Kubernetesdeployment utilizes that image and configures the parameters to connect it to the target event source (for instance, a queue):
The application can then run on one or many instances, orpods in Kubernetes terms. Kubernetes has a built-in component to scale out the pods:Horizontal Pod Autoscaler (HPA). By default, HPA makes scaling decisions based on the processor utilization of existing pods. CPU turns out to be a poor metric for event-driven applications: many workloads are not CPU-bound, so the scale-out won’t be aggressive enough to keep the queue empty. Therefore, KEDA introduces aScaledObject—acustom resource which pulls metric values from the event source and feeds them as a custom metric to HPA:
At the time of the initial KEDA preview announcement and until Kubernetes version 1.16, Horizontal Pod Autoscaler wasn’t able to scale a deployment down to zero pods. Therefore, KEDA includes an extra controller to disable the deployment if the event source is empty and re-enable it when new events come in. This duty might be delegated back to HPA in the future versions.
Now, when we know what KEDA is and how it works, it’s time to deploy a Function App!
Here is the list of the components required to run a Function App in Kubernetes with KEDA:
All these components can be defined and deployed within a single Pulumi program. Below I highlight the main blocks of the program. Navigate to thefollowing section to look at a short and reusable component.
KEDA can run on any Kubernetes cluster, but I choose to do so on managed AKS. My customAksCluster
component defines a cluster, including the required Active Directory and networking configuration. It makes multiple assumptions, so I only need to specify the main properties:
const resourceGroup =new azure.core.ResourceGroup("keda-sample");const aks =new AksCluster("keda-cluster", { resourceGroupName:resourceGroup.name, kubernetesVersion:"1.13.5", vmSize:"Standard_B2s", vmCount:3,});
aks.cluster
now has all the required output values, for instance,aks.cluster.kubeConfigRaw
configuration.
The next group of components needs to be deployed just once.
Azure Container Registry is not part of the AKS cluster. Its purpose is to host Docker images of applications.
const registry =new azure.containerservice.Registry("registry", { resourceGroupName:args.resourceGroup.name, adminEnabled:true, sku:"Premium",});
A Helm chartkedacore/keda-edge
deploys the KEDA service and theScaledObject
custom resource definition.
const keda =new k8s.helm.v2.Chart("keda-edge", { repo:"kedacore", chart:"keda-edge", version:"0.0.1-2019.07.24.21.37.42-8ffd9a3", values: { logLevel:"debug", },}, { providers: { kubernetes:aks.provider } });
KEDA supports multiple types of event sources, and the current list is availablehere. An event source is supported if there is a scaler which knows how to pull metrics out of it and turn them into a custom metric for HPA.
My example uses an Azure Storage Queue as the event source:
const storageAccount =new azure.storage.Account("kedasa", { resourceGroupName:resourceGroup.name, accountTier:"Standard", accountReplicationType:"LRS",});const queue =new azure.storage.Queue("kedaqueue", { storageAccountName:storageAccount.name,});
The same Pulumi program is capable of building a Docker image and uploading it to the Container Registry.
const dockerImage =new docker.Image("image", { imageName:pulumi.interpolate`${registry.loginServer}/${args.queue.name}:v1.0.0`, build: { context:"./functionapp", }, registry: { server:registry.loginServer, username:registry.adminUsername, password:registry.adminPassword, },});
The image refers to the folder with aDockerfile
in it and uses theregistry
variables to fill the credentials.
Now, we can define a Deployment which uses the Docker image to run our Function App on Kubernetes pods.
const appLabels = { app:name };const deployment =new k8s.apps.v1.Deployment(name, { apiVersion:"apps/v1", kind:"Deployment", metadata: { labels:appLabels, }, spec: { selector: { matchLabels:appLabels }, template: { metadata: { labels:appLabels, }, spec: { containers: [{ name, image:dockerImage.imageName, env: [{ name:"queuename", value:args.queue.name }], envFrom: [{ secretRef: {name:secretQueue.metadata.name } }], }], imagePullSecrets: [{ name:args.service.registrySecretName }], }, }, },}, { provider:aks.provider });
The deployment refers to the secret value which stores the connection string to our target Storage Queue:
const secretQueue =new k8s.core.v1.Secret("queue-secret", { data: { queueConnectionString:args.storageAccount.primaryConnectionString.apply(c => Buffer.from(c).toString("base64")), },}, { provider:aks.provider });
Finally, we need to deploy an instance of theScaledObject
custom resource, which takes care of feeding the metrics from the queue to the Horizontal Pod Autoscaler.
const scaledObject =new k8s.apiextensions.CustomResource("scaledobject", { apiVersion:"keda.k8s.io/v1alpha1", kind:"ScaledObject", metadata: { labels: { deploymentName:name }, }, spec: { scaleTargetRef: { deploymentName:name }, triggers: [{type:"azure-queue", metadata: {type:"queueTrigger", connection:"queueConnectionString", queueName:args.queue.name, name:"myQueueItem", }, }], },}, { provider:aks.provider });
We can simplify the program further by creating reusable Pulumi components for a Cluster, a Service, and a Function app. After those are done, here is all the code required to deploy a Kubernetes cluster with a Function App and KEDA.
// Create an AKS K8s clusterconst aks =new AksCluster("keda-cluster", { resourceGroupName:resourceGroup.name, kubernetesVersion:"1.13.5", vmSize:"Standard_B2s", vmCount:3,});// Deploy shared components of KEDA (container registry, kedacore/keda-edge Helm chart)const service =new KedaService("keda-edge", { resourceGroup, k8sProvider:aks.provider,});// Deploy a Function App which subscribes to the Storage Queueconst app =new KedaStorageQueueHandler("queue-handler", { resourceGroup, storageAccount, queue, service, path:"./functionapp",});
You can find the full code of the components, the sample program, and steps to run it inthis example.
As of October 2019, the KEDA project is still in the experimental phase and should not be used for any production applications. Also, if the managed version of Azure Functions suits your needs, you should probably stick to that service. It requires less effort, provides high-level primitives, and enables unlimited elastic scaling.
However, if your company is betting on Kubernetes as the standard application platform, but you still see high value in event-driven fine-grained applications, KEDA might be an exciting option for the future.
Pulumi is a great tool to get a sample KEDA application running quickly and effortlessly. It combines Docker image creation, Kubernetes cluster provisioning, Helm chart installation, and KEDA deployment in the single program written in a familiar general-purpose language.
Get started withAzure Kubernetes Service (AKS) Cluster and Azure Functions with KEDA.