Skip to main content

Koでコンテナイメージを簡単に作成

feature building go images

2022年10月10日

0 分で読めます

以前の記事では、Google Open SourceグループのJibツールを使ってJavaアプリケーションのコンテナイメージを構築する方法、そしてその理由について紹介しました。Jibは、Dockerのようなコンテナランタイムを必要とせず、ベストプラクティスに準拠した、スリムでJVMベースのOCI準拠イメージを構築します。また、Dockerfileの作成と管理も不要になります。では、Goアプリケーションを構築する場合はどうでしょうか。Goにも同様に使える、Koというオープンソースツールがあります。

注:この記事の執筆中に、KoプロジェクトはGoogleが所有するGitHubリポジトリから、独立したトップレベル組織「ko-build」へ移行しました。この変更を反映するため、記事内の表記を更新しましたが、オンラインやモジュール名には、しばらく「Google Ko」という表記が残っている場合があります。いずれも同じプロジェクトですのでご安心ください。この移行に至るほど成功を収めたKoプロジェクトに、お祝いを申し上げます。

この記事では、Koを使ってDockerfileなしでコンテナイメージを構築する方法、SBOM、Kubernetesとの統合について解説します。

Koとは?

Koは、開発プロセスで現在goコンパイラを実行している箇所に代わって使うことを想定した、単一バイナリのコマンドラインツールです。アプリケーションをコンパイルするだけでなく、アプリケーションをインストールした非常に軽量なコンテナイメージも生成します。Jibと同様に、設定や実行方法に応じて、イメージをレジストリにプッシュするか、ローカルのDockerイメージキャッシュに格納します。

Koにはさらに、ソフトウェア部品表(SBOM)の作成やKubernetesとの統合に関する機能もあり、反復的な開発とデプロイのプロセスをとてもシンプルにできます。

Koが解決しようとしている課題

使用する言語を問わず、多くのプログラマーはコンテナの構築に慣れておらず、イメージのビルドについて次のような疑問を抱きがちです。

  • どのベースイメージを使えばよいのか。組織のポリシーに準拠しているのか。

  • レイヤーの肥大化を抑えるには、コマンドをどう組み合わせればよいのか。

  • アプリケーションを実行するために、アプリのどの部分をイメージにコピーすればよいのか。

  • イメージのビルドにはどのツールを習得する必要があるのか。(Docker?Buildah?BuildKit?)

  • 組織の要件に応じて、含めるべき標準アノテーションはあるのか。

アプリケーションアーキテクトやリーダーは、ガバナンスや標準の実装をチームが容易に遵守できるようにしたいと考えています。しかし、各チームが独自に作り上げてきたDockerfileのパターンを使っていると、一貫した運用を維持するのは困難です。

セキュリティチームは、イメージに何が含まれるか、また構築にどのツールが使われるかにも大きな関心を寄せています。たとえば、CIビルドノードにDockerエンジン、つまりホストマシンのdocker.sockを公開すると、ビルド環境にそのノード上で高い権限を与えることになりかねません。

「[Docker]デーモン…には、ビルドやレジストリとのやり取り以外にも多くの機能があります。追加のセキュリティツールがなければ、このマシンでdocker buildを実行できるユーザーは、docker runを使って好きなコマンドを実行することもできます…。好きなコマンドを実行できるだけでなく、この権限を悪意ある行為に利用された場合、誰が実行したのかを特定するのは困難です。」

— Liz Rice著『Container Security』第6章

一方で、新しいビルドツールの導入は複雑になり、ビルドシステムの担当者にとって学習の負担が増す可能性があります。Koは、開発者に優れた体験を提供しながら、これらの課題それぞれに対処することを目指しています。

Koを使ったイメージのビルドと、使わない場合の比較

Koが上記の課題にどう対処するのかを説明するため、まず既存のGo + Dockerによるビルド手順と比較してみましょう。

Koを使わずにイメージをビルドする

この例では、Goの定番チュートリアルアプリケーションである「Hello World」のWebアプリをビルドします。

1.空のディレクトリに、次の内容をhello.goとして作成します。

package main

import (
"fmt"
"net/http"
)

func main() {
http.HandleFunc("/", HelloWebServer)
http.ListenAndServe(":8080", nil)
}

