Packaging Microservices: Do Helm Right To Help Escape Deployment Hell
Preface
Alright, let’s do this. In this article, I’m gonna walk through what’s packaging, how it was done in the past, and eventually land at the mess we’ve created for ourselves recently 🙃
What tools like helm does is help us sort out this mess/ or mesh, if you will… and give users or fellow devs a decent experience
Traditional packaging
So software was traditionally shipped as executable binaries, be it exe
s on windows you installed as a kid, or the elf binaries you chmod +x
and move to '/usr/local/bin
once you got cool 😎, they are all the same, with certain exceptions.
Wasn’t all sunshines and roses though..
There are two kinds of binaries, generally... statically linked and dynamically linked. We'll do another article digging through these, here's a meme to give you the general idea. And trust me, the difference shows up the moment you try running them on someone else’s machine, or containers
You'll see a lot of these repeating in our modern cloud native
applications as well ;)
Anyway, there’s one more thing we need to address before we proceed
Dynamic Languages 🥲
Of course, this is not the only reason docker exists, as server applications/ microservices have a bunch of configuration/ networking complexity too, we’ll get to that soon, just addressing a gripe on modern web dev here
A big reason containers even had to exist was the pain of distributing dynamic-language apps. With Python, Ruby, Node, etc., shipping an app meant shipping the right interpreter, the right version of pip/npm/gem packages, and matching system libraries. Suddenly, “works on my machine” became a universal curse. Tell me you haven’t encountered this…
Here’s how you would package typical services as docker images
Go HTTP (scratch)
FROM golang:1.22 AS builder
WORKDIR /app
COPY . .
RUN go build -o server main.go
FROM scratch
COPY --from=builder /app/server /
EXPOSE 8080
ENTRYPOINT ["/server"]
FastAPI (distroless)
FROM python:3.11-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --prefix=/install \
-r requirements.txt
FROM gcr.io/distroless/python3
WORKDIR /app
COPY --from=builder /install \
/usr/local
COPY . .
EXPOSE 8000
CMD ["main:app"]
You’d usually use distroless for dynamically linked binaries as well, e.g. rust
If most things were self-contained static binaries, a simple scp would’ve been enough — no container runtime, no dependency wrangling, just drop the binary and run. Docker filled that gap by wrapping dynamic runtimes, dependencies, and system packages into a portable unit. Things like distroless or slim
do help tone down the image sizes and security surface area, to make it feasible to run these services in the cloud
Deployment
Based on the deployment targets, you would start off by creating a deploy repo
, which will either house a bunch of docker-compose.yaml
's or if you are not trapped in a lefacy environment, a bunch of kubernetes resources that will then be hooked up to a CD
tool that pulls in the desired state when you make changes
This is good enough, if you have a single deployment target, or if your applications don’t need much in terms of external dependencies, e.g. if you are using cloud services like SQS, S3 etc
So why helm then?
As their site says… it’s a package manager for microservices, similar to what brew
, yay
or apt
are on the desktop. Let’s build a scenario and do it without helm first, in order to appreciate what we’re getting here
Scenario
=======
Cool, so let's take a typical SaaS situation where you'd need the following:
Functional reqs
- email verification
- jwt authentication
- proxy level authentication
- profile pic update
- authorized users to manage products
- simple product catalogue
- user cart / orders
- scheduled notifications
Non functional reqs
- cloud agnostic
- distributed DB shipped
- pub/sub broker shipped
- stateless applications
- proxy level auth / authz
- scalability
- encrypted secrets
- easy install
Okay, I’m getting ahead of myself 😅, let’s not worry about the actual code, just the services and the setups
Here’s what the architecture will look like
Essentials
Let’s get the essentials out of the way. Essentials are off the shelf services you would pick of artifacthub
or docker.io
, to run the core infrastructure of a cloud agnostic microservices system. You can skip this if you have the luxury to stick to a single cloud provider, as those would be served on a silver platter. But then again, if you are shipping something as an “installable”, or as an open source project that others could run locally, you’d usually ship these as well
We’ll get to how to include this in out shipped “package”, in the next part. Right now, I’ll show you what it’s like on the other side,… to just install stuff :)
Here are the essentials we’re going to pull
Pubsub broker (nats)
This is what the install section on artifacthub says
1
2
helm repo add bitnami https://charts.bitnami.com/bitnami
helm install nats bitnami/nats
Cool right? but we do need to tweak a few things, like so
1
2
3
4
5
6
helm install nats bitnami/nats \
--set jetstream.enabled=true \
--set cluster.enabled=true \
--set auth.enabled=false \
--set replicaCount=3 \
--set persistence.enabled=false
If this seems like a lot, try doing this with docker compose. It’s well and good until it’s a single container with some volume mounts and envs, trust me… gets a lot harder on real systems, unless you got something like this
note: helm does accept a values.yaml, for those ready to jump to comments, didn’t want to bring that up just yet
Distribued database (Cruncy Postgres Operator)
1
helm install pgo oci://registry.developers.crunchydata.com/crunchydata/pgo
This ones a little weird, could be daunting, cuz there’s both a helm install and a kubectl apply, out of the box. We’ll see why and simplify this in our chart subsequently
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
kubectl apply -f -- <<EOF
apiVersion: postgres-operator.crunchydata.com/v1beta1
kind: PostgresCluster
metadata:
name: db
spec:
service:
type: NodePort
instances:
- dataVolumeClaimSpec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 5Gi
name: postgres
replicas: 3
port: 5432
postgresVersion: 16
patroni:
dynamicConfiguration:
postgresql:
parameters:
max_connections: "500"
proxy:
pgBouncer:
service:
type: NodePort
port: 5432
replicas: 2
EOF
If you feel like it, go ahead and use a standalone postgres instead
1 helm install postgresql bitnami/postgresql --version 16.7.26Refer artifacthub for configuration options
Object store
We need to upload our profile pictures, remember? Relational databases are not designed to store arbitrary files like this. That’s where an object store comes in. Usually you’d just use AWS S3 or Google’s GCS, but we’re being cloud agnostic here, so I’m going with MinIO
1
2
helm repo add bitnami https://charts.bitnami.com/bitnami
helm install minio bitnami/minio --version
That’s it, we’re ready to code… We could always add more stuff like key value stores, vector databases, etc… but we don’t need those for our use case
Services
Each of out service will be a simple go service with minimal logic, only payload deserialization where needed
If you are one of the I’m devops, I don’t look at code folks, skip to the next section :)
Let’s look at them one by one. We’ll keep it simple yet reasonably structured, instead of cramming everything into a single main.go
, keeping track of the external dependencies and config envs as we go
Here’s the standard structure, with the conf, handlers, state and spec under pkg
, and our binary’s main under cmd
1
2
3
4
5
6
7
8
9
10
├── cmd
│ └── main.go
├── config.env
├── go.mod
├── go.sum
└── pkg
├── conf.go
├── handlers.go
├── spec.go
└── state.go
Environment variables
Don’t worry, these are my local credentials and are rotated frequently
LISTEN_PORT=3000
CONN_TIMEOUT=10
DATABASE_URL=postgresql://db:bjw9GPJ%5D2%5EM2Ynw;myNiny%7B%7C@localhost:31562/db
NATS_BROKER_URL=nats://localhost:30042
1
2
3
4
5
6
type Settings struct{
ListenPort int `env:"LISTEN_PORT"`
ConnTimeout int `env:"CONN_TIMEOUT"`
DatabaseUrl string `env:"DATABASE_URL"`
NatsBrokerUrl string `env:"NATS_BROKER_URL"`
}
Dependencies
- Database, i.e. postgres
- Broker, i.e. nats - so that we can communicate with our notification service to send emails
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
func NewState() (*AppState, error){
ctx, done := context.WithTimeout(context.Background(), time.Millisecond*time.Duration(settings.ConnTimeout))
defer done()
dbPool, err := pgxpool.New(ctx, settings.DatabaseUrl)
if err != nil{
return nil, fmt.Errorf("DB connection failed: %v", err)
}
nc, err := nats.Connect(settings.NatsBrokerUrl)
if err != nil{
return nil, fmt.Errorf("Nats connection failed: %v", err)
}
js, err := jetstream.New(nc)
if err != nil{
return nil, fmt.Errorf("ERR-NATS-JS: %v", err)
}
streamConfig := jetstream.StreamConfig{
Name: "messages",
Subjects: []string{"msg.>"},
}
stream, err := js.CreateOrUpdateStream(ctx, streamConfig)
return &AppState{DBPool: dbPool, Nc: nc, Stream: stream}, nil
}
Endpoints
The auth service will expose three endpoints, one lets our users create new session, and two internal endpoints we’d be using in our proxy auth for verifying said session
1
2
3
http.HandleFunc("POST /auth/login", NewSession)
http.HandleFunc("POST /authn", Authenticate)
http.HandleFunc("POST /authz", Authorize)
Similarly, our other services would host the necessary endpoint like so
Products Simple CRUD on products
1
2
3
4
5
http.HandleFunc("POST /product", CreateProduct)
http.HandleFunc("GET /product", ListProducts)
http.HandleFunc("GET /product/{id}", GetProduct)
http.HandleFunc("PUT /product/{id}", UpdateProduct)
http.HandleFunc("DELETE /product/{id}", DeleteProduct)
The list endpoint would be for anyone to use, whereas only authorized users can perform the rest
Orders
1
2
3
http.HandleFunc("POST /order", CreateOrder)
http.HandleFunc("GET /order", ListOrders)
http.HandleFunc("GET /order/{id}", GetOrder)
These are authenticated endpoints, and performs these actions for the currently logged in user
Notifications
This service won’t have any http endpoints, instead, will consumer messages producted at msg.>
and send notifications accordingly, refer nats docs
Again, if you are only interested in the packaging aspects, you can skip over this bit and proceed to the next section For the rest, I’ve tried to cover at a high level here, you can always look at the examples here
Ingress
Setting up our charts
superpowers - lib charts
parent charts/ dependencies
operator nuances
conclusion
`