반응형

인터페이스가 다른 인터페이서를 가지는 임베딩 방식을 사용하여 인터페이스를 선언할 수 있다. 예를 들어 io.ReadCloser 인터페이스는 io.Reader 와 io.Closer 인터페이스를 가지고 있다.

type Reader interface {
        Read(p []byte) (n int, err error)
}

type Closer interface {
        Close() error
}

type ReadCloser interface {
        Reader
        Closer
}

이 경우 ReadCloser 인터페이스는 아래와 동일 효과를 갖는다.

type ReadCloser interface {
        Read(p []byte) (n int, err error)
        Close() error
}

그런데 인터페이스를 struct 타입 안에 임베딩할 수 도 있다.

이렇게 struct 타입 안에 인터페이스를 넣는 이유는 보통 Stub 으로 유닛 테스트 코드를 만들기 쉽기 때문이다.

아래와 같이 Calculator 라는 스트럭트가 있다고 하자.

type Calculator struct {
    Resolver MathResolver
}

여기에는 MathResolver 인터페이스 타입의 필드를 가지고 있다.

type MathResolver interface {
    Resolve(expression string) (float64, error)
}

이렇게 인터페이스를 만들어 놓으면 MathResolver 를 Stub 으로 구현하여 테스트 코드를 만들 수 있다.

Calculator 는 계산 표현식을 가지고 실제 계산하여 결과 값을 리턴하는 Process 라는 메소스도 가진다.

func (c Calculator) Process(r io.Reader) (float64, error) {
    expression, err := readOneLine(r)
    if err != nil {
        return 0, err
    }
    if len(expression) == 0 {
        return 0, errors.New("no expression to read")
    }
    answer, err := c.Resolver.Resolve(expression)
    return answer, err
}

readOneLine 함수는 계산 표현식을 한 줄만 읽어들이는 함수이며, 이렇게 읽어들인 함수를 MathResolver 타입의 Resolve 함수에 아규먼트로 넘겨서 결과를 받아오는 구조이다.

이제 Proecess 메소드에 대한 테스트 코드를 만들어 보자.

첫번째로 테스트 코드로 MathResolverStub 스트럭트와 Resolve 메소드를 간단히 구현한다.

type MathResolverStub struct{}

func (mr MathResolverStub) Resolve(expr string) (float64, error) {
    switch expr {
    case "2 + 4 * 10":
        return 42, nil
    case "( 2 + 4 ) * 10":
        return 60, nil
    case "( 2 + 4 * 10":
        return 0, fmt.Errorf("invalid expression: %s", expr)
    }
    return 0, nil
}

다음으로 이 스텁을 사용한 테스트 코드를 작성한다.

func TestCalculatorProcess(t *testing.T) {
    c := embed.Calculator{Resolver: MathResolverStub{}}
    in := strings.NewReader(`2 + 4 * 10
( 2 + 4 ) * 10
( 2 + 4 * 10`)

    data := []float64{42, 60, 0}
    expectedErr := errors.New("invalid expression: ( 2 + 4 * 10")
    for _, d := range data {
        result, err := c.Process(in)
        if err != nil {
            if err.Error() != expectedErr.Error() {
                t.Errorf("want (%v) got (%v)", expectedErr, err)
            }
        }
        if result != d {
            t.Errorf("Expected result %f, got %f", d, result)
        }
    }
}

MathResolverStub 를 가지는 Calculator 를 생성하여 Calculator 의 Process 메소드를 테스트할 수 있는 코드를 쉽게 작성할 수 있다.

테스트 코드의 전체 작성은 다음과 같다.

$ mkdir -p interface/embed
$ vi interface/embed/calculate.go

package embed

import (
    "errors"
    "io"
)

type Calculator struct {
    Resolver MathResolver
}

type MathResolver interface {
    Resolve(expression string) (float64, error)
}

func (c Calculator) Process(r io.Reader) (float64, error) {
    expression, err := readOneLine(r)
    if err != nil {
        return 0, err
    }
    if len(expression) == 0 {
        return 0, errors.New("no expression to read")
    }
    answer, err := c.Resolver.Resolve(expression)
    return answer, err
}

func readOneLine(r io.Reader) (string, error) {
    var out []byte
    b := make([]byte, 1)
    for {
        _, err := r.Read(b)
        if err != nil {
            if err == io.EOF {
                return string(out), nil
            }
        }
        if b[0] == '\n' {
            break
        }
        out = append(out, b[0])
    }
    return string(out), nil
}
$ vi interface/embed/calculate_test.go

package embed_test

import (
    "errors"
    "fmt"
    "strings"
    "testing"

    "github.com/seungkyua/go-test/interface/embed"
)

type MathResolverStub struct{}

func (mr MathResolverStub) Resolve(expr string) (float64, error) {
    switch expr {
    case "2 + 4 * 10":
        return 42, nil
    case "( 2 + 4 ) * 10":
        return 60, nil
    case "( 2 + 4 * 10":
        return 0, fmt.Errorf("invalid expression: %s", expr)
    }
    return 0, nil
}

func TestCalculatorProcess(t *testing.T) {
    c := embed.Calculator{Resolver: MathResolverStub{}}
    in := strings.NewReader(`2 + 4 * 10
( 2 + 4 ) * 10
( 2 + 4 * 10`)

    data := []float64{42, 60, 0}
    expectedErr := errors.New("invalid expression: ( 2 + 4 * 10")
    for _, d := range data {
        result, err := c.Process(in)
        if err != nil {
            if err.Error() != expectedErr.Error() {
                t.Errorf("want (%v) got (%v)", expectedErr, err)
            }
        }
        if result != d {
            t.Errorf("Expected result %f, got %f", d, result)
        }
    }
}

실행을 위한 세팅 명령어는 다음과 같다.

$ go mod init github.com/seungkyua/go-test
$ go mod tidy
$ go mod vendor

$ go work init
$ go work use .

go work 는 비지니스 모듈 패키지를 아직 github 에 커밋하지 않은 상태에서 로컬의 최신 패키지 참조를 위해서 필요하다.

전체 소스 트리는 다음과 같다.

$ tree .                   
.
├── README.md
├── go.mod
├── go.work
└── interface
    └── embed
        ├── calculate.go
        └── calculate_test.go

다음은 테스트 실행 결과이다.

$ go test interface/embed/calculate_test.go
ok      command-line-arguments  0.390s

소스는 아래의 링크에서 다운 받을 수 있다.

https://github.com/seungkyua/go-test.git

반응형
Posted by seungkyua@gmail.com
,
반응형

Git 을 사용하다 보면 저장소에 작업한 commit 을 원복해야 하는 경우가 종종 발생한다. 로컬에서 혼자서 작업한다면 reset 을 사용해서 이전 commit 으로 쉽게 돌릴 수 있지만 이미 원격 저장소에 push 한 상태라면 revert 를 사용하여 이전 commit 을 취소하는 새로운 commit 을 만들어야 한다.

명료하게 아래 2가지 경우만 생각하면 된다.

아직 원격 저장소에 push 하지 않은 경우 : reset 사용
원격 저장소에 push 한 경우 : revert 사용

예외적으로 원격 저장소에 push 한 경우라도 reset 을 사용해서 commit 을 돌릴 수 있다. 하지만 이 때는 원격의 commit 도 같이 삭제하는 작업이 필요하므로 git push 를 할 때 -f 으로 강제 push 를 해야하는 문제가 있어 여러명이 함께 작업하는 경우라면 다른 사람들에게 문제가 발생할 수 있다. (웬만하면 하지 말아야 한다)

git reset : 로컬 환경을 특정 commit 위치로 되돌리기

아래와 같은 commit log 가 있다고 보자.

$ git log --oneline -n 5

4e61ca5 (HEAD -> main, origin/main, origin/HEAD) add c.txt
3bd328a add b.txt
be0d36b add a.txt
205a70c Initial commit
$ ls -l
total 8
-rw-r--r--@ 1 ask  staff  17  3 14 10:12 README.md
-rw-r--r--@ 1 ask  staff   0  3 14 10:15 a.txt
-rw-r--r--@ 1 ask  staff   0  3 14 10:16 b.txt
-rw-r--r--@ 1 ask  staff   0  3 14 10:17 c.txt

Initial 커밋에는 README.md 파일이 추가되어 있고, 이후 각 커밋은 a.txt, b.txt, c.txt 가 추가되어 있는 상태이다. 여기서 a.txt 만 남기고 b.txt, c.txt 를 지운 상태의 돌아가고 싶다고 하면 a.txt 를 추가한 be0d36b add a.txt 커밋 상태로 돌아가면 된다.

$ git reset --hard be0d36b
HEAD is now at be0d36b add a.txt

로그와 파일을 조회해 보면 정상적으로 commit 이 이전 상태로 돌아온 것을 알 수 있다.

$ git log --oneline -n 5

be0d36b (HEAD -> main) add a.txt
205a70c Initial commit
$ ls -l
total 8
-rw-r--r--@ 1 ask  staff  17  3 14 10:12 README.md
-rw-r--r--@ 1 ask  staff   0  3 14 10:15 a.txt

reset 명령을 수행하면 커밋이 이전 상태로 돌아간 것이기 때문에 다시 원상태로 돌릴려면 원격 저장소에서 다시 pull 로 가져오면 된다.

$ git pull 

Updating be0d36b..4e61ca5
Fast-forward
 b.txt | 0
 c.txt | 0
 2 files changed, 0 insertions(+), 0 deletions(-)
 create mode 100644 b.txt
 create mode 100644 c.txt
$ git log --oneline -n 5

4e61ca5 (HEAD -> main, origin/main, origin/HEAD) add c.txt
3bd328a add b.txt
be0d36b add a.txt
205a70c Initial commit

파일도 원상태로 생성된 것을 알 수 있다.

$ ls -l
total 8
-rw-r--r--@ 1 ask  staff  17  3 14 10:12 README.md
-rw-r--r--@ 1 ask  staff   0  3 14 10:15 a.txt
-rw-r--r--@ 1 ask  staff   0  3 14 10:16 b.txt
-rw-r--r--@ 1 ask  staff   0  3 14 10:17 c.txt

git revert : 이전 commit 제거하는 신규 commit 을 추가

현재 커밋 로그는 다음과 같습니다.

$ git log --oneline -n 5

4e61ca5 (HEAD -> main, origin/main, origin/HEAD) add c.txt
3bd328a add b.txt
be0d36b add a.txt
205a70c Initial commit

여기서 3bd328a add b.txt 을 삭제하고 싶을 때 revert 를 할 수 있다.

$ git revert 3bd328a --no-edit

[main 5a9e9f1] Revert "add b.txt"
 Date: Thu Mar 14 10:51:58 2024 +0900
 1 file changed, 0 insertions(+), 0 deletions(-)
 delete mode 100644 b.txt

git log 를 보면 다음과 같다.

$ git log --oneline -n 5

5a9e9f1 (HEAD -> main) Revert "add b.txt"
4e61ca5 (origin/main, origin/HEAD) add c.txt
3bd328a add b.txt
be0d36b add a.txt
205a70c Initial commit

5a9e9f1 (HEAD -> main) Revert "add b.txt" 커밋 로그를 보면 revert 하면서 새로운 commit 이 생긴 것을 알 수 있다. reset 과는 다르게 commit 의 순서와 내용은 그대로 살아있으면서 revert 가 추가된 것이기 협업할 때 아무런 문제가 없다.

리스트를 보면 b.txt 가 삭제되어 다음과 같다.

$ ls -l
total 8
-rw-r--r--@ 1 ask  staff  17  3 14 10:12 README.md
-rw-r--r--@ 1 ask  staff   0  3 14 10:15 a.txt
-rw-r--r--@ 1 ask  staff   0  3 14 10:38 c.txt

revert 와 reset 은 둘 다 파라미터로 commit hash 값을 넣는 것은 동일하나 동작되는 의미는 다르다. reset 은 해당 commit 으로 돌아가기 때문에 그 이후의 commit 은 없어지는 반면에 revert 는 해당 commit 만 제거하는 의미가 있다.

revert 를 다시 revert 할 수 있다

revert 하여 b.txt 를 삭제한 commit 은 5a9e9f1 (HEAD -> main) Revert "add b.txt" 이다. 이 commit 을 revert 하면 다시 b.txt 파일이 살아날 수 있다. revert 할 때 commit hash 값과 이를 가리키는 HEAD 도 같은 의미이기 때문에 HEAD 를 이용하여 revert 해보자.

$ git revert HEAD --no-edit

[main d2b2258] Revert "Revert "add b.txt""
 Date: Thu Mar 14 11:00:28 2024 +0900
 1 file changed, 0 insertions(+), 0 deletions(-)
 create mode 100644 b.txt
$ git log --oneline -n 10

d2b2258 (HEAD -> main) Revert "Revert "add b.txt""
5a9e9f1 Revert "add b.txt"
4e61ca5 (origin/main, origin/HEAD) add c.txt
3bd328a add b.txt
be0d36b add a.txt
205a70c Initial commit

$ ls -l
total 8
-rw-r--r--@ 1 ask  staff  17  3 14 10:12 README.md
-rw-r--r--@ 1 ask  staff   0  3 14 10:15 a.txt
-rw-r--r--@ 1 ask  staff   0  3 14 11:00 b.txt
-rw-r--r--@ 1 ask  staff   0  3 14 10:38 c.txt

여러 commit 을 revert 하기

commit hash 값을 나열하면 여러 commit 을 revert 할 수 있다.

3bd328a add b.txt 커밋과 4e61ca5 (origin/main, origin/HEAD) add c.txt 커밋을 동시에 revert 해보자.

$ git revert --no-edit be0d36b 4e61ca5

[main 3b6ada1] Revert "add a.txt"
 Date: Thu Mar 14 11:11:07 2024 +0900
 1 file changed, 0 insertions(+), 0 deletions(-)
 delete mode 100644 a.txt
[main bd91431] Revert "add c.txt"
 Date: Thu Mar 14 11:11:07 2024 +0900
 1 file changed, 0 insertions(+), 0 deletions(-)
 delete mode 100644 c.txt
$ git log --oneline -n 20

bd91431 (HEAD -> main) Revert "add c.txt"
3b6ada1 Revert "add a.txt"
d2b2258 Revert "Revert "add b.txt""
5a9e9f1 Revert "add b.txt"
4e61ca5 (origin/main, origin/HEAD) add c.txt
3bd328a add b.txt
be0d36b add a.txt
205a70c Initial commit

revert 가 잘 되었지만 각각의 revert 에 대한 커밋이 2개 추가되었다. 3b6ada1 Revert "add a.txt" , bd91431 (HEAD -> main) Revert "add c.txt"

여러 revert 를 하나의 commit 으로 만들기

앞에서 작업한 2개의 revert commit 을 원상태로 되돌려 보자. 원격으로 push 하지 않았으므로 reset 을 사용해도 문제가 없다.

여기서는 d2b2258 Revert "Revert "add b.txt"" 커밋으로 돌아가면 된다.

$ git reset --hard d2b2258
HEAD is now at d2b2258 Revert "Revert "add b.txt""

$ git log --oneline -n 20
d2b2258 (HEAD -> main) Revert "Revert "add b.txt""
5a9e9f1 Revert "add b.txt"
4e61ca5 (origin/main, origin/HEAD) add c.txt
3bd328a add b.txt
be0d36b add a.txt
205a70c Initial commit

$ ls -l
total 8
-rw-r--r--@ 1 ask  staff  17  3 14 10:12 README.md
-rw-r--r--@ 1 ask  staff   0  3 14 11:19 a.txt
-rw-r--r--@ 1 ask  staff   0  3 14 11:00 b.txt
-rw-r--r--@ 1 ask  staff   0  3 14 11:19 c.txt

revert -n 옵션을 사용하면 revert 할 때 index 는 사용하지만 commit 을 하지 않은 상태가 된다. 그러므로 git revert --continue 로 commit 을 진행하면 된다.

 $ git revert -n be0d36b 4e61ca5

현재 상태를 보면 index 에 저장된 상태임을 알 수 있다.

$ git status

On branch main
Your branch is ahead of 'origin/main' by 2 commits.
  (use "git push" to publish your local commits)

You are currently reverting commit 4e61ca5.
  (all conflicts fixed: run "git revert --continue")
  (use "git revert --skip" to skip this patch)
  (use "git revert --abort" to cancel the revert operation)

Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
    deleted:    a.txt
    deleted:    c.txt

이제 commit 을 하면서 커밋 메세지를 추가할 수 있다.

$ git revert --continue

메세지는 다음과 같이 입력했다.

Revert "add c.txt"
Revert "add a.txt"

git log 를 보면 커밋은 a4b6156 (HEAD -> main) Revert "add c.txt" Revert "add a.txt" 하나만 생성되었음을 알 수 있다.

$ git log --oneline -n 20

a4b6156 (HEAD -> main) Revert "add c.txt" Revert "add a.txt"
d2b2258 Revert "Revert "add b.txt""
5a9e9f1 Revert "add b.txt"
4e61ca5 (origin/main, origin/HEAD) add c.txt
3bd328a add b.txt
be0d36b add a.txt
205a70c Initial commit

$ ls -l
total 8
-rw-r--r--@ 1 ask  staff  17  3 14 10:12 README.md
-rw-r--r--@ 1 ask  staff   0  3 14 11:00 b.txt

다음 설명을 위해서 다시 reset 을 하자. commit 하나만 뒤로가면 되므로 HEAD^1 을 사용해도 된다.

$ git reset --hard HEAD^1

git revert: merge commit 에 대한 revert 하기

현재 커밋 로그는 다음과 같다.

$ git log --oneline -n 20

d2b2258 (HEAD -> main) Revert "Revert "add b.txt""
5a9e9f1 Revert "add b.txt"
4e61ca5 (origin/main, origin/HEAD) add c.txt
3bd328a add b.txt
be0d36b add a.txt
205a70c Initial commit

$ ls -l
total 8
-rw-r--r--@ 1 ask  staff  17  3 14 10:12 README.md
-rw-r--r--@ 1 ask  staff   0  3 14 11:34 a.txt
-rw-r--r--@ 1 ask  staff   0  3 14 11:00 b.txt
-rw-r--r--@ 1 ask  staff   0  3 14 11:34 c.txt