func HelloWebServer(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Hello, %s!", r.URL.Path[1:])
}

2.よく使われるパターンなので、Dockerfile内でバイナリをビルドします。

FROM golang AS build
WORKDIR /go/src
COPY . .
RUN GOOS=linux go build -ldflags "-linkmode external -extldflags -static" -a hello.go

FROM gcr.io/distroless/static:nonroot
USER nonroot:nonroot
COPY --from=build /go/src/hello .
EXPOSE 8080
CMD ["./hello"]

これはマルチステージビルドです。最初のステージはbuildという名前で、公式golangイメージをベースにしています。このステージでは、アプリの内容(現時点ではhello.goファイルのみ)をイメージ内の/go/srcにコピーし、静的バイナリを生成するために必要なパラメーターを指定してgo buildツールを実行します。

2つ目のステージでは、Google Distrolessプロジェクトのstatic:nonrootベースイメージを使い、デフォルトユーザーをnonrootに設定します。buildステージからhelloバイナリをルートディレクトリにコピーし、公開するポートのメタデータを設定して、コンテナ起動時にデフォルトで./helloが実行されるようにします。

なぜ2つのステージを使うのでしょうか?

Dockerfile内でアプリケーションをビルドするのは一般的な方法です。これにより、開発者のワークステーションでも自動ビルドでも、適切なバージョンのコンパイラが使われることが保証されます。しかし、ビルドに使用したイメージをそのままデプロイするのはよくある間違いです。コンパイラなどの不要なツールだけでなく、アプリケーションのソースコードまでデプロイ先のイメージに含まれてしまうため、セキュリティリスクとなります。攻撃者が脆弱性を悪用したり、イメージを入手したりした場合、攻撃を拡大するために利用できる情報やツールが数多く手に入ります。distroless/static:nonrootベースイメージを使うことで、デフォルトユーザーがroot以外に設定された、極めて最小限のファイルシステムを実現できます。

3.Dockerfileの準備ができたら、docker buildコマンドを使ってイメージをローカルキャッシュに作成し、タグを付けます。

$ docker build -t localhost:5000/hello-go:1.2.3 .
[+] Building 5.6s (12/12) FINISHED
 => [internal] load build definition from Dockerfile
 => => transferring dockerfile: 298B
 => [internal] load .dockerignore
 => => transferring context: 73B
 => [internal] load metadata for gcr.io/distroless/static:nonroot
 => [internal] load metadata for docker.io/library/golang:latest
 => [stage-1 1/2] FROM gcr.io/distroless/static:nonroot
 => CACHED [build 1/4] FROM docker.io/library/golang
 => [internal] load build context
 => => transferring context: 5.15kB
 => [build 2/4] WORKDIR /go/src
 => [build 3/4] COPY . .
 => [build 4/4] RUN GOOS=linux go build -ldflags "-linkmode external -extldflags -static" -a hello.go
 => [stage-1 2/2] COPY --from=build /go/src/hello .
 => exporting to image
 => => exporting layers
 => => writing image sha256:eb9735e61e1dec63bd557dd5c61d8789733f2f4456a0d01687816bd0d135a7bc
 => => naming to localhost:5000/hello-go:1.2.3

$ docker images
REPOSITORY               TAG    IMAGE ID      CREATED         SIZE
localhost:5000/hello-go  1.2.3  eb9735e61e1d  11 minutes ago  9.23MB

注:実行しているDockerのバージョンやBuildKitの拡張機能が有効かどうかによって、出力は多少異なる場合があります。

4.他の人がこのイメージを使えるようにするには、レジストリリポジトリにプッシュする必要があります。この例では、DockerHubのregistry:2イメージを使って、ワークステーションのポート5000でローカルリポジトリを実行しています。(そのため、イメージにlocalhost:5000/のプレフィックスを付けています。)

Dockerを使ったレジストリへのプッシュは、docker pushコマンドで簡単に行えます。

$ docker push localhost:5000/hello-go:1.2.3
The push refers to repository [localhost:5000/hello-go]
0ba424468cb9: Pushed
ca623f32e759: Pushed
1.2.3: digest: sha256:ddcabff499d90fdf6850ef0b7addb33db7def…

