PHP 8.4/8.5 + php-fpm 환경에서 Nextcloud를 운영하던 중, OPcache를 켜면 php-fpm 워커가 coredump를 남기며 죽는 문제를 만났다.
처음엔 OPcache가 범인인 줄 알았지만, 디버그 빌드로 coredump 백트레이스를 떠보니 진범은 전혀 다른 곳에 있었다.
이 글은 coredump 발생·캡처 → gdb 백트레이스 추출 → 해석 → 설정 적용까지 전체 과정을 그대로 정리한 것이다.

증상

PHP 8.4/8.5 + php-fpm + nginx 환경에서 Nextcloud를 운영 중이었다.
opcache.enable=1 이면 php-fpm 워커가 무작위 시점에 죽으면서 502 에러가 발생된다.
opcache.enable=0 으로 opcache를 사용하지 않도록 설정하면 정상적으로 서비스된다.

여기까지만 보면 "OPcache 버그"로 단정하기 쉽다. 실제로 검색해보면 PHP 8.4 + OPcache segfault 리포트가 꽤 많아서 더 그렇게 보인다.
하지만 죽는지는 추측만으로 알 수 없다. 죽는 순간의 콜스택을 봐야 한다. 그리고 PHP에서 opcache를 포기하기에는 낭비가 심하다고 생각한다. 그래서 coredump를 발생시켜 정확한 원인을 찾아보려한다..

아래는 syslog에서 php-fpm에서 발생하는 오류 내용

1단계 — coredump를 발생시키고 캡처

php-fpm은 기본 상태에서 워커가 죽어도 core 파일을 남기지 않는 경우가 많다. core dump가 떨어지도록 아래 세 부분을 설정한다.

(1) php-fpm 풀 설정에서 core 크기 제한 해제

풀 설정(www.conf 또는 자신의 경로의 풀 conf)에 추가:

; php-fpm pool config
rlimit_core = unlimited

(2) systemd 서비스의 LimitCORE 해제

systemd로 php-fpm을 돌린다면 유닛 레벨 제한도 풀어야 한다. drop-in으로 추가하는 게 깔끔하다.

sudo systemctl edit php-fpm
[Service]
LimitCORE=infinity
sudo systemctl daemon-reload
sudo systemctl restart php-fpm

(3) 커널 core_pattern 확인

core 파일이 어디로 가는지는 커널의 core_pattern이 결정한다.

cat /proc/sys/kernel/core_pattern

출력이 |/usr/lib/systemd/systemd-coredump ... 같은 형태면 systemd-coredump가 받아서 coredumpctl로 관리된다(이 경우가 가장 편하다). 

만약 직접 파일로 받고 싶다면 아래와 같이 설정한다.

# 예: /var/coredumps 아래로 떨어뜨리기
sudo mkdir -p /var/coredumps && sudo chmod 1777 /var/coredumps
sudo sysctl -w kernel.core_pattern='/var/coredumps/core.%e.%p.%t'

주의: php-fpm 워커는 보통 비특권 유저(www-data 등)로 실행된다. 파일 경로 방식이면 실행하는 유저가 쓸 수 있는 디렉터리 권한을 가지고 있어야한다. systemd-coredump 방식은 이 권한 문제를 알아서 처리해주므로 더 권장한다.

(4) 디버그 심볼 — 중요!!

여기서 가장 중요한 포인트. 심볼이 없으면 백트레이스가 ?? () 로 함수가 표시되지 않아 해석이 불가능하다.
PHP 8.4/8.5 OPcache segfault 리포트들이 진척이 안 됐던 이유 대부분이 opcache.so가 strip되어 심볼이 없어서이다.

두 가지 선택지가 있다.
배포판 패키지 사용자는 debuginfo 패키지를 설치한다.

# RHEL / Rocky
sudo dnf debuginfo-install php-fpm php-opcache

# Debian / Ubuntu (dbgsym 저장소 활성화 필요)
sudo apt install php8.4-fpm-dbgsym php8.4-opcache-dbgsym

