Recommanded Free YOUTUBE Lecture: <% selectedImage[1] %>

Contents

애자일 & DevOps와 Jenkins

Jenkins는 CI(Continuous Integration)툴이다. Git, SVN과 같은 형상관리 툴과 연동을 해서, commit(혹은 Push)한 코드를 읽어와서 빌드하고 테스트 하고 배포를 위한 패키지를 만드는 일을 한다.

최근의 소프트웨어 개발 방법론의 트랜드는 "애자일"과 "데브옵스(DevOps)로 대표 할 수 있다. 이들 개발론들은 개발과 배포 주기의 압축이라는 공통적인 특징을 가지고 있다. 현재 주기에 반드시 필요한 기능만을 추가 함으로써, 개발 속도를 빠르게 하고 그만큼 빠르게 배포를 하는 방식이다. 추가되는 기능이 작으니 문제가 발생할 확률이 적고, 문제해결도 쉽고 고객과 빠른 피드백이 가능하다. 또한 제품이 하나의 큰 주기가 아닌 여러 주기로 쪼개져서 배포가 되기 때문에, 위험이 각 주기로 분산되는 효과도 얻을 수 있다. 아래 그림은 폭포수 모델에서의 개발주기와 애자일 모델에서의 개발 주기의 차이점을 보여준다.

 Agile vs Waterfall

개발과 배포가 끊임 없이 계속 이루어지는데, 이러한 주기체 맞춰서 지속적으로 소프트웨어를 통합해주는 CI 툴이 필요하다.

Jenkins를 이용한 통합

현재 나의 주력언어는 Go언어다. Go로 만들어진 소프트웨어를 Jenkins로 관리하는 방법을 살펴보도록 하겠다. 시나리오는 다음과 같다.
  • 간단한 RESTFul API 서버 프로그램을 만든다. 이 프로그램의 이름은 myRest 이며, bitbucket으로 관리한다.
  • myRest 프로그램은 테스트 케이스를 포함한다. 테스트 케이스는 유닛테스트와 서버 & 클라이언트 테스트를 포함한다. 목업으로 테스트하는게 아니고, REST 웹서버를 실행하고 http 클라이언트로 테스트한다. 젠킨스 서버에 웹서버 실행 환경까지 만들어야 해서 좀 번거롭기는 한데, 만들어 두면 확실히 도움이 된다. 목업 환경 만드느라고 삽질할 시간에 그냥 서버/클라이언트 테스트 환경을 만드는게 낫다.
  • 테스트가 끝난 프로그램은 tar.gz 으로 묶은 다음 웹을 통해서 배포한다.

Jenkins 설치 및 환경

개인 리눅스 데스크탑에 Jenkins 환경을 만들기로 했다. Virtualbox에 우분투 리눅스 15.10 환경에 젠킨스를 설치한다. 젠킨스 설치는 Installing Jenkins on Ubuntu 문서를 참고했다.
# wget -q -O - https://jenkins-ci.org/debian/jenkins-ci.org.key | apt-key add -
# sh -c 'echo deb http://pkg.jenkins-ci.org/debian binary/ > /etc/apt/sources.list.d/jenkins.list'
# apt-get update
# apt-get install jenkins
설치가 끝나면 8080 포트로 접근해서 젠킨스 서비스를 이용 할 수 있다.

 젠킨스 처음 화면

  • 새로운 Item : 새로운 작업(new job)을 만든다. 헷갈릴 수 있는데 새로운 Item새 작업은 같은 명령이다. 둘다 new job인데, 번역을 하면서 실수 한 것 같다. 젠킨스는 작업 단위로 빌드를 관리한다. 보통은 git 혹은 svn 프로젝트가 작업이 된다.
  • 사람 : 유저 정보를 확인 할 수 있다.
  • 빌드 기록 : 빌드 성공/실패 정보를 확인 할 수 있다.
  • Jenkins 관리 : 젠킨스와 관련된 전반적인 설정을 할 수 있다.
젠킨스는 내부적으로 빌드 큐(queue)를 관리한다. 빌드를 요청하면, 이 요청은 큐에 들어가고 젠킨스의 빌드엔진이 큐에 있는 요청을 꺼내서 빌드를 실행한다. 따라서 여러 개의 프로젝트를 함께 관리 할 수 있다. 빌드 대기 목록과 빌드 실행 상태에서, 현대 큐에 들어있는 작업과 빌드 중인 작업, 빌드가 끝난 작업들을 확인 할 수 있다.

테스트 프로젝트 등록

