My experience adding a MongoDB No-SQL database to my Kubernetes cluster

If you have read my article on how to decide between SQL and No-SQL databases, you may be wondering if you can add a No-SQL MongoDB database to your Kubernetes cluster. In this article I explain what I did to do just that and how I then used it with a Spring Boot application.

Martin Hodges
15 min readJun 10, 2024
Adding MongoDB to a Spring Boot application

Starting out

As usual, when developing a Kubernetes service, I start with a local Kind Kubernetes cluster for development. I have written on how to set up a Kind before and the GitHub repository that is associated to this article has the configuration files in it to do just that.

You can clone the repository with:

git clone git@github.com:MartinHodges/aquarium-with-mongo-db.git

Why MongoDB?

My earlier article addresses the decision on SQL vs No-SQL. If you are reading this, I assume that you decided to go with No-SQL.

Once that is decided, it is now a question of which No-SQL database to go with. MongoDB has twice the market share compared to its nearest competitors. It is highly sophisticated and has, both a community edition and an enterprise edition. It is typically the go-to No-SQL database.

The technical comparison with other databases is beyond this article and MongoDB was selected for this article based on its popularity — and the fact that it can do the job!

Installing MongoDB

Installing MongoDB onto our Kubernetes cluster is done in a similar way to other applications, through the use of an operator.

MongoDB Kubernetes operator

A Kubernetes operator manages an application on behalf of you. It is able to install and manage the lifecycle of the application whilst also monitoring it and taking action as necessary.

In the case of a database, it could be creating a database cluster, scaling it, doing backups etc. Typically, an operator relies on installing Custom Resource Definitions (CRDs) that provide it with its own ‘Kubernetes configuration language’. It listens out for requests to add these custom resources to the cluster and then acts on your behalf.

Creating a development Kubernetes cluster

Assuming you have installed Kind, you can now create a Kind cluster using the following configuration:

kind/kind-config.yml

apiVersion: kind.x-k8s.io/v1alpha4
kind: Cluster
nodes:
- role: control-plane
extraPortMappings:
# apis
- containerPort: 30080
hostPort: 30080
- role: worker
- role: worker
- role: worker

This creates a 4 node cluster (1 controller, 3 workers). It also makes port 30080 available on the development machine. You can use this to create your local Kubernetes cluster this with:

kind create cluster --config kind/kind-config.yml

Installing the operator

You can install a community supported operator using Helm.

First add the Helm link to your local repository with:

helm repo add mongodb https://mongodb.github.io/helm-charts

You can see what charts this has added with:

helm search repo mongo

In the list, you will see the community operator, which we will use.

We will place our operator and our database in their own separate namespace called mongo. Let’s create it with:

kubectl create namespace mongo

You can now install the operator with:

 helm install community-operator mongodb/community-operator -n mongo

If you want the operator to watch for resources being created in a different namespace add --set operator.watchNamespace="<other namespace>" to the command above.

You can check the ready status is 1/1 Running with:

kubectl get pods -n mongo

Now we have an operator up and running, we can see the CRDs it installed with:

kubectl get crds
kubectl describe crd mongodbcommunity.mongodbcommunity.mongodb.com

We are now ready to create our first MongoDB cluster.

Creating a cluster

With the operator installed, it is now listening out for any request to create a MongoDB database. We can make a request by applying a MonogDB manifest to our Kubernetes cluster using the CRDs that were loaded by the operator.

Before we do this, we will need to set up a password for our database user as a Kubernetes secret.

Create the secret as follows (remember to replace <…> with your chosen password):

kubectl create secret generic my-user-password -n mongo --from-literal="password=<your password>" 

You can check this with:

kubectl get secrets -n mongo my-user-password -o jsonpath={.data.password} | base64 -d; echo

You will notice that I am using base64 -d to decode the password, as all Kubernetes secrets are base 64 encoded. The password was automatically base 64 encoded by the create secret command because we used--from-literal.

Now we have a password, we can create a MonogoDB cluster and database with an admin user that has this password.

Create the manifest file:

k8s/my-mongo-db.yml

apiVersion: mongodbcommunity.mongodb.com/v1
kind: MongoDBCommunity
metadata:
name: my-mongo-db
namespace: mongo
spec:
members: 3
type: ReplicaSet
version: "7.0.11"
security:
authentication:
modes: ["SCRAM"]
users:
- name: my-user
db: admin
passwordSecretRef: # a reference to the secret that will be used to generate the user's password
name: my-user-password
key: password
roles:
- name: clusterAdmin
db: admin
- name: userAdminAnyDatabase
db: admin
scramCredentialsSecretName: my-user-scram
additionalMongodConfig:
storage.wiredTiger.engineConfig.journalCompressor: zlib