注:マネージドレジストリの場合は、先にdocker loginで認証する必要があります。

5.最後に、イメージを実行するにはdocker runか、適切なkubectlデプロイを使います。ここでは、よりシンプルな前者を使います。

$ docker run --rm -d -p 8080:8080 localhost:5000/hello-go:1.2.3
Unable to find image 'localhost:5000/hello-go:1.2.3' locally
1.2.3: Pulling from hello-go
45e68f4d0d8c: Already exists
ce1a03145a01: Already exists
Digest: sha256:ddcabff499d90fdf6850ef0b7addb33db7defe4669f9af1079894e84ad407199
Status: Downloaded newer image for localhost:5000/hello-go:1.2.3
2ef3c3dc0363ad10e9bf21baa7e78c63ca4df904bcba1fdab3af7732fce3857d

次に、シンプルなcurlコマンドでアプリをテストします。

$ curl http://localhost:8080/Patch
Hello, Patch!

ここまでの手順を図にすると、次のようになります。

GoとDockerのワークフローを示す図。GoのビルドとDockerfileからDockerイメージを作成し、レジストリにプッシュして、コンテナまたはKubernetesで実行する流れ。

それでは最初に戻り、Koを使う場合の流れを見てみましょう。

Koを使ってイメージをビルドする

1.空のディレクトリに、同じhello.goファイルを用意します。

package main

import (
"fmt"
"net/http"
)

func main() {
http.HandleFunc("/", HelloWebServer)
http.ListenAndServe(":8080", nil)
}

func HelloWebServer(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Hello, %s!", r.URL.Path[1:])
}

2.次に、イメージレジストリを環境変数に設定し、ko buildを実行します。

$ export KO_DOCKER_REPO=localhost:5000
$ ko build hello.go
2022/09/19 14:12:37 No matching credentials were found, falling back on anonymous
2022/09/19 14:12:40 Using base gcr.io/distroless/static:nonroot@sha256:2a9e2b4fa771d31fe3346a873be845bfc2159695b9f90ca08e950497006ccc2e for hello.go
2022/09/19 14:12:40 Building hello.go for linux/amd64
2022/09/19 14:12:44 Publishing localhost:5000/hello.go-7a204cfb24536a350234d9132276cae7:latest
2022/09/19 14:12:44 existing blob: sha256:2952e4f69ebf4bea5cc557f73626f95649cb546424fd998481ba690a08d9db7f
2022/09/19 14:12:44 existing blob: sha256:a706af0bb599ee120bd57c0e6abca55f66fd714f9e74706d9c97a583fc79d37e
2022/09/19 14:12:44 localhost:5000/hello.go-7a204cfb24536a350234d9132276cae7:sha256-3925979ac92afb8cb89fc80438097873daf067195e4d0b9d2fd6f55d6201355c.sbom: digest: sha256:7d444debc3cd2d5545e88606dc529fa7ed90b1bb581ff8ac30b0f475b68d4ec0 size: 367
2022/09/19 14:12:44 Published SBOM localhost:5000/hello.go-7a204cfb24536a350234d9132276cae7:sha256-3925979ac92afb8cb89fc80438097873daf067195e4d0b9d2fd6f55d6201355c.sbom
2022/09/19 14:12:44 existing blob: sha256:5ec5232d47ab0ad088792a191b672fe6ec27db63b19daebd7322ad64a2cd8676
2022/09/19 14:12:44 existing blob: sha256:250c06f7c38e52dc77e5c7586c3e40280dc7ff9bb9007c396e06d96736cf8542
2022/09/19 14:12:45 pushed blob: sha256:2d23903e55394a021ba4936cfad8ccbec6998413164416fcae4f6bf888665fce
2022/09/19 14:12:45 pushed blob: sha256:1cd0595314a53d179ddaf68761c9f40c4d9d1bcd3f692d1c005938dac2993db6
2022/09/19 14:12:45 localhost:5000/hello.go-7a204cfb24536a350234d9132276cae7:latest: digest: sha256:3925979ac92afb8cb89fc80438097873daf067195e4d0b9d2fd6f55d6201355c size: 750
2022/09/19 14:12:45 Published localhost:5000/hello.go-7a204cfb24536a350234d9132276cae7@sha256:3925979ac92afb8cb89fc80438097873daf067195e4d0b9d2fd6f55d6201355c
localhost:5000/hello.go-7a204cfb24536a350234d9132276cae7@sha256:3925979ac92afb8cb89fc80438097873daf067195e4d0b9d2fd6f55d6201355c

