Validating Android In-App Purchases with Laravel

Laravel 앱을 백앤드로 사용하는 안드로이드 프로젝트에서, 인앱결제 후 결제정보를 백앤드 API 을 이용하여 시스템에 반영하고 있었는데, 어느새 해당 API 를 임의로 호출하는 해킹 시도가 발생했다. 인앱 결제의 유효성 검증 단계 없이 저장하는 이런 보안에 취약한 API 를 위하여, 영수증 토큰값을 구글 API 를 통해 결제가 유효한지 검사하는 검증 로직과, 정기적으로 환불/주문취소/지불거절을 통해 무효화된 과거 인앱 주문에 대한 관리 로직을 추가했는데, 이번 포스팅에서 그 내용을 살펴본다.

아래 링크로 이동 하면 구글 개발자 콘솔상에서 해야할 내용들을 찾아볼 수 있다. (지만, 내용이 많고 장황한 설명이 제공되어, 원하는 내용을 한눈에 확인하기는 쉽지 않다.)

https://developers.google.com/android-publisher/getting_started?hl=ko#setting_up_api_access_clients

Google Developer Console

개발자 콘솔에 설정 > API 액세스 메뉴에서, 이미 등록된 OAuth 클라이언트와 서비스 계정이 없다면, 새롭게 만든다. 기등록된 서비스 계정이 있더라도, 해당 계정에 재무정보를 볼수 있는 권한이 없다면 해당 권한을 추가한다.

콘솔상에서, 서비스 계정을 생성시 다운받은 JSON credentials 파일은 서버의 적당한 위치에 복사하도록 한다. (본인의 경우 S3 에 저장한 다음, deploy 시에 base_path() 위치로 복사하는 방법을 사용했다.)

이제 구글 Play API 를 통하여 정상적인 결제 요청인지를 검증하는 API 와, 취소된 결제 리스트를 받아오는 API 를 위한 핸들러를 만들어야 하는데, 구글에서 제공하는 아래 패키지를 추가하면, OAuth 토큰 관리 등에 관한 로직을 직접 만들지 않아도 되므로, 간단히 작성할 수 있다.

composer require google/apiclient

위 패키지를 설치 후, 작성한 핸들러의 내용은 아래와 같다.

<?php

namespace App\Services;

use Exception;
use Google_Client;
use Google_Exception;
use Google_Service_AndroidPublisher;

class GooglePaymentHandler
{
    private $client;
    private $service;

    public function __construct(Google_Client $client)
    {
        $this->client = $client;
        $this->client->addScope(['https://www.googleapis.com/auth/androidpublisher']);
        $this->client->setAuthConfig(base_path() . '/google.credentials.json');
        $this->client->setIncludeGrantedScopes(true);
    }

    /**
     * 결제 검증 API
     * @param string $appName, (e.g. `개나소나`)
     * @param string $productCode, 안드로이드 상품코드 (e.g. `com.whatever.history.06`)
     * @param string $purchaseToken, 안드로이드 결제 영수증 코드
     * @return object|null
     * @throws Google_Exception
     */
    public function verify(string $appName, string $productCode, string $purchaseToken): ?object
    {
        $this->client->setApplicationName = $appName;
        $this->service = new Google_Service_AndroidPublisher($this->client);

        return $this->service->purchases_products->get(
            $this->getPackageName($appName), // com.some.thing
            $productCode,
            $purchaseToken
        );
    }

    /**
     * 취소한 결제 리스트 API
     * @param string $appName
     * @return object|null
     * @throws Exception
     */
    public function voidedPurchases(string $appName): ?object
    {
        $this->client->setApplicationName = $appName;
        $this->service = new Google_Service_AndroidPublisher($this->client);

        return $this->service->purchases_voidedpurchases->listPurchasesVoidedpurchases(
            $this->getPackageName($appName) // com.some.thing
        );
    }

