[Tutorial] Adding Services and Dependencies in Kubernetes

Daniel Olaogun
Minute Read

Services and dependencies work together in Kubernetes to ensure that applications have the required network access and resources, enabling seamless communication and efficient operation within the cluster environment. Creating and managing services is essential in Kubernetes to enable network access to sets of pods. Application dependencies ensure your apps have everything they need to run correctly and efficiently.

This article is the second part of the series "Platform Engineering Workflows" and focuses on adding and managing services and dependencies in Kubernetes. It follows the first part, which covers environment variables and configuration.

The article provides a step-by-step guide on how to set up a demo Kubernetes deployment and create a service. Additionally, it demonstrates the process of introducing a dependency like a MySQL database and establishing a connection with the existing deployment.

You'll also explore best practices for managing services and dependencies in Kubernetes, from utilizing namespaces and service registries to implementing health checks and setting up autoscaling.

Platform Engineering + Kubernetes Series

  1. Platform Engineering on Kubernetes for Accelerating Development Workflows
  2. Adding Environment Variables and Changing Configurations in Kubernetes
  3. Adding Services and Dependencies in Kubernetes
  4. Adding and Changing Kubernetes Resources
  5. Enforcing RBAC in Kubernetes
  6. Spinning up a New Kubernetes Environment

Adding Services and Dependencies to Kubernetes

Kubernetes services and dependencies are very important in any large-scale Kubernetes application. Services allow pods to communicate with each other and external systems, while dependencies are the various resources your application requires to function correctly. Whether they include a supporting library, a database, or an external application, correctly managing dependencies ensures the smooth operation of your application.

To understand the roles that services and dependencies play, you'll create a demo application in Node.js that depends on a MySQL database, then Dockerize the Node.js application. You'll then create a deployment manifest file to add the application to your cluster and a service to make the application available via an endpoint.

Creating the Demo Application

You'll create a simple server application developed on Node.js that will be deployed on Kubernetes with a MySQL dependency. If the app is able to connect to the database, it will display a message saying "Connected to the database." If it can't make a connection, it will display a different message saying "Cannot connect to the database."

Start by creating a directory titled nodeapp and cd into the directory:

mkdir nodeapp 
cd nodeapp

Initialize a new Node project and package.json file in the directory:

npm init -y

Create a server.js file:

vim server.js

Paste the following into the server.js file:

const express = require('express');
const mysql = require('mysql');
const app = express();
const port = 8080;

let dbConfig = {
  host: process.env.DB_HOST,
  user: process.env.DB_USER,
  password: process.env.DB_PASS,
  database: 'mydb'
};

let connection;

const databaseConnectionError = () => {
  connection = mysql.createConnection(dbConfig);

  connection.connect(function(err) {              
    if(err) {                                     
      console.log('Error when connecting to db:', err);
      setTimeout(databaseConnectionError, 2000); 
    }                                     
  });                                     

  connection.on('error', function(err) {
    console.log('db error', err);
    if(err.code === 'PROTOCOL_CONNECTION_LOST') { 
      databaseConnectionError();                         
    } else {                                      
      throw err;                                  
    }
  });
}

databaseConnectionError();

app.get('/', (req, res) => {
  connection.query('SELECT 1 + 1 AS solution', function (error, results, fields) {
    if (error) {
      res.send('Cannot connect to the database');
    } else {
      res.send('Connected to the database');
    }
  });
});

app.listen(port, () => {
  console.log(`Node app listening at http://localhost:${port}`)
});

The above script contains the database and server configuration. The database config allows the application to connect to the database from the application (provided that the details are properly set), while the server config allows the application to receive requests from users through the endpoint configured.

Finally, install the express and mysql packages:

npm install express mysql

Creating the Docker Image

The application needs to be Dockerized so that you can use it in your Kubernetes cluster.

First, set up a Dockerfile that will be used to create a Docker image:

vim Dockerfile

Next, paste in the following:

# Use an official Node.js runtime as the base image
FROM node:18.14.2

# Set the working directory in the container to /app
WORKDIR /app

# Copy package.json and package-lock.json to the working directory
COPY package*.json ./

# Install the application dependencies
RUN npm install

# Copy the rest of the application code to the working directory
COPY . .

# Make the application's port available to the outside world
EXPOSE 8080

