Laravel with Jenkins (4/5) – Jenkins CI Pipelines

Jenkins 초기 화면

Jenkins 초기화면에서 Create New Jobs 를 누르고, Multibranch Pipeline 을 선택한다. 그 다음 입력화면에서, 프로젝트 이름, GitHub project URL, GitHub hook trigger for GITScm polling, Pipeline script 등 자신의 Jenkins 가 픽업해야할 GitHub 프로젝트에 대한 내용을 입력한다.

이 부분이 번거롭다 생각들면, Blue Ocean 플럭인을 설치 후, 왼쪽 메뉴바에서 Open Blue Ocean 메뉴를 선택 후,

  1. 새로운 Github Pipeline 생성,
  2. Github 페이지에서 Personal Access Token (Jenkins Blue Ocean 에 해당 링크가 나온다.) 를 생성후, 해당 토큰값을 입력,
  3. Github 사용자명을 선택하고 리파지토리 스캔 후 Jenkinsfile 이 있는 자신의 프로젝트 선택

과정을 걸쳐 GitHub 프로젝트를 선택할 수도 있다.

💡 ./Jenkinsfile

Jenkinsfile 파일은 Jenkins 의 파이프라인 즉, build => test => package => deploy 단계별 동작을 정의하는 파일이며, 프로젝트 root 에 아래와 같은 내용으로 만든다. (Jenkins classic theme 에서는 Pipeline 을 선택하면 BRANCH_NAME 환경변수가 셋팅되지 않으므로, Multibranch pipeline 이나 Freestyle 을 지정해야 하며, 임산부나 심신이 미약한 사용자는 셋팅이 좀더 쉬운 Blue Ocean 플럭인의 사용을 추천한다.)

#!/usr/bin/env groovy

node('master') {
    try {
        stage('build') {
            git url: '[email protected]:jinseokoh/nuxt-app-backend.git'
            sh "./h start"
            sh "./h composer install"
            // .env for testing
            sh "cp .env.example .env"
            sh "./h artisan key:generate"
        }

        stage('test') {
            sh "APP_ENV=testing ./h test"
        }

        stage('package') {
            if ("${env.BRANCH_NAME}" == 'master') {
                sh './docker/build'
            }
        }

        stage('deploy') {
            // sh "/usr/bin/ansible-playbook /opt/deploy.yml -i /etc/ansible/digital_ocean.py"

            sh "echo 'WE ARE DEPLOYING'"
        }
    } catch (error) {
        throw error
    } finally {
        sh "./h stop"
    }
}

위 Jenkinsfile 를 보면 BRANCH_NAME 이 master 인 경우에, ./docker/build 라 명명된 스크립트를 실행하여, 새로운 도커 이미지를 빌드하는 내용이 있는데, 이 스크립트는

  • 현재 master 브랜치의 최신 소스코드 내용을 appRoot/docker/app/packaged 라는 하위 폴더에 다운
  • 해당 폴더에 composer 로 dependency 설치
  • DO Spaces 로 부터 production 용 .env 파일 다운
  • 새로운 Docker 이미지 빌드 및 Docker registry 로 해당 이미지 푸시
  • 해당 폴더 삭제

와 같은 일련의 동작을 하도록 지정한 스크립트이며, 아래에 첨부한 내용과 같다.

💡 appRoot/docker/build

#!/usr/bin/env bash

# bail out on first error
set -e

# get the directory of the build script
DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"

# get the current git commit sha
HASH=$(git rev-parse HEAD)

# package the app
cd $DIR/../
git archive --format=tar --worktree-attributes $HASH | tar -xf - -C $DIR/app/packaged

# production build steps
cd $DIR/app/packaged
./h composer install --no-dev

# get the production .env
s3cmd get s3://example-env/.env.production .env --force

# build the docker image with the latest code
cd $DIR/app
docker build \
  -t registry.example.com/jinseokoh/laravel-app:latest \
  -t registry.example.com/jinseokoh/laravel-app:$HASH .