소스 빌드 사용자--enable-debug로 빌드한다. 나는 홈랩이라 소스를 직접 컴파일했고, 진단을 위해 디버그 빌드를 썼다.

./configure --enable-debug ...   # 그 외 필요한 옵션
make -j$(nproc) && make install

--enable-debug는 두 가지를 동시에 켠다.
디버그 심볼(-g, strip 안 함) → 백트레이스에 함수명·파일·라인이 찍힘
ZEND_DEBUG assertion → 엔진 내부 정합성 검사가 살아남
이 두 번째가 결과적으로 결정타였다. 뒤에서 다시 설명한다.

(5) 재현

설정을 적용하고 php-fpm을 재시작한 뒤, 죽던 동작(Nextcloud 페이지 접근, occ, cron.php 등)을 다시 수행한다. 워커가 죽으면 core가 떨어진다.

# systemd-coredump 방식이면 목록 확인
coredumpctl list php-fpm

 

2단계 — gdb로 백트레이스 뽑기

systemd-coredump를 쓰는 경우:

# 최근 php-fpm 크래시 정보
coredumpctl info php-fpm

# 바로 gdb로 진입
coredumpctl gdb php-fpm

또는 core 파일을 추출해서 직접 열기:

coredumpctl dump php-fpm --output=/tmp/php-fpm.core
gdb /path/to/php-fpm /tmp/php-fpm.core

파일 패턴으로 직접 받은 경우:

gdb /path/to/php-fpm /var/coredumps/core.php-fpm.12345.1700000000

gdb 안에서 백트레이스를 뽑는다.

(gdb) bt
(gdb) bt full              # 로컬 변수까지
(gdb) thread apply all bt  # 멀티스레드면 전체 스레드

여기서 내가 얻은 백트레이스(요약):

#5  __libc_message_impl (... "glibc: assert")
#7  __assert_fail (assertion="ce->ce_flags & (1 << 12)",
                   file=".../Zend/zend_lazy_objects.c", line=582)
#8  zend_lazy_object_init (obj=0x...9ee0)
#9  zend_std_read_property (... cache_slot=0x74e288872d28 ...)
...
#22 zif_array_map (...)
...
#28 zend_lazy_object_init (obj=0x...3180)   <- 바깥쪽 lazy init
#29 zend_std_read_property (...)
...
#36 main (...) at .../sapi/fpm/fpm/fpm_main.c

 

3단계 — 백트레이스 해석

백트레이스를 위에서부터 읽으면서 몇 가지를 즉시 알 수 있다.

(a) 이건 SIGSEGV가 아니라 SIGABRT다

#5 __libc_message_impl ("glibc: assert"), #7 __assert_fail.
즉 segfault(SIGSEGV, signal 11)가 아니라 assertion 실패 → abort()(SIGABRT, signal 6)다.
무작정 메모리를 잘못 짚어 죽은 게 아니라, 엔진이 "이건 일어나면 안 되는 상태"라고 스스로 검출하고 멈춘 것이다. 디버그 빌드(ZEND_DEBUG)였기 때문에 이 검사가 살아 있었다.

프로덕션(non-debug) 빌드였다면 이 assertion은 컴파일되지 않으므로 같은 불일치가 조용히 넘어가거나 나중에 엉뚱한 SIGSEGV로 터졌을 것이다. 디버그 빌드 덕에 "정확히 어디가 깨졌는지"가 드러났다.

(b) 깨진 위치: PHP 8.4/8.5 Lazy Objects

zend_lazy_objects.c:582
assertion: ce->ce_flags & (1 << 12)

zend_lazy_objects.cPHP 8.4/8.5에서 새로 들어온 Lazy Objects 기능의 파일이다.
ce_flags & (1 << 12)는 "이 클래스 엔트리(ce)가 lazy object 기구에 참여 가능한 클래스"임을 나타내는 플래그 검사다.
이게 실패했다는 건 — lazy 초기화를 하려는 시점에 그 객체의 클래스 엔트리가 이미 해제됐거나 메모리가 재사용되어 플래그가 사라졌다는 뜻. 전형적인 use-after-free(UAF)다.

