Outsider's Dev Story

Stay Hungry. Stay Foolish. Don't Be Satisfied.
RetroTech 팟캐스트 44BITS 팟캐스트

HashiCorp의 Waypoint의 커스텀 플러그인 작성하기 #1

HashiCorp의 배포 도구인 WaypointHashiCorp의 Waypoint 살펴보기 , HashiCorp Waypoint로 Kubernetes 클러스터에 배포하기에서 설명했는데 사용해보면 Waypoint는 아주 간단해서 금방 익힐 수 있지만, 라이프사이클을 담당하고 있는 build, deploy, release에서 사용하는 플러그인이 핵심이라는 것을 알 수 있다.

다음과 waypoint.hcl을 보면 build, deploy, release 단계를 정의하고 있고 각 단계에서 use 스탠자(Stanza, {}로 묶이는 코드 블록을 이렇게 부른다.)를 이용해서 플러그인을 사용하고 있다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
project = "project-name"

app "app-name" {
  build {
    use "pack" {}
  }

  deploy {
    use "kubernetes" {}
  }

  release {
    use "kubernetes" {}
  }

여기서 pack, kubernetes 플러그인을 사용하고 있는데 결국 이 플러그인을 사용해서 어떻게 빌드하고 배포하고 릴리스하는지를 정하는 것이므로 환경에 맞게 필요한 기능이 있다면 플러그인을 어떻게 확장할 수 있는지가 궁금해진다.

내장 플러그인

Waypoint의 플러그인 페이지에 가면 내장된 플러그인을 볼 수 있는데 이 글을 쓰는 시점의 기준으로는 다음과 같은 내장 플러그인이 있다.

이름을 보면 어떤 서비스 혹은 프로그램과 연관되었는지 알 수 있긴 한데 각 플러그인은 build, deploy, release 단계에 맞춰져 있고 한 단계만을 위한 플러그인도 여러 단계에서 같이 사용할 수 있는 플러그인도 있다.

Waypoint의 버전이 초기이므로 플러그인이 많지는 않고 다 써보진 않았지만 약간 복잡한 실 서비스에 쓰려면 아쉬운 점이 보일 거로 생각한다. 그래서 원하는 기능을 적용하려면 플러그인을 직접 만들어야 한다.

Waypoint 플러그인

플러그인은 Waypoint 애플리케이션과 통신하는 별도의 바이너리라고 할 수 있고 통신은 gRPC를 사용한다. 그래서 gRPC를 사용할 수 있는 어떤 언어로도 플러그인을 만들 수는 있지만 Go 언어로 Waypoint SDK를 제공하고 있으므로 이를 사용하면 쉽게 플러그인을 만들 수 있다.

플러그인과 관련된 용어를 먼저 살펴보자.

  • 플러그인: Waypoint 애플리케이션이 실행하는 컴포넌트가 담긴 컴파일된 바이너리이다.
  • 컴포넌트: 애플리케이션의 라이프 사이클(빌드, 디플로이, 릴리스)을 담당하고 여러 인터페이스를 구현해서 만들어진다.
  • 인터페이스: 컴포넌트에서 Configurable, ReleaseManager같은 동작을 구현한다.
  • 출력값: Protocol Buffer로 직렬화해서 컴포넌트 간에 주고받을 수 있다.

Go 프로젝트를 빌드하는 플러그인

이 글은 공식 사이트에서 제공하는 Creating Waypoint Plugins를 따라 해 본 것이다. 한번 따라 해보면 플러그인을 통해서 어느 정도 확장할 수 있을지 감을 잡을 수 있다. 앞에서 얘기한 대로 Go 언어용 SDK를 이용해서 만들 것이다.

Go 언어로 플러그인을 작성하려면 아래 의존성이 설치되어 있어야 한다.

여기서 만들 플러그인은 Builder 컴포넌트를 만들어서 Waypoint의 build 단계에서 Go 프로젝트를 빌드해서 바이너리로 만들어주는 플러그인을 만들 것이다.

Waypoint가 제공하는 예제 프로젝트를 클론 받는다.

1
2
3
$ git clone git@github.com:hashicorp/waypoint-plugin-examples.git

$ cd waypoint-plugin-examples/template

이 예제프로젝트에서 플러그인 제작을 시작할 수 있는 템플릿을 제공한다. 이 템플릿에서 컴포넌트와 인터페이스가 구현되어 있는데 복사해서 사용하기 쉽게 clone.sh를 제공한다.

1
2
3
4
5
$ ./clone.sh gobuilder ../gobuilder github.com/hashicorp/waypoint-plugin-examples/gobuilder
Created new plugin in gobuilder
You can build this plugin by running the following command

cd ../gobuilder && make

clone.sh를 실행하면서 플러그인 이름, 복사할 경로, Go 패키지 명을 지정하면 코드에서 이름과 패키지도 알아서 바꿔주면서 복사한다. cd ../gobuilder로 복사된 폴더로 이동한다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
├── Dockerfile
├── Makefile
├── README.md
├── builder
│   ├── builder.go
│   ├── output.pb.go
│   └── output.proto
├── clone.sh
├── go.mod
├── go.sum
├── main.go
├── platform
│   ├── auth.go
│   ├── deploy.go
│   ├── destroy.go
│   ├── output.pb.go
│   └── output.proto
├── print_arch
├── registry
│   ├── auth.go
│   ├── output.pb.go
│   ├── output.proto
│   └── registry.go
└── release
    ├── destroy.go
    ├── output.pb.go
    ├── output.proto
    └── release.go

복사된 파일은 위와 같은 파일을 가지고 있다. 여기서는 builder만 사용할 것이지만 플러그인 템플릿이므로 다른 파일도 포함되어 있다.

의존성이 잘 설치되었는지 일단 make로 빌드를 해보자. 앞에서 언급한 의존성이 제대로 설치되지 않았다면 빌드 단계에서 오류가 날 것이다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
$ make

Build Protos
protoc -I . --go_out=plugins=grpc:. --go_opt=paths=source_relative ./builder/output.proto
protoc -I . --go_out=plugins=grpc:. --go_opt=paths=source_relative ./registry/output.proto
protoc -I . --go_out=plugins=grpc:. --go_opt=paths=source_relative ./platform/output.proto
protoc -I . --go_out=plugins=grpc:. --go_opt=paths=source_relative ./release/output.proto

Compile Plugin
# Clear the output
rm -rf ./bin
GOOS=linux GOARCH=amd64 go build -o ./bin/linux_amd64/waypoint-plugin-gobuilder ./main.go
GOOS=darwin GOARCH=amd64 go build -o ./bin/darwin_amd64/waypoint-plugin-gobuilder ./main.go
GOOS=windows GOARCH=amd64 go build -o ./bin/windows_amd64/waypoint-plugin-gobuilder.exe ./main.go
GOOS=windows GOARCH=386 go build -o ./bin/windows_386/waypoint-plugin-gobuilder.exe ./main.go

./bin/linux_amd64/waypoint-plugin-gobuilder처럼 bin 폴더 아래 OS와 아키텍처별로 컴파일된 Go 바이너리가 생기고 이 파일이 플러그인이다.

플러그인 컴포넌트 등록

소스를 수정해 보자. main.go 파일은 아래와 같이 작성되어 있다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
func main() {
  // sdk.Main allows you to register the components which should
  // be included in your plugin
  // Main sets up all the go-plugin requirements

  sdk.Main(sdk.WithComponents(
    // Comment out any components which are not
    // required for your plugin
    &builder.Builder{},
    &registry.Registry{},
    &platform.Platform{},
    &release.ReleaseManager{},
  ))
}

여기서 sdk.Main 함수로 Waypoint SDK를 설정하고 go-plugin 시스템에 인터페이스를 등록하는데 sdk.WithComponents 함수를 이용해서 플러그인에서 구현한 컴포넌트를 정리한다. 모든 컴포넌트가 등록되어 있지만 Builder만 필요하므로 나머지는 제거한다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
func main() {
  // sdk.Main allows you to register the components which should
  // be included in your plugin
  // Main sets up all the go-plugin requirements

  sdk.Main(sdk.WithComponents(
    // Comment out any components which are not
    // required for your plugin
    &builder.Builder{},
  ))
}

설정 처리

컴포넌트는 Waypoint 인터페이스를 구현한 Go Struct인데 builder/builder.go 파일을 보면 Builder Struct를 볼 수 있다. 템플릿에서는 Directory를 가진 BuildConfig 타입의 config 필드를 가지고 있다.

1
2
3
4
5
6
7
type BuildConfig struct {
  Directory string `hcl:"directory,optional"`
}

type Builder struct {
  config BuildConfig
}

여기서 만들 gobuilder라는 플러그인을 생각하면 Go 프로젝트를 빌드하기 위해 빌드한 바이너리의 이름과 소스 코드의 위치를 입력받아야 할 것이다. 상상해보면 다음과 같이 waypoint.hcl을 통해 설정을 컴포넌트에 전달하게 될 것이다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
project = "guides"

app "example" {
  build {
    use "gobuilder" {
      output_name = "server"
      source = "./"
    }
  }
}

Waypoint가 이 설정 파라미터를 Struct로 매핑하는 방법을 알고 있으므로 컴포넌트의 Struct에서는 config에서 직렬화할 필드와 태그를 추가하면 된다. builder/builder.go 파일의 BuildConfig를 다음과 같이 변경한다.

1
2
3
4
type BuildConfig struct {
  OutputName string `hcl:"output_name,optional"`
  Source     string `hcl:"source,optional"`
}

각 필드는 생략할 수 있으므로 반드시 optional로 지정해야 한다.

Waypoint가 설정을 파싱할 때 컴포넌트가 Configurable 인터페이스를 구현했는지 확인하고 구현했다면 config struct가 반환한 참조에서 Config 메서드를 호출한다. 여기서 Waypoint는 이 참조를 사용해서 설정을 직렬화한다.

builder/builder.go을 보면 이미 Configurable 인터페이스를 구현한 코드를 볼 수 있다.

1
2
3
4
// Implement Configurable
func (b *Builder) Config() (interface{}, error) {
  return &b.config, nil
}

컴포넌트는 ConfigurableNotify 인터페이스를 사용해서 설정의 유효성을 검사한다. ConfigurableNotify는 Waypoint가 HCL 설정을 읽을 수 이를 Config가 반환한 struct로 직렬화한 후에 호출된 ConfigSet 메서드를 정의하고 있다. 이 인터페이스는 다음과 같이 생겼다.

1
2
3
4
5
6
7
type ConfigurableNotify interface {
  Configurable

  // ConfigSet is called with the value of the configuration after
  // decoding is complete successfully.
  ConfigSet(interface{}) error
}

BuildFunc같은 컴포넌트용 인터페이스 메서드가 호출되기 전에는 항상 ConfigSet이 호출되는데 여기서 설정을 검사하고 오류를 반환할 수 있다. 템플릿 코드에서는 이미 BuilderConfigSet이 다음과 같이 구현되어 있다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// Implement ConfigurableNotify
func (b *Builder) ConfigSet(config interface{}) error {
  c, ok := config.(*BuildConfig)
  if !ok {
    // The Waypoint SDK should ensure this never gets hit
    return fmt.Errorf("Expected *BuildConfig as parameter")
  }

  // validate the config
  if c.Directory == "" {
    return fmt.Errorf("Directory must be set to a valid directory")
  }

  return nil
}

이를 전달받은 소스 코드의 폴더인 Source 필드의 폴더가 실제로 존재하는지 검사하도록 수정해 보자. os.Stat으로 해당 폴더가 있는지 검사하고 없으면 오류를 반환하게 다음과 같이 수정한다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
func (b *Builder) ConfigSet(config interface{}) error {
  c, ok := config.(*BuildConfig)
  if !ok {
    return fmt.Errorf("Expected type BuildConfig")
  }

  // validate the config
  _, err := os.Stat(c.Source)
  if err != nil {
    return fmt.Errorf("Source folder does not exist")
  }

  // config validated ok
  return nil
}




글이 길어져서 이 글은 HashiCorp의 Waypoint의 커스텀 플러그인 작성하기 #2으로 이어진다.

Valid HTML5 Valid CSS WCAG 2.1 AA tested