bitbucket에 테스트 프로젝트를 등록했다. 프로젝트의 이름은 myRest다. 젠킨스 서버에서 bitbucket git 서버에 접근 할 수 있도록 ssh public key를 등록해야 한다.

젠킨스 서버는 jenkins라는 일반 유저로 작동 된다. 따라서 jenkins 유저가 사용 할 수 있는 ssh key를 만들어야 한다. jenkins 유저의 홈 디렉토리는 /var/lib/jenkins 다.
# cat /etc/password | grep jenkins
jenkins:x:108:116:Jenkins,,,:/var/lib/jenkins:/bin/bash
jenkins 계정으로 su(switch user) 한 다음, ssh-keygen을 이용해서 ssh key를 만든다.
# ssh-keygen -t rsa
Generating public/private rsa key pair.
Enter file in which to save the key (/var/lib/jenkins/.ssh/id_rsa): 
Created directory '/var/lib/jenkins/.ssh'.
Enter passphrase (empty for no passphrase): 
Enter same passphrase again: 
Your identification has been saved in /var/lib/jenkins/.ssh/id_rsa.
Your public key has been saved in /var/lib/jenkins/.ssh/id_rsa.pub.
....
id_rsa.pub을 bitbucket에 등록하면 된다.

빌드 프로세스 설계

먼저 어떤 식으로 코드를 가져와서 테스트하고 배포할지에 대한 계획을 세운다. 이 계획에 따라서 젠킨스에 작업설정을 하면 된다. 내 계획은 다음과 같다.

 배포 프로세스

  1. 개발 PC에서 개발한다. 기본적인 빌드와 테스트를 수행한다.
  2. 테스트가 끝나면 git push 한다.
  3. 젠킨스 서버가 git pull로 최신의 코드를 가져온다.
  4. 빌드, 테스트가 성공하면 패키징 한다.
  5. 배포를 위해서 패키징된 프로그램을 웹 서버에 복사한다.
  6. 서비스 서버에서 패키지를 다운로드 해서 서비스를 수행한다.
deb로 패키징해서 배포하면 좋겠지만, 여기에서는 tar.gz 으로 대신한다.

버전관리 방안

버전은 git 버전으로 관리하기로 했다. 빌드 과정에서 git rev-parse --short HEAD로 버전정보를 가져오고, 이것을 go 코드에 박아버리는 방법을 사용하기로 했다. 버전을 go 코드에 입력하는 예제코드다.
package main

import (
    "flag"
    "fmt"
    "os"
)

var Version = "No version provided"

func main() {
    ver := flag.Bool("v", false, "Print version")
    flag.Parse()
    
    if *ver {
        fmt.Printf("%s %s\n", os.Args[0], Version)
        os.Exit(0)
    }
}
	
		

아래와 같이 컴파일 시 Version를 설정하면 된다.
# revVersion=git rev-parse --short HEAD
# go build -ldflags "-X main.Version=$revVersion"
# ./myrest -v
./myrest 2ebcd11

이 과정은 make로 관리한다.
default: build

buildTime=$(shell date -u "+%Y%m%d%I%M")
revVersion=$(shell git rev-parse --short HEAD)
build: 
    go build -ldflags "-X main.Version=$(revVersion).$(buildTime)"

작업 등록

이제 본격적으로 젠킨스를 사용해 보자. 먼저 myrest 프로젝트를 위한 빌드 작업을 등록해야 한다. myrest 프로젝트는 git으로 관리하고 있는데, git 작업을 위해서 git 플러그인을 설치해야 한다. Jenkins 관리플러그인 관리으로 이동한다.

 플러그인 관리

설치 가능 탭으로 이동한 다음 git plugin을 찾아서 설치하면 된다. git 플러그인의 설치가 끝나면 새 작업을 클릭해서 프로젝트 등록 과정을 진행한다.

 프로젝트 등록

몇 개 지원하는 프로젝트 유형이 있는데, git 프로젝트의 경우 Freestyle project를 선택하면 된다. 작업을 만들고 나면 상세 설정화면으로 넘어간다.

프로젝트 빌드 환경 만들기

고급 프로젝트 옵션에서 사용자 빌드 경로 사용에 go 패키지 빌드 설정을 해줘야 한다.. 젠킨스 서버는 프로젝트를 pull 하고, make를 실행해서 코드를 빌드한다. 따라서 젠킨스 서버에 go 빌드 환경을 만들기로 했다.

먼저 go 패키지를 설치해야 한다. /usr/local/go 밑에 설치하기로 했다. 설치는 golang 시작하기문서를 참고하자.