# Define the command that should be executed when the container starts
CMD [ "node", "server.js" ]

For the purposes of this tutorial, you'll be using minikube to create a local cluster on your local machine.

Since this tutorial uses minikube, you won't need to deploy your Docker image on Docker Hub. However, if you opt to use a Kubernetes cluster that is not hosted on your local machine, then you'll have to deploy your Docker image.

If you are using a minikube cluster, before you build your application image, run the following command:

eval $(minikube docker-env)

This is to ensure that Docker and minikube are using the same Docker daemon.

Run the following command to build the Docker image:

docker build -t nodeapp:v1 .

Creating a Kubernetes Deployment and Service

Now that you've built the Docker image for your application, you can use it in your Kubernetes cluster. To add your application to the Kubernetes cluster, you have to create a deployment.

Move out of the nodeapp directory and create a new directory titled nodeapp-k8s:

cd .. && mkdir nodeapp-k8s

Move into the nodeapp-k8s directory and create a new deployment called nodeapp-deployment:

vim nodeapp-deployment.yaml

Paste in the following code:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: nodeapp-deployment
spec:
  replicas: 1
  selector:
    matchLabels:
      app: nodeapp
  template:
    metadata:
      labels:
        app: nodeapp
    spec:
      containers:
        - name: nodeapp
          image: nodeapp:v1
          ports:
            - containerPort: 8080
          env:
            - name: DB_HOST
              value: mysql-service
            - name: DB_USER
              value: root
            - name: DB_PASS
              valueFrom:
                secretKeyRef:
                  name: mysql-pass
                  key: password

For more information about Kubernetes deployments, visit the official documentation.

To avoid potential pod failures, create the database secret before adding the deployment to your cluster. This ensures the secret is available when the deployment's pods start, facilitating a smooth deployment process.

To create the secret, execute the following command:

kubectl create secret generic mysql-pass --from-literal=password='yourpassword'

Run the following command to add the deployment to your cluster:

kubectl apply -f nodeapp-deployment.yaml

You can use the following command to confirm that your deployment is running:

kubectl get deployment

You should get a response similar to the one below:

NAME                 READY   UP-TO-DATE   AVAILABLE   AGE
nodeapp-deployment   1/1     1            1           12s

You now need to expose your deployed application so that it will be available via an endpoint.

Create a nodeapp-service.yaml file:

vim nodeapp-service.yaml

Paste the following into the file:

apiVersion: v1
kind: Service
metadata:
  name: nodeapp-service
spec:
  selector:
    app: nodeapp
  ports:
    - protocol: TCP
      port: 80
      targetPort: 8080
  type: LoadBalancer

Run the following command to apply nodeapp-service to your cluster:

kubectl apply -f nodeapp-service.yaml

Open a new terminal and use the following command to get the URL for nodeapp-service:

minikube service nodeapp-service --url

This command is necessary because nodeapp-service is a LoadBalancer service type. In a cloud-based Kubernetes cluster, an external IP is automatically generated and available for communication with the service. However, when running Kubernetes on minikube, this external IP is not accessible. Therefore, you need to run the above command to retrieve the IP you can use locally to communicate with your service. Note that this command is not needed in a cloud-based Kubernetes cluster.

In a separate terminal, try to access the URL, replacing the address below with the one provided by the minikube service command:

curl http://127.0.0.1:56789

Once you ping the URL using the curl command, you'll get the following response:

Cannot connect to the database% 

You can see that your Node application depends on a database dependency to function properly. Without the database, the application won't be able to function.

Deploying the Database Dependency

You'll need to deploy the database dependency so that your application can function properly.

In the nodeapp-k8s directory, create a new file named mysql-deployment.yaml:

vim mysql-deployment.yaml

Paste the following content into the file:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: mysql-deployment
spec:
  replicas: 1
  selector:
    matchLabels:
      app: mysql
  template:
    metadata:
      labels:
        app: mysql
    spec:
      containers:
        - name: mysql
          image: mysql:5.7.11
          env:
            - name: MYSQL_DATABASE
              value: mydb
            - name: MYSQL_ROOT_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: mysql-pass
                  key: password

Apply the deployment file to the cluster:

kubectl apply -f mysql-deployment.yaml

Now, run the following command:

kubectl get deployment

You should see the following:

