feat: add legacy-api service

실험·앱 계층 설계서 3.2의 legacy-api(VM 계층). /healthz(프로세스 생존),
/v1/price/{item}(db-primary app.price 조회, DB 불가 시 503), /metrics(Prometheus).
Go 1.27.1, pgx v5.11.0, client_golang v1.24.1. 이미지는 Cloud Build로 Artifact Registry에
올린다(MIG 인스턴스가 VM SA로 pull).
This commit is contained in:
seon committed 2026-09-23 23:35:10 +09:00
commit 7186ef0c49
5 files changed
+221

No files matched your search

+112
View File
@@ -0,0 +1,112 @@
// legacy-api: the "VM layer" service of the self-healing lab (experiment design 3.2).
// GET /healthz process liveness only (MIG autohealing + ILB health check)
// GET /v1/price/{item} reads app.price on db-primary (503 when the DB is unreachable)
// GET /metrics Prometheus exposition
package main
import (
"context"
"database/sql"
"encoding/json"
"log"
"net/http"
"os"
"strconv"
"strings"
"time"
_ "github.com/jackc/pgx/v5/stdlib"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
var (
reqTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "legacy_http_requests_total", Help: "HTTP requests by route and status",
}, []string{"route", "status"})
reqDur = prometheus.NewHistogramVec(prometheus.HistogramOpts{
Name: "legacy_http_request_duration_seconds", Help: "HTTP request latency",
Buckets: []float64{.005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5},
}, []string{"route"})
)
type statusWriter struct {
http.ResponseWriter
code int
}
func (w *statusWriter) WriteHeader(c int) { w.code = c; w.ResponseWriter.WriteHeader(c) }
func instrument(route string, h http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
sw := &statusWriter{ResponseWriter: w, code: 200}
start := time.Now()
h(sw, r)
reqDur.WithLabelValues(route).Observe(time.Since(start).Seconds())
reqTotal.WithLabelValues(route, strconv.Itoa(sw.code)).Inc()
}
}
func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(v)
}
func main() {
prometheus.MustRegister(reqTotal, reqDur)
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
dsn := os.Getenv("DB_DSN")
var db *sql.DB
if dsn != "" {
var err error
if db, err = sql.Open("pgx", dsn); err != nil {
log.Printf("db open: %v", err)
db = nil
} else {
db.SetMaxOpenConns(8)
db.SetConnMaxLifetime(5 * time.Minute)
}
} else {
log.Printf("DB_DSN not set: /v1/price will answer 503")
}
mux := http.NewServeMux()
mux.HandleFunc("/healthz", instrument("healthz", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
_, _ = w.Write([]byte("ok\n"))
}))
mux.HandleFunc("/v1/price/", instrument("price", func(w http.ResponseWriter, r *http.Request) {
item := strings.TrimPrefix(r.URL.Path, "/v1/price/")
if item == "" || strings.Contains(item, "/") {
writeJSON(w, 404, map[string]string{"error": "item required"})
return
}
if db == nil {
writeJSON(w, 503, map[string]string{"error": "db unavailable"})
return
}
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
var price float64
var updated time.Time
err := db.QueryRowContext(ctx, "SELECT price, updated_at FROM app.price WHERE item = $1", item).Scan(&price, &updated)
switch {
case err == sql.ErrNoRows:
writeJSON(w, 404, map[string]string{"error": "unknown item", "item": item})
case err != nil:
log.Printf("price query: %v", err)
writeJSON(w, 503, map[string]string{"error": "db unavailable"})
default:
writeJSON(w, 200, map[string]any{"item": item, "price": price, "updated_at": updated.UTC().Format(time.RFC3339)})
}
}))
mux.Handle("/metrics", promhttp.Handler())
srv := &http.Server{Addr: ":" + port, Handler: mux, ReadHeaderTimeout: 5 * time.Second}
log.Printf("legacy-api listening on :%s", port)
log.Fatal(srv.ListenAndServe())
}