반응형

DevOps 는 개발자와 운영자의 역할을 함께 수행하는 것으로 개발과 운영의 책임을 공동으로 진다. 처음 이 단어를 접한 것이 2011년 OpenStack Summit 에 참석했을 때인데 클라우드, 그 중에서 IaaS(Infrastructure as a Service)가 널리 퍼지기 시작했을 때다. DevOps 는 클라우드 기반에서 빠르게 개발하고, 배포하고, 운영하기 위해서 스타트업 회사를 중심으로 빠르게 퍼지기 시작했다.

아래는 클라우드 가상머신 기반의 DevOps 영역 중 CI/CD 에 대한 프로세스이다. 개발 언어는 Java 를 기준으로 표현하였으며, 이하 모든 설명은 Java 를 기준으로 설명한다.

문제

2015년 7월 쿠버네티스 1.0 버전이 릴리즈 되면서 DevOps 는 가상머신이 아니라 컨테이너 기반으로 점차 변화하였다. 쿠버네티스 이전의 Ops는 가상머신을 빠르게 만들고 개발된 소스 코드를 자동으로 통합 빌드하여 배포하는 영역이었다. 하지만 쿠버네티스가 나오고, 컨테이너 관리가 효율적/안정적으로 변하면서 Ops 는 소스 코드 통합 빌드, 컨테이너 이미지 만들기와 쿠버네티스에 배포, 운영하는 영역으로 바뀌었다. 즉, Ops 영역을 맡은 운영자는 컨테이너도 알아야 하고, 쿠버네티스도 알아야 한다는 의미이다.

결국 쿠버네티스 기반의 DevOps 는 소스 코드 개발, 통합 빌드, 컨테이너 이미지화, 배포의 영역 모두를 의미한다. 이를 간략하게 프로세스로 표시하면 다음과 같다.

여기서 부터 문제가 발생한다. 기존의 Dev 역할은 인프라스트럭처가 가상 머신이든 쿠버네티스이든 상관이 없지만 Ops 역할은 컨테이너와 쿠버네티스라는 새로운 기술을 알아야 하는데 해당 기술을 습득하기까지는 어느 정도의 기술 허들을 넘어야 하고 일정 기간이 지나야 한다. (클라우드 기술이 널리 퍼지기까지 기간을 생각해 보면 쉽게 이해될 것이다)

또한 배포 영역을 생각해 보면 결코 쉬운 문제가 아니다. 배포 전략에는 아래와 같은 3가지 방법이 존재한다. (크게는 4가지 이지만 가장 단순한 Recreate 배포는 생략하였다)

  • 이미지 출처: 쿠버네티스 패턴 (책만출판사)

사족이지만 카나리아 배포를 “까나리”라고 발음하지 말자. “까나리”는 액젓이다.

해결책

개발자는 개발의 영역 즉, Dev 영역에 집중하게 하자. 어려운 Ops 영역은 시스템으로 자동으로 동작하도록 제공하자.

앞서 간단히 살펴본 개발/배포 프로세스를 다시 살펴보자.

  1. 개발자가 IDE 툴을 통해 프로그램을 개발한다.
  2. Maven 혹은 Gradle 로 소스 코드를 빌드한다. 로컬 빌드, Jenkins 혹은 기타 다른 CI 툴을 활용한 빌드 결과물로 jar 혹은 war 파일이 생성된다. 일반적으로 스프링 부트 프로젝트는 jar 파일로 만들어지며, war 파일은 일반 스프링 프로젝트이다. 해당 결과 파일은 저장소(e.q. nexus)에 저장된다.
  3. jar 혹은 war 파일을 로컬 빌드 혹은 기타 다른 CI 툴을 활용하여 컨테이너 이미지로 빌드한다.
  4. 빌드된 컨테이너 이미지를 이미지 저장소에 저장한다.
  5. 쿠버네티스에 배포하기 위해 deployment.yaml, service.yaml 등을 포함한 helm chart 를 만들고 이를 서버에 배포한다. 배포할 때는 배포 전략에 따라서 Rolling update, Blue-Green, Canary 로 배포한다.

