운영 서버에 새 PHP 버전을 올리던 날, 로컬에서는 멀쩡하던 코드가 서버에서만 경고를 쏟아낸 적이 있다. 원인은 어이없을 만큼 단순했다. 로컬 php.ini의 upload_max_filesize는 64M인데, 서버 것은 2M으로 맞춰져 있었다. 개발자 세 명이 각자 다른 PHP 마이너 버전을 쓰고 있었다는 사실도 그날 처음 알았다. "내 컴퓨터에선 됐는데요"라는 말은 이럴 때 나온다.

리눅스 서버 위에서 PHP와 MySQL을 오래 운영해 본 사람이라면 한 번쯤 이 문제를 겪는다. 서버를 새로 세팅할 때마다 apt install php, php.ini 수정, php-fpm 재시작을 순서대로 반복하고, 그 순서를 어딘가에 문서로 남겨두지만 반년 뒤엔 그 문서조차 최신이 아니게 된다. 도커(Docker)는 이 반복을 통째로 파일 하나에 담아버리는 방법이다.

php.ini 하나 때문에 생기는 일

서버 환경이 달라서 생기는 버그는 디버깅이 유난히 괴롭다. 코드는 그대로인데 결과만 다르기 때문에, 원인을 코드 밖에서 찾아야 한다는 사실을 받아들이는 데 시간이 걸린다. 확장 모듈 하나가 빠져 있거나, memory_limit이 낮게 잡혀 있거나, 타임존 설정이 서버마다 제각각인 경우도 흔하다.

서버 환경을 코드처럼 버전 관리할 수 있다면, 이 문제의 절반은 애초에 일어나지 않는다.

도커는 애플리케이션과 그 애플리케이션이 필요로 하는 PHP 버전, 확장 모듈, 설정값을 이미지(image) 라는 하나의 단위로 묶는다. 이 이미지를 실행하면 로컬이든 운영 서버든 똑같은 환경이 만들어진다는 것이 핵심이다.

Dockerfile, 처음부터 다시 쓰기

가장 흔한 실수는 하나의 Dockerfile에 소스 코드, 개발 도구, 빌드 캐시까지 전부 집어넣어 이미지를 뚱뚱하게 만드는 것이다. 멀티스테이지 빌드를 쓰면 빌드에 필요한 도구는 최종 이미지에서 빠진다.

FROM composer:2 AS vendor
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --prefer-dist

FROM php:8.3-fpm-alpine
RUN docker-php-ext-install pdo_mysql opcache
COPY --from=vendor /app/vendor /var/www/html/vendor
COPY . /var/www/html
RUN chown -R www-data:www-data /var/www/html
USER www-data

주목할 부분은 마지막 줄의 USER www-data다. 컨테이너를 root 권한으로 그냥 띄우는 경우가 의외로 많은데, 컨테이너 하나가 뚫렸을 때 피해 범위를 최소화하려면 애플리케이션 프로세스는 일반 사용자로 실행하는 편이 안전하다.

AD

php-fpm과 nginx, 컨테이너를 나누는 이유

한 컨테이너 안에 nginx와 php-fpm을 함께 넣고 싶은 유혹이 생기지만, 도커의 기본 철학은 컨테이너 하나에 프로세스 하나다. 둘을 분리하면 nginx 이미지와 php-fpm 이미지를 각각 독립적으로 업데이트할 수 있고, 로그도 따로 관리하기 쉬워진다.

# docker-compose.yml 일부
services:
  app:
    build: .
    volumes:
      - ./:/var/www/html
  web:
    image: nginx:1.27-alpine
    ports:
      - "80:80"
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf
      - ./:/var/www/html
    depends_on:
      - app
  db:
    image: mysql:8.0
    environment:
      MYSQL_DATABASE: myapp

web 컨테이너의 nginx는 정적 파일과 요청 라우팅을 맡고, .php로 끝나는 요청만 fastcgi_pass app:9000; 설정으로 app 컨테이너의 php-fpm에 넘긴다. 이렇게 나눠두면 트래픽이 몰릴 때 웹 서버와 PHP 처리 계층을 따로 늘릴 수도 있다.

opcache와 헬스체크, 빠뜨리기 쉬운 두 가지

이미지를 만들었다고 끝이 아니다. 실무에서 자주 놓치는 두 가지가 있다.

AD

첫째는 opcache다. 기본 설정으로 컨테이너를 띄우면 opcache가 파일 변경 여부를 매 요청마다 확인하느라(opcache.validate_timestamps=1) 속도 이득을 절반도 못 챙기는 경우가 많다. 운영 환경에서는 배포할 때만 opcache를 초기화하도록 값을 고정하는 편이 유리하다.

opcache.validate_timestamps=0
opcache.memory_consumption=256
opcache.max_accelerated_files=20000

둘째는 헬스체크다. php-fpm 프로세스가 죽었는데도 컨테이너 자체는 "실행 중"으로 표시되는 경우가 있다. Dockerfile에 HEALTHCHECK 지시어를 넣어두면 오케스트레이션 도구(또는 단순한 재시작 스크립트)가 실제로 요청을 처리할 수 있는 상태인지 주기적으로 확인해 준다.

정리하며

PHP를 도커로 옮기는 작업은 처음 하루 이틀은 번거롭다. php.ini 값 하나, 확장 모듈 하나까지 다시 점검해야 하기 때문이다. 하지만 그 과정을 한 번 Dockerfile에 정리해두면, 다음 서버 세팅은 문서를 뒤적이는 대신 이미지를 빌드하는 일로 바뀐다. 로컬과 운영 환경의 차이에서 오는 버그도 자연스럽게 줄어든다.

오늘 밤 서버에 급하게 PHP 확장 모듈을 설치해 본 경험이 있다면, 그 작업을 다음번엔 Dockerfile 한 줄로 바꿔보길 권한다. 같은 실수를 세 번째 반복하지 않는 가장 확실한 방법이니까.