NAME                 READY   UP-TO-DATE   AVAILABLE   AGE
mysql-deployment     1/1     1            1           2m
nodeapp-deployment   1/1     1            1           20m

Next, create a service for mysql-deployment by creating a new file titled mysql-service.yaml:

vim mysql-service.yaml

Paste in the following code:

apiVersion: v1
kind: Service
metadata:
  name: mysql-service
spec:
  selector:
    app: mysql
  ports:
    - protocol: TCP
      port: 3306
  type: ClusterIP

Run the following command to apply the service:

kubectl apply -f mysql-service.yaml

Now, run kubectl get service to check that the MySQL service has been deployed properly:

kubernetes        ClusterIP      10.96.0.1        <none>        443/TCP        44m
mysql-service     ClusterIP      10.109.17.243    <none>        3306/TCP       11s
nodeapp-service   LoadBalancer   10.108.177.235   <pending>     80:32530/TCP   30m

After a few minutes, ping the nodeapp-service URL again, using the address provided by the minikube service command earlier:

curl http://127.0.0.1:56789

You should get the following response:

Connected to the database% 

This tutorial demonstrated a simple use case for service dependencies, underscoring the importance of their correct configuration and deployment. To ensure that your deployments are as efficient as possible and optimize overall performance, it's essential to have a comprehensive understanding of all service dependencies and ensure their correct setup prior to deploying your services.

Best Practices for Managing Services and Dependencies in Kubernetes

To ensure efficient and maintainable management of services and dependencies within Kubernetes, there are some best practices you should follow.

Use Correct Organization with Namespaces

Namespaces in Kubernetes allow you to divide cluster resources between multiple users or groups of users (such as teams, projects, or clients), applications, or environments. They provide a scope for the names of Kubernetes objects like pods and deployments, which must be unique within a namespace, but not across namespaces.

To create a new namespace, you can use the kubectl create namespace command:

kubectl create namespace my-namespace

Then, in your Kubernetes manifest files, you can specify the namespace for each resource:

apiVersion: v1
kind: Pod
metadata:
  name: my-pod
  namespace: my-namespace
...

Alternatively, you can specify the namespace resources that can be deployed when using kubectl, as seen below:

kubectl -n team-a-ns apply -f team-a-app.yaml
kubectl -n team-b-ns apply -f team-b-app.yaml

This practice helps maintain order, especially in large clusters with many users or teams. It also adds isolation, as resources in one namespace can't directly interact with resources in another.

Use a Service Registry for Better Service Discovery

Using a service registry enhances service discovery capabilities. Kubernetes' built-in service registry mechanism, service discovery, allows pods to communicate with each other through services. It enables consistent access to services in dynamic environments where pods are frequently created and destroyed, eliminating the need to track changing IP addresses. It also provides built-in load balancing, distributing network traffic to prevent overload on specific service instances and improving overall system efficiency. Moreover, service discovery ensures high availability by automatically redirecting traffic to available pods if one fails, guaranteeing uninterrupted service access and promoting fault tolerance.

Here's an example of a service manifest that creates a service:

apiVersion: v1
kind: Service
metadata:
  name: example-service
spec:
  selector:
    app: example-app
  ports:
    - protocol: TCP
      port: 80
      targetPort: 8080

In this example, any pod with the label app: example-app can be discovered through the example-service service.

Use a Service Mesh to Manage Interservice Communication

A service mesh is an infrastructure layer that manages service-to-service communication over the network. It provides valuable features such as traffic management, service discovery, load balancing, security, and observability. With a service mesh like Istio or Linkerd, you can control load balancing, service discovery, and more on a per-service level.

For example, if you're using Istio, you can set up a traffic rule that splits traffic between two versions of a service like this:

apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
  name: example-service
spec:
  hosts:
  - example-service
  http:
  - route:
    - destination:
        host: example-service
        subset: v1
      weight: 60
    - destination:
        host: example-service
        subset: v2
      weight: 40

The above config sends 60 percent of the traffic to v1 of example-service and 40 percent to v2.

Use DNS-Based Service Discovery

Kubernetes employs DNS-based service discovery, which enables applications to discover services using their DNS names rather than IP addresses. This simplifies service management by providing consistent DNS names even if the underlying IP addresses change.