このコマンドは、次の処理を行います。

  • アプリケーションを静的バイナリにコンパイル

  • 適切な形式のイメージに格納

  • レジストリにプッシュ

これらの作業にDockerfileもコンテナランタイムエンジンも必要ありません。さらに、出力にはSBOMが作成されて公開されたことも示されています。これについては後ほど説明します。

注:ここでのイメージタグにはmd5ハッシュが含まれています。デフォルトでは、Goアプリケーションのインポートパスがハッシュになります。この例には完全なGoモジュールがないため、ハッシュはhello.goから取得されます。タグの詳細はKoのドキュメントをご覧ください。

3.次に、docker runでイメージを実行します。

$ docker run --rm -d -p 8080:8080 localhost:5000/hello.go-7a204cfb24536a350234d9132276cae7:latest
Unable to find image 'localhost:5000/hello.go-7a204cfb24536a350234d9132276cae7:latest' locally
latest: Pulling from hello.go-7a204cfb24536a350234d9132276cae7
1cd0595314a5: Pull complete
250c06f7c38e: Pull complete
5ec5232d47ab: Pull complete
Digest: sha256:3925979ac92afb8cb89fc80438097873daf067195e4d0b9d2fd6f55d6201355c
Status: Downloaded newer image for localhost:5000/hello.go-7a204cfb24536a350234d9132276cae7:latest
WARNING: The requested image's platform (linux/amd64) does not match the detected host platform (linux/arm64/v8) and no specific platform was requested
70877dcd53d23c66873e8e1e5a2092b46740e699b214a545e386113e5e098d7c

…そしてcurlでテストします。

curl http://localhost:8080/ko
Hello, ko!

Koのパイプラインを図にすると、次のようになります。

KO buildがイメージをレジストリに送信し、その後DockerまたはKubernetesが暗黙的なイメージプルによってコンテナを実行する様子を示す図

イメージの作成、構築、プッシュという負荷の高い作業をKoに任せることで、必要な手順を減らせました。また、ビルド時のコンテナランタイムエンジンへの依存や、Dockerfileに必要だった専門知識とメンテナンスの負担も軽減されます。

賢いデフォルト設定と、決定論的な特化設定

Koのユーザーは、オープンソースコミュニティが蓄積してきたベストプラクティスを活用できます。しかし、組織独自の標準がそれらに適合しない場合はどうでしょうか。そんなときに便利なのが、Koのコマンドラインオプションやko.yaml設定ファイルです。

カスタムベースイメージ

たとえば、会社でGoアプリケーションのベースとして使用する特定のイメージが決められているとします。トップレベルディレクトリにあるko.yamlファイルに、そのイメージを値としてdefaultBaseImage:の行を追加するだけです。

defaultBaseImage: repo.mycorp.com/myteam/corp-approved-scratch:220923

Goコンパイラのフラグ

元の例のようにGoコンパイラの-ldflagsを明示的に指定するには、同じko.yamlファイルにbuilds:エントリを追加し、Koのドキュメントに記載されている適切な設定を指定します。

builds:
 - id: hello
   dir: .
   main: hello.go
   env:
     - GOOS=linux
     - CGO_ENABLED=0
   ldflags:
     - -extldflags "-static"
     - -linkmode external

Dockerラベル

Dockerラベル(OCIアノテーションとも呼ばれます)は、イメージにメタデータを付与する方法です。イメージのソースリポジトリや作成したCIビルドなどの情報や、チームが埋め込みたいその他のデータを記録できます。イメージラベルの詳細と利用すべき場面については、Dockerラベルの使い方と活用するタイミングに関する私のブログをご覧ください。

Koでは、コマンドラインの--image-labelフラグを使ってラベルを追加できます。

$ ko build hello.go --image-label foo=bar -L –image-label org.opencontainers.image.source=https://repo.mycorp.com/superteam/hello
…
ko.local/hello.go-7a204cfb24536a350234d9132276cae7:acf1dce794131205d488ed8fe4818866ebf509a61f4cf60aa07463e2b054d97d

