Files
seon cd8fbdddab feat(harness): hold recovery while a Grafana alert is firing, add legacy:busy host resolution
キャンペーン第 2 回のグレーゾーン 3 件を受けた変更。SLO 3 系列に現れない症状
(レプリケーション一時停止、メモリ圧迫)でも、受信中のアラートが解決するまで
recovered を出さないよう、AlertRouter が firing 中のアラートを保持し judge が
alerts_ok を条件に加える。legacy:busy は直近 2 分で最も多くリクエストを受けた
MIG インスタンス(Mimir topk)を指し、run+シナリオ単位でキャッシュして revert が
同じインスタンスに当たるようにする(ILB のハッシュで api の接続が 1 台に偏る)。
2026-10-08 19:20:35 +09:00

209 lines
6.7 KiB
Go

package main
import (
"context"
"fmt"
"sort"
"strconv"
"strings"
"time"
"google.golang.org/api/compute/v1"
)
// GCP calls use the VM service account through ADC (metadata server); no key files (Terraform design 6.11).
func (a *App) computeSvc(ctx context.Context) (*compute.Service, error) {
if a.cfg.GCPProject == "" {
return nil, fmt.Errorf("GCP_PROJECT not set")
}
return compute.NewService(ctx)
}
type gceInstance struct {
Name, Zone, IP, Status string
}
func (a *App) gcpInstances(ctx context.Context, prefix string) ([]gceInstance, error) {
svc, err := a.computeSvc(ctx)
if err != nil {
return nil, err
}
var out []gceInstance
err = svc.Instances.AggregatedList(a.cfg.GCPProject).Filter(fmt.Sprintf("name eq ^%s.*", prefix)).Pages(ctx, func(l *compute.InstanceAggregatedList) error {
for _, s := range l.Items {
for _, in := range s.Instances {
ip := ""
if len(in.NetworkInterfaces) > 0 {
ip = in.NetworkInterfaces[0].NetworkIP
}
z := in.Zone[strings.LastIndex(in.Zone, "/")+1:]
out = append(out, gceInstance{Name: in.Name, Zone: z, IP: ip, Status: in.Status})
}
}
return nil
})
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out, err
}
func (a *App) gcpListInstances(ctx context.Context, prefix string) ([]string, error) {
l, err := a.gcpInstances(ctx, prefix)
if err != nil {
return nil, err
}
names := make([]string, 0, len(l))
for _, i := range l {
names = append(names, i.Name+"("+i.Status+")")
}
return names, nil
}
// resolveInstance turns "legacy:N" (N-th MIG instance by name) or a pet VM short name (db-primary -> selfheal-db-primary) into an instance.
func (a *App) resolveInstance(ctx context.Context, ref string) (gceInstance, error) {
if ref == "legacy:busy" {
return a.resolveBusyLegacy(ctx)
}
if strings.HasPrefix(ref, "legacy:") {
n, _ := strconv.Atoi(strings.TrimPrefix(ref, "legacy:"))
l, err := a.gcpInstances(ctx, "legacy-")
if err != nil {
return gceInstance{}, err
}
if n < 0 || n >= len(l) {
return gceInstance{}, fmt.Errorf("legacy:%d out of range (%d instances)", n, len(l))
}
return l[n], nil
}
name := ref
if !strings.HasPrefix(name, "selfheal-") {
name = "selfheal-" + name
}
l, err := a.gcpInstances(ctx, name)
if err != nil {
return gceInstance{}, err
}
for _, i := range l {
if i.Name == name {
return i, nil
}
}
return gceInstance{}, fmt.Errorf("instance %s not found", name)
}
// waitOp polls a zone / region / global operation until DONE (scope = zone name, "region:<name>" or "").
func (a *App) waitOp(ctx context.Context, svc *compute.Service, scope string, op *compute.Operation) error {
for i := 0; i < 150; i++ {
var cur *compute.Operation
var err error
switch {
case strings.HasPrefix(scope, "region:"):
cur, err = svc.RegionOperations.Get(a.cfg.GCPProject, strings.TrimPrefix(scope, "region:"), op.Name).Context(ctx).Do()
case scope != "":
cur, err = svc.ZoneOperations.Get(a.cfg.GCPProject, scope, op.Name).Context(ctx).Do()
default:
cur, err = svc.GlobalOperations.Get(a.cfg.GCPProject, op.Name).Context(ctx).Do()
}
if err != nil {
return err
}
if cur.Status == "DONE" {
if cur.Error != nil && len(cur.Error.Errors) > 0 {
return fmt.Errorf("operation %s: %s", cur.Name, cur.Error.Errors[0].Message)
}
return nil
}
time.Sleep(2 * time.Second)
}
return fmt.Errorf("operation %s timed out", op.Name)
}
// gcpOp executes one catalogue/injection operation. instance is "legacy:N" or a pet VM short name; mig is the MIG name.
func (a *App) gcpOp(ctx context.Context, op, instance, mig string, size int64) (map[string]any, error) {
svc, err := a.computeSvc(ctx)
if err != nil {
return nil, err
}
res := map[string]any{"op": op}
switch op {
case "instance_delete", "instance_stop", "instance_start", "instance_reset":
in, err := a.resolveInstance(ctx, instance)
if err != nil {
return nil, err
}
res["instance"], res["zone"] = in.Name, in.Zone
var o *compute.Operation
switch op {
case "instance_delete":
o, err = svc.Instances.Delete(a.cfg.GCPProject, in.Zone, in.Name).Context(ctx).Do()
case "instance_stop":
o, err = svc.Instances.Stop(a.cfg.GCPProject, in.Zone, in.Name).Context(ctx).Do()
case "instance_start":
o, err = svc.Instances.Start(a.cfg.GCPProject, in.Zone, in.Name).Context(ctx).Do()
case "instance_reset":
o, err = svc.Instances.Reset(a.cfg.GCPProject, in.Zone, in.Name).Context(ctx).Do()
}
if err != nil {
return nil, err
}
return res, a.waitOp(ctx, svc, in.Zone, o)
case "mig_recreate":
in, err := a.resolveInstance(ctx, instance)
if err != nil {
return nil, err
}
res["instance"] = in.Name
req := &compute.RegionInstanceGroupManagersRecreateRequest{Instances: []string{fmt.Sprintf("zones/%s/instances/%s", in.Zone, in.Name)}}
region := a.region(in.Zone)
o, err := svc.RegionInstanceGroupManagers.RecreateInstances(a.cfg.GCPProject, region, mig, req).Context(ctx).Do()
if err != nil {
return nil, err
}
return res, a.waitOp(ctx, svc, "region:"+region, o)
case "mig_resize":
region := a.regionOfMIG()
res["mig"], res["size"] = mig, size
o, err := svc.RegionInstanceGroupManagers.Resize(a.cfg.GCPProject, region, mig, size).Context(ctx).Do()
if err != nil {
return nil, err
}
return res, a.waitOp(ctx, svc, "region:"+region, o)
}
return nil, fmt.Errorf("unknown gcp op %q", op)
}
func (a *App) region(zone string) string { return zone[:strings.LastIndex(zone, "-")] }
func (a *App) regionOfMIG() string { return env("GCP_REGION", "asia-northeast1") }
// resolveBusyLegacy picks the MIG instance that served the most legacy-api requests over the last 2 minutes. The ILB
// hashes the api pods' few keep-alive connections onto one instance (campaign 2, 2026-10-08: legacy:0 served 0 rps while
// the delay was on it), so a fault must go where the traffic is. The choice is cached per run+scenario so the revert
// hits the same instance after the traffic has moved away from it.
func (a *App) resolveBusyLegacy(ctx context.Context) (gceInstance, error) {
key := ""
if cur := a.runs.current(); cur != nil {
key = cur.RunID + "/" + cur.ScenarioID
}
if key != "" {
if v, ok := a.busyLegacy.Load(key); ok {
return v.(gceInstance), nil
}
}
name, err := a.mimirTopInstance(ctx, `topk(1, sum by (instance) (rate(legacy_http_requests_total{route!="healthz"}[2m])))`)
if err != nil {
return gceInstance{}, fmt.Errorf("legacy:busy: %w", err)
}
l, err := a.gcpInstances(ctx, "legacy-")
if err != nil {
return gceInstance{}, err
}
for _, i := range l {
if i.Name == name {
if key != "" {
a.busyLegacy.Store(key, i)
}
return i, nil
}
}
return gceInstance{}, fmt.Errorf("legacy:busy: instance %q from Mimir not in the MIG (%d instances)", name, len(l))
}