When you create a service, a unique DNS name is assigned in the following format:

service-name.namespace-name.svc.cluster.local

For example, when a pod within a Kubernetes cluster needs to communicate with a service, it's more efficient to utilize the service's DNS name rather than its IP address. This is because when a service is redeployed, it might receive a new IP address. If pods relied on the old IP address to communicate with the service, they would encounter connection issues after the redeployment. However, using the service's DNS name establishes a consistent point of contact that remains unchanged across deployments as long as the service's name remains the same. This ensures uninterrupted communication between the pod and the service, regardless of any redeployments.

Choose the Right Resource Type: Deployment vs. StatefulSet for Dependencies

When managing dependencies in Kubernetes, choosing the right type of resource for each dependency is essential. Deployments are best for stateless applications, while StatefulSets are best for stateful applications.

For example, a MySQL database would be better suited to a StatefulSet, as each replica needs its own unique identity and storage. On the other hand, a Node.js web server would be better suited to a deployment, as each replica is interchangeable.

Use Pod Affinity and Anti-Affinity

Pod affinity and anti-affinity enable you to define rules regarding the preferred or undesired scheduling of pods onto specific nodes. These rules can be based on node labels or the presence of other pods running on the nodes.

For example, you can use pod anti-affinity to ensure that two pods of the same type don't get scheduled on the same node:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: my-deployment
spec:
  ...
  template:
    ...
    spec:
      affinity:
        podAntiAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
          - labelSelector:
              matchExpressions:
              - key: app
                operator: In
                values:
                - my-app
            topologyKey: "kubernetes.io/hostname"

This rule prevents the scheduler from placing two pods with the label app: my-app on the same node.

Balance Traffic across Pods

A service in Kubernetes acts as a load balancer, distributing traffic across the pods associated with the service. The default mode uses the round robin method, but for more complex scenarios, you can use session affinity to ensure all requests from a particular client are directed to the same pod:

apiVersion: v1
kind: Service
metadata:
  name: my-service
spec:
  sessionAffinity: ClientIP
  selector:
    app: MyApp
  ports:
    - protocol: TCP
      port: 80
      targetPort: 9376

Implement Health Checks

Kubernetes allows you to specify liveness and readiness probes in your pod specifications. These probes are used to check your containers' health and ensure they're ready to receive traffic.

Here's an example of a liveness probe that checks an HTTP endpoint:

apiVersion: v1
kind: Pod
metadata:
  name: example-pod
spec:
  containers:
  - name: example-container
    image: example-image
    livenessProbe:
      httpGet:
        path: /healthz
        port: 8080
      initialDelaySeconds: 20
      periodSeconds: 5

In this example, the kubelet sends an HTTP GET request to the /healthz endpoint of the container every 5 seconds, 20 seconds after the container starts. If the endpoint returns a failure, the kubelet restarts the container.

Configure Autoscaling in Your Cluster

Kubernetes has built-in autoscaling through the Horizontal Pod Autoscaler (HPA). The HPA adjusts the number of replicas in a deployment or StatefulSet based on observed CPU utilization or custom metrics.

Here's an example of an HPA configuration:

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: example-hpa
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: example-deployment
  minReplicas: 1
  maxReplicas: 10
  metrics:
  - type: Resource
    resource:
      name: cpu
      target:
        type: Utilization
        averageUtilization: 50

In this example, the HPA increases or decreases the number of replicas of example-deployment to maintain an average CPU utilization of 50 percent. It won't reduce the number of replicas below 1 or increase it above 10.

Additionally, you can apply an HPA using the kubectl command, as seen below:

kubectl autoscale deployment example-hpa --min=2 --max=5 --cpu-percent=80

Conclusion

This tutorial explored how to incorporate services and dependencies into your applications within a Kubernetes environment. You deployed a demo Node.js application and integrated a MySQL database as a dependency, emphasizing the significance of services and dependencies in ensuring seamless operation within your cluster. The article also provided some best practices for managing services and dependencies in Kubernetes, encompassing key aspects such as namespaces, service registries, autoscaling, and more.

Understanding Kubernetes services and dependencies and managing them effectively is crucial for any platform engineering workflow. They are integral to setting up robust, scalable applications and functional applications in Kubernetes.

Sign up for our newsletter

Be the first to know about new features, announcements and industry insights.