# push images to private docker registry
docker push registry.example.com/jinseokoh/laravel-app:latest
docker push registry.example.com/jinseokoh/laravel-app:$HASH

# clean up packaged directory
cd $DIR/app
PWD=$(pwd)
if [ "$PWD" == "$DIR/app" ]; then
  docker run --rm -w /opt -v $(pwd):/opt ubuntu:18.04 bash -c "rm -rf packaged"
  mkdir packaged
  touch packaged/.gitkeep
fi

Private Docker Registry

따라서, 빌드한 도커 이미지를 저장할 private docker registry 가 필요하다. private docker registry 을 생성하는 방법은 다양한 옵션이 가능하지만, 개인적으로는 아래와 같은 간단한 절차로 Docker Registry 를 별도의 DO 인스턴스에 만들어, https://registry.example.com 라는 주소에 접속할 수 있도록 만들었다.

  • docker-machine 으로 registry 인스턴스 생성
  • DO DNS 셋업
  • docker-compose / nginx / htpasswd / cerbot 패키지 설치
  • nginx port forwarding 설정
  • nginx.conf 에 client_max_body_size 2G 추가

단계별 상세 설명은 아래 문서를 참고하자.

https://www.digitalocean.com/community/tutorials/how-to-set-up-a-private-docker-registry-on-ubuntu-18-04

💡 docker-compose.yml

최종적인 docker-compose 파일의 내용은 아래와 같다.

version: '3'

services:
  registry:
    image: registry:2
    ports:
    - "5000:5000"
    environment:
      REGISTRY_AUTH: htpasswd
      REGISTRY_AUTH_HTPASSWD_REALM: Registry
      REGISTRY_AUTH_HTPASSWD_PATH: /auth/registry.password
      REGISTRY_STORAGE_FILESYSTEM_ROOTDIRECTORY: /data
    restart: unless-stopped
    volumes:
      - ./auth:/auth
      - ./data:/data
      - ./config.yml:/etc/docker/registry/config.yml
💡 config.yml

private docker registry 의 커스텀 config.yml 의 내용은 아래와 같다. 이미지 삭제를 enable 시킨 내용이 추가되었다.

version: 0.1
log:
  fields:
    service: registry
storage:
  cache:
    blobdescriptor: inmemory
  delete:
    enabled: true
  filesystem:
    rootdirectory: /var/lib/registry
http:
  addr: :5000
  headers:
    X-Content-Type-Options: [nosniff]
health:
  storagedriver:
    enabled: true
    interval: 10s
    threshold: 3

Jenkins 인스턴스에서, 다음과 같은 docker login 명령을 실행하여, 레지스트리 접속 credentials 정보를 입력한다.

sudo docker login --username=jenkins registry.example.com

만일 Error saving credentials: error storing credentials - err: exit status 1, out: Cannot autolaunch D-Bus without X11 $DISPLAY 와 같은 에러 메시지가 나온다면,

아래의 명령을 입력하여 필요한 패키지들을 설치 후 다시 실행하면 된다.

sudo apt install gnupg2 pass

이제 아래의 curl 명령으로 레지스트리로부터 {"repositories":[""]} 라는 응답 오는 것을 확인했다면, 정상적으로 동작하는 private docker registry 를 갖게 되었다는 의미이다. Congrats!

curl -u jenkins:{your-password} https://registry.example.com/v2/_catalog

물론, 장기적인 운영을 하다보면, 일정 갯수 이상의 이미지를 보관할 필요는 없으므로, private docker registry 의 house keeping 을 해줄 필요가 있을 터이나, 이 문서의 scope 을 벗어나는 내용이므로, 실제 도커 레지스트리 운영에 관심이 있다면 아래 링크를 참고하기 바란다.

https://stackoverflow.com/questions/29802202/docker-registry-2-0-how-to-delete-unused-images