    /**
     * convert app-name to package-name (com.some.thing)
     *
     * @param string $appName
     * @return string|null
     */
    private function getPackageName(string $appName): ?string
    {
        if ($appName === '개나소나') {
            return 'com.some.thing.xxx';
        }
        if ($appName === '돈크라이') {
            return 'com.some.thing.yyy';
        }
        if ($appName === '함흥차사') {
            return 'com.some.thing.zzz';
        }

        return null;
    }
}

각 API 로부터 리턴되는 객체에 대한 설명은 아래의 링크에서 볼 수 있다.

https://developers.google.com/android-publisher/api-ref/purchases/products/get
https://developers.google.com/android-publisher/api-ref/purchases/voidedpurchases/list

일반적이라면, verify() API 의 경우, 매 결제 요청시마다 호출하여, 그 validity 를 검사하도록 만들고, voidedPurchases() API 의 경우, CRON scheduler 로 등록한 Console Command 로 새벽시간에 1번 호출되도록 하여, 무효화된 결제에 관련된 사용자 구매 상태를 보정하도록 하면 되겠다.

Happy Coding!

Posted by admin in Backend, 0 comments

Laravel with Jenkins (5/5) – Wrap-up

이제 Jenkins 인스턴스에 접속하여 ansible 을 설치한다. (DO 에서 제공하는 레퍼런스 문서 링크 참고)

https://www.digitalocean.com/community/tutorials/how-to-install-and-configure-ansible-on-ubuntu-18-04

ansible 설치 후 로컬 PC 에 한것과 마찬가지로 /etc/ansible/digital_ocean.ini 와 /etc/ansible/digital_ocean.py 파일을 복사 후, jenkins 사용자 계정으로 Ansible 플레이북 (/home/jenkins/.ansible/deloy.yml) 이 정상적으로 실행되는지 확인한다.

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

위의 수동명령으로, api01 서버에 rolling deployment 실행을 할 수 있었다면, 라라벨 앱의 root 폴더에 있는 Jenkinsfile 의 25번째 라인에 deploy 스크립트 실행이 안되도록 comment out 시킨 부분을 찾아 # 문자를 삭제한다. 이렇게 함으로써, 새로운 commit 이 master 브랜치에 푸쉬될 때마다 새로운 도커 이미지가 api01 서버에 deploy 되는 것까지의 CI/CD 과정이 마무리 되는 것이다.

이제 마지막으로 api01 서버에 HTTPS 연결을 위하여, certbot 를 설치하고 nginx 설정을 변경해보자. Docker Swarm 모드로 nginx reverse proxy 를 설치할 때의 설정과 크게 다르지 않다.

Let’s Encrypt and Certbot

비영리 인터넷 보안 연구 그룹(ISRG)이 제공하는 Let’s Encrypt 를 사용하여, 90 일간 유효한 무료 Certificate 을 사용하면, 무료로 HTTPS 접속을 하도록 웹서버 설정이 가능하다. 이를 사용하기 위한 Python 스크립트 Certbot 과 Nginx 설정에 대한 훌륭한 문서들은 다양하게 존재하므로, 그 내용들은 아래의 링크들을 읽어보기 바란다.

일반적으로 Let’sEcrypt 인증방법은 DNS 인증방법이나, Webroot 방식을 선택하는데, 전자의 경우, 아래의 예시에서 처럼 certbot/certbot 도커 이미지를 이용하여 명령을 내리고, 해당 domain 의 DNS 에 TXT 레코드를 추가/수정하는데, 자동화를 위해서는 Terraform 명령을 실행하는 shell script 를 만들고 –manual-auth-hook 을 사용할 수 있다. 아니면, 후자의 방법을 선택하면 되는데, 이에 대한 설명은 위 레퍼런스 리스트 마지막 글에 자세히 설명되어 있어 생략한다.

docker run --rm -it \
   -v /opt/cert/key:/etc/letsencrypt \
   -v /opt/cert/lib:/var/lib/letsencrypt \
   -v /opt/cert/log:/var/log/letsencrypt \
   certbot/certbot \
   certonly \
     --manual \
     --preferred-challenges dns \
     -d "api.hanlingo.com" \
     --email [email protected] \
     --agree-tos