1번과 2번은 개발자가 이제까지 하던 방식 그대로 개발하면 된다. 우리가 시스템으로 만들어 제공해야 할 부분은 3, 4, 5 번 영역이다.

구현 방법

해당 시스템에 대한 아키텍처를 구성하면 다음과 같다.

사용 오픈소스 S/W

  • Nexus
    • Maven 저장소로 사용되며 테스트 용도의 jar 파일을 저장하고 다운로드 할 수 있음
  • Keycloak
    • 인증 서버로 활용
    • OIDC 접속 백엔드로 활용할 수 있음
  • Argo Workflow
    • CNCF Graduated Project
    • 워크플로우 서버로 파이프라인을 설계하고 실행할 수 있음.
    • 워크플로우 템플릿을 작성하면 재사용 가능함
    • 워크플로우 실행은 컨테이너 단위로 실행됨
  • Harbor
    • CNCF Graduated Project
    • 이미지 저장소로 활용
  • Gitea
    • Helm chart 저장소로 활용
  • Helm
    • Helm chart template 관리
  • Argo Rollout
    • 배포 전략을 다양하게 지원함
    • 지원 배포 전략: Rolling update, Blue-Green, Canary

프로세스 설명

  1. 사용자는 cli (golang)로 앱 배포를 요청한다.
  2. API 서버 (golang) 는 해당 요청을 받아서 Argo workflow 를 호출한다.
  3. Argo Workflow 에서 Nexus 로 부터 jar 파일을 가져온다.
  4. Argo Workflow 에서 jar 파일을 컨테이너 이미지 파일로 빌드하고 이미지 저장소인 하버에 저장한다.
  5. Argo Workflow 에서 이미지를 가져온다.
  6. Argo Workflow 에서 Helm chart 템플릿을 가져온다.
  7. Argo Workflow 에서 Helm chart 와 이미지를 조합하여 Argo Rollout 으로 배포한다.
  8. Argo Rollout 은 초기 배포를 Blue 로 배포한다. 배포된 Blue 는 로드 발랜서와 연결된다.
  9. 사용자가 Cli 로 앱 업그레이드를 Blue-Green 전략으로 요청한다.
  10. Argo Workflow 에서는 Rollout 으로 Green 으로 배포한다.
반응형
Posted by seungkyua@gmail.com
,
반응형

Kubernetes custom controller 개발에 가장 잘 맞는 프로그래밍 언어는 Go 이다. Kubernetes 가 Go 로 개발된 S/W 이다 보니 Custom controller 도 Go 로 만드는 것이 좋을 것 같다는 생각이다.

그래서 겸사겸사 Custom Controller 개발에 필요한 Go 문법만 정리해 보기로 했다.

  • 변수 선언 : var, Short Variable Declaration Operator(:=)
  • Package 선언 및 활용
  • Struct (json 으로 변환)
  • Receiver function
  • Interface 선언, 활용

변수 선언

변수는 var 키워드로 쉽게 선언할 수 있다.

// var 변수명 변수타입
var message string

변수를 선언하면서 값을 대입하면 마지막의 변수 타입은 생략 가능하다.

var message = "Hi, Welcome!"

:= operator 를 사용하면 shortcut 으로 선언하여 var 도 생략할 수 있다.

message := "Hi, Welcome!"

Package 선언 및 활용

모듈로 만들어서 import 하여 사용할 수 있다.

아래와 같이 디렉토리를 만들어보자. greetings 는 모듈로 선언하고 basic 에서 greetings 모듈을 import 하여 사용할 예정이다.

$ mkdir -p go-sample
$ cd go-sample

$ mkdir -p {basic,greetings}
$ cd greetings

go-sample 이라는 프로젝트 아래에 greetings 라는 모듈을 만든다.

모듈을 만들기 위해서 go.mod 파일을 만든다.

$ go mod init github.com/seungkyua/go-sample/greetings

go.mod 파일이 생성되며 파일 내용은 아래와 같다.

