tech

PHP-FPM 구조와 버그 그리고 기여

PHP의 FastCGI는 웹 서버 API의 제약 없이 CGI를 확장한 방식이다. 기존 CGI 구현은 프로세스를 생성해 클라이언트 요청 하나를 처리한 뒤 종료하는 구조를 가진다. 하지만 프로세스 생성 비용이 크기 때문에 다수의 사용자를 동시에 처리하기에는 한계가 있다.

이를 보완하기 위해 나온 FastCGI는 매 요청마다 프로세스를 생성하지 않고, 미리 만들어둔 프로세스를 기반으로 요청을 처리하는 인터페이스다. 이러한 인터페이스의 여러 구현체 중 하나가 바로 FPM(FastCGI Process Manager) 이다.

이번 글에서는 FastCGI 동작 자체보다는 FPM의 내부 구조를 코드 관점에서 간단히 살펴보고, 분석 과정에서 발견한 버그와 그에 대한 기여 경험을 공유하고자 한다.

컴파일 및 설치

분석에 사용한 PHP 버전은 8.4.7이다. 만약 직접 컴파일하여 확인하고 싶다면 다음 명령어를 사용할 수 있다. (필요한 의존성 패키지는 시스템 환경에 맞게 설치해야 한다.)

git clone --depth 1 --branch php-8.4.7 https://github.com/php/php-src.git
cd php-src
./buildconf --force
./configure --enable-debug --enable-fpm --disable-cgi --with-openssl \
            --enable-phpdbg --enable-phpdbg-debug --enable-opcache
./config.nice
make -j $(nproc)
make test
sudo make install
./sapi/fpm/php-fpm -v

정상 설치 시 다음과 같은 결과가 출력된다.

PHP 8.4.7 (fpm-fcgi) (built: Sep  7 2025 17:02:40) (NTS DEBUG)
Copyright (c) The PHP Group
Zend Engine v4.4.7, Copyright (c) Zend Technologies

PHP-FPM 전반적 구조 및 초기화 부분

살펴보기 전에 PHP-FPM의 기본적인 구조에 대해 설명하자면 여러 개의 pool이 존재하고 각 pool마다 애플리케이션 도메인이 따로 동작한다. 각 pool은 자식 프로세스를 생성하여 앞단의 NGINX 또는 APACHE가 FastCGI Protocol에 맞춰 전달해주는 요청을 처리하는 구조다. 그리고 자식 프로세스는 pm.max_requests라는 설정값을 기반으로 하나의 자식 프로세스가 살아있는 동안 몇 번의 요청을 처리할지 결정하는 변수값이다. 그리고 pool마다 scoreboard라는 것이 존재하는데 이는 pool의 현재 상태 그리고 각 자식 프로세스별로 상태값을 기록한다. 대략적인 구조는 위와 같지만 이를 그림으로 살펴보자.

PHP-FPM의 기본적인 구조는 fpm_init() 함수를 통해 설정 파일 파싱, pool 생성을 진행한다.

int fpm_scoreboard_init_main(void){    
    struct fpm_worker_pool_s *wp;    
    for (wp = fpm_worker_all_pools; wp; wp = wp->next) {        
        size_t scoreboard_procs_size;        
        void *shm_mem;
        [...]        
        scoreboard_procs_size = sizeof(struct fmp_scoreboard_proc_s) * wp->config->pm_max_children;        
        shm_mem = fpm_shm_alloc(sizeof(struct fpm_scoreboard_s) + scoreboard_procs_size);
        [...]        
        wp->scoreboard = shm_mem;        
        wp->scoreboard->nprocs = wp->config->pm_max_children;
        [...]    
    }    
    return 0;
}

fpm_init() 함수 내부에서 fpm_scoreboard_init_main() 함수를 호출하여 이전에 언급한 scoreboard를 생성한다. fpm_scoreboard는 pool마다 크게 하나씩 존재하고 fmp_scoreboard_procs는 pool이 가지는 프로세스 개수만큼 할당된다. 그리고 이를 fpm_shm_alloc() 함수를 이용하여 shared memory(공유 메모리)에 저장한다. 할당되는 2개의 구조체 내용은 아래 내용과 같다.

