相信有那麼一天,我們將可以像畢凱艦長一樣用嘴巴叫所有主機做事!


Cheng Wei Chen



建立 PHPConf 2016 自動化與持續整合實作工作坊 的實作環境 (Local VM)

這次受邀擔任 PHPConf 2016 的工作坊講師,負責一場「自動化與持續整合」的實作工作坊,因為希望工作坊的過程中能將網路與實作環境的問題減少到最少,所以事先預備了 Local VM 提供參與者事先安裝設置。

此文即是紀錄這個 Loacl VM 是如何建立的。

環境描述

因為考慮到不是每個人都會有頂規的電腦設備,同時也為了方便設置,因此在 Local VM 的規劃上,不打算實際建立多台 VM,而是以單一 VM 並在其中透過多個 docker container 的方式來模擬多 VM 的情況。

我們會需要用到下面四個 Server:
  • CI Server:這次工作坊會以 GitLab 作為主要的 CI Server。
  • CI Worker:即是 GitLab Runner。
  • Web Server:標準的 Nginx + Php-fpm,而且要開放可以 SSH Login。
  • Selenium Server:測試案例中會用到 Selenium。
基於以上四項都要放在同一台 虛擬 VM 裡,並用多個 Container 來實現,在經過實驗之後建議運行此 VM 的電腦或筆電(host機)至少需要:
  • 超過 2GB 的記憶體。因為 GitLab 與 Selenium 都滿吃 Ram,因此分配給 VM 的 Ram 至少要 2GB,
  • 預留 10 ~ 20 GB 硬碟空間。因為運行 Docker 環境其實也滿吃硬碟空間的,硬碟空間能留越多是越好啦!
  • VM 與 host 機將對映幾個 port 分別是 80、2222、10122、10180
為了簡化環境建置的難度,作為此 VM 的 OS 我們即選用 ubuntu 14.04 這個目前最容易入門的穩定版本,接下來就一一說明建置的步驟。

環境建置

建立以 ubuntu 14.04 為基底建置 VM

如果要快速建置與管理 VM,不想慢慢設置 Virtual Box,那麼透過 Vagrant 的 Vagrantfile 是一個滿方便的做法。

因此首先建立一個 Vagrantfile 如下:

    VAGRANTFILE_API_VERSION = '2'
    
    Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|
    
      config.vm.box = 'ubuntu/trusty64'
    
      config.vm.hostname = 'phpconf2016'
      config.vm.boot_timeout = 600
    
      config.ssh.insert_key = false
    
      config.vm.provider :virtualbox do |vb|
        vb.customize ['modifyvm', :id, '--memory', '2048']
        vb.customize ['modifyvm', :id, '--natdnsproxy1', 'on']
        vb.customize ['modifyvm', :id, '--natdnshostresolver1', 'on']
      end
    
      # Configure Port Forwarding
      config.vm.network 'forwarded_port', guest: 80, host: 10080
      config.vm.network 'forwarded_port', guest: 10122, host: 10122
      config.vm.network 'forwarded_port', guest: 10180, host: 10180
      
    
    end

其中最重要的應該就是 config.vm.box 了,我們要使用的 vagrant box 即是 ubuntu/trusty64

接著就 vagrant up,然後稍作休息,等待它將 VM 建立完畢。

如果沒有安裝 vagrant,基本上還是可以透過 Virtual Box 的 Import Appliance 來建立 VM,因為其實 vagrant box 也只是把 Virtual Box 的 .ova/ovf 加上一些描述檔打包成 box。有興趣你可以試著將 box 解壓縮看看裡面的內容。(更多詳情可參閱 Vagrant 官方文件)

如果你想要手動下載 ubuntu 的 vagrant box,可以先上 vagrantcloud 查看最新的 box 版號。

然後自己組合一下 URL,例如要下載 v20161020.0.6 這個版號的 box。

那它的 URL 即是
https://atlas.hashicorp.com/ubuntu/boxes/trusty64/versions/20161020.0.6/providers/virtualbox.box

透過 provision.sh 來自動安裝所需軟體與環境設置

當 VM 順利建立並啟動之後,就 ssh 登入,預設的帳號密碼皆是 vagrant

接著透過 sudo 切換至 root 權限執行以下的 shell script。

#!/bin/bash

