Stage 7 · Master
Phase 8 — Maintenance Service
Deployment
One image, two entrypoints, and a scheduled job that is defense-in-depth, not the guarantee
Two Binaries, One Docker Image
maintenance-service ships two Go binaries from the same source tree: the long-running API server (cmd/api) and the one-shot billing-runner batch job (cmd/billing-runner). A single multi-stage Dockerfile with two named build stages and two final targets keeps both binaries built from the exact same dependency graph and Go version, so there is never a version-skew question between 'the server that receives payments' and 'the job that generates invoices'.
# syntax=docker/dockerfile:1
FROM golang:1.26-alpine AS build
WORKDIR /src
COPY go.work go.work.sum ./
COPY services/maintenance-service ./services/maintenance-service
COPY pkg ./pkg
RUN go build -o /out/api ./services/maintenance-service/cmd/api
RUN go build -o /out/billing-runner ./services/maintenance-service/cmd/billing-runner
FROM alpine:3.19 AS api
RUN adduser -D -u 10001 appuser
COPY --from=build /out/api /usr/local/bin/api
USER appuser
EXPOSE 8083
ENTRYPOINT ["/usr/local/bin/api"]
FROM alpine:3.19 AS billing-runner
RUN adduser -D -u 10001 appuser
COPY --from=build /out/billing-runner /usr/local/bin/billing-runner
USER appuser
ENTRYPOINT ["/usr/local/bin/billing-runner"]docker build --target selects which final stage to produce — the same Dockerfile yields a long-running server image or a batch-job image with no duplicated build logic between them.
Reuse the API Deployment; Add Only the Batch Workload
The long-running API reuses the internal Deployment and Service contract already established by earlier services. Reprinting replicas, selectors, ports, and probes would add no knowledge. Maintenance's unique deployment problem is the second executable: a monthly billing runner that must finish, fail visibly, and remain safe when operators rerun it.
Scheduled Billing: Defense-in-Depth, Not the Guarantee
It is tempting to read a monthly CronJob and conclude that 'billing only runs once a month' is what keeps invoices from duplicating. That is backwards. The unique (tenant_id, flat_id, billing_period) index and ON CONFLICT DO NOTHING built in the Transactions lesson are what actually make reruns safe — the CronJob schedule and Kubernetes's own restart-avoidance settings below are a second layer that reduces how often reruns happen, not the reason reruns are harmless when they do happen.
apiVersion: batch/v1
kind: CronJob
metadata:
name: maintenance-billing-runner
spec:
schedule: "0 2 1 * *" # 02:00 UTC on the 1st of every month
concurrencyPolicy: Forbid # a still-running previous invocation blocks a new one from starting
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 3
jobTemplate:
spec:
backoffLimit: 0 # a failed run is investigated, not blindly retried by Kubernetes
template:
spec:
restartPolicy: Never
containers:
- name: billing-runner
image: hoa-platform/maintenance-service:latest
command: ["/usr/local/bin/billing-runner"]
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: maintenance-service-db
key: url
- name: FLAT_SERVICE_URL
value: "http://flat-service:8081"
- name: RESIDENT_SERVICE_URL
value: "http://resident-service:8082"
- name: BILLING_TENANT_ID
valueFrom:
secretKeyRef:
name: maintenance-billing-tenant
key: tenant-idconcurrencyPolicy: Forbid and backoffLimit: 0 together mean a failed or overlapping run does not silently retry into a pile-up of concurrent billing-runner processes — a human is expected to look at why it failed and rerun it deliberately, at which point the idempotent-insert guarantee is what makes that safe.
Flat Service already established the dedicated migration Job and wait-before-rollout sequence. Maintenance applies that unchanged contract with its own image, Secret, and migration ConfigMap. This lesson therefore spends its code budget on the new monthly CronJob, two build targets, and rerun semantics instead of printing the same Job again.
Rehearsing the Monthly Runner Contract
Kubernetes can avoid overlap and expose failed jobs, but it cannot make billing correct. Correctness still lives in StartRun, CreateIfAbsent, and the invoices unique index from the Transactions lesson.
Applied exercise
Add a /healthz endpoint that reports database connectivity
The API image is buildable before this exercise, but it still lacks a maintenance-specific health route. Add it now so future local and Kubernetes checks exercise real database reachability instead of trusting process startup.
- Add a HealthHandler with a Check method that runs pool.Ping(ctx) with a short timeout (2s) and returns 200 if it succeeds, 503 with the error message if it does not.
- Wire GET /healthz to it in cmd/api/main.go before the other routes.
- After adding the route, confirm a future readinessProbe would mark a pod unready if the database becomes unreachable by manually stopping the local Postgres container and curling /healthz.
- Ensure the billing-runner binary does NOT expose or need this endpoint — it is not a server.
Deliverable
A working GET /healthz endpoint on cmd/api that reflects real database connectivity, verified manually against both a healthy and an intentionally stopped database.
Completion checks
- curl -s -o /dev/null -w '%{http_code}\n' localhost:8083/healthz returns 200 against a healthy database.
- The same command returns 503 once the local Postgres container is stopped.
- go build ./services/maintenance-service/... still succeeds for both cmd/api and cmd/billing-runner.
What actually guarantees a rerun of the monthly CronJob does not create duplicate invoices?
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.