JavaでUnix Domain Socketを使ってみる(Dockerのコンテナ一覧を取得する)

はじめに

Java 16でUnix Domain Socketのサポートが入っています(JEP 380)。前から興味はありつつもかなか触れてなかったので今回少し触ってみようかと思います。

お題として、以下の2つをやってみようと思います。

  • Echoサーバとそのクライアントを書く
  • JavaのプログラムからDockerを Unix Domain Socketを使って操作してみたいと思います。

Unix Domain Socket?

Unix Domain Socketは単一マシンで複数プロセスが、効率の良い通信を行なうためのソケットインターフェースです。 TCP/IPソケットと似たようなインターフェースですが、インターネットプロトコルの代わりにファイルシステムを利用しています。
以下のような特徴があるようです。

  • 同一マシン内で厳密にコミュニケーションを行い、リモートでの通信が必要ないためよりセキュアに通信できる
  • TCP/IPよりもセットアップが速い。スループットが高い

少し前にはなりますが、WindowsでもUnix Domain Socketサポートが入ったみたいです。

JavaUnix Domain Socketを使ってみる

環境

$ uname -srvmpio
Linux 5.4.0-89-generic #100-Ubuntu SMP Fri Sep 24 14:50:10 UTC 2021 x86_64 x86_64 x86_64 GNU/Linux

$ lsb_release -a
LSB Version:    core-11.1.0ubuntu2-noarch:security-11.1.0ubuntu2-noarch
Distributor ID: Ubuntu
Description:    Ubuntu 20.04.3 LTS
Release:    20.04
Codename:   focal

$ java --version
openjdk 17 2021-09-14
OpenJDK Runtime Environment (build 17+35-2724)
OpenJDK 64-Bit Server VM (build 17+35-2724, mixed mode, sharing)

Echoサーバーを書く

文字列を受け取って、その文字列に"Sent words are:"と言うプリフィクスを付けて返してプロセスを終了するシンプルなEchoサーバとそのクライアントを書いてみます。

ここを参考にコードを書いていきます。
基本的にはTCP/IPのソケットを開くときと似たような感じで利用できるみたいです。

サーバー側は以下のような感じ

import java.io.IOException;
import java.net.StandardProtocolFamily;
import java.net.UnixDomainSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;

public class UnixDomainSocketServer {

