Docker로 라라벨 앱 운영 준비하기

엽토군·2024년 3월 25일
0

tl;dr

라라벨 앱을 운영 환경에 배포하려면 어떡하면 좋을까?
나의 경우에는 다음과 같은 Dockerfile 이미지를 빌드해서 이걸 실행하고 있다.

FROM composer AS s
WORKDIR /app
COPY . .
RUN composer install --prefer-dist --no-dev --no-scripts && \
    composer dump-autoload --no-scripts && \
    mv vendor/ .vendor/

FROM node:alpine AS c
WORKDIR /app
COPY . .
COPY --from=s /app/.vendor /app/vendor
RUN npm i && npm run build && \
	mv public/ .public/

FROM php:8.2-apache AS p
RUN curl -sSL https://github.com/mlocati/docker-php-extension-installer/releases/latest/download/install-php-extensions -o - | sh -s redis pdo_mysql

FROM p AS a
WORKDIR /var/www
COPY . .
COPY --from=s /app/.vendor       /var/www/vendor
COPY --from=c /app/.public/build /var/www/public/build
RUN sed -ri -e 's!/var/www/html!/var/www/public!g' /etc/apache2/sites-available/*.conf && \
    sed -ri -e 's!/var/www/!/var/www/public!g' /etc/apache2/apache2.conf /etc/apache2/conf-available/*.conf && \
    a2enmod rewrite && \
    rm -rf /var/www/html && \
    mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini" && \
    chown -R www-data:www-data /var/www && \
    chmod +x /var/www/artisan

.dockerignore에 대해서는 맨 끝에 붙이겠음.

해설

다단계 빌드 (multi-stage builds)

눈으로는 익었는데 아직 머리에 들어가지는 않은 지식들이 몇 개 있고 그 중 하나가 Dockerfile에서의 multi-stage builds였다. 어딘가 지나가듯이 본 블로그에서 "이때 이미지가 만들어집니다"라는 설명이 지나가듯이 써 있었는데, 이 힌트 덕분에 이제야 겨우 조금 이해했다.

docker.com의 Composer 공식 이미지 소개를 보면 이런 부분이 보인다.

안내: Docker 17.05부터 다단계 빌드가 정식 지원되므로, 이 절차*가 대단히 간단해집니다.

COPY --from=composer /usr/bin/composer /usr/bin/composer

(*직접 빌드하는 이미지에 composer를 깔아서 쓰는 것을 말함.)

여기서 실제로 일어나고 있는 일이 뭐냐면, composer라는 이미지 안에 들어 있는 /usr/bin/composer를, 지금 빌드 중인 이미지의 /usr/bin/composer 위치에 COPY하는 것이다.
일반화하면 다음과 같다.

# A 이미지의 B를 C에 복사한다
COPY --from=A B C

이것이 맨 위의 도커파일의 핵심 전개다.

FROM 뭐시기 AS s
어쩌구 저쩌구... 뭔가를 빌드

FROM 저시기 AS c
COPY --from=s [s에서 빌드했던 거] [a에 복사할 위치]
이러쿵 저러쿵... 뭔가 다른 걸 빌드

FROM php:8.2-apache AS p
수군수군... 최종 이미지의 모든 버전에 공통되는 작업을 수행

FROM p AS a
COPY --from=s [s에서 빌드했던 거] [a에 복사할 위치]
COPY --from=c [c에서 빌드했던 거] [a에 복사할 위치]
궁시렁 궁시렁... 진짜로 하고 싶은 작업들

s라는 이미지가 로컬에서 잠깐 빌드되는데, 이 이미지에 대해서는 어쩌구 저쩌구(만)을 실행해서 뭔가를 설치/컴파일/빌드해둘 수 있다. 그리고 이걸 ca에서 퍼오는 것이 가능하고, 여러 방향에서부터 퍼오는 것도 가능하다. "역류" 빼고 다 된다고 생각하면 된다. c도 마찬가지.

이상하게 나는 지금껏 multi stage builds에 대해서 다음과 같은 오해들을 하고 있었다.

  • COPY는 일회성으로만 된다.
    한 번 특정 stage에서 COPY된 자료는 다음 stage에서부터는 쓸 수 없다.
  • 2스테이지까지만 가능하다.
    3스테이지 이상 진행 못 한다.
  • 한 스테이지에서 다른 스테이지로 COPY를 항상 수행해야 한다.

뭐 보시다시피 전부 거짓이다. 그간 눈으로 본 예제 포스팅들이 다 2단계짜리 단순 사례만 소개하고 있어서 빚어진 오해였다. 역시 눈에만 익으면 안되고 머리까지 가야 되는 것 같다.

이를 이용해서, 다음과 같이 빌드 단계를 분리한다.

  1. s = server. /vendor 폴더를 꾸린다.
  2. c = client. /public/build 폴더를 꾸린다.
  3. p = PHP. 필요한 확장이 들어 있는, 모든 버전에서 공통되는 런타임 이미지를 만든다.
  4. a = app = PHP + server + client. 잘 꾸려놓은 두 폴더를 공통 이미지에서 사용한다.

왜 이 짓을 하느냐고? PHP 웹앱 도커라이징을 조금이라도 덜 못생기게 하기 위해서다.

FROM ubuntu # alpine이나 scratch를 써도 문제상황 자체는 크게 좋아지지 않는다
RUN apt-get update && apt-get install node php8.2 gcc ... # 이런 식으로 한무더기
RUN php -r "copy('https://getcomposer.org/installer' ..." # 이런 식으로 또 한무더기
RUN apt-get install nginx ... # 이런 식으로 또 한무더기
CMD [ "nginx -g" ] # 이쯤에서 빌드되는 이미지 대략 337 GiB

시중에는 대략 위와 같은 겁내 뚱뚱한 예제들이 돌아다닌다.
잘 모르고 봤을 때도 미학적으로 싫고 공학적 직감상 영 별로였던 것이, 지금 다시 보자면 정말 이젠 이런 건 슬슬 거부해야 한다 싶은 관행이라 여겨진다.
왜? 우리는 개발 컨테이너를 만들려는 게 아니기 때문이다. 확실히 개발 컨테이너라면 이 많은 걸 전부 다 설치해 갖고 있을 필요가 있지만, 프로덕션용 이미지는 최종 빌드와 그걸 실행할 수 있는 런타임만 가지면 되기 때문에 최소한으로 구성할 필요가 있다.
내가 찾은 해답은, 다음 3개 이미지만으로 충분하다는 것이다.

  1. composer - 서버 의존성 설치
  2. node:alpine - 클라이언트 빌드
  3. php:버전-apache - 웹서버 런타임

라라벨 앱 운영에서의 composer install

라라벨 공식문서는 유난히 운영배포(deployment) 문서가 빈약하다. 아마 의도된 것일 테다. 라라벨 팀도 Forge, Vapor를 팔아야 하지 않겠는가?
사실 이 포스트는 이 빈곤한 공식문서를 보완하고 싶어서 쓰는 것이기도 하다.

개발 환경에서야 아무 생각 없이 composer install 돌리면 되지만, 운영 환경에서는 옵션이 좀더 필요하다.

composer install \
	--prefer-dist --no-dev \ # require-dev에 명시된 의존성은 설치 안 해도 (작동해야) 된다.
    --no-scripts 			 # package discovery 등의 동작 방지를 위한 부분. 아래 후술
composer dump-autoload \     # no-scripts 옵션을 줬으므로 autoload 돌려줘야 한다.
	--no-scripts 		     # 물론 이때도 no-scripts 옵션 줘야 한다.

--no-scripts 옵션은, 특히 지금 사례처럼, 의존성을 '설치 따로 실행 따로' 할 때 반드시 필요하다.
안 그러면, 단지 php artisan migrate를 실행할 뿐인데도, 쓸데없이 무슨Resolver 클래스를 찾을수 없음 오류를 당하게 된다.
어떤 artisan 명령들은, --no-dev 옵션을 주면 안 깔리는 클래스들을 자꾸 참조하려고 들기 때문에, 이걸 막아야 하는 것이다.

라라벨 앱 빌드에서의 npm build

이 장은 좀 사소한 내용. 위의 도커파일 중 이 부분이 왜 이렇게 됐는지에 대한 설명이다.

# 필요 없는 디테일은 생략
FROM node:alpine AS c
COPY --from=s /app/.vendor /app/vendor # <------ 이 부분
RUN npm i && npm run build

FROM도 알겠고 RUN도 알겠는데 저 COPY는 뭐냐 싶으실 것이다. 이게 어떻게 된 거냐면... 나의 경우에는 지금 빌드하려는 라라벨 앱이 하필 Alpine 인스턴스를 수동으로 띄워야 하는 상황이었다. (커스텀 디렉티브를 쓰고 싶었음) 그래서 자바스크립트 소스 중에 이런 부분이 있다.

import { Alpine, Livewire } from '../../vendor/livewire/livewire/dist/livewire.esm';

그렇다. vendor/가 없으면 빌드가 깨지는 소스다.
그래서 구태여 composer install을 먼저 한 다음 그걸 npm run build에서 복사 받아 활용한다.
이런 불우한 사연만 아니라면 COPY는 건너뛰고 바로 RUN만 해도 된다.

이 얘기는 여기서 끝~

PHP 공식 이미지 예찬론

PHP를 "설치"하지 말자

바야흐로 2024년이다.
혹시 아직도 PHP 개발/운영 하느라고 C:\php73 폴더를 만들고 "시스템 환경 변수"를 고치고 계신가?
멈춰!
이제 Docker를 깔고 모든 것을 공식 PHP 이미지에 맡기기로 하자.

Docker와 함께라면, 특정 PHP 파일을 특정 버전의 PHP로 실행하는 것이 이보다 더 쉬울 수가 없다.

# 현재 폴더의 foo.php 파일을 PHP 5.4로 실행하기
docker run --rm -it -v "$(pwd):/app" -w /app php:5.4 php foo.php

뭣하면 통째로 .bashrc alias로 등록해도 된다.

# 방금 내 컴퓨터에서 퍼옴
alias php='docker run --rm -it -v "$(pwd):/app" -w /app php'

무엇보다도, PHP 앱 빌드가 엄청 편해진다.
왜냐면 이미 이미지가 있기 때문이다.
PHP 공식이미지는 cli 버전도 있고 fpm 버전도 있고 심지어 apache 버전도 있다.
당신이 돌리려는 앱 성격에 맞게 골라서 필요한 소스만 마운트/COPY하면 된다.
그동안 힘들게 apt-get update 받고 remi 저장소를 추가하고 어쩌고 했던 걸 다 잊어버려도 좋다.

나의 경우에는 apache 이미지를 써서 웹서버+FPM을 붙인 사례인데, 다음과 같이 구성하는 것도 가능하다.

# docker-compose.yaml
services:
	nginx:
    	image: nginx:alpine
        volumes:
        	- ./conf.d/:/etc/nginx/conf.d/
            - ./php/:/var/www/html/ # ---> 이 경로 잘 봐둘 것. nginx와 php 양쪽에 모두 mount해야 함
        ports:
        	- 80
	php: # ---> 이 서비스명 잘 봐둘것
    	image: php:fpm-alpine
        volumes:
            - ./php/:/var/www/html/ # ---> 이 안에 public/index.php 있다고 가정
# conf.d/default.conf
server { 
  listen 80; 
  root /var/www/html/public; # <--- 여기서 그 경로 사용
  index index.php;
  location / {
    try_files $uri $uri/ /index.php?$args;
  }
  location ~ [^/]\.php(/|$) { 
    fastcgi_split_path_info ^(.+?\.php)(/.*)$;
    if (!-f $document_root$fastcgi_script_name) {
      return 404;
    }
    include fastcgi_params;
    fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    fastcgi_param PATH_INFO       $fastcgi_path_info;
    fastcgi_param PATH_TRANSLATED $document_root$fastcgi_path_info;
    fastcgi_pass   php:9000; # <--- 여기서 그 서비스명 사용
    fastcgi_index  index.php; 
  } 
}

나의 경우에는 꼭 NGINX일 필요가 없어서, 그리고 라라벨에 .htaccess가 기본 첨부돼 있기 때문에, 그래서 php:apache 이미지를 쓰기로 했다. 다른 이유는 음슴.

확장 컴파일을 겁내지 말자

개인적으로는 PHP 도커 전환의 최대 이점 중 하나가 '확장 설치'를 정말 쉽게 해준다는 것이다. 앞서 언급한 remi 저장소 어쩌고를 할 필요가 없다.
예컨대 다음 Dockerfile 명령은, 최종 이미지에 Redis와 PDO MySQL 확장을 설치한다.

RUN curl -sSL https://github.com/mlocati/docker-php-extension-installer/releases/latest/download/install-php-extensions -o - | sh -s redis pdo_mysql

요약하자면 install-php-extensions라는 sh 스크립트를 어딘가에서 퍼온 다음 그 스크립트에 redis, pdo_mysql 인자를 줘서 실행하는 부분이다.
이게 끝이지만, 실제로 일어나는 일은 진짜 빌드다.
누가 미리 빌드해놓은 걸 무슨 저장소에서 gpg 키 등록해서 퍼오는 것이 아니다.
당신의 이미지에서 돌아가는, 당신의 이미지를 위한 so 바이너리가 컴파일된다.
그게 이 한 줄로 해결된다.
관련 프로젝트가 지원하는 다른 확장들도 다 이런 식으로 끝에 이름만 붙이면 그만이다.
실제 코드를 컴파일하는 과정인지라 시간이 좀 걸린다는 점이 유일한 흠인데, 이 부분도 별도의 stage로 떼어 놓으면 cache가 되므로, 한 번만 시간을 쓰면 된다.
PHP 확장에 관한 이야기 치고는 멋지지 않은가?
그래서 나로서는 아주 고평가하고 있다. 랄까 너무 저평가된 프로젝트라고 생각한다. 바야흐로 2024년, 이제 사람들은 PHP 확장의 누락, 충돌, 비호환성 등을 붙잡고 씨름할 필요도 이유도 없다.

.dockerignore

나머지는 너무 사소하거나 뻔하거나 다른 문서에 나온 내용 그대로라서 생략하고, 자주 반복되는 다음 부분을 변명하면서 이 글을 끝내겠다.

WORKDIR /app
COPY . .

너무 많은 걸 복사하는 거 아니냐고? 캐시 폴더, 이미 개발환경에서 생성되던 vendor/ 폴더나 storage/framework/cache/ 폴더도 깡그리 다 복사되지 않겠냐고?

그래서 .dockerignore 파일을 넣어놨다. 다음 파일들은 COPY 과정에서 절대 이미지에 인입되지 않는다.

.vscode/
.git/
vendor/
node_modules/
bootstrap/cache/*.php
storage/framework/cache/*
tests/

public/hot
.*ignore
.env
.editorconfig
.gitattribute
docker-compose.yml
Dockerfile
phpunit.xml
README.md

이제 mv vendor/ .vendor/ 같은 걸 왜 했는지 이해가 되실 것이다.

이해가 안 되는 분들을 위해 힌트를 드리겠다. .dockerignore는 모든 stage에서 COPY를 수행할 때마다 참조된다. 만약 s단계에서 기껏 다 꾸려 놓은 vendor/ 폴더를 이름 안 바꾸고 그대로 뒀더라면, c 단계에서는 과연 무슨 일이 (안) 일어났을까?

결론

PHP 애플리케이션의 배포는 지난 20여 년간 늘 더럽고 힘들고 위험한 일이었다.
솔직히 말하자면 이 부분은 라라벨도 별반 차이 없었다고 본다.
PHP 에코시스템의 마지막 허들이었다고 할까?
하지만 컨테이너 오케스트레이션 개념이 나온 지금, 이제는 PHP가 3D 업계라는 오명을 벗는 시도를 해볼 때도 됐지 않았을까 한다.
내 사례는 라라벨에 최적화돼 있지만, 다른 PHP 앱도 얼마든지 기존 이미지들을 활용해 빌드, 설치, 도커라이징이 가능하다. 한번 알아보시라!

사실 빌드까지 갈 것도 없이, 대부분의 PHP 웹앱은, PHP 버전/확장이 안 중요하다면, 웬만해서는 linuxserver/nginx 이미지 하나로 즉시 도커라이징 가능하다.

services:
  nginx:
    image: lscr.io/linuxserver/nginx:latest
    volumes:
      - /path/to/your/index-dot-php-file:/config/www
    ports:
      - "6388:80"

linuxserver 이미지들은 cron 서비스, MariaDB 등을 붙이는 게 매우 쉬우므로, 실용성을 위해서는 이쪽을 적극 추천한다.

그래서 이 이미지로 만든 서비스가 뭐냐고? 정말 라이브 서비스 띄우긴 띄웠냐고?
아직 안 띄웠다.
이번 주 중에 올리는 대로 정식으로 서비스 소개를 드리도록 하겠다.

profile
4년차 PHP 개발자입니다.

0개의 댓글