(c) 중첩된 lazy 초기화

스택을 보면 zend_lazy_object_init두 번 등장한다.

#28 zend_lazy_object_init   <- 바깥쪽 객체 lazy init 시작
#22 zif_array_map           <- 그 초기화 도중 array_map 실행
...
#8  zend_lazy_object_init   <- array_map 콜백 안에서 또 lazy init -> 여기서 abort

즉 lazy 객체를 초기화하는 도중에 또 다른 lazy 초기화가 재진입하고, 그 과정에서 객체/클래스가 해제되면서 바깥쪽이 들고 있던 ce가 dangling이 됐다.

(d) OPcache의 역할 — 원인이 아니라 "노출 조건"

#9 zend_std_read_property (... cache_slot=0x... )에서 cache_slotOPcache의 inline cache다.
OPcache를 켜면 클래스가 SHM에 immutable로 공유 저장되고, 프로퍼티 접근마다 이 캐시 슬롯에 클래스 엔트리 포인터가 캐싱된다.
재진입 중 객체가 해제돼도 이 슬롯이 stale한 ce 포인터를 그대로 들고 있다가 역참조하면서 불일치가 드러난다.

그래서 OPcache를 끄면 클래스 엔트리의 생명주기·메모리 레이아웃이 달라져서 그 dangling 윈도우가 안 생기거나 다른 메모리로 가기 때문에 증상이 사라진다.
OPcache는 버그가 아니라 버그를 트리거·노출시키는 조건일 뿐이다. 그러니 "OPcache를 끈다"는 건 임시방편이지 해결이 아니다.

(e) 알려진 PHP 버그와의 대조

zend_lazy_objects.c:582의 동일 assertion(ce->ce_flags & (1 << 12))은 PHP 본체에 이미 보고된 버그 클래스다(php-src 이슈에서 fuzzer가 발견, 한 차례 수정됨).
하지만 같은 라인의 lazy object UAF는 릴리스마다 새로운 트리거 경로로 계속 재발하고 있다. 즉 array_map 콜백 안의 중첩 lazy init은 기존 수정이 막지 못한 또 다른 경로로 보인다 — 이건 PHP 엔진 쪽에서 더 손봐야 할 진짜 버그다.

정리하면 사슬은 이렇다.

누군가 lazy object를 만든다
  -> 초기화 클로저 안에서 array_map으로 의존성을 resolve
  -> resolve가 또 lazy object를 중첩 생성
  -> 그 와중에 객체/클래스 해제 -> ce dangling
  -> OPcache inline cache가 stale ce를 잡고 역참조
  -> ZEND_DEBUG assertion 실패 -> abort (프로덕션이면 SIGSEGV)

남은 질문은 하나. 누가 lazy object를 만드는가?

 

4단계 — 범인 추적: 누가 lazy object를 호출하는지 확인

lazy object는 ReflectionClass::newLazyGhost() / newLazyProxy()로 명시적으로 만들어진다. Nextcloud 코드 전체에서 grep한다.

grep -rn "newLazyGhost\|newLazyProxy\|setLazyGhostObjectEnabled" \
  /path/to/nextcloud/3rdparty \
  /path/to/nextcloud/lib \
  /path/to/nextcloud/apps

결과는 단 한 곳이었다.

lib/private/AppFramework/Utility/SimpleContainer.php:68:  return $class->newLazyGhost(...)

SimpleContainerNextcloud 코어의 DI(의존성 주입) 컨테이너이다.
즉 서드파티 앱이 아니라 코어가, 거의 모든 서비스 resolve 경로에서 lazy object를 쓰고 있었다. 그래서 빈번하게 죽었던 것으로 생각된다.

해당 함수의 핵심 분기(요지):

public static bool $useLazyObjects = false;  // 기본값 false

