Stage 7 · Master
Phase 6 — Flat Service
Deployment
Shipping a service with its migration as separate steps
Reuse the Established Image Contract
Organization Service already taught the multi-stage, non-root image. Flat Service adopts that reviewed contract rather than reprinting it with a different binary name. The pull request must show only the service path, binary, UID, and port substitutions; reviewers can therefore see immediately that no new container behavior is being introduced.
-COPY services/organization ./services/organization
+COPY services/flat-service ./services/flat-service
-RUN go work init ./services/organization ./pkg/config ./pkg/logging ./pkg/apperr
+RUN go work init ./services/flat-service
-WORKDIR /src/services/organization
+WORKDIR /src/services/flat-service
-RUN go build -o /out/organization ./cmd/api
+RUN go build -o /out/flat-service ./cmd/api
-COPY --from=build /out/organization /usr/local/bin/organization
+COPY --from=build /out/flat-service /usr/local/bin/flat-service
-EXPOSE 8080
+EXPOSE 8081
-ENTRYPOINT ["/usr/local/bin/organization"]
+ENTRYPOINT ["/usr/local/bin/flat-service"]The base stages, module cache, and non-root runtime stay inherited from the reviewed Phase 2/3 Dockerfile pattern; only the binary path and private port change.
Separate Process Liveness From Database Readiness
The full observability story — metrics, tracing, structured health checks with dependency status — belongs to its own later module. Kubernetes still needs something to probe today, so this lesson adds the smallest honest health check: it proves the process can reach its own database, nothing more.
package http
import (
"context"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/jackc/pgx/v5/pgxpool"
)
func NewLivenessHandler() gin.HandlerFunc {
return func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "up"})
}
}
func NewReadinessHandler(pool *pgxpool.Pool) gin.HandlerFunc {
return func(c *gin.Context) {
ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
defer cancel()
if err := pool.Ping(ctx); err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"status": "down", "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "up"})
}
}-func NewRouter(h *FlatHandler) *gin.Engine {
+func NewRouter(h *FlatHandler, pool *pgxpool.Pool) *gin.Engine {
r := gin.New()
r.Use(gin.Recovery())
+ r.GET("/health/live", NewLivenessHandler())
+ r.GET("/health/ready", NewReadinessHandler(pool))-router := httphandler.NewRouter(httphandler.NewFlatHandler(svc))
+router := httphandler.NewRouter(httphandler.NewFlatHandler(svc), pool)Apply the Existing Migration Contract Without Reprinting It
Organization and User services already established the dedicated migration Job and wait-before-rollout contract. Flat Service reuses it with FLAT_DATABASE_URL and its own migration image. The new deployment question is different: how quickly should Kubernetes stop a rollout whose database-aware readiness probe never becomes healthy?
spec:
+ progressDeadlineSeconds: 180
template:
spec:
containers:
- name: flat-service
+ readinessProbe:
+ httpGet: { path: /health/ready, port: 8081 }
+ periodSeconds: 5
+ failureThreshold: 3
+ livenessProbe:
+ httpGet: { path: /health/live, port: 8081 }
+ periodSeconds: 15The baseline internal Service and Deployment are not repeated. progressDeadlineSeconds turns a permanently unready build into a failed rollout, while split probes stop a database outage from restarting an otherwise healthy process.
Readiness Failure Rehearsal
A broken database URL should make readiness fail within three probe periods and the rollout fail within progressDeadlineSeconds. Liveness stays green so Kubernetes does not hide configuration drift behind restarts.
Applied exercise
Make the readiness probe fail closed during a bad deploy
A rollout ships a build where the Postgres connection string env var was renamed but the Deployment manifest was not updated. The process starts, the health check pings a pool that was never configured, and Kubernetes currently has no signal to stop the rollout.
- Reproduce locally: start the container with an intentionally wrong FLAT_DATABASE_URL.
- Confirm /health/ready returns 503 while /health/live remains 200.
- Add a
readinessProbe.failureThresholdandprogressDeadlineSecondsto the Deployment so Kubernetes halts the rollout instead of cycling replicas indefinitely, and document the values chosen.
Deliverable
An updated deployment.yaml with explicit failure thresholds and a one-paragraph note on the values chosen and why.
Completion checks
- Readiness against the broken container returns 503 while liveness remains 200.
- The Deployment manifest specifies failureThreshold and progressDeadlineSeconds explicitly, not relying on cluster defaults.
- The note explains the trade-off between failing fast and tolerating a slow-starting database connection.
Why does the migration run as a separate Kubernetes Job instead of inside flat-service's main.go on startup?
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.