Jenkins in Action for Production Images

이제 GitHub 에 새로운 commit 을 push 하거나, Build Now 메뉴를 클릭하면, 아래 이미지와 같이 파이프라인별 진행 상황을 볼 수 있다.

젠킨스 Classic Theme 동작 화면

만일, Jenkins Blue Ocean 플럭인을 선택하였다면, 아래와 같은 보다 개선된 UI 를 볼 수 있다.

Jenkins Blue Ocean 파이프라인화면

젠킨스 CI/CD 과정이 처음으로 성공하였다면, 빌딩된 도커이미지가 private docker registry 에 {“repositories”:[“jinseokoh/laravel-app”]} 와 같이 등록된 것을 볼 수 있어야만 하며, Jenkins 인스턴스에서도 그 이미지가 아래처럼 리스팅 되어야만 한다.

Jenkins 인스턴스에서 docker images 확인

지금까지의 파이프라인 동작으로, GitHub 계정의 Laravel PHP 앱 프로젝트 리포에 master 브랜치를 배포하면, Jenkins 가 그것을 픽업하여 Jenkins 인스턴스의 Docker 환경에서 테스트를 진행하고, 오류가 발생하지 않으면 Docker 이미지를 빌드한 다음, 별도의 인스턴스에 구축한 private docker registry 에 푸쉬하는 과정을 살펴보았다. 이제 프로덕션 코드를 위한 새로운 인스턴스가 필요하다.

Deploy Laravel-app to production droplet

프로덕션용 Laravel PHP 앱이 빌딩되어 도커 이미지로 registry 에 푸쉬되었다면, 그 이미지를 실행할 “Docker 가 설치된 인스턴스”에 배포해야 하는데, 그 인스턴스는 이미 앞서 언급했 듯, Terraform/Ansible 조합을 이용하여 생성한다. 이전 Terraform 포스팅 설명 내용과 함께 비교해 보면, 각각의 차이점을 볼 수 있는 좋은 예시가 되리라 본다.

Terraform

이전 포스팅에서는, 테라폼 변수 내용을 명령행 인자로 넘겨주는 방법을 쓴 것에 비해, 이번에는 테라폼 변수 내용을 terraform.tfvars 파일에 작성하고 이를 픽업하도록 만드는 방법을 사용했다.

💡 terraform.tfvars

아래와 같이, terraform.tfvars 을 작성한다. 이 파일 이름을 .gitignore 에 등록하여 유출을 막도록 하자. fingerprint# 값은 DO 에 등록된 SSH public 키의 해쉬값이다. (즉 3개의 머신으로부터, 새롭게 만들어지는 DO 인스턴스에 SSH 접속이 가능하도록 만들기 위함이다.)

do_api_token = "d8a22e27a1b440fa4a91a6f65e0cbfd1f42a781df5a508bfae2fa7127a6axxxx"
fingerprint1 = "1f:8f:97:8b:1d:c2:83:d9:24:40:81:3d:93:94:xx:xx"
fingerprint2 = "ce:33:72:a6:03:bc:ae:21:5c:d8:de:10:67:ac:xx:xx"
fingerprint3 = "af:45:a7:88:d5:60:e3:b1:9d:a5:92:0f:5c:22:xx:xx"
💡 main.tf
variable "do_api_token" {}
variable "fingerprint1" {}
variable "fingerprint2" {}
variable "fingerprint3" {}

## cloud privider
provider "digitalocean" {
  token = "${var.do_api_token}"
}

## firewall
resource "digitalocean_firewall" "api" {
  name = "api"

  droplet_ids = [
    "${digitalocean_droplet.api.id}",
  ]

  inbound_rule = [
    {
      protocol   = "tcp"
      port_range = "22"
    },
    {
      protocol   = "tcp"
      port_range = "80"
    },
    {
      protocol   = "tcp"
      port_range = "443"
    },
  ]
}

