初めて Cloud Native Buildpack を使うなら、最初に Cloud Native Buildpack(CNB) の作り方 を読んでください。 Packit は CNB の仕様 を満たす Buildpack を作成するために必要な抽象を提供する Go 言語のライブラリです。
このチュートリアルの目標は、ファイルシステム上に1つの依存関係を要求するだけの Buildpack を作成することです。 早速やっていきましょう。
Packit の完全なドキュメントは手前のリンクから参照してください。
読者の時間を無駄にさせないためにも、ここではこれから作成する Buildpack に含める3種類のアーティファクトについて説明します。
CNB の仕様を満たすために最低限必要なのは、 buildpack.toml
と bin/detect
と bin/build
です。
Buildpack をビルドし、テストするには、ローカルPCに次のようなツールが必要です。
Node Engine Cloud Native Buildpack で Node.js エンジンをインストールするだけの Buildpack を作成する様子を説明します。
サンプルリポジトリ には全てのソースコードが残っています。 新しい Go 言語のプロジェクトを作成するには、ディレクトリを作成して、そこで次のコマンドを実行します。
go mod init </path/to/project>
Copied!
buildpack.toml
buildpack.toml
を作成します。
次のような内容を記述しましょう。
# ライフサイクルと互換性のあるバージョンを定義します api = "0.2" # ライフサイフルに対する Buildpack の基本的なメタデータを記述します [buildpack] id = "com.example.node-engine" name = "Node Engine Buildpack" version = "0.0.1" # Buildpack と互換性のあるスタックを記述します [[stacks]] id = "io.buildpacks.stacks.bionic"
Copied!
bin/detect
注意:検出フェーズの詳細な説明は packit の godocs の Detect Phase
セクションを参照してください。
Packit には packit.DetectFunc
を引数とする関数 packit.Detect()
があります。
packit.DetectFunc
は Buildpack の作者が実装する関数で、Buildpack に必要な全ての検出ロジックを内包する関数です。
具体的な実装を説明していきましょう。
cmd/detect/main.go
package main import ( "<path/to/project>" "github.com/paketo-buildpacks/packit" ) func main() { packit.Detect(node.Detect()) }
Copied!
node/detect.go
package node import ( "fmt" "github.com/paketo-buildpacks/packit" ) func Detect() packit.DetectFunc { return func(context packit.DetectContext) (packit.DetectResult, error) { return packit.DetectResult{}, fmt.Errorf("always fail") } }
Copied!
この時点の Buildpack は常に検出に失敗します。
packit.DetectFunc
を独自パッケージで実装することにしたのは、Buildpack のビジネスロジックを Packit に関連する実装と分離するためです。
つまり、Buildpack の全てのビジネスロジックは node
パッケージで実装することになります。
将来的に Buildpack を拡張していく可能性があるなら、こういう構造にしておくことをお勧めします。
detect
コマンドをビルドして、ここまでの状態を確かめてみましょう。
GOOS=linux go build -ldflags="-s -w" -o ./bin/detect ./cmd/detect/main.go
Copied!
そうしたら、pack
コマンドで Node.js アプリケーションのコンテナイメージをビルドしてみましょう。
ここでは、 Node Engine Cloud Native Buildpack の simple app で試しています。
pack build <app-name> \ --path <path/to/app> \ --buildpack <path/to/project> \ --builder paketobuildpacks/builder:base \ --verbose
Copied!
===> DETECTING
[detector] ======== Output: com.example.node-engine@0.0.1 ========
[detector]
[detector] always fail
[detector] ======== Results ========
[detector] err: com.example.node-engine@0.0.1 (1)
[detector] ERROR: No buildpack groups passed detection.
[detector] ERROR: Please check that you are running against the correct path.
[detector] ERROR: failed to detect: no buildpacks participating
ERROR: failed with status code: 6
骨格が出来たことを確認できたので、「常に失敗する」のをまともに動作するするようにしてみましょう。
provides と requires という考え方に取り組まなければなりません。 詳しくは CNB の仕様 を参照してください。
簡単な説明:Buildpack の detect コマンドの構成要素
packit.DetectResult
から参照できる packit.BuildPlan
は provisions と requirements のリストを持っています。
provisions と requirements は、その Buildpack が依存対象として使用できる成果物を提供するのか、依存対象を要求するのか、あるいはその両方になるのかを説明します。
Buildpack の detect コマンドが true になるのは次の両方の条件を満たした場合だけです。
1つ目の条件は、Buildpack の全ての provides が、自分自身と後に続く全ての Buildpack の requires にマッチした場合。
2つ目の条件は、Buildpack の全ての requires が、自分自身と前に並ぶ全ての Buildpack の provides にマッチした場合。
このチュートリアルの場合、他に実行している Buildpack はないので、detect コマンドを true にするには、依存対象として node
を提供し、同様に node
を必須としなければなりません。
detect コマンドに実装してみましょう。
node/detect.go
package node import ( "github.com/paketo-buildpacks/packit" ) func Detect() packit.DetectFunc { return func(context packit.DetectContext) (packit.DetectResult, error) { return packit.DetectResult{ Plan: packit.BuildPlan{ Provides: []packit.BuildPlanProvision{ {Name: "node"}, }, Requires: []packit.BuildPlanRequirement{ {Name: "node"}, }, }, }, nil } }
Copied!
何をしているのか簡単に説明していきます。
まず、 packit.DetectResult
のフィールド Plan
に値を詰めるようにしました。
値は provides に依存対象として node
を、requires に依存対象として node
を持つオブジェクトです。
前の例のように detect コマンドをビルドして、pack コマンドを実行してみましょう。
GOOS=linux go build -ldflags="-s -w" -o ./bin/detect ./cmd/detect/main.go pack build <app-name> \ --path <path/to/app> \ --buildpack <path/to/project> \ --builder paketobuildpacks/builder:base \ --verbose
Copied!
===> DETECTING
[detector] ======== Results ========
[detector] pass: com.example.node-engine@0.0.1
[detector] Resolving plan... (try #1)
[detector] com.example.node-engine 0.0.1
===> ANALYZING
[analyzer] Previous image with name "index.docker.io/library/test-node:latest" not found
===> RESTORING
===> BUILDING
[builder] ERROR: failed to build: fork/exec /cnb/buildpacks/com.example.node-engine/0.0.1/bin/build: no such file or directory
ERROR: failed with status code: 7
検出フェーズ(DETECTING)を通過していることがわかりますね。
そして、build
コマンドが存在しないせいでエラーになっています。
修正してきいきましょう。
bin/build
ビルドフェーズの詳細な説明は packit の godocs の Build Phase
セクションを参照してください。
Packit には packit.BuildFunc
を引数とする関数 packit.Build()
があります。
packit.BuildFunc
は Buildpack の作者が実装する関数で、Buildpack に必要な全てのビルドロジックを内包する関数です。
スケルトンコードを作ってみましょう。
cmd/build/main.go
package main import ( "<path/to/project or github.com/<some-org or some-user>/<some-repo>>/node" "github.com/paketo-buildpacks/packit" ) func main() { packit.Build(node.Build()) }
Copied!
node/build.go
package node import ( "fmt" "github.com/paketo-buildpacks/packit" ) func Build() packit.BuildFunc { return func(context packit.BuildContext) (packit.BuildResult, error) { return packit.BuildResult{}, fmt.Errorf("always fail") } }
Copied!
この実装はビルドプロセスで常に失敗します。 detect コマンドと同じように build コマンドをビルドしましょう。 それから pack コマンドを実行してみましょう。
GOOS=linux go build -ldflags="-s -w" -o ./bin/build ./cmd/build/main.go pack build <app-name> \ --path <path/to/app> \ --buildpack <path/to/project> \ --builder paketobuildpacks/builder:base \ --verbose
Copied!
===> DETECTING
[detector] ======== Results ========
[detector] pass: com.example.node-engine@0.0.1
[detector] Resolving plan... (try #1)
[detector] com.example.node-engine 0.0.1
===> ANALYZING
[analyzer] Previous image with name "index.docker.io/library/test-node:latest" not found
===> RESTORING
===> BUILDING
[builder]
[builder] always fail
[builder] ERROR: failed to build: exit status 1
ERROR: failed with status code: 7
これで、最終的なイメージへ格納する成果物を、ビルドプロセスでどのように作成するのか説明できるようになりました。
ビルドプロセスに依存対象に関するメタデータを渡すには buildpack.toml
を使用します。
ファイルを作成して次のようなフィールドを記述しましょう。
[metadata] [[metadata.dependencies]] uri = "https://nodejs.org/dist/v12.16.1/node-v12.16.1-linux-x64.tar.xz"
Copied!
記述したのは次のような内容です。
# Buildpack 固有のメタデータを記述します [metadata] [[metadata.dependencies]] # 実行している stack と互換性のある依存対象の URI uri = "https://nodejs.org/dist/v12.16.1/node-v12.16.1-linux-x64.tar.xz"
Copied!
uri フィールドを参照する実装を追加しましょう。 ここでは BurntSushi’s TOML Library で TOML をパースしています。
node/build.go
package node import ( "fmt" "os" "path/filepath" "github.com/BurntSushi/toml" "github.com/paketo-buildpacks/packit" ) func Build() packit.BuildFunc { return func(context packit.BuildContext) (packit.BuildResult, error) { file, err := os.Open(filepath.Join(context.CNBPath, "buildpack.toml")) if err != nil { return packit.BuildResult{}, err } var m struct { Metadata struct { Dependencies []struct { URI string `toml:"uri"` } `toml:"dependencies"` } `toml:"metadata"` } _, err = toml.DecodeReader(file, &m) if err != nil { return packit.BuildResult{}, err } uri := m.Metadata.Dependencies[0].URI fmt.Printf("URI -> %s", uri) return packit.BuildResult{}, fmt.Errorf("always fail") } }
Copied!
build コマンドをビルドして、pack build
コマンドを実行してみましょう。
GOOS=linux go build -ldflags="-s -w" -o ./bin/build ./cmd/build/main.go pack build <app-name> \ --path <path/to/app> \ --buildpack <path/to/project> \ --builder paketobuildpacks/builder:base \ --verbose
Copied!
===> DETECTING
[detector] ======== Results ========
[detector] pass: com.example.node-engine@0.0.1
[detector] Resolving plan... (try #1)
[detector] com.example.node-engine 0.0.1
===> ANALYZING
[analyzer] Previous image with name "index.docker.io/library/test-node:latest" not found
===> RESTORING
===> BUILDING
[builder] URI -> https://nodejs.org/dist/v12.16.1/node-v12.16.1-linux-x64.tar.xz
[builder] ERROR: failed to build: exit status 1
[builder] always fail
ERROR: failed with status code: 7
ダウンロードすべき依存対象の URI が取得できるようになりました。 次は、依存対象をインストールするためのレイヤー構造を準備しましょう。
package node import ( "fmt" "os" "path/filepath" "github.com/BurntSushi/toml" "github.com/paketo-buildpacks/packit" ) func Build() packit.BuildFunc { return func(context packit.BuildContext) (packit.BuildResult, error) { file, err := os.Open(filepath.Join(context.CNBPath, "buildpack.toml")) if err != nil { return packit.BuildResult{}, err } var m struct { Metadata struct { Dependencies []struct { URI string `toml:"uri"` } `toml:"dependencies"` } `toml:"metadata"` } _, err = toml.DecodeReader(file, &m) if err != nil { return packit.BuildResult{}, err } uri := m.Metadata.Dependencies[0].URI fmt.Printf("URI -> %s", uri) nodeLayer, err := context.Layers.Get("node") if err != nil { return packit.BuildResult{}, err } nodeLayer, err = nodeLayer.Reset() if err != nil { return packit.BuildResult{}, err } nodeLayer.Launch = true return packit.BuildResult{}, fmt.Errorf("always fail") } }
Copied!
packit.Layers.Get()
関数にはレイヤー名を渡します。
返り値の packit.Layer
には前回ビルドしたときの情報が設定されています。
このメタデータを参照すると、レイヤーを再利用できるか判断できるのですが、ここでは無視しています。
packit.Layer.Reset()
関数は、取得したレイヤーの関するキャッシュや過去の情報を初期化します。
nodeLayer.Launch = true
とするのは、そのレイヤーが起動フェーズで利用可能であることを教えるためです。
レイヤーが準備できたので、依存対象をダウンロードして展開してみましょう。
package node import ( "fmt" "io/ioutil" "os" "os/exec" "path/filepath" "github.com/BurntSushi/toml" "github.com/paketo-buildpacks/packit" ) func Build() packit.BuildFunc { return func(context packit.BuildContext) (packit.BuildResult, error) { file, err := os.Open(filepath.Join(context.CNBPath, "buildpack.toml")) if err != nil { return packit.BuildResult{}, err } var m struct { Metadata struct { Dependencies []struct { URI string `toml:"uri"` } `toml:"dependencies"` } `toml:"metadata"` } _, err = toml.DecodeReader(file, &m) if err != nil { return packit.BuildResult{}, err } uri := m.Metadata.Dependencies[0].URI fmt.Printf("URI -> %s\n", uri) nodeLayer, err := context.Layers.Get("node") if err != nil { return packit.BuildResult{}, err } nodeLayer, err = nodeLayer.Reset() if err != nil { return packit.BuildResult{}, err } nodeLayer.Launch = true downloadDir, err := ioutil.TempDir("", "downloadDir") if err != nil { return packit.BuildResult{}, err } defer os.RemoveAll(downloadDir) fmt.Println("Downloading dependency...") err = exec.Command("curl", uri, "-o", filepath.Join(downloadDir, "node.tar.xz"), ).Run() if err != nil { return packit.BuildResult{}, err } fmt.Println("Untaring dependency...") err = exec.Command("tar", "-xf", filepath.Join(downloadDir, "node.tar.xz"), "--strip-components=1", "-C", nodeLayer.Path, ).Run() if err != nil { return packit.BuildResult{}, err } return packit.BuildResult{ Plan: context.Plan, Layers: []packit.Layer{ nodeLayer, }, }, nil } }
Copied!
build コマンドを再びビルドして、pack build
コマンドを実行すると、今度は成功するはずです。
GOOS=linux go build -ldflags="-s -w" -o ./bin/build ./cmd/build/main.go pack build <app-name> \ --path <path/to/app> \ --buildpack <path/to/project> \ --builder paketobuildpacks/builder:base \ --verbose
Copied!
追加したコードは単純で特に説明する必要はないと思います。
一時ディレクトリを作成して、curl
コマンドで取得した tar.gz ファイルを保存しています。
そして、nodeLayer
の対応するパスへ tar
コマンドで tar.gz ファイルを展開しています。
Go 言語の実装としては非効率ですが、とても分かりやすいと思います。
tar コマンドの引数に --strip-components
を付けているのは、ライフサイクルの参照する lib
や bin
や include
ディレクトリの前に存在するディレクトリを無視するためです(ライフサイクルについて詳しくは CNB の仕様 を参照してください)。
これで、完全に機能する Buildpack が作成できました。
pack build
コマンドで作成したコンテナイメージを docker
コマンドで実行してみましょう。
docker run -d -p 8080:8080 -e PORT=8080 <app-name> "node server.js"
Copied!
実行したコンテナイメージに curl
コマンドでアクセスして確かめてみましょう。
curl http://localhost:8080
Copied!
hello world
Webブラウザで http://localhost:8080
にアクセスしても確認できるはずです。
どちらにしても “hellow world” と表示されれば成功です。
Last modified: September 13, 2021