Laravel with Jenkins (3/5) – Dockerize Laravel application

이 글은 이전 포스팅에서 만든 Jenkins 에 연결할 Laravel PHP 앱의 dockerization 과정을 설명하고, 이후 Docker 를 활용한 Jenkins 의 CI/CD 파이프라인을 만들기 위한 준비 내용을 정리한다. 맥사용자가 최신 Docker Desktop for Mac CE 인 v2.0을 설치했다는 가정하에 설명한다.

Bash_it 사용자?

잠깐! 만일 맥에서, bash_it 을 사용하는 사용자가 docker 용 bash completion script 를 추가하려면, ~/.bash_it/custom 폴더에 custom.prompt.bash 파일을 아래처럼 만들고

#!/usr/bin/env bash

function prompt_command() {
    PS1="\n$(battery_char) $(__bobby_clock)${yellow}$(ruby_version_prompt) ${purple}\h ${reset_color}in ${green}\w\n${bold_cyan}$(scm_prompt_char_info) ${green}→${reset_color} $(__docker_machine_ps1 "[%s] ")"
}

safe_append_prompt_command prompt_command

~/.bash_profile 에 bash_it.sh 을 로드하기 전, 아래와 같이 docker-machine prompt 관련 스크립트들을 로드하도록 만들면 된다. bash_it 사용자가 아니라면 패스… (나도 최근 zsh 로 갈아탐. ㅎ)

# Load docker-machine prompt
source /usr/local/etc/bash_completion.d/docker-machine-prompt.bash
source /usr/local/etc/bash_completion.d/docker-machine-wrapper.bash
source /usr/local/etc/bash_completion.d/docker-machine.bash

# Load Bash It
source $BASH_IT/bash_it.sh

Docker Machine

Docker Machine 은 커맨드라인 명령으로 클라우드 환경에 도커용 인스턴스 (droplet) 를 간단하게 프로비져닝하는 툴이다. 심플한 테라폼이라고 보면 된다.

아래의 명령 한방으로 registry 라고 명명된 Docker 사용이 가능한 DO 인스턴스를 생성할 수 있다. (물론 AWS 나 다른 클라우드 프로바이더를 지정할 수도 있다.)

docker-machine create \
  --driver digitalocean \
  --digitalocean-region "sgp1" \
  --digitalocean-image "ubuntu-18-04-x64" \
  --digitalocean-size "s-1vcpu-1gb" \
  --digitalocean-private-networking \
  --digitalocean-monitoring \
  --digitalocean-access-token $DO_TOKEN \
  --digitalocean-ssh-key-fingerprint $SSH_FINGERPRINT \
  registry

약 1분 정도의 시간이 경과하면, Docker is up and running! 이라는 피드백이 나오고, DO 콘솔 화면을 통해서 인스턴스가 생성된 것을 확인할 수 있다.

docker-machine env registry 명령을 입력하면 아래와 유사한 인스턴스의 정보가 보여진다.

export DOCKER_TLS_VERIFY="1"
export DOCKER_HOST="tcp://134.209.xx.xx:2376"
export DOCKER_CERT_PATH="/Users/chuck/.docker/machine/machines/registry"
export DOCKER_MACHINE_NAME="registry"
# Run this command to configure your shell:
# eval $(docker-machine env registry)

docker-machine ssh registry 명령으로 registry 라고 명명된 인스턴스에 접속할 수 있고, docker-machine rm registry 명령으로 해당 인스턴스를 제거할 수 도 있다. docker-machine create 명령이 수행한 내용들을 정리해보면, 아래와 같다.

  • docker-machine create 커맨드와 같이 제공한 옵션을 사용하여, 우리가 지정한 인스턴스 리젼과, 지정한 인스턴스 크기, 지정한 인스턴스 OS 를 갖는 droplet 을 DO 에 생성
  • Docker daemon 을 설치
  • ~/.docker/machine/machines/registry 폴더의 certificate 으로 tcp://134.209.xx.xx:2376 주소로 접속이 가능하도록 설정
  • 나의 PC 에서 해당 인스턴스로 ssh 접속을 할 수 있도록 나의 ~/.ssh/id_rsa.pub 을 해당 인스턴스 root 계정 ~/.ssh/authorized_keys 에 추가
docker-01 로 명명된 DO 인스턴스 생성 후 접속화면