git push 를 해서 원격 저장소에 저장한 다음 merge commit 을 만드는 작업을 한다.

$ git push

$ git switch -c merge_branch
$ touch d.txt
$ git add -A
$ git commit -m "add d.txt"

$ git push --set-upstream origin merge_branch 

github 에서 pr 을 올리고 main branch 로 merge 한다.

이후에 main branch 에서 pull 한 다음에 커밋 로그를 보면 다음과 같다.

$ git switch main

$ git pull

$ ls -l
total 8
-rw-r--r--@ 1 ask  staff  17  3 14 10:12 README.md
-rw-r--r--@ 1 ask  staff   0  3 14 11:34 a.txt
-rw-r--r--@ 1 ask  staff   0  3 14 11:00 b.txt
-rw-r--r--@ 1 ask  staff   0  3 14 11:34 c.txt
-rw-r--r--@ 1 ask  staff   0  3 14 11:44 d.txt

$ git log
commit 409bf49c1b05a39609207da03f28f782c3b8a0b9 (HEAD -> main, origin/main, origin/HEAD)
Merge: d2b2258 9fdd01f
Author: Seungkyu Ahn <seungkyua@gmail.com>
Date:   Thu Mar 14 11:42:42 2024 +0900

    Merge pull request #1 from seungkyua/merge_branch

    add d.txt

commit 9fdd01fb9b0eff870093f15e246c998ae1fac452 (origin/merge_branch, merge_branch)
Author: Seungkyu Ahn <seungkyua@gmail.com>
Date:   Thu Mar 14 11:40:46 2024 +0900

    add d.txt

commit d2b22584cb8108cd7bc1eaaaa5775e1f19f330fa
Author: Seungkyu Ahn <seungkyua@gmail.com>
Date:   Thu Mar 14 11:00:28 2024 +0900

    Revert "Revert "add b.txt""

    This reverts commit 5a9e9f14b9b7403ad5aef1df83d14f1a1d4938dd.

첫번째 커밋 로그를 보면 Merge: d2b2258 9fdd01f 와 같이 Merge 임을 알 수 있다. merge 의 경우 revert 는 -m 옵션으로 첫번재 hash 값을 적용할지 두번째 hash 값을 적용할지를 결정해 주어야 한다.

merge 바로 이전 커밋인 d2b2258 으로 revert 할 것이기 때문에 첫번재를 선택해 준다.

$ git revert 409bf49c -m 1

[main d53c40d] Revert "Merge pull request #1 from seungkyua/merge_branch"
 1 file changed, 0 insertions(+), 0 deletions(-)
 delete mode 100644 d.txt

로그를 보면 revert 되었음을 알 수 있다.

$ git log --oneline -n 20

d53c40d (HEAD -> main) Revert "Merge pull request #1 from seungkyua/merge_branch"
409bf49 (origin/main, origin/HEAD) Merge pull request #1 from seungkyua/merge_branch
9fdd01f (origin/merge_branch, merge_branch) add d.txt
d2b2258 Revert "Revert "add b.txt""
5a9e9f1 Revert "add b.txt"
4e61ca5 add c.txt
3bd328a add b.txt
be0d36b add a.txt
205a70c Initial commit
$ ls -l
total 8
-rw-r--r--@ 1 ask  staff  17  3 14 10:12 README.md
-rw-r--r--@ 1 ask  staff   0  3 14 11:34 a.txt
-rw-r--r--@ 1 ask  staff   0  3 14 11:00 b.txt
-rw-r--r--@ 1 ask  staff   0  3 14 11:34 c.txt

patch 로 commit 삭제하기

commit 에 대한 패치 파일을 만들고 -R 옵션을 사용하여 패치 파일을 apply 하면 해당 패치 파일에 대한 commit 을 삭제할 수 있다.

현재 커밋 로그는 다음과 같다.

$ git log --oneline -n 20

d53c40d (HEAD -> main, origin/main, origin/HEAD) Revert "Merge pull request #1 from seungkyua/merge_branch"
409bf49 Merge pull request #1 from seungkyua/merge_branch
9fdd01f (origin/merge_branch, merge_branch) add d.txt
d2b2258 Revert "Revert "add b.txt""
5a9e9f1 Revert "add b.txt"
4e61ca5 add c.txt
3bd328a add b.txt
be0d36b add a.txt
205a70c Initial commit
$ ls -l
total 8
-rw-r--r--@ 1 ask  staff  17  3 14 10:12 README.md
-rw-r--r--@ 1 ask  staff   0  3 14 11:34 a.txt
-rw-r--r--@ 1 ask  staff   0  3 14 11:00 b.txt
-rw-r--r--@ 1 ask  staff   0  3 14 11:34 c.txt

여기서 3bd328a add b.txt 에 대한 패치 파일을 만들어 보자.

$ git format-patch -1 3bd328a

아래와 같이 하나의 0001-add-b.txt.patch 패치 파일이 생성되었다.

$ ls -l
total 16
-rw-r--r--@ 1 ask  staff  368  3 14 13:46 0001-add-b.txt.patch
-rw-r--r--@ 1 ask  staff   17  3 14 10:12 README.md
-rw-r--r--@ 1 ask  staff    0  3 14 11:34 a.txt
-rw-r--r--@ 1 ask  staff    0  3 14 11:00 b.txt
-rw-r--r--@ 1 ask  staff    0  3 14 11:34 c.txt

이제 -R 옵션을 적용하여 patch 파일을 적용하자. -R 옵션은 reverse 로 패치 파일을 삭제하는 역할을 한다.

$ git apply -R 0001-add-b.txt.patch

상태를 보면 다음과 같다.

$ git status
On branch main
Your branch is up to date with 'origin/main'.