sudo apt-get update
sudo apt-get install -y apt-transport-https ca-certificates
sudo apt-key adv --keyserver hkp://p80.pool.sks-keyservers.net:80 --recv-keys 58118E89F3A912897C070ADBF76221572C52609D
sudo echo "deb https://apt.dockerproject.org/repo ubuntu-trusty main" > /etc/apt/sources.list.d/docker.list
sudo apt-get update
sudo apt-get install -y linux-image-extra-$(uname -r) linux-image-extra-virtual
sudo apt-get install -y docker-engine
sudo usermod -aG docker vagrant

docker pull sameersbn/redis:latest
docker pull sameersbn/postgresql:9.5-1
docker pull sameersbn/gitlab:8.12.4
docker pull gitlab/gitlab-runner:latest
docker pull selenium/standalone-firefox:2.53.1
docker pull ubuntu:16.04
docker pull alpine:3.4
docker pull php:7.0-alpine

docker pull chengweisdocker/phpconf2016-devserver:16.04
docker pull chengweisdocker/phpconf2016-ansible:dev
docker pull chengweisdocker/phpconf2016-composer:php7

docker tag chengweisdocker/phpconf2016-devserver:16.04 ci/devserver:16.04
docker tag chengweisdocker/phpconf2016-ansible:dev ci/ansible:dev
docker tag chengweisdocker/phpconf2016-composer:php7 ci/composer:php7

上面的 shell script 不只是安裝軟體,還包含了下載會使用到的 Dcoekr Images。

咦,如果有 Vagrant 的專家應該會問說,怎麼不把 provision.sh 放進上面的 Vagrantfile 裡,讓 vagrant up 時一次搞定 provision 動作。

因為本文不是要做 Vagrant 教學啊!而且如果要講這麼多,那麼乾脆直接用 Packer 更好,這種全自動的做法,就另外專文說明吧!

自建幾個特別的 Docker Images

其實大部分的需求都可以找到現成的 Docker Images 來滿足,但在本次工作坊的模擬環境中,還是需要 build 幾個特別的 Docker Images:
  • ci/devserver:16.04 = 可以 SSH 登入、具備 Nginx + Php7.0 (含 xdebug) + sqlite 用來模擬 VM 的 Dev Server。
  • ci/ansible:dev = 內含 ansible 並預先內建 ssh key 可直接無密碼連上 Dev Server。在 CI 流程中會多次用它來完成工作。
  • ci/composer:php7 = 內含 php7 + composer 用來在 CI 流程中執行 composer 相關動作。
以上三種 Docker Images 都有合適的 base image,我只需要簡單的再加工一下即可。

而其實加工後的結果,我已經 docker push 上 Docker Hub 了,所以前面的 provision.sh 已經下載好了。

所以下面就快速的說明做了哪些加工,但其實你不用照著做,就幫我看一看我的做法,順便幫我除錯一下。

先從 ci/devserver:16.04 開始,建一個空白資料夾,新增一個 Dockerfile 如下

這個 Dockerfile 的基礎是參考了 Docker 官方的 Dockerizing an SSH daemon servicelaravel/settler

FROM ubuntu:16.04

RUN apt-get update && apt-get install -y openssh-server

RUN mkdir /var/run/sshd
RUN echo 'root:phpconf2016' | chpasswd
RUN sed -i 's/PermitRootLogin prohibit-password/PermitRootLogin yes/' /etc/ssh/sshd_config

RUN sed 's@session\s*required\s*pam_loginuid.so@session optional pam_loginuid.so@g' -i /etc/pam.d/sshd

ENV NOTVISIBLE "in users profile"
RUN echo "export VISIBLE=now" >> /etc/profile

RUN apt-get install -y software-properties-common curl

RUN apt-add-repository ppa:nginx/development -y
RUN apt-add-repository ppa:ondrej/php -y

RUN apt-get install -y --force-yes php7.0-cli php7.0-dev php-pgsql php-sqlite3 php-gd php-apcu php-curl php7.0-mcrypt php-imap php-mysql php-memcached php7.0-readline php-mbstring php-xml php7.0-zip php7.0-intl php7.0-bcmath php-soap
RUN curl -sS https://getcomposer.org/installer | php
RUN mv composer.phar /usr/local/bin/composer
RUN apt-get install -y --force-yes nginx php7.0-fpm
RUN apt-get install -y php-xdebug

RUN rm /etc/nginx/sites-enabled/default
RUN rm /etc/nginx/sites-available/default

RUN apt-get install -y sqlite


EXPOSE 22
CMD ["/usr/sbin/sshd", "-D"]

