diff --git a/cmd/instance/main.go b/cmd/instance/main.go index 9b2ea5f..77c2da0 100644 --- a/cmd/instance/main.go +++ b/cmd/instance/main.go @@ -159,7 +159,7 @@ func main() { primaryEndpoint := os.Getenv("PRIMARY_ENDPOINT") endpoint := instanceEndpoint(podName, cluster, int32(shardOrdinal), namespace) restartedPrimaryAsStandby, err := prepareRestartedPrimaryAsStandby( - dataDir, primaryEndpoint, binDir, podName, memberCount, logger, + dataDir, primaryEndpoint, endpoint, binDir, podName, memberCount, logger, ) if err != nil { logger.Error("Failed to prepare restarted former primary as standby", @@ -477,7 +477,7 @@ func parsePodOrdinalOrDie(podName string) int { } func prepareRestartedPrimaryAsStandby( - dataDir, primaryEndpoint, binDir, applicationName string, + dataDir, primaryEndpoint, selfEndpoint, binDir, applicationName string, memberCount int, logger *slog.Logger, ) (bool, error) { @@ -487,6 +487,7 @@ func prepareRestartedPrimaryAsStandby( prepared, err := supervise.PrepareRestartedPrimaryAsStandbyWithRewind(context.Background(), supervise.RejoinOptions{ DataDir: dataDir, PrimaryEndpoint: primaryEndpoint, + SelfEndpoint: selfEndpoint, ApplicationName: applicationName, BinDir: binDir, BasebackupOnRewindFailure: true, diff --git a/cmd/instance/main_test.go b/cmd/instance/main_test.go index 719f5d4..c25bbf6 100644 --- a/cmd/instance/main_test.go +++ b/cmd/instance/main_test.go @@ -50,6 +50,7 @@ func TestPrepareRestartedPrimaryAsStandby_UsesCurrentPrimaryEndpointForAnyOrdina prepared, err := prepareRestartedPrimaryAsStandby( dir, "demo-shard-0-2.demo-shard-0-headless.ns1.svc.cluster.local:5432", + "demo-shard-0-0.demo-shard-0-headless.ns1.svc.cluster.local:5432", // selfEndpoint ≠ primary → standby 화 동작 유지 "", "demo-shard-0-0", 2, diff --git a/internal/instance/supervise/standby.go b/internal/instance/supervise/standby.go index 0a59d8a..3a9a92e 100644 --- a/internal/instance/supervise/standby.go +++ b/internal/instance/supervise/standby.go @@ -73,6 +73,12 @@ type RejoinOptions struct { BinDir string Runner CommandRunner BasebackupOnRewindFailure bool + // SelfEndpoint 는 본 인스턴스 자신의 endpoint(host:port). PrimaryEndpoint 가 + // SelfEndpoint 와 같으면 operator 가 *본 노드를 primary 로 지정*한 것이므로 + // standby 화(pg_rewind to self → connection refused 무한 crashloop)를 skip 한다. + // INC-2026-06-23: 죽은 옛 primary 가 PRIMARY_ENDPOINT 에 남아 standby 화 시도 → + // DNS 실패 crashloop 했고, 수동 marker 제거로만 복구되던 라이브 사고의 코드 fix. + SelfEndpoint string } // IsStandby 는 dataDir 안에 standby.signal 파일이 존재하는지 검사한다. @@ -138,6 +144,18 @@ func PrepareRestartedPrimaryAsStandbyWithRewind(ctx context.Context, opts Rejoin } return false, fmt.Errorf("stat %s: %w", marker, err) } + // INC-2026-06-23 guard: operator 가 PRIMARY_ENDPOINT 를 *본 노드 자신* 으로 + // 지정했다면 본 노드가 primary 다. marker 가 남아있어도 standby 화(pg_rewind to + // self → "connection refused" 무한 crashloop)를 하면 안 된다. marker 를 정리하고 + // primary 로 부팅하도록 skip. (죽은 옛 primary endpoint 가 PRIMARY_ENDPOINT 에 + // stale 하게 남아 standby 화 시도 → DNS 실패 crashloop 한 라이브 사고의 코드 fix — + // 그때 수동 marker 제거로만 복구됐다.) + if opts.SelfEndpoint != "" && opts.PrimaryEndpoint == opts.SelfEndpoint { + if err := os.Remove(marker); err != nil && !errors.Is(err, os.ErrNotExist) { + return false, fmt.Errorf("remove self-primary marker %s: %w", marker, err) + } + return false, nil + } if opts.PrimaryEndpoint == "" { return false, errors.New("primaryEndpoint must not be empty") } diff --git a/internal/instance/supervise/standby_test.go b/internal/instance/supervise/standby_test.go index dfd1a34..1b2bc98 100644 --- a/internal/instance/supervise/standby_test.go +++ b/internal/instance/supervise/standby_test.go @@ -73,6 +73,36 @@ func TestPrepareRestartedPrimaryAsStandby_NoMarker(t *testing.T) { } } +// TestPrepareRestartedPrimaryAsStandby_SelfPrimarySkips 는 INC-2026-06-23 회귀 가드: +// PrimaryEndpoint 가 SelfEndpoint 와 같으면(operator 가 본 노드를 primary 로 지정) +// marker 가 있어도 standby 화를 skip 하고 marker 를 제거해야 한다 (self pg_rewind → +// connection refused 무한 crashloop 차단). 본 guard 제거 시 본 테스트 실패. +func TestPrepareRestartedPrimaryAsStandby_SelfPrimarySkips(t *testing.T) { + dir := t.TempDir() + marker := filepath.Join(dir, RestartPrimaryAsStandbyMarker) + if err := os.WriteFile(marker, []byte("1"), 0o600); err != nil { + t.Fatalf("write marker: %v", err) + } + self := "postgres-prod-shard-0-0.svc.cluster.local:5432" + prepared, err := PrepareRestartedPrimaryAsStandbyWithRewind(context.Background(), RejoinOptions{ + DataDir: dir, + PrimaryEndpoint: self, // operator 가 본 노드를 primary 로 지정 + SelfEndpoint: self, + }) + if err != nil { + t.Fatalf("PrepareRestartedPrimaryAsStandbyWithRewind: %v", err) + } + if prepared { + t.Fatal("prepared = true; self-primary 는 standby 화 skip(false) 해야") + } + if _, err := os.Stat(marker); !os.IsNotExist(err) { + t.Errorf("marker 가 self-primary skip 후 잔존(stat err=%v) — 제거 기대", err) + } + if _, err := os.Stat(filepath.Join(dir, "standby.signal")); !os.IsNotExist(err) { + t.Error("standby.signal 이 self-primary 인데 생성됨 — primary 부팅 차단") + } +} + func TestPrepareRestartedPrimaryAsStandby_ConfiguresStandby(t *testing.T) { dir := t.TempDir() marker := filepath.Join(dir, RestartPrimaryAsStandbyMarker)