위 스크린 샷에서 보이는 것 처럼, 로컬 PC 의 Docker client 가 docker-machine 으로 프로비젼닝한 원격 DO 인스턴스(여기서는 docker-01 로 명명)에 접속하여 원하는 docker 명령을 실행할 수도 있다.

이 도커 머신으로 생성한 인스턴스는, 비교적 셋업이 간단한 private docker registry 로 사용할 예정이다.

DO 1 Click Droplet Images

프로덕션용 Laravel PHP 앱을 배포하려면, 프로덕션 환경에서 사용할 MySQL, Redis, Elasticsearch, MongoDB 등 의 서비스 인프라를 어떻게 사용할 지 고민할 때가 올 것이다.

로컬 이나 테스트 환경에서는 Docker official 이미지들을 이용하지만, 프로덕션 환경에서는 대부분 경우, managed service 의 이용을 권장한다. 비교적 소규모 프로젝트 또는 managed service 를 fully 지원하지 않는 cloud service provider 를 이용하는 경우, Terraform/Ansible 을 사용하는 것이 적합하겠지만, DO 의 경우 1 Click droplet image 배포가 가능하여, DO Marketplace 에서 제공되는 MySQL 5.7 이미지를 사용하여 설치해 보았다. (참고로 최근 DO 도 Managed Databases 서비스가 GA 로 확대되었는데, 아직 MySQL 은 지원하지 않는다.)

해당 MySQL 이미지 설치 후, 고려사항은 아래와 같다.

  • Ubuntu 18.04 MySQL 5.7 의 root 사용자 auth plugin 은 기본적으로 mysql_native_password 가 아니라 auth_socket 으로 지정되어 있으므로, 로컬 PC 의 TablePlus 나 Sequel Pro 와 같은 DB 클라이언트에 접속을 위한 설정에 참고.
  • mysql_secure_installation 명령을 실행하여, secure 한 설정으로 변경.
  • 해당 DO 이미지는 MySQL 이외에 phpMyAdmin, Apache 등이 기본 제공되므로 다음 명령들로 제거.
    • apt purge phpmyadmin*
    • apt purge apache*
    • apt purge php*
    • apt autoremove
  • MySQL 8.0 과는 달리 MySQL 5.7의 character set 디폴트 값은 UTF8 이기때문에, 데이터베이스 생성시, create database something character set UTF8mb4 collate utf8mb4_bin 처럼 옵션값을 오버라이딩.
  • 다음 명령으로 localhost 나 private network 으로 접속할 수 있는 사용자 추가.
    • GRANT ALL ON . TO 'user'@'localhost' IDENTIFIED BY 'your-password';
    • GRANT ALL ON . TO 'user'@'10.130.0.0/255.255.0.0' IDENTIFIED BY 'your-password';
  • Laravel 환경변수 .env 의 mysql 접속주소는 private network 주소로 전달.

설명을 하다보니 CI 에 필요한 dockerization 에 대한 이야기를 하려던 원래 의도와는 달리, MySQL 환경설정에 관련 내용을 이야기 하며 off the track 으로 빠져버렸는데, 이 문서에서 설명하고자 하는 scope 밖의 내용이므로, MySQL 와 Redis 인스턴스 설치에 대한 이야기는 여기까지로 마치고, private network 으로 접속가능하고 firewall 로 접속 제한된 프로덕션 MySQL 과 Redis 인스턴스가 어딘가에 존재 한다고 가정하겠다.

Configure Laravel app with Docker

이제 다시 본론으로 돌아와서, Laravel 프로젝트(루트폴더가 appRoot 라고 가정)에 아래와 같이 Docker 관련 폴더를 만든다.

appRoot
    ├── :
    ├── :
    └── docker
        ├── app
        ├── cron
        └── worker

docker 폴더는 이제부터만들 dockerization 을 위한 config 파일 및 Dockerfile 등을 저장하는 폴더들이며 어떤 파일들이 어떤 폴더에 있는지는 차차 설명하기로 하겠다.

Docker for Laravel PHP App

우선, 기본이 되는 Laravel PHP 앱을 위한 Docker 이미지는 nginx 와 php-fpm 2개의 서비스를 각기 독립적으로 돌리지 않고, 하나의 컨테이너에서 실행되는 이미지를 만들도록 한다.

하나의 Docker 컨테이너에서 멀티 서비스를 실행하는 것에 대한 논란이 있지만, PHP 앱의 경우, 독립적인 Docker 서비스로 앱을 실행시키기 위한 overhead 가 많아, 득보다 실이 많다. (좀더 구체적인 이유는 아래 링크의 포스팅을 읽어 보기 바란다.)