## instance
resource "digitalocean_tag" "api" {
  name = "api"
}

resource "digitalocean_droplet" "api" {
  name               = "api01"
  image              = "ubuntu-18-04-x64"
  region             = "sgp1"
  size               = "s-1vcpu-1gb"
  private_networking = true
  monitoring         = true

  tags = [
    "${digitalocean_tag.api.name}",
  ]

  ssh_keys = [
    "${var.fingerprint1}",
    "${var.fingerprint2}",
    "${var.fingerprint3}",
  ]
}

resource "digitalocean_record" "example" {
  name   = "api"
  type   = "A"
  domain = "example.com"
  value  = "${digitalocean_droplet.api.ipv4_address}"
}

위 설정을 갖고 terraform init, terraform plan, terraform apply 를 차례로 진행하면 api01 라는 이름의 인스턴스를 생성할 수 있다. 다음으로, 이 인스턴스의 설정을 위해 Ansible 을 사용해보자.

Ansible to the rescue!

아직 Ansible 을 설치하지 않았다면 homebrew 를 이용하여 설치하고, /etc/ansible 폴더에 Ansible 에서 배포하는 DigitalOcean external inventory script (digital_ocean.pydigitial_ocean.ini) 를 아래 링크로부터 복사한 다음, pip install requests 명령을 내리면 필요한 디펜던시들이 설치된다.

https://github.com/ansible/ansible/tree/devel/contrib/inventory

/etc/ansible/digital_ocean.ini 파일의 내용 중 아래 2개 변수를 수정하고

api_token = your_digital_ocean_api_token_goes_here
:
group_variables = { 'ansible_user': 'root' }

스크립트가 동작하는지 확인하기 위하여, 아래 명령을 내려본다.

/etc/ansible/digital_ocean.py --pretty

자신의 DO 인벤토리 내용이 정상적으로 출력되면, 이제 첫번째 ansible playbook 을 만들 차례이다.

💡 playbook.yml

플레이북은 yaml 파일로 정의하며, 각 task 별 수행내용을 name 키에 적어 놓았으므로 참고하기 바라며, 전체 파일은 https://github.com/jinseokoh/ansible 에서 다운받을 수 있다.

---
- hosts: api01
  gather_facts: false
  become: true
  vars:
    ansible_python_interpreter: /usr/bin/python3

  tasks:
    - name: "sudo apt update"
      apt:
        update_cache: yes
      tags:
        - system

    - name: "APT - 도커 GPG key 추가"
      apt_key:
        url: https://download.docker.com/linux/ubuntu/gpg
        state: present

    - name: "APT - 도커 리파지토리 추가"
      apt_repository:
        repo: "deb [arch=amd64] https://download.docker.com/linux/ubuntu bionic stable"
        state: present
        filename: docker

    - name: "APT - 기본 패키지 설치"
      apt:
        pkg:
          - "apt-transport-https"
          - "ca-certificates"
          - "curl"
          - "vim"
          - "python3-pip"
          - "software-properties-common"
        state: present

    - name: "PIP - docker-py 패키지 설치"
      pip:
        name: "docker-py"

    - name: "APT - 도커 설치"
      apt:
        name: "docker-ce"
        update_cache: yes

    - name: "도커 - 레지스트리 로그인"
      docker_login:
        registry: registry.hanlingo.com
        username: jenkins
        password: dhfgoRhr1djrekftjd!
        reauthorize: yes

    - name: "디렉토리 생성 - /opt/conf.d"
      file:
        path: /opt/conf.d
        state: directory

    - name: "파일복사 - nginx reverse proxy 설정 config"
      copy:
        src: "./files/nginx.conf"
        dest: "/opt/conf.d/proxy.conf"
        mode: 0644

    - name: "파일복사 - rolling deployment bash script"
      copy:
        src: "./files/deploy.sh"
        dest: "/opt/deploy"
        mode: 0755

    - name: "sudo apt autoclean"
      apt:
        autoclean: yes
      tags:
        - system

    - name: "sudo apt autoremove"
      apt:
        autoremove: yes
      tags:
        - system