struct fpm_scoreboard_proc_s {    
    union {        
        atomic_t lock;        
        char dummy[16];    
    };    
    int used; // 현재 프로세스가 사용 중인가 free가 아닐 경우 1로 설정    
    pid_t pid; // 프로세스의 PID     
    unsigned long requests; // 현재 프로세스가 처리한 요청 개수    
    enum fpm_request_stage_e request_stage; // 현재 프로세스 요청 처리 단계
    [...]
};

struct fpm_scoreboard_s {    
    union {        
        atomic_t lock;        
        char dummy[16];    
    };    
    atomic_t writer_active; // scoreboard spin lock 접근을 위한 writer    
    unsigned int reader_count; // scoreboard spin lock 접근을 위한 reader    
    int pm; // fpm 모드    
    int idle; // 현재 요청을 처리하지 않고 있는 프로세스 개수    
    int active; // 현재 요청을 처리 중인 프로세스 개수    
    int active_max;    
    unsigned long int requests; // pm.max_requests의 값    
    unsigned int max_children_reached;     
    unsigned int nprocs; // 자식 프로세스 개수    
    int free_proc; // 초기화되지 않은 프로세스 개수
    [...]    
    struct fpm_scoreboard_s *shared;    
    struct fpm_scoreboard_proc_s procs[] ZEND_ELEMENT_COUNT(nprocs);
};

scoreboard를 관리하기 위해서는 fpm_scoreboard_s와 fpm_scoreboard_proc_s가 존재한다. fpm_scoreboard_s 구조체는 pool(애플리케이션)의 상태 관리이고 후자는 각 pool이 관리하는 각 프로세스마다의 scoreboard다. 중요 필드는 주석으로 따로 기입했다. 각 구조체는 자식 프로세스, 부모 프로세스가 동시 접근이 가능해야 하므로 shared memory(공유메모리)에 저장한다. 접근 시에는 각 구조체의 lock 필드를 사용하여 spin lock을 기반으로 접근한다. 이러한 구조체들이 어떻게 사용되는지는 아래 fpm_run() 함수에서 계속한다.

fpm_run() 함수는 (pm.mode가 static이라면) fork(2) syscall을 호출하여 pm.max_children만큼 자식 프로세스를 생성한다. 부모 프로세스는 fpm_event_loop() 함수에서 블로킹 상태에서 자식 프로세스의 변화 감지를 대기한다. 이때 발생할 수 있는 대표적인 signal은 3가지가 존재한다. SIGKILL, SIGTERM, SIGCHLD. SIGKILL의 경우 자식 프로세스에서 실행하는 php script의 사용 메모리가 제한 메모리보다 커졌을 경우 kernel로부터 발생하는 시그널이다. SIGTERM의 경우 자식 프로세스가 의도치 않게 NULL 참조(대표적인 예시)로 종료되었을 때다. 마지막으로 SIGCHLD는 자식 프로세스가 처리한 요청의 개수가 max_requests와 같거나 넘겼을 때 정상 종료하여 발생하는 시그널이다. 이러한 시그널은 부모 프로세스가 수집하여 트리거시킨 자식 프로세스를 새로 생성하여 초기화한다.

fpm_resources_prepare() 함수를 호출하여 자식 프로세스의 정보가 담긴 child 구조체를 할당한다. fpm_scoreboard_proc_alloc 함수를 호출하여 이전에 할당한 scoreboard의 procs 배열 필드에서 사용하지 않는 필드의 used 값을 1로 설정한다. 그리고 난 뒤 fork syscall을 호출하여 parent process 로직, child process 로직으로 나눠서 실행된다. 부모 프로세스 로직의 큰 목적은 child 구조체를 링크드 리스트 형태로 보관하고 각종 이벤트를 등록하여 fpm_event_loop() 함수에서 수신할 수 있도록 설정한다.

자식 프로세스 로직의 경우 fpm_child_resources_use() 함수를 호출하여 자식 프로세스에 선언된 2개의 전역 변수 필드 fmp_scoreboard_i(scoreboard->procs[N]에서 N의 값), fpm_scoreboard(pool마다 가지는 scoreboard 공유 메모리 주소)를 저장하고 child를 해제한다. 생성된 자식 프로세스는 아래 fmp_init_request() 함수부터 로직이 실행된다.