$ docker image inspect ko.local/hello.go-7a204cfb24536a350234d9132276cae7:latest --format '{{json .Config.Labels}}'
{"foo":"bar","org.opencontainers.image.source":"https://repo.mycorp.com/superteam/hello"}

注:この記事の公開時点では、.ko.yaml設定ファイルからイメージラベルを指定する方法はありません。ただし、この機能の追加を求めるissueが公開されています。

Koのその他の便利な機能

イメージのSBOM

前述のとおり、Koは新しいコンテナイメージのSBOMを自動的に生成してプッシュします。デフォルトの形式はSPDXですが、コマンドラインで--sbom=cyclonedxフラグを渡せばCycloneDXも利用できます。--sbom=noneを指定して、この機能を無効にすることもできます。これらの設定などについてはKoのドキュメントをご覧ください。

SBOMとセキュアなサプライチェーンの構築における役割について詳しく説明することは、この記事の範囲を超えます。詳細を知りたい方は、オープンソースのサプライチェーンセキュリティに向けたSBOMの作成をご覧ください。

Kubernetesとの統合

Kubernetesクラスター上でイメージをテストする必要がある場合、次のような手順を何度も繰り返す手間に悩まされたことがあるでしょう。

  • イメージをビルドする

  • レジストリにデプロイする(または、別の方法でKubernetesクラスターに読み込む)

  • 新しいイメージタグでデプロイメントYAMLを更新する

  • kubectlで再デプロイする

Koを使えば、これらすべての手順をko applyという1つのコマンドにまとめて自動化できます。デプロイメントYAMLのイメージタグを、専用形式のko://に置き換えるだけで、ko applyが上記の処理をすべて自動で実行します。デプロイ先は、kubectlの設定コンテキストで現在選択されているクラスターです。

たとえば、サンドボックスまたはローカルでKubernetesクラスターを実行し、開発を進めながら繰り返しデプロイしてテストする場合を考えてみましょう。「hello」アプリケーションをデプロイするためのマニフェストは次のとおりです。

apiVersion: apps/v1
kind: Deployment
metadata:
 name: hello-server
spec:
 selector:
   matchLabels:
     run: hello-server
 replicas: 2
 template:
   metadata:
     labels:
       run: hello-server
   spec:
     containers:
       - name: hello-server
         imagePullPolicy: IfNotPresent
         image: ko://github.com/myteam/hello
         ports:
           - containerPort: 8080
             name: http

image:の行にはko://が指定され、その後にGitHub上のアプリケーションのGoモジュールへのパスが続いていることがわかります。

あとはko applyを実行し、kubectlと同じように-fフラグでYAMLファイルを指定するだけです。

$ ko apply -f myfile.yaml
2022/09/27 14:35:51 Using base gcr.io/distroless/static:nonroot@sha256:2a9e2b4fa771d31fe3346a873be845bfc2159695b9f90ca08e950497006ccc2e for github.com/myteam/hello
2022/09/27 14:35:51 Building github.myteam/hello for linux/amd64
…
2022/09/27 14:35:56 Published myteam/golang-ea0a77f5cbe6ba6aea599ad83048ae7b@sha256:bd7eb1052cf5b8664890f23683930f07423aba0089150bb818a53e76ef2ebf68
deployment.apps/hello-server created

$ k get pods
NAME                                READY   STATUS    RESTARTS   AGE
pod/hello-server-5b5cc95db4-gc9rn   1/1     Running   0          10s
pod/hello-server-5b5cc95db4-rhwnp   1/1     Running   0          10s

これでアプリケーションをテストし、変更を加え、必要なだけko apply -f myfile.yamlを再実行できます。面倒な作業はすべてKoが処理してくれます。

もちろん、次のような他のKubernetesコマンドも必要になるでしょう。

  • デプロイメントを削除するdelete

  • kubectlやその他のツールに渡せるYAMLを生成するresolve

詳しいドキュメントについては、https://github.com/ko-build/ko#kubernetes-integrationをご覧ください。

Koを使用する際の課題

Koをワークフローに取り入れるメリットについてはすでにご紹介しました。導入を始める前に、Koの使用時に直面する可能性のある課題について説明します。