Changes not staged for commit:
  (use "git add/rm <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
    deleted:    b.txt

Untracked files:
  (use "git add <file>..." to include in what will be committed)
    0001-add-b.txt.patch

no changes added to commit (use "git add" and/or "git commit -a")

파일 리스트를 보면 b.txt 가 삭제되어 있다.

$ ls -l
total 16
-rw-r--r--@ 1 ask  staff  368  3 14 13:46 0001-add-b.txt.patch
-rw-r--r--@ 1 ask  staff   17  3 14 10:12 README.md
-rw-r--r--@ 1 ask  staff    0  3 14 11:34 a.txt
-rw-r--r--@ 1 ask  staff    0  3 14 11:34 c.txt

이제 삭제된 파일을 stage 에 add 한 후 commit 한다.

$ git add b.txt
$ git commit -m "-R patch to b.txt"

커밋 로그를 보면 b.txt 가 삭제된 것을 알 수 있다.

$ git log --oneline -n 20

44e55ce (HEAD -> main) -R patch to b.txt
d53c40d (origin/main, origin/HEAD) Revert "Merge pull request #1 from seungkyua/merge_branch"
409bf49 Merge pull request #1 from seungkyua/merge_branch
9fdd01f (origin/merge_branch, merge_branch) add d.txt
d2b2258 Revert "Revert "add b.txt""
5a9e9f1 Revert "add b.txt"
4e61ca5 add c.txt
3bd328a add b.txt
be0d36b add a.txt
205a70c Initial commit

패치 파일이 있으니 apply 로 해당 commit 을 다시 살려보자. 실제로는 commit 을 살리는 것이 아니라 해당 commit 의 변경된 파일을 되살리는 것이다.

$ git apply 0001-add-b.txt.patch
$ git add b.txt
$ git commit -m "restore b.txt using patch"

아래 디렉토리에 b.txt 가 살아난 것을 알 수 있다.

$ ls -l
total 16
-rw-r--r--@ 1 ask  staff  368  3 14 13:46 0001-add-b.txt.patch
-rw-r--r--@ 1 ask  staff   17  3 14 10:12 README.md
-rw-r--r--@ 1 ask  staff    0  3 14 11:34 a.txt
-rw-r--r--@ 1 ask  staff    0  3 14 14:03 b.txt
-rw-r--r--@ 1 ask  staff    0  3 14 11:34 c.txt

필요없는 패치 파일은 삭제한다.

$ rm 0001-add-b.txt.patch

여러 커밋을 하나의 패치 파일로 만들기

현재 커밋 로그는 아래와 같다.

$ git log --oneline -n 20

9aced43 (HEAD -> main, origin/main, origin/HEAD) restore b.txt using patch
44e55ce -R patch to b.txt
d53c40d Revert "Merge pull request #1 from seungkyua/merge_branch"
409bf49 Merge pull request #1 from seungkyua/merge_branch
9fdd01f (origin/merge_branch, merge_branch) add d.txt
d2b2258 Revert "Revert "add b.txt""
5a9e9f1 Revert "add b.txt"
4e61ca5 add c.txt
3bd328a add b.txt
be0d36b add a.txt
205a70c Initial commit

여기서 be0d36b add a.txt , 3bd328a add b.txt, 4e61ca5 add c.txt 을 하나의 패치 파일로 만들고 싶으면 다음과 같이 하면 된다.

시작 hash값 ^.. 종료 hash값

만약 .. 만 사용하면 시간 hash값은 포함되지 않는다(여기서는 be0d36b add a.txt 커밋이 포함되지 않는다). 그러므로 시작 hash값을 포함하고 싶으면 ^.. 을 사용해야 한다.

$ git format-patch be0d36b^..4e61ca5 --stdout > commits.patch

한가지 더 설명하자면 format-patch 는 커밋 히스토리까지 파일에 포함 시킨다. diff 를 사용하면 커밋 히스토리를 제외할 수 있다.

$ git diff be0d36b^..4e61ca5 > diff.patch
반응형
Posted by seungkyua@gmail.com
,
반응형

helm chart 를 만들기 위해서는 여러 기능들을 알아야 하지만 그 중에서 가장 많이 쓰고 헷갈리는 기능에 대해서 살펴 본다.

기본적으로 실습할 수 있는 환경을 먼저 만들고 하나씩 공부해 본다.

$ helm create flow-control
$ cd flow-control

yaml 형태의 출력을 확인해 본다.

$ helm template .
---
# Source: flow-control/templates/serviceaccount.yaml
apiVersion: v1
kind: ServiceAccount
metadata:
  name: release-name-flow-control
  labels:
    helm.sh/chart: flow-control-0.1.0
    app.kubernetes.io/name: flow-control
    app.kubernetes.io/instance: release-name
    app.kubernetes.io/version: "1.16.0"
    app.kubernetes.io/managed-by: Helm
automountServiceAccountToken: true
---
# Source: flow-control/templates/service.yaml
apiVersion: v1
kind: Service
metadata:
  name: release-name-flow-control
  labels:
    helm.sh/chart: flow-control-0.1.0
    app.kubernetes.io/name: flow-control
    app.kubernetes.io/instance: release-name
    app.kubernetes.io/version: "1.16.0"
    app.kubernetes.io/managed-by: Helm
spec:
  type: ClusterIP
  ports:
    - port: 80
      targetPort: http
      protocol: TCP
      name: http
  selector:
    app.kubernetes.io/name: flow-control
    app.kubernetes.io/instance: release-name
---
# Source: flow-control/templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: release-name-flow-control
  labels:
    helm.sh/chart: flow-control-0.1.0
    app.kubernetes.io/name: flow-control
    app.kubernetes.io/instance: release-name
    app.kubernetes.io/version: "1.16.0"
    app.kubernetes.io/managed-by: Helm
spec:
  replicas: 1
  selector:
    matchLabels:
      app.kubernetes.io/name: flow-control
      app.kubernetes.io/instance: release-name
  template:
    metadata:
      labels:
        helm.sh/chart: flow-control-0.1.0
        app.kubernetes.io/name: flow-control
        app.kubernetes.io/instance: release-name
        app.kubernetes.io/version: "1.16.0"
        app.kubernetes.io/managed-by: Helm
    spec:
      serviceAccountName: release-name-flow-control
      securityContext:
        {}
      containers:
        - name: flow-control
          securityContext:
            {}
          image: "nginx:1.16.0"
          imagePullPolicy: IfNotPresent
          ports:
            - name: http
              containerPort: 80
              protocol: TCP
          livenessProbe:
            httpGet:
              path: /
              port: http
          readinessProbe:
            httpGet:
              path: /
              port: http
          resources:
            {}
---
# Source: flow-control/templates/tests/test-connection.yaml
apiVersion: v1
kind: Pod
metadata:
  name: "release-name-flow-control-test-connection"
  labels:
    helm.sh/chart: flow-control-0.1.0
    app.kubernetes.io/name: flow-control
    app.kubernetes.io/instance: release-name
    app.kubernetes.io/version: "1.16.0"
    app.kubernetes.io/managed-by: Helm
  annotations:
    "helm.sh/hook": test
spec:
  containers:
    - name: wget
      image: busybox
      command: ['wget']
      args: ['release-name-flow-control:80']
  restartPolicy: Never

제대로 출력된다면 필요없는 파일은 삭제하고 초기화 하자.

$ rm -rf template/*
$ cat /dev/null > values.yaml

0. yaml 은 들여 쓰기가 중요하다.

가장 간단한 configmap 을 만들어서 value 값을 출력한다.

$ values.yaml
---
favorite:
  drink: coffee
  food: pizza

$ vi template/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: {{ .Release.Name }}-configmap
data:
  myvalue: "Hello World"
  drink: {{ .Values.favorite.drink | default "tea" | quote }}
  food: {{ .Values.favorite.food | upper | quote }}
  {{ if eq .Values.favorite.drink "coffee" }}
    mug: "true"
  {{ end }}

$ helm template .
--- output ---
Error: YAML parse error on flow-control/templates/configmap.yaml: error converting YAML to JSON: yaml: line 8: did not find expected key

Use --debug flag to render out invalid YAML

template 을 제너레이션하면 에러가 발생한다. 왜 그럴까?

configmap.yaml 에 mug: "true" 가 2칸 들여써 있어서 발생하는 에러이다. 이런 에러를 조심하면서 아래 실습을 해보자.

1. 조건문과 빈라인 없애기

configmap.yaml 을 수정해서 제대로 yaml 을 생성해 보자.

$ vi template/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: {{ .Release.Name }}-configmap
data:
  myvalue: "Hello World"
  drink: {{ .Values.favorite.drink | default "tea" | quote }}
  food: {{ .Values.favorite.food | upper | quote }}
  {{ if eq .Values.favorite.drink "coffee" }}
  mug: "true"
  {{ end }}

$ helm template .
---
# Source: flow-control/templates/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: release-name-configmap
data:
  myvalue: "Hello World"
  drink: "coffee"
  food: "PIZZA"
                    --------------> 빈라인 발생한다.
  mug: "true"
                    --------------> 빈라인 발생한다.

.Release.Name 은 helm 으로 설치할 때 파라미터로 넘기는 값인데 여기서는 설치가 아니므로 기본 값인 release-name 으로 치환 되었고, .Values.favorite.drink 는 values.yaml 에 지정한 키값으로 그에 해당하는 밸류 값이 제너레이트 될 때 출력된다.

함수의 연속적 사용은 | 라인으로 호출 가능하며 default 는 키에 대한 값이 없을 때, quote 는 값을 " 으로 묶을 때 upper 는 값을 대문자로 변환할 때 사용하는 내장 함수이다.

비교 구문은 if eq 값1 값2 와 같이 사용할 수 있다.

출력하지 않는 곳에서는 빈라인이 발생하는데 이 부분을 다음과 같이 없애 줄 수 있다.

{{ if eq .Values.favorite.drink "coffee" }}mug: "true"{{ end }}

하지만 가독성이 떨어지므로 {{- 와 같이 표현하면 빈라인이 없어지면서 윗라인에 나란히 붙는 것과 같은 효과를 낼 수 있다.

  {{- if eq .Values.favorite.drink "coffee" }}
  mug: "true"
  {{- end }}

다시 yaml 을 생성해 보면 아래와 같이 빈라인이 없어졌음을 알 수 있다.

$ helm template .
---
# Source: flow-control/templates/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: release-name-configmap
data:
  myvalue: "Hello World"
  drink: "coffee"
  food: "PIZZA"
  mug: "true"

조건문 대신 다음과 같이 with 를 사용하여 조건문과 키밸류 스쿱을 지정할 수 있다.

### configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: {{ .Release.Name }}-configmap
data:
  myvalue: "Hello World"
  {{- with .Values.hobby }}
  sports: {{ .sports }}
  {{- end }}

### values.yaml
hobby:
  sports: golf

with 와 함께 사용한 키는 해당 키에 대한 값이 있을 때만 with ~ end 로 감싼 구문이 출력된다. 또한 감싼 구문 안에서는 스쿱이 재정의되어 hobby 아래의 키인 sports 를 .sports 로 바로 사용할 수 있다.

yaml 을 생성하면 다음과 같은 결과가 나온다.

$ helm template .

# Source: flow-control/templates/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: release-name-configmap
data:
  myvalue: "Hello World"
  sports: golf

만약 values 에 sports 를 없애면 아래와 같이 출력되지 않는다.

### values.yaml
hobby: {}

# Source: flow-control/templates/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: release-name-configmap
data:
  myvalue: "Hello World"

hobby 아래의 키는 키밸류인 디셔너리 타입을 넣기 때문에 아래의 값을 모두 없애기 위해서{} 빈 딕셔너리 값으로 지정했다. 만약 아래의 값이 리스트라면 [] 와 같이 빈 리스트 값을 지정할 수 있다.

with ~ end 로 감싼 구문에서 root 영역의 value 를 활용하고 싶을 수 있다. 이 때는 $ 를 붙혀서 영역을 최상위 root 로 접근할 수 있다. 아래 예제에서 $.Release.Name 을 참고한다.

### configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: {{ .Release.Name }}-configmap
data:
  myvalue: "Hello World"
  {{- with .Values.hobby }}
  sports: {{ .sports }}
  release: {{ $.Release.Name }}
  {{- end }}

YAMLJSON 의 수퍼셋이기 때문에 JSON 으로 표현하여 가독성을 높혀줄 수 도 있다. pod 를 만들 때 yaml 에 args 를 추가할 수 있는데. 이 때 JSON 을 쓰면 읽기에 편하다.

args:
  - "--dirname"
  - "/foo"  

위의 내용은 JSON 으로 아래와 같이 바꿀 수 있다.

args: ["--dirname", "/foo"]

2. range 함수를 이용한 반복문

range 를 이용하여 반복문을 사용할 수 있다.

### values.yaml
pizzaToppings:
  - mushrooms
  - cheese
  - peppers
  - onions

### configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: {{ .Release.Name }}-configmap
data:
  toppings: |-
    {{- range .Values.pizzaToppings }}
    - {{ . | title | quote }}
    {{- end }}

pizzaToppings 의 값은 리스트이다(- 기호가 값으로 붙었기 때문에 리스트임을 알 수 있다). 리스트로 값을 가져와서 출력하기 때문에 아래와 같은 결과가 나온다.

# Source: flow-control/templates/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: release-name-configmap
data:
  toppings: |-
    - "Mushrooms"
    - "Cheese"
    - "Peppers"
    - "Onions"

한가지 yaml 에서 toppings: 의 뒤에 따라온 |- 기호의 의미는 멀티 라인 스트링을 받겠다는 의미이다.

tuple 을 사용하여 튜플로 만들어 쓸 수 도 있다.

### configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: {{ .Release.Name }}-configmap
data:
  sizes: |-
    {{- range tuple "small" "medium" "large" }}
    - {{ . }}
    {{- end }}

### 출력 결과
# Source: flow-control/templates/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: release-name-configmap
data:
  sizes: |-
    - small
    - medium
    - large

$:= 를 이용하여 변수를 지정할 수 있다. 아래는 리스트에서 받은 값을 index 변수와 value 변수로 받아서 활용하는 부분이다.

### values.yaml
pizzaToppings:
  - mushrooms
  - cheese
  - peppers
  - onions

### configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: {{ .Release.Name }}-configmap
data:
  toppings: |-
    {{- range $index, $value := .Values.pizzaToppings }}
    - {{ $index }}: {{ $value }}
    {{- end }}

# Source: flow-control/templates/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: release-name-configmap
data:
  toppings: |-
    - 0: mushrooms
    - 1: cheese
    - 2: peppers
    - 3: onions

map 값을 변수로 받아 처리할 수 도 있다.

### values.yaml
favorite:
  drink: coffee
  food: pizza

### configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: {{ .Release.Name }}-configmap
data:
  favorite: |-
    {{- range $key, $value := .Values.favorite }}
    {{ $key }}: {{ $value }}
    {{- end }}

# Source: flow-control/templates/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: release-name-configmap
data:
  favorite: |-
    drink: coffee
    food: pizza

3. template 선언 및 활용

부분적으로 사용자 정의 template 을 만들어서 활용 할 수 있다.

define - tempate 사용

template 은 define 으로 선언하고 tempate 으로 활용할 수 있다.

### configmap.yaml
{{- define "mychart.labels" }}
  labels:
    generator: helm
    date: {{ now | htmlDate }}
{{- end }}
apiVersion: v1
kind: ConfigMap
metadata:
  name: {{ .Release.Name }}-configmap
  {{- template "mychart.labels" }}
data:
  myvalue: "Hello World"

# Source: flow-control/templates/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: release-name-configmap
  labels:
    generator: helm
    date: 2023-10-31
data:
  myvalue: "Hello World"

위의 예제는 labels 에 date: 날짜 를 추가로 넣는 부분을 named template 을 만들어서 사용한 예제이다.

chat 를 만들 때 configmap.yaml 과 같이 쿠버네티스 리소스들은 template 디렉토리 아래에 위치 시킨다고 했다. 이 디렉토리에 위치한 yaml 파일들은 자동으로 렌더링에 포함되는데 _ 로 시작하는 파일은 렌더링에서 제외한다. 그래서 보통 define 으로 정의한 함수들은 _helper.tpl 파일을 만들어서 이곳에 위치 시킨다.

define 으로 정의된 named template (함수) 은 template 으로 호출되기 전까지는 렌더링 되지 않는다. 이제 이 함수를 _helper.tpl 파일로 옮겨서 렌더링 결과를 살펴보자.

# _helper.tpl
{{- define "mychart.labels" }}
  labels:
    generator: helm
    date: {{ now | htmlDate }}
{{- end }}

# configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: {{ .Release.Name }}-configmap
  {{- template "mychart.labels" }}
data:
  myvalue: "Hello World"

# Source: flow-control/templates/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: release-name-configmap
  labels:
    generator: helm
    date: 2023-10-31
data:
  myvalue: "Hello World"

위의 예제에서 define 함수 내에서 .Values 와 같이 value 를 가져오는 것은 하지 않았다. 아래와 같이 {{ .Chart.Name }} 을 사용한다면 위의 방식으로는 값을 표현할 수 없다.

# _helper.tpl
{{- define "mychart.labels" }}
  labels:
    generator: helm
    date: {{ now | htmlDate }}
    chart: {{ .Chart.Name }}
    version: {{ .Chart.Version }}
{{- end }} 

# Source: flow-control/templates/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: release-name-configmap
  labels:
    generator: helm
    date: 2023-10-31
    chart:
    version:
data:
  myvalue: "Hello World"

이는 template 으로 호출할 때 뒤에 value 를 보내지 않아서 발생한 부분이다. 즉 {{- template "mychart.labels" . }} 과 같이 마지막에 현재의 scope value 인 . 을 넘겨 주어야 제대로 된 값이 출력된다.

# configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: {{ .Release.Name }}-configmap
  {{- template "mychart.labels" . }}
data:
  myvalue: "Hello World"

# Source: flow-control/templates/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: release-name-configmap
  labels:
    generator: helm
    date: 2023-10-31
    chart: flow-control
    version: 0.1.0

define - include 사용

template 은 있는 그대로 output 을 보여주기 때문에 들여쓰기의 문제가 있을 수 있다.

# _helper.tlp
{{- define "mychart.app" -}}
app_name: {{ .Chart.Name }}
app_version: "{{ .Chart.Version }}"
{{- end -}}

# configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: {{ .Release.Name }}-configmap
  labels:
    {{ template "mychart.app" . }}
data:
  myvalue: "Hello World"
{{ template "mychart.app" . }}

# Source: flow-control/templates/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: release-name-configmap
  labels:
    app_name: flow-control
app_version: "0.1.0"
data:
  myvalue: "Hello World"
app_name: flow-control
app_version: "0.1.0"

app_name 과 app_version 이 출력된 것을 보면 define 에 정의된 들여쓰기 대로 그대로 출력되어 원하는 대로 출력되지 않는다.

includenindent 를 사용하면 원하는 들여쓰기가 가능하다.

# configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: {{ .Release.Name }}-configmap
  labels:
    {{ template "mychart.app" . }}
data:
  myvalue: "Hello World"
{{ template "mychart.app" . }}

# Source: flow-control/templates/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: release-name-configmap
  labels:
    app_name: flow-control
    app_version: "0.1.0"
data:
  myvalue: "Hello World"
  app_name: flow-control
  app_version: "0.1.0"

끝으로 helm install 할 때 들여쓰기가 잘못되면 렌더링 오류가 나서 최종 결과를 볼 수 가 없다. 이를 해결할 수 있는 옵션이 -disable-openapi-validation 이다.

$ helm install --dry-run --disable-openapi-validation mychart ./
반응형
Posted by seungkyua@gmail.com
,
반응형

스프링 부트 기반으로 개발한 어플리케이션을 컨테이너 이미지로 만들 때 이미지 사이즈를 줄이는 방법에 대해서 알아보자.

먼저, sample source 를 다운 받는다.

$ git clone https://github.com/seungkyua/springboot-docker.git
$ cd springboot-docker

다운 받은 소스에서 mvn 으로 package 를 빌드한다.

$ mvn package

빌드 후에 소스의 디렉토리 구조는 다음과 같다.

$ tree . -L 2
.
├── LICENSE
├── README.md
├── docker
│   ├── Dockerfile
│   ├── Dockerfile2
│   └── Dockerfile3
├── logs
│   └── access_log.log
├── pom.xml
├── scripts
│   └── run.sh
├── src
│   └── main
├── target
│   ├── classes
│   ├── example-0.0.1-SNAPSHOT.jar
│   ├── example-0.0.1-SNAPSHOT.jar.original
│   ├── generated-sources
│   ├── maven-archiver
│   └── maven-status
└── work
    └── Tomcat

오늘은 자바 소스는 상관없이 도커 이미지 빌드에 대해서만 설명하므로 Dockerfile 만 살펴보자.

컨테이너 이미지 만들기 - 기본편

docker/Dockerfile2 을 보면 아래와 같다.

FROM openjdk:17.0.2-jdk-slim
MAINTAINER Ahn Seungkyu

ARG JAR_FILE=example-0.0.1-SNAPSHOT.jar

RUN mkdir -p /app
WORKDIR /app

COPY target/${JAR_FILE} /app/app.jar
COPY scripts/run.sh .
ENTRYPOINT ["./run.sh"]

base 이미지로 openjdk:17.0.2-jdk-slim 이미지를 사용하였고 target 아래의 빌드된 jar 파일을 복사하여 실행하는 방법이다.

실행 명령어는 scripts/run.sh 파일을 보면 알 수 있다.

#!/bin/sh

java ${JAVA_OPTS} -jar app.jar ${@}

JAVA_OPTS 는 환경 변수 이므로 컨테이너를 실행할 때 해당 값을 전달하는 것이 가능하다.

이제 컨테이너 이미지를 빌드하면 그 사이즈는 다음과 같다.

$ docker build -t seungkyua/springboot-docker -f docker/Dockerfile2 .

------- output -----------
[+] Building 2.6s (11/11) FINISHED                                                                                                                                                                                                               docker:desktop-linux
 => [internal] load build definition from Dockerfile2                                                                                                                                                                                                            0.0s
 => => transferring dockerfile: 442B                                                                                                                                                                                                                             0.0s
 => [internal] load .dockerignore                                                                                                                                                                                                                                0.0s
 => => transferring context: 2B                                                                                                                                                                                                                                  0.0s
 => [internal] load metadata for docker.io/library/openjdk:17.0.2-jdk-slim                                                                                                                                                                                       2.2s
 => [auth] library/openjdk:pull token for registry-1.docker.io                                                                                                                                                                                                   0.0s
 => CACHED [1/5] FROM docker.io/library/openjdk:17.0.2-jdk-slim@sha256:aaa3b3cb27e3e520b8f116863d0580c438ed55ecfa0bc126b41f68c3f62f9774                                                                                                                          0.0s
 => [internal] load build context                                                                                                                                                                                                                                0.0s
 => => transferring context: 291B                                                                                                                                                                                                                                0.0s
 => [2/5] RUN mkdir -p /app                                                                                                                                                                                                                                      0.1s
 => [3/5] WORKDIR /app                                                                                                                                                                                                                                           0.0s
 => [4/5] COPY target/example-0.0.1-SNAPSHOT.jar /app/app.jar                                                                                                                                                                                                    0.1s
 => [5/5] COPY scripts/run.sh .                                                                                                                                                                                                                                  0.0s
 => exporting to image                                                                                                                                                                                                                                           0.1s
 => => exporting layers                                                                                                                                                                                                                                          0.1s
 => => writing image sha256:6d6ba6764805971eef0532e21ec28feb6308ddb04bb650a7d087ab689d0d65be                                                                                                                                                                     0.0s
 => => naming to docker.io/seungkyua/springboot-docker
$ docker image ls 
REPOSITORY                    TAG       IMAGE ID       CREATED          SIZE
seungkyua/springboot-docker   latest    6d6ba6764805   54 seconds ago   473MB

이미지 크기가 473M 로 만들어졌다. 어플리케이션 jar 파일의 크기가 68M 이므로 JVM 을 포함하는 이미지가 405M 의 크기가 된다는 의미이다.

보통 이미지를 작게하기 위해서 base 이미지 태그로 alpine 이나 slim 을 많이 사용한다. 여기서 slim 을 사용했는데도 이 정도 크기라면 사이즈가 작다고 할 수 없다.

더 작은 사이즈를 만들기 위해서 alpine 이미지를 찾아서 적용해 보자.

base 이미지를 amazoncorretto 로 변경하고 다시 사이즈를 비교해 보자.

FROM amazoncorretto:17-alpine
MAINTAINER Ahn Seungkyu

ARG JAR_FILE=example-0.0.1-SNAPSHOT.jar

RUN mkdir -p /app
WORKDIR /app

COPY target/${JAR_FILE} /app/app.jar
COPY scripts/run.sh .
ENTRYPOINT ["./run.sh"]
$ docker build -t seungkyua/springboot-docker -f docker/Dockerfile2 .

$ docker image ls
REPOSITORY                    TAG       IMAGE ID       CREATED          SIZE
seungkyua/springboot-docker   latest    ed08524545ba   31 seconds ago   358MB
<none>                        <none>    6d6ba6764805   7 minutes ago    473MB

조회되는 이미지를 보면 base 이미지만을 바꿨을 뿐인데 358M 로 줄어들었다.

컨테이너 이미지 만들기 - 멀티 스테이지

컨테이너 이미지 사이즈를 줄이기 위해서 멀티 스테이지를 써야 한다는 말을 들어봤을 것이다. 여기서도 사이즈를 줄이기 위해 멀티 스테이지를 사용해 보자.

docker/Dockerfile3 를 보면 멀티 스테이지를 어떻게 구성하는지 알 수 있다.

# syntax=docker/dockerfile:1
FROM maven:3.8.5-openjdk-17 as build
MAINTAINER Ahn Seungkyu

WORKDIR /app
COPY . /app
RUN --mount=type=cache,target=/root/.m2 mvn -Dmaven.wagon.http.ssl.insecure=true -Dmaven.wagon.http.ssl.allowall=true package

FROM amazoncorretto:17-alpine
MAINTAINER Ahn Seungkyu

ARG JAR_FILE=example-0.0.1-SNAPSHOT.jar

WORKDIR /app
COPY --from=build /app/target/${JAR_FILE} /app/app.jar
COPY scripts/run.sh /app/
ENTRYPOINT ["./run.sh"]

빌드하는 이미지와 런타임에서 실행되는 이미지가 나눠져 있다.

# syntax=docker/dockerfile:1 는 이미지 안에서 소스 빌드를 할 때 디펜던시가 있는 파일을 매번 가져오지 말고 한 번만 가져와서 캐싱하여 효율적으로 사용하고자 할 때 사용한다.

--mount=type=cache,target=/root/.m2 는 로컬에 저장해서 재활용하자는 의미이고 -Dmaven.wagon.http.ssl.insecure=true -Dmaven.wagon.http.ssl.allowall=true 메이븐으로 패키지할 때 jvm 이 ssl 인증서 없이 사용하기 위해서 추가되었다.

첫번째는 어플리케이션을 빌드하기 위해 필요한 이미지 정의이고 두번째는 빌드된 어플리케이션을 복사하는 이미지 정의로 두번째는 앞서 기본편에서 설명한 것과 동일하다.

jar 파일만 빌드해서 복사하는 구조라 실제로 이미지 차이는 없을 것이다. 다만, 빌드를 이미지를 만들 때 로컬 환경 구성 없이도 만들 수 있다는 장점이 있다.

$ docker build -t seungkyua/springboot-docker -f docker/Dockerfile3 .

$ docker image ls
REPOSITORY                    TAG       IMAGE ID       CREATED          SIZE
seungkyua/springboot-docker   latest    33ed3cbf0300   31 seconds ago   358MB
<none>                        <none>    ed08524545ba   4 minutes ago    358MB
<none>                        <none>    6d6ba6764805   7 minutes ago    473MB

컨테이너 이미지 만들기 - alpine 에 추가

여전히 사이즈가 358M 로 작지 않은 사이즈이다.

이제 기본 alpine 이미지에 JRE 와 어플리케이션을 설치하는 방법으로 이미지 사이즈를 줄여보자.

docker/Dockerfile 을 살펴보자.

FROM amazoncorretto:17-alpine3.18 as builder-jre

RUN apk add --no-cache --repository=http://dl-cdn.alpinelinux.org/alpine/edge/main/ binutils=2.41-r0

RUN $JAVA_HOME/bin/jlink \
         --module-path "$JAVA_HOME/jmods" \
         --verbose \
         --add-modules ALL-MODULE-PATH \
         --strip-debug \
         --no-man-pages \
         --no-header-files \
         --compress=2 \
         --output /jre

#=========================================================================

# syntax=docker/dockerfile:1
FROM maven:3.8.5-openjdk-17 as build
MAINTAINER Ahn Seungkyu

WORKDIR /app
COPY . /app
RUN --mount=type=cache,target=/root/.m2 mvn -Dmaven.wagon.http.ssl.insecure=true -Dmaven.wagon.http.ssl.allowall=true package

#=========================================================================

FROM alpine:3.18.4
MAINTAINER Ahn Seungkyu

ENV JAVA_HOME=/jre
ENV PATH="$JAVA_HOME/bin:$PATH"
ARG JAR_FILE=example-0.0.1-SNAPSHOT.jar

COPY --from=builder-jre /jre $JAVA_HOME

ARG APPLICATION_USER=appuser
RUN adduser --no-create-home -u 1000 -D $APPLICATION_USER

RUN mkdir /app && chown -R $APPLICATION_USER /app

USER 1000

COPY --chown=1000:1000 --from=build /app/target/${JAR_FILE} /app/app.jar
COPY scripts/run.sh /app/

WORKDIR /app
EXPOSE 8080

ENTRYPOINT ["./run.sh"]

이미지가 3개로 구성되어 있다.

첫번째는 작은 사이즈의 jre 를 만드는 이미지, 두번째는 어플리케이션을 빌드하는 이미지, 마지막으로 세번째는 작은 alpine 베이스 이미지에 jre 와 빌드된 어플리케이션 jar 파일을 복사하는 이미지이다.

첫번째 이미지 만드는 부분에서 binutils 을 설치하는데 다운 받을 리파지토리를 지정하여 에러가 없게 한다. binutils 는 jre 을 만들 때 strip-debug 옵션을 사용하기 위해서 설치한다.

세번째 이미지 만드는 부분에서 사용자를 추가하는 로직이 있는데 이는 보안상 이미지내 실행 프로세스를 root 가 아닌 지정된 사용자로 하기 위해서 일반적으로 추가한다.

이미지 사이즈는 다음과 같다.

$ docker build -t seungkyua/springboot-docker -f docker/Dockerfile .

$ docker image ls                                                   
REPOSITORY                    TAG       IMAGE ID       CREATED          SIZE
seungkyua/springboot-docker   latest    f5dc2f994864   28 seconds ago   168MB
<none>                        <none>    ed08524545ba   24 minutes ago   358MB
<none>                        <none>    6d6ba6764805   31 minutes ago   473MB

이미지가 최종적으로 168M 로 줄어들었다.

이제 실제 이미지가 정상적으로 실행되는지 확인해 보자.

mysql 을 띄우고 어플리케이션을 띄운 후에 curl 로 데이터를 입력해 본다.

1. mysql 실행

$ mkdir -p ~/.docker-data/mysql

$ docker run --cap-add=sys_nice -d --restart=always -p 3306:3306 -e MYSQL_ROOT_PASSWORD=password \
-v ~/.docker-data/mysql:/var/lib/mysql \
--name mysql-ask mysql:8.0.34 \
--character-set-server=utf8mb4 \
--collation-server=utf8mb4_unicode_ci

2. database 생성

$ docker exec -it mysql-ask bash
# mysql -uroot -ppassword
mysql> create database if not exists order_service;

3. 어플리케이션 실행

$ docker run -d --name springboot --rm -p 19090:19090 --link mysql-ask:localhost seungkyua/springboot-docker

4. 데이터 조회

$ curl -X POST http://127.0.0.1:19090/orders \
   -H "Content-Type: application/json" \
   -d '{"customerId": 1, "orderTotal": 12.23}'

--- output ---
{"orderId":1}

정상적으로 어플리케이션이 실행되어 동작하는 것을 확인할 수 있다.

반응형
Posted by seungkyua@gmail.com
,
반응형
반응형
Posted by seungkyua@gmail.com
,
반응형

Kubernetes 에서 ServiceAccount 를 생성하면 1.22 버전까지는 자동으로 token 을 생성하였다. 그러나 1.23 부터는 토큰을 자동으로 생성해 주지 않기 때문에 수동으로 생성해야 한다.

이 바뀐 기능은 ServiceAcount 와 RBAC 을 연동하여 권한을 제어하고자 할 때 문제가 되므로 수동으로 만드는 방법을 살펴본다.

1. 네임스페이스 - SA 생성

먼저 테스트할 네임스페이스를 만든다.

$ kubectl create ns ask

ask 네임스페이스에 서비스 어카운트를 생성한다.

$ kubectl create sa ask-sa -n ask

1.24 버전부터는 sa 를 생성해도 token 이 자동으로 생성되지 않는다.

참고: https://github.com/kubernetes/kubernetes/blob/master/CHANGELOG/CHANGELOG-1.24.md#no-really-you-must-read-this-before-you-upgrade

token 은 Secret 타입이므로 Secret 을 조회해 보면 token이 자동 생성되지 않았음을 알 수 있다.

참고: https://kubernetes.io/docs/concepts/configuration/secret/#service-account-token-secrets

2. Token 생성

ask-sa 에 해당하는 token 을 수동으로 생성한다.

$ cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: Secret
metadata:
  name: ask-sa
  namespace: ask
  annotations:
    kubernetes.io/service-account.name: ask-sa
type: kubernetes.io/service-account-token
EOF

token 을 생성할 때 어노테이션으로 연결될 서비스 어카운트를 지정한다.

$ kubectl get secret -n ask
NAME     TYPE                                  DATA   AGE
ask-sa   kubernetes.io/service-account-token   3      7s 

조회를 하면 service-account-toke 타입으로 secret 이 생성되었음을 알 수 있다.

혹은 token 을 수동으로 생성하는 방법도 있다.

$ kubectl create token ask-sa --bound-object-kind Secret --bound-object-name ask-sa --duration=999999h  -n ask

------- output -----------
xxxxxxxxxxxxxxxxxxxxxxx

base64 로 변환하여 secret 에 data.token 값으로 저장한다.

$ kubectl create token ask-sa --bound-object-kind Secret --bound-object-name ask-sa --duration=999999h  -n ask | base64 -w 0

------- output -----------
xxxxxxxxxxxxxxxxxxxxxxx
$ kubectl edit secret ask-sa -n ask
...
data:
  token: xxxxxxxxxxxxxxxxxxxx
...

3. Role 과 RoleBinding 생성

Role 과 RoleBinding 은 네임스페이스 별로 연결된다. 그러므로 생성한 권한은 해당 네임스페이스에만 권한이 주어진다.

먼저 Role 을 생성한다.

 $ cat <<EOF | kubectl apply -f -
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: ask-role
  namespace: ask
rules:
- apiGroups: ["", "*"]
  resources: ["*"]
  verbs: ["*"]
EOF

apiGroup 에서 ""core API group 으로 다음의 출력으로 확인할 수 있다.

APIVERSION 이 v1 인 리소스들이 core API group 이 되면 이들에 대해서 권한을 사용하겠다는 뜻이다.

$ kubectl api-resources -o wide
NAME                              SHORTNAMES       APIVERSION      NAMESPACED   KIND                             VERBS
bindings                                           v1              true         Binding                          [create]
componentstatuses                 cs               v1              false        ComponentStatus                  [get list]
configmaps                        cm               v1              true         ConfigMap                        [create delete deletecollection get list patch update watch]
endpoints                         ep               v1              true         Endpoints                        [create delete deletecollection get list patch update watch]
events                            ev               v1              true         Event                            [create delete deletecollection get list patch update watch]
limitranges                       limits           v1              true         LimitRange                       [create delete deletecollection get list patch update watch]
namespaces                        ns               v1              false        Namespace                        [create delete get list patch update watch]
nodes                             no               v1              false        Node                             [create delete deletecollection get list patch update watch]
persistentvolumeclaims            pvc              v1              true         PersistentVolumeClaim            [create delete deletecollection get list patch update watch]
persistentvolumes                 pv               v1              false        PersistentVolume                 [create delete deletecollection get list patch update watch]
pods                              po               v1              true         Pod                              [create delete deletecollection get list patch update watch]
podtemplates                                       v1              true         PodTemplate                      [create delete deletecollection get list patch update watch]
replicationcontrollers            rc               v1              true         ReplicationController            [create delete deletecollection get list patch update watch]
resourcequotas                    quota            v1              true         ResourceQuota                    [create delete deletecollection get list patch update watch]
secrets                                            v1              true         Secret                           [create delete deletecollection get list patch update watch]
serviceaccounts                   sa               v1              true         ServiceAccount                   [create delete deletecollection get list patch update watch]
services                          svc              v1              true         Service                          [create delete deletecollection get list patch update watch]

다음은 Rolebinding을 생성한다.

$ cat <<EOF | kubectl apply -f - 
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: ask-role-binding
  namespace: ask
subjects:
- kind: ServiceAccount
  name: ask-sa
  namespace: ask
roleRef:
  kind: Role
  name: ask-role
  apiGroup: rbac.authorization.k8s.io
EOF

ServiceAcount 인 ask-saask-role Role 을 서로 연결 시킨 다는 의미이다.

이렇게 되면 이제 ask-sa sa 는 ask-role role 에 대한 권한만을 사용할 수 있다.

4. kubeconfig 생성

sa 를 만들었으니 이를 연동할 kubeconfig 를 만들어 본다.

token 을 조회해 보자.

$ kubectl get secret -n ask ask-sa -ojsonpath={.data.token} | base64 -d

----- output -----
xxxxxxxxxxxxxxxxxxxxxxxxx

token 값으로 kubeconfig 의 user 접속 token 에 넣는다.


apiVersion: v1
clusters:
- cluster:
    certificate-authority-data: xxxxxxxxxxxxxxxxxxxxxxxx
server: https://xxxxxxxxxx.ap-northeast-2.eks.amazonaws.com
  name: mycluster
contexts:
- context:
    cluster: mycluster
    user: ask-sa
    namespace: ask
  name: mycluster
current-context: mycluster
kind: Config
users:
- name: ask-sa
  user:
    token: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

위의 kubeconfig 로 접속하면 ask 네임스페이스에 대해서 kubectl 명령어를 실행할 수 있다.

$ kubectl --kubeconfig ask.kubeconfig get pods
Error from server (Forbidden): pods is forbidden: User "system:serviceaccount:ask:ask-sa" cannot list resource "pods" in API group "" in the namespace "default"

default 네임스페이스에는 권한이 없으므로 권한 없음 에러가 리턴된다.

$ kubectl --kubeconfig ask.kubeconfig get pods -n ask
No resources found in ask namespace.

ask 네임스페이스의 파드는 정상적으로 조회된다.

반응형
Posted by seungkyua@gmail.com
,
반응형

컨테이너 이미지 저장소를 독립적으로 구성하는 방법을 살펴본다.

일반적으로 컨테이너 이미지는 CNCF 의 프로젝트 중에 하나인 Harbor 를 사용하여 구성한다. Helm chart 가 잘 되어 있어 쿠버네티스 위에서 설치하는 것은 매우 쉬운데 운영 환경에 걸맞는 HA 구성은 여러가지 고려해야 할 사항들이 있다.

그래서 이번에는 Harbor 를 HA 로 구성하는 방법을 알아본다.

Harbor HA Architecture

Harbor 를 HA 로 구성하려면 아래의 전제 조건이 필요하다.

  1. Kubernetes cluster 1.10 이상
  2. Helm 2.8.0 이상
  3. High Available 하게 설치된 Ingress Controller
  4. High Available 하게 설치된 PostgreSQL 데이터베이스
  5. High Available 하게 설치된 Redis
  6. Shared storage 로 된 PVC

이 중에서 1, 2, 3 번은 구성되어 있다고 가정하고 이후를 살펴본다.

또한 Public Cloud 는 AWS 를 사용했고 쿠버네티스 클러스터는 EKS 를 사용했다.

아키텍처는 아래 그림과 같다.

[출처: https://goharbor.io/docs/1.10/img/ha.png]

PostgreSQL 데이터 베이스 구성

AWS 의 RDS 를 사용했으며 Harbor 에서 사용할 필요한 user 생성과 권한을 부여한다.

# psql -U postgres
postgres=# CREATE DATABASE registry;
postgres=# CREATE USER harbor WITH ENCRYPTED PASSWORD 'xxxxxxx';
postgres=# GRANT ALL PRIVILEGES ON DATABASE registry TO harbor;

어드민 권한으로 데이터베이스에 접속하여 Harbor 에서 사용할 registry database 를 생성한다. user 는 harbor 이고 필요한 password 를 생성한 다음 registry 데이터베이스의 모든 권한을 harbor 유저에 부여한다.

테이블과 스퀀스를 다루기 위해서는 아래 추가적으로 권한을 부여해야 한다. (아래 권한이 추가되지 않으면 harbor 유저로 테이블과 시퀀스에 대한 생성/조회/삭제/수정을 하지 못한다.

postgres=# \c registry
registry=# GRANT ALL ON ALL TABLES IN SCHEMA public TO harbor;
registry=# GRANT ALL ON ALL SEQUENCES IN SCHEMA public TO harbor;

Redis 구성

Harbor 는 캐시로 레디스를 사용하며 이 때 레디스 구성은 독립 혹은 레디스 + 센티널(sentinel) 구성만을 지원한다. 한마디로 클러스터 모드의 레디스는 지원하지 않는다.

AWS Elasticache Redis 서비스는 센티널을 지원하지 않아 굳이 Elasticache 서비스를 사용할 이유가 없다.

Elasticache 서비스의 레디스 구성으로 1개의 컨트롤노드 - 멀티 워커노드 로 하여 데이터 복제는 가능하나 1개의 컨트롤 노드가 무너지면 역시 장애가 발생하므로 서비스를 사용하여 구성하지 않았다.

이 후 살펴볼 Harbor Helm chart 에서 쿠버네티스 위에 레디스를 1개로 띄우는 internal 생성 방식을 사용한다.

레디스 구성을 HA 로 하고 싶다면, 레디스를 멀티 노드 센티널 구성으로 쿠버네티스 위에 띄우는 방법도 있으니 이는 레디스 설치 문서를 참고하면 된다. (센티널 구성일 때 Harbor chart 의 value 값은 코멘트로 적혀있으니 쉽게 이해할 수 있다)

Shared Storage 구성

AWS 에서 지원하는 공유 스토리지는 EFS 가 있다. EFS 는 NFSv4 프로토콜을 지원하니 공유 스토리지로 사용 가능하다.

먼저 AWS EFS 서비스에서 파일스토리지를 생성한다.

생성된 EFS 는 실제로 파일시스템의 스토리지가 생성된 것은 아니다. 일종의 정보를 생성한 것이며 필요에 따라 실제 스토리지를 생성하고 할당 받는 방식이다.

쿠버네티스에서는 Provisioner, StroageClass PVC, PV 라는 스토리지 표준 관리 방법이 있다.

[출처: https://d2908q01vomqb2.cloudfront.net/e1822db470e60d090affd0956d743cb0e7cdf113/2022/10/28/4.Dynamic-Provisioning.png]

흔히 Provisioner 라고 말하는 CSI Driver 를 설치한다.

EKS 에서는 추가 기능으로 Amazon EFS CSI Driver 를 추가할 수 있다.

이 때 권한에서 중요한 한가지가 있는데 EKS node 에서 사용하는 role 에 (role 명은 eks 의 태그 정보를 확인해 보면 된다) AmazonEFSCSIDriverPolicy 정책이 반드시 추가되어 있어야 한다.

이제 스토리지 클래스를 설치하자.

$ curl -Lo efs-sc.yaml https://raw.githubusercontent.com/kubernetes-sigs/aws-efs-csi-driver/master/examples/kubernetes/dynamic_provisioning/specs/storageclass.yaml

$ vi efs-sc.yaml
--- 
kind: StorageClass
apiVersion: storage.k8s.io/v1
metadata:
  name: taco-efs-storage
provisioner: efs.csi.aws.com
parameters:
  provisioningMode: efs-ap
  fileSystemId: fs-xxxxxxx  # EFS 에서 생성한 fs id
  directoryPerms: "700"

$ kubectl apply -f efs-sc.yaml

변경해야 할 것은 fileSystemId 로 앞서 EFS 에서 생성한 파일스토리지의 fs id 값으로 변경해 준다.

스토리지클래스가 잘 작동하는지 확인하기 위해서 아래와 같이 테스트 파드를 생성해 본다.

$ kubectl create ns harbor-ask

$ vi efs-test-pod.yaml
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: efs-claim
  namespace: harbor-ask
spec:
  accessModes:
    - ReadWriteMany
  storageClassName: taco-efs-storage
  resources:
    requests:
      storage: 5Gi
---
apiVersion: v1
kind: Pod
metadata:
  name: efs-app
  namespace: harbor-ask
spec:
  containers:
    - name: app
      image: centos
      command: ["/bin/sh"]
      args: ["-c", "while true; do echo $(date -u) >> /data/out; sleep 5; done"]
      volumeMounts:
        - name: persistent-storage
          mountPath: /data
  volumes:
    - name: persistent-storage
      persistentVolumeClaim:
        claimName: efs-claim

$ kubectl apply -f efs-test-pod.yaml

Pod 가 생성되고 Pod 로 접속하면 /data/out 파일에 시간이 출력되고 있으면 정상적으로 작동하는 것이다.

PVC 를 생성할 때 accessModesReadWriteMany 인 것도 확인하자.

Harbor chart 로 HA 설치

이제 필요한 사전 구성은 마쳤으니 chart 로 설치를 진행한다.

먼저 chart 를 등록하고 다운 받는다.

$ helm repo add harbor https://helm.goharbor.io
$ helm repo update

$ helm fetch harbor/harbor --untar

차트를 다운 받을 필요는 없으니 관련 values.yaml 을 확인하기 위해서 참고용으로 다운 받았다.

필요한 value 를 설정한다.

$ vi ask-values.yaml
---
harborAdminPassword: "xxxxx"

expose:
  type: ingress
  tls:
    enabled: true
    certSource: secret
    secret:
      secretName: "taco-cat-tls"
  ingress:
    hosts:
      core: harbor.xxx
    className: "nginx"

externalURL: https://harbor.xxx

harborAdminPassword 는 Harbor 웹 화면에서 admin 계정으로 접속할 때 필요한 패스워드 이다.

expose 는 ingress 에 노출되는 값이며 도메인이 harbor.xxx 으로 DNS 에 연결되어 있으며 (DNS 가 없으면 로컬 컴퓨터에 /etc/hosts 파일에 등록해서 사용한다), 도메인 인증서는 앞에서 생성한 harbor-ask 네임스페이스에 taco-cat-tls 라는 secret 이름으로 저장되어 있다.

persistence:
  enabled: true
  resourcePolicy: "keep"
  persistentVolumeClaim:
    registry:
      storageClass: "taco-efs-storage"
      accessMode: ReadWriteMany
      size: 1024Gi
    jobservice:
      jobLog:
        storageClass: "taco-efs-storage"
        accessMode: ReadWriteMany
        size: 128Gi
    redis:
      storageClass: "taco-efs-storage"
      accessMode: ReadWriteMany
      size: 256Gi
    trivy:
      storageClass: "taco-efs-storage"
      accessMode: ReadWriteMany
      size: 128Gi
  imageChartStorage:
    disableredirect: false
    type: filesystem
    filesystem:
      rootdirectory: /storage

persistence 는 Harbor 컴포넌트에서 사용하는 스토리지 정보이다. Harbor 차트에서 설치하는 컴포넌트는 registry , jobservice , redis , trivy , core, portal 등이 있으며 스토리지가 필요한 컴포넌트만 기술하면 된다.

registry:
  replicas: 2
portal:
  replicas: 2
core:
  replicas: 2
jobservice:
  replicas: 2
trivy:
  enabled: true
  replicas: 2
notary:
  enabled: false
cache:
  enabled: true
  expireHours: 24

각 컴포넌트의 Pod 갯수를 넣는다. HA 구성 이므로 최소 2 이상을 넣는다.

notary 는 이미지 서명 관련 컴포넌트로 이번 구성에서는 설치하지 않았다.

database:
  type: external
  external:
    host: xxxxxx.ap-northeast-2.rds.amazonaws.com
    port: "5432"
    username: "harbor"
    password: "xxxxxxx"
    coreDatabase: "registry"

RDS 에 만들어진 외부 데이터베이스 정보를 넣는다.

redis:
  type: internal

레디스는 내부에서 단일 Pod 로 생성한다.

전체 values 는 다음과 같다.

$ vi ask-ha-values.yaml
---
harborAdminPassword: "xxxxxx"

expose:
  type: ingress
  tls:
    enabled: true
    certSource: secret
    secret:
      secretName: "taco-cat-tls"
  ingress:
    hosts:
      core: harbor.xxx
    className: "nginx"

externalURL: https://harbor.xxx

persistence:
  enabled: true
  resourcePolicy: "keep"
  persistentVolumeClaim:
    registry:
      storageClass: "taco-efs-storage"
      accessMode: ReadWriteMany
      size: 1024Gi
    jobservice:
      jobLog:
        storageClass: "taco-efs-storage"
        accessMode: ReadWriteMany
        size: 128Gi
    redis:
      storageClass: "taco-efs-storage"
      accessMode: ReadWriteMany
      size: 256Gi
    trivy:
      storageClass: "taco-efs-storage"
      accessMode: ReadWriteMany
      size: 128Gi
  imageChartStorage:
    disableredirect: false
    type: filesystem
    filesystem:
      rootdirectory: /storage

registry:
  replicas: 2
portal:
  replicas: 2
core:
  replicas: 2
jobservice:
  replicas: 2
trivy:
  enabled: true
  replicas: 2
notary:
  enabled: false
cache:
  enabled: true
  expireHours: 24

database:
  type: external
  external:
    host: xxxxxx.ap-northeast-2.rds.amazonaws.com
    port: "5432"
    username: "harbor"
    password: "xxxxxxx"
    coreDatabase: "registry"

redis:
  type: internal

쿠버네티스에 배포한다.

$ helm upgrade -i harbor-ask harbor/harbor --version 1.12.3 -n harbor-ask -f ask-ha-values.yaml

설치 확인

Harbor 웹에 접속하여 사용자(tks)와 프로젝트(tks)를 만든다.

해당 프로젝트의 Members 탭에는 사용자가 등록되어 있어야 한다. (그래야 컨테이너 이미지를 올릴 수 있는 권한이 있다)

$ docker login harbor.xxx -u tks
Password:

$ docker pull hello-world
$ docker tag hello-world harbor.xxx/tks/hello-world
$ docker push harbor.xxx/tks/hello-world
반응형
Posted by seungkyua@gmail.com
,
반응형

MSA 패턴(CQRS, SAGA 등)을 코드로 구현하기 위해서 SpringBoot 으로 REST API 어플리케이션을 만드는 것을 정리하고 있는데 자료 구조외에 자주쓰게 되는 코드들이 있어서 이를 정리해 봤다.

어플리케이션 개발에 Java 를 사용하는 이유는 아직 우리나라에서는 Java 가 많이 쓰이기 때문이다. SpringBoot 으로 어느 정도 정리되면 이후에는 Gin (Go 언어 기반 웹 프레임워크)으로 정리할 예정이다.

1. null check

Primitive Type 보다는 Object 를 많이 사용하기 때문에 null check 를 항상 해야 된다.

String nullString = null;
String emptyString = "";

if (Objects.isNull(nullString))
    System.out.println("nullString is null");

if (Objects.nonNull(emptyString))
    System.out.println("emptyString is not null");

--------- output -----------
nullString is null
emptyString is not null

Objects 클래스를 사용하여 가독성있게 null 체크를 할 수 있다.

2. null check & default value 세팅

null check 와 기본 값 세팅을 메소드 체이닝으로 바로 할 수 있다.

BigDecimal bd = null;

BigDecimal defaultValue = Optional.ofNullable(bd).orElse(BigDecimal.ZERO);
System.out.println(defaultValue);

--------- output -----------
0

Optional.ofNullable 은 static 메소드로 null 혹은 객체 를 담고있는 Optional 을 리턴한다.

Optional.orElse 는 메소드로 객체의 값이 null 일 경우 넘겨주는 아큐먼트로 그 값을 채운다.

여기서는 BigDecimal.ZERO 로 채워진 것을 알 수 있다.

3. null check & throw Exception

null check 와 null 일 때 예외를 던질 수 있다.

BigDecimal bd = null;

Optional.ofNullable(bd).orElseThrow(() -> new IllegalArgumentException("db is null"));
System.out.println(defaultValue);

--------- output -----------
Exception in thread "main" java.lang.IllegalArgumentException: db is null
    at com.ask.example.Main.lambda$setDefaultValue$0(Main.java:45)
    at java.base/java.util.Optional.orElseThrow(Optional.java:403)
    at com.ask.example.Main.setDefaultValue(Main.java:45)
    at com.ask.example.Main.main(Main.java:24)

orElseThrow 메소드는 function 을 파라미터로 받으며 여기서는 람다함수를 사용하여 간단히 IllegalArgumentException 을 생성하여 던진다.

4. Array 와 List 를 Stream 으로 만들기

Stream은 Java 8부터 추가된 기능으로, Collection(Array, List, Map 등)과 같은 데이터 요소들을 처리하는데 도움을 주는 기능이다. Stream을 이용하면 다양한 작업을 수행할 수 있다.

  1. 필터링(Filtering): filter() 메서드를 사용하여 특정 조건을 만족하는 요소들만 걸러낼 수 있다.
  2. 변환(Mapping): map() 메서드를 사용하여 요소들을 다른 형태로 변환할 수 있다.
  3. 소팅(Sorting): sorted() 메서드를 사용하여 요소들을 정렬할 수 있다.
  4. 그룹화(Grouping): collect() 메서드를 사용하여 요소들을 그룹화할 수 있다.
  5. 집계(Reduction): reduce() 메서드를 사용하여 요소들을 축소하여 하나의 값으로 만들 수 있다.
  6. 제한(Limiting): limit() 메서드를 사용하여 스트림의 크기를 제한할 수 있다.
  7. 건너뛰기(Skipping): skip() 메서드를 사용하여 스트림의 앞부분 요소들을 건너뛸 수 있다.
  8. 병렬 처리(Parallel Processing): parallelStream() 메서드를 사용하여 스트림 요소들을 병렬로 처리할 수 있다.
  9. 중복 제거(Distinct): distinct() 메서드를 사용하여 스트림의 중복 요소들을 제거할 수 있다.
  10. 조인(Joining): joining() 메서드를 사용하여 문자열 요소들을 합쳐서 하나의 문자열로 만들 수 있다.

먼저 Stream 을 만들어 보자.

배열을 Stream 으로 만들기

String[] fruits = { "apple", "banana", "orange", "grape", "banana" };
Stream stream = Arrays.stream(fruits);
System.out.println(stream);

--------- output -----------
java.util.stream.ReferencePipeline$Head@36baf30c

List 를 Stream 으로 만들기

List<String> fruits = new ArrayList<String>(
        Arrays.asList("apple", "banana", "orange", "grape", "banana"));
Stream stream = fruits.stream();
System.out.println(stream);

--------- output -----------
java.util.stream.ReferencePipeline$Head@36baf30c

5. Stream 을 다른 Collection 으로 바꾸기

Stream 은 Collectors 클래스를 사용하여 다른 Collection 으로 쉽게 변경이 가능하다.

Stream → Set 으로 변경 (중복 제거)

참고로 distinct 로도 중복 제거가 가능하다.

List<String> fruits = new ArrayList<String>(
        Arrays.asList("apple", "banana", "orange", "grape", "banana"));
Set<String> newSet = fruits.stream()
        .collect(Collectors.toSet());
newSet.forEach(item -> System.out.println(item));

--------- output -----------
banana
orange
apple
grape

중복된 banana 는 하나만 출력된다.

Stream → LinkedList 로 변경

List<String> fruits = new ArrayList<String>(
        Arrays.asList("apple", "banana", "orange", "grape", "banana"));
List<String> newList = fruits.stream()
        .collect(Collectors.toCollection(
                LinkedList::new
        ));
newList.forEach(item -> System.out.println(item));

--------- output -----------
apple
banana
orange
grape
banana

Stream → Map 으로 변경

List<String> fruits = new ArrayList<String>(
        Arrays.asList("apple", "banana", "orange", "grape", "banana"));
Map<String, Integer> newMap = fruits.stream()
        .collect(Collectors.toSet()).stream()
        .collect(Collectors.toMap(
                Function.identity(),
                String::length
        ));
newMap.forEach((key, value) -> System.out.println(key + " " + value));

--------- output -----------
banana 6
orange 6
apple 5
grape 5

Map 은 중복 키를 가질 수 없기 때문에 Set 으로 변환한 다음에 Map 으로 변환하였다.

여기서 Function.identity() 는 item 그 자체의 값으로 apple 과 같은 리스트의 요소를 가리키며, Map의 키로 사용되었다. String::Length 는 스트링 객체의 길이를 구하는 메소드를 호출한 리턴 값으로 Map 의 값으로 사용되었다.

6. Stream 과 데이터 변경 메소드들

Stream.map()

map() 은 Item 각각에 대해서 다른 형태로 변경할 수 있다.

List<String> fruits = new ArrayList<String>(
        Arrays.asList("apple", "banana", "orange", "grape", "banana"));
List<String> newList = fruits.stream()
        .map(item -> item.toUpperCase())
        .collect(Collectors.toList());
newList.forEach(item -> System.out.println(item));

--------- output -----------
APPLE
BANANA
ORANGE
GRAPE
BANANA

map() 은 아규먼트로 function 을 받으며 결과로 Stream 을 반환한다. 아규먼트로 들어가는 함수는 각 요소를 변경하여 반환하는 함수이어야 한다.

Stream.flatMap()

flatMap() 은 중첩된 Collection 을 한꺼풀 벗긴다고 생각하면 된다.

List<List<String>> listInList = Arrays.asList(
        Arrays.asList("apple", "banana"),
        Arrays.asList("orange", "grape", "banana"));

List<String> newList = listInList.stream()
        .flatMap(Collection::stream)
        .distinct()
        .collect(Collectors.toList());
newList.forEach(item -> System.out.println(item));

--------- output -----------
apple
banana
orange
grape

flatMap()function 를 아규먼트로 받고 결과를 Stream 으로 변환한다. 단 아규먼트로 들어가는 함수는 각 요소에 영향을 주면서 Stream 을 반환하는 함수이어야 한다.

6. enum 활용

enum 은 enum 타입이 반환된다.

enum Gender {
    MALE("male"),
    FEMALE("female");

    private final String param;

    Gender(String param) {
        this.param = param;
    }

    String getParam() {
        return this.param;
    }
}

System.out.println(Gender.MALE.getParam());

--------- output -----------
male

Gender.MALE 값은 enum Gender 타입이다.

하지만 아래와 같이 문자열을 enum 값으로 변환할 수 있다. 이 방법은 Json 을 marshal , unmarshal 할 때 많이 사용된다.

enum Gender {
    MALE("male"),
    FEMALE("female");

    private final String param;

    private static final Map<String, Gender> paramMap =
            Arrays.stream(Gender.values())
                    .collect(Collectors.toMap(
                            Gender::getParam,
                            Function.identity()
                    ));

    Gender(String param) {
        this.param = param;
    }

    static Gender fromParam(String param) {
        return Optional.ofNullable(param)
                .map(paramMap::get)
                .orElseThrow(() -> new IllegalArgumentException("param is not valid"));
    }

    String getParam() {
        return this.param;
    }
}

System.out.println(Gender.fromParam("male"));

--------- output -----------
MALE

static 으로 Map<String, Gender> 변수를 선언하여 거기에 값을 채워넣고, static fromParam 메소드를 사용하여 String 값으로 Gender 값을 가져온다.

7. Map 의 종류

Map 에는 다음과 같은 종류가 있다.

  1. HashMap : key - value 로 저장되며 key 의 중복을 허용하지 않는다
  2. TreeMap : HashMap 의 기능에 key 가 정렬되어 있다.
  3. LinkedHashMap: HashMap 의 기능에 key 입력 순서대로 출력된다.
  4. MultiValueMap: HashMap 가 다른 점이 key 의 중복을 허용한다.

다만, MultiValueMap 을 사용하기 위해서는 spring-core 패키지를 import 해야 한다. (apache commons 의 commons-collections4 에도 MultiValueMap 은 있지만 spring-core 가 더 사용하기 편하다)

pom.xml 에 아래와 같은 dependcy 를 추가한다.

<dependencies>
    <dependency>
        <groupId>org.springframework</groupId>
        <artifactId>spring-core</artifactId>
        <version>6.0.9</version>
    </dependency>
</dependencies>
MultiValueMap<String, String> map = new LinkedMultiValueMap<>();
map.add("Accept-Encoding", "compress;q=0.5");
map.add("Accept-Encoding", "gzip;q=1.0");

map.forEach((key, value) -> System.out.println(key + " " + value));

--------- output -----------
Accept-Encoding [compress;q=0.5, gzip;q=1.0]

REST API 를 만들 때 Header 에 값을 넣기 위해서 위와 같이 MultiValueMap 을 사용한다.

반응형
Posted by seungkyua@gmail.com
,
반응형

Kubernetes 에는 이미 CronJob 이라는 리소스 타입이 있지만, Kubebuilder 을 이용하여 Custom Controller 로 재작성 해보는 연습을 해보도록 하자.

Project 구조 만들기

먼저, Project 구조를 만들기 위해 아래와 같이 kubebuilder init 명령어를 실행한다.

$ mkdir -p cronjob-kubebuilder
$ cd cronjob-kubebuilder

$ kubebuilder init --domain tutorial.kubebuilder.io --repo tutorial.kubebuilder.io/project

도메인을 tutorial.kubebuilder.io 로 했으므로 모든 API Group 은 <group>.tutorial.kubebuilder.io 방식으로 정해지게 된다. 또한 특별히 프로젝트 이름은 지정하지 않았는데, --project-name=<dns1123-label-string> 과 같이 옵션을 추가하지 않으면 폴더의 이름이 기본적으로 프로젝트 이름이 된다. (여기서 프로젝트명은 DNS-1123 label 규칙을 따라야 한다)

한가지 주의해야 할 점은 cronjob-kubebuilder 디렉토리는 $GOPATH 경로 아래에 있어서는 안된다. 이는 Go modules 의 규칙 때문인데 좀 더 자세히 알고 싶으면 https://go.dev/blog/using-go-modules 블로그 포스트를 읽어보자.

만들어진 프로젝트의 구조는 다음과 같다.

$ tree -L 2
.
├── Dockerfile
├── Makefile
├── PROJECT
├── README.md
├── cmd
│   └── main.go
├── config
│   ├── default
│   ├── manager
│   ├── prometheus
│   └── rbac
├── go.mod
├── go.sum
└── hack
    └── boilerplate.go.txt

7 directories, 8 files

go.mod 파일은 모듈 디펜던시를 표시하고, Makefile 은 custom controller 를 빌드하고 디플로이 할 수 있다.

config 디렉토리 아래에는 Kustomize 로 작성되어 CustomResourceDefinition, RBAC, WebhookConfiguration 등의 yaml 파일들이 정의되어 있다.

특히, config/manager 디렉토리에는 Cluster 에 Custom Controller 를 파드 형태로 배포할 수 있는 Kustomize yaml 이 있고, config/rbac 디렉토리에는 서비스 어카운트로 Custom Controller 의 권한이 정의되어 있다.

Custom Controller 의 Entrypoint 는 cmd/main.go 파일이다.

처음 필요한 모듈을 임포트 한 것을 보면 아래 2개가 보인다.

  • core controller-runtime 라이브러리
  • 기본 controller-runtime 로깅인 Zap
package main

import (
    "flag"
    "fmt"
    "os"

    _ "k8s.io/client-go/plugin/pkg/client/auth"

    "k8s.io/apimachinery/pkg/runtime"
    utilruntime "k8s.io/apimachinery/pkg/util/runtime"
    clientgoscheme "k8s.io/client-go/kubernetes/scheme"
    _ "k8s.io/client-go/plugin/pkg/client/auth/gcp"
    ctrl "sigs.k8s.io/controller-runtime"
    "sigs.k8s.io/controller-runtime/pkg/cache"
    "sigs.k8s.io/controller-runtime/pkg/healthz"
    "sigs.k8s.io/controller-runtime/pkg/log/zap"
    // +kubebuilder:scaffold:imports
)

모든 컨트롤러에는 Scheme 이 필요하다. 스킴은 KindGo types 간의 매핑을 제공해 준다.

var (
    scheme   = runtime.NewScheme()
    setupLog = ctrl.Log.WithName("setup")
)

func init() {
    utilruntime.Must(clientgoscheme.AddToScheme(scheme))

    //+kubebuilder:scaffold:scheme
}

main function 에는 아래의 내용들이 들어가 있다.

  • 기본 플래그 셋업
  • manager 를 생성하여 모든 Custom Controller 의 실행을 추적하고, shared cache 세팅하고, scheme 을 아규먼트로 넘기주어 클라이언트를 API 서버에 설정한다.
  • manager 를 실행하면 manager 가 모든 컨트롤러와 웹혹을 실행한다.
func main() {
    var metricsAddr string
    var enableLeaderElection bool
    var probeAddr string
    flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "The address the metric endpoint binds to.")
    flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.")
    flag.BoolVar(&enableLeaderElection, "leader-elect", false,
        "Enable leader election for controller manager. "+
            "Enabling this will ensure there is only one active controller manager.")
    opts := zap.Options{
        Development: true,
    }
    opts.BindFlags(flag.CommandLine)
    flag.Parse()

    ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts)))

    mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
        Scheme:                 scheme,
        MetricsBindAddress:     metricsAddr,
        Port:                   9443,
        HealthProbeBindAddress: probeAddr,
        LeaderElection:         enableLeaderElection,
        LeaderElectionID:       "80807133.tutorial.kubebuilder.io",
    })
    if err != nil {
        setupLog.Error(err, "unable to start manager")
        os.Exit(1)
    }

manager 생성 시에 컨트롤러가 특정 네임스페이스의 리소스만을 감시할 수 있도록 할 수 있다.

    mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
        Scheme:                 scheme,
        Namespace:              namespace,
        MetricsBindAddress:     metricsAddr,
        Port:                   9443,
        HealthProbeBindAddress: probeAddr,
        LeaderElection:         enableLeaderElection,
        LeaderElectionID:       "80807133.tutorial.kubebuilder.io",
    })

이렇게 특정 네임스페이스를 지정한 경우에는 권한을 ClusterRoleClusterRoleBinding 에서 RoleRoleBinding 으로 변경하는 것을 권장한다.

그리고 MutiNamespacedCacheBuilder 를 사용하면 특정 네임스페이스의 묶음의 리소스만을 감시하게 제한할 수 있다.

    var namespaces []string // List of Namespaces
    cache.Options.Namespaces = namespaces

    mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
        Scheme:                 scheme,
        NewCache:               cache.MultiNamespacedCacheBuilder(namespaces),
        MetricsBindAddress:     fmt.Sprintf("%s:%d", metricsHost, metricsPort),
        Port:                   9443,
        HealthProbeBindAddress: probeAddr,
        LeaderElection:         enableLeaderElection,
        LeaderElectionID:       "80807133.tutorial.kubebuilder.io",
    }) 

MultiNamespacedCacheBuilder 는 deprecated api 이므로 cache.Options.Namespaces 를 사용한다. (https://pkg.go.dev/sigs.k8s.io/controller-runtime/pkg/cache#Options)

Groups, Versions, Kinds and Resources

쿠버네티스에서 API 에 대해서 이야기할 때는 groups, versions, kinds and resources 4개의 용어를 사용한다.

쿠버네티스의 API Group 은 단순히 관련 기능의 모음이다. 각 Group 에는 하나 이상의 Version 이 있으며, 이름에서 알 수 있듯이 시간이 지남에 따라 API의 작동 방식을 변경할 수 있다.

각 API group-version 에는 하나 이상의 API type 이 포함되며, 이를 Kind 라고 부른다. Kind 는 Version 간에 양식을 변경할 수 있지만, 각 양식은 어떻게든 다른 양식의 모든 데이터를 저장할 수 있어야 한다(데이터를 필드 또는 주석에 저장할 수 있음). 즉, 이전 API 버전을 사용해도 최신 데이터가 손실되거나 손상되지 않는다.

Resource 란 간단히 말해서 API 안에서 Kind 를 사용하는 것이다. 종종, Kind 와 Resource 는 일대일로 매핑된다. 예를 들어, Pod Resource 는 Pod Kind 에 해당한다. 그러나 때로는 여러 Resource 에서 동일한 Kind를 반환할 수도 있다. 예를 들어, Scale Kind 는 deployments/scale 또는 replicasets/scale 과 같은 모든 scale 하위 리소스에 의해 반환된다. 이것이 바로 Kubernetes HorizontalPodAutoscaler 가 서로 다른 resource 와 상호 작용할 수 있는 이유다. 그러나 CRD를 사용하면 각 Kind 는 단일 resource 에 해당한다.

resource 는 항상 소문자이며, 관례에 따라 소문자 형태의 Kind를 사용한다.

특정 group-version 에서 어떤 kind 를 지칭할 때는 줄여서 GroupVersionKind 혹은 줄여서 GVK 라고 부른다. 같은 방식으로 resource 도 GroupVersionResource 혹은 GVR 이라고 부른다.

GVK 는 패키지에서 Go type 에 해당한다.

API 는 왜 만들어야 할까?

Kind 에 대해서 Custom Resource (CR) 과 Custom Resource Definition (CRD) 을 만들어야 한다. 그 이유는 CustomResourceDefinitions 으로 Kubernetes API 를 확장할 수 있기 때문이다.

새롭게 만드는 API 는 쿠버네티스에게 custom object 를 가리치는 방법이다.

기본으로 CRD 는 customized Objects 의 정의이며, CR 은 그것에 대한 인스턴스이다.

API 추가

아래 명령으로 새로운 Kind 를 추가하자.

$ kubebuilder create api --group batch --version v1 --kind CronJob

Create Resource 와 Create Controller 를 하겠냐고 물으면 y 로 대답한다.

$ tree -L 2
.
├── Dockerfile
├── Makefile
├── PROJECT
├── README.md
├── api
│   └── v1
├── bin
│   └── controller-gen
├── cmd
│   └── main.go
├── config
│   ├── crd
│   ├── default
│   ├── manager
│   ├── prometheus
│   ├── rbac
│   └── samples
├── go.mod
├── go.sum
├── hack
│   └── boilerplate.go.txt
└── internal
    └── controller 

이 경우 batch.tutorial.kubebuilder.io/v1 에 해당하는 api/v1 디렉토리가 생성된다.

api/v1/cronjob_types.go 파일을 보면, 모든 쿠버네티스 Kind 에 공통으로 포함된 metadata 를 가리키는 meta/v1 API group 을 임포트 하고 있다.

package v1

import (
    metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)

다음으로 Kind 의 SpecStatus 에 대한 type 을 정의 한다.

쿠버네티스는 원하는 상태(Spec)를 실제 클러스터 상태(Status) 및 외부 상태와 조정한 다음 관찰한 것(Status)를 기록하는 방식으로 작동한다. 따라서 모든 기능 object 는 spec 과 status 를 포함한다. ConfigMap 과 같은 몇몇 타입은 원하는 상태를 인코딩하지 않기 때문에 이 패턴을 따르지 않지만 대부분의 타입은 이 패턴을 따른다.

// EDIT THIS FILE!  THIS IS SCAFFOLDING FOR YOU TO OWN!
// NOTE: json tags are required.  Any new fields you add must have json tags for the fields to be serialized.

// CronJobSpec defines the desired state of CronJob
type CronJobSpec struct {
    // INSERT ADDITIONAL SPEC FIELDS - desired state of cluster
    // Important: Run "make" to regenerate code after modifying this file
}

// CronJobStatus defines the observed state of CronJob
type CronJobStatus struct {
    // INSERT ADDITIONAL STATUS FIELD - define observed state of cluster
    // Important: Run "make" to regenerate code after modifying this file
}

실제 Kind 에 해당하는 타입인 CronJob 과 CronJobList 를 정의한다. CronJob 은 루트 타입이며, CronJob kind를 설명한다. 모든 쿠버네티스 오브젝트와 마찬가지로, API version 과 Kind 를 설명하는 TypeMeta를 포함하며, name, namespace, labes 과 같은 것을 보유하는 ObjectMeta 도 포함한다.

CronJobList 는 단순히 여러 CronJob 을 위한 컨테이너이다. LIST와 같은 대량 작업에 사용되는 Kind 이다.

//+kubebuilder:object:root=true
//+kubebuilder:subresource:status

// CronJob is the Schema for the cronjobs API
type CronJob struct {
    metav1.TypeMeta   `json:",inline"`
    metav1.ObjectMeta `json:"metadata,omitempty"`

    Spec   CronJobSpec   `json:"spec,omitempty"`
    Status CronJobStatus `json:"status,omitempty"`
}

//+kubebuilder:object:root=true

// CronJobList contains a list of CronJob
type CronJobList struct {
    metav1.TypeMeta `json:",inline"`
    metav1.ListMeta `json:"metadata,omitempty"`
    Items           []CronJob `json:"items"`
}

마지막으로 API group 에 Go 타입을 추가한다. 이렇게 하면 이 API group 의 타입을 모든 Scheme 에 추가할 수 있다.

func init() {
    SchemeBuilder.Register(&CronJob{}, &CronJobList{})
}

API 설계

쿠버네티스에는 API를 설계하는 방법에 대한 몇 가지 규칙이 있다. 즉, 직렬화된 모든 필드는 camelCase 여야 하며 JSON 구조체 태그를 사용하여 이를 지정한다. 또한, 필드가 비어 있을 때 직렬화에서 필드를 생략해야 한다는 것을 표시하기 위해 omitempty 구조체 태그를 사용할 수도 있다.

필드는 대부분의 기본 유형을 사용할 수 있다. 다만 숫자는 예외이다. API 호환성을 위해 정수의 경우 int32int64, 소수의 경우 resource.Quantity 와 같이 3가지 형식의 숫자를 허용한다.

Quantity 는 10진수에 대한 특수 표기법으로, 머신 간에 이식성을 높이기 위해 명시적으로 고정된 표현을 가지고 있다.

예를 들어 2m 값은 십진수 표기법에서 0.002 를 의미한다. 2Ki 는 십진수로 2048 을 의미하고, 2K 는 십진수로 2000 을 의미한다. 분수를 지정하려면 정수를 사용할 수 있는 접미사로 전환하면 된다(예: 2.52500m).

지원되는 베이스는 두 가지이다: 10과 2(각각 10진수 및 2진수라고 함)이다. 10진수는 "nomal" SI 접미사(예: MK)로 표시되며, 2진수는 "mebi" 표기법(예: MiKi)으로 지정된다. 메가바이트와 메비바이트를 생각하면 된다.

우리가 사용하는 또 다른 특수 유형이 하나 더 있는데, 바로 metav1.Time 이다. 이것은 고정된 이식 가능한 직렬화 형식을 가지고 있다는 점을 제외하면 time.Time 과 동일하게 작동한다.

package v1

import (
    batchv1 "k8s.io/api/batch/v1"
    corev1 "k8s.io/api/core/v1"
    metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)

// EDIT THIS FILE!  THIS IS SCAFFOLDING FOR YOU TO OWN!
// NOTE: json tags are required.  Any new fields you add must have json tags for the fields to b

CronJob 을 세부적으로 살펴보자.

먼저 spec 을 보면, spec 에는 원하는 상태가 저장되므로 controller 에 대한 모든 "입력" 은 여기에 저장된다.

기본적으로 크론잡에는 다음과 같은 요소가 필요하다:

  • 스케줄 (CronJob 내의 cron)
  • 실행할 Job 에 대한 template (CronJob 내의 job)

편하게 만들어줄 몇 가지 추가 기능도 필요하다:

  • job 시작에 대한 deadline (이 deadline 을 놓치면 다음 예정된 시간까지 기다리게 된다)
  • 여러 job 이 한 번에 실행될 경우 어떻게 할 것인가(기다릴 것인가? 기존 job 을 중지할 것인가? 둘 다 실행할 것인가?)
  • CronJob 에 문제가 있을 경우, CronJob 실행을 일시 중지하는 방법
  • 이전 job 기록에 대한 limit

자신의 상태를 읽지 않기 때문에 job 이 실행되었는지 여부를 추적할 수 있는 다른 방법이 필요하다. 이를 위해 적어도 하나의 이전 job 을 사용할 수 있다.

// CronJobSpec defines the desired state of CronJob
type CronJobSpec struct {
    //+kubebuilder:validation:MinLength=0

    // The schedule in Cron format, see https://en.wikipedia.org/wiki/Cron.
    Schedule string `json:"schedule"`

    //+kubebuilder:validation:Minimum=0

    // Optional deadline in seconds for starting the job if it misses scheduled
    // time for any reason.  Missed jobs executions will be counted as failed ones.
    // +optional
    StartingDeadlineSeconds *int64 `json:"startingDeadlineSeconds,omitempty"`

    // Specifies how to treat concurrent executions of a Job.
    // Valid values are:
    // - "Allow" (default): allows CronJobs to run concurrently;
    // - "Forbid": forbids concurrent runs, skipping next run if previous run hasn't finished yet;
    // - "Replace": cancels currently running job and replaces it with a new one
    // +optional
    ConcurrencyPolicy ConcurrencyPolicy `json:"concurrencyPolicy,omitempty"`

    // This flag tells the controller to suspend subsequent executions, it does
    // not apply to already started executions.  Defaults to false.
    // +optional
    Suspend *bool `json:"suspend,omitempty"`

    // Specifies the job that will be created when executing a CronJob.
    JobTemplate batchv1.JobTemplateSpec `json:"jobTemplate"`

    //+kubebuilder:validation:Minimum=0

    // The number of successful finished jobs to retain.
    // This is a pointer to distinguish between explicit zero and not specified.
    // +optional
    SuccessfulJobsHistoryLimit *int32 `json:"successfulJobsHistoryLimit,omitempty"`

    //+kubebuilder:validation:Minimum=0

    // The number of failed finished jobs to retain.
    // This is a pointer to distinguish between explicit zero and not specified.
    // +optional
    FailedJobsHistoryLimit *int32 `json:"failedJobsHistoryLimit,omitempty"`
}

ConcurrencyPolicy 는 실제로는 string 이지만, 재사용과 유효성 검사를 쉽게 할 수 있으므로 타입을 재정의 했다.

// ConcurrencyPolicy describes how the job will be handled.
// Only one of the following concurrent policies may be specified.
// If none of the following policies is specified, the default one
// is AllowConcurrent.
// +kubebuilder:validation:Enum=Allow;Forbid;Replace
type ConcurrencyPolicy string

const (
    // AllowConcurrent allows CronJobs to run concurrently.
    AllowConcurrent ConcurrencyPolicy = "Allow"

    // ForbidConcurrent forbids concurrent runs, skipping next run if previous
    // hasn't finished yet.
    ForbidConcurrent ConcurrencyPolicy = "Forbid"

    // ReplaceConcurrent cancels currently running job and replaces it with a new one.
    ReplaceConcurrent ConcurrencyPolicy = "Replace"
)

다음은 관찰된 상태를 저장하는 status 를 디자인해 보자.

현재 실행중인 job 목록과 마지막으로 job 을 성공적으로 실행한 시간을 유지한다. 그리고 직렬화를 위해서 time.Time 대신 metav1.Time 을 사용한다.

// CronJobStatus defines the observed state of CronJob
type CronJobStatus struct {
    // INSERT ADDITIONAL STATUS FIELD - define observed state of cluster
    // Important: Run "make" to regenerate code after modifying this file

    // A list of pointers to currently running jobs.
    // +optional
    Active []corev1.ObjectReference `json:"active,omitempty"`

    // Information when was the last time the job was successfully scheduled.
    // +optional
    LastScheduleTime *metav1.Time `json:"lastScheduleTime,omitempty"`
}

Controller 구현

컨트롤러는 쿠버네티스와 모든 operator 의 핵심이다.

컨트롤러의 역할은 주어진 오브젝트에 대해 실세계의 실제 상태(클러스터 상태와 잠재적으로 외부 상태(예: Kubelet의 경우 컨테이너 실행 또는 Cloud Provider 의 경우 로드밸런서)가 오브젝트의 원하는 상태와 일치하는지 확인하는 것이다. 각 컨트롤러는 하나의 루트 Kind 에 중점을 두지만 다른 Kind 와 상호 작용할 수 있다.

이 프로세스를 reconciling 이라고 부른다.

controller-runtime 에서 특정 kind 에 대한 reconciling 을 구현하는 로직을 Reconciler 라고 한다.

internal/controller/cronjob_controller.go 파일을 살펴 보자.

기본으로 임포트하는 모듈이 있는데, core controller-runtime 라이브러리와 client 패키지, API 타입 패키지가 있다.

package controllers

import (
    "context"

    "k8s.io/apimachinery/pkg/runtime"
    ctrl "sigs.k8s.io/controller-runtime"
    "sigs.k8s.io/controller-runtime/pkg/client"
    "sigs.k8s.io/controller-runtime/pkg/log"

    batchv1 "tutorial.kubebuilder.io/project/api/v1"
)

컨트롤러의 기본 로직은 다음과 같다.

  1. 명명된 CronJob을 로드한다.
  2. 모든 active job 을 나열하고, status 를 업데이트 한다.
  3. 히스토리 수 제한에 따라 오래된 job 을 정리한다.
  4. Suspend 값이 세팅되었는지 확인 (값이 세팅된 경우 다른 작업을 수행하지 않음)
  5. 다음 예약된 실행 가져오기
  6. 새로운 job 이 스케줄에 맞고, deadline 이 지나지 않았으며, 동시성 정책에 의해 차단되지 않은 경우 실행
  7. 실행 중인 job 이 보이거나 (자동으로 수행됨) 다음 예약된 실행 시간이 되면 Requeue 한다.

임포트 모듈을 추가한다.

package controller

import (
    "context"
    "fmt"
    "sort"
    "time"

    "github.com/robfig/cron"
    kbatch "k8s.io/api/batch/v1"
    corev1 "k8s.io/api/core/v1"
    metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    "k8s.io/apimachinery/pkg/runtime"
    ref "k8s.io/client-go/tools/reference"
    ctrl "sigs.k8s.io/controller-runtime"
    "sigs.k8s.io/controller-runtime/pkg/client"
    "sigs.k8s.io/controller-runtime/pkg/log"

    batchv1 "tutorial.kubebuilder.io/project/api/v1"
)

테스트를 위해서 Clock 을 추가한다.

// CronJobReconciler reconciles a CronJob object
type CronJobReconciler struct {
    client.Client
    Scheme *runtime.Scheme
    Clock
}

type realClock struct{}

func (_ realClock) Now() time.Time { return time.Now() }

// clock knows how to get the current time.
// It can be used to fake out timing for testing.
type Clock interface {
    Now() time.Time
}

RBAC 을 위해 batch group 의 job 을 핸들링 할 수 있는 권한을 추가한다.

//+kubebuilder:rbac:groups=batch.tutorial.kubebuilder.io,resources=cronjobs,verbs=get;list;watch;create;update;patch;delete
//+kubebuilder:rbac:groups=batch.tutorial.kubebuilder.io,resources=cronjobs/status,verbs=get;update;patch
//+kubebuilder:rbac:groups=batch.tutorial.kubebuilder.io,resources=cronjobs/finalizers,verbs=update
//+kubebuilder:rbac:groups=batch,resources=jobs,verbs=get;list;watch;create;update;patch;delete
//+kubebuilder:rbac:groups=batch,resources=jobs/status,verbs=get

annotation 을 위한 변수를 추가한다.

var (
    scheduledTimeAnnotation = "batch.tutorial.kubebuilder.io/scheduled-at"
)

// Reconcile is part of the main kubernetes reconciliation loop which aims to
// move the current state of the cluster closer to the desired state.
// TODO(user): Modify the Reconcile function to compare the state specified by
// the CronJob object against the actual cluster state, and then
// perform operations to make the cluster state reflect the state specified by
// the user.
//
// For more details, check Reconcile and its Result here:
// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.15.0/pkg/reconcile
func (r *CronJobReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    log := log.FromContext(ctx)

1. 이름으로 CronJob 을 로드한다

client 를 사용하여 CronJob 을 가져온다. 모든 client 의 메소드에는 취소가 가능하게 context 를 아규먼트로 받는다.

    var cronJob batchv1.CronJob
    if err := r.Get(ctx, req.NamespacedName, &cronJob); err != nil {
        log.Error(err, "unable to fetch CronJob")
        // we'll ignore not-found errors, since they can't be fixed by an immediate
        // requeue (we'll need to wait for a new notification), and we can get them
        // on deleted requests.
        return ctrl.Result{}, client.IgnoreNotFound(err)
    }

2. 모든 active job 을 나열하고 status 를 업데이트 한다.

    var childJobs kbatch.JobList
    if err := r.List(ctx, &childJobs, client.InNamespace(req.Namespace), client.MatchingFields{jobOwnerKey: req.Name}); err != nil {
        log.Error(err, "unable to list child Jobs")
        return ctrl.Result{}, err
    }

active job 을 조회했으면 이를 active, successful, failded job 으로 분류한다.

    // find the active list of jobs
    var activeJobs []*kbatch.Job
    var successfulJobs []*kbatch.Job
    var failedJobs []*kbatch.Job
    var mostRecentTime *time.Time // find the last run so we can update the status
    isJobFinished := func(job *kbatch.Job) (bool, kbatch.JobConditionType) {
        for _, c := range job.Status.Conditions {
            if (c.Type == kbatch.JobComplete || c.Type == kbatch.JobFailed) && c.Status == corev1.ConditionTrue {
                return true, c.Type
            }
        }

        return false, ""
    }
    getScheduledTimeForJob := func(job *kbatch.Job) (*time.Time, error) {
        timeRaw := job.Annotations[scheduledTimeAnnotation]
        if len(timeRaw) == 0 {
            return nil, nil
        }

        timeParsed, err := time.Parse(time.RFC3339, timeRaw)
        if err != nil {
            return nil, err
        }
        return &timeParsed, nil
    }
    for i, job := range childJobs.Items {
        _, finishedType := isJobFinished(&job)
        switch finishedType {
        case "": // ongoing
            activeJobs = append(activeJobs, &childJobs.Items[i])
        case kbatch.JobFailed:
            failedJobs = append(failedJobs, &childJobs.Items[i])
        case kbatch.JobComplete:
            successfulJobs = append(successfulJobs, &childJobs.Items[i])
        }

        // We'll store the launch time in an annotation, so we'll reconstitute that from
        // the active jobs themselves.
        scheduledTimeForJob, err := getScheduledTimeForJob(&job)
        if err != nil {
            log.Error(err, "unable to parse schedule time for child job", "job", &job)
            continue
        }
        if scheduledTimeForJob != nil {
            if mostRecentTime == nil {
                mostRecentTime = scheduledTimeForJob
            } else if mostRecentTime.Before(*scheduledTimeForJob) {
                mostRecentTime = scheduledTimeForJob
            }
        }
    }

    if mostRecentTime != nil {
        cronJob.Status.LastScheduleTime = &metav1.Time{Time: *mostRecentTime}
    } else {
        cronJob.Status.LastScheduleTime = nil
    }
    cronJob.Status.Active = nil
    for _, activeJob := range activeJobs {
        jobRef, err := ref.GetReference(r.Scheme, activeJob)
        if err != nil {
            log.Error(err, "unable to make reference to active job", "job", activeJob)
            continue
        }
        cronJob.Status.Active = append(cronJob.Status.Active, *jobRef)
    }

디버깅을 위해서 log 를 남긴다.

    log.V(1).Info("job count", "active jobs", len(activeJobs), "successful jobs", len(successfulJobs), "failed jobs", len(failedJobs))

status 를 업데이트 한다.

    if err := r.Status().Update(ctx, &cronJob); err != nil {
        log.Error(err, "unable to update CronJob status")
        return ctrl.Result{}, err
    }

3. 히스토리 수 제한에 따른 오래된 job 삭제하기

    // NB: deleting these are "best effort" -- if we fail on a particular one,
    // we won't requeue just to finish the deleting.
    if cronJob.Spec.FailedJobsHistoryLimit != nil {
        sort.Slice(failedJobs, func(i, j int) bool {
            if failedJobs[i].Status.StartTime == nil {
                return failedJobs[j].Status.StartTime != nil
            }
            return failedJobs[i].Status.StartTime.Before(failedJobs[j].Status.StartTime)
        })
        for i, job := range failedJobs {
            if int32(i) >= int32(len(failedJobs))-*cronJob.Spec.FailedJobsHistoryLimit {
                break
            }
            if err := r.Delete(ctx, job, client.PropagationPolicy(metav1.DeletePropagationBackground)); client.IgnoreNotFound(err) != nil {
                log.Error(err, "unable to delete old failed job", "job", job)
            } else {
                log.V(0).Info("deleted old failed job", "job", job)
            }
        }
    }

    if cronJob.Spec.SuccessfulJobsHistoryLimit != nil {
        sort.Slice(successfulJobs, func(i, j int) bool {
            if successfulJobs[i].Status.StartTime == nil {
                return successfulJobs[j].Status.StartTime != nil
            }
            return successfulJobs[i].Status.StartTime.Before(successfulJobs[j].Status.StartTime)
        })
        for i, job := range successfulJobs {
            if int32(i) >= int32(len(successfulJobs))-*cronJob.Spec.SuccessfulJobsHistoryLimit {
                break
            }
            if err := r.Delete(ctx, job, client.PropagationPolicy(metav1.DeletePropagationBackground)); (err) != nil {
                log.Error(err, "unable to delete old successful job", "job", job)
            } else {
                log.V(0).Info("deleted old successful job", "job", job)
            }
        }
    }

4. Suspend 값이 세팅되었는지 확인

CronJob 객체에 suspend 값이 세팅되어 있다면 CronJob 을 일시 중단한다. CronJob 을 삭제하지 않고 잠시 멈추고 싶을 때 사용할 수 있다.

    if cronJob.Spec.Suspend != nil && *cronJob.Spec.Suspend {
        log.V(1).Info("cronjob suspended, skipping")
        return ctrl.Result{}, nil
    }

5. 다음 예약된 실행 가져오기

잠시 멈춤 상태가 아니라면 다음 스케줄을 가져온다.

    getNextSchedule := func(cronJob *batchv1.CronJob, now time.Time) (lastMissed time.Time, next time.Time, err error) {
        sched, err := cron.ParseStandard(cronJob.Spec.Schedule)
        if err != nil {
            return time.Time{}, time.Time{}, fmt.Errorf("Unparseable schedule %q: %v", cronJob.Spec.Schedule, err)
        }

        // for optimization purposes, cheat a bit and start from our last observed run time
        // we could reconstitute this here, but there's not much point, since we've
        // just updated it.
        var earliestTime time.Time
        if cronJob.Status.LastScheduleTime != nil {
            earliestTime = cronJob.Status.LastScheduleTime.Time
        } else {
            earliestTime = cronJob.ObjectMeta.CreationTimestamp.Time
        }
        if cronJob.Spec.StartingDeadlineSeconds != nil {
            // controller is not going to schedule anything below this point
            schedulingDeadline := now.Add(-time.Second * time.Duration(*cronJob.Spec.StartingDeadlineSeconds))

            if schedulingDeadline.After(earliestTime) {
                earliestTime = schedulingDeadline
            }
        }
        if earliestTime.After(now) {
            return time.Time{}, sched.Next(now), nil
        }

        starts := 0
        for t := sched.Next(earliestTime); !t.After(now); t = sched.Next(t) {
            lastMissed = t
            // An object might miss several starts. For example, if
            // controller gets wedged on Friday at 5:01pm when everyone has
            // gone home, and someone comes in on Tuesday AM and discovers
            // the problem and restarts the controller, then all the hourly
            // jobs, more than 80 of them for one hourly scheduledJob, should
            // all start running with no further intervention (if the scheduledJob
            // allows concurrency and late starts).
            //
            // However, if there is a bug somewhere, or incorrect clock
            // on controller's server or apiservers (for setting creationTimestamp)
            // then there could be so many missed start times (it could be off
            // by decades or more), that it would eat up all the CPU and memory
            // of this controller. In that case, we want to not try to list
            // all the missed start times.
            starts++
            if starts > 100 {
                // We can't get the most recent times so just return an empty slice
                return time.Time{}, time.Time{}, fmt.Errorf("Too many missed start times (> 100). Set or decrease .spec.startingDeadlineSeconds or check clock skew.")
            }
        }
        return lastMissed, sched.Next(now), nil
    }
    // figure out the next times that we need to create
    // jobs at (or anything we missed).
    missedRun, nextRun, err := getNextSchedule(&cronJob, r.Now())
    if err != nil {
        log.Error(err, "unable to figure out CronJob schedule")
        // we don't really care about requeuing until we get an update that
        // fixes the schedule, so don't return an error
        return ctrl.Result{}, nil
    }

requeue 할 값을 준비만 해 놓는다.

    scheduledResult := ctrl.Result{RequeueAfter: nextRun.Sub(r.Now())} // save this so we can re-use it elsewhere
    log = log.WithValues("now", r.Now(), "next run", nextRun)

6. 새로운 job 이 스케줄에 맞고, deadline 이 지나지 않았으며, 동시성 정책에 의해 차단되지 않은 경우 실행

    if missedRun.IsZero() {
        log.V(1).Info("no upcoming scheduled times, sleeping until next")
        return scheduledResult, nil
    }

    // make sure we're not too late to start the run
    log = log.WithValues("current run", missedRun)
    tooLate := false
    if cronJob.Spec.StartingDeadlineSeconds != nil {
        tooLate = missedRun.Add(time.Duration(*cronJob.Spec.StartingDeadlineSeconds) * time.Second).Before(r.Now())
    }
    if tooLate {
        log.V(1).Info("missed starting deadline for last run, sleeping till next")
        // TODO(directxman12): events
        return scheduledResult, nil
    }
    // figure out how to run this job -- concurrency policy might forbid us from running
    // multiple at the same time...
    if cronJob.Spec.ConcurrencyPolicy == batchv1.ForbidConcurrent && len(activeJobs) > 0 {
        log.V(1).Info("concurrency policy blocks concurrent runs, skipping", "num active", len(activeJobs))
        return scheduledResult, nil
    }

    // ...or instruct us to replace existing ones...
    if cronJob.Spec.ConcurrencyPolicy == batchv1.ReplaceConcurrent {
        for _, activeJob := range activeJobs {
            // we don't care if the job was already deleted
            if err := r.Delete(ctx, activeJob, client.PropagationPolicy(metav1.DeletePropagationBackground)); client.IgnoreNotFound(err) != nil {
                log.Error(err, "unable to delete active job", "job", activeJob)
                return ctrl.Result{}, err
            }
        }
    }
    constructJobForCronJob := func(cronJob *batchv1.CronJob, scheduledTime time.Time) (*kbatch.Job, error) {
        // We want job names for a given nominal start time to have a deterministic name to avoid the same job being created twice
        name := fmt.Sprintf("%s-%d", cronJob.Name, scheduledTime.Unix())

        job := &kbatch.Job{
            ObjectMeta: metav1.ObjectMeta{
                Labels:      make(map[string]string),
                Annotations: make(map[string]string),
                Name:        name,
                Namespace:   cronJob.Namespace,
            },
            Spec: *cronJob.Spec.JobTemplate.Spec.DeepCopy(),
        }
        for k, v := range cronJob.Spec.JobTemplate.Annotations {
            job.Annotations[k] = v
        }
        job.Annotations[scheduledTimeAnnotation] = scheduledTime.Format(time.RFC3339)
        for k, v := range cronJob.Spec.JobTemplate.Labels {
            job.Labels[k] = v
        }
        if err := ctrl.SetControllerReference(cronJob, job, r.Scheme); err != nil {
            return nil, err
        }

        return job, nil
    }
    // actually make the job...
    job, err := constructJobForCronJob(&cronJob, missedRun)
    if err != nil {
        log.Error(err, "unable to construct job from template")
        // don't bother requeuing until we get a change to the spec
        return scheduledResult, nil
    }

    // ...and create it on the cluster
    if err := r.Create(ctx, job); err != nil {
        log.Error(err, "unable to create Job for CronJob", "job", job)
        return ctrl.Result{}, err
    }

    log.V(1).Info("created Job for CronJob run", "job", job)

7. 실행 중인 job 이 보이거나 (자동으로 수행됨) 다음 예약된 실행 시간이 되면 Requeue 한다.

    // we'll requeue once we see the running job, and update our status
    return scheduledResult, nil
}

Setup

var (
    jobOwnerKey = ".metadata.controller"
    apiGVStr    = batchv1.GroupVersion.String()
)

// SetupWithManager sets up the controller with the Manager.
func (r *CronJobReconciler) SetupWithManager(mgr ctrl.Manager) error {
    // set up a real clock, since we're not in a test
    if r.Clock == nil {
        r.Clock = realClock{}
    }

    if err := mgr.GetFieldIndexer().IndexField(context.Background(), &kbatch.Job{}, jobOwnerKey, func(rawObj client.Object) []string {
        // grab the job object, extract the owner...
        job := rawObj.(*kbatch.Job)
        owner := metav1.GetControllerOf(job)
        if owner == nil {
            return nil
        }
        // ...make sure it's a CronJob...
        if owner.APIVersion != apiGVStr || owner.Kind != "CronJob" {
            return nil
        }

        // ...and if so, return it
        return []string{owner.Name}
    }); err != nil {
        return err
    }

    return ctrl.NewControllerManagedBy(mgr).
        For(&batchv1.CronJob{}).
        Owns(&kbatch.Job{}).
        Complete(r)
}

Webhook 생성

$ kubebuilder create webhook --group batch --version v1 --kind CronJob --defaulting --programmatic-validation

api/v1/cronjob_webhook.go 파일이 생성된다. 해당 파일에 체크 로직을 추가한다.

// Default implements webhook.Defaulter so a webhook will be registered for the type
func (r *CronJob) Default() {
    cronjoblog.Info("default", "name", r.Name)

    if r.Spec.ConcurrencyPolicy == "" {
        r.Spec.ConcurrencyPolicy = AllowConcurrent
    }
    if r.Spec.Suspend == nil {
        r.Spec.Suspend = new(bool)
    }
    if r.Spec.SuccessfulJobsHistoryLimit == nil {
        r.Spec.SuccessfulJobsHistoryLimit = new(int32)
        *r.Spec.SuccessfulJobsHistoryLimit = 3
    }
    if r.Spec.FailedJobsHistoryLimit == nil {
        r.Spec.FailedJobsHistoryLimit = new(int32)
        *r.Spec.FailedJobsHistoryLimit = 1
    }
}
var _ webhook.Validator = &CronJob{}

// ValidateCreate implements webhook.Validator so a webhook will be registered for the type
func (r *CronJob) ValidateCreate() error {
    cronjoblog.Info("validate create", "name", r.Name)

    return r.validateCronJob()
}

// ValidateUpdate implements webhook.Validator so a webhook will be registered for the type
func (r *CronJob) ValidateUpdate(old runtime.Object) error {
    cronjoblog.Info("validate update", "name", r.Name)

    return r.validateCronJob()
}

// ValidateDelete implements webhook.Validator so a webhook will be registered for the type
func (r *CronJob) ValidateDelete() error {
    cronjoblog.Info("validate delete", "name", r.Name)

    // TODO(user): fill in your validation logic upon object deletion.
    return nil
}
func (r *CronJob) validateCronJob() error {
    var allErrs field.ErrorList
    if err := r.validateCronJobName(); err != nil {
        allErrs = append(allErrs, err)
    }
    if err := r.validateCronJobSpec(); err != nil {
        allErrs = append(allErrs, err)
    }
    if len(allErrs) == 0 {
        return nil
    }

    return apierrors.NewInvalid(
        schema.GroupKind{Group: "batch.tutorial.kubebuilder.io", Kind: "CronJob"},
        r.Name, allErrs)
}
func (r *CronJob) validateCronJobSpec() *field.Error {
    // The field helpers from the kubernetes API machinery help us return nicely
    // structured validation errors.
    return validateScheduleFormat(
        r.Spec.Schedule,
        field.NewPath("spec").Child("schedule"))
}
func validateScheduleFormat(schedule string, fldPath *field.Path) *field.Error {
    if _, err := cron.ParseStandard(schedule); err != nil {
        return field.Invalid(fldPath, schedule, err.Error())
    }
    return nil
}
func (r *CronJob) validateCronJobName() *field.Error {
    if len(r.ObjectMeta.Name) > validationutils.DNS1035LabelMaxLength-11 {
        // The job name length is 63 character like all Kubernetes objects
        // (which must fit in a DNS subdomain). The cronjob controller appends
        // a 11-character suffix to the cronjob (`-$TIMESTAMP`) when creating
        // a job. The job name length limit is 63 characters. Therefore cronjob
        // names must have length <= 63-11=52. If we don't validate this here,
        // then job creation will fail later.
        return field.Invalid(field.NewPath("metadata").Child("name"), r.Name, "must be no more than 52 characters")
    }
    return nil
}

Controller 배포 및 실행

CR 과 CRD yaml 을 만드는 명령어를 수행한다.

$ make manifests

CRD 를 배포한다.


$ make install

WebHook 를 로컬에서 다른 터미널로 실행한다.

$ export ENABLE_WEBHOOKS=false
$ make run

config/samples/batch_v1_cronjob.yaml 파일에 값을 추가한다.

apiVersion: batch.tutorial.kubebuilder.io/v1
kind: CronJob
metadata:
  labels:
    app.kubernetes.io/name: cronjob
    app.kubernetes.io/instance: cronjob-sample
    app.kubernetes.io/part-of: cronjob-kubebuilder
    app.kubernetes.io/managed-by: kustomize
    app.kubernetes.io/created-by: cronjob-kubebuilder
  name: cronjob-sample
spec:
  schedule: "*/1 * * * *"
  startingDeadlineSeconds: 60
  concurrencyPolicy: Allow # explicitly specify, but Allow is also default.
  jobTemplate:
    spec:
      template:
        spec:
          containers:
            - name: hello
              image: busybox
              args:
                - /bin/sh
                - -c
                - date; echo Hello from the Kubernetes cluster
          restartPolicy: OnFailure

Reference site

반응형
Posted by seungkyua@gmail.com
,
반응형

Kubebuilder 의 아키텍처에 대해서 살펴보고 Kubebuilder 로 프로젝트를 생성하는 방법을 알아본다.

Kubebuilder Architeture

[출처: https://book.kubebuilder.io/architecture.html]


위의 다이어그램에서 Kubebuilder 는 controller-runtime 모듈을 사용하는 것을 알 수 있다. 또한 사용자의 비즈니스 로직은 Reconciler 에 위치 시킨다는 것을 알 수 있다.

 

Kubebuilder 로 프로젝트 생성

Kubebuilder 를 사용하기 위해서 사전 준비 작업이 필요하다.


사전 준비 작업

  • go version v1.19.0+
  • docker version 17.03+.
  • kubectl version v1.11.3+.
  • Access to a Kubernetes v1.11.3+ cluster.

 

Kubebuilder 설치

kubebuilder 는 간단히 다운 받아서 설치할 수 있다. ~/bin/ 디렉토리가 path 로 잡혀있기 때문에 다운 받은 바이너리 파일을 이 곳으로 이동시켰다.

$ cd ~/Documents/tmp

$ curl -L -o kubebuilder https://go.kubebuilder.io/dl/latest/$(go env GOOS)/$(go env GOARCH)
$ chmod +x kubebuilder
$ mv ~/Documents/tmp/kubebuilder ~/bin/kubebuilder

$ kubebuilder version
--- output ---
Version: main.version{KubeBuilderVersion:"3.10.0", KubernetesVendor:"1.26.1", GitCommit:"0fa57405d4a892efceec3c5a902f634277e30732", BuildDate:"2023-04-15T08:10:35Z", GoOs:"darwin", GoArch:"amd64"}

 

kustomize 설치

$ cd ~/Documents/tmp

$ curl -s "https://raw.githubusercontent.com/kubernetes-sigs/kustomize/master/hack/install_kustomize.sh"  | bash
$ mv ~/Documents/tmp/kustomize ~/bin/kustomize

$ kustomize version
--- output ---
v5.0.3

 

kind 설치 및 cluster 생성

$ cd ~/Documents/tmp

$ [ $(uname -m) = x86_64 ]&& curl -Lo ./kind https://kind.sigs.k8s.io/dl/v0.19.0/kind-darwin-amd64
$ chmod +x kind
$ mv ~/Documents/tmp/kind ~/bin/kind

$ kind version
--- output ---
kind v0.19.0 go1.20.4 darwin/amd64

$ kind create cluster

 

kubectl 설치

$ cd ~/Documents/tmp

$ curl -LO "https://dl.k8s.io/release/v1.27.1/bin/darwin/amd64/kubectl"
$ chmod +x kubectl
$ mv kubectl ~/bin/kubectl

$ kubectl version
Client Version: version.Info{Major:"1", Minor:"27", GitVersion:"v1.27.1", GitCommit:"4c9411232e10168d7b050c49a1b59f6df9d7ea4b", GitTreeState:"clean", BuildDate:"2023-04-14T13:21:19Z", GoVersion:"go1.20.3", Compiler:"gc", Platform:"darwin/amd64"}
Kustomize Version: v5.0.1
Server Version: version.Info{Major:"1", Minor:"27", GitVersion:"v1.27.1", GitCommit:"4c9411232e10168d7b050c49a1b59f6df9d7ea4b", GitTreeState:"clean", BuildDate:"2023-05-12T19:03:40Z", GoVersion:"go1.20.3", Compiler:"gc", Platform:"linux/amd64"}

 


프로젝트 생성

kubebuilder 명령어로 간단히 프로젝트와 API 를 생성할 수 있다. 즉, 필요한 코드들이 자동으로 생성된다.

먼저 프로젝트를 생성한다.

$ mkdir -p guestbook-kubebuilder
$ cd guestbook-kubebuilder

$ kubebuilder init --domain my.domain --repo my.domain/guestbook
--- output ---
Writing kustomize manifests for you to edit...
Writing scaffold for you to edit...
Get controller runtime:
$ go get sigs.k8s.io/controller-runtime@v0.14.4
go: downloading sigs.k8s.io/controller-runtime v0.14.4
go: downloading k8s.io/apimachinery v0.26.1
go: downloading github.com/prometheus/client_golang v1.14.0
go: downloading k8s.io/client-go v0.26.1
go: downloading k8s.io/utils v0.0.0-20221128185143-99ec85e7a448
go: downloading github.com/prometheus/client_model v0.3.0
go: downloading k8s.io/api v0.26.1
go: downloading k8s.io/component-base v0.26.1
go: downloading golang.org/x/time v0.3.0
go: downloading k8s.io/apiextensions-apiserver v0.26.1
go: downloading github.com/matttproud/golang_protobuf_extensions v1.0.2
go: downloading golang.org/x/net v0.3.1-0.20221206200815-1e63c2f08a10
go: downloading github.com/imdario/mergo v0.3.6
go: downloading k8s.io/kube-openapi v0.0.0-20221012153701-172d655c2280
go: downloading golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b
Update dependencies:
$ go mod tidy
go: downloading go.uber.org/goleak v1.2.0
Next: define a resource with:
$ kubebuilder create api

 

다음으로 api 를 생성한다.

$ kubebuilder create api --group webapp --version v1 --kind Guestbook
--- output ---
Create Resource [y/n]
y
Create Controller [y/n]
y
Writing kustomize manifests for you to edit...
Writing scaffold for you to edit...
api/v1/guestbook_types.go
api/v1/groupversion_info.go
internal/controller/suite_test.go
internal/controller/guestbook_controller.go
Update dependencies:
$ go mod tidy
Running make:
$ make generate
mkdir -p /Users/ahnsk/Documents/github.com/seungkyua/guestbook-kubebuilder/bin
test -s /Users/ahnsk/Documents/github.com/seungkyua/guestbook-kubebuilder/bin/controller-gen && /Users/ahnsk/Documents/github.com/seungkyua/guestbook-kubebuilder/bin/controller-gen --version | grep -q v0.11.3 || \
    GOBIN=/Users/ahnsk/Documents/github.com/seungkyua/guestbook-kubebuilder/bin go install sigs.k8s.io/controller-tools/cmd/controller-gen@v0.11.3
go: downloading sigs.k8s.io/controller-tools v0.11.3
go: downloading golang.org/x/tools v0.4.0
go: downloading k8s.io/utils v0.0.0-20221107191617-1a15be271d1d
go: downloading github.com/mattn/go-colorable v0.1.9
/Users/ahnsk/Documents/github.com/seungkyua/guestbook-kubebuilder/bin/controller-gen object:headerFile="hack/boilerplate.go.txt" paths="./..."
Next: implement your new API and generate the manifests (e.g. CRDs,CRs) with:
$ make manifests

CR 이나 CRD 를 수정하면 마지막의 make manifests 를 수행하여 다신 generation 해야 한다고 친절히 알려주고 있다.

CR 과 CRD 는 아래 guestbook_types.go 파일에 struct 로 생성되어 있다. 이곳을 원하는 대로 변경하면 된다.

 


테스트로 아래과 같이 변경하자.

// GuestbookSpec defines the desired state of Guestbook
type GuestbookSpec struct {
    // INSERT ADDITIONAL SPEC FIELDS - desired state of cluster
    // Important: Run "make" to regenerate code after modifying this file

    // Quantity of instances
    // +kubebuilder:validation:Minimum=1
    // +kubebuilder:validation:Maximum=10
    Size int32 `json:"size"`

    // Name of the ConfigMap for GuestbookSpec's configuration
    // +kubebuilder:validation:MaxLength=15
    // +kubebuilder:validation:MinLength=1
    ConfigMapName string `json:"configMapName"`

    // +kubebuilder:validation:Enum=Phone;Address;Name
    Type string `json:"alias,omitempty"`
}

// GuestbookStatus defines the observed state of Guestbook
type GuestbookStatus struct {
    // INSERT ADDITIONAL STATUS FIELD - define observed state of cluster
    // Important: Run "make" to regenerate code after modifying this file

    // PodName of the active Guestbook node.
    Active string `json:"active"`

    // PodNames of the standby Guestbook nodes.
    Standby []string `json:"standby"`
}

//+kubebuilder:object:root=true
//+kubebuilder:subresource:status

// Guestbook is the Schema for the guestbooks API
type Guestbook struct {
    metav1.TypeMeta   `json:",inline"`
    metav1.ObjectMeta `json:"metadata,omitempty"`

    Spec   GuestbookSpec   `json:"spec,omitempty"`
    Status GuestbookStatus `json:"status,omitempty"`
}

//+kubebuilder:object:root=true

// GuestbookList contains a list of Guestbook
type GuestbookList struct {
    metav1.TypeMeta `json:",inline"`
    metav1.ListMeta `json:"metadata,omitempty"`
    Items           []Guestbook `json:"items"`
}

Guestbook struct 에 있는 metav1.TypeMetametav1.ObjectMeta 를 설명하면, 전자는 우리가 흔히 보는 yaml 파일에서 apiVersionKind 이고 후자는 metadataname, namespace 등을 나타낸다. 다음에 우리가 정의한 SpecStatus 가 있음을 알 수 있다.



테스트 방법 1 - Cluster 밖에서 테스트 하기

CRD 를 cluster 에 설치한다.

$ make install
--- output ---
/Users/ahnsk/Documents/github.com/seungkyua/guestbook-kubebuilder/bin/controller-gen rbac:roleName=manager-role crd webhook paths="./..." output:crd:artifacts:config=config/crd/bases
test -s /Users/ahnsk/Documents/github.com/seungkyua/guestbook-kubebuilder/bin/kustomize || { curl -Ss "https://raw.githubusercontent.com/kubernetes-sigs/kustomize/master/hack/install_kustomize.sh" --output install_kustomize.sh && bash install_kustomize.sh 5.0.0 /Users/ahnsk/Documents/github.com/seungkyua/guestbook-kubebuilder/bin; rm install_kustomize.sh; }
v5.0.0
kustomize installed to /Users/ahnsk/Documents/github.com/seungkyua/guestbook-kubebuilder/bin/kustomize
/Users/ahnsk/Documents/github.com/seungkyua/guestbook-kubebuilder/bin/kustomize build config/crd | kubectl apply -f -
customresourcedefinition.apiextensions.k8s.io/guestbooks.webapp.my.domain created

 

controller 를 실행시킨다. (터미널에서 포그라운드로 실행한다)

$ make run
--- output ---
test -s /Users/ahnsk/Documents/github.com/seungkyua/guestbook-kubebuilder/bin/controller-gen && /Users/ahnsk/Documents/github.com/seungkyua/guestbook-kubebuilder/bin/controller-gen --version | grep -q v0.11.3 || \
        GOBIN=/Users/ahnsk/Documents/github.com/seungkyua/guestbook-kubebuilder/bin go install sigs.k8s.io/controller-tools/cmd/controller-gen@v0.11.3
/Users/ahnsk/Documents/github.com/seungkyua/guestbook-kubebuilder/bin/controller-gen rbac:roleName=manager-role crd webhook paths="./..." output:crd:artifacts:config=config/crd/bases
/Users/ahnsk/Documents/github.com/seungkyua/guestbook-kubebuilder/bin/controller-gen object:headerFile="hack/boilerplate.go.txt" paths="./..."
go fmt ./...
go vet ./...

go run ./cmd/main.go
2023-05-24T17:18:18+09:00       INFO    controller-runtime.metrics      Metrics server is starting to listen    {"addr": ":8080"}
2023-05-24T17:18:18+09:00       INFO    setup   starting manager
2023-05-24T17:18:18+09:00       INFO    Starting server {"kind": "health probe", "addr": "[::]:8081"}
2023-05-24T17:18:18+09:00       INFO    Starting server {"path": "/metrics", "kind": "metrics", "addr": "[::]:8080"}
2023-05-24T17:18:18+09:00       INFO    Starting EventSource    {"controller": "guestbook", "controllerGroup": "webapp.my.domain", "controllerKind": "Guestbook", "source": "kind source: *v1.Guestbook"}
2023-05-24T17:18:18+09:00       INFO    Starting Controller     {"controller": "guestbook", "controllerGroup": "webapp.my.domain", "controllerKind": "Guestbook"}
2023-05-24T17:18:18+09:00       INFO    Starting workers        {"controller": "guestbook", "controllerGroup": "webapp.my.domain", "controllerKind": "Guestbook", "worker count": 1}

참고로 앞서 api 를 생성할 때 Create Resource [y/n] y 로 했다면 CR 이 config/samples 디렉토리 아래에 생성되어 있다.


여기에 Spec 부분만 추가한다.

$ tree config/samples
config/samples
├── kustomization.yaml
└── webapp_v1_guestbook.yaml

$ vi config/samples/webapp_v1_guestbook.yaml
--- output ---
apiVersion: webapp.my.domain/v1
kind: Guestbook
metadata:
  labels:
    app.kubernetes.io/name: guestbook
    app.kubernetes.io/instance: guestbook-sample
    app.kubernetes.io/part-of: guestbook-kubebuilder
    app.kubernetes.io/managed-by: kustomize
    app.kubernetes.io/created-by: guestbook-kubebuilder
  name: guestbook-sample
spec:
  # TODO(user): Add fields here
  size: 1
  configMapName: "myconfig"
  alias: "Address"

 

터미널을 새로 열어서 이를 설치한다.

$ kubectl apply -k config/samples/
--- output ---
guestbook.webapp.my.domain/guestbook-sample created

$ kubectl get guestbook
--- output ---           
NAME               AGE
guestbook-sample   29s


테스트 방법 2 - Cluster 안에서 돌리기

controller 를 cluster 안에서 돌리기 위해서는 먼저 이미지를 만들어야 한다.

$ docker login -u seungkyua
--- output ---         
Password: 
Login Succeeded

$ make docker-build docker-push IMG=docker.io/seungkyua/guestbook-kubebuilder:1.0

 

다음은 image 를 가지고 deploy 한다.

$ make deploy IMG=docker.io/seungkyua/guestbook-kubebuilder:1.0
--- output ---
test -s /Users/ahnsk/Documents/github.com/seungkyua/guestbook-kubebuilder/bin/controller-gen && /Users/ahnsk/Documents/github.com/seungkyua/guestbook-kubebuilder/bin/controller-gen --version | grep -q v0.11.3 || \
        GOBIN=/Users/ahnsk/Documents/github.com/seungkyua/guestbook-kubebuilder/bin go install sigs.k8s.io/controller-tools/cmd/controller-gen@v0.11.3
/Users/ahnsk/Documents/github.com/seungkyua/guestbook-kubebuilder/bin/controller-gen rbac:roleName=manager-role crd webhook paths="./..." output:crd:artifacts:config=config/crd/bases
test -s /Users/ahnsk/Documents/github.com/seungkyua/guestbook-kubebuilder/bin/kustomize || { curl -Ss "https://raw.githubusercontent.com/kubernetes-sigs/kustomize/master/hack/install_kustomize.sh" --output install_kustomize.sh && bash install_kustomize.sh 5.0.0 /Users/ahnsk/Documents/github.com/seungkyua/guestbook-kubebuilder/bin; rm install_kustomize.sh; }
cd config/manager && /Users/ahnsk/Documents/github.com/seungkyua/guestbook-kubebuilder/bin/kustomize edit set image controller=docker.io/seungkyua/guestbook-kubebuilder:1.0
/Users/ahnsk/Documents/github.com/seungkyua/guestbook-kubebuilder/bin/kustomize build config/default | kubectl apply -f -
# Warning: 'patchesStrategicMerge' is deprecated. Please use 'patches' instead. Run 'kustomize edit fix' to update your Kustomization automatically.
namespace/guestbook-kubebuilder-system created
customresourcedefinition.apiextensions.k8s.io/guestbooks.webapp.my.domain configured
serviceaccount/guestbook-kubebuilder-controller-manager created
role.rbac.authorization.k8s.io/guestbook-kubebuilder-leader-election-role created
clusterrole.rbac.authorization.k8s.io/guestbook-kubebuilder-manager-role created
clusterrole.rbac.authorization.k8s.io/guestbook-kubebuilder-metrics-reader created
clusterrole.rbac.authorization.k8s.io/guestbook-kubebuilder-proxy-role created
rolebinding.rbac.authorization.k8s.io/guestbook-kubebuilder-leader-election-rolebinding created
clusterrolebinding.rbac.authorization.k8s.io/guestbook-kubebuilder-manager-rolebinding created
clusterrolebinding.rbac.authorization.k8s.io/guestbook-kubebuilder-proxy-rolebinding created
service/guestbook-kubebuilder-controller-manager-metrics-service created
deployment.apps/guestbook-kubebuilder-controller-manager created

 

확인하면 다음과 같이 pod 가 설치된 것을 알 수 있다.

$ kubectl get pods -n guestbook-kubebuilder-system 
--- output ---
NAME                                                        READY   STATUS    RESTARTS   AGE
guestbook-kubebuilder-controller-manager-5f74f9d765-r68gn   2/2     Running   0          2m55s

 


삭제하기

crd 삭제

$ make uninstall

 

Cluster 에 설치된 controller 삭제

$ make undeploy
반응형
Posted by seungkyua@gmail.com
,