Skip to content
VickTor61Public

About

Rails 7 app built VM-era style, then migrated to Docker and Kubernetes end-to-end: multi-stage builds, web/worker/cron workload split, health checks, config/secret separation, and one-shot migration jobs.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

kygo

A Rails 7.0.8 application built to look like a pre-container, VM-hosted system, then migrated to Docker and Kubernetes. It exists to practice and demonstrate that migration path end to end: containerizing a legacy Rails app, splitting it into web/worker/cron workloads, and running it on Kubernetes with proper health checks, config/secret separation, and one-shot database migrations.

This is a fixture, not a production system. The "before" state (VM-era configuration, no traffic, no real users) was written deliberately so the "after" state could be built and verified against something concrete. See Project background for the reasoning and the exact boundary of what this project does and doesn't demonstrate.

Stack

  • Ruby on Rails 7.0.8, Sprockets, PostgreSQL, Redis
  • Sidekiq for background jobs, Active Storage (disk-backed) for file uploads
  • Docker (multi-stage build) and Docker Compose
  • Kubernetes (kind for local development), plain manifests under k8s/

Getting started

Two ways to run this, depending on what you're trying to exercise.

Docker Compose (containerized, no Kubernetes)

docker compose up --build -d
docker compose run --rm web bin/rails db:migrate
curl http://localhost:3000/health

Tear down with docker compose down (add -v to also remove the database/storage volumes).

Kubernetes via kind

Requires kind, kubectl, and optionally k9s for an interactive dashboard:

brew install kind kubectl k9s

Bring the cluster up and deploy everything:

kind create cluster --name kygo

docker build -t kygo:dev .
kind load docker-image kygo:dev --name kygo

kubectl create secret generic kygo-secrets \
  --from-literal=SECRET_KEY_BASE="$(docker run --rm kygo:dev bin/rails secret)" \
  --from-literal=DATABASE_URL=postgres://kygo:kygo@postgres:5432/kygo_production

kubectl apply -k k8s/overlays/local

kubectl logs job/migrate

Check status and reach the app:

kubectl get pods
kubectl port-forward svc/web 3000:3000   # then, in another shell:
curl http://localhost:3000/health

Tear down with kind delete cluster --name kygo.