빌드 경로는 bitbuctet 프로젝트 경로를 따라서 workspace/src/bitbucket.org/dream_yun/myrest로 하기로 했다. jenkins 계정의 홈 디렉토리에 작업 디렉토리를 만든다. jenkins 계정의 홈 디렉토리는 /var/lib/jenkins다.
# cd /var/lib/jenkins
# mkdir -p workspace/src/bitbucket.org/dream_yun/myrest
# chown jenkins.jenkins -R workspace/

젠킨스에도 이 정보를 알려줘야 한다. 고급 프로젝트 옵션에 사용자 빌드 경로를 설정한다.  고급 프로젝트 옵션

Git 설정

고급 프로젝트 옵션 밑에 소스 코드 관리 부분으로 넘어가자. Git 프로젝트이므로 Git을 선택하고, Repository URL을 설정했다. Repository URL을 설정하면 젠킨스는 자동으로 repository에 대한 접근이 가능한지 테스트 한다. 앞서 ssh key를 등록했다면 성공 할 것이다.

 git repository 설정

빌드 설정

빌드 유발은 빌드 조건을 설정하기 위해서 사용한다.
  • Build after other projects are built : 다른 프로젝트가 빌드 되면, 빌드를 시작한다. 연동되는 소프트웨어가 새로 빌드 됐을 때, 테스트와 패키징을 위해서 사용 할 수 있다.
  • Build periodically : 주기적으로 빌드 한다. 이른바 나이틀리 빌드 같은 걸 만들 수 있을 것이다.
테스트 프로젝트므로 빌드 유발 조건을 설정하지 않았다. 그냥 git push 하고 나서 Build Now를 클릭해서 수동으로 빌드 하기로 했다. 빌드 유발 조건을 설정했으면, Build 메뉴에서 실제 빌드에 사용할 스크립트를 만들어야 한다. 쉘에서 make build를 수행하도록 설정했다.

 빌드 설정

환경 변수 설정

마지막으로 GOPATHPATH 환경 변수를 설정하면 된다. 환경 변수는 프로젝트 단위가 아닌, 젠킨스 전체에 적용된다. Jenkins 관리 > 시스템 설정으로 이동해서 Global properties 에 환경변수(Environment variables)를 추가했다.

 환경변수 설정

빌드 수행

준비가 끝났다. 이제 Build Now를 클릭하면, git 저장소에서 코드를 가져와서 make build를 수행하고 그 결과를 출력한다. 빌드 결과는 메일등으로 전송 할 수 있다. 슬랙(slack)으로 전송 하면 편하겠다는 생각을 했다.

테스트

Go의 유닛테스트를 이용해서 테스트 할 수 있겠다. 나는 여기에 더해서 서버 & 클라이언트 테스트를 수행하기로 했다. 젠킨스 서버에서 myrest를 실행 한다음에, curl 등으로 직접 API를 테스트 하는 방식이다. 프로그램이 복잡 할 경우, 환경 구축에 상당히 많은 시간이 걸릴 수 있다는 단점이 있기는 하다. myrest 야 그냥 실행파일만 올리면 되지만, RDBMS, NoSQL, 메시지 큐 등 다영한 컴포넌트들과 연동하는 소프트웨어라면 환경 구성하는 것도 일이다.

이런 단점이 있긴 하지만 혼자 도는 소프트웨어가 아니라면 연동 테스트는 해야 하니 (뭔가 좀 하나쯤 빠진 것 같은)목업 만드는 삽질 하는 시간에, 풀 세트를 구축하는게 훨씬 낫다고 생각한다.

테스트 코드를 작성 할 수 있도록 코드를 약간 수정했다.

package main

import (
    "flag"
    "fmt"
    "github.com/gorilla/mux"
    "net/http"
    "os"
    "strconv"
)

var Version = "No version provided"

func Ping(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "Pong")
}

func Divide(w http.ResponseWriter, r *http.Request) {
    vars := mux.Vars(r)
    a := vars["a"]
    b := vars["b"]

    ai, _ := strconv.Atoi(a)
    bi, _ := strconv.Atoi(b)

    fmt.Fprintf(w, "%d", ai/bi)
}

func main() {
    ver := flag.Bool("v", false, "Print version")
    flag.Parse()

    if *ver {
        fmt.Printf("%s %s\n", os.Args[0], Version)
        os.Exit(0)
    }
    router := mux.NewRouter()
    router.HandleFunc("/ping", Divide).Methods("GET")
    router.HandleFunc("/divide/{a}/{b}", Divide).Methods("GET")

    http.Handle("/", router)
    http.ListenAndServe(":8888", nil)
}
		

