Artifactory WebhookとDockerで継続的デプロイメントを実現

Continuous Deployment with Artifactory Webhooks & Docker

継続的デプロイメント(CD)ではメインブランチから最新のコード変更でソフトウェアを更新するために、インフラストラクチャと自動化に関する設定を行う必要があります。これが私たちが“Liquid Software”と呼んでいるものです。完全な自動化により、デプロイがシームレスでエラーが発生しにくく、高速になり、変更の度にデプロイできるため、フィードバックループが短くなります。

継続的デプロイメントを実現するには以下の要素が必要です:

  • JenkinsやJFrog Pipelinesなどの継続的インテグレーション(CI)で新バージョンの検証/構築を行う。
  • JFrog Artifactoryのようなアーティファクト管理でアーティファクトを保存し、デプロイの対象(サーバー、スマートアプライアンス、コンピュータ)に新しいバージョンを提供する。
  • 新しいアーティファクトを処理して運用可能にするデプロイエージェント(カレントサーバーを停止し、バイナリをダウンロードし、サーバーを起動する)。エージェントには2つのタイプがあります:
    • プル: ターゲット上で実行するエージェント
    • プッシュ: ターゲットをリモートで更新する単一の場所で実行されるエージェント

プルとプッシュモデルには長所と短所がありますが、両方組合わせて使用することもできます。 プルモデルの最も大きな欠点はエージェントがバイナリストアの変更を認識していないため、いつ更新をトリガーするか分からないことです。 プッシュモデルの欠点の一つはセキュリティで、これはターゲットがデプロイメントエージェントの認証を確実にする必要があるためです。

このブログではプッシュ/プル・ソリューションの作成方法をご説明します。Dockerイメージをプッシュして検証後、本番環境へとプロモーションし、最後にJFrog Artifactory webhookを使用して本番サーバーへのデプロイをトリガーする手順を説明します。

Artifactoryのセットアップ

まず、稼働しているArtifactoryサーバーが必要です。お持ちでない場合は無償でクラウドインスタンスを作成することができます

初めにdocker-local-stageとdocker-local-prodの2つのDockerリポジトリを作成します。

新しいリポジトリを作成します:

Creating new Docker repositories

新しいリポジトリ・ウィンドウから:

  1. Dockerを選択
  2. Repository Keyに”docker-local-stage”を入力
  3. “Save and finish”をクリック
  4. “docker-local-prod”も同様に設定

2つの空のリポジトリが作成されたので、webhookの設定を行います。Adminタブ | General | Webhooksに移動し、”New webhook “をクリックします。以下のフォームに記入します:

Create new webhook

ノート: この例ではURLは”http://host.docker.internal:7979/“に設定されています。これはwebhookハンドラーがローカルホストのポート7979で実行されるているためです。ここでの”host.docker.internal”というホスト名はDockerコンテナからホストにアクセスするためです。本番環境ではこの値を本番サーバーのURLとポートに変更する必要があります。

Secret Tokenフィールドには任意の文字列を入力することができ、HTTPヘッダ “X-jfrog-event-auth”で送信されるため、信頼できるソースから来ていることをクエリによって確認することもできます。

Docker tag promoted ecent

Docker tag got promoted”イベントを選択します。ArtifactoryではDockerイメージをプロモーションさせることができます。これはDockerイメージをあるリポジトリから別のリポジトリに移動しますが内容を変更することはありません。これによりステージングでテストされたイメージが本番でデプロイされるイメージであることが保証されます。

Select Repositories”をクリックし、イメージをプロモーションするリポジトリを選択します。また、”Include Patterns”セクションにアーティファクトブラウザでDockerイメージのmanifest.jsonパスと一致するフィルターを追加することもできます。

Edit Repositories window

これでArtifactoryでの設定が完了したので、ハンドラーの構築に移ります。

Webhookハンドラー

webhookハンドラーは本番サーバー上で実行され、イベントのペイロードを含むHTTPリクエストを受け取ります。プロモーションの場合は以下のようになります:

