| 항목 | 내용 |
|---|---|
| 프로젝트 | OPA (Open Policy Agent) |
| 관련 릴리스 | v1.18.0 (2026-06-25) |
| 관련 PR | open-policy-agent/opa#8784 — runtime: Restore goautomaxprocs, add automemlimit (되돌린 대상: #8696 — Remove automaxprocs dependency) |
| 발견 출처 | CHANGELOG.md v1.18.0 항목 |
| 검증 근거 | PR #8784/#8696의 실제 설명과 diff(v1/runtime/runtime.go) |
OPA v1.18.0에서, 서버 프로세스가 자신이 돌고 있는 컨테이너의 CPU/메모리 제한(cgroup)을 인지해서 Go 런타임의 GOMAXPROCS/GOMEMLIMIT를 자동으로 맞춰 설정하는 동작이 강화됐다. 흥미로운 점은 이게 "새 기능 추가"가 아니라, 한 릴리스 전에 제거했던 동작을 실제 운영 문제 때문에 되돌리고, 그 김에 메모리 쪽까지 같은 원리로 확장한 사례라는 것이다.
Go 프로세스는 기본적으로 GOMAXPROCS(동시에 실행될 OS 스레드/고루틴 스케줄러 수)를 호스트 머신의 논리 CPU 개수로 설정한다. 문제는 쿠버네티스 같은 환경에서 컨테이너에 CPU 0.5개(500m)만 할당해도, 컨테이너 안에서 실행되는 Go 프로세스 입장에서는 여전히 호스트 전체의 CPU 코어 수(예: 8개)가 보이기 때문에 GOMAXPROCS를 그만큼 큰 값으로 잡아버린다는 점이다. 실제로 쓸 수 있는 CPU는 0.5개뿐인데 스케줄러가 8개 스레드를 굴리려고 하면, 컨텍스트 스위칭 오버헤드와 스레드별 스택/GC 관련 메모리 사용이 불필요하게 늘어난다.
이 문제를 풀기 위해 uber-go/automaxprocs 같은 서드파티 라이브러리가 프로세스 시작 시 cgroup의 CPU quota를 직접 읽어 GOMAXPROCS를 실제 할당량에 맞게 낮춰주는 관행이 생겼고, Prometheus·Istio·Thanos 같은 여러 CNCF 프로젝트가 이 라이브러리를 채택했다. OPA도 오랫동안 이 라이브러리를 써왔다.
Go 1.25부터 표준 런타임 자체가 cgroup CPU quota를 인식하는 기능을 네이티브로 갖추게 됐다. OPA는 이를 근거로 #8696에서 automaxprocs 의존성을 제거했다 — "이제 표준 라이브러리가 하는 일을 서드파티 의존성에 맡길 이유가 없다"는 합리적인 판단이었다. 이때 유일하게 언급된 차이점은 "Go 네이티브 구현은 최소값이 2, automaxprocs는 최소값이 1"이라는 것이었고, 이 차이가 그렇게 중요하지 않을 거라 판단했다.
[변경 전 — #8696 이후, v1.17.x]
호스트: 8 vCPU, 컨테이너 CPU limit: 0.5 (500m)
┌──────────────────────────┐
│ Go 1.25 네이티브 cgroup 인지 │ GOMAXPROCS = max(quota 기반 계산, 최소값 2)
│ (최소값 2로 하드코딩됨) │ ─────────────────────────────────────────▶ 실제 0.5보다 과한 동시성
└──────────────────────────┘
automaxprocs 제거로 최소값이 1→2로 올라감 → 메모리 제약 배포에서 OOM 발생
하지만 실제 운영에서는 이 "최소 1 vs 최소 2" 차이가 무시할 수 없는 문제였다. PR #8784는 "메모리가 제약된 일부 OPA 배포 환경에서, 2개 프로세스가 도는 게 이전보다 메모리를 더 써서 OOM이 발생했다"고 명시한다. CPU를 아주 적게 할당한 컨테이너일수록 "최소 1개짜리 스레드로 돌던 것"이 "최소 2개짜리 스레드로 강제"되는 셈이라, 스레드 수만큼 늘어나는 GC worker/스택 메모리 오버헤드가 저사양 배포에서 실제로 OOM을 유발한 것이다.
왜 표준 라이브러리를 계속 믿지 않고 서드파티 의존성을 다시 받아들였는가: "표준 기능이 서드파티 라이브러리를 대체할 수 있다"는 판단은 이론적으로는 맞았지만, 실측(OOM 리포트)으로 반증됐다. PR #8784는 이 판단을 뒤집으면서 "직접 설정하는 사용자에게는 영향이 없고, 대부분의 경우에 Go 기본값보다 더 합리적인 기본값을 제공하려는 것"이라고 명시한다 — 즉 이론적 우아함보다 실제 배포 환경에서 검증된 동작을 우선시하는 선택이다.
왜 CPU 문제를 고치면서 메모리(GOMEMLIMIT)까지 같이 확장했는가: 근본 원인이 같다 — Go의 GC는 기본적으로 호스트 전체 메모리를 기준으로 판단하기 때문에, cgroup으로 메모리를 제한한 컨테이너에서는 실제 한도보다 늦게 GC가 개입해 OOMKill을 당할 위험이 있다. automemlimit으로 cgroup 메모리 제한을 읽어 GOMEMLIMIT을 미리 설정해두면 OOMKill 전에 GC가 먼저 개입할 여지가 생긴다. PR은 이 조합(automaxprocs + automemlimit)이 Prometheus, Istio, Grafana Alloy, Thanos, kube-state-metrics, SpiceDB 등 여러 CNCF 프로젝트에서 이미 쓰이는 조합이라는 점을 근거로 든다 — 직접 새로운 방식을 발명하지 않고, 이미 여러 데이터 집약적 프로젝트에서 검증된 패턴을 그대로 채택한 것이다.
[변경 후 — #8784, v1.18.0]
┌───────────────┐ cgroup CPU quota ┌─────────────────────┐
│ automaxprocs │ ──────────────────▶ │ GOMAXPROCS (최소 1) │
└───────────────┘ └─────────────────────┘
┌───────────────┐ cgroup memory 제한 ┌─────────────────────┐
│ automemlimit │ ──────────────────▶ │ GOMEMLIMIT (신규) │
└───────────────┘ └─────────────────────┘
둘 중 하나라도 설정 실패 시 Debug 로그만 남기고 서버 기동은 계속 진행
왜 설정 실패를 서버 기동을 막는 에러가 아니라 Debug 로그로만 남겼는가: 실제 코드(maxprocs.Set/memlimit.SetGoMemLimitWithOpts 호출 결과)를 보면 두 호출 모두 에러가 나도 rt.logger.Debug로만 기록하고 그대로 진행한다. 이 자동 설정은 "더 나은 기본값을 제공하려는" 보조 기능이지 필수 전제조건이 아니므로, cgroup 정보를 못 읽는 환경(예: cgroup이 없는 베어메탈)에서도 OPA 서버 자체는 정상적으로 뜨는 쪽을 택한 것이다.
- Go 표준 런타임의 cgroup 인지 기능이 앞으로 최소값 1을 지원하게 되면,
automaxprocs의존성을 다시 제거할 계기가 될까? automemlimit이 설정한GOMEMLIMIT이 OPA의 인메모리 정책/데이터 저장 방식(대용량 bundle 로드 시)과 충돌해 오히려 너무 자주 GC가 도는 상황은 없을까?- 실패를 Debug 레벨 로그로만 남기는 지금 방식이, 운영자가 "자동 설정이 실제로 적용됐는지"를 놓치기 쉬운 설계는 아닐지?
- open-policy-agent/opa#8784 — runtime: Restore goautomaxprocs, add automemlimit
- open-policy-agent/opa#8696 — Remove automaxprocs dependency
- OPA CHANGELOG.md — v1.18.0 항목
본문 참고 자료로 충분했다.