From 4c31e9bc57d4882ec27912dfb6792bb08023c991 Mon Sep 17 00:00:00 2001 From: Support Date: Tue, 23 Jun 2026 09:33:54 +0900 Subject: [PATCH] =?UTF-8?q?fix(instance):=20self-primary=EB=A9=B4=20standb?= =?UTF-8?q?y=ED=99=94=20skip=20(PRIMARY=5FENDPOINT=3D=3Dself=20crashloop?= =?UTF-8?q?=20=EC=B0=A8=EB=8B=A8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit INC-2026-06-23 postgres-prod 7일 다운 라이브 차단점: operator status가 죽은 옛 primary(shard-0-1)를 primary로 stale 인식 → PRIMARY_ENDPOINT=죽은노드 → 모든 노드가 PrepareRestartedPrimaryAsStandbyWithRewind 에서 죽은 노드로 pg_rewind 시도 → DNS/connection 실패 무한 crashloop. 실제 primary(0-0)조차 PRIMARY_ENDPOINT(=자기 or 죽은노드) 보고 자기를 standby화하려다 self-rewind connection-refused crashloop. Fix: RejoinOptions.SelfEndpoint 추가 + PrimaryEndpoint==SelfEndpoint면 operator가 본 노드를 primary로 지정한 것이므로 standby화 skip + marker 제거. 라이브에서 수동 marker(.keiailab-restart-primary-as-standby)+standby.signal 제거로만 복구되던 것을 코드 자동화. 회귀테스트 TestPrepareRestartedPrimaryAsStandby_SelfPrimarySkips PASS, go build/vet/test ok. 관련: #205/#220 reseed + postmaster.pid(#276) + RBAC(#277) 동일 INC 체인의 라이브 차단점 최종 fix. Co-Authored-By: Claude Opus 4.8 (1M context) --- cmd/instance/main.go | 5 ++-- cmd/instance/main_test.go | 1 + internal/instance/supervise/standby.go | 18 +++++++++++++ internal/instance/supervise/standby_test.go | 30 +++++++++++++++++++++ 4 files changed, 52 insertions(+), 2 deletions(-) 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)