HTTPS 통신을 하려면, 위에서 생성한 Let’s Encrypt 인증서 volume 바인딩을 추가하여 재실행해야 하는데, Nginx 의 reverse proxy 설정은 아래처럼 변경한다.

upstream container {
  server app:80;
}
server {
  listen      80;
  listen [::]:80;
  server_name api.hanlingo.com;

  return 301 https://$host$request_uri;
}
server {
  listen 443 ssl http2;
  listen [::]:443 ssl http2;
  server_name api.hanlingo.com;

  ssl_certificate     /etc/letsencrypt/live/api.hanlingo.com/fullchain.pem;
  ssl_certificate_key /etc/letsencrypt/live/api.hanlingo.com/privkey.pem;
  ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
  ssl_ciphers   HIGH:!aNULL:!MD5;

  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";
    proxy_set_header Host $host;
    proxy_cache_bypass $http_upgrade;
  }
}

/opt/conf.d/proxy.conf 를 업데이트 했다면, 아래의 일련의 Docker 명령들을 다시 실행한다.

정상적으로 설정이 되었다면 브라우져 주소창에 https://api.example.com 을 입력하거나, 그냥 api.example.com 를 입력하면, nginx 에 의해 자동으로 https 리다이렉트된 https://api.example.com 주소에서, 정상적으로 앱이 실행되고 있음을 확인할 수 있다.

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/cert/key:/etc/letsencrypt \
    -v /opt/conf.d:/etc/nginx/conf.d \
    -p 80:80 \
    -p 443:443 \
    nginx:alpine

지금까지 5회에 걸쳐 Jenkins 를 이용한 PHP Laravel 앱의 CI/CD 설정에 대하여 알아보았는데. 다음에는 Docker Swarm 이나, 최근 GA 서비스로 확장된 DO 의 Kubernetes 에 배포하는 그림까지 확장해 보도록 하겠다.

So Stay tuned. Folks!

Posted by admin in Backend, 0 comments

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 접속이 가능하도록 만드는 작업 등

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

Posted by admin in Backend, 0 comments

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 단계별 동작들을 어떻게 지정하는지 살펴보겠다.

Posted by admin in Backend, 0 comments

Laravel with Jenkins (2/5) – Setup Jenkins on DO


Jenkins 기본 플럭인 설치 화면

이번 포스팅에서는 이전 포스팅에서 설명한 Terraform 으로 젠킨스 설치를 마친 후 Laravel 프로젝트를 위해서 필요한 기본적인 젠킨스 설정 방법을 설명한다. 우선 스크린샷에서 보여지는 것처럼 https://jenkins.example.com 에 접속한 다음, 젠킨스가 추천하는 기본 플럭인들을 모두 설치한다.

기본 플럭인 이외 설치한 플럭인 리스트는 아래와 같다.

  • Slack Notification Plugin : 슬랙 노티피케이션
  • Disk-usage Plugin : 디스크 사용 현황
  • Locale Plugin : 로케일 정보 변경
  • GitHub Branch Source Plugin : GitHub 에서 멀티 브랜치 사용
  • Simple Theme Plugin : 새로운 Material Design theme 을 사용 (현재 적용한 Theme 의 CSS URL 은 아래 링크 참고)
https://cdn.rawgit.com/afonsof/jenkins-material-theme/gh-pages/dist/material-teal.css

테라폼이 DO 인스턴스 생성 후 필요한 프로그램들을 설치/설정하는 스크립트인 jenkins-userdata.sh 10번째 줄을 보면, ssh 키를 생성하는 내용이 있는데, 이 키를 가지고 젠킨스가 GitHub 의 비공개 리포를 access 한다.

Jenkins 인스턴스가 나의 GitHub 비공개 리포로부터 코드를 읽어오려면 이전 포스팅에서 설명했다시피 Jenkins 인스턴스의 SSH public 키를 Github Account > Settings > SSH keys 에 등록해야 한다.