아래와 같이 playbook 실행 명령을 내리면

ansible-playbook ./playbook.yml -i /etc/ansible/digital_ocean.py

Terraform 으로 만든 DO 프로덕션용 api01 인스턴스에, 도커를 실행시키기 위한 모든 패키지 설정이 한방에 이뤄진다. 아래의 명령으로 레지스트리로부터 도커 이미지를 다운 받아 실행시켜보자.

docker run -d -p 80:80 \
   --restart=unless-stopped \
   --name=app \
   registry.example.com/jinseokoh/laravel-app

그러면 아래와 같이 app 이라 명명된 하나의 이미지가 돌아가는 것을 docker ps 명령으로 확인 할 수 있다.

docker-01 인스턴스에 프로덕션 코드 배포

브라우저를 이용하여 http://api.example.com 주소로 접속해 보면, 정상적으로 Laravel 앱이 실행되는 걸 확인 할 수 있다. Yay!

Rolling deployment

위에서는 빌딩한 이미지를 api01 인스턴스의 80 포트에 바인딩해서, 아래와 같이 실행하였는데

browser
   └─ droplet (api01)
     └─ app 이미지 컨테이너

rolling deployment 스타일 배포를 위하여, 아래처럼 nginx 도커 이미지를 이용하여, nginx reverse proxy 를 거쳐 app 이미지 컨테이너로 트래픽을 전달하게 만든다.

browser
   └─ droplet (api01)
     └─ nginx reverse proxy
       └─ app 이미지 컨테이너

위의 playbook.yml 58~62번째 줄을 보면 nginx.conf 을 /opt/conf.d/proxy.conf 로 복사하는 내용이 있는데, 이것이 바로 nginx reverse proxy 를 위한 nginx 설정이며, 그 내용은 아래와 같다.

💡 /opt/conf.d/proxy.conf
upstream container {
  server app:80;
}

server {
  listen 80 default_server;
  server_name _;

  location / {
    proxy_set_header Host $host:$server_port;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;

    proxy_pass http://container;
    proxy_redirect off;

    # Handle web socket connection
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
  }
}

nginx reverse proxy 설정 파일이 이미 인스턴스 안에 존재하므로, 현재 그 인스턴스에서 실행중인 컨테이너를 중단 (docker stop app) 하고, 삭제 (docker rm -v app) 한 다음, 아래의 커맨드라인 Docker 명령을 하나씩 실행해 보자.

docker network create app-network

docker run -d \
  --name=app \
  --network=app-network \
  --restart=unless-stopped \
  registry.hanlingo.com/jinseokoh/laravel-app

docker run -d \
  --name=nginx \
  --network=app-network \
  --restart=unless-stopped \
  -v /opt/conf.d:/etc/nginx/conf.d \
  -p 80:80 \
  nginx:alpine

이전과 마찬가지로, 브라우저를 이용하여 http://api.example.com 주소로 접속해 보면 Laravel PHP 앱이 정상 동작하는 것을 확인할 수 있어야 한다. nginx reverse proxy 가 제대로 동작한다면, 마지막으로, 새로운 도커 이미지가 만들어질때마다 어떻게 rolling deployment 작업을 수행할지 각 단계별 수행 내용들을 생각해 보자.

  1. PHP 앱의 latest 이미지를 registry 로부터 다운
  2. 새롭게 다운받은 이미지로 도커 컨테이너 실행
  3. nginx 가 트래픽을 새로 실행한 도커 컨테이너로 보내도록 config 변경 후 재실행
  4. 이전 컨테이너 실행 중단/제거 후 이미지 삭제

