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, {}로 묶이는 코드 블록을 이렇게 부른다.)를 이용해서 플러그인을 사용하고 있다.

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 언어로 플러그인을 작성하려면 아래 의존성이 설치되어 있어야 한다.
- Go 1.14+
- Protocol Buffer 컴파일러
- Go 용 Protocol Buffer 컴파일 플러그인

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

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

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

$ cd waypoint-plugin-examples/template

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

$ ./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로 복사된 폴더로 이동한다.

├── 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로 빌드를 해보자. 앞에서 언급한 의존성이 제대로 설치되지 않았다면 빌드 단계에서 오류가 날 것이다.

$ 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 파일은 아래와 같이 작성되어 있다.

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만 필요하므로 나머지는 제거한다.

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 필드를 가지고 있다.

type BuildConfig struct {
  Directory string `hcl:"directory,optional"`
}

type Builder struct {
  config BuildConfig
}

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

project = "guides"

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

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

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 인터페이스를 구현한 코드를 볼 수 있다.

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

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

type ConfigurableNotify interface {
  Configurable

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

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

// 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으로 해당 폴더가 있는지 검사하고 없으면 오류를 반환하게 다음과 같이 수정한다.

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으로 이어진다.

2021/03/20 19:50 2021/03/20 19:50