Microservices with Kubernetes? Giving a try to AKS
· 3,087 words · 16 minutes reading time
Let's be honest, most likely you don't need microservices nor Kubernetes for your project. There is no advantage in using them in the early stages of any product. You can get really far with a monolithic application, even with a team of moderate size.
But let's say that you have already exhausted other alternatives, like working on the modularization of your application, and you are at a point in which you really prefer the burden of introducing a network between your components rather than one day more of slow development, blocked deployments and repetitive long discussions to figure out why things are broken again. In that case, maybe you want to give Kubernetes a try and start a journey of splitting your application into microservices.
Now, if you are still interested in knowing the first steps for setting up a cluster in Azure Kubernetes Service (AKS), just keep reading. And by the way, all the code and config files discussed in this post can be found in this repo. Let's start!
Microservices and Kubernetes: a brief introduction
Opposite to the idea of having your business logic in a single application, as it happens in a monolithic approach, a microservice architecture aims to split the logic into a set of independent and decoupled services that communicate with each other through a network. Each service can evolve in a separate way, meaning that they can scale, have deployments and be developed independently.
This opens the door to having specialized teams in different aspects of the business logic. These teams can treat the rest of the product as a set of black boxes. They know how to communicate with them and what to expect from the requests but they don't need to know how other services are implemented.
This approach also helps to isolate bugs and accelerate the introduction of fixes as each service can count with their own CI/CD pipeline, test suite and test environments with different degrees of isolation from the rest of the product, either in development or production.
There are multiple ways to achieve this architecture, but one of the simplest is using Kubernetes. Kubernetes is an orchestration system for containerized applications. It provides a single solution that takes care of automating deployments, scaling services and providing the network capabilities for a microservice infrastructure.
The basic elements in a Kubernetes cluster are pods. Pods are indivisible groups of containers. In our context, a pod will contain all the containers necessary for running a single instance of a microservice. Pods are deployed into nodes, the workers or computers in which containers are executed. Each type of pod can be scaled independently by replication and the whole cluster can scale by adding more nodes to it.
While pods have visibility over other pods, as they are assigned a unique IP address within the cluster, they can be grouped into services. A service in Kubernetes provides an IP address and a DNS record to which incoming requests are load balanced across all the pods under them. In our case, we will have a Kubernetes service for each microservice, grouping all their pods so the applications inside can just make requests to these load balancers directly.
That's a glimpse to the different elements that I will use in the following sections. Of course, Kubernetes offers more, like support for volumes or storage, secrets management and networking. But there is no point in just enumerating and describing features. Let's keep it simple here and start implementing things.
Dummy services
First things first, we need a bunch of web service applications that interact between each other. Those will be the cores of our microservices. I'll keep things simple once again and just create a dummy service in Rust (using Actix) that will return a Hello world
message.
use actix_web::{
web::{self, Data},
App, HttpResponse, HttpServer, Responder,
};
async fn hello(service_name: Data<String>) -> impl Responder {
HttpResponse::Ok().body(format!("Hello world from {}!", service_name.get_ref()))
}
pub async fn prepare_server(service_name: String) -> std::io::Result<()> {
HttpServer::new(move || {
App::new()
.app_data(Data::new(service_name.clone()))
.route("/", web::get().to(hello))
})
.bind(("0.0.0.0", 8080))?
.run()
.await
}
As I pretend to have multiple instances of this dummy service, all of them doing essentially the same, I'll customize the code with a service_name
. This name will serve as an id of the microservice. So for a running instance of service_1
, I'll get a Hello world from service_1!
, for service_2
, Hello world from service_1!
and so on. This code can be used in a main
function like the one below:
use dummy_services_lib::dummy_service_logic::prepare_server;
use std::env;
#[actix_web::main]
async fn main() -> std::io::Result<()> {
let args: Vec<String> = env::args().collect();
let service_name = if args.len() > 1 {
args[1].clone()
} else {
"unknown_dummy_service".to_string()
};
prepare_server(service_name).await
}
Here I just take care of fetching the service name from the arguments and pass them to the prepare_server
function that I defined before. All of this gets compiled into a binary called dummy-service
. By executing ./dummy-service service_1
, a web server in port 8080 will reply with the expected response:
$ curl http://localhost:8080/
Hello world from service_1!%
Ok, these dummy services don't have any dependency between them. They are too dummy. Just for making things more interesting I'll create another service that will consume the response from the dummy services:
use actix_web::{
web::{self},
App, HttpResponse, HttpServer, Responder,
};
use serde_json::json;
async fn request_handler() -> impl Responder {
let req1 = reqwest::get("http://service1.devo/");
let req2 = reqwest::get("http://service2.devo/");
let req3 = reqwest::get("http://service3.devo/");
match (req1.await, req2.await, req3.await) {
(Ok(res1), Ok(res2), Ok(res3)) => {
let response = json!({
"res1": res1.text().await.unwrap_or_default(),
"res2": res2.text().await.unwrap_or_default(),
"res3": res3.text().await.unwrap_or_default()
});
HttpResponse::Ok().json(response)
}
_ => HttpResponse::InternalServerError().finish(),
}
}
async fn prepare_server() -> std::io::Result<()> {
HttpServer::new(|| App::new().route("/", web::get().to(request_handler)))
.bind(("0.0.0.0", 8080))?
.run()
.await
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
prepare_server().await
}
This consumer-service
, as it will get known, will make 3 requests to 3 different dummy services and group the responses into a json object that will be returned as a response by this service. I can do as I did before with the dummy service and execute an instance of the consumer by running ./consumer-service
. And again I can make a request to it with curl, but this time something goes wrong:
$ curl -i http://localhost:8080/
HTTP/1.1 500 Internal Server Error
content-length: 0
date: Mon, 12 Feb 2024 18:42:28 GMT
Indeed, the problem is that the domains that I used for referring to the dummy services don't get resolved. That's something that will get fixed once this code is executed inside the Kubernetes cluster. For now, I'll keep this code aside and I'll focus on containerizing the services. For that, I'll use the following Dockerfile:
FROM rust:alpine as builder
RUN apk update
RUN apk add --no-cache musl-dev pkgconf
WORKDIR /service
COPY Cargo.* .
COPY src ./src
RUN cargo build --release
FROM scratch as runtime
WORKDIR /service
COPY --from=builder /service/target/release/dummy-service .
COPY --from=builder /service/target/release/consumer-service .
The Dockerfile just uses the rust:alpine
image for building the project and then the service binaries get copied into /service
in a scratch
container. I can build the image and call it dummy_service
and assign it the version 1.0.0
with docker build -t dummy_service:1.0.0 .
. But, for now, there is not much we can do with this image. It's time to provision some infrastructure.
Provisioning an infrastructure in Azure with Terraform
It looks like we got blocked by not having yet a Kubernetes cluster. I admit that one can play around with this toy example that I'm preparing by creating a cluster locally using minikube. But I discourage that approach as we are discussing microservice architectures rather than just talking about Kubernetes.
As I mentioned before, the idea is to decouple services as much as possible so teams can focus on smaller parts of the whole environment. Ideally, a developer shouldn't need to run a cluster locally at all. They shouldn't need to go beyond the point of running a few containers using Docker or even just execute the service application directly in their computers as I did in the previous section. In other words, the developer should have access to the cluster and be able to make requests to the services inside from their dev environments, but nothing else.
Having said that, I decided to create my resources in Azure, the cloud computing services by Microsoft, and more specifically, I'll use Azure Kubernetes Service (AKS). There is no strong reason for using Azure, rather than, for example, AWS EKS. Everything discussed in this post can be done in Azure for free with their free trial period and with the starting $200 credit that they provide, while in AWS, it would incur a cost. That's why I decided to go with Azure.
Rather than creating the resources through the portal or with Azure's CLI, I'll use Terraform, an infrastructure-as-code tool that allows us to define cloud resources using config files.
Just to mention a few of the advantages of using Terraform, you can quickly notice that by having the config files in a git repository, you have the full history of what has ever happened with your resources, in addition to have the power to recreate the state at any point of the history with just a single command. It also comes handy when new stages are created, even in separate accounts, when you want to have a separation between your resources in development from the ones in production. And of course, you can go a step further and have a CI/CD pipeline for your infrastructure, with automated deployments and testing. The sky is the limit.
Now, let's start by indicating which modules will be used in the config files, by listing those dependencies in a versions.tf
file:
terraform {
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "3.90.0"
}
kubectl = {
source = "gavinbunney/kubectl"
version = ">= 1.7.0"
}
}
}
Just 2 modules, azurerm
for defining resources in Azure and kubectl
for managing later on the configuration for the cluster. We are ready for defining the first resource, a container registry (CR):
provider "azurerm" {
features {}
}
resource "azurerm_resource_group" "devo-rg" {
name = "devo-cluster-rg"
location = "UK South"
}
resource "random_id" "cr-id" {
byte_length = 5
}
resource "azurerm_container_registry" "cluster-cr" {
name = "clustercr${random_id.cr-id.hex}"
location = azurerm_resource_group.devo-rg.location
resource_group_name = azurerm_resource_group.devo-rg.name
sku = "Standard"
}
Resources in Azure are grouped into resource groups. For this example, I'll use the same devo-cluster-rg
resource group for all the resources. Other than that, you can see that I created a container registry called clustercr
followed by a random suffix. I did that because all CRs in Azure require a unique name, but one can resort to other creative ways of naming their CR. Regarding the Stock-Keeping Unit (SKU), a pedantic way to say pricing plan, I decided to go with the standard plan. Let's create the CR:
az login
terraform init -upgrade
terraform apply
After being asked to log in and to confirm the resources that will be created, I can now see the CR in the Azure portal. I can now push the Docker image that I created before:
az acr login --name clustercr<RANDOM_HASH>
docker tag dummy_service:1.0.0 clustercr<RANDOM_HASH>.azurecr.io/dummy_service/dummy_service:1.0.0
docker push clustercr<RANDOM_HASH>.azurecr.io/dummy_service/dummy_service:1.0.0
And again, the portal should show that the image was pushed indeed. We are ready to create the cluster:
resource "azurerm_kubernetes_cluster" "devo-cluster" {
name = "devo-cluster"
location = azurerm_resource_group.devo-rg.location
resource_group_name = azurerm_resource_group.devo-rg.name
dns_prefix = "devo-cluster"
default_node_pool {
name = "devonp"
node_count = 1
vm_size = "standard_b2s"
}
identity {
type = "SystemAssigned"
}
role_based_access_control_enabled = true
}
resource "azurerm_role_assignment" "devo-cluster-acr-role" {
scope = azurerm_container_registry.cluster-cr.id
role_definition_name = "AcrPull"
principal_id = azurerm_kubernetes_cluster.devo-cluster.kubelet_identity[0].object_id
}
Here, I'm defining a cluster called devo-cluster
that will be created inside the devo resource group. I assign devo-cluster
also as a prefix for the DNS record that will resolve to the Kubernetes' API. The node pool, the pool of virtual machines that will host the cluster, will have 1 standard b2s host, the smallest x86 instance supported by AKS. The identity
field is set to SystemAssigned
so any necessary identity associated to the cluster is created automatically and I enable the role based access control so the permissions to the Kubernetes' API can be defined in Azure instead of using Kubernetes for that. Finally, I give permissions to the cluster to pull images from the CR with a role assignment.
After executing terraform apply
once again, the cluster is created in Azure, and we can now discuss the cluster configuration.
First steps with Kubernetes
I mentioned before in a previous section the elements that we will use in Kubernetes so we can just jump into defining them in a few yaml files called manifests. But first of all, let's start by defining a namespace. Namespaces are just a way to keep the different elements of the cluster under different scopes, similar to the resource groups in Azure. So I proceed to define a devo
namespace in our first manifest in the global-manifest.yaml
file:
apiVersion: v1
kind: Namespace
metadata:
name: devo
Easy, right? Let's create the manifest for the first dummy service that we will host in the cluster. Here you can see the content of service1.yaml
manifest:
apiVersion: apps/v1
kind: Deployment
metadata:
name: service1
namespace: devo
spec:
replicas: 2
selector:
matchLabels:
app: service1
template:
metadata:
labels:
app: service1
spec:
containers:
- name: service1-container
image: clustercr604613db7d.azurecr.io/dummy_service/dummy_service:1.0.0
imagePullPolicy: "Always"
command: ["/service/dummy-service"]
args: ['service_1']
ports:
- containerPort: 8080
---
apiVersion: v1
kind: Service
metadata:
name: service1
namespace: devo
spec:
selector:
app: service1
ports:
- protocol: TCP
port: 80
targetPort: 8080
type: ClusterIP
It is a bit verbose, but there is nothing to be afraid of. Basically, I first define a deployment, a type of workload in Kubernetes that takes care of maintaining a defined state for a pod definition. In this case, we want to have 2 replicas of a pod with a single container inside, our dummy_service
container. The spec for the pod also contains the command and arguments that has to be executed (i.e. /service/dummy-service service_1
) and it exposes the port 8080 where the web service will be listening. After that, we define the Kubernetes service, so requests to its port 80 will get load balanced to the 2 replicas or instances of the dummy service.
I repeat the same process for dummy services service_2
and service_3
in service2.yaml
and service3.yaml
manifests, replacing only a few values, and, again, with a slightly different configuration for consumer-service
in consumer-service.yaml
manifest:
apiVersion: apps/v1
kind: Deployment
metadata:
name: consumer-service
namespace: devo
spec:
replicas: 2
selector:
matchLabels:
app: consumer-service
template:
metadata:
labels:
app: consumer-service
spec:
containers:
- name: consumer-service-container
image: clustercr604613db7d.azurecr.io/dummy_service/dummy_service:1.0.0
imagePullPolicy: "Always"
command: ["/service/consumer-service"]
ports:
- containerPort: 8080
---
apiVersion: v1
kind: Service
metadata:
name: consumer-service
namespace: devo
spec:
selector:
app: consumer-service
ports:
- protocol: TCP
port: 80
targetPort: 8080
type: ClusterIP
We are ready for passing the manifests to the cluster using Terraform with the kubectl
module that we added to the dependencies before:
provider "kubectl" {
load_config_file = false
host = azurerm_kubernetes_cluster.devo-cluster.kube_config[0].host
token = azurerm_kubernetes_cluster.devo-cluster.kube_config[0].password
cluster_ca_certificate = base64decode(azurerm_kubernetes_cluster.devo-cluster.kube_config[0].cluster_ca_certificate)
}
data "kubectl_path_documents" "docs" {
pattern = "*.yaml"
}
resource "kubectl_manifest" "manifests" {
for_each = toset(data.kubectl_path_documents.docs.documents)
yaml_body = each.value
}
Nothing special happens here, other than passing the cluster's credentials to kubectl
and indicating where the manifests are available. As I don't have other yaml files in this project, I just use *.yaml
for importing them and recreating the manifests into Terraform. One more terraform apply
and the services are created in the cluster.
At last, we can test the consumer service using kubectl, the Kubernetes CLI (not to be confused with the Terraform module):
az aks get-credentials --resource-group devo-cluster-rg --name devo-cluster
kubectl port-forward --namespace devo service/consumer-service 8080:80
With this, a port forwarding is established from port 8080 of my computer to port 80 of the consumer service. One curl command will be enough for checking that everything is working:
$ curl http://localhost:8080/
{"res1":"Hello world from service_1!","res2":"Hello world from service_2!","res3":"Hello world from service_3!"}%
Great!
Conclusion and future steps
I hope this post gave some clarity on the early stage of creating a microservice architecture using Kubernetes and AKS. These are just the foundations for an actual project, on top of which many components can be added.
One of the first things to add would be a CI/CD pipeline, so changes in the service code are built and deployed automatically without relying on the developers to create Docker images locally and getting them pushed to the container registry, so later on, they can get fetched by the cluster through a manual request to the Kubernetes API. This would reduce significantly the chances of regressions, bugs and outages as it usually happens with manual operations. There are plenty of solutions out there, like Jenkins or, if you fancy to use Azure, Azure Pipelines, part of Azure DevOps, would do the trick.
Additionally, as I commented before, the local environment for a developer should have access to the microservices inside the cluster. Otherwise, they would need to either make changes without being able to test them immediately or they would need to recreate the cluster locally, and neither of those options make sense. Fortunately, this opinion is shared by the community and there are solutions for this as well. Telepresence is a tool that allows that. By deploying a sidecar service into the cluster, it replicates local requests into the cluster network so the resources in the cluster appear as local resources. It goes further by also giving the possibility of intercepting incoming traffic from a service in the cluster so a local instance can process it.
After that, it should be all about creating a production environment, exactly the same as the one for development, and keep developing the business logic, adding tests and creating more and more microservices as your product evolves. Have fun with it!