https://blog.forma-pro.com/dockerize-a-php-project-supervisord-approach-53860e8b4d9e

따라서, 지금부터 생성할 Docker 이미지에서는 멀티 서비스(즉, nginx 와 php-fpm)를 실행할 것이며, 이를 위해 supervisor 가 두 서비스들을 구동한다.

💡 appRoot/docker/app/Dockerfile

Dockerfile 파일의 내용은 아래와 같고, ubuntu 18.04 로부터 필요한 패키지를 추가하였다. Docker Hub https://hub.docker.com/r/jinseokoh/nginx-php-fpm 으로도 해당 이미지의 다운이 가능하다.

FROM ubuntu:18.04

LABEL maintainer="Chuck JS. Oh"

ENV LANG="en_US.UTF-8"
ENV LANGUAGE="en_US:en"
ENV LC_ALL="en_US.UTF-8"
ENV DEBIAN_FRONTEND=noninteractive

RUN apt-get update && apt-get install -y \
  software-properties-common gnupg wget gnupg tzdata locales \
  && ln -fs /usr/share/zoneinfo/Asia/Seoul /etc/localtime \
  && locale-gen "en_US.UTF-8" \
  && echo "Asia/Seoul" > /etc/timezone \
  && dpkg-reconfigure -f noninteractive tzdata

RUN add-apt-repository -y ppa:ondrej/php
RUN apt-get update && apt-get install -y --no-install-recommends --fix-missing \
  curl zip unzip git supervisor sqlite3 nginx \
  php7.3-fpm \
  php7.3-cli \
  php7.3-sqlite3 \
  php7.3-gd \
  php7.3-curl \
  php7.3-bcmath \
  php7.3-imap \
  php7.3-mysql \
  php7.3-mbstring \
  php7.3-xml \
  php7.3-zip \
  php7.3-bcmath \
  php7.3-soap \
  php7.3-intl \
  php7.3-readline \
  php-raphf \
  php-msgpack \
  php-igbinary \
  && php -r "readfile('http://getcomposer.org/installer');" | php -- --install-dir=/usr/bin/ --filename=composer \
  && mkdir /run/php \
  && apt-get remove -y --purge software-properties-common \
  && apt-get -y autoremove \
  && apt-get clean \
  && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* \
  && echo "daemon off;" >> /etc/nginx/nginx.conf \
  && ln -sf /dev/stdout /var/log/nginx/access.log \
  && ln -sf /dev/stderr /var/log/nginx/error.log

COPY default /etc/nginx/sites-available/default
COPY php-fpm.conf /etc/php/7.3/fpm/php-fpm.conf
COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf
COPY start-container /usr/bin/start-container

RUN chmod +x /usr/bin/start-container

ENTRYPOINT ["start-container"]
💡 appRoot/docker/app/default

nginx 용 디폴트 서버 config 파일 내용은 아래와 같다.

server {
    listen 80;
    root /var/www/public;
    index index.php index.html;
    charset utf-8;

    server_name _;
    # server_name backend.test;

    location / {
        try_files $uri $uri/ /index.php$is_args$args;
    }

    location = /favicon.ico { access_log off; log_not_found off; }
    location = /robots.txt  { access_log off; log_not_found off; }

    location ~ \.php$ {
        include snippets/fastcgi-php.conf;
        fastcgi_pass unix:/run/php/php7.3-fpm.sock;
    }

    error_page 404 /index.php;

    location ~ /\.(?!well-known).* {
        deny all;
    }
}
💡 appRoot/docker/app/php-fpm.conf

php-fpm 의 설정의 경우, supervisor 에 의해 구동되므로 foreground 로 실행하는 옵션과, 에러 로그를 /dev/stderr 에 해당하는 /proc/self/fd/2 로 지정한 내용이 전부다.

;;;;;;;;;;;;;;;;;;;;;
; FPM Configuration ;
;;;;;;;;;;;;;;;;;;;;;

; All relative paths in this configuration file are relative to PHP's install
; prefix (/usr). This prefix can be dynamically changed by using the
; '-p' argument from the command line.