Note that this creates Persistent Volume Claims (PVCs) under the storageClass of standard. You should ensure that your Persistent Volume (PV) operator correctly creates the PV for this requested class. This is the case for Kind but others may need something like nfs-client. Your cluster will not start if these PVs are not available.

Now you can apply this with:

kubectl apply -f k8s/my-mongo-db.yml 

And check its progress with:

kubectl get pods -n mongo

You are waiting for 3 instances to be created. On my MacBook Pro, (M2 Max Apple silicon) using a 4-node Kind cluster, this took around 5 minutes to start all 3.

Once up and running, you can check the service is up with:

kubectl get svc -n mongo

Which should give you:

NAME              TYPE        CLUSTER-IP   EXTERNAL-IP   PORT(S)     AGE
my-mongo-db-svc ClusterIP None <none> 27017/TCP 6m

Testing your database

With our application, we are going to connect to the database directly from within Kubernetes using the DNS name for its service but, for testing purposes, we want to connect to it from our local development machine.

When I first tested this out, I used port forwarding to forward one of my MonogoDB pods to my local development machine, I received this error message when I tried to make any changes:

MongoServerError[NotWriteablePrimary]: not primary

This is because the pod I chose to port forward was not the primary for the cluster. Secondary pods are read-only copies and so all writes must go via the primary.

To avoid this problem, we need to connect to the primary.

Because we are outside the cluster, if you use a MongoDB client (such as Compass), it will try to find the primary pod using the Kubernetes pod names (eg: my-mongo-db-1.my-mongo-db-svc.mongo.svc.cluster.local) but will, of course, fail as these are not accessible externally.

You can find out which node is the primary by examining the logs of any of the nodes:

kubectl logs my-mongo-db-0 -n mongo -c mongod | grep "\"primary\":"

If this results in no results, you have hit the primary.

If you do get a result, even though this only pulls out a few lines, they are very long and hard to read. If you have a JSON pretty printer, such as jq, you can use this:

kubectl logs my-mongo-db-0 -n mongo -c mongod | grep "\"primary\":" | jq

You will then see a line, such as this:

...
"primary": "my-mongo-db-1.my-mongo-db-svc.mongo.svc.cluster.local:27017",
...

This tells you which pod you need to connect to (in my case: my-mongo-db-1). You can now port forward this pod using:

kubectl port-forward my-mongo-db-1 -n mongo 27017:27017

Note that you can connect via the other pods but you will be restricted to read-only commands. Also note that the primary can change.

Once this port forward is in place, we need to connect to the database. To do this we can use the MongoDB Compass client. You can download this client from https://www.mongodb.com/try/download/compass.