クロスプラットフォームに関する懸念事項

Linux以外のマシンで開発している場合、コンテナランタイムがないことで、作業が少し難しくなることがあります。コンテナベースのビルドイメージを使えば、ビルドツールだけでなくOSやそのライブラリも共通のベースビルドイメージにまとめられるため、開発用マシンのプラットフォームが本番環境と異なることを実質的に無視できます。

たとえば、上記のGoとDockerを使った例は、ほぼすべてのプラットフォームで動作します。どこでビルドするかにかかわらず、golangベースイメージには、Goビルダーが静的バイナリを作成するために必要なDebianベースのglibcライブラリが含まれているからです。一方、たとえばMacOSマシンで同じldflagsオプションを指定してKoの例を実行しようとすると、静的リンクに必要なライブラリが見つからないというエラーが発生します。

これは、MacOS/DarwinにはLinux向けの静的バイナリをクロスコンパイルするために必要なライブラリが含まれていないためです(少なくとも、この記事の執筆時点では)。Appleだけの問題ではなく、LinuxやWindows WLSのワークステーションでCPUアーキテクチャが異なる場合にも、同様の問題が発生することがあります。

スキルの衰退

適切に設計されたコンテナイメージの作成・保守に必要なスキルや知識は、貴重な資産です。それらを抽象化したり、不要にしたりするツールを使うと、問題発生時に対処する能力が低下する可能性があります。実際、チーム内でコンテナ技術を理解している人が少ないほど、実行時のトラブルシューティングや保守では、必要な経験を持つ人に頼る度合いが高まります。深刻な場合、燃え尽きや離職、サービス停止につながることもあります。

セキュリティへの油断

コンテナイメージの構築プロセスを完全に自動化し、開発者の手から切り離すと、イメージの脆弱性スキャンなどのプロセスが適切に実施されなくなるおそれがあります。更新されていないイメージやパッケージ、ライブラリなどに脆弱性が入り込み、アプリケーションが攻撃にさらされる可能性があります。そのため、次のような別の自動化手段を使って、スキャンを継続してください。

  • ビルドスクリプトやMakefile

  • Gitフック

  • CIのビルドステップ

イメージスキャンをまだ実施していない場合は、Snykの無料コンテナイメージスキャンをご利用ください。イメージ内の脆弱性を検出し、簡単に実行できる具体的な修正方法を提示します。

コンテナイメージをシンプルに

Koをはじめとする代替イメージ構築ツールを使うと、Goアプリケーションのコンテナイメージ作成を大幅に簡素化し、チームでのイメージ構築を効率化・標準化できます。特に大きなメリットが得られるのはCIツールです。ビルド環境にコンテナランタイムを公開する必要がなくなり、SDLCにおける特権アクセスを狙った攻撃経路を大幅に減らせます。Koのようなビルドツールを使うかどうかにかかわらず、チームがコンテナ技術と、イメージ構築に使うプロセスやツールに伴うセキュリティ上の影響を十分に理解していることを確認してください。

CTFを始めよう

オンデマンドのバーチャル入門ワークショップで、CTFチャレンジの解き方を学びましょう。

続きを読む

feature customer snowflake
Article

開発初期からセキュリティを確保:Snowflake Cortex Code向けSnyk Studioインテグレーションを発表

Snyk StudioがSnowflake Cortex Codeと連携し、開発中にAI生成コード、依存関係、コンテナの脆弱性をスキャンします。

blog feature pypi spoof
Article

オープンソースのメンテナーに、Snyk AI Security Platformをすべて無料で提供

オープンソースのメンテナーは、実際の脆弱性レポートに追われ、優先順位付けや修正、修正版のリリースを迅速に進めるための支援を必要としています。SnykのSecure Developer Programでは、条件を満たすプロジェクトにSnyk AI Security Platformを無料で提供します。

feature insights announcement
Blog

Node-gypサプライチェーン侵害:binding.gypに潜む自己増殖型npmワーム

新たなnpmワームがbinding.gypを悪用し、インストール時にnode-gypを起動。ライフサイクルスクリプトを使わずに悪意あるパッケージからコードを実行します。認証情報を窃取し、GitHub上に潜伏し、メンテナー間で自己増殖します。