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 a
WORKDIR /var/www
RUN curl -sSL https://github.com/mlocati/docker-php-extension-installer/releases/latest/download/install-php-extensions -o - | sh -s redis pdo_mysql
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 a
COPY --from=s [s에서 빌드했던 거] [a에 복사할 위치]
COPY --from=c [c에서 빌드했던 거] [a에 복사할 위치]
궁시렁 궁시렁... 진짜로 하고 싶은 작업들

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

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

  • COPY는 단방향으로만 된다.
    한 번 특정 stage에서 COPY된 자료는 다음 stage에서부터는 쓸 수 없다.
  • 2스테이지까지만 가능하다.
    3스테이지 이상 진행 못 한다.

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

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

  1. s = server. /vendor 폴더를 꾸린다.
  2. c = client. /public/build 폴더를 꾸린다.
  3. a = app = 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도 알겠고 RUN도 알겠는데 저 COPY는 왜 하는 걸까?

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

나의 경우에는 지금 빌드하려는 라라벨 앱이 하필 Alpine 인스턴스를 수동으로 띄워야 하는 상황이었다. (커스텀 디렉티브를 쓰고 싶었음)
그래서 자바스크립트 소스 중에 이런 부분이 있다.

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

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

이 얘기는 여기서 끝~

PHP 공식 이미지 예찬론 1: PHP를 "설치"하지 말자

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

왜 그래야 하느냐고? 우선은, 특정 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을 붙인 사례인데, 이론상 다음과 같이 구성하는 것도 가능하다.

# my-php-fpm-app 이미지의 Dockerfile
FROM php:fpm
WORKDIR /app # ---> 이 경로 잘 봐둘것
COPY . .
# docker-compose.yaml
services:
	nginx:
    	image: nginx:alpine
        volumes:
        	- ./conf.d/:/etc/nginx/conf.d/
        ports:
        	- 80
	php: # ---> 이 서비스명 잘 봐둘것
    	image: my-php-fpm-app
# conf.d/default.conf
server { 
  listen 80; 
  root /app/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 공식 이미지 예찬론 2: 확장도 빌드해서 쓰자

개인적으로 PHP 도커 전환의 최대 이점은, 확장 설치가 정말 간편하다는 것이다. 위 도커파일은 최종 이미지에 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 인자를 줘서 실행하는 부분이다.
이게 끝이지만, 실제로 일어나는 일은 진짜 빌드다. pecl 다운로드도 아니고 누가 미리 빌드해놓은 걸 무슨 저장소에서 gpg 키 등록해서 퍼오고 그런 것도 아니다. 당신의 이미지에서 돌아가는 당신의 이미지를 위한 so 바이너리가 빌드된다. 그게 이 한 줄로 해결된다.

멋지지 않은가? PHP 확장에 관한 이야기 치고는.

.dockerignore

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

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개의 댓글