module github.com/seungkyua/go-sample/greetings

go 1.19

greetings.go 파일을 만들어서 아래와 같이 입력한다.

여기도 error 핸들링과 string format 을 위해서 모듈을 import 하고 있는데 이를 이해할려고 하지 말고 그냥 Hello function 이 있다는 것만 이해하자.

package greetings

import (
	"errors"
	"fmt"
)

func Hello(name string) (string, error) {
	if name == "" {
		return "", errors.New("empty name")
	}

	message := fmt.Sprintf("Hi, %v. Welcome!", name)
	return message, nil
}

go-sample 홈디렉토리에서 보는 greetings 디렉토리 구조는 아래와 같다.

greetings
├── go.mod
└── greetings.go

golang 1.19 버전 부터는 로컬 하위 디렉토리를 인식하기 위해서 [go.work](<http://go.work>) 를 사용한다. 그리니 아래와 같이 하여 파일을 만들어 보자.

$ go work use ./basic
$ go work use ./greetins

go-sample 홈디렉토리 아래에 [go.work](<http://go.work>) 파일이 아래와 같이 만들어 진다.

go 1.19

use (
	./greetings
	./basic
)

이제 basic 디렉토리로 가서 똑같이 go.mod 파일을 만들고 main.go 파일도 만들어 본다.

$ cd basic

$ go mod init github.com/seungkyua/go-sample/basic

go.mod 파일이 아래와 같이 생성되었음을 알 수 있다.

module github.com/seungkyua/go-sample/basic

go 1.19

greetings 모듈을 로컬로 인식하게 변경한다.

$ go mod edit -replace github.com/seungkyua/go-sample/greetings=../greetings

그리고 로컬 버전을 사용하기 위해서 pseudo 버전을 사용하게 tidy 명령을 활용한다.

$ go mod tidy

그럼 최종 go.mod 는 다음과 같다.

module github.com/seungkyua/go-sample/basic

go 1.19

// go mod edit 으로 로컬 경로를 보도록 수정
replace github.com/seungkyua/go-sample/greetings => ../greetings

// 로컬이므로 pseudo 버전을 만든다 
require github.com/seungkyua/go-sample/greetings v0.0.0-00010101000000-000000000000

main.go 파일을 만들어서 greetings 모듈을 import 하여 활용해 본다.

package main

import (
	"fmt"
	"log"

	"github.com/seungkyua/go-sample/greetings"
)

func main() {
	message, err := greetings.Hello("Seungkyu")
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(message)
}

go-sample 디렉토리 구조는 아래와 같다.

basic
├── go.mod
└── main.go
greetings
├── go.mod
└── greetings.go
go.work

Struct 활용 (json 변환)

struct 를 json data 로 변환하는 것을 marshal (encoding) 이라고 하고 json data를 struct 로 변환하는 것을 unmarshal (decoding) 이라고 한다.

json 패키지의 Marshal function 은 다음과 같다.

func Marshal(v interface{}) ([]byte, error)

Struct 를 만들어서 json data (byte slice) 로 변환해 보자.

type album struct {
	ID     string  `json:"id"`
	Title  string  `json:"title"`
	Artist string  `json:"artist"`
	Price  float64 `json:"price"`
}

a := album{ID: "1", Title: "Blue Train", Artist: "John Coltrane", Price: 56.99}
b, err := json.Marshal(a)
if err != nil {
	log.Fatal(err)
}

b2 := []byte(`{"id":"1","title":"Blue Train","artist":"John Coltrane","price":56.99}`)
fmt.Println(bytes.Equal(b, b2))

Unmarshal function 은 다음과 같다.

func Unmarshal(data []byte, v interface{}) error

json data (byte slice) 를 struct 로 다시 변환한다.

var a2 album
err = json.Unmarshal(b, &a2)
if err != nil {
	log.Fatal(err)
}
fmt.Println(a2)

Receiver Function

Receiver function 은 struct 의 멤버 변수를 활용할 수 있는 function 이다.

Vertex struct 를 만들고 그에 속한 멤버 변수를 활용하는 function 을 만들면 된다.

func 와 함수명 사이에 Struct 를 변수와 함께 넣으면 Receiver function 이 된다.

type Vertex struct {
	X, Y float64
}

func (v Vertex) Abs() float64 {
	return math.Sqrt(v.X*v.X + v.Y*v.Y)
}

func (v *Vertex) Scale(f float64) {
	v.X = v.X * f
	v.Y = v.Y * f
}

아래와 같이 main 함수를 실행하면 값은 50 이 나온다.

func main() {
	v := Vertex{3, 4}
	v.Scale(10)
	fmt.Println(v.Abs())
}

그런데 만약 위의 func (v *Vertex) Scale(f float64) **에서 Vertex 를 포인터가 아닌 value 로 만들면 어떤 결과가 나올까? 위의 함수에서 * 를 지우고 다시 실행해 보자.

결과는 5 가 된다.

즉, Struct 의 멤버 변수의 값을 바꾸고 싶으면 Pointer Receiver 를 사용해야 한다.

Interface 선언, 활용

Interface 는 method 를 모아둔 것이라 할 수 있다. interface 역시 type 키워드로 선언하기 때문에 interface 타입이라고도 말한다.

아래와 같이 Abs() 메소드를 선언하고 나서 Abs 를 Receiver function 으로 구현했다면 Abs 를 구현한 타입은 Abser 타입이라고 할 수 있다.

type Abser interface {
	Abs() float64
}

아래는 MyFloat 타입도 Abser 타입이라고 할 수 있다. 하지만 Abs 의 Receiver 는 value Receiver 이다.

type MyFloat float64

func (f MyFloat) Abs() float64 {
	if f < 0 {
		return float64(-f)
	}
	return float64(f)
}

Vertex 타입도 역시 Abser 타입이며 pointer Receiver 이다.

type Vertex struct {
	X, Y float64
}

func (v *Vertex) Abs() float64 {
	return math.Sqrt(v.X*v.X + v.Y*v.Y)
}

main 함수에서 interface 를 활용해 본다.

a = v 에서 에러가 발생한다.

func main() {
	var a Abser
	f := MyFloat(-math.Sqrt2)
	v := Vertex{3, 4}

	a = f  // MyFloat 은 Abser 인터페이스를 구현했으니 가능함
	a = &v // *Vertex 는 Abser 인터페이스를 구현했으니 가능함

	a = v  // v 는 Vertext 타입임 (*Vertex 타입이 아님), value receiver 는 구현안했으니 에러 

	fmt.Println(a.Abs())
}

 

Interface 선언, 활용 (2)

interface 는 interface 를 포함할 수 있다.

아래와 같이 Client interface 가 Reader interface 를 가지면 Reader interface 에 선언된 함수가 그 대로 Client 에도 속하게 된다.

package interfaces

import (
	"fmt"
)

type Reader interface {
	Get(obj string) error
	List(list []string) error
}

type Client interface {
	Reader
}

var ReconcileClient Client = &client{}

type client struct {
	name string
}

func (c *client) Get(obj string) error {
	fmt.Println(obj)
	return nil
}

func (c *client) List(list []string) error {
	fmt.Println(list)
	return nil
}

그런 다음 struct 에서 interface 를 또 다시 포함할 수 있다.

아래와 같이 GuestbookReconciler struct 에 Client interface 를 포함하면 GuestbookReconciler 는 마치 Client interface 가 가지는 함수를 자신의 메소드 처럼 사용할 수 있다.

package main

import "github.com/seungkyua/go-sample/interfaces"

type GuestbookReconciler struct {
	interfaces.Client
}

func main() {
	g := GuestbookReconciler{interfaces.ReconcileClient}
	g.Get("Seungkyu")
	g.Client.Get("Seungkyu")
}

이 때 메소드 활용은 g.Get 혹은 g.Client.Get 둘 다 가능하다.

반응형
Posted by seungkyua@gmail.com
,