    public static void main(String[] args) {
        Path path = Path.of("/tmp", ".unixserver");
        UnixDomainSocketAddress socketAddress = UnixDomainSocketAddress.of(path);

        try (ServerSocketChannel server = ServerSocketChannel.open(StandardProtocolFamily.UNIX)) {
            Files.deleteIfExists(path);
            server.bind(socketAddress);
            ByteBuffer buf = ByteBuffer.allocate(1024);

            try (SocketChannel channel = server.accept()) {
                channel.read(buf);
                buf.flip();

                String input = StandardCharsets.UTF_8.decode(buf).toString();
                System.out.println("input = " + input);
                String response = "Sent words are: %s".formatted(input);
                channel.write(ByteBuffer.wrap(response.getBytes(StandardCharsets.UTF_8)));
            } catch (IOException e) {
                e.printStackTrace();
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

UnixDomainSocketAddressを使ってファイルシステムのアドレスをバインドすることできます。
また、Unix Domain Socketを利用するときはSocketを開く際にプロトコルファミリー(StandardProtocolFamily.UNIX))を指定してやる必要があるようです。

続いてクライアント側は以下のような感じです。

import java.io.IOException;
import java.net.StandardProtocolFamily;
import java.net.UnixDomainSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SocketChannel;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;

public class UnixDomainSocketClient {

    public static void main(String[] args) {
        Path path = Path.of("/tmp", ".unixserver");
        UnixDomainSocketAddress socketAddress = UnixDomainSocketAddress.of(path);

        try (final SocketChannel channel = SocketChannel.open(StandardProtocolFamily.UNIX)) {
            channel.connect(socketAddress);

            final String input = "Hello, UNIX Domain Socket";
            System.out.println("input = " + input);
            channel.write(ByteBuffer.wrap(input.getBytes(StandardCharsets.UTF_8)));

            ByteBuffer buf = ByteBuffer.allocate(1024);
            channel.read(buf);
            buf.flip();
            System.out.println("response = " + StandardCharsets.UTF_8.decode(buf));
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

サーバと同様にUnixDomainSocketAddressを使ってサーバに繋ぐができます。

これらをサーバ→クライアントの順で実行すると、それぞれで以下のような出力を得られます。
サーバ側

input = Hello, UNIX Domain Socket

クライアント側

input = Hello, UNIX Domain Socket
response = Sent words are: Hello, UNIX Domain Socket

Dockerのコンテナ一覧を取得する

Docker エンジンはUnix Domain Socketを利用して、cURLなどで操作することができます。 各バージョンのAPIドキュメンテーションこちらから確認できます。
少しDockerのバージョンが低いですがcURLでDockerエンジンを操作するブログも過去に書いているのでよかったら読んでみてください。

今回は、コンテナの一覧を取得するJavaのコードを書いてみます。

import java.io.IOException;
import java.net.StandardProtocolFamily;
import java.net.UnixDomainSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SocketChannel;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;

public class DockerClient {
    public static void main(String[] args) {
        Path path = Path.of("/var/run", "docker.sock");
        UnixDomainSocketAddress socketAddress = UnixDomainSocketAddress.of(path);

        final String request = """
                GET /containers/json HTTP/1.1
                Host: localhost
                User-Agent: curl/7.68.0
                Accept: */*
                
                """;

        try (SocketChannel channel = SocketChannel.open(StandardProtocolFamily.UNIX)) {
            channel.connect(socketAddress);

            channel.write(ByteBuffer.wrap(request.getBytes(StandardCharsets.UTF_8)));

            ByteBuffer buf = ByteBuffer.allocate(1024);
            channel.read(buf);
            buf.flip();

            System.out.println("============= response ================");
            System.out.println(StandardCharsets.UTF_8.decode(buf));

        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

さほど複雑なことはしておらず、先程のクライアントのコードとほぼ同じです。ただHTTPのリクエスト形式で動いているコンテナの一覧を取得するリクエストを送っています。
cURLのリクエストをパクったので、User-Agentがcurlになってるのはご愛嬌ということで。。。。) このプログラムをコンテナが1つも動いていない状態で実行すると以下のような出力になります。

============= response ================
HTTP/1.1 200 OK
Api-Version: 1.41
Content-Type: application/json
Docker-Experimental: true
Ostype: linux
Server: Docker/20.10.9 (linux)
Date: Sat, 23 Oct 2021 06:03:09 GMT
Content-Length: 3

[]

今度は適当なコンテナを立ち上げて再度コードを実行してみます。

$  docker run --name tmp-nginx-container -d nginx
c1b1f0d91f368fcbe13113160dd06c1a755dc662e1d3ddfe82239e0775a3c6a1

$ docker ps
CONTAINER ID   IMAGE     COMMAND                  CREATED         STATUS         PORTS     NAMES
c1b1f0d91f36   nginx     "/docker-entrypoint.…"   4 seconds ago   Up 3 seconds   80/tcp    tmp-nginx-container

コードを実行すると今度は以下のような出力が得られ、実行されているコンテナの情報を取得が行えているのがわかります。

============= response ================
HTTP/1.1 200 OK
Api-Version: 1.41
Content-Type: application/json
Docker-Experimental: true
Ostype: linux
Server: Docker/20.10.9 (linux)
Date: Sat, 23 Oct 2021 06:07:44 GMT
Content-Length: 932

[{"Id":"c1b1f0d91f368fcbe13113160dd06c1a755dc662e1d3ddfe82239e0775a3c6a1","Names":["/tmp-nginx-container"],"Image":"nginx","ImageID":"sha256:08b152afcfae220e9709f00767054b824361c742ea03a9fe936271ba520a0a4b","Command":"/docker-entrypoint.sh nginx -g 'daemon off;'","Created":1634969175,"Ports":[{"PrivatePort":80,"Type":"tcp"}],"Labels":{"maintainer":"NGINX Docker Maintainers <docker-maint@nginx.com>"},"State":"running","Status":"Up About a minute","HostConfig":{"NetworkMode":"default"},"NetworkSettings":{"Networks":{"bridge":{"IPAMConfig":null,"Links":null,"Aliases":null,"NetworkID":"1ad0f3f401ba159560bd54403e670da4fd92e07b24f798cb2a97acc5b88e67d5","EndpointID":"f201ce886f9160d417fb4d9141253d591b54b9e2929884638321d057026f0436","Gateway":"172.17.0.1","IPAddress":"172.17.0.2","IPPrefixLen":16,"IPv6Gateway":"","Glob

Echoのロガーを使う

はじめに

GoのフレームワークEchoを使うことが最近は多いのですが、そのログ周りの機能を試してみようかと思います。

Echoのロガー

Echoにはロガーに関するサポートが以下の2つあります。

このブログでは2つ両方とも試してみたいと思います。
また、リクエストにIDを割与えるRequest ID Middlewareも存在しておりこちらもLoggerに少し関わる機能を持っているので試してみようと思います。

動かす

環境

今回の動作環境は以下のような感じです。

$ go version
go version go1.17.1 linux/amd64

$ uname -srvmpio
Linux 5.4.0-88-generic #99-Ubuntu SMP Thu Sep 23 17:29:00 UTC 2021 x86_64 x86_64 x86_64 GNU/Linux

$ lsb_release -a
LSB Version:    core-11.1.0ubuntu2-noarch:security-11.1.0ubuntu2-noarch
Distributor ID: Ubuntu
Description:    Ubuntu 20.04.3 LTS
Release:    20.04
Codename:   focal

このLogger MiddlewareはLoggerWithConfigでカスタマイズすることができます。
例えば以下のようにすると任意のフォーマットでよりシンプルなロガーを出力することができます。

server go

func main() {
    e := echo.New()

    // 削除
    //e.Use(middleware.Logger())

    // 追加
    e.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{
        Format: "time=${time_rfc3339_nano}, method=${method}, uri=${uri}, status=${status}\n",
    }))

    e.GET("/", func(c echo.Context) error {
        return c.String(http.StatusOK, "Hello, World!")
    })
    e.Logger.Fatal(e.Start(":1323"))
}

出力されるログは次の通りになります。

time=2021-10-15T08:56:03.378067938+09:00, method=GET, uri=/, status=200

サンプルアプリの作成

ベースとなるプロジェクトとサンプルアプリを作成します。
公式にあるやり方をさっくりとなぞります。

$ mkdir echo-logger && cd echo-logger

$ go mod init echo-logger
go: creating new go.mod: module echo-logger

$ go get github.com/labstack/echo/v4
go get: added github.com/labstack/echo/v4 v4.6.1
go get: added github.com/labstack/gommon v0.3.0
go get: added github.com/mattn/go-colorable v0.1.8
go get: added github.com/mattn/go-isatty v0.0.14
go get: added github.com/valyala/bytebufferpool v1.0.0
go get: added github.com/valyala/fasttemplate v1.2.1
go get: added golang.org/x/crypto v0.0.0-20210817164053-32db794688a5
go get: added golang.org/x/net v0.0.0-20210913180222-943fd674d43e
go get: added golang.org/x/sys v0.0.0-20210910150752-751e447fb3d0
go get: added golang.org/x/text v0.3.7

次に以下のファイルを作成してEchoのインスタンスを作りハンドラーを登録します。

server.go

package main

import (
    "net/http"
 
    "github.com/labstack/echo/v4"
)

func main() {
    e := echo.New()
    e.GET("/", func(c echo.Context) error {
        return c.String(http.StatusOK, "Hello, World!")
    })
    e.Logger.Fatal(e.Start(":1323"))
}

アプリを起動します。

$ go run server.go 

   ____    __
  / __/___/ /  ___
 / _// __/ _ \/ _ \
/___/\__/_//_/\___/ v4.6.1
High performance, minimalist Go web framework
https://echo.labstack.com
____________________________________O/_______
                                    O\
⇨ http server started on [::]:1323


$ curl localhost:1323
Hello, World!

これで下準備は完了です。

Logger Middlewareを使う

前述の通り、Logger Middlewareはアクセスログを出力してくれます。
使い方は超簡単で、以下のように

server.go

package main

import (
    "github.com/labstack/echo/v4"
    "github.com/labstack/echo/v4/middleware"
    "net/http"
)

func main() {
    e := echo.New()

    // 追加
    e.Use(middleware.Logger())

    e.GET("/", func(c echo.Context) error {
        return c.String(http.StatusOK, "Hello, World!")
    })
    e.Logger.Fatal(e.Start(":1323"))
}

このままでは必要な依存がまだ入っていないので、以下のコマンドを実行します。

$ go get -t .

サーバを再起動し、先ほどと同じようにcURLでアクセスすると今度はアクセスログが出力されることが確認できます。

$ go run server.go 

   ____    __
  / __/___/ /  ___
 / _// __/ _ \/ _ \
/___/\__/_//_/\___/ v4.6.1
High performance, minimalist Go web framework
https://echo.labstack.com
____________________________________O/_______
                                    O\
⇨ http server started on [::]:1323
{"time":"2021-10-15T08:44:10.73485721+09:00","id":"","remote_ip":"127.0.0.1","host":"localhost:1323","method":"GET","uri":"/","user_agent":"curl/7.68.0","status":200,"error":"","latency":7637,"latency_human":"7.637µs","bytes_in":0,"bytes_out":13}

デフォルトのログの設定は以下のようになっているみたいです。

DefaultLoggerConfig = LoggerConfig{
  Skipper: DefaultSkipper,
  Format: `{"time":"${time_rfc3339_nano}","id":"${id}","remote_ip":"${remote_ip}",` +
    `"host":"${host}","method":"${method}","uri":"${uri}","user_agent":"${user_agent}",` +
    `"status":${status},"error":"${error}","latency":${latency},"latency_human":"${latency_human}"` +
    `,"bytes_in":${bytes_in},"bytes_out":${bytes_out}}` + "\n",
  CustomTimeFormat: "2006-01-02 15:04:05.00000",
}

Request ID Middlewareを使う

Request ID Middlewareを使えばRequestに対してユニークなIDを割り振ることができます。
このミドルウェアはクライアント側がX-Request-IDのリクエストヘッダーを付けている場合そのIDを利用し、ない場合はデフォルトでrandom.String(32)を利用してIDを割り振ります。
本来は分散トレーシングがメインな理由で用いられるものかと推測していますが、このミドルウェアを利用しているとLogger MiddlewareでもRequest IDが出力れるようになりログを追うときに何かと便利です。

こちらも使い方は簡単で、以下のようにミドルウェアを追加します。

server.go

package main

import (
    "github.com/labstack/echo/v4"
    "github.com/labstack/echo/v4/middleware"
    "github.com/labstack/gommon/log"
    "net/http"
)

func main() {
    e := echo.New()

    // 追加
    e.Use(middleware.RequestID())

    e.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{
        Format: "id=${id}, time=${time_rfc3339_nano}, method=${method}, uri=${uri}, status=${status}\n",
    }))

    e.Logger.SetLevel(log.INFO)
    e.Logger.Info("hello")

    e.GET("/", func(c echo.Context) error {
        return c.String(http.StatusOK, "Hello, World!")
    })

    e.Logger.Fatal(e.Start(":1323"))
}

サーバを再起動し、先ほどと同じようにcURLでアクセスすると今度はアクセスログにIDが含まれていることが確認できます。

# Request ID Middlewareを使っていない時
{"time":"2021-10-15T08:44:10.73485721+09:00","id":"","remote_ip":"127.0.0.1","host":"localhost:1323","method":"GET","uri":"/","user_agent":"curl/7.68.0","status":200,"error":"","latency":7637,"latency_human":"7.637µs","bytes_in":0,"bytes_out":13}

# Request ID Middlewareを使った時
{"time":"2021-10-15T09:09:08.795038871+09:00","id":"DIO17Vgnl1NN9sxnoZPDJNXiSf4o4p53","remote_ip":"127.0.0.1","host":"localhost:1323","method":"GET","uri":"/","user_agent":"curl/7.68.0","status":200,"error":"","latency":78319,"latency_human":"78.319µs","bytes_in":0,"bytes_out":13}

idの項目に値が入っているのを確認できます。
今度はX-Request-IDを付与して、リクエストを送ってみます。

$ curl -H "X-Request-ID: 12345" localhost:1323
Hello, World!

するとログ出力は以下のようになります。

{"time":"2021-10-15T09:26:56.894261759+09:00","id":"12345","remote_ip":"127.0.0.1","host":"localhost:1323","method":"GET","uri":"/","user_agent":"curl/7.68.0","status":200,"error":"","latency":43280,"latency_human":"43.28µs","bytes_in":0,"bytes_out":13}

X-Request-IDの値が入ってますね。

Loggerを使う

最後にEchoのLoggerを使ってみます。
これはEchoのインスタンスに生えています。
具体的には、以下のようにします。

package main

import (
    "github.com/labstack/echo/v4"
    "github.com/labstack/echo/v4/middleware"
    "github.com/labstack/gommon/log"
    "net/http"
)

func main() {
    e := echo.New()

    e.Use(middleware.RequestID())
    e.Use(middleware.Logger())

    // 追加
    if l, ok := e.Logger.(*log.Logger); ok {
        l.SetLevel(log.INFO)
    }

    e.GET("/", func(c echo.Context) error {
        // 追加
        e.Logger.Info("hello func is called")
        return c.String(http.StatusOK, "Hello, World!")
    })

    e.Logger.Fatal(e.Start(":1323"))
}

ポイントは、e.Logger.SetLevel(log.INFO)のところで、EchoのロガーはデフォルトでログレベルがERRORになるのでそれ以上のレベルのエラーを出力したい場合は明示的に設定してやる必要があります。

アプリを再起動してcURLを叩くと以下のようなログが出力されているのが確認できます。

{"time":"2021-10-15T12:26:21.21687368+09:00","level":"INFO","prefix":"echo","file":"server.go","line":"20","message":"hello func is called"}

この辺まだ理解が甘いところではあるのですが、おそらくレイヤリングの設計をしているアプリケーション内で使っていくにはEchoのコンテキストを引き回していくか、DIして使うのが基本的な感じになるかなと想像しています。(もっとよいやり方があればコメントとかで教えてくださると嬉しいです)

このロガーのフォーマットを変えたい場合はEcho#Logger.SetHeader(string)で行なうことができます。
例えば以下のように利用します。

func main() {
    e := echo.New()

    e.Use(middleware.RequestID())
    e.Use(middleware.Logger())

    if l, ok := e.Logger.(*log.Logger); ok {
        // 追加
        l.SetHeader("${time_rfc3339} ${level}")
        l.SetLevel(log.INFO)
    }


    e.GET("/", func(c echo.Context) error {
        // 追加
        e.Logger.Info("hello func is called")
        return c.String(http.StatusOK, "Hello, World!")
    })

    e.Logger.Fatal(e.Start(":1323"))
}

アプリを再起動してcURLを叩くと以下のようなログが出力されているのが確認できます。

2021-10-15T12:32:48+09:00 INFO hello func is called

ここで、利用できるタグが以下のものがあるみたいです。

  • time_rfc3339
  • time_rfc3339_nano
  • level
  • prefix
  • long_file
  • short_file
  • line

1つ1つは名前の通りなので説明はしませんが、大雑把な使い方は全体を通してこんな感じになってるみたいです。

SkaffoldのPipeline Stagesについてまとめる

はじめに

自分が所属する組織ではSkaffoldを開発ワークフローのツールとして導入しているのですが、しっかり使っていく上で動作の基本をきちんと抑えておこうかと。
今まででは、JavaのアプリをデプロイしてデバッグしてみたりHelmを使って環境変数の切り替えをやってみたりやりたいことベースで学んでいたんですが、ツールの動作に関する全体感を抑えておこうかと思いました。 基本はSkaffold Pipeline Stagesに書かれる内容を自分が気になったところに関してまとめたり、動かしたりして深堀する感じで行なう感じでやろうかと思います。
より正確な情報は本家の方を参照するようにお願いします。
特定の文字列を返すだけのサンプルアプリを1つ作って、このアプリにSkaffoldを適用してそれぞれのStageで提供される機能を使ってみます。
動かすサンプルアプリはGoでEchoを使って作ろうかと思います。

Skaffold Pipeline Stagesについて

Skaffoldには以下のようなパイプラインのステージがあります。

Stage名 説明
Init Skaffoldプロジェクトの作成
Build イメージのビルド。様々なビルダーを利用することが可能
Tag ビルドしたイメージのタグ付け。様々なポリシーでタグ付けを行なうことが可能
Test テスターを用いたテストの実行する
Deploy アプリをk8sクラスターにデプロイする。kubectl、Helm、kustomize等を用いることが可能
File Sync ローカルのファイルを直接コンテナにシンクする
Log Tailing デプロイしたアプリやワークフローのログをテールする
Port Forwarding Serviceや任意のリソースをlocalhostへポートフォワードする
Lifecycle Hooks Skaffoldの実行ライフサイクルの中のざまざまなイベントでコードをHookする
Clean Up リソースやイメージのクリーンアップ

Skaffoldのコマンドによって実行するステージが違っており、スキップ可能なものもあります。

各ステージの機能を少しほってみる(ものによっては動かしてみる)

動作環境

今回クラスターはminikube(docker driver)を用いて作成します。

作ったクラスターやその他のバージョンは以下の通り

$ uname -srvmpio
Linux 5.4.0-88-generic #99-Ubuntu SMP Thu Sep 23 17:29:00 UTC 2021 x86_64 x86_64 x86_64 GNU/Linux

$ lsb_release -a
LSB Version:    core-11.1.0ubuntu2-noarch:security-11.1.0ubuntu2-noarch
Distributor ID: Ubuntu
Description:    Ubuntu 20.04.3 LTS
Release:    20.04
Codename:   focal


$ minikube version
minikube version: v1.23.2
commit: 0a0ad764652082477c00d51d2475284b5d39ceed

$ kubectl version -o yaml
clientVersion:
  buildDate: "2021-09-15T21:38:50Z"
  compiler: gc
  gitCommit: 8b5a19147530eaac9476b0ab82980b4088bbc1b2
  gitTreeState: clean
  gitVersion: v1.22.2
  goVersion: go1.16.8
  major: "1"
  minor: "22"
  platform: linux/amd64
serverVersion:
  buildDate: "2021-09-15T21:32:41Z"
  compiler: gc
  gitCommit: 8b5a19147530eaac9476b0ab82980b4088bbc1b2
  gitTreeState: clean
  gitVersion: v1.22.2
  goVersion: go1.16.8
  major: "1"
  minor: "22"
  platform: linux/amd64

$ docker version
Client: Docker Engine - Community
 Version:           20.10.8
 API version:       1.41
 Go version:        go1.16.6
 Git commit:        3967b7d
 Built:             Fri Jul 30 19:54:27 2021
 OS/Arch:           linux/amd64
 Context:           default
 Experimental:      true

Server: Docker Engine - Community
 Engine:
  Version:          20.10.8
  API version:      1.41 (minimum version 1.12)
  Go version:       go1.16.6
  Git commit:       75249d8
  Built:            Fri Jul 30 19:52:33 2021
  OS/Arch:          linux/amd64
  Experimental:     true
 containerd:
  Version:          1.4.10
  GitCommit:        8848fdb7c4ae3815afcc990a8a99d663dda1b590
 runc:
  Version:          1.0.2
  GitCommit:        v1.0.2-0-g52b36a2
 docker-init:
  Version:          0.19.0
  GitCommit:        de40ad0


$ go version
go version go1.17.1 linux/amd64

また、Skaffoldのバージョンは以下のものを用います。

$ skaffold version
v1.33.0

Skaffoldのインストールに関してはこちらをご確認ください。

サンプルアプリとDockerfileを用意する

以下のコマンドを実行してサンプルアプリを作成します。

$ mkdir skaffold-demo && cd skaffold-demo

$ go mod init skaffold-demo

$ go get github.com/labstack/echo/v4
go: downloading github.com/labstack/echo/v4 v4.6.1
go get: added github.com/labstack/echo/v4 v4.6.1
go get: added github.com/labstack/gommon v0.3.0
go get: added github.com/mattn/go-colorable v0.1.8
go get: added github.com/mattn/go-isatty v0.0.14
go get: added github.com/valyala/bytebufferpool v1.0.0
go get: added github.com/valyala/fasttemplate v1.2.1
go get: added golang.org/x/crypto v0.0.0-20210817164053-32db794688a5
go get: added golang.org/x/net v0.0.0-20210913180222-943fd674d43e
go get: added golang.org/x/sys v0.0.0-20210910150752-751e447fb3d0
go get: added golang.org/x/text v0.3.7

以下のように文字列を返すだけのサーバを記述します。

server.go

package main

import (
    "net/http"
 
    "github.com/labstack/echo/v4"
)

func main() {
    e := echo.New()
    e.GET("/hello", func(c echo.Context) error {
        return c.String(http.StatusOK, "Hello, World!")
    })
    e.Logger.Fatal(e.Start(":1323"))
}

アプリを起動します。

$ go run server.go 

   ____    __
  / __/___/ /  ___
 / _// __/ _ \/ _ \
/___/\__/_//_/\___/ v4.6.1
High performance, minimalist Go web framework
https://echo.labstack.com
____________________________________O/_______
                                    O\
⇨ http server started on [::]:1323


$ curl localhost:1323/hello
Hello, World!

次にアプリのイメージをビルドするDockerfileを作成します。

FROM golang:1.17 as buildImage

ENV CGO_ENABLED=0
ENV GOOS=linux
ENV GOARCH=amd64

WORKDIR /usr/src
COPY . .
RUN go build -o skaffold-demo-app server.go

FROM alpine:3.14.2

COPY --from=buildImage /usr/src/skaffold-demo-app /

ENTRYPOINT ["/skaffold-demo-app"]

イメージをビルドして起動してみます。
(docker buildx installコマンドでBuildxを有効化してます)

$ docker build -t skaffold-demo-app .
[+] Building 18.9s (14/14) FINISHED                                                                                                                                                                        
 => [internal] load build definition from Dockerfile                                                                                                                                                  0.0s
 => => transferring dockerfile: 300B                                                                                                                                                                  0.0s
 => [internal] load .dockerignore                                                                                                                                                                     0.0s
 => => transferring context: 2B                                                                                                                                                                       0.0s
 => [internal] load metadata for docker.io/library/alpine:3.14.2                                                                                                                                      2.2s
 => [internal] load metadata for docker.io/library/golang:1.17                                                                                                                                        2.2s
 => [auth] library/golang:pull token for registry-1.docker.io                                                                                                                                         0.0s
 => [auth] library/alpine:pull token for registry-1.docker.io                                                                                                                                         0.0s
 => [buildimage 1/4] FROM docker.io/library/golang:1.17@sha256:45d45a39258425b0386643efc863b3b3c1481173d64ec6151b18d48b565df9a0                                                                       0.0s
 => CACHED [stage-1 1/2] FROM docker.io/library/alpine:3.14.2@sha256:e1c082e3d3c45cccac829840a25941e679c25d438cc8412c2fa221cf1a824e6a                                                                 0.0s
 => [internal] load build context                                                                                                                                                                     0.1s
 => => transferring context: 6.86MB                                                                                                                                                                   0.1s
 => CACHED [buildimage 2/4] WORKDIR /usr/src                                                                                                                                                          0.0s
 => [buildimage 3/4] COPY . .                                                                                                                                                                         0.0s
 => [buildimage 4/4] RUN go build -o skaffold-demo-app server.go                                                                                                                                     16.4s
 => [stage-1 2/2] COPY --from=buildImage /usr/src/skaffold-demo-app /                                                                                                                                 0.0s
 => exporting to image                                                                                                                                                                                0.0s
 => => exporting layers                                                                                                                                                                               0.0s
 => => writing image sha256:ecb267c14c4664a488afe8586e64c5bf6b80b1715b6a86e4e9efb626b6d3a279                                                                                                          0.0s 
 => => naming to docker.io/library/skaffold-demo-app   

$ docker run -d -p 1323:1323 skaffold-demo-app  
1fd8dc30d0e0e19ec439ebacc5a411427ab42d06dab5aa4efd93fd86a969fbc0

$ curl localhost:1323/hello
Hello, World!

下準備はこれで完了です。

各ステージをみていく

Init

先ずはInit ステージからみていきます。
前述の通りこのステージではSkaffoldプロジェクトの作成を行います。
Initステージはskaffold initコマンドで実行します。
skaffold initコマンドで初期設定可能なものとしては、以下のようなビルダーをサポートします。

また、skaffold initではプロジェクトのディレクトリ内を走査して、以下のようなファイルからビルドの設定を読み込みます。

  • Dockerfile
  • build.gradle/pom.xml
  • package.json
  • requirements.txt
  • go.mod
  • init

この際、500MB以上のファイルは無視されるようです。
例えば今回作成下アプリの場合Dockerfilego.modがあるので以下のように2つの選択肢を提示してくれるようになります。

$ skaffold init
? Which builders would you like to create kubernetes resources for?  [Use arrows to move, space to select, <right> to all, <left> to none, type to filter]
> [ ]  Buildpacks (go.mod)
  [ ]  Docker (Dockerfile)

ここではDockerを選びます方向キーで上下の移動をし、スペースキーで選択します。
プロジェクト作成の際プロジェクト内にk8sのリソースファイルがない場合は以下のようなエラーが出力されます。

$ skaffold init
? Which builders would you like to create kubernetes resources for? Docker (Dockerfile)
one or more valid Kubernetes manifests are required to run skaffold

skaffold initを行なう場合先にマニフェストを容易しておくか、--generate-manifestsフラグを指定してマニフェストの自動生成を行なう必要があります。
今回は自動生成をしてもらうので以下のようにコマンドを実行します。

$ skaffold init --generate-manifests 
? Which builders would you like to create kubernetes resources for? Docker (Dockerfile)
? Select port to forward for dockerfile-image (leave blank for none): 1323
apiVersion: skaffold/v2beta22
kind: Config
metadata:
  name: skaffold-demo
build:
  artifacts:
  - image: dockerfile-image
    docker:
      dockerfile: Dockerfile
deploy:
  kubectl:
    manifests:
    - deployment.yaml
portForward:
- resourceType: service
  resourceName: dockerfile-image
  port: 1323

deployment.yaml - apiVersion: v1
kind: Service
metadata:
  name: dockerfile-image
  labels:
    app: dockerfile-image
spec:
  ports:
  - port: 1323
    protocol: TCP
  clusterIP: None
  selector:
    app: dockerfile-image
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: dockerfile-image
  labels:
    app: dockerfile-image
spec:
  replicas: 1
  selector:
    matchLabels:
      app: dockerfile-image
  template:
    metadata:
      labels:
        app: dockerfile-image
    spec:
      containers:
      - name: dockerfile-image
        image: dockerfile-image

? Do you want to write this configuration, along with the generated k8s manifests, to skaffold.yaml? Yes
Generated manifest deployment.yaml was written
Configuration skaffold.yaml was written
You can now run [skaffold build] to build the artifacts
or [skaffold run] to build and deploy
or [skaffold dev] to enter development mode, with auto-redeploy

実行が成功すると以下のようにSkaffoldの設定ファイルのskaffold.yamlk8sのリソース定義ファイルであるdeployment.yamlが生成されているのが確認できます。

$ ls
Dockerfile  deployment.yaml  go.mod  go.sum  server.go  skaffold-demo-app  skaffold.yaml

前のログでも出力されていますが、できたskaffold.yamlは以下のようになっています。

apiVersion: skaffold/v2beta22
kind: Config
metadata:
  name: skaffold-demo
build:
  artifacts:
  - image: dockerfile-image
    docker:
      dockerfile: Dockerfile
deploy:
  kubectl:
    manifests:
    - deployment.yaml
portForward:
- resourceType: service
  resourceName: dockerfile-image
  port: 1323

今回はStageをざっくり把握することが目的なので詳しくはみませんが skaffold.yamlに関してはこちらを参照ください。
これでプロジェクトのinitは完了です。

Build

Buildステージではイメージのビルドを行います。
このステージはskaffold buildskaffold runskaffold devなどで実行されるようです。
Buildステージでは以下のようなビルドの方法があるみたいです。

各ビルド手法でのビルダーサポートはここをご確認ください。
ローカルでは基本的にどのようなビルダーもサポートしているようです。

Buildに関する設定はskaffold.yamlbuildセクションで行います。

skaffold buildでコマンドでビルドしてみます。
設定的にはローカルのDockerが利用されビルドされるようです。
ちなみにBuild時のBuildKitの有効無効は設定で明示的にOnにしない限りはローカルのDockerの設定に依存するようです。

$ skaffold build

$ docker images | grep dockerfile-image
dockerfile-image              83b1cdbd0c82d697b527964e54a126ebb6b65bf690d1a40fe8b00f313ba380f8   83b1cdbd0c82   55 seconds ago   12.4MB
dockerfile-image              latest                                                             83b1cdbd0c82   55 seconds ago   12.4MB

Tag

ビルドしたイメージのタグ付けを行います。
この際以下のようなポリシーでタグ付けを行えるようです。

  • gitCommit : Gitのcommit referencesを用いたタグ付け
  • inputDigest : ソースファイルのダイジェスト情報を用いたタグ付け
  • envTemplate : 環境変数を用いたタグ付け
  • datetime : ビルド時の日付を用いたタグ付け。設定変更可能
  • customTemplate : 複数のタガーとテンプレートを用いたタグ付け

skaffold.yamlに特に指定がない場合はgitCommitが用いられるようです。
また、コマンド実行時に--tagを指定するとタグの上書きが可能になるようです。

今回はcustomTemplateiinputDigestdatetimeを用いてタグ付けをしてみます。 skaffold.yamlファイルを以下のように書き換えます。

apiVersion: skaffold/v2beta22
kind: Config
metadata:
  name: skaffold-demo
build:
  tagPolicy:
    customTemplate:
      template: "{{.FOO}}_{{.BAR}}"
      components:
      - name: FOO
        dateTime:
          format: "2006-01-02"
          timezone: "UTC"
      - name: BAR
        inputDigest: {}
  artifacts:
  - image: dockerfile-image
    docker:
      dockerfile: Dockerfile
deploy:
  kubectl:
    manifests:
    - deployment.yaml
portForward:
- resourceType: service
  resourceName: dockerfile-image
  port: 1323

customTemplateを用いる場合templateセクションでテンプレートを定義しcomponentsセクションでそれぞれのタガーのコンポーネントを定義するようです。
この設定でBuildを実行するとタグが先程と違い日付とダイジェストを指定したテンプレートに当てはめて作られていることが確認できます。

$ skaffold build
(省略)

$ docker images | grep dockerfile-image
dockerfile-image              2021-10-09_a0660834a25ae146d26458a6b6247f101a093ec6fde33e3955fe488031ee13ac   83b1cdbd0c82   28 minutes ago   12.4MB
dockerfile-image              83b1cdbd0c82d697b527964e54a126ebb6b65bf690d1a40fe8b00f313ba380f8              83b1cdbd0c82   28 minutes ago   12.4MB
dockerfile-image              latest                                                                        83b1cdbd0c82   28 minutes ago   12.4MB           

Test

Skaffoldはパイプラインの中でテストを実行することも可能です。
skaffold testskaffold runskaffold devなどのコマンドでテストが実行されます。

現状では以下の2つのタイプのテストをサポートしています。

  • Custom Test : Skaffoldのパイプラインの中でテストフェーズとしてカスタムコマンドの実行をおこなう。ユニットテストやセキュリティスキャンの実行が可能
  • Container Structure Test : ビルドされたコンテナをデプロイ前にバリデーションする。

ここではCustom Testの方を使ってGoのユニットテストの実行を試してみたいと思います。
まずは以下のようなテストを実行するスクリプトを用意します。

#!/bin/bash

set -e

echo "go custom test $@"

go test .

次に適当に失敗するテストを用意しておきます。

server_test.go

package main

import "testing"

func TestServer(t *testing.T) {
    t.FailNow()
}

このテストをSkaffoldのTestステージで実行するようにします。
skaffold.yamlを以下のように書き換えます。

apiVersion: skaffold/v2beta22
kind: Config
metadata:
  name: skaffold-demo
build:
  tagPolicy:
    customTemplate:
      template: "{{.FOO}}_{{.BAR}}"
      components:
      - name: FOO
        dateTime:
          format: "2006-01-02"
          timezone: "UTC"
      - name: BAR
        inputDigest: {}
  artifacts:
  - image: dockerfile-image
    docker:
      dockerfile: Dockerfile
test:
  - image: dockerfile-image
    custom:
      - command: ./test.sh
        timeoutSeconds: 60
        dependencies:
          paths:
          -  "*_test.go"
          -  "test.sh"
deploy:
  kubectl:
    manifests:
    - deployment.yaml
portForward:
- resourceType: service
  resourceName: dockerfile-image
  port: 1323

testセクションを追加しています。
customセクションに諸々の設定を書いてます。
大体はセクション名の通りですが、dependenciesセクションにはファイル変更を検知してテストを再実行するための設定を記述しています。

skaffold devコマンドでテストを実行してみます。
(skaffold testコマンドを用いる場合はビルド時にskaffold build --file-output tags.jsonのようにしてアーティファクト情報を出力しテスト時にskaffold test --build-artifacts tags.jsonの用に指定してやる必要があります)

$ skaffold dev

(省略)

Successfully built a68e7ef37e4e
Successfully tagged dockerfile-image:2021-10-09_a22222e721470777f9403c07cef58d363eeb53a438486c9f999cd5499ca5eb44
Starting test...
Testing images...
Running custom test command: "./test.sh" with timeout 60 s
go custom test 
--- FAIL: TestServer (0.00s)
FAIL
FAIL    skaffold-demo   0.002s
FAIL
Command finished with non-0 exit code.
running tests: command ./test.sh finished with non-0 exit code: exit status 1. Check the custom command contents: "./test.sh".

ビルド後にテストが実行されているのが確認できます。
テストを修正してもう一度skaffold devを実行してみます。

package main

import "testing"

func TestServer(t *testing.T) {
}

なにもテストしてないですが、成功のステータスになればよいので一旦これで勧めます。

$ skaffold dev

(省略)

Successfully tagged dockerfile-image:2021-10-09_829325c7d6931da186db8c88d95d30cdbcc7de9fd7782f7c86db821e5834899a
Starting test...
Testing images...
Running custom test command: "./test.sh" with timeout 60 s
go custom test 
ok      skaffold-demo   0.002s
Command finished successfully.
Tags used in deployment:
 - dockerfile-image -> dockerfile-image:a68e7ef37e4ecb1a02a8787b476221750e931017692745e632d7b2f8e83a5eee
Starting deploy...
 - service/dockerfile-image created
 - deployment.apps/dockerfile-image created
Waiting for deployments to stabilize...

(省略)

今度はテストの実行も成功しているみたいですね。

Deploy

デプロイのステージではその名の通りアプリをk8sにデプロイします。
skaffold devskaffold runskaffold deployなどのコマンドで実行されます。

デプロイ時には以下のステップが実行されます。

Skaffoldはデプロイ時に以下のようなツールをサポートします。

  • kubectl
  • helm
  • kustomize

今回は生成されたskaffold.yamlに記述されているようにkubectlを用いたデプロイを行います。
skaffold devコマンドを実行します。

$ skaffold dev --kube-context=minikube

(省略)

Tags used in deployment:
 - dockerfile-image -> dockerfile-image:22f84ce796f1c77f1bc5fceb6b494b9a5f67b86f8eeeb345fee0328047b3c115
Starting deploy...
 - service/dockerfile-image created
 - deployment.apps/dockerfile-image created
Waiting for deployments to stabilize...
 - deployment/dockerfile-image is ready.
Deployments stabilized in 2.228 seconds
Port forwarding service/dockerfile-image in namespace default, remote port 1323 -> http://127.0.0.1:1323
Press Ctrl+C to exit
Watching for changes...
[dockerfile-image] 
[dockerfile-image]    ____    __
[dockerfile-image]   / __/___/ /  ___
[dockerfile-image]  / _// __/ _ \/ _ \
[dockerfile-image] /___/\__/_//_/\___/ v4.6.1
[dockerfile-image] High performance, minimalist Go web framework
[dockerfile-image] https://echo.labstack.com
[dockerfile-image] ____________________________________O/_______
[dockerfile-image]                                     O\
[dockerfile-image] ⇨ http server started on [::]:1323

(省略)

kubecltコマンドでリソースを確認してみます。

$ kubectl --context=minikube -n default get all 
NAME                                    READY   STATUS    RESTARTS   AGE
pod/dockerfile-image-6bc7589896-vth7m   1/1     Running   0          6m16s

NAME                       TYPE        CLUSTER-IP   EXTERNAL-IP   PORT(S)    AGE
service/dockerfile-image   ClusterIP   None         <none>        1323/TCP   6m16s
service/kubernetes         ClusterIP   10.96.0.1    <none>        443/TCP    99m

NAME                               READY   UP-TO-DATE   AVAILABLE   AGE
deployment.apps/dockerfile-image   1/1     1            1           6m16s

NAME                                          DESIRED   CURRENT   READY   AGE
replicaset.apps/dockerfile-image-6bc7589896   1         1         1       6m16s

DeploymentやServiceなどがクラスターにデプロイされました。
--kube-context=minikubeでコンテキストを明示的に指定していますが、指定しない場合kubectlのカレントコンテキストを用いるようです。
[skaffold config](https://skaffold.dev/docs/references/cli/#skaffold-config)コマンドでカレントコンテキストを変えることも可能なようです。

File Sync

Skaffoldは不要なリビルドを避けるためにファイルをコンテナに直接コピーする方法も提供しています。
リビルドの必要のない設定ファイルやHTMLファイルをシンクしたい場合この方法が使えます。
今回は深く機能は試しませんが、以下のようなパターンでのシンクを実行できます。

  • manual : ローカルのディレクトリと実行されているコンテナのファイルパスをマニュアルで指定してシンクする。すべてのアーティファクトに対応している
  • infer : ビルダーから推測可能なファイルディレクトリの中をチェックしシンクする。dockerやkanikoの場合はDokcerfileから推測される。
  • auto : Skaffoldがシンクの設定を自動的に設定する。JibやBuildpacksのアーティファクトに対して有効。Buildpacksを用いている場合はデフォルトで有効になる。

Port Forwarding

Skaffoldはビルドインでクラスターからローカルマシンへのポートフォワードのサポートを行ってくれています。
この機能はskaffold devskaffold debugskaffold deployskaffold runなどのコマンドで有効にできます。
(コマンドによって、有効になるポートフォワードの種類が違います。詳細はこちらをご確認ください)

ポートフォワードはskaffold.yaml内で以下のような種類で定義することが可能です。

  • user : ユーザが明示的に有効にできるポートフォワード。k8sのリソースタイプを指定してぽーどフォワードを行なう(設定方法はこちら
  • services : Skaffoldでデプロイされた、Serviceリソースに対して設定できるポートフォワード
  • debug : デバック用のポートを公開するためのポートフォワード(skaffold debugコマンドで有効にできる)
  • pods : SkaffoldがビルドしたイメージのすべてのPodのcontainerPortsに対するポートフォワード

また、ポートフォワードを明示的にoffにしたい場合はコマンドのフラグとして--port-forward=offを指定します。

今回はデフォルトで生成されたuserのタイプのServiceリソースに対するポートフォワードを行ってみます。
以下のコマンドを実行します。

$ skaffold dev --kube-context=minikube

(省略)

Port forwarding service/dockerfile-image in namespace default, remote port 1323 -> http://127.0.0.1:1323

(省略)

ログからポートフォワードが動作しているのがわかります。
cURLlocalhost:1323にアクセスしてみます。

$ curl localhost:1323/hello
Hello, World!

Lifecycle Hooks

Lifecycle Hooksでは、Skaffoldのライフサイクルイベントによってコードを実行したりすることができます。
buildsyncdeployのフェーズでそれぞれbeforeafterのホックを設定できます。
以下の2つのタイプのホックが存在します。

  • Host hooks : ランナー(例えばホストマシン)内で実行されるホック
  • Container hooks : 実行されるコンテナ内で実行されるホック

また、Host hooksでは、フェーズによって利用できるいくつかの環境変数が存在しており、例えばbuildsyncのフェーズではSKAFFOLD_IMAGEというイメージ名が設定された環境変数が利用可能です。
環境変数の一覧とその環境変数が利用できるフェーズの詳細はこちらをご確認ください。

今回は、Host hooksを利用して、ビルドの前と後にechoコマンドを実行してイメージ名を出力してみます。
具体的にはskaffold.yamlbuildセクションを以下のように書き換えます。

build:
  tagPolicy:
    customTemplate:
      template: "{{.FOO}}_{{.BAR}}"
      components:
      - name: FOO
        dateTime:
          format: "2006-01-02"
          timezone: "UTC"
      - name: BAR
        inputDigest: {}
  artifacts:
  - image: dockerfile-image
    docker:
      dockerfile: Dockerfile
    hooks:
      before:
        - command: ["sh", "-c", "echo Start Building: $SKAFFOLD_IMAGE"]
          os: [linux]
      after:
        - command: ["sh", "-c", "echo Finish Building: $SKAFFOLD_IMAGE"]
          os: [linux]

そして、skaffold buildコマンドを実行します。

$ skaffold build

(省略)

Starting pre-build hooks...
Start Building: dockerfile-image:2021-10-09_7db982f9391c94bd44049fdefeb1e2e15d4a9f876009f322744cca7ca74c3667
Completed pre-build hooks

(省略)


Starting post-build hooks...
Finish Building: dockerfile-image:2021-10-09_7db982f9391c94bd44049fdefeb1e2e15d4a9f876009f322744cca7ca74c3667
Completed post-build hooks

Clean Up

最後に、クリーンアップステージです。
Skaffoldはローカルマシンやクラスターに以下のような副作用を起こします。

Skaffoldは自動でこれらの副作用をクリーンアップしてくれる機能を有しています。
skaffold deleteを使えばそれらのリソースのクリーンアップが行えます。
また、skaffold devskaffold debugコマンドを用いていてクラスターへのデプロイを行っている場合はCtrl + cを実行すれば自動的に作成されたリソースやイメージがクリーンアップされます。

$ skaffold dev --kube-context=minikube

(省略)

Watching for changes...
^CCleaning up...
 - service "dockerfile-image" deleted
 - deployment.apps "dockerfile-image" deleted

ログから不要なリソースがクリーンアップされていることがわかります。
実際にkubectlコマンドで確認してみてもクリーンアップされています。

$ kubectl --context=minikube -n default get all 
NAME                 TYPE        CLUSTER-IP   EXTERNAL-IP   PORT(S)   AGE
service/kubernetes   ClusterIP   10.96.0.1    <none>        443/TCP   103m

Docker Buildxを使う

はじめに

DokcerにはBuildxというCLIプラグインが存在します。
いままでなんとなく使ってただけなのでちょっと調べてまとめてみようかと思います。

Docker Buildxとは

前述していますがDocker のCLIプラグインです。
これはBuildxの機能を拡張したもので以下のような機能や特徴を持ちます。 (Buildxに関しては以前ブログにまとめてます)

  • docker buildと似たUIで提供される
  • Docker BuildKitの機能をすべて備えている
  • マルチノードビルドやクロスプラットフォームイメージの作成できる
  • Compose Buildサポートをしている
  • ハイレベルなビルド構成(bake)

BuildxはDokcer 19.03以降のバージョンを必要とします。
しかし、古いバージョンでもバイナリを直接実行することで、機能を限定した利用は可能なようです。
インストールに関しては最新のバージョンのDocker CEのパッケージをインストールしていれば一緒にダウンロードされているようです。
(もしバイナリをインストールしたい場合はこちらをご確認ください)

使ってみる

環境

今回の動作環境は以下の通り。

$ uname -srvmpio
Linux 5.4.0-88-generic #99-Ubuntu SMP Thu Sep 23 17:29:00 UTC 2021 x86_64 x86_64 x86_64 GNU/Linux

$ lsb_release -a
LSB Version:    core-11.1.0ubuntu2-noarch:security-11.1.0ubuntu2-noarch
Distributor ID: Ubuntu
Description:    Ubuntu 20.04.3 LTS
Release:    20.04
Codename:   focal

$ docker version
Client: Docker Engine - Community
 Version:           20.10.8
 API version:       1.41
 Go version:        go1.16.6
 Git commit:        3967b7d
 Built:             Fri Jul 30 19:54:27 2021
 OS/Arch:           linux/amd64
 Context:           default
 Experimental:      true

Server: Docker Engine - Community
 Engine:
  Version:          20.10.8
  API version:      1.41 (minimum version 1.12)
  Go version:       go1.16.6
  Git commit:       75249d8
  Built:            Fri Jul 30 19:52:33 2021
  OS/Arch:          linux/amd64
  Experimental:     true
 containerd:
  Version:          1.4.10
  GitCommit:        8848fdb7c4ae3815afcc990a8a99d663dda1b590
 runc:
  Version:          1.0.2
  GitCommit:        v1.0.2-0-g52b36a2
 docker-init:
  Version:          0.19.0
  GitCommit:        de40ad0

Buildxのコマンド

Buildxのコマンドには以下のようなものがあります。

この中からいくつかのコマンドは利用してみたいと思います。

dokcerコマンドにエイリアスを作成する

buildx installコマンドを使うと通常のdokcerコマンドに対するエイリアスを貼ることができます。
buildx は通常 docker buildx buildなどのようにdokcer buildx [SUBCOMMAND]の構成を取りますが、buildx installコマンドを実行すると

$ docker buildx install

// docker buildコマンドが docker buildx buildへのエイリアスになっているのでBuildxでのビルドが実行される
$ docker build .
[+] Building 46.2s (11/11) FINISHED                                                                                                                                                                        
 => [internal] load build definition from Dockerfile                                                                                                                                                  0.0s
 => => transferring dockerfile: 155B                                                                                                                                                                  0.0s
 => [internal] load .dockerignore                                                                                                                                                                     0.0s
 => => transferring context: 2B                                                                                                                                                                       0.0s
 => resolve image config for docker.io/docker/dockerfile:experimental                                                                                                                                 8.8s
 => [auth] docker/dockerfile:pull token for registry-1.docker.io                                                                                                                                      0.0s
 => docker-image://docker.io/docker/dockerfile:experimental@sha256:600e5c62eedff338b3f7a0850beb7c05866e0ef27b2d2e8c02aa468e78496ff5                                                                  33.4s
 => => resolve docker.io/docker/dockerfile:experimental@sha256:600e5c62eedff338b3f7a0850beb7c05866e0ef27b2d2e8c02aa468e78496ff5                                                                       0.0s
 => => sha256:600e5c62eedff338b3f7a0850beb7c05866e0ef27b2d2e8c02aa468e78496ff5 1.69kB / 1.69kB                                                                                                        0.0s
 => => sha256:3c244c0c6fc9d6aa3ddb73af4264b3a23597523ac553294218c13735a2c6cf79 528B / 528B                                                                                                            0.0s
 => => sha256:b587adb6abfd8d6c87b1f649c2e924d53f148ae1c7f0ceaaded70b27b44dccb5 1.21kB / 1.21kB                                                                                                        0.0s
 => => sha256:d7f0373ffb1d5ac3477b10ab7f91cba7c5df586c72e7e4a12649024efdc0d531 9.64MB / 9.64MB                                                                                                       33.2s
 => => extracting sha256:d7f0373ffb1d5ac3477b10ab7f91cba7c5df586c72e7e4a12649024efdc0d531                                                                                                             0.1s
 => [internal] load metadata for docker.io/library/busybox:latest                                                                                                                                     2.9s
 => [auth] library/busybox:pull token for registry-1.docker.io                                                                                                                                        0.0s
 => [internal] load build context                                                                                                                                                                     0.0s
 => => transferring context: 155B                                                                                                                                                                     0.0s
 => [1/2] FROM docker.io/library/busybox@sha256:f7ca5a32c10d51aeda3b4d01c61c6061f497893d7f6628b92f822f7117182a57                                                                                      0.7s
 => => resolve docker.io/library/busybox@sha256:f7ca5a32c10d51aeda3b4d01c61c6061f497893d7f6628b92f822f7117182a57                                                                                      0.0s
 => => sha256:f7ca5a32c10d51aeda3b4d01c61c6061f497893d7f6628b92f822f7117182a57 2.29kB / 2.29kB                                                                                                        0.0s
 => => sha256:febcf61cd6e1ac9628f6ac14fa40836d16f3c6ddef3b303ff0321606e55ddd0b 527B / 527B                                                                                                            0.0s
 => => sha256:16ea53ea7c652456803632d67517b78a4f9075a10bfdc4fc6b7b4cbf2bc98497 1.46kB / 1.46kB                                                                                                        0.0s
 => => sha256:24fb2886d6f6c5d16481dd7608b47e78a8e92a13d6e64d87d57cb16d5f766d63 767.32kB / 767.32kB                                                                                                    0.6s
 => => extracting sha256:24fb2886d6f6c5d16481dd7608b47e78a8e92a13d6e64d87d57cb16d5f766d63                                                                                                             0.1s
 => [2/2] RUN --mount=type=bind,target=tmp cat tmp/Dockerfile > text.txt                                                                                                                              0.3s
 => exporting to image                                                                                                                                                                                0.0s
 => => exporting layers                                                                                                                                                                               0.0s
 => => writing image sha256:00d095571d6c88a17fc804422a29bf628377bbd2d6616f0e11252e5959955b8f   

エイリアスを削除したい場合はbuildx uninstallコマンドを用います。

ビルダーインスタンスを作成する

buildxコマンドを使えば、複数のビルダーインスタンスを作成したり削除したりすることができます。
このビルダーインスタンスはスコープを持っており、例えば、共有のデーモンのステートを変更すること無い独立したビルダーを作成することができます。
また、このビルダーインスタンスにはリモートのノードを複数追加することが可能です。

ビルダーインスタンスの作成は以下のコマンドを用います。

$ docker buildx create default --name henoheno_builder
henoheno_builder

$ docker buildx use henoheno_builder

$ docker buildx ls
NAME/NODE           DRIVER/ENDPOINT             STATUS   PLATFORMS
henoheno_builder *  docker-container                     
  henoheno_builder0 unix:///var/run/docker.sock inactive 
default             docker                               
  default           default                     running  linux/amd64, linux/386

新しいビルダーインスタンスを作成する場合は引数にdocker contextを指定するかDOCKER_HOST環境変数を指定します。
新しく作成したビルダーのステータスがinactiveになってしますがbuildxコマンドを利用時に自動的に起動してくれるようです。
明示的に起動したい場合は以下のコマンドを実行します。

$ docker buildx inspect --bootstrap
[+] Building 19.5s (1/1) FINISHED                                                                                                                                                                          
 => [internal] booting buildkit                                                                                                                                                                      19.5s
 => => pulling image moby/buildkit:buildx-stable-1                                                                                                                                                   18.8s
 => => creating container buildx_buildkit_henoheno_builder0                                                                                                                                           0.6s
Name:   henoheno_builder
Driver: docker-container

Nodes:
Name:      henoheno_builder0
Endpoint:  unix:///var/run/docker.sock
Status:    running
Platforms: linux/amd64, linux/386

最後に作ったビルダーインスタンスを削除する場合は以下のコマンドを実行します。

$ docker buildx rm henoheno_builder

$ docker buildx ls
NAME/NODE DRIVER/ENDPOINT STATUS  PLATFORMS
default * docker                  
  default default         running linux/amd64, linux/386

Springのアノテーション付きコントローラーのメソッドを明示的にハンドラーとして登録する

はじめに

Spring のWebFluxのドキュメント眺めていたら明示的なハンドラーの登録という項目が合ってなんとなく気になったので動かしてみたいと思います。
あとは、Java 16から入ってるRecordsクラスを試してみたい気持ちもあり。 ちなみに明示的なハンドラーの登録はMVCの方でも使えるみたいです。
Webfluxでは大きく以下の2つのハンドラー定義の方法をサポートしています(MVCでも同様)。

上記のやり方でニーズを満たせない場合、より高度なハンドラーの登録ロジックが必要な場合(動的なハンドラー登録、異なるURLで同一ハンドラーを登録など)明示的なハンドラーの登録を行なうことができます。
また、この方法はアノテーション付きコントローラーを使っている際に利用可能なようです。

やってみる

環境

$ java --version
openjdk 17 2021-09-14
OpenJDK Runtime Environment (build 17+35-2724)
OpenJDK 64-Bit Server VM (build 17+35-2724, mixed mode, sharing)

$ mvn -v
Apache Maven 3.6.3
Maven home: /usr/share/maven
Java version: 17, vendor: Oracle Corporation, runtime: /home/******/.sdkman/candidates/java/17-open
Default locale: ja_JP, platform encoding: UTF-8
OS name: "linux", version: "5.4.0-86-generic", arch: "amd64", family: "unix"

プロジェクトの作成

Spring Initializrを使ってプロジェクトを作成します。
設定は以下のように

f:id:yuya_hirooka:20210926121552p:plain

コントローラーを作って登録する

リクエストで名前を受け付けて、挨拶を返すコントローラーを作成します。
まずは、リクエストとレスポンスを受ける用のRecordクラスを作成します。

NameRequest.java

public record NameRequest(String value) {}

GreetingResponse

public record GreetingResponse(String value) {
    public GreetingResponse(String value){
        this.value = String.format("Hello %s!", value);
    }
}

次にコントローラーを実装します。

GreetingController.java

import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Mono;

@RestController
public class GreetingController {

    public Mono<GreetingResponse> hello(@RequestBody Mono<NameRequest> name) {
        return name.map(it -> new GreetingResponse(it.value()));
    }
}

@〇〇Mapptingみたいなのを付けてない以外は普通のコントローラーですね。
また、Recordクラスは今まで定義してたDTOみたいな感じで扱えるみたいですね。

このコントローラーのメソッドをハンドラーマッピングとして登録します。

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.reactive.result.method.RequestMappingInfo;
import org.springframework.web.reactive.result.method.annotation.RequestMappingHandlerMapping;
import reactor.core.publisher.Mono;

import java.lang.reflect.Method;

@Configuration
public class Config {

    @Autowired
    public void handlerSetting(RequestMappingHandlerMapping mapping, GreetingController handler) throws NoSuchMethodException {
        RequestMappingInfo info = RequestMappingInfo
                .paths("/hello").methods(RequestMethod.POST).build();
        Method method = GreetingController.class.getMethod("hello", Mono.class);
        mapping.registerMapping(info, handler, method);
    }
}

@Autowiredを使ってRequestMappingHandlerMappingGreetingControllerを受け取ってます。
また、RequestMappingInfoを使ってリクエストのマッピング設定を記述し、その定義とコントローラーのリクエストをハンドルするMethodオブジェクト、そしてコントロラー本体をRequestMappingHandlerMappingに登録しています。
この際に色々処理を入れてロジックでハンドラーの定義をごにょごにょできるみたいですね。

さっくり触っただけですが、ほぼ同じような記述でMVC側も動くみたいです。
あと、Recordクラスはほんとに今まで通りな感じで使えましたね。
関数エンドポイントの方でも今までと全くおんなじように利用できました。

pprofでGoアプリのプロファイル情報を取得する

はじめに

Javaでプロファイル情報を取得して分析するための方法は、いくつか思い当たるものはあるのですがGoだとやり方全くわからなかったので少しまとめておこうかと思いました。
Go言語は標準ライブラリとして、runtime/pprofパッケージやnet/http/pprofパッケージが用意されており、これらをちょっと使ってみようと思います。
また、プロファイル情報を分析するためのツールは公式のpprofを使うのが一般的なようなのでほんの少しだけ使い方をみてみようと思います。

pprofを用いたプロファイリング

Go言でプロファイル情報を取得するには以下の2つのやり方があるみたいです。

  • runtime/pprofパッケージを用いてプロファイル情報をファイルに出力する
  • net/http/pprofパッケージを用いてHTTP経由でプロファイル情報を取得する

上記の2つはprofile.proto形式で情報を出力するようです。
profile.protoは記号化されたコールスタックの情報がProtocol Buffersの書式で記述されているようです。
pprofで取得可能な項目には以下のようなものがあるようです。

  • CPU: CPUの使用時間に関するプロファイル
  • Heep: メモリアロケーションのプロファイル、メモリリークのチェック情報
  • Threadcreate: OSのスレッド生成に関するプロファイル
  • Goroutine: すべてのGoroutineのスタックトレース
  • Block: Goroutineのブロッキングに関するプロファイル。デフォルトで無効なのでruntime.SetBlockProfileRateを使って有効化する必要がある。
  • Mutex: Mutexのロックに関するプロファイル。デフォルトで無効なのでruntime.SetMutexProfileFractionを使って有効化する必要がある。

これらの詳細はこちらをご確認ください。

また軽く前述していますが、取得したprofile.protoの分析はpprofの可視化ツールを使って行なうのが一般的なようです。 このツールはのデータを読み込んで可視化を行ってくれます。

やってみる

フィボナッチ数列を計算する簡単なのアプリケーションを作成して、runtime/pprofパッケージやnet/http/pprofパッケージで情報を取得してみるのと、その情報を可視化するとことまでやってみようかと思います。

環境

動作環境は以下のような感じ。

$ uname -srvmpio
Linux 5.4.0-84-generic #94-Ubuntu SMP Thu Aug 26 20:27:37 UTC 2021 x86_64 x86_64 x86_64 GNU/Linux


$ go version
go version go1.17.1 linux/amd64

$ lsb_release -a
LSB Version:    core-11.1.0ubuntu2-noarch:security-11.1.0ubuntu2-noarch
Distributor ID: Ubuntu
Description:    Ubuntu 20.04.3 LTS
Release:    20.04
Codename:   focal

サンプルアプリの作成

アプリは引数で正数nを受け取ってフィボナッチ数列を計算してn番目のフィボナッチ数を返すアプリを作ろうと思います。
まずはプロジェクト作成から。

$ mkdir echo-fibo && cd echo-fibo


$ go mod init echo-fibo

次にfiboパッケージを作り、よくある定義のフィボナッチ数列を計算する関数を定義します。
また、その関数に対するテストも記述しておきます。

fibo.go

package fibo

func Fibo(n int) int {
    if n < 2 {
        return n
    }
    return Fibo(n-2) + Fibo(n-1)
}

fibo_test.go

package fibo

import "testing"

func TestFibo(t *testing.T) {
    tests := []struct {
        name string
        input int
        want int
    }{
        {
            "input 10",
            10,
            55,
        },
        {
            "input 11",
            11,
            89,
        },
        {
            "input 20",
            20,
            6765,
        },
        {
            "input 30",
            30,
            832040,
        },
        {
            "input 44",
            44,
            701408733,
        },
    }
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            if got := Fibo(tt.input); got != tt.want {
                t.Errorf("Fibo() = %v, want %v", got, tt.want)
            }
        })
    }
}

ここでは、効率は度外視で直感的なフィボナッチ数列の計算アルゴリズムものを記述しました。

最後にこの関数を呼び出すmain関数を書きます。

package main

import (
    "echo-fibo/fibo"
    "fmt"
    "log"
    "os"
    "strconv"
)

func main() {


    number, err := strconv.Atoi(os.Args[1])
    if err != nil {
        log.Fatal(err)
    }

    fmt.Println(fibo.Fibo(number))
}

バリデージョンもなにもしてないほんとに最低限のものですが、pprofを動かすことが目的なだけなのでこのまま行きます。

runtime/pprofパッケージを用いてプロファイル情報を取得する

runtime/pprofパッケージを用いてプロファイル情報を取得する方法は以下の2通りの方法があります。

  • Goのtestingパッケージにビルドインされているプロファイラを使う
  • pprof.StartCPUProfile(filename)pprof.WriteHeapProfile(filename)等を使ってスタンドアローンアプリでプロファイルを有効化する

testingパッケージにビルドインされているプロファイラを使う

前者のやり方は簡単で、testingパッケージにはプロファイルのサポートがビルドインされているので、以下のコマンドを実行するだけでプロファイル情報を取得することが可能です。

$ cd fibo
$ go test -cpuprofile cpu.prof -memprofile mem.prof -benchtime 1ms -bench .

$ ls
cpu.prof  fibo.go  fibo.test  fibo_test.go  mem.prof

上記のコマンドでは、-bench .ですべてのベンチマークが実行され、-cpuprofile cpu.prof-memprofile mem.profで実行結果を出力する項目とファイル名を決定しているようです。

上記の例ではCPUとMemoryに対するプロファイル情報を取得しましたが、以下のオプションでそれぞれのプロファイル情報が取得可能なようです。

Option 説明
-blockprofile [outputFileName] goroutineのブロッキングプロファイル
-coverprofile [outputFileName] すべてのテストが実行された時のカバレッジプロファイル
-cpuprofile [outputFileName] CPUプロファイル
-memprofile [outputFileName] Memoryプロファイル
-mutexprofile mutexの競合プロファイル

また、プロファイルとは少し毛色が違いますが-trace [outputFileName]実行のトレースを出力することも可能なようです。

スタンドアローンアプリでプロファイルを有効化する

testingパッケージは利用せずスタンドアローンのアプリに対してプロファイルを実行したい場合は少し工夫が必要です。
pprof.StartCPUProfile(filename)やpprof.WriteHeapProfile(filename)などの関数をmain関数で呼び出しプロファイルを有効にできるようにする必要があるからです。 ' main.go`を以下のように書き換えます。

package main

import (
    "echo-fibo/fibo"
    "flag"
    "fmt"
    "log"
    "os"
    "runtime"
    "runtime/pprof"
    "strconv"
)

var cpuprofile = flag.String("cpuprofile", "", "write cpu profile to `file`")
var memprofile = flag.String("memprofile", "", "write memory profile to `file`")
var number = flag.String("number", "", "fibo number")

func main() {

    flag.Parse()
    if *cpuprofile != "" {
        f, err := os.Create(*cpuprofile)
        if err != nil {
            log.Fatal("could not create CPU profile: ", err)
        }
        defer f.Close() // error handling omitted for example
        if err := pprof.StartCPUProfile(f); err != nil {
            log.Fatal("could not start CPU profile: ", err)
        }
        defer pprof.StopCPUProfile()
    }

    fmt.Println(os.Args)
    num, err := strconv.Atoi(*number)
    if err != nil {
        log.Fatal(err)
    }
    fmt.Println(fibo.Fibo(num))


    if *memprofile != "" {
        f, err := os.Create(*memprofile)
        if err != nil {
            log.Fatal("could not create memory profile: ", err)
        }
        defer f.Close() // error handling omitted for example
        runtime.GC()    // get up-to-date statistics
        if err := pprof.WriteHeapProfile(f); err != nil {
            log.Fatal("could not write memory profile: ", err)
        }
    }
}

この状態でアプリをビルドし以下のように起動するとプロファイル情報が取得できます。

$ go build echo-fibo

$  ./echo-fibo -number 44 -memprofile mem.prof

1つ1つプロファイルごとに設定していくのは流石に面倒に感じますが、ラップしてくれているライブラリがあるみたいなのでこれを使えばもう少し楽にできるのかも知れません。また、別で試してみようと思います。

net/http/pprofパッケージ

net/http/pprofを用いればプロファイル情報をHTTP経由で公開することも可能です。
基本的な使い方は簡単で、以下のインポートを自分のプログラムに追加するだけです。

import _ "net/http/pprof"

ここで、注意が必要で、もしアプリケーションがhttp serverを起動していない場合に以下のようなコードを記述して自分で起動してやる必要があります。

go func() {
    log.Println(http.ListenAndServe("localhost:6060", nil))
}()

net/http/pprofを使えばデフォルトでハンドラーが実装され以下のようなURLでプロファイル情報を取得できるようになります。

また、すべてのプロファイル情報を取得する場合はhttp://localhost:6060/debug/pprof/で行けるみたいです。

main関数を以下のように書き換えてHTTPのパスパラメータで受け取った値のフィボナッチ数列の答えを返すアプリに変更します。

package main

import (
    "echo-fibo/fibo"
    "flag"
    "fmt"
    "log"
    "net/http"
    _ "net/http/pprof"
    "strconv"
    "strings"
)

func main() {

    flag.Parse()

    http.HandleFunc("/fibo/", func(writer http.ResponseWriter, request *http.Request) {

        number := strings.TrimPrefix(request.URL.Path, "/fibo/")
        writer.WriteHeader(http.StatusOK)

        num, err := strconv.Atoi(number)
        if err != nil {
            log.Fatal(err)
        }

        fiboNum := fibo.Fibo(num)
        log.Println(fiboNum)

        fmt.Fprintf(writer, "The Answer is %d", fiboNum)
    })

    log.Println(http.ListenAndServe("localhost:6060", nil))
}

普通のHTTPハンドラーの実装とサーバの起動を行っているだけですが、Importに_ "net/http/pprof"を追加しています。
wgetでプロファイル情報を取得してみます。

$ wget -O heep.prof http://localhost:6060/debug/pprof/heap
--2021-09-23 18:47:58--  http://localhost:6060/debug/pprof/heap
localhost (localhost) をDNSに問いあわせています... 127.0.0.1
localhost (localhost)|127.0.0.1|:6060 に接続しています... 接続しました。
HTTP による接続要求を送信しました、応答を待っています... 200 OK
長さ: 特定できません [application/octet-stream]
`heep.prof' に保存中

heep.prof                                              [ <=>                                                                                                            ]   2.79K  --.-KB/s    in 0s      

2021-09-23 18:47:58 (8.25 MB/s) - `heep.prof' へ保存終了 [2858]

$ ls
echo-fibo  fibo  go.mod  go.sum  heep.prof  main.go

pprof 可視化ツールを使ってみる

pprofの可視化ではいくつかのやり方があるようです。
今回はWeb UIを通した可視化を行ってみたいと思います。
Web UIを用いる場合はgraphvizがインストールされている必要があるみたいなので、以下のコマンドでインストールしておきます。

sudo apt install graphviz

可視化ツールの使い方自体はさほど難しくなく以下のコマンドでWebのUIが立ち上がります。

$ go tool pprof -http=localhost:8081 pprof heep.prof 
pprof: open pprof: no such file or directory
Fetched 1 source profiles out of 2
Serving web UI on http://localhost:8081

f:id:yuya_hirooka:20210923184946p:plain

HTTP経由でプロファイルを取得する場合は以下のようにします。

$ go tool pprof -http=localhost:8082 http://localhost:6060/debug/pprof/heap
Fetching profile over HTTP from http://localhost:6060/debug/pprof/heap
Saved profile in /home/someone/pprof/pprof.___go_build_echo_fibo.alloc_objects.alloc_space.inuse_objects.inuse_space.005.pb.gz
Serving web UI on http://localhost:8082

力尽きたのでUIの詳細な見かたは別の機会に。

Docker Notaryで署名したイメージをDockerHubで公開する

はじめに

ソフトウェアをインストールするさいにHashの検証や署名の検証などで、ダウンロードしてきたバイナリの信頼性を検証するみたいなのはよくやると思います。Dockerのイメージに対してこれがどのように解決されるかというところに理解が浅かったので、Dockerのコンテントトラストのドキュメントを読みつつ、自分で作成したイメージに署名をするところまでやってみようかと思います。

どのようにDocker のコンテントトラストが実現されるか

DockerではDCT(Docker Content Trust)と呼ばれる機能でデジタル署名を利用してデータの整合性と公開者情報を検証できる仕組みを提供しているようです。
この機能を使うと特定のイメージタグに対して検証を行えるようになります。
DCTでは、タグ毎にサインを行いどのタグにサインを行なうかはイメージの公開者が決める必要があります。
また、1つのリポジトリで1つのイメージに対してサインされているタグは1つだけ存在するようです。

クライアント目線で言うと、DCTを有効にした場合実行できるイメージはサインされたイメージのみで、ほかは利用できなくなります。フィルターの概念が近いようです。

Notaryについて

Docker DCTの仕組みはNotrayという機能の上で実装されているみたいです。
Notrayはサーバサイドとクライアントサイドで提供されており、サーバサイドが利用するDockerリポジトリにアタッチされている必要があるようです。
このブログでは独自にリポジトリを用意してアタッチすることなどは行いません(そのやり方に付いてはこちらを確認ください)。 今回はDocker Hubを使います。

また、NotrayはTOFU(Trust On First Use)というモデルを採用しており、最初にダウンロードしたものを信じるという仕様になってます。
V2ではこれを改善するような議論がコミュニティで行われているようです(ソースを見つけられなかった...)

自分で作成したイメージに署名する

環境

今回は以下の環境で諸々を動かしてみます。

$ lsb_release -a
LSB Version:    core-11.1.0ubuntu2-noarch:security-11.1.0ubuntu2-noarch
Distributor ID: Ubuntu
Description:    Ubuntu 20.04.2 LTS
Release:    20.04
Codename:   focal

$ uname -srvmpio
Linux 5.4.0-80-generic #90-Ubuntu SMP Fri Jul 9 22:49:44 UTC 2021 x86_64 x86_64 x86_64 GNU/Linux

$ docker version
Client: Docker Engine - Community
 Version:           20.10.8
 API version:       1.41
 Go version:        go1.16.6
 Git commit:        3967b7d
 Built:             Fri Jul 30 19:54:27 2021
 OS/Arch:           linux/amd64
 Context:           default
 Experimental:      true

Server: Docker Engine - Community
 Engine:
  Version:          20.10.8
  API version:      1.41 (minimum version 1.12)
  Go version:       go1.16.6
  Git commit:       75249d8
  Built:            Fri Jul 30 19:52:33 2021
  OS/Arch:          linux/amd64
  Experimental:     true
 containerd:
  Version:          1.4.9
  GitCommit:        e25210fe30a0a703442421b0f60afac609f950a3
 runc:
  Version:          1.0.1
  GitCommit:        v1.0.1-0-g4144b63
 docker-init:
  Version:          0.19.0
  GitCommit:        de40ad0

イメージの作成

まずは署名を行なうイメージを作っておきます。
今回はnginxの公式イメージを使って作ります。

$ docker create nginx

$ docker ps -a
CONTAINER ID   IMAGE     COMMAND                  CREATED          STATUS    PORTS     NAMES
896fa7872413   nginx     "/docker-entrypoint.…"   27 seconds ago   Created             cranky_dewdney

$ docker commit 896fa7872413 hirohiroyuya/nginx:singed
sha256:ffcb0c4915f34a5d68f4eb6e8452db191d4c19e6ecfc4bc17a90b16689e0dfaa

$ docker commit 896fa7872413 hirohiroyuya/nginx:non-singed
sha256:8e321e701fd6f0f5bf730dfd55c464804f85570e0dcb31326f6e50e4c289b8a4


$ docker images
REPOSITORY           TAG          IMAGE ID       CREATED              SIZE
hirohiroyuya/nginx   non-singed   8e321e701fd6   About a minute ago   133MB
hirohiroyuya/nginx   singed       ffcb0c4915f3   About a minute ago   133MB
nginx                latest       08b152afcfae   3 weeks ago          133MB

今回は2つのタグを作り片方は署名しもう片方は署名せずにPushしようと思います。 下準備は完了です。

鍵の作成とNotaryサーバへの設定

署名をする前に鍵を作る必要があります。
鍵を作る場合には以下のコマンドを実行します。

$ docker trust key generate henoheno
Generating key for henoheno...
Enter passphrase for new henoheno key with ID 3eb42d4: 
Repeat passphrase for new henoheno key with ID 3eb42d4: 
Successfully generated and loaded private key. Corresponding public key available: /home/someone/henoheno.pub

今回は試しませんがすでに鍵がある場合は以下のようにして既存のロードできるみたいです。

$ docker trust key load key.pem --name jeff

次に作成された公開鍵を公開鍵を Notary サーバーへ追加します。 今回はDockerHubを使うので特にドメインなどは指定してませんが、必要な場合は指定してください。

$ docker trust signer add --key /home/yuya-hirooka/henoheno.pub henoheno hirohiroyuya/nginx
Adding signer "henoheno" to hirohiroyuya/nginx...
Enter passphrase for repository key with ID 7d993ef: 
Successfully added signer: henoheno to hirohiroyuya/nginx

ここまでで鍵の生成とサーバへの設定は終了です。

署名したイメージとしてないイメージをリポジトリにPushする

署名は以下のコマンドで行なうことができます。

$ docker trust sign hirohiroyuya/nginx:singed
Signing and pushing trust data for local image hirohiroyuya/nginx:singed, may overwrite remote trust data
The push refers to repository [docker.io/hirohiroyuya/nginx]
e3135447ca3e: Mounted from library/nginx 
b85734705991: Mounted from library/nginx 
988d9a3509bb: Mounted from library/nginx 
59b01b87c9e7: Mounted from library/nginx 
7c0b223167b9: Mounted from library/nginx 
814bff734324: Mounted from library/nginx 
singed: digest: sha256:505db062138c1e3dd094c9e5811c6cd9baae8c7beb77b1c010db809f2e0d8fd3 size: 1570
Signing and pushing trust metadata
Enter passphrase for henoheno key with ID 3eb42d4: 
Successfully signed docker.io/hirohiroyuya/nginx:singed

イメージをPushします。 この際にDOCKER_CONTENT_TRUST=1環境変数に指定してコンテントトラストを有効にする必要があるようです。

$ DOCKER_CONTENT_TRUST=1 docker push hirohiroyuya/nginx:singed
The push refers to repository [docker.io/hirohiroyuya/nginx]
e3135447ca3e: Layer already exists 
b85734705991: Layer already exists 
988d9a3509bb: Layer already exists 
59b01b87c9e7: Layer already exists 
7c0b223167b9: Layer already exists 
814bff734324: Layer already exists 
singed: digest: sha256:505db062138c1e3dd094c9e5811c6cd9baae8c7beb77b1c010db809f2e0d8fd3 size: 1570
Signing and pushing trust metadata
Enter passphrase for henoheno key with ID 3eb42d4: 
Successfully signed docker.io/hirohiroyuya/nginx:singed

これでDockerHubでの公開が完了しました。

f:id:yuya_hirooka:20210814160814p:plain

Pushしたイメージの署名の情報を見るためには以下のコマンドを実行します。

$ docker trust inspect --pretty hirohiroyuya/nginx:singed

Signatures for hirohiroyuya/nginx:singed

SIGNED TAG   DIGEST                                                             SIGNERS
singed       12d3e6084e8af99509bd65b1d4583953cfb0791ddd66c4db199b725f6463327c   henoheno

List of signers and their keys for hirohiroyuya/nginx:singed

SIGNER     KEYS
henoheno   3eb42d4ad775

Administrative keys for hirohiroyuya/nginx:singed

  Repository Key:   7d993ef2d41d5473aa8556e987cf8449bda7edd07b34856a13788a495bf70e3c
  Root Key: 9f0717638ac4ef0e113004354e2946c8010e2f6cb5b425af4d0779986ad45c74

Pushしたイメージを利用する

まずは、比較を行なうために先程のhirohiroyuya/nginx:non-singedの方もPushしておきます。
また、ローカルのイメージも綺麗にしておきます。

$ docker push hirohiroyuya/nginx:non-singed
The push refers to repository [docker.io/hirohiroyuya/nginx]
e3135447ca3e: Layer already exists 
b85734705991: Layer already exists 
988d9a3509bb: Layer already exists 
59b01b87c9e7: Layer already exists 
7c0b223167b9: Layer already exists 
814bff734324: Layer already exists 
non-singed: digest: sha256:1ab4fc461a4c9028fa375aefec46c862d9317a2b2009321273c0135f7bdcb6ec size: 1570

$ docker rmi -f $(docker images -a -q)

$ docker images
REPOSITORY   TAG       IMAGE ID   CREATED   SIZE

f:id:yuya_hirooka:20210814161604p:plain

これでOK。

DockerクライアントではDCTはデフォルトで無効になっているようです。
これを有効にするためにはDOCKER_CONTENT_TRUST=1環境変数に指定してコマンドを実行する必要があるようです。

署名が行われていない状態のnginx:non-signeddocker runしてみます。

$ DOCKER_CONTENT_TRUST=1 docker run hirohiroyuya/nginx:non-singed
docker: No valid trust data for non-singed.

実行できないようになってますね。
今度は、署名がされているものをdocker runしてみます。

$ DOCKER_CONTENT_TRUST=1 docker run hirohiroyuya/nginx:singed
/docker-entrypoint.sh: /docker-entrypoint.d/ is not empty, will attempt to perform configuration
/docker-entrypoint.sh: Looking for shell scripts in /docker-entrypoint.d/
/docker-entrypoint.sh: Launching /docker-entrypoint.d/10-listen-on-ipv6-by-default.sh
10-listen-on-ipv6-by-default.sh: info: Getting the checksum of /etc/nginx/conf.d/default.conf
10-listen-on-ipv6-by-default.sh: info: Enabled listen on IPv6 in /etc/nginx/conf.d/default.conf
/docker-entrypoint.sh: Launching /docker-entrypoint.d/20-envsubst-on-templates.sh
/docker-entrypoint.sh: Launching /docker-entrypoint.d/30-tune-worker-processes.sh
/docker-entrypoint.sh: Configuration complete; ready for start up
2021/08/14 07:41:54 [notice] 1#1: using the "epoll" event method
2021/08/14 07:41:54 [notice] 1#1: nginx/1.21.1
2021/08/14 07:41:54 [notice] 1#1: built by gcc 8.3.0 (Debian 8.3.0-6) 
2021/08/14 07:41:54 [notice] 1#1: OS: Linux 5.4.0-80-generic
2021/08/14 07:41:54 [notice] 1#1: getrlimit(RLIMIT_NOFILE): 1048576:1048576
2021/08/14 07:41:54 [notice] 1#1: start worker processes
2021/08/14 07:41:54 [notice] 1#1: start worker process 36
2021/08/14 07:41:54 [notice] 1#1: start worker process 37
2021/08/14 07:41:54 [notice] 1#1: start worker process 38
2021/08/14 07:41:54 [notice] 1#1: start worker process 39
2021/08/14 07:41:54 [notice] 1#1: start worker process 40
2021/08/14 07:41:54 [notice] 1#1: start worker process 41
2021/08/14 07:41:54 [notice] 1#1: start worker process 42
2021/08/14 07:41:54 [notice] 1#1: start worker process 43

きちんと実行ができました。
この通り、クライアントサイドではDCTを有効にしている場合、署名されたものしか実行されず更に検証も行われているようです。