{
    "domain": "docker",
    "event_type": "promoted",
    "data": {
        "image_name": "helloworld",
        "name": "manifest.json",
        "path": "helloworld/latest/manifest.json",
        "platforms": [],
        "repo_key": "docker-local-staging",
        "sha256": "ee34c5c94b4d7d0b319af21a84ebb0bcc16ef01be9a6ee0277329256ecee29b0",
        "size": 949,
        "tag": "latest"
    }
}

webhook ハンドラーは以下の考慮が必要です:

  • HTTPメッセージ・ボディーを解析します。
  • Dockerイメージとリポジトリを検証します。Artifactoryのwebhook設定にフィルターを追加した場合でも、サーバーは常に検証する必要があります。
  • 最新のDockerイメージをプルします。
  • 実行中のコンテナを停止します(コンテナが存在する場合)。
  • 新バージョンを開始します。

これがハンドラーのコアになります。コードのサンプルはこちらのGithubリポジトリにあります。

func main() {
	http.HandleFunc("/", func (w http.ResponseWriter, r *http.Request) {
		ctx := context.Background()
		p, err := readPayload(r)
		if err != nil {
			http.Error(w, err.Error(), http.StatusBadRequest)
			log.Printf("Payload reading error: %+v", err)
			return
		}
		if !isMyServerEvent(p) {
			http.Error(w, "Bad event", http.StatusBadRequest)
			log.Printf("Unexpected event %+v", p)
			return
		}
		cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
		if err != nil {
			log.Printf("New client error: %+v", err)
			return
		}
		err = pullLatestVersion(cli, ctx)
		if err != nil  {
			log.Printf("Pull error: %+v", err)
			return
		}
		err = stopRunningContainer(cli, ctx)
		if err != nil {
			if client.IsErrNotFound(err){
				log.Printf("Container does not exists")
			} else {
				log.Printf("Stop error: %+v", err)
				return
			}
		}
		err = startContainer(cli, ctx)
		if err != nil {
			log.Printf("Start error: %+v", err)
		} else {
            log.Printf("Container updated ")
        }
	})
	http.ListenAndServe(":8081", nil)
}

ここでは以下のライブラリを使用しています:

  • golangビルトインhttpサーバー
  • docker golang SDK

以下のメソッドはイベントのペイロードの内容をチェックしています。メッセージを確認するために、ペイロードのさまざまなフィールドをチェックしています。また、webhookの作成フォームで入力したsecretもチェックしています。ペイロード構造体の定義はこちらのGithubリポジトリにあります。

func isMyServerEvent(r *http.Request, p DockerEventPayload) bool {
	return p.Domain == "docker" &&
		p.EventType == "promoted" &&
		p.Data.ImageName == "helloworld" &&
		p.Data.RepoKey == "docker-local-staging" &&
		p.Data.Tag == "latest" &&
		r.Header.Get("X-JFrog-Event-Auth") == "mysecrets"
}

次に以下の方法でDocker SDKで最新のイメージをプルします。
ノート: ソースコードにユーザーの認証情報をハードコードするのはバッドプラクティスです。このサンプルでは認証方法を示すためにユーザーとパスワードを追加しています。ただし、サーバー環境は”docker login”で設定しておくべきなので、これは必要ないでしょう。

func pullLatestVersion(cli *client.Client, ctx context.Context) error {
	authConfig := types.AuthConfig{
		Username: "admin",
		Password: "password",
	}
	encodedJSON, _ := json.Marshal(authConfig)
	_, err := cli.ImagePull(ctx, imageName, types.ImagePullOptions{RegistryAuth: base64.URLEncoding.EncodeToString(
		encodedJSON)})
	if err != nil {
		log.Printf("Pull error: %+v", err)
		return err
	}
	return nil
}

その後、実行中のサーバーを停止します:

func stopRunningContainer(cli *client.Client, ctx context.Context) error {
	return cli.ContainerRemove(ctx, containerName, types.ContainerRemoveOptions{Force: true})
}

コンテナの作成後に起動します(golang Docker SDKには”docker run”はありません):