private function buildClass(ReflectionClass $class, array $chain): object {
    $constructor = $class->getConstructor();
    if ($constructor === null) {
        return $class->newInstance();
    }

    if (PHP_VERSION_ID >= 80400 && self::$useLazyObjects && !$class->isInternal()) {
        // 문제의 lazy ghost 경로
        return $class->newLazyGhost(function (object $object) use ($constructor, $chain): void {
            $object->__construct(
                ...$this->buildClassConstructorParameters($constructor, $chain) // <- 여기서 array_map
            );
        });
    } else {
        // 일반(eager) 인스턴스화
        return $class->newInstanceArgs(
            $this->buildClassConstructorParameters($constructor, $chain)
        );
    }
}

그리고 buildClassConstructorParameters()는 생성자 파라미터를 array_map으로 순회하며 각각을 다시 컨테이너에서 resolve한다. resolve가 또 buildClass()를 호출 → 또 lazy ghost.
백트레이스의 zend_lazy_object_init → array_map → zend_lazy_object_init 중첩 구조와 코드가 1:1로 맞아떨어진다.

결정적으로, lazy ghost 경로는 self::$useLazyObjectstrue일 때만 탄다. 기본값은 false다.
이 정적 속성은 config 옵션 enable_lazy_objects가 켜졌을 때 설정된다. 즉 내 환경에선 이 옵션이 켜져 있는것으로 확인된다.

 

5단계 — 설정 적용 (해결)

enable_lazy_objects를 끄면 buildClass()else 분기(newInstanceArgs, eager 인스턴스화)로 돌아가서 lazy object를 아예 만들지 않는다. OPcache는 켠 채로 버그가 사라진다.

occ로:

sudo -u www-data php occ config:system:delete enable_lazy_objects

또는 config/config.php에서 직접:

// 이 줄을 제거하거나 false로
'enable_lazy_objects' => false,

적용 후 php-fpm 리로드:

sudo systemctl reload php-fpm

이 옵션은 요청당 객체 생성을 지연시키는 성능 최적화(opt-in)일 뿐 필수가 아니다.
끈다고 기능이 깨지지 않고 체감 성능 차이도 거의 없다. 오히려 지금은 크래시 때문에 못 쓰는 상태였으니, 끄는 게 명백한 이득이었다. PHP 8.4의 lazy object UAF가 안정화되면 그때 다시 켜서 테스트하면 된다.

마무리 정리. 진단이 끝났으면 디버깅용으로 풀어둔 것들을 정리한다.
프로덕션은 디버그 빌드 대신 일반(non-debug, NTS) 빌드로 되돌린다(디버그 빌드는 느리고, assertion이 운영 중 abort를 유발할 수 있다).
필요 없으면 core dump 설정(rlimit_core, LimitCORE, core_pattern)도 원복.

 

검토

"OPcache 켜면 죽는다"는 OPcache가 범인이라는 뜻이 아니다. OPcache는 메모리 레이아웃과 캐시 슬롯을 바꿔서 잠재된 UAF를 노출시켰을 뿐이다. 증상이 사라진다고 그게 원인인 건 아니다.

추측을 멈추고 coredump를 떠라. file_cache, 확장 모듈 상호작용 등 그럴듯한 가설이 여럿 있었지만, 디버그 빌드 백트레이스 한 장이 전부 정리했다. 콜스택은 거짓말을 하지 않는다.

심볼이 전부다. strip된 바이너리의 ?? () 백트레이스로는 아무것도 못 한다. debuginfo 패키지 또는 --enable-debug가 진단의 전제 조건이다.

--enable-debug의 진짜 가치는 심볼만이 아니라 assertion이다. ZEND_DEBUG가 "일어나면 안 되는 상태"를 정확한 라인에서 잡아줬다. 프로덕션 빌드였다면 무음 손상이나 막연한 SIGSEGV로 끝났을 것이다.

opt-in 성능 최적화는 의심 목록에 올려둬라. enable_lazy_objects 같은 새 최적화 플래그는 새 PHP 기능(lazy objects)에 의존하고, 그 기능이 아직 거친 상태면 그대로 폭탄이 된다. 새 메이저 버전 + 새 엔진 기능 조합은 특히 그렇다.

 

환경: PHP 8.4.x/8.5.x / php-fpm / nginx / Nextcloud. 경로와 패키지명은 배포판·설치 방식에 맞게 조정할 것