Overboard


Overboard: Guestbook example

In this tutorial we will recreate the official Kubernetes Guestbook example. The example deploys a Redis cluster with a master node and 3 replicas. A PHP web application makes use of the Redis cluster to allow a guest to leave a note.

The official example does not currently work if you run it as is but don't worry. We will make adjustments to make sure our deployment runs as expected.

This tutorial will also start to show the power of using a programming language over static configuration.

Topics covered

Requirements

  1. A Kubernetes cluster to learn on such as Kubernetes on Docker or minikube.
  2. A basic knowledge of using kubectl
  3. Ingress enabled for your clusterW
  4. dotnet SDK installed
  5. Optionally, any IDE that supports F# (Visual Studio Code, IntelliJ Rider, Visual Studio, NeoVim)

Visual Studio Code with the Ionide is a great choice. See Setup your environment for more details.

Configuring a Redis Deployment

In the Hello World tutorial you nested in-line the config. Here we will be defining each resource and assign it to a variable. We then build up our Deployment using the variables rather than in-lining the config.

First off, import the Overboard package and import the namespaces we need.

  1. Create a file called guestbook.fsx
  2. Use the #r directive to import the Overboard package and open up the namespaces
#r "nuget:Overboard"

// open the required namespaces
open System
open Overboard
open Overboard.Common
open Overboard.Workload
open Overboard.Service

The we are going to create our first function. This function, redisDeployment will return the Deployment resource configuration. A F# function is just a value, so we use the let keyword to assign the value.

Tip: Start with defining your configuration with hard-coded values, then pull our parameters to pass in as arguments as needed.

/// Return a Redis Kubernetes Deployment Resource Config
let redisDeployment redisName role replicaCount portNumber  =
    
    // which image depends on the role
    let redisImage = if role = "leader" then "docker.io/redis:6.0.5" else "gcr.io/google_samples/gb-redis-follower:v2"

    // define the container
    let redisContainer = container {
        name redisName
        image redisImage
        containerPort {
            port portNumber
        }
    }

    // build up the list of labels
    let labels = [
        ("app", "redis")
        ("role", role)
        ("tier", "backend")
    ]

    // the pod to use for podTemplate
    let redisPod = pod {
        _labels labels
        add_container redisContainer
    }

    // define the deployment using the same labels used for the pod in the matchLabels
    let redisDeployment = deployment {
        _name redisName
        replicas replicaCount
        add_matchLabels labels
        podTemplate redisPod
    }

    // return the deployment config
    redisDeployment

So now we have a function that can return the config for both our master and replica Redis deployments.

Configuring a Redis Service

Next we need to have a Service to match up to a Deployment. This Service will proxy the traffic to the Redis pods provisioned through the Deployment.empty

We do this the same way as with the Deployment. With a function called redisService.

/// Return a Redis Kubernetes Service Resource Config
let redisService serviceName role portNumber =
    // list the labels
    let labels = [
        ("app", "redis")
        ("role", role)
        ("tier", "backend")
    ]

    // configure the port
    let port = servicePort {
        port portNumber
        targetPortInt portNumber
    }

    // put it together in a Service config
    let redisService = service {
        _name serviceName
        _labels labels
        add_port port
        matchLabels labels
    }

    // return the config
    redisService

This function will provide the Service config when passed the parameters for the service. We can use this for defining a Service for both the master and replica Redis Deployments.

Configuring the Guestbook

The last piece of the puzzle is the web application. The image we are pulling in is of a PHP application but Kubernetes doesn't really care what it is. We just define the image used by the container in the Pod.

So we define a general function for returning the config for a Deployment of an application and the Service to make it available.

/// Return a Deployment and Service for a frontend application connected to Redis
let frontendApp appName imageName replicaCount portName portNumber=
    // pass back a K8s config type (contains both Deployment and Service)
    k8s {
        // add deployment to K8s config
        deployment {
            _name appName
            replicas replicaCount
            labelSelector {
                matchLabels [
                    ("app", appName)
                    ("tier", "frontend")
                ]
            }
            pod {
                _labels [
                    ("app", appName)
                    ("tier", "frontend")
                ]
                container {
                    appName
                    image imageName
                    add_port (containerPort {
                        name portName
                        port portNumber
                    })
                    cpuRequest 100<m>
                    memoryRequest 100<Mi>
                    // environment variables instructing how to find Redis cluster
                    envVar {
                        name "GET_HOSTS_FROM"
                        value "dns"
                    }
                }
            }
        } 

        // add service to K8s config
        service {
            _name appName
            _labels [
                ("app", appName)
                ("tier", "frontend")
            ]
            add_port (servicePort {
                        port portNumber
                        targetPortString portName
                    })
            matchLabels [
                ("app", appName)
                ("tier", "frontend")
            ]
            typeOf NodePort
        }
    } // F# returns the last expression of a function. Since the k8s definition is a single expression, it is returned.