func startContainer(cli *client.Client, ctx context.Context) error {
	resp, err := cli.ContainerCreate(ctx,
		&container.Config{
			Image: imageName,
		},
		&container.HostConfig{
			PortBindings: nat.PortMap{
				"8080/tcp": []nat.PortBinding{
					{
						HostIP:   "0.0.0.0",
						HostPort: "8080",
					},
				},
			},
		}, nil, nil, containerName)
	if err != nil {
		return err
	}
	err = cli.ContainerStart(ctx, resp.ID, types.ContainerStartOptions{})
	if err != nil {
		return err
	}
	return nil
}

これでwebhookハンドラーが完成しましたので確認します。

イメージをビルド/プッシュ

以下のシンプルなgolangのwebサーバーをテストに利用します:

package main

import (
    "fmt"
    "net/http"
)

func main() {
    http.HandleFunc("/", func (w http.ResponseWriter, r *http.Request) 
      {
		fmt.Fprintf(w, "Hello World, %s!", r.URL.Path[1:])
	 }
    )
    http.ListenAndServe(":8080", nil)
}

以下のgoコマンドを使って実行します:

go run serve.go

ブラウザで”http://localhost:8080”を指定することで”Hello world”と表示されます。

このアプリのDockerfileは以下の通りです(ほとんどがVSCodeのgolang Dockerfileテンプレートと同様です):

FROM golang:alpine AS builder
RUN apk add --no-cache git
WORKDIR /go/src/app
COPY . .
RUN go install -v ./...

FROM alpine:latest
RUN apk --no-cache add ca-certificates
COPY --from=builder /go/bin/app /app
ENTRYPOINT ./app
LABEL Name=blogpostevent Version=0.0.1
EXPOSE 8080

以下のコマンドでdockerfileをビルドします。これはCIプロセスで自動化されているはずです。

docker build . -t localhost:8082/docker-local-staging/helloworld

JFrog CLIを使用してDockerイメージをArtifactoryにプッシュします。

jfrog rt docker-push localhost:8082/docker-local-staging/helloworld docker-local-staging --url http://localhost:8082/artifactory --user admin --password password

これでQAチームでサーバーをテストしてもらえます。テスト完了後に本番環境にリリースする場合は次のコマンドを実行するだけです:

jfrog rt docker-promote helloworld docker-local-staging docker-local-prod --copy --user admin --password password --url http://localhost:8082/artifactory

このコマンドは以下のプロセスを起動します:

  • ArtifactoryはDockerイメージをdocker-local-prodリポジトリにコピーします。
  • ArtifactoryはHTTPリクエストでWebhookを呼出します。
  • webhookサーバーは最新バージョンをプルします。
  • 実行中のサーバが存在する場合、そのサーバを終了します。
  • 最新の状態でサーバーを起動します。

これで完成です。これで継続的なデプロイメントの設定ができました。

さらに

上記のガイドが継続的デプロイメントの実装とwebhookの利用に役立つことを願っています。追加できる機能はたくさんあります。以下にいくつかのアイデアをご紹介します:

  • CI環境ですべてのDocker、JFrog CLIコマンドを実行します。例えば”#prod”を含むコミットメッセージを使用して、開発者がデプロイできるようにします。
  • リアルなコンテナ・オーケストレーションを利用する。Dockerコマンドを実行するのではなく、KubernetesやDocker swarmあるいはクラウドプロバイダーのSDKを使うことをお勧めします。
  • セキュリティを向上させます。ArtifactoryからのHTTPクエリにカスタムヘッダーを追加することにより、不要なデプロイメントがトリガーされないようにします。
  • “docker push”イベント用のwebhookを作成し、ステージング・デプロイを自動化します。
  • クラウドのFaaSまたはPaaSにエージェントを導入する
  • 2つのプロモーションイベントが同時に発生した場合に集約することで、コンテナの複数の並列デプロイメントを防止します。

最後に

このブログではArtifactoryのwebhookと完全に自動化されたワークフローを利用し、CI/CDプロセスの最後の1マイルを達成する方法をご紹介しました。ぜひご自身でお試し頂き、フィードバックを頂ければと思います。