위 리스트에 나열된 작업들을 차례대로 수행하는 것이 바로 rolling deployment 를 수행하는 것이며, 이를 위한 bash 스크립트는 이미 작성하여, 위 playbook.yml 64~68 번째줄의 task 로, /opt/deploy 위치에 복사해 놓았다. 그 내용을 살펴보면 아래와 같다.

💡 /opt/deploy

주석문의 번호와 위 설명 리스트의 번호가 일치하므로 참고한다.

#!/usr/bin/env bash

APP_CONTAINER=$(sudo docker ps -a -q --filter="name=app")
NEW_CONTAINER="app`date '+%y%m%d%H%M%S'`"
REGISTRY="registry.hanlingo.com/jinseokoh/laravel-app"
DANGLING_IMGS=$(sudo docker image ls -f "dangling=true" -q)
RUNNING_IMG=$(sudo docker inspect $(sudo docker ps -a -q --filter="name=app") | grep -m 1 -o 'sha256[^"]*')
CURRENT_IMG=$(sudo docker image inspect $REGISTRY | grep -m 1 -o 'sha256[^"]*')

# 1) pull the latest image
sudo docker pull $REGISTRY

# avoid deployment if running image is latest
if [ "$CURRENT_IMG" == "$RUNNING_IMG" ]; then
    echo "The latest image is already in use."
    exit 0
fi

# 2) spin off new instance
NEW_APP_CONTAINER=$(sudo docker run -d --network=app-network --restart=unless-stopped --name="$NEW_CONTAINER" $REGISTRY)

# wait for processes to boot up
sleep 5
echo "Started new container $NEW_APP_CONTAINER"

# 3) update nginx
sudo sed -i "s/server app.*/server $NEW_CONTAINER:80;/" /opt/conf.d/proxy.conf

# config test
sudo docker exec nginx nginx -t
NGINX_STABLE=$?

if [ $NGINX_STABLE -eq 0 ]; then
    # reload nginx
    sudo docker kill -s HUP nginx

    # 4) stop older instance
    sudo docker stop $APP_CONTAINER
    sudo docker rm -v $APP_CONTAINER
    echo "Removed old container $APP_CONTAINER"

    # cleanup
    if [ ! -z "$DANGLING_IMGS" ]; then
        sudo docker image rm $DANGLING_IMGS
    fi
else
    echo "ERROR: nginx config test failed."
    exit 1
fi

api01 인스턴스에 접속 후 커맨드라인에서 /opt/deploy 라는 명령을 내리면, 아래 스크린 샷에서 확인 할 수 있는 것 처럼 새로운 도커 이미지로 rolling update 가 이뤄지는 것을 볼 수 있다.

따라서, Jenkins 인스턴스에서 api01 인스턴스에 존재하는 /opt/deploy script 를 실행시길 방법이 필요한데, 이는 ansible 을 사용하여 다음과 같이 작성했다.

💡 deploy.yml

로컬 PC 에서 아래와 같이 ansible playbook 명령을 실행하면, Ansible 을 이용하여, api01 인스턴스의 deploy 스크립트를 실행할 수 있음을 확인할 수 있다.

ansible-playbook ./deploy.yml -i /etc/ansible/digital_ocean.py
---
- hosts: api01
  gather_facts: false
  become: true
  vars:
    ansible_python_interpreter: /usr/bin/python3

  tasks:

    - name: "run rolling deployment bash script"
      command: "sh /opt/deploy"

지금까지 많은 작업을 했는데, 이제 다음 포스팅에서는

  1. Jenkins 인스턴스에서도 위의 playbook 파일을 실행할 수 있도록 Ansible 설치와 설정을 하고,
  2. Laravel PHP 앱 코드베이스에 있는 Jenkinsfile 의 내용을 수정하여 registry 로부터 다운받은 이미지로 rolling deployment 를 하게 만들고
  3. 더불어, certbot 을 이용하여, HTTPS 접속이 가능하도록 만드는 작업 등

에 대하여, 설명을 이어가겠다.

Leave a Reply