
Jenkins 초기화면에서 Create New Jobs 를 누르고, Multibranch Pipeline 을 선택한다. 그 다음 입력화면에서, 프로젝트 이름, GitHub project URL, GitHub hook trigger for GITScm polling, Pipeline script 등 자신의 Jenkins 가 픽업해야할 GitHub 프로젝트에 대한 내용을 입력한다.
이 부분이 번거롭다 생각들면, Blue Ocean 플럭인을 설치 후, 왼쪽 메뉴바에서 Open Blue Ocean 메뉴를 선택 후,
- 새로운 Github Pipeline 생성,
- Github 페이지에서 Personal Access Token (Jenkins Blue Ocean 에 해당 링크가 나온다.) 를 생성후, 해당 토큰값을 입력,
- 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 추가
단계별 상세 설명은 아래 문서를 참고하자.
💡 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 메뉴를 클릭하면, 아래 이미지와 같이 파이프라인별 진행 상황을 볼 수 있다.

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

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

지금까지의 파이프라인 동작으로, 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.py
와 digitial_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
명령으로 확인 할 수 있다.

브라우저를 이용하여 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 작업을 수행할지 각 단계별 수행 내용들을 생각해 보자.
- PHP 앱의 latest 이미지를 registry 로부터 다운
- 새롭게 다운받은 이미지로 도커 컨테이너 실행
- nginx 가 트래픽을 새로 실행한 도커 컨테이너로 보내도록 config 변경 후 재실행
- 이전 컨테이너 실행 중단/제거 후 이미지 삭제
위 리스트에 나열된 작업들을 차례대로 수행하는 것이 바로 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 가 이뤄지는 것을 볼 수 있다.
업데이트 전후 docker ps 실행 화면
따라서, 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"
지금까지 많은 작업을 했는데, 이제 다음 포스팅에서는
- Jenkins 인스턴스에서도 위의 playbook 파일을 실행할 수 있도록 Ansible 설치와 설정을 하고,
- Laravel PHP 앱 코드베이스에 있는 Jenkinsfile 의 내용을 수정하여 registry 로부터 다운받은 이미지로 rolling deployment 를 하게 만들고
- 더불어, certbot 을 이용하여, HTTPS 접속이 가능하도록 만드는 작업 등
에 대하여, 설명을 이어가겠다.