及另外兩個檔案 authorized_keysdefault.conf。這兩個就直接說明內容。為了要讓 CI Worker 有權限可以通過 SSH 來部署程式碼,因此要預先設置 authorized_keys。而 default.conf 則是 Nginx 的 site config,要與程式碼運行的環境相配合,像是檔案路徑、phpfpm.sock 的位置等各種設定。

檔案新增完畢,接著就 docker build -t ci/devserver:16.04 .,然後再起身活動一下筋骨吧。

我知道各位專家們又有話要說了,你這樣 build 出來的 Docker Image,當它 run 起來成為 Container 時,那些 service 如 nginx、php7.0-fpm 是不會自動啟動的,你應該要加一個 supervisor 之類的服務來幫忙代為啟動。

只好再向大家報告一次,本文沒有要做 Docker 深入教學啊,只是提供一個最低學習成本的方式,讓人可以模仿自建出本次工作坊的實作環境 VM。

所以不打算搞這些深入的技巧,屆時打算先運行 Container 再透過 docker exec 的方式手動啟動 Services。

接著是 ci/ansible:dev,建一個空白資料夾,新增一個 Dockerfile 如下

FROM alpine:3.4
MAINTAINER chengwei

RUN set -ex \
  && apk add --no-cache py-pip build-base python-dev libffi-dev openssl-dev openssh \
  && pip install ansible \
  && mkdir /ansible \
  && mkdir /etc/ansible \
  && apk del build-base py-pip libffi-dev openssl-dev git
  
RUN mkdir /root/.ssh/
RUN chmod 700 /root/.ssh/

COPY id_rsa /root/.ssh/
COPY known_hosts /root/.ssh/

RUN chmod 600 /root/.ssh/id_rsa
RUN chmod 644 /root/.ssh/known_hosts

COPY ansible-deploy.yml /ansible/

WORKDIR /ansible

除了 Dockerfile,還需要建立 key (id_rsa),還有 know_hosts。這兩個檔案應該不用多說,就是配合前一個 Image,既然在目標的 Server 放了 authorized_keys,這一個要用來登入別的 Server 的 Container 當然就要先塞好 id_rsaknow_hosts

(其實 id_rsaknow_hosts 也不是必備,想要克服 SSH Login 問題,有很多方式,端看你怎麼設計整個流程。)

還要再新增一個 ansible playbook,ansible-deploy.yml,這是為了將自動化部署會用到的腳本事先塞進 Container。

---

- name: deploy
  hosts: "{{ MYHOST }}"

  vars:
    KEYWORD: "forge.laravel.com"

  tasks:
    - name: check artifact name
      debug: msg={{ TAR_FILENAME }}

    - name: "upload artifact to {{ MYHOST }} server"
      copy:
        src: "{{ CODE_PATH }}{{ CI_PROJECT_NAME }}/{{ TAR_FILENAME }}"
        dest: "{{ CODE_PATH }}"

    - command: ls -al {{ CODE_PATH }}
      register: check_artifact_upload

    - name: check artifact upload
      debug:
        msg: "{{ check_artifact_upload.stdout_lines }}"

    - name: remove app
      file:
        path: "{{ CODE_PATH }}{{ CI_PROJECT_NAME }}"
        state: absent

    - name: unarchive artifact
      shell: "/bin/tar -zxf {{ CODE_PATH }}{{ TAR_FILENAME }} -C {{ CODE_PATH }}"

    - name: setup .env
      shell: "cp {{ CODE_PATH }}{{ CI_PROJECT_NAME }}/.env_test {{ CODE_PATH }}{{ CI_PROJECT_NAME }}/.env"

    - name: composer dump-autoload
      shell: "/usr/local/bin/composer dump-autoload -d {{ CODE_PATH }}{{ CI_PROJECT_NAME }}/"

    - name: reload nginx
      service:
        name: nginx
        state: reloaded

    - name: check deploy
      shell: "curl http://127.0.0.1 | grep {{ KEYWORD }}"
      register: check_deploy

    - debug: msg="{{ check_deploy.stdout_lines }}"

然後執行 docker build -t ci/ansible:dev .,這次的 build 不用等太久。

最後是 ci/composer:php7,建一個空白資料夾,新增一個 Dockerfile 如下

# ci/composer:php7
FROM php:7.0-alpine