새로운 버전의 myrest는 2개의 API를 제공한다. GET /ping는 서버가 살아있는지 확인하기 위해서 사용한다. GET /divide/{a}/{b}는 나눗셈을 위해서 사용한다. curl을 이용해서 나눗셈에 대한 테스트를 만들려고 한다.

아래는 테스트 코드다. 테스트코드의 이름은 test/divide_test.go 다.

package test

import "io/ioutil"
import "net/http"
import "testing"

// 일반적인 나눗셈이 잘 되는지
func TestDivide_6d2(t *testing.T) {
    resp, err := http.Get("http://localhost:8888/divide/6/2")
    if err != nil {
        t.Fatal(err.Error())
    }
    defer resp.Body.Close()
    if resp.StatusCode != http.StatusOK {
        t.Error("HTTP Request error ", resp.StatusCode)
    }
    result, err := ioutil.ReadAll(resp.Body)
    if err != nil {
        t.Error("Divide Error 6/2 : ", err.Error())
        return
    }

    if string(result) != "3" {
        t.Error("Divide Error 6/2 = ", result)
    }
}

// 분모가 0일 경우 400 (Bad Request)를 리턴해야 한다.
func TestDivide_5d0(t *testing.T) {
    resp, err := http.Get("http://localhost:8888/divide/5/0")
    if err != nil {
        t.Fatal(err.Error())
    }
    defer resp.Body.Close()
    if resp.StatusCode != http.StatusBadRequest {
        t.Error("HTTP Request error ", resp.StatusCode)
    }
}
		

테스트를 하면 TestDivide_5d0 에서 에러가 발생 할 것이다.
# cd test
# go test divide_test.go
divide_test.go
yundream@home:~/golang/src/bitbucket.org/dream_yun/myrest/test$ go test
--- FAIL: TestDivide_5d0 (0.00s)
	divide_test.go:32: Get http://localhost:8888/divide/5/0: EOF
FAIL
exit status 1
FAIL	bitbucket.org/dream_yun/myrest/test	0.003s

이제 젠킨스에서 유닛 테스트를 실행 하도록 Makefile을 수정한다.
default: build

buildTime=$(shell date -u "+%Y%m%d%I%M")
revVersion=$(shell git rev-parse --short HEAD)
#CGO_ENABLED=0
#GOOS=linux
build: 
    go build -ldflags "-X main.Version=$(revVersion).$(buildTime)"
unittest:
    @sudo service myrest stop
    @cp myrest /opt/bin/myrest
    @sudo service myrest start
    @cd test && go test divide_test.go
이 유닛테스트는 http 클라이언트로 테스트 한다. 따라서 테스트 하기 전에 myrest 서버를 실행 해야 한다. 빌드된 myrest 를 /opt/bin/myrest로 복사한 다음, service로 실행을 했다.

테스트를 수행하기 위해서 젠킨스 서버에 /opt/bin 디렉토리를 만들고, jenkins 계정으로 파일을 복사 할 수 있도록 권한을 설정해야 한다.
# mkdir -p /opt/bin
# chown jenkins.jenkins /opt/bin
service는 루트권한으로만 실행 할 수 있다. 따라서 jenkins유저에 대해서 service myrest 에 대한 sudo 권한을 줘야 한다. visudo로 작업을 했다. 물론 service로 실행 할수 있도록 myrest를 systemd(Ubuntu 15.10은 init.d가 아닌 systemd로 프로세스를 관리한다.) 에 등록하는 과정을 거쳐야 한다. 구글 신탁으로 해결하자.
# Members of the admin group may gain root privileges
%admin ALL=(ALL) ALL

# Allow members of group sudo to execute any command
%sudo   ALL=(ALL:ALL) ALL
jenkins ALL=(ALL) NOPASSWD:ALL
보안을 위해서는 service myrest작업에 대해서만 sudo 권한을 줘야겠지만 귀찮아서 모든 명령에 대해서 sudo 권한을 줬다. 이제 jenkins 계정은 (패스워드 입력 없이) sudo로 모든 애플리케이션을 루트권한으로 실행 할 수 있다.

젠킨스로 이동 해서 Build 항목에 make unittest를 추가한다.

 유닛테스트 룰 추가

myrest 코드를 push하고 젠킨스에서 빌드를 하면, 아래와 같은 실패 메시지를 볼 수 있을 것이다.

 유닛테스트 실패

어디에서 에러가 발생했는지 확인 했으니, 테스트를 성공하도록 코드를 수정하면 된다.