Note: kind clusters run as a single Docker container and do not survive a Docker Desktop restart. If kubectl get nodes returns a connection error, the cluster is gone — re-run the steps above from kind create cluster (the Secret must always be recreated imperatively; a Job's spec is immutable, so re-running migrations after a rebuild requires kubectl delete job migrate first).

Project layout

app/                    Rails application code
config/                 Rails configuration, plus VM-era reference files (see below)
db/migrate/              Schema migrations
k8s/                     Kubernetes manifests, structured with Kustomize
  base/                   Shared resources: ConfigMap, Postgres, Redis, web, worker, migration Job, CronJob
  overlays/local/         Local-dev patch (web replicas: 1) — what ArgoCD is currently pointed at
  overlays/production/    Production-shaped patch (web replicas: 3), not deployed anywhere in this project
puppet/manifests/        VM-era reference file (see below)
Dockerfile               Multi-stage build: build stage compiles native extensions, final stage is runtime-only
docker-compose.yml        Postgres + Redis + web + worker, for local container testing

Project background

This app was written in the shape of a pre-Kubernetes Rails deployment on purpose, to have a concrete "before" to migrate from:

  • config/god.rb — a God process-supervisor config for Puma and Sidekiq. Not installed or run in this project; kept as a reference for what a container runtime's restart policy and liveness probes replace.
  • config/deploy.rb — a Capistrano deploy script. Not run; kept as a reference for what a Docker build + GitOps sync replaces.
  • config/schedule.rb — a whenever cron schedule. Not run; kept as a reference for what a Kubernetes CronJob replaces.
  • puppet/manifests/app.pp — a Puppet manifest stub. Not run; kept as a reference for what container images replace.

None of the above are wired into any executable path in this repository. They exist purely as artifacts to point at when discussing what a VM-to-Kubernetes migration actually changes.

The commit history is intentionally structured as a migration narrative: an initial commit representing the VM-era "before" state, followed by commits that containerize the app and then move it onto Kubernetes.

Scope boundary: this project demonstrates the mechanics of a containerization and Kubernetes migration — Dockerfile design, health checks, workload separation, config/secret handling, one-shot migration jobs. It does not demonstrate the operational side of migrating a live system with real traffic: safe cutover, rollback strategy, or decommissioning existing infrastructure. Those are a different (and, in practice, usually harder) problem than the one this repository solves.

VM era → Kubernetes: what actually changed

The table below maps each VM-era artifact in this repo to what replaced it, and where in the repo to point at for each side.

VM era (this repo's "before") Kubernetes (this repo's "after")
config/god.rb supervising Puma + Sidekiq Deployment replicas, container restart policy, liveness probes (k8s/base/web.yaml, k8s/base/worker.yaml)
config/deploy.rb (Capistrano cap deploy) Docker image build + git commit; ArgoCD syncs the cluster to match git (argocd/application.yaml)
config/schedule.rb (whenever cron syntax) CronJob (k8s/base/cron.yaml)
puppet/manifests/app.pp (config management) The Docker image itself — the build is the environment definition now (Dockerfile)
One process on one VM doing everything Three workloads off one image, differing only in command: web (Puma), worker (Sidekiq), cron (CronJob)
Uploads on local disk (storage/, single VM) Shared PersistentVolumeClaim across pods — itself flagged below as a kind-only stand-in for real object storage
Manually editing config on the box ConfigMap for non-secret env vars (k8s/base/configmap.yaml)
.env / secrets.yml written to disk on the VM Secret, created imperatively, never committed to git — see the note on sealed-secrets below
SSH in and run RAILS_ENV=production rails db:migrate One-shot Job (k8s/base/migrate-job.yaml), run once per deploy, not on pod boot
One fixed environment (prod on the VM) k8s/base/ + Kustomize overlays (overlays/local, overlays/production) — same manifests, environment-specific patches
"Who deployed what, when" lives in shell history / memory git log on the manifests repo is the deploy history; commit SHA is the image tag

Two follow-up questions this table doesn't answer on its own, worth having ready:

  • Who bumps the image tag after a build? CI (GitHub Actions) builds the image, pushes it to GHCR tagged with the commit SHA, then commits that tag into k8s/base/*.yaml itself (.github/workflows/deploy.yml). ArgoCD only ever syncs manifests — it has no opinion about image builds.
  • What about the Secret sitting unencrypted outside git? Sealed-secrets or SOPS solves this by encrypting the Secret so the ciphertext can live in git safely and only the cluster can decrypt it. Deliberately not built here — evaluated and scoped out, see IDEA.md for the related idea of building a minimal secrets manager instead of adopting one, to understand the mechanism rather than just the tool.

Insights from building this

A few things surfaced while building this that were more instructive than anything planned up front — noted here because they're the kind of detail that's easy to state abstractly but only really sinks in after hitting it.

A shared filesystem between pods is not "free" the way it is in Docker Compose, and the obvious fix doesn't generalize. docker-compose.yml shares a single named volume between web and worker, so Active Storage's local-disk uploads just work — any container can read what any other container wrote. Kubernetes has no equivalent by default: web and worker are separate pods with separate, ephemeral filesystems. Uploading a file through web and having worker try to process it (a background ThumbnailJob generating an image variant) reliably failed with ActiveStorage::FileNotFoundError, because the file only ever existed on web's own disk.

The fix that comes to mind first — a shared PersistentVolumeClaim mounted into both Deployments — does work, and is what this repository currently uses. But it only works because this cluster is single-node (kind). A PersistentVolumeClaim with the default access mode (ReadWriteOnce) can only be mounted by pods scheduled onto the same node; on a real multi-node cluster, web and worker landing on different nodes would break this exact setup the same way local disk did originally. Most cloud block storage (the thing a PersistentVolume is usually backed by in production) doesn't support the access mode (ReadWriteMany) that would actually fix this properly across nodes. The real fix is to stop using local/block storage for this kind of shared, multi-writer state at all, and move Active Storage onto an object storage backend (S3-compatible; MinIO for a self-hosted equivalent) — which was deliberately not done here, to keep this project's infrastructure dependencies local-only.

The practical takeaway: the correctness of "pods can share files" in Kubernetes depends entirely on cluster topology, not just on whether a volume is attached. A fix that looks complete in local testing can be silently node-count-dependent.

A health check passing doesn't mean the feature it's protecting works. /health only executes SELECT 1 against Postgres — it never touches application tables. That was deliberate (a health check that queries real business data is usually the wrong kind of health check, since it couples liveness to data state), but it meant web's liveness and readiness probes stayed green through an entire period where every migration-dependent feature (/documents) was returning 500 PG::UndefinedTable, because migrations for a fresh Postgres pod hadn't been re-run yet. The lesson generalizes: a passing probe is evidence the process is alive and can reach its declared dependency, not evidence the application is actually functional end to end.

About

Rails 7 app built VM-era style, then migrated to Docker and Kubernetes end-to-end: multi-stage builds, web/worker/cron workload split, health checks, config/secret separation, and one-shot migration jobs.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages