How I Designed Selective Service Deployment with Jenkins and Kubernetes
Introduction
This post follows How I Turned Omnilude's Backend from One Monolith into MSA with AI. If the previous post explained why I split service boundaries, this one looks at how those boundaries turned into actual deployment units.
Talk about MSA often stops at how many services there are. But the moment operations actually start to feel different is not when the folders change. It is when the deployment unit changes.
In this post, I want to summarize what problems the current structure reduced, how it works, and what is still missing.
Why Deployment Separation Was More Tangible Than Code Separation
When everything lived in one large backend, the hardest part was not simply that features were mixed together. It was that even a small change kept turning into pressure to redeploy the whole system.
Even if I touched only blog functionality, I still had to rebuild the whole application, rebuild the image, and push the same large bundle again. In that kind of structure, the areas that change often lose experimentation speed first. A bigger problem was that services that need long-lived connections were dragged into the same deployment blast radius.
For example, a service like backbone-service, which stays close to WebSocket, SSE, and event streams, immediately feels worse in production if it is shaken by the same pressure as ordinary feature changes. So for me, service separation was less about who owns which API and more about what can be deployed independently.
How Jenkins Chooses Deployment Targets
I run the services on Kubernetes, and Jenkins handles CI/CD.
This post focuses less on implementation detail and more on how I cut deployment boundaries from a CI/CD perspective.
In the current pipeline, deployment starts with BUILD_MODE and per-service boolean parameters. With all, I can deploy everything. In auto, I can either deploy only the services I check manually or let the pipeline detect targets from Git changes when nothing is selected. In other words, I can do full or partial deployment manually, and automatic triggers can narrow the target set from changed files.
The selectable services are auth-service, backbone-service, storage-service, ai-service, game-service, blog-service, and legacy-service. The repository is still one repository, but the deployment targets are chosen from the start at the service level.
The auto-detection logic is also simple. It looks at git diff and maps changed files under modules/{service} back to services. There is one important exception. If the shared module common(jspring) changes, the pipeline treats it as a change to the common foundation and rebuilds every service. That rule keeps changes in common from being hidden behind partial deployment.
I think this point matters a lot. Selective deployment is attractive because it means only what is needed, quickly. But if you underestimate changes to shared contracts, it can become more dangerous instead of safer.
Artifacts Are Split Per Service
The actual build flow follows the same idea. I first publish common to local Maven, then build only the selected services through bootJar in sequence. Choosing sequential builds instead of parallel ones was a practical decision. It was more stable to reduce Q class generation issues and Kotlin daemon conflicts.
Image builds run inside a kaniko container. This step is also sequential instead of parallel. Parallel builds may look more impressive on paper, but at Omnilude's current stage it fit better to reduce resource conflicts and make failure points easier to read.
Image names are mapped separately by service. For example, auth-service becomes jongmin-auth, ai-service becomes jongmin-ai, and blog-service becomes jongmin-blog. That makes the deployment unit readable in the registry as well, and it becomes easier to trace a specific service later.
Folder Structure Matches Operational Units
If Jenkins chooses what to build, Kubernetes fixes that result into actual operational units. The current layout is kubernetes/services/{service}/{dev|prd}. Each service owns its own Deployment and Service, and ingress is managed separately by environment under ingress/{env}.
The advantage of this structure is that it is easy to explain. If you want to see how a service is deployed, you can look at that service's folder. Because the code boundary and the manifest boundary largely match, ownership is less blurry from an operational point of view too.
Routing follows the same idea. Under dev-api.omnilude.com and api.omnilude.com, each service is connected by path prefix. Service separation is not just a concept inside the repository. It extends all the way to API ownership and ingress routing.
Basic stability devices also enter here. Each service Deployment already includes readinessProbe and livenessProbe, and some production services also have HPA, PDB, and topology spread constraint. Not every service has the same operational guardrails yet, but at least I can now manage separately how far each service has gone and which stability devices have already been applied.
Keep the Deployment Flow Simple
The current deployment flow is more linear than it looks.
- Jenkins decides the deployment targets for this run.
- Only the selected services are built and turned into images.
kubectl applyreflects each service manifest.kubectl rollout restartrolls the new version out.kubectl rollout statuswaits until each service comes up.- Start, success, and failure states are sent to Slack.
What matters here is readability more than sophistication. At this stage, I would rather have a flow where anyone can clearly tell what was deployed, where it failed, and what it is waiting for. More complex deployment strategies can be added later, but if the current structure is hard to explain, operations can become less stable instead of more stable.
What Changed After Applying It
The biggest difference after introducing this selective deployment structure was that a small change no longer forced a full redeployment. ai-service, where experimentation speed matters, blog-service, where I want to refine product functionality quickly, and relatively independent game-side work can now move at different tempos.
Another gain is explainability. If someone asks how Omnilude is operated today, I can now explain the service list, image names, Kubernetes folders, ingress paths, and Slack notification flow in one continuous line. Even in a personal project, I think this kind of explainability matters. Operations should not stop at merely working. The reason they are split this way should also be understandable if the next round of improvements is going to continue.
That said, the current structure is not complete. automatic rollback, post-deploy smoke tests, and metric-based deployment gates are still missing. At the moment, the center of gravity is still rollout checks and Slack summaries.
Closing
The current structure is not a giant CI/CD platform, but at least the principle of one repository, split deployment by service is connected consistently from Jenkins parameters to image names, Kubernetes folders, ingress paths, and Slack notifications.
The next step is clear. I need to add real guardrails on top of this structure, such as automatic rollback, smoke tests, and better operational alerts. That part is still an ongoing task, so I will write about it in a later post once the actual implementation is done.