docs community blog github
Edit

Paketo Buildpack を作成する

初めて Cloud Native Buildpack を使うなら、最初に Cloud Native Buildpack(CNB) の作り方 を読んでください。 Packit は CNB の仕様 を満たす Buildpack を作成するために必要な抽象を提供する Go 言語のライブラリです。

このチュートリアルの目標は、ファイルシステム上に1つの依存関係を要求するだけの Buildpack を作成することです。 早速やっていきましょう。

Packit

GoDoc

Packit の完全なドキュメントは手前のリンクから参照してください。 読者の時間を無駄にさせないためにも、ここではこれから作成する Buildpack に含める3種類のアーティファクトについて説明します。 CNB の仕様を満たすために最低限必要なのは、 buildpack.tomlbin/detectbin/build です。

前提条件

Buildpack をビルドし、テストするには、ローカルPCに次のようなツールが必要です。

それでは始めましょう

Node Engine Cloud Native Buildpack で Node.js エンジンをインストールするだけの Buildpack を作成する様子を説明します。

サンプルリポジトリ には全てのソースコードが残っています。 新しい Go 言語のプロジェクトを作成するには、ディレクトリを作成して、そこで次のコマンドを実行します。

go mod init </path/to/project>
copy to clipboard
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"
copy to clipboard
Copied!

bin/detect

注意:検出フェーズの詳細な説明は packit の godocsDetect 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())
}
copy to clipboard
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")
	}
}
copy to clipboard
Copied!

この時点の Buildpack は常に検出に失敗します。 packit.DetectFunc を独自パッケージで実装することにしたのは、Buildpack のビジネスロジックを Packit に関連する実装と分離するためです。 つまり、Buildpack の全てのビジネスロジックは node パッケージで実装することになります。 将来的に Buildpack を拡張していく可能性があるなら、こういう構造にしておくことをお勧めします。

detect コマンドをビルドして、ここまでの状態を確かめてみましょう。

GOOS=linux go build -ldflags="-s -w" -o ./bin/detect ./cmd/detect/main.go
copy to clipboard
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
copy to clipboard
Copied!
View Output
        ===> 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
	}
}
copy to clipboard
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
copy to clipboard
Copied!
View Output
        ===> 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 の godocsBuild 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())
}
copy to clipboard
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")
	}
}
copy to clipboard
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
copy to clipboard
Copied!
View Output
        ===> 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"
copy to clipboard
Copied!

記述したのは次のような内容です。

# Buildpack 固有のメタデータを記述します
[metadata]
  [[metadata.dependencies]]
    # 実行している stack と互換性のある依存対象の URI
    uri = "https://nodejs.org/dist/v12.16.1/node-v12.16.1-linux-x64.tar.xz"
copy to clipboard
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")
	}
}
copy to clipboard
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
copy to clipboard
Copied!
View Output
        ===> 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")
	}
}
copy to clipboard
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
	}
}
copy to clipboard
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
copy to clipboard
Copied!

追加したコードは単純で特に説明する必要はないと思います。 一時ディレクトリを作成して、curl コマンドで取得した tar.gz ファイルを保存しています。 そして、nodeLayer の対応するパスへ tar コマンドで tar.gz ファイルを展開しています。 Go 言語の実装としては非効率ですが、とても分かりやすいと思います。 tar コマンドの引数に --strip-components を付けているのは、ライフサイクルの参照する libbininclude ディレクトリの前に存在するディレクトリを無視するためです(ライフサイクルについて詳しくは CNB の仕様 を参照してください)。

これで、完全に機能する Buildpack が作成できました。 pack build コマンドで作成したコンテナイメージを docker コマンドで実行してみましょう。

docker run -d -p 8080:8080 -e PORT=8080 <app-name> "node server.js"
copy to clipboard
Copied!

実行したコンテナイメージに curl コマンドでアクセスして確かめてみましょう。

curl http://localhost:8080
copy to clipboard
Copied!
View Output
        hello world

    

Webブラウザで http://localhost:8080 にアクセスしても確認できるはずです。 どちらにしても “hellow world” と表示されれば成功です。

Edit

Last modified: September 13, 2021