자식 프로세스의 클라이언트 요청 처리

자식 프로세스는 fcgi_accept_request 함수 내부에 accept syscall을 호출하여 클라이언트의 소켓 연결을 대기한다. 연결이 발생하게 되면(3 way handshake가 끝난 소켓) 블로킹 상태에서 빠져나오게 되며 현재 process의 Stage 값을 FMP_REQUEST_ACCEPTING 상태로 변경한다. fpm_request_info() 함수에서는 (생략된 이전 함수 init_request_info) query_string, request_uri, content_length 등의 값을 기입한다. 그리고 Stage를 FPM_REQUEST_INFO로 변경한다. 요청한 Script를 실행할 준비가 되었다면 php_execute_script() 함수를 호출하여 요청한 Script를 실행한다. php_request_startup과 php_request_shutdown은 PHP의 Lifecycle을 참고하기 바란다. (https://www.phpinternalsbook.com/php7/extensions_design/php_lifecycle.html)

요청 처리가 끝났다면 fpm_request_end() 함수를 호출하여 Stage 값을 FPM_REQUEST_FINISHED로 변경한다. 마지막으로 자식 프로세스가 처리한 요청 수를 max_requests 값과 비교하여 돌아갈지 exit될지 선택된다. exit된다면 부모 프로세스는 SIGCHLD 시그널을 수신하여 자식 프로세스를 생성 및 초기화하여 사용자의 요청을 처리한다.

여기까지가 대략적인 PHP-FPM의 전반적인 구조다. 코드 기반 구체적 설명이 아닌 구조 중심의 그림을 보이다 보니 많은(구체적인 동작 구조) 부분들이 생략되었다. 하지만 전반적인 FPM의 구조는 이해하는 데 문제가 없을 것으로 예상되기에 자세한 부분은 직접 소스코드 기반으로 분석하기 바란다. 다음 섹션은 분석 과정에서 찾은 PHP-FPM의 버그가 무엇인지 어떻게 해결하여 Pull Request를 올렸는지를 설명한다.

8.4.7에서 발생했던 버그와 기여

5월 19일 php-src github에 하나의 issue가 등록된 것을 확인할 수 있다. (https://github.com/php/php-src/issues/18595) 이는 사용자 요청으로 fpm_get_status() 함수가 트리거되면 어느 순간 segmentation fault가 발생한다는 issue였다. 이는 기본적으로 부모 프로세스와 자식 프로세스 간의 경쟁 상태에서 문제가 발생한 것이다. 조금 더 자세히 풀어보겠다. 다음 가정을 진행하자.

“부모 프로세스(P)와 2개의 자식 프로세스(A와 B)가 존재하고 자식 프로세스의 max_requests 값은 1이다. 그리고 사용자는 curl localhost/bug.php를 1번 요청한 상태다. bug.php의 내용은 아래와 같다.”

<?php
fpm_get_status();
?>

조금 부연설명을 하자면 fpm_get_status() 함수는 현재 scoreboard에서 정보를 읽어서 fpm의 현재 상태를 출력한다.

/* fmp_get_status() 함수 내부 구조  */
for (i = 0; i < scoreboard.nprocs; i++) {    
    if (!procs[i].used) {        
        continue;    
    }    
    proc_p = &procs[i];    
    
    /*
        fpm-get_status 함수는 내부적으로 fmp_request-get_stage_name 함수를 호출함.
        이는 현재 Stage 정수 값을 기반으로 문자열을 뽑는 함수.
    */    
    add_assoc_string(&fpm_proc_stat, "state",                     
                     fpm_request_get_stage_name(procs[i].request_stage));
}

const char *fpm_request_get_stage_name(int stage) {
    return requests_stages[stage];
}

static const char *requests_stages[] = {
    [FPM_REQUEST_ACCEPTING]       = "Idle",              // index 1
    [FPM_REQUEST_READING_HEADERS] = "Reading headers",   // index 2
    [FPM_REQUEST_INFO]            = "Getting request information", // index 3
    [FPM_REQUEST_EXECUTING]       = "Running",           // index 4
    [FPM_REQUEST_END]             = "Ending",            // index 5
    [FPM_REQUEST_FINISHED]        = "Finishing",         // index 6
};

가장 먼저 Accept을 준비한 자식 프로세스 A가 요청을 처리하고 max_requests의 값에 도달해서 exit된다. 이때 exit되면 이전에 언급한 부모 프로세스(P)가 대기하고 있던 fpm_event_loop() 함수에서 시그널이 수신되어 블로킹 상태에서 빠져나온다. 부모 프로세스는 fpm_children_bury(), fpm_scoreboard_proc_free() 함수를 호출하여 자식 프로세스(A)의 구조체를 해제한다. 해제된 구조체의 request_stage 필드값을 0으로 변경한다.

그리고 fmp_children_make(), fpm_resources_prepare(), fpm_scoreboard_proc_alloc() 함수를 호출하여 아래 필드값을 1로 설정한다.

scoreboard->procs[i].used = 1;

used 값을 1로 설정했다는 것은 해당 process가 사용 중임을 나타낸다. 즉, 다른 프로세스(B)가 보기에는 A의 used 값이 1이니 사용 중인 것으로 인식할 수 있다. 하지만 이 시점에서 아직 request_stage 값은 0이다.

그리고 사용자가 curl localhost/bug.php 함수를 호출하여 2번째 요청을 했을 때 프로세스(B)가 요청을 처리하게 된다. B가 요청을 처리하면 fpm_get_status 함수를 호출하게 된다. 그리고 이는 scoreboard에 접근하여 다른 프로세스의 used 값을 기반으로 사용 중(1)이라면 request_stage 값을 꺼내 fpm_request_get_stage_name 함수에 넣어 문자열을 반환받는다. 여기서 문제는 requests_stage 배열의 인덱스 시작 부분이 1부터 시작한다는 것이다. 아까 말했듯이 프로세스(A)의 request_stage는 현재 초기화 단계이기 때문에 0이다. 여기서 0은 인덱스에 없기 때문에 NULL 참조로 Segmentation Fault가 발생한다. 이해를 위해 아래 그림을 참고하기 바란다.

이를 해결하기 위한 가장 간단한 방법은 0번째 인덱스의 Stage 값을 만드는 것이다.

위의 수정사항을 바탕으로 PHP github에 Pull Request를 올리게 되었고 머지가 된 것을 확인할 수 있다. 조금 더 자세한 사항은 아래 링크를 참조하기 바란다. https://github.com/php/php-src/pull/18662 (물론 Close되었지만 이는 메인테이너가 커밋을 기반으로 올렸기 때문이다…) 덕분에 php-src의 Contributor가 될 수 있었으며 기쁜 마음에 본 기사를 작성한다.

마무리

PHP-FPM은 기존 CGI의 확장형인 FastCGI의 구현체다. 기본적인 동작은 모두가 알다시피 클라이언트의 요청 전 프로세스를 미리 생성하고 만들어진 process 기반으로 요청을 처리한다. max_request에 도달한 프로세스는 exit되며 부모 프로세스는 exit 등의 프로세스 시그널을 탐지하여 재생성 로직을 수행한다. 부모 프로세스가 생성된 자식 프로세스를 초기화하는 과정에서 프로세스의 used 필드값은 초기화되었지만 request_stage 값은 초기화하지 못한 상태에서 경쟁 상태 문제가 발생하게 되면서 Segmentation Fault가 발생한 것을 확인할 수 있었다.

분석을 통해 발견한 문제의 해결법을 제시하여, 오픈소스에 처음으로 기여할 수 있었다는 점은 매우 뜻깊었다. 앞으로도 이러한 분석과 기여를 통해 더 안정적인 PHP 생태계에 이바지하고 싶다.

김수창 기자

PHP-FPM의 구조는 과거와 달리 현시대적으로는 뒤떨어질 수 있는 구조이나 아직까지도 널리 사용되고 있는 애플리케이션이다. 만약 PHP-FPM을 사용하여 웹 서버 애플리케이션 서비스를 제공할 때 어떻게 동작하는지 알면 좋을 것이라고 판단해서 분석글을 작성했다. 또한 분석 과정에서 찾은 버그와 기여를 통해 첫 번째 오픈소스 컨트리뷰션이 되었음에 기쁘다.


TOP