Once installed, you should be able to connect to your database. It will suggest a connection string (mongodb://localhost:27017) but we need to change a few settings.

Click Advanced Connection Options and click Direct connection (without this you will get an address not found as the client will try to use an internal Kubernetes address).

Click on the Authentication tab. Select Username/Password and enter the username (my-user) and password you chose earlier. Add Admin as the database and then select the SCRAM-SHA-256 authentication mechanism (scroll down if necessary).

Click Save and Connect, name your connection, and then you should be shown the Compass console connected to your database.

You will see it has created the admin, config and local databases within our cluster.

If you have got this far, it means that your MongoDB cluster is up and running.

Creating an application user

You may think that any application that will connect to our MongoDB will be able to use the my-user we created. Unfortunately, this is not the case as this user is really for maintenance of the database.

To allow an application to use our database cluster, we need to create a database and a user to access it.

At the bottom of the compass window, you will see a prompt for >_MONGOSH. Click on this to get to the command line.

We will now create our user with the following:

use aquarium
db.createUser( { user: "my-app-user",
pwd: "<password>",
roles: [ {db: "aquarium", role: "dbOwner"} ] } )

There are a few things to notice. The first is that we switch to a non-existent database (aquarium) before it is created. This aligns to the principle of not having to define anything before use. The database and any collections are created the first time you add a document.

The second is the role that is assigned to our new database. MongoDB has a small set of in-built roles that users can be given. In this case, dbOwner allows the user to read, write and administer the database. For production use, you will need to restrict your user appropriately.

You should get a long response that includes the following near the top:

...
ok: 1,
...

To check out this user, let’s open a new Compass connection. You can do this through the menus or, on a Mac, you can do Cmd N. Note that it can take a few seconds before it comes up with no indication that it is doing anything — so only press it once!

When the new connection window appears, I find it easier to duplicate the connection we saved earlier (use the menu beside the connection).

Change the username and password. You will also need to change the Authentication Database to aquarium. Then connect.

You should now be able to see your new aquarium database. You can test it out by creating a fishes collection by click + next to the name of the database. You can then add data in the form of a document, into the database, such as:

{
"_id": 123,
"fish": "Guppy"
}

Ok, so at this point we now have a MongoDB up and ready to use with our Spring Boot application.

Creating the Spring Boot application

When I create simple, database backed examples, I start with my aquarium application. The idea is, using REST APIs, you can create and manage fishes and fish tanks. You can then add your fishes in one of your fish tanks.

In this article I do not look at the feature that allows adding a fish to a fish tank as I will talk about relationships in another article.

Code

I do not intend to include the code here but it is available in the associated GitHub repository.

Dependencies

Starting a Spring Boot application is always easier with Spring Initializr at https://start.spring.io/. I assume you know how to use it.

For this project, add Spring Web and Spring Data MongoDB as dependencies and create the project.

I also include Lombok to remove the need for boilerplate code.

Package structure

Depending on the application I am building, I may choose to structure my packages based on component type (eg: controllers, services and repositories) or on the business domain.

As this is a small application with only two business domains (fishes and fish tanks), I will base this project on these domains and create the following:

fishes
FishController
FishService
FishRepository
fishtanks
FishTankController
FishTankService
FishTankRepository

You can see that I follow the standard layered approach with a Controller, Service and Repository layers.

API endpoints

The controllers provide Create, Read, Update and Delete (CRUD) endpoints for their respective API.

Entities and Documents

If you are familiar with JPA and a SQL databases like Postgres, you will be at home with entities and repositories.

In a No-SQL database, the tables are replaced with collections and rows within the table are replaced with documents.

This means our repositories are a little different for No-SQL databases than they are with SQL databases.

As a No-SQL database can handle any structure, entities (or documents) become simple Plain Old Java Objects (POJOs). This means that in our example application we can create entities like this:

aquarium/fishes/Fish.java

...

@Setter
@Getter
@Document("fishes")
@NoArgsConstructor
public class Fish {

@Id
public UUID id;

public String type;

public Fish(String type) {
this.id = UUID.randomUUID();
this.type = type;
}
...
}

There are a few things to note:

  1. Instead of defining an @Entity, we are now defining @Document which takes the name of our collection.
  2. I am using @Id, (this is optional as MongoDB will add its own if not supplied) rather than @mongoId so I can manage my own UUID Ids.
  3. I like to use Lombok (eg: @Getter) to remove some of the boiler plate code.

We can now create the fish tanks in a similar way:

aquarium/fishtanks/FishTank.java

@Setter
@Getter
@Document("fish tanks")
@NoArgsConstructor
public class FishTank {

@Id
public UUID id;

public String name;

public FishTank(String name) {
this.id = UUID.randomUUID();
this.name = name;
}

@Override
public String toString() {
return String.format(
"FishTank[id=%s, type='%s']",
id.toString(), name);
}
}

Repositories

Ok, so we have our documents, now how do we access them?

What I will show is the changes to our repositories. Take the fishes repository, for example:

...
public interface FishRepository extends MongoRepository<Fish, UUID> {

public List<Fish> findAll();

public Optional<Fish> findFirstById(UUID id);

public Optional<Fish> findFirstByType(String type);
}
...

You can see that it is almost identical to the types of repository that we would find with an SQL database. The only difference is that the interface is extending MongoRespository rather than CrudRepository.

I am leaving the subject of one-to-many and other mappings until another article so, for now, we will only be able to create and manage our fishes and fish tanks.

Application properties

Of course, when working with any database, you have to tell the application how to connect to it. We do that with our application properties in the same way we would with an SQL database.

I prefer to use YAML files for the Spring Boot properties file and so my configuration looks like this (remember to replace < > fields with your own values):

resources/application.yml

spring:
application:
name: aquarium-with-mongo-db

data:
mongodb:
host: localhost
port: 27017
database: aquarium
username: my-app-user
password: <password>

I will come back to this when I talk about profiles later on.

Controllers and services

We can now add our controllers and services in the way we would with an SQL database. I do not propose to show them here as they are available in the GitHub repository.

Test the application

Complete the code (or clone my repository) and then run the application within your IDE. If you are still port-forwarding to the primary, the application should start.

You can then use these curl commands to test it out:

curl localhost:8080/api/v1/fishes -H "Content-Type: application/json" -d '{"type": "guppy2"}' 
curl localhost:8080/api/v1/fish-tanks -H "Content-Type: application/json" -d '{"name": "big one"}'
curl localhost:8080/api/v1/fishes
curl localhost:8080/api/v1/fish-tanks

Now, if you go to your compass client and refresh your aquarium database, you should see two collections fishes and fish tanks. Inside these collections you should see the fishes and fish tanks you have created.

Final step

At this point we have a Spring Boot application connected to our MongoDB running in our Kubernetes cluster. We now need to complete the final step, which is to load our Spring Boot application into the Kubernetes cluster as well.

To do this we need to:

  1. Create a fat JAR file (which includes all dependencies)
  2. Create a Docker image from that JAR
  3. Upload the image to our Docker repository
  4. Create a Deployment manifest file
  5. Apply the Deployment manifest to the Kubernetes cluster

As I am using Kind, step 3 can be replaced with a simple load step, which avoids having to use a Docker repository.

Profiles

Before we create our JAR file, it is useful to create two Spring Boot profiles so we can run our application in connected mode (like we have so far) and within the Kubernetes cluster. We will create two Spring Boot profiles:

  • connected … used when we are running outside the cluster
  • local-cluster … used when we are running inside the cluster

The first is the mode we are currently running in. This means we can simply copy our application.yml (or application.properties) file to application-connected.yml. We can then add a JVM argument to our JVM command line:

-Dspring.profiles.active=connected

We do the same for the local-cluster file but this time we need to make a change.

...
data:
mongodb:
host: my-mongo-db-svc.mongo.svc.cluster.local
port: 27017
...

By using the DNS name, we will be connected to the correct pod. Note, due to the way DNS search rules are set up in the pod, it is possible to omit parts of the name, such as my-mongo-db-svc.mongo.svc. This allows you to deploy to different clusters and have the application still work.

Creating the image

Let’s now look at creating our image. As the project I have put in GitHub is a Gradle project, we can create a JAR file from within the root project folder using:

gradle build 

Note that the following has been added to the gradle.build to ensure the manifest points to the main application file:

gradle.build

jar {
manifest {
attributes "Main-Class": "com.requillion_solutions.aquarium.AquariumWithMongoDbApplication"
}
}

This creates a jar file: build/libs/aquarium-with-mongo-db-0.0.1-SNAPSHOT.jar.

To create a Docker image, we need a Docker file. Create the following:

Dockerfile

FROM openjdk:17.0.2-slim-buster
RUN addgroup --system spring && useradd --system spring -g spring
USER spring:spring
ARG JAR_FILE=build/libs/*.jar
COPY ${JAR_FILE} app.jar
ENTRYPOINT ["java","-jar","/app.jar"]
EXPOSE 8080

This starts with a Java 17 base image (this is required to avoid problems with Lombok) and then adds a new user (spring) so we do not run as root. The JAR file is then copied into the image and then an entrypoint is created to run the application.

Create the docker image with:

docker build -t aquarium .

And, if you are using Kind, load it directly into the Kubernetes cluster with:

kind load docker-image aquarium

Once that is done, we are ready to create the Deployment manifest to run it in the cluster.

Deployment manifest

Now we have the docker image loaded on our Kubernetes cluster, we can now deploy it with a Deployment manifest. Create the file:

k8s/deployment.yml

apiVersion: apps/v1
kind: Deployment
metadata:
name: aquarium
namespace: default
spec:
replicas: 1
selector:
matchLabels:
app: aquarium
template:
metadata:
labels:
app: aquarium
spec:
containers:
- name: aquarium
image: aquarium
imagePullPolicy: IfNotPresent
ports:
- containerPort: 8080
env:
# Note that the following environment variable is converted to a
# property override called spring.profiles.active when read by Spring
- name: SPRING_PROFILES_ACTIVE
value: local-cluster
---
apiVersion: v1
kind: Service
metadata:
name: aquarium
namespace: default
spec:
selector:
app: aquarium
type: NodePort
ports:
- port: 8080
targetPort: 8080
nodePort: 30080

There are a number of things to note:

  1. The application is deployed to the default namespace (the one used when it is not given)
  2. There is only 1 replica
  3. The image is only pulled if it is not present (which it is as we loaded it earlier)
  4. The profile is set to local-cluster
  5. A service is created to map application port 8080 and to port 30080 on the development machine

This can now be deployed with:

kubectl apply -f k8s/deployment.yml

Check that it has started successfully with:

kubectl get pods

Once this is deployed, the API can be tested with the same curl commands as we used earlier but with port 30080.

curl localhost:30080/api/v1/fishes -H "Content-Type: application/json" -d '{"type": "guppy2"}' 
curl localhost:30080/api/v1/fish-tanks -H "Content-Type: application/json" -d '{"name": "big one"}'
curl localhost:30080/api/v1/fishes
curl localhost:30080/api/v1/fish-tanks

You can also see the new documents in the Compass UI (remember to ensure your port forward is still in place).

Summary

This article has looked at my experience with installing MongoDB into a Kind Kubernetes cluster and then integrating it to a Spring Boot application.

The exercise was quite simple but only a demonstration of how to do it. In practice there would need to be work done on security, backups and failovers.

In other articles, I will also show how relationships between documents can be managed.

As a demonstration I hope it showed that a No-SQL database can be used quite simply with Kubernetes and Spring Boot.

I hope you enjoyed this article and that you have extended your skills by learning something new, even if it is something small.

If you found this article of interest, please give me a clap as that helps me identify what people find useful and what future articles I should write. If you have any suggestions, please add them as notes or responses.

--

--