[global]
pid = /run/php/php7.3-fpm.pid
error_log = /proc/self/fd/2
daemonize = no
include=/etc/php/7.3/fpm/pool.d/*.conf
💡 appRoot/docker/app/supervisord.conf

supervisor 설정내용은, Docker 가 이 이미지를 실행할때, supervisor 를 daemon 모드가 아닌 foreground 로 실행하라는 옵션과, nginx 와 php-fpm 7.3 을 실행하면서, 해당 로그는 파일이 아닌 텍스트 스트림으로 지정하는 옵션이 추가되었다.

[supervisord]
nodaemon=true

[program:nginx]
command=nginx
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0

[program:php-fpm]
command=php-fpm7.3
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0

지금까지의 나열한 파일들이 준비되었다면, 프로젝트 root 폴더에서, 아래의 명령으로 컨테이너 이미지를 빌딩한다.

docker build -f app/docker/Dockerfile -t jinseokoh/laravel-app:latest ./app/docker

컨테이너 빌딩이 끝난 후 아래의 명령을 실행하면, 해당 컨테이너가 실행되며 라라벨 PHP 앱이 동작하는 것을 브라우저를 통해 확인할 수 있다.

docker run --rm -it -p 80:80 -v $(pwd):/var/www jinseokoh/laravel-app

Docker Compose

Docker Compose 는 docker-compose.yml 파일을 사용하여, 도커 컨테이너를 쉽게 구동 시킬 수 있는 툴이다.

💡 appRoot/docker-compose.yml
version: "3"
services:
  app:
    build:
      context: ./docker/app
      dockerfile: Dockerfile
    image: jinseokoh/laravel-app
    working_dir: /var/www
    ports:
      - ${APP_PORT}:80
    networks:
      - app-network
    volumes:
      - .:/var/www
  redis:
    image: redis:alpine
    networks:
      - app-network
    volumes:
      - ~/.docker/data/redis:/data
  mysql:
    image: mysql:5.7
    environment:
      MYSQL_ROOT_PASSWORD: secret
      MYSQL_DATABASE: meso
      MYSQL_USER: homestead
      MYSQL_PASSWORD: secret
    ports:
      - ${DB_PORT}:3306
    networks:
      - app-network
    volumes:
      - ~/.docker/data/mysql:/var/lib/mysql
networks:
  app-network:
    driver: bridge
volumes:
  mysql:
    driver: local
  redis:
    driver: local

위 yaml 파일이 준비되었다면, 로컬 및 테스트를 위한 app, redis, mysql 컨테이너들의 실행을 docker-compose 명령으로 실행할 수 있다. 빈번히 사용하는 docker-compose 명령을 살펴보면 다음과 같다.

app 컨테이너에서 특정 명령 수행

APP_PORT=80 DB_PORT=3306 docker-compose run --no-deps --rm -w /var/www app composer update

모든 컨테이너 서비스 실행 명령

APP_PORT=80 DB_PORT=3306 docker-compose up -d

app 컨테이너의 로그 출력 명령

APP_PORT=80 DB_PORT=3306 docker-compose logs app

모든 컨테이너 서비스 중단 명령

docker-compose down
💡 appRoot/h (초안)

자, 이제 프로젝트의 루트에 h 라는 이름의 헬퍼 스크립트를 만들어 보겠다. 이 스크립트는 각기 다른 실행환경에서의 적절한 커맨드 옵션을 많은 타이핑 없이 쉽게 입력할 수 있는 헬퍼 shell 커맨드이다. 아래의 내용으로 파일을 만든 뒤 chmod +x h 명령으로 실행 속성을 준다.

#!/usr/bin/env bash

export APP_PORT=${APP_PORT:-80}
export DB_PORT=${DB_PORT:-3306}

if [ $# -gt 0 ];then
  docker-compose "[email protected]"
else
  docker-compose ps
fi

이제 ./h up -d 이나 ./h down 등의 명령으로 서비스를 올리거나 내릴 수 있다.

  • 앱 컨테이너의 내용을 살펴보려면 ./h run --rm app ls -al
  • 라라벨 artisan CLI 명령으로 마이그레이션 명령을 내리려면, ./h run --rm app php artisan migrate

와 같이 사용할 수 있다. 현재까지는 명령 입력시 장황한 키입력의 개선이 거의 없지만, 이글의 후반부에서, 좀 더 개선된 내용을 살펴보겠다.

💡 appRoot/docker-compose.ci.yml

여기까지 도달했다면 Jenkins 에서, Continuous Integration 을 위해 사용할 docker-compose.ci.yml 파일을 만들고, 아래 내용을 입력한다.

version: "3"
services:
  app:
    build:
      context: ./docker/app
      dockerfile: Dockerfile
    image: jinseokoh/laravel-app
    working_dir: /var/www
    networks:
      - app-network
    volumes:
      - .:/var/www
  redis:
    image: redis:alpine
    container_name: redis
    networks:
      - app-network
    volumes:
      - /tmp/redis:/data
networks:
  app-network:
    driver: bridge
volumes:
  redis:
    driver: local

기존의 docker-compose.yml 에서, 젠킨스가 테스트를 돌릴경우 불필요한, port binding 이 삭제되었고, phpunit 을 사용시 MySQL 대신 Sqlite3 을 사용할 것이기 때문에 mysql 서비스 관련내용도 삭제된 내용이다. (Redis 는 unit-test 에서 사용할 것을 대비 남겨둠. 테스트를 위한 minimum system requirement 라고 보면된다.)

💡 appRoot/docker-compose.dev.yml

내친김에 로컬 개발 환경을 위한 docker-compose.dev.yml 파일도 만들고, 그 내용은 위에서 작성한 docker-compose.yml 파일을 그대로 카피한다. (docker-compose.yml 파일은 프로덕션 배포용으로 개선할 예정이다.)

💡 appRoot/h (개선안)

위에서 작성했던 같단한, 헬퍼 스크립트를 아래와 같이 확장했다.

#!/usr/bin/env bash

# set environment variables
export APP_PORT=${APP_PORT:-80}
export DB_PORT=${DB_PORT:-3306}

# set default options
STAGE="dev"
TTY=""
# set CI options (if BUILD_NUMBER exists, it's Jenkins)
if [ ! -z "$BUILD_NUMBER" ]; then
    STAGE="ci"
    TTY="-T"
fi

DOCKER_COMPOSE="docker-compose -f docker-compose.$STAGE.yml"

if [ $# -gt 0 ]; then
    
    if [ "$1" == "start" ]; then
        $DOCKER_COMPOSE up -d
    elif [ "$1" == "stop" ]; then
        $DOCKER_COMPOSE down
    
    elif [ "$1" == "artisan" ]; then
        shift 1
        $DOCKER_COMPOSE run --rm $TTY app php artisan "[email protected]"
    elif [ "$1" == "composer" ]; then
        shift 1
        $DOCKER_COMPOSE run --rm $TTY app composer "[email protected]"
    elif [ "$1" == "phpunit" ] || [ "$1" == "test" ]; then
        shift 1
        $DOCKER_COMPOSE run --rm $TTY app ./vendor/bin/phpunit "[email protected]"

    elif [ "$1" == "a" ]; then
        shift 1
        $DOCKER_COMPOSE exec app php artisan "[email protected]"
    elif [ "$1" == "c" ]; then
        shift 1
        $DOCKER_COMPOSE exec app composer "[email protected]"
    elif [ "$1" == "p" ] || [ "$1" == "t" ]; then
        shift 1
        $DOCKER_COMPOSE exec app ./vendor/bin/phpunit "[email protected]"
    
    else
        $DOCKER_COMPOSE "[email protected]"
    fi

else
    docker-compose ps
fi

이제 아래와 같은 짧은 명령행 타이핑만으로도, docker-compose 관련 루틴한 명령들의 수행이 가능해져서, 보다 수월한 dockerized 환경내의 코딩이 가능하다.

  • ./h start — dev 환경 spin off
  • ./h stop — dev 환경 spin down
  • ./h artisan tinker — tinker 실행
  • ./h composer – composer 실행
  • ./h test — test 실행

Jenkins 실행시에는 BUILD_NUMBER 라는 환경변수가 자동으로 설정되는데, 이를 detect 하여 로컬 환경인지 아니면 Jenkins 환경인지를 구분할 수가 있다. 따라서, ./h 스크립트는 BUILD_NUMBER 환경변수가 설정되어 있는지 아닌지를 검사하여,

  • BUILD_NUMBER 환경변수 있으면, docker-compose.ci.yml
  • BUILD_NUMBER 환경변수 없으면, docker-compose.dev.yml

자동으로 자기 환경에 맞는 yaml 파일을 픽업하고, 필요한 컨테이너들만 실행한다. 따라서, 로컬 개발용 PC 에서나, Jenkinsfile 에서 편하게 사용할 수 있다.

다음 포스팅에서는 Jenkins 에 Laravel PHP 앱 프로젝트를 등록하고 Jenkinsfile 에서 Pipeline 단계별 동작들을 어떻게 지정하는지 살펴보겠다.

Leave a Reply