또한 Jenkins 젠킨스의 Credentials > System > Global credentials (unrestricted) 메뉴에서 해당 SSH private 키를 등록한다.

등록할 ssh 키의 위치는 public 키의 경우, /home/jenkins/.ssh/id_rsa.pub 에, private 키는 /home/jenkins/.ssh/id_rsa 에 위치한다.

젠킨스 빌드 트리거 설정화면

GitHub 의 리포에 새로운 브랜치 머지가 이뤄질 때마다 자동으로 젠킨스 파이프라인을 트리거 하도록 만드려면, 위 스크린샷에서 처럼 GitHub hook trigger for GITScm polling 메뉴를 선택 후, GitHub 으로 이동한다.

위 스크린샷에 보이는 것 처럼, GitHub 리포 설정 페이지로 이동하여, Payload URL 을 Jenkins 인스턴스의 웹훅 주소로 지정한다. (참고로 trailing slash 가 생략되지 않도록 https://jenkins.example.com/github-webhook/ 와 같이 지정한다.)

만일 Jenkins Blue Ocean 플럭인 사용을 원한다면, 해당 플러그인을 젠킨스 인스턴스에 설치 후, GitHub 에서 웹훅 주소를 지정하는 대신 Personal Access Token 을 생성한 다음, Jenkins Blue Ocean 플럭인 설정페이지에서 해당 토큰을 입력하면 준비가 완료된다.

다음 포스팅에서는 Laravel PHP 앱 프로젝트에 대한 Continuous Integration 을 위한 준비 과정으로, 테라폼으로 설치한 이 Jenkins 를 사용하여, 해당 프로젝트의 dockerization 준비과정을 설명한다. Docker, Docker Machine, Docker Compose 를 간단히 살펴보고, 어떻게 Laravel PHP 앱 프로젝트를 dockerize 하는지에 대한 설명을 이어가겠다.

Posted by admin in Backend, 0 comments

Laravel with Jenkins (1/5) – Install Jenkins on DO using Terraform

이 글에서는, Laravel 프로젝트 build 파이프라인에 CI/CD 를 사용하기 위하여 Jenkins 를 DigitalOcean 에 설치하고, 더 나아가서는 Docker 를 사용한 전체적인 코드 배포 전략까지 설명한다. 비교적 간단한 Toy Project 의 인프라 설정 작업을 해가면서 정리한 내용이라서 use-case 에 따라서는 더 많은 부분을 고려해야하는 부분이 있겠으나, 다양한 CI/CD best practice 를 고민하는 팀에게 도움이 되었으면 좋겠다. 분량이 적지않아 여러 포스팅으로 나눠 5개 시리즈 포스팅으로 작성했다.

DigitalOcean

DigitalOcean 콘솔 Access Token 생성 페이지

첫번째 해야할 일은 테라폼을 실행하는 로컬 PC 에서 DO 인프라와 인터액션을 하기위한 access 토큰 생성이다. 위 스크린샷에 보이는 것 처럼 DO 콘솔 좌측하단의 API 메뉴를 선택하고 Personal access tokens 의 Generate New Token 버튼을 클릭하면, access 토큰을 생성할 수 있다. 이 토큰은 인프라 리소스를 생성할 수 있어야만 하므로, read/write 권한을 모두 선택한다.

그 다음에 해야할 일은 테라폼으로 생성한 DO 인스턴스와 로컬 PC 간의 ssh 통신을 할 수 있도록 로컬 PC 의 ssh 키를 DO 에 등록하는 과정이다. SysOp 의 로컬 PC 에 이미 ssh 키가 등록되어 있단 가정 하에 아래 명령으로 public ssh 키를 복사한다. (이 ssh 키에는 패스워드가 없어야 하며, 필요하다면 디폴트 ssh 키가 아닌 새로운 ssh 키를 생성하여, 테라폼에서 DO 인스턴스를 생성할 때만 해당키를 지정하여 사용하는 방법도 가능하다.)

cat id_rsa.pub | pbcopy
DigitalOcean 콘솔 SSH key 등록 페이지

위 스크린샷에서 볼 수 있는 것 같이 SSH keys 등록 페이지를 통하여, 로컬 PC 의 SSH 키를 붙여넣고 원하는 이름으로 등록 후 해당키의 fingerprint 를 기록해 둔다. 이는 이후 작업에서, fingerprint 로 지정한 SSH 키를 (또는 복수의 SSH 키들을) 프로비져닝할 인스턴스에 자동으로 등록하기 위함이다.

이제 PC 에 테라폼을 설치한다. 맥 사용자의 경우 homebrew install 명령으로 간단한 설치가 가능하다.

Terraform

https://www.terraform.io/docs/providers/do/index.html

테라폼 문서의 DO 섹션을 보면 테라폼으로 프로비져닝할 수 있는 DO 리소스들이 리스팅 되어 있다. 우리가 우선 사용할 리소스는 digitalocean_droplet 과 digitalocean_record 등 이다.

Provisioning Nginx server using Terraform

이제 로컬 PC 의 적당한 위치에 테라폼 코드를 저장할 폴더를 생성하고 아래와 같은 내용을 갖는 provider.tf 파일을 만든다.

variable "do_token" {}
variable "pub_key" {}
variable "pvt_key" {}
variable "ssh_fingerprint" {}

provider "digitalocean" {
  token = "${var.do_token}"
}

다음으론, 간단한 nginx 서버를 DO 에 프로비져닝하기 위한 nginx.tf 이란 파일을 만들어보자.

resource "digitalocean_droplet" "nginx" {
  image              = "ubuntu-18-04-x64"
  name               = "nginx"
  region             = "sgp1"
  size               = "s-1vcpu-1gb"
  private_networking = true
  monitoring         = true
  ssh_keys = [
    "${var.ssh_fingerprint}",
  ]
  connection {
    user        = "root"
    type        = "ssh"
    private_key = "${file(var.pvt_key)}"
    timeout     = "2m"
  }
  provisioner "remote-exec" {
    inline = [
      "export PATH=$PATH:/usr/bin",
      # install nginx
      "sudo apt-get update",
      "sudo apt-get -y install nginx",
    ]
  }
}

이제까지의 준비로, 테라폼 실행에 필요한 파일들이 모두 준비되었다. 처음 내릴 테라폼 명령은 terraform init 이다. 이 명령은 필요한 플러그인들을 다운받고 초기화한다.

terraform init

초기화 후에는 이번 프로비젼 단계의 인프라 변경내용을 확인하기 위하여, terraform plan 명령을 실행한다. (DO 에서 생성한 access token 값과 ssh fingerprint 값은 각각 DO_TOKEN 과 SSH_FINGERPRINT 환경변수로 미리 export 했다고 가정한다. 또한, 디폴트 id_rsa 가 아닌 별도의 ssh 키를 DO 연결을 위해 생성하였다면 해당 파일 이름으로 변경해야한다.)

terraform plan \
-var "do_token=${DO_TOKEN}" \
-var "pub_key=$HOME/.ssh/id_rsa.pub" \
-var "pvt_key=$HOME/.ssh/id_rsa" \
-var "ssh_fingerprint=$SSH_FINGERPRINT"

Terraform 으로 실제 인프라를 프로비젼하기 위해서는, terraform apply 명령을 실행하면 되며, 약 1분 정도의 시간이 경과된 후에는 새로 생성된 DO droplet 에 nginx 가 설치된 것을 확인 할 수 있다.

terraform apply \
-var "do_token=${DO_TOKEN}" \
-var "pub_key=$HOME/.ssh/id_rsa.pub" \
-var "pvt_key=$HOME/.ssh/id_rsa" \
-var "ssh_fingerprint=$SSH_FINGERPRINT"

생성된 리소스의 정보를 확인하려면 terraform show명령으로 사용한다. 생성된 리소스를 제거하기 위해서는 terraform plan -destroyterraform apply 명령을 순차적으로 실행하던지, 아래와 같이 terraform destroy 명령을 실행하면 된다.

terraform destroy \
  -var "do_token=${DO_TOKEN}" \
  -var "pub_key=$HOME/.ssh/id_rsa.pub" \
  -var "pvt_key=$HOME/.ssh/id_rsa" \
  -var "ssh_fingerprint=$SSH_FINGERPRINT"

만일 nginx 서버 인스턴스 생성시 문제가 발생하였다면, 커맨드라인에서, export TF_LOG=1 이라고 설정한 뒤 나오는 로그 기록을 살펴보면서, trouble-shoot 을 할 수 있다.

테라폼 실행시 환경변수를 인자로 주는 것이 불편하다면, 민감한 credentials 정보를 별도의 파일로 만들고 .gitignore 에 등록하여 그 내용을 보호할 수도 있다. (이 방법의 예는 Dockerized Laravel PHP 앱 인스턴스를 만들때 실제 코드로 살펴볼 예정이다.) 일단, nginx 프로비져닝에 성공 했다면 해당 인스턴스를 제거하고, 이 포스팅에서 목표로 하는 젠킨스 인스턴스를 만들어 보자.

Provisioning Jenkins using Terraform

Jenkins 인스턴스를 만들기 위해, jenkins.tf 라는 파일을 아래와 같이 만든다.

resource "digitalocean_droplet" "jenkins" {
  image              = "ubuntu-18-04-x64"
  name               = "jenkins"
  region             = "sgp1"
  size               = "s-1vcpu-1gb"
  private_networking = true
  monitoring         = true
  user_data          = "${file("config/jenkins-userdata.sh")}"

  ssh_keys = [
    "${var.ssh_fingerprint}",
  ]

  connection {
    user        = "root"
    type        = "ssh"
    private_key = "${file(var.pvt_key)}"
    timeout     = "2m"
  }
}

앞서 보인 nginx.tf 와 비교하면, provisioner “remote-exec” 블럭이 없어지고, user_data 로 jenkins-userdata.sh 라는 이름의 bash 스크립트 파일을 아래와 같이 만들어서 프로그램 설치 및 설정을 실행하도록 했다.

일반적으로는 Terraform/Ansible 조합을 선호하는데, 이후 Dockerized Laravel PHP 앱 인스턴스를 만들때 해당 조합으로 프로비져닝을 할 예정이므로, 비교해 보면 좋겠다.

#!/bin/bash

export DEBIAN_FRONTEND=noninteractive
export PATH=$PATH:/user/bin
export LANG=en_US.UTF-8
export LANGUAGE=en_US:en
export LC_ALL=en_US.UTF-8

# create ssh key to communicate b/w jenkins and repository
ssh-keygen -t rsa -N "" -f ~/.ssh/id_rsa

# add sudoable jenkins user
adduser jenkins
usermod -aG sudo jenkins
rsync --archive --chown=jenkins:jenkins ~/.ssh /home/jenkins

# add additional apt repositories
wget -q -O - https://pkg.jenkins.io/debian/jenkins.io.key | sudo apt-key add -
sh -c 'echo deb http://pkg.jenkins.io/debian-stable binary/ | sudo tee -a /etc/apt/sources.list.d/jenkins.list'
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add -
add-apt-repository -y "deb [arch=amd64] https://download.docker.com/linux/ubuntu bionic stable"
add-apt-repository -y ppa:certbot/certbot

# pull the packages for installing jenkins
apt update && apt -y upgrade

# add 2gb swap memory
fallocate -l 2G /swapfile
chmod 600 /swapfile
mkswap /swapfile
swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
echo 'vm.swappiness=10' | sudo tee -a /etc/sysctl.conf
echo 'vm.vfs_cache_pressure=50' | sudo tee -a /etc/sysctl.conf

# setting up a basic firewall
ufw allow 22
ufw allow 80
ufw allow 443
ufw allow 8080
ufw --force enable

# install general tools
apt install -y apt-transport-https ca-certificates
apt install -y software-properties-common python-software-properties build-essential
apt install -y vim git curl htop

# install java
apt install -y openjdk-8-jdk

# install jenkins
apt install -y jenkins

# install docker and docker-compose
apt install -y docker-ce
apt install -y docker-compose

# install nginx
apt install -y nginx

# install certbot
apt install -y certbot python-certbot-nginx

# install s3cmd (for easy dot-env file management)
apt install -y s3cmd

# remove packages no longer needed
apt remove -y --purge software-properties-common
apt -y autoremove

# add jenkins to docker group
sudo usermod -aG docker jenkins

# download nginx config
wget https://gist.githubusercontent.com/jinseokoh/cf21d257fda1d858e298c7322e8b2c5b/raw/60d98dde66133c3cadea53ee4205f740f8438238/nginx.conf -O /etc/nginx/sites-available/default

# let certbot to update nginx config
certbot --nginx --non-interactive --redirect --agree-tos -m "[email protected]" -d jenkins.example.com

# restart services
systemctl start jenkins
systemctl reload nginx

# print out initial password
cat /var/lib/jenkins/secrets/initialAdminPassword

위의 스크립트는 테라폼으로 생성한 Jenkins 인스턴스에 필요한 패키지들을 설치한다. 주요 작업 리스트는 아래와 같다.

  • ssh 키 생성
  • jenkins 사용자 생성
  • 2GB swap 메모리 설정 (1G 메모리 밖에 없는 저렴한 인스턴스라…)
  • java, jenkins 설치
  • docker, docker-compose 설치
  • nginx, certbot 설치
  • s3cmd 설치 (Laravel PHP 앱의 dot-env 파일 복사용)
  • nginx reverse proxy 설정

참고로, nginx reverse proxy 설정은 아래의 내용과 같다.

server {
  listen 80 default_server;
  server_name localhost;

  location / {
    proxy_pass            http://localhost:8080;
    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_connect_timeout 120;
    proxy_send_timeout    100;
    proxy_buffers         4 32k;
    client_max_body_size  8m;
    client_body_buffer_size 128k;

    # Required for new HTTP-based CLI
    proxy_http_version 1.1;
    proxy_request_buffering off;
    proxy_buffering off; # Required for HTTP-based CLI to work over SSL
    # workaround for https://issues.jenkins-ci.org/browse/JENKINS-45651
    add_header 'X-SSH-Endpoint' 'jenkins.example.com:50022' always;
  }
}

참고로, Jenkins 가 도커 이미지 빌드시 Laravel 코드베이스에서 사용할 .env 파일을 DO Spaces (AWS s3 의 alternative) 에 미리 업로드해 놓고 복사하여 사용할 예정이라서 이 인스턴스에 s3cmd 를 설치했고, s3cmd 의 configuration 은 아래 문서를 참고하여 수동으로 설정했다.

https://www.digitalocean.com/docs/spaces/resources/s3cmd/

Jenkins 인스턴스에서, git 리파지토리로부터 소스코드를 읽어올 수 있도록 GitHub (또는 bitbucket) 의 설정페이지로 이동 후, Jenkins 인스턴스 .ssh 폴더의 id_rsa.pub 키를 등록한다.

해당 인스턴스가 생성된 이후, DO 의 DNS 에 jenkins.example.com 라는 이름의 A 레코드를 등록하는 테라폼 코드를 아래와 같이 추가한다. (참고로, 스크립트 및 설명 전반에 노출되는 도메인 이름은 example.com 로 명명하겠다.)

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

아래 GitHub 리포에 방문하면, 테라폼 관련 코드를 볼 수 있다.

https://github.com/jinseokoh/terraform

이로서, 단 월 $5 의 비용으로 유지/관리할 수 있는 Jenkins 인스턴스를 설치해 보았다. 다음 포스팅에서는 Laravel 프로젝트에서 사용하기 위한 기본적인 Jenkins 설정 내용을 살펴보도록 하겠다.

Posted by admin in Backend, 0 comments