# Packages
RUN apk --update add \
    autoconf \
    build-base \
    curl \
    git \
    subversion \
    freetype-dev \
    libjpeg-turbo-dev \
    libmcrypt-dev \
    libpng-dev \
    libbz2 \
    libstdc++ \
    libxslt-dev \
    openldap-dev \
    make \
    unzip \
    wget && \
    docker-php-ext-install bcmath mcrypt zip bz2 mbstring pcntl xsl && \
    docker-php-ext-configure gd --with-freetype-dir=/usr/include/ --with-jpeg-dir=/usr/include/ && \
    docker-php-ext-install gd && \
    docker-php-ext-configure ldap --with-libdir=lib/ && \
    docker-php-ext-install ldap && \

    docker-php-ext-install pdo_mysql && \
    docker-php-ext-install pdo_sqlite && \
    docker-php-ext-install opcache && \
    docker-php-ext-install json && \
    docker-php-ext-install calendar && \
    docker-php-ext-install xml && \
    docker-php-ext-install mbstring && \
    docker-php-ext-install curl && \

    pecl install xdebug && \
    docker-php-ext-enable xdebug && \

    pecl install phar && \
    docker-php-ext-install phar && \
    pecl install intl && \
    docker-php-ext-install intl && \
    apk del build-base && \
    rm -rf /var/cache/apk/*

# PEAR tmp fix
RUN echo "@testing http://dl-4.alpinelinux.org/alpine/edge/testing/" >> /etc/apk/repositories && \
    apk add --update php7-pear@testing && \
    rm -rf /var/cache/apk/*

# Memory Limit
RUN echo "memory_limit=-1" > $PHP_INI_DIR/conf.d/memory-limit.ini

# Time Zone
RUN echo "date.timezone=${PHP_TIMEZONE:-UTC}" > $PHP_INI_DIR/conf.d/date_timezone.ini

RUN curl -sS https://getcomposer.org/installer | php
RUN mv composer.phar /usr/local/bin/composer

# Set up the command arguments
CMD ["/bin/sh"]

然後就 docker build -t ci/composer:php7 .

首次 docker run

上述的 Docker Images 都建立完畢之後,就來將所需的 Container 先跑起來看看。

為了要讓工作坊的實作環境能一致,因此一樣用 shell script 將要下的指令一次搞定。

新增一個 run_container.sh 並用 bash run_container.sh 執行它。

#2
docker run -i -d -v /home/workshop/gitlab/postgresql:/var/lib/postgresql -e DB_USER=gitlab -e DB_PASS=password -e DB_NAME=gitlabhq_production --env 'DB_EXTENSION=unaccent,pg_trgm' --name postgresql sameersbn/postgresql:9.5-1

#3
docker run -i -d -v /home/workshop/gitlab/redis:/var/lib/redis --name redis sameersbn/redis:latest

#4
docker run -i -d -v /home/workshop/gitlab/gitlab:/home/git/data --name gitlab --link redis:redis --link postgresql:postgresql \
-p 80:80 -p 10122:10122 \
-e DEBUG=false \
-e DB_ADAPTER=postgresql \
-e DB_HOST=postgresql \
-e DB_PORT=5432 \
-e DB_USER=gitlab \
-e DB_PASS=password \
-e DB_NAME=gitlabhq_production \
-e REDIS_HOST=redis \
-e REDIS_PORT=6379 \
-e TZ=Asia/Taipei \
-e GITLAB_TIMEZONE=Taipei \
-e GITLAB_HTTPS=false \
-e SSL_SELF_SIGNED=false \
-e GITLAB_HOST=172.17.0.4 \
-e GITLAB_PORT=80 \
-e GITLAB_SSH_PORT=10122 \
-e GITLAB_RELATIVE_URL_ROOT= \
-e GITLAB_SECRETS_DB_KEY_BASE=long-and-random-alphanumeric-string \
-e GITLAB_SECRETS_SECRET_KEY_BASE=long-and-random-alphanumeric-string \
-e GITLAB_SECRETS_OTP_KEY_BASE=long-and-random-alphanumeric-string \
-e GITLAB_ROOT_PASSWORD=phpconf2016 \
-e GITLAB_ROOT_EMAIL= \
-e GITLAB_NOTIFY_ON_BROKEN_BUILDS=false \
-e GITLAB_NOTIFY_PUSHER=false \
-e GITLAB_EMAIL=notifications@example.com \
-e GITLAB_EMAIL_REPLY_TO=noreply@example.com \
-e GITLAB_INCOMING_EMAIL_ADDRESS=reply@example.com \
-e GITLAB_BACKUP_SCHEDULE=disable \
-e SMTP_ENABLED=false \
-e IMAP_ENABLED=false \
-e OAUTH_ENABLED=false \
sameersbn/gitlab:8.12.4

#5
docker run -d -i --name registry -p 5000:5000 \
-v /home/workshop/registry:/var/lib/registry registry:2

#6
docker run -d --name runner -v /run/docker.sock:/var/run/docker.sock -v /home/workshop/gitlab/runner-testing:/etc/gitlab-runner --link gitlab:gitlab gitlab/gitlab-runner:latest

#7 dev_server
docker run -d -i --name dev_server -p 10280:80 ci/devserver:16.04 bash

#8 selenium_server
docker run -d -i --name selenium_server selenium/standalone-firefox:2.53.1

執行之後一樣等待一段時間,因為 GitLab 首次啟動會需要一點時間建立資料庫。

嘿,我知道你們這些 Docker 專家們又有話要說了。大概會說「你怎麼不用 docker-compose 呢?」,這裡還是用同樣的答案回答各位了,本文沒有要做 Docker 深入教學啊,所以不打算搞這些深入的技巧,屆時打算透過另一個 shell script 讓使用者可以簡單的一行指令啟動這些 container。

最後再補上一個啟動 container 的 shell script

為了方便使用,直接在 /usr/local/bin 中新增一個 shell script,就命名為 start-container
#!/bin/bash

#2
docker start redis

#3
docker start postgresql

#4
docker start gitlab

#5
docker start registry

#6
docker start runner

#7
docker start dev_server
docker exec dev_server service ssh start
docker exec dev_server service php7.0-fpm start
docker exec dev_server service nginx start

#8 
# docker start selenium_server
docker rm selenium_server
docker run -d -i --name selenium_server selenium/standalone-firefox:2.53.1

記得 chmod +x start-container 這樣就可以直接在 commaind line 下指令 start-container 執行它。

註冊 GitLab Runner

不過事情還沒完畢,接著我們要手動註冊 GitLab Runner。

先確認一下是否有將 VM 的 80 port 對映至 host 機的 10080 port。



若設定無誤,那應該可以在 host 機透過瀏覽器瀏覽 http://127.0.0.1:10080 進入 GitLab 管理介面,帳號密碼分別是 rootphpconf2016

接著進入 Admin Area 中 Overview 的 Runners。來這裡的目的是為了查看 Runners 的 Registration token。





複製好 Token,再回到 command line 中。

執行下面的指令,即可完成註冊 Runner。當然 Token 要改成你實際查到的 Token。
docker exec runner gitlab-ci-multi-runner register -n \
  --url http://172.17.0.4/ \
  --registration-token uUuusWxRKAQAokau1AxR \
  --executor docker \
  --description "My Docker Runner" \
  --docker-image "ci/ansible:dev" \
  --docker-volumes /var/run/docker.sock:/var/run/docker.sock

我知道 Docker 專家又要說話了,大概想要吐槽說你上面怎麼是直接指定 172.17.0.4 這個由 Docker 配發出來的 ip,萬一你的 container 重新啟動,這 ip 不就不一樣了?

所以我前面才要建立 start-container 這個指令啊,要是有問題,那就把所有的 Container 都關一關,在全部重新按順序 start,讓它們可以重新獲得我希望它們獲得的 ip。

若註冊成功,應該在 GitLab 後台就會出現類似下圖的資訊,顯示你已經註冊了一個 shared Runner。


匯出 .ova

完成前面所有的動作之後,將 VM 匯出成 .ova 即完成了 VM 建置。

如此一來其他要使用者只要透過 Virtual Box 的 Import Appliance 即可建立相同環境的 VM。
當然也可以用此 .ova 來打包做成 vagrant box 更方便使用。

其實還有一些小動作沒說

其實為了避免工作坊可能會遇到網路速度與記憶體不足的問題,因此在實際提供給學員的 VM 還有再加工了以下幾件事情:
  • 前面在製作 Docker Image 時,ci/composer:php7 製作完畢後,我還有再額外讓它對工作坊的 code 跑過 composer install,之後再 docker commit 做成 Docker Image。
  • 加了 SWAP,用一點硬碟空間作為交換,避免 VM 死當。
其實這些步驟也都可以一併寫入 shell script 與 dockerfile 裡,不過這種只適用在此工作坊的小動作,就沒寫進前面的範例,讓前面所記錄的那些步驟可以更通用一點。


以上就是 Local VM 的建置步驟,因為有些部分來來回回測試重做了好幾次,上面的內容是憑最後的印象編寫,如果有人照做卻無法成功,就再留言給我吧。

沒有留言:

張貼留言

不歡迎留言打廣告,所以有進行留言管理,敬請見諒。