In the above function we used a different style to the previous 2. We defined the configuration in a single expression without assigning labels and pods to intermediate variables. Which style you use is up to you and might depend on the complexity of the configuration or you might chose variables for reuse (eg. labels are a common candidate).

Putting it together

Now that we have our functions, we need the values we will be passing into them.

// Capture our values for the Redis and App config
// Redis settings
let leaderName = "redis-leader"
let leaderRole = "leader"
let leaderReplicaCount = 1
let followerName = "redis-follower"
let followerRole = "follower"
let followerReplicaCount = 2
let redisPortNumber = 6379
// Guestbook settings
let guestbookAppName = "guestbook"
let guestbookAppImage = "gcr.io/google-samples/gb-frontend:v5"
let guestbookReplicas = 3
let guestBookPortName = "http-server"
let guestBookPortNumber = 80

Now we put together our final Kubernetes configuration by generating the config by calling the functions we created above. We compose them in a k8s builder to build up our final configuration.

// build up the config by calling the functions
let k8sConfig = k8s {
    // Redis master deployments and service
    redisDeployment leaderName leaderRole leaderReplicaCount redisPortNumber
    redisService leaderName leaderRole redisPortNumber
    // Redis replica deployment and service
    redisDeployment followerName followerRole followerReplicaCount redisPortNumber
    redisService followerName followerRole redisPortNumber
    // Guestbook app (combines that K8s instance with the current one)
    frontendApp guestbookAppName guestbookAppImage guestbookReplicas guestBookPortName guestBookPortNumber
}

We use KubeCtlWriter to write a YAML (or JSON if you prefer) file that we can use to deploy our configuration to Kubernetes.

Tip: __SOURCE_DIRECTORY__ contains a string to the directory the that the script file is running in.

// write the file
KubeCtlWriter.toYamlFile k8sConfig $"{ __SOURCE_DIRECTORY__}{IO.Path.DirectorySeparatorChar}guestbook.yaml"

Test out the configuration you have created by applying it to your Kubernetes cluster.

kubectl apply -f guestbook.yaml
kubectl get all

You should see something similar to this:

NAME                                  READY   STATUS    RESTARTS   AGE
pod/guestbook-7c9bfb4679-d8mzr        1/1     Running   0          29s
pod/guestbook-7c9bfb4679-jgqsk        1/1     Running   0          29s
pod/guestbook-7c9bfb4679-jrqb5        1/1     Running   0          29s
pod/redis-follower-75b578cb57-6bkcv   1/1     Running   0          29s
pod/redis-follower-75b578cb57-c6f4b   1/1     Running   0          29s
pod/redis-leader-5f6f4b8bb7-wqcz8     1/1     Running   0          29s

NAME                     TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)        AGE
service/guestbook        NodePort    10.98.17.142    <none>        80:30762/TCP   29s
service/kubernetes       ClusterIP   10.96.0.1       <none>        443/TCP        22h
service/redis-follower   ClusterIP   10.99.147.198   <none>        6379/TCP       29s
service/redis-leader     ClusterIP   10.108.61.85    <none>        6379/TCP       29s

NAME                             READY   UP-TO-DATE   AVAILABLE   AGE
deployment.apps/guestbook        3/3     3            3           29s
deployment.apps/redis-follower   2/2     2            2           29s
deployment.apps/redis-leader     1/1     1            1           29s

NAME                                        DESIRED   CURRENT   READY   AGE
replicaset.apps/guestbook-7c9bfb4679        3         3         3       29s
replicaset.apps/redis-follower-75b578cb57   2         2         2       29s
replicaset.apps/redis-leader-5f6f4b8bb7     1         1         1       29s

To test out the application, open up a port to 80, where the guestbook application is running.

kubectl port-forward svc/guestbook 81:80

81 is the port you can access the app on your side. You can choose a port you prefer here.

Navigate to localhost:81 and you should see the application.

Guestbook running

Go ahead and leave a note.

Conclusion

In this tutorial we defined a more complicated configuration than hello-world.

  1. We saw how you can extract repetitive configuration into functions and then use that function to generate similar configuration multiple times.
  2. We also saw how using values allowed us to reuse those values by reference rather than having repeating values like names even when calling our functions.
  3. We demonstrated different styles available and say how we can not only compose resources like a Deployment but can also compose full Kubernetes configurations into combined ones.
namespace System
namespace Overboard
namespace Overboard.Common
namespace Overboard.Workload
namespace Overboard.Service
val redisDeployment: redisName: string -> role: string -> replicaCount: int -> portNumber: int -> Deployment
 Return a Redis Kubernetes Deployment Resource Config
val redisName: string
val role: string
val replicaCount: int
val portNumber: int
val redisImage: string
val redisContainer: Container
val container: ContainerBuilder
<summary> A single application container that you want to run within a pod. https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#Container </summary>
custom operation: name (string) Calls ContainerBuilder.Name
custom operation: image (string) Calls ContainerBuilder.Image
val containerPort: ContainerPortBuilder
custom operation: port (int) Calls ContainerPortBuilder.ContainerPort
val labels: (string * string) list
val redisPod: Pod
val pod: PodBuilder
custom operation: _labels ((string * string) list) Calls PodBuilder.Labels
<summary> Labels for the Pod </summary>
custom operation: add_container (Container) Calls PodBuilder.Container
val redisDeployment: Deployment
val deployment: DeploymentBuilder
custom operation: _name (string) Calls DeploymentBuilder.Name
<summary> Name of the Deployment. Name must be unique within a namespace. https://kubernetes.io/docs/reference/kubernetes-api/common-definitions/object-meta/#ObjectMeta </summary>
custom operation: replicas (int) Calls DeploymentBuilder.Replicas
custom operation: add_matchLabels ((string * string) list) Calls DeploymentBuilder.MatchLabels
<summary> Add multiple label selectors to the Deployment. </summary>
custom operation: podTemplate (Pod) Calls DeploymentBuilder.Pod
val redisService: serviceName: string -> role: string -> portNumber: int -> Service
 Return a Redis Kubernetes Service Resource Config
val serviceName: string
val port: ServicePort
val servicePort: ServicePortBuilder
custom operation: port (int) Calls ServicePortBuilder.Port
custom operation: targetPortInt (int) Calls ServicePortBuilder.TargetPortI
val redisService: Service
val service: ServiceBuilder
custom operation: _name (string) Calls ServiceBuilder.Name
<summary> Name of the Service. Name must be unique within a namespace. https://kubernetes.io/docs/reference/kubernetes-api/common-definitions/object-meta/#ObjectMeta </summary>
custom operation: _labels ((string * string) list) Calls ServiceBuilder.Labels
<summary> Labels for the Service </summary>
custom operation: add_port (ServicePort) Calls ServiceBuilder.ServicePort
custom operation: matchLabels ((string * string) list) Calls ServiceBuilder.MatchLabels
<summary> Add multiple label selectors to the Service. </summary>
val frontendApp: appName: string -> imageName: string -> replicaCount: int -> portName: string -> portNumber: int -> K8s
 Return a Deployment and Service for a frontend application connected to Redis
val appName: string
val imageName: string
val portName: string
val k8s: K8sBuilder
val labelSelector: LabelSelectorBuilder
<summary> A label selector is a label query over a set of resources. https://kubernetes.io/docs/reference/kubernetes-api/common-definitions/label-selector/#LabelSelector </summary>
custom operation: matchLabels ((string * string) list) Calls LabelSelectorBuilder.MatchLabel
custom operation: add_port (ContainerPort) Calls ContainerBuilder.ContainerPort
custom operation: name (string) Calls ContainerPortBuilder.Name
custom operation: cpuRequest (int<m>) Calls ContainerBuilder.CpuRequest
[<Measure>] type m
<summary> Millicpus: 1000m = 1cpu </summary>
custom operation: memoryRequest (int<Mi>) Calls ContainerBuilder.MemoryRequest
[<Measure>] type Mi
<summary> Mebibytes </summary>
val envVar: EnvVarBuilder
custom operation: name (string) Calls EnvVarBuilder.Name
custom operation: value (string) Calls EnvVarBuilder.Value
custom operation: targetPortString (string) Calls ServicePortBuilder.TargetPortS
custom operation: typeOf (ServiceType) Calls ServiceBuilder.Type
<summary> Type of the Service. </summary>
union case ServiceType.NodePort: ServiceType
val leaderName: string
val leaderRole: string
val leaderReplicaCount: int
val followerName: string
val followerRole: string
val followerReplicaCount: int
val redisPortNumber: int
val guestbookAppName: string
val guestbookAppImage: string
val guestbookReplicas: int
val guestBookPortName: string
val guestBookPortNumber: int
val k8sConfig: K8s
module KubeCtlWriter from Overboard.K8s
val toYamlFile: k8s: K8s -> filePath: string -> ValidationProblem list
namespace System.IO
type Path = static member ChangeExtension: path: string * extension: string -> string static member Combine: path1: string * path2: string -> string + 3 overloads static member EndsInDirectorySeparator: path: ReadOnlySpan<char> -> bool + 1 overload static member GetDirectoryName: path: ReadOnlySpan<char> -> ReadOnlySpan<char> + 1 overload static member GetExtension: path: ReadOnlySpan<char> -> ReadOnlySpan<char> + 1 overload static member GetFileName: path: ReadOnlySpan<char> -> ReadOnlySpan<char> + 1 overload static member GetFileNameWithoutExtension: path: ReadOnlySpan<char> -> ReadOnlySpan<char> + 1 overload static member GetFullPath: path: string -> string + 1 overload static member GetInvalidFileNameChars: unit -> char[] static member GetInvalidPathChars: unit -> char[] ...
<summary>Performs operations on <see cref="T:System.String" /> instances that contain file or directory path information. These operations are performed in a cross-platform manner.</summary>
field IO.Path.DirectorySeparatorChar: char