はじめに

この章ではDockerfileを作成し、イメージをビルドしてアプリケーションをローカルマシンで起動させることをゴールとします。

docker imageは、アプリケーションとその依存関係をひとまとめにした“実行可能なパッケージ”であり、これを元にコンテナが作成されます。そしてこれは配布可能なものになっています。

このハンズオンにおける「デプロイ」とは、ビルドした docker imageを実行環境(ローカル/AWS)に参照させ、アプリケーションとして動かすことだといえます。

構成図で言うと、以下の部分です。

コンテナイメージについて

そもそも、このハンズオンのゴールはEKS環境でアプリケーションが動くようにすることです。そこで、まずはじめにコンテナイメージを作ることから始めます。

そこで、コンテナイメージとは何か、そしてどう使われるのかを理解し、その必要性について知るところから始めます。

コンテナイメージとは何か

コンテナイメージとは、ホストOS上で「コンテナ」を作成するための諸々がまとまったパッケージです。

まずは「仮想コンテナ」とは何かについて知る必要がありますが、なんとなくでいいので、以下のようにざっくり把握しておきましょう。

  • コンテナとは、アプリケーションを実行するための軽量な仮想環境
  • ホストOSのカーネルを共有しつつ、プロセス・ファイル・ネットワークを分離して動作する

docker公式より引用(https://www.docker.com/ja-jp/resources/what-container/)

コンテナイメージを元に、「コンテナ」を実行する感じです。

コンテナレジストリ

そして、コンテナイメージとは配布可能なものとも述べました。イメージは、コンテナレジストリという、「作成したコンテナイメージを管理する場所」を介することで配布または使用することが可能になっています。

代表的なコンテナレジストリ:

コンテナイメージは、依存関係を含む複数のレイヤーによって構成されています。これらのレイヤーは、コンテナレジストリにイメージとして保存されており、そのイメージを Pull することでまとめて取得・利用できます。今回作成するイメージはgoアプリケーションになりますが、ベースとなるgoのイメージをDocker HubからPullしてビルドする形になります。

Amazon ECRも後章で登場します。

ここでは一旦、「このようなコンテナレジストリにコンテナイメージを保存して、そこから取ってきて利用する」と認識しておきましょう。

コンテナイメージはどう使われるのか

コンテナイメージは、「コンテナ」の起動に利用されると述べました。

イメージを用いてコンテナを起動する役割を果たしているのが、コンテナランタイムです。

コンテナランタイム

コンテナランタイムは、いくつか代表的なものがあります。

ちなみに、EKSで現在採用されているコンテナランタイムはcontainerdです。

https://docs.aws.amazon.com/ja_jp/eks/latest/userguide/eks-optimized-ami.html

正直、筆者はこのように疑問を感じました「コンテナ実行といえばDockerだから、Docker Engine以外はなんなんだ?」「手元でdocker buildしたイメージが、Docker Engineではなくcontainerdで動く....?」

....などなど。

ここは整理しておきたいところなので、しっかり扱います。

OCI(Open Container Initiative)

コンテナを扱う上で登場した3つの要素について、以下のような仕様があります。

  • the Image Specification (image-spec)
  • the Distribution Specification (distribution-spec)
  • the Runtime Specification (runtime-spec)
https://opencontainers.org/

image-specについては、コンテナイメージの仕様についてです。docker buildだけでなく、イメージの作成方法は様々あります。しかし、結果的に作成されるイメージがimage-specに沿っていればよく、image-specの仕様を解釈できる高レベル(※後述)コンテナランタイム上なら何製のイメージでも扱えるわけです。

At a high-level an OCI implementation would download an OCI Image then unpack that image into an OCI Runtime filesystem bundle.

訳:高レベルの OCI 実装は、OCI イメージをダウンロードし、そのイメージを OCI Runtime のファイルシステムバンドルに展開する、という流れになります。

https://github.com/opencontainers/image-spec?tab=readme-ov-file

distribution-specについては、コンテナレジストリの仕様についてです。レジストリには、イメージをPushして保存しPullして取得するワークフローが定められています。

https://github.com/opencontainers/distribution-spec/blob/main/spec.md

runtime-specについては、コンテナをどう起動するかの仕様についてです。コンテナを実行・制御するための操作のinterfaceについてなどが書いてあります。

  • create/start/kill/delete/state
https://github.com/opencontainers/runtime-spec/blob/main/runtime.md#operations

また、以下の形式をもつFilesystem Bundleを元にコンテナは実行される仕様です。

bundle
├── config.json   ← 起動方法・権限・namespace・cgroup 設定
└── rootfs/       ← コンテナのルートファイルシステム
https://github.com/opencontainers/runtime-spec/blob/main/bundle.md

実は、runtime-specに準拠して、bundleを元にコンテナを実行するのは、先ほど挙げたコンテナランタイムらではありません。以下のような、コンテナを実際に起動する低レベルのコンテナランタイムが直接的にコンテナの操作を担います。

先ほど挙げた、

  • containerd
  • CRI-O
  • Docker Engine

らは、「runtime-specに準拠した低レベルのランタイムを利用する側」の存在です。 image-specに沿ったコンテナイメージから、runtime-specに沿った形式のbundleを用意して、低レベルのコンテナランタイムに渡すという役割を果たします。そのため、これらは高レベルのコンテナランタイムと表せます。先ほども引用した文章を再度示します。

At a high-level an OCI implementation would download an OCI Image then unpack that image into an OCI Runtime filesystem bundle.

訳:高レベルの OCI 実装は、OCI イメージをダウンロードし、そのイメージを OCI Runtime のファイルシステムバンドルに展開する、という流れになります。

なお、高レベルのランタイムが低レベルのランタイムを隠蔽しているため、開発者が普段runcなどについて意識することはないと思います。

ここまでの話を図解すると以下のように整理できます。

コンテナイメージをビルドツールも、それを保管するレジストリも、それを実行するランタイムも様々ありますが、OCIで標準化されているからこそ、組み合わせは自由ということですね。

kubernetesの文脈で、高レベルのコンテナランタイムにも標準のインターフェースが定められています。それが、CRI(Container Runtime Interface)です。

https://kubernetes.io/ja/docs/concepts/architecture/cri/

この辺については、次の3章で取り上げます。

開発環境でDockerがよく利用される理由

EKSで動く高レベルのコンテナランタイムはcontainerdであることは先述しました。ただ、EKSでもコンテナランタイムとしてDocker Engineが使用されていた過去もあったようですね(理由はkubernetesの文脈なので、次章で触れます)。

https://aws.amazon.com/jp/blogs/containers/all-you-need-to-know-about-moving-to-containerd-on-amazon-eks

Fargateでも同じくDocker Engineからcontainerdに移行しているんですね。

下のの記事から、以下のような文章を引用します。

However, Fargate provides most of that functionality natively already and it doesn’t need all the bells and whistles that Docker provides

https://aws.amazon.com/jp/blogs/containers/aws-fargate-launches-platform-version-1-4/

「Dockerの提供してくれる付加機能は全て必要ない」って書いてますね。そういえば、結局はDocker Engineも内部でcontainerdを使っているんですよね....。

いったい、Docker Engineって何を提供してくれているんでしょうか。

Docker Engineは何を提供するのか

以下の図がわかりやすいです。 https://www.docker.com/ja-jp/products/container-runtime/

色々用意されているんですね。全てDockerというエコシステムでコンテナ関連の技術が完結できそうです。ただ、Fargateにしろ、kurbenetesにしろ、各々のplatformで用意している機能があります。以下のスライドでは、kurbernetesの提供する機能性とDockerが提供する機能性に被りがあることが述べられています。

https://speakerdeck.com/inductor/the-rise-and-fall-of-the-container-runtimes-surrounding-kubernetes?slide=28

そういったplatformでは、機能性てんこ盛りのDocker Engineを脱却して、よりコンテナランタイムをシンプルに扱うためにcontainerdを使うようにしたりしているみたいです(次章で取り上げますがk8sの文脈だと別のも理由もあると思われます)。

コンテナの時代が到来した当初は、事実上Docker Engineが標準のコンテナランタイムだったのだと思います。あれもこれもが元々はDocker Engineを利用していたというのはその歴史的な文脈が強いと思われます。

https://zenn.dev/ttnt_1013/articles/f36e251a0cd24e#4.3.-container%3Ddocker%E6%9C%9F%EF%BC%882012~2015%E5%B9%B4%E3%81%94%E3%82%8D%EF%BC%89

開発環境ならDockerは依然として便利

それでも、これだけ多くの機能性を持つDockerはローカルマシン上だととても便利です。 Docker Engine には、

  • コンテナ間ネットワークの簡単な構築
  • volume を用いた永続化やコード共有
  • docker compose による複数コンテナ構成の定義
  • イメージビルドから実行までを一貫して扱える CLI

といった、開発者体験を意識した機能が揃っています。

そういったわけで、この章では、Docker Engineを用いてアプリケーションを動かしていくことにします。

Dockerfileを書く

アプリケーションのソースコードを用意

本質じゃないのでここは特に解説しません。 golangで実装されたありきたりなTodoListです。永続化とかはしません。

以下のように/appを切って、コードをコピペしておきましょう。

.
├── app
│   ├── index.html
│   └── main.go
main.go
package main

import (
	"html/template"
	"log"
	"net/http"
	"sync"
)

// Todo represents a single todo item
type Todo struct {
	Title string
}

// In-memory storage for todo items
type TodoListStore struct {
	mu       sync.Mutex
	todoList []Todo
}

var todoListStore = &TodoListStore{
	todoList: []Todo{},
}

// Template for rendering the todo list
var tmpl = template.Must(template.ParseFiles("./index.html"))

// getTodoHandler handles GET requests to retrieve the todo list
func getTodoHandler(w http.ResponseWriter, r *http.Request) {
	tmpl.Execute(w, todoListStore.todoList)
}

// postTodoHandler handles POST requests to add a new todo item
func postTodoHandler(w http.ResponseWriter, r *http.Request) {
	if err := r.ParseForm(); err != nil {
		http.Error(w, "Failed to parse form", http.StatusBadRequest)
		return
	}
	title := r.FormValue("title")
	if title != "" {
		todoListStore.mu.Lock()
		todoListStore.todoList = append(todoListStore.todoList, Todo{Title: title})
		todoListStore.mu.Unlock()
	}

	http.Redirect(w, r, "/todos", http.StatusSeeOther)
}

// responseRecorder is a custom ResponseWriter to capture the status code
type responseRecorder struct {
	http.ResponseWriter
	status int
}

func (r *responseRecorder) WriteHeader(code int) {
	r.status = code
	r.ResponseWriter.WriteHeader(code)
}

// accessLogMiddleware logs each incoming HTTP request
func accessLogMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		rec := &responseRecorder{ResponseWriter: w, status: 200}
		next.ServeHTTP(rec, r)
		log.Printf("%s %s %s %d", r.RemoteAddr, r.Method, r.URL, rec.status)
	})
}

func main() {
	mux := http.NewServeMux()

	mux.Handle("GET /todos", http.HandlerFunc(getTodoHandler))
	mux.Handle("POST /todos", http.HandlerFunc(postTodoHandler))
	mux.Handle("GET /healthz", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusOK)
	}))

	http.ListenAndServe(":8080", accessLogMiddleware(mux))
}
index.html
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <title>Todo List</title>
  </head>
  <body>
    <h1>Todo List</h1>
    <form method="POST" action="/todos">
      <input type="text" name="title" placeholder="New Todo" required />
      <button type="submit">Add</button>
    </form>
    <ul>
      {{range .}}
      <li>{{.Title}}</li>
      {{else}}
      <li></li>
      {{end}}
    </ul>
  </body>
</html>

Dockerfileの記述

それでは、Dockerfileを記述していきます。

マルチステージビルド

今回扱っているのがgolangアプリケーションということで、「マルチステージビルド」の構成で記述していきます。

マルチステージの構成にすることで、最終的なイメージを軽量にすることができます。

https://docs.docker.jp/develop/develop-images/multistage-build.html

以下の図のように、「バイナリをコンパイル→実行」までをまとめたDockerfileをビルドすると、コンパイルの過程でのみ必要であったGoコンパイラ等々が不要に残ったイメージが生成されます。一方、マルチステージでは、「バイナリのコンパイル」と「バイナリ実行」をステージで分けることで、「バイナリのコンパイル」で利用した諸々は捨てて、コンテナ実行に必要なLinuxのベース+バイナリのみを最終的なイメージに含めることができます。

コンテナイメージは、依存関係や変更差分を「レイヤー」として積み重ねた構造を持ちます。単一のステージでまとめた時と、マルチステージで記述したDockerfileでそれぞれコンテナイメージをビルドして比較したところ、以下のような差が出ました。

項目 マルチステージ シングルステージ 差分
レイヤー数 8層 19層 +11層
イメージサイズ 109MB 924MB +815MB (約8.5倍)

最終的なイメージから余計なレイヤーを省くことができ、その結果としてサイズも少なくすることができるんですね。

ビルドステージ

まずはバイナリをコンパイルしていくビルドステージの記述をしましょう。

# Dockerfile
# Build stage
FROM golang:1.25-bookworm AS builder

WORKDIR /src

COPY . .

WORKDIR /src/app

RUN GOOS=linux GOARCH=amd64 go build -o todo-app main.go

golang:1.25-bookwormは、Debian上に Go 1.25 がインストールされた公式Goイメージです。 Docker Hubにて配布されています。

https://hub.docker.com/layers/library/golang/1.25-bookworm/images/sha256-cfacfe031d4eb1c1ad50593e4988cc9e8286ff1ca5f33bf357eb863fddc08073

以下では、Linuxのamd64アーキテクチャで動作するように指定してバイナリをコンパイルしています。

RUN GOOS=linux GOARCH=amd64 go build -o todo-app main.go

これは、Amazon Linux 2023でも確実に動くように明示しておくためです。

Our supported architectures include x86_64 and aarch64 (written as arm64 in some contexts

https://github.com/amazonlinux/amazon-linux-2023?tab=readme-ov-file#architectures

実行ステージ

先のビルドステージのコードの下に書いていきます。

# Dockerfile
# existing codes

FROM debian:bookworm-slim

# Create non-root user
RUN useradd -m -u 1000 appuser

WORKDIR /app

# Copy binary from builder with correct ownership
COPY --from=builder --chown=appuser:appuser /src/app/todo-app .
COPY --from=builder --chown=appuser:appuser /src/app/index.html .

# Switch to non-root user
USER appuser

EXPOSE 8080

CMD ["./todo-app"]

押さえておきたいのは、

  • debianの軽量ベースイメージの上でバイナリを実行している
  • 非ルートユーザーを作成して実行を行っている

ことです。

1点目に関しては、以下の部分で、ビルドステージでコンパイルしたバイナリ(todo-app)とhtmlファイルをコピーしています。

COPY --from=builder --chown=appuser:appuser /src/app/todo-app .
COPY --from=builder --chown=appuser:appuser /src/app/index.html .

2点目に関しては、1つのトピックとして取り上げます。

コンテナをrootユーザーで実行すると何が問題か

まず、linuxにおける「rootユーザー」とは、システム上で最高の権限を持っているユーザを意味します。つまり、ファイルのpermissionもほぼ無視しますし、基本的にコマンドの実行に制限を受けません。そんな特権を持つrootユーザーは、UID=0であるユーザーを指します。

Dockerfileは以下のような仕様になっているようです。

Dockerfile に USER を設定しない場合、ユーザーはデフォルトで root になることに注意してください。

https://www.docker.com/ja-jp/blog/understanding-the-docker-user-instruction/

つまり、以下のようにUSERを指定せずに実行した場合、このイメージはrootユーザーで実行されることになります。

FROM debian:bookworm-slim
WORKDIR /app
COPY --from=builder  /src/app/todo-app .
COPY --from=builder  /src/app/index.html .
EXPOSE 8080

# rootユーザーとしてアプリケーションを起動
CMD ["./todo-app"]

この状態で攻撃者によりコンテナへ侵入された場合、コンテナ内で特権を用いた操作が可能になってしまいます。また、ここ理解しておくべきは、「コンテナ内のroot ユーザーは、ホストOSのカーネルに対して強い影響を及ぼし得る」と言うことです。

このカラクリについては、コンテナ仮想技術がどのようにして実現されていたかを知れば頭に入ってきやすいです。もう一度以下の図を引用します。 https://www.docker.com/ja-jp/blog/top-questions-for-getting-started-with-docker/

コンテナはホストOSのLinuxカーネル上で動いています。そもそもコンテナは、Linuxカーネルの既存機能である、namespace/cgroup等々を用いて隔離された「プロセス」にすぎません。「コンテナ」というワードだけに注目しているとそれ単体で独立している環境のように思えますが、コンテナとホストOSは密接に繋がっています。

namespaceで隔離されているので、通常、コンテナ内のrootユーザーとホストOSカーネルのrootユーザーは別物です。しかし、何らかの脆弱性がカーネルまたはコンテナランタイムにあった場合、コンテナ内→ホストOSカーネルに対してrootレベルの操作ができてしまうケースがあるようです。

https://medium.com/@ayoubseddiki132/why-running-docker-containers-as-root-is-a-security-nightmare-and-how-to-fix-it-83a60509d9bf

脆弱性があったとしても、コンテナ内で特権(root権限)を持たせないことにより、ホストへの影響を大きく制限することができます。そのため、コンテナでは作業用のユーザーを作成し、その非rootユーザーを利用するようにするのが推奨されます。

そういうわけで、UID=1000のユーザーを作成し、実行前に切り替え、そのユーザーで実行するようにします。基本的にUID=0じゃなければ非rootユーザーです。

# UID=1000のユーザーをappuserという名前で作成
RUN useradd -m -u 1000 appuser

# ファイルの所有者をappuserにする(appuserで実行できるように)
COPY --from=builder --chown=appuser:appuser /src/app/todo-app .
COPY --from=builder --chown=appuser:appuser /src/app/index.html .

# ユーザーをappuserに設定
USER appuser

# appuserで実行
CMD ["./todo-app"]

.dockerignore

Dockerfileにはソースコードをコピーする以下のような記述をしています。

COPY . .

ここで、アプリケーションのソースコード以外の無駄なファイルはコピーして欲しくないので、.dockerignoreファイルを追加しておきましょう。

https://docs.docker.jp/v20.10/engine/reference/builder.html#dockerignore-file

内容は以下のようにしてください。

.dockerignore
# .dockerignore
# Git
.git
.gitignore

# Kubernetes (不要 - イメージには含めない)
k8s/

# Terraform (不要 - イメージには含めない)
terraform/

# Documentation (不要 - イメージには含めない)
README.md
*.md
*.png
*.drawio.png

# Docker関連 (不要 - イメージには含めない)
Dockerfile.single

# IDE
.vscode/
.idea/
*.swp
*.swo
*~

# macOS
.DS_Store

# Build artifacts
*.exe
*.test
*.out

# Dependencies
vendor/

Dockerfileからコンテナイメージをビルド→起動する

Dockerfileが記述できたので、ビルドしましょう。-t(tag)オプションを使って、appというイメージ名にlocalというタグをつけるようにしています、。

docker build -t app:local .

以下のようなログが見えます。

[+] Building 9.1s (16/16) FINISHED                                             docker:desktop-linux
 => [internal] load build definition from Dockerfile                                           0.0s
 => => transferring dockerfile: 554B                                                           0.0s
 => [internal] load metadata for docker.io/library/golang:1.25-bookworm                        0.8s
 => [internal] load metadata for docker.io/library/debian:bookworm-slim                        0.8s
 => [internal] load .dockerignore                                                              0.0s
 => => transferring context: 479B                                                              0.0s
 => [builder 1/5] FROM docker.io/library/golang:1.25-bookworm@sha256:09f53deea14d4019922334af  0.0s
 => [stage-1 1/5] FROM docker.io/library/debian:bookworm-slim@sha256:e899040a73d36e2b36fa3321  0.0s
 => [internal] load build context                                                              0.0s
 => => transferring context: 3.18kB                                                            0.0s
 => CACHED [builder 2/5] WORKDIR /src                                                          0.0s
 => [builder 3/5] COPY . .                                                                     0.0s
 => [builder 4/5] WORKDIR /src/app                                                             0.0s
 => [builder 5/5] RUN GOOS=linux GOARCH=amd64 go build -o todo-app main.go                     8.1s
 => CACHED [stage-1 2/5] RUN useradd -m -u 1000 appuser                                        0.0s
 => CACHED [stage-1 3/5] WORKDIR /app                                                          0.0s
 => CACHED [stage-1 4/5] COPY --from=builder --chown=appuser:appuser /src/app/todo-app .       0.0s
 => CACHED [stage-1 5/5] COPY --from=builder --chown=appuser:appuser /src/app/index.html .     0.0s
 => exporting to image                                                                         0.0s
 => => exporting layers                                                                        0.0s
 => => writing image sha256:10fcabb3f555430450de35c7a35f2153bff5efa518ab5d4a5983d1dd6bf6ebe0   0.0s
 => => naming to docker.io/library/app:local                                                   0.0s

load metadata for docker.io/library/golang:1.25-bookwormや、load metadata for docker.io/library/debian:bookworm-slimのように、Docker HubからイメージをPullしている様子もありますね。

これでコンテナイメージができました。以下のコマンドで、今作成したものがあるか確認してみます。

docker image ls

app:localというイメージがありますね。

REPOSITORY   TAG       IMAGE ID       CREATED       SIZE
app          local     10fcabb3f555   4 hours ago   109MB

app:localのコンテナイメージからコンテナを起動しましょう。

docker run -p 8080:8080 app:local

ここで、-pオプションによって、ホストのポート:8080をコンテナ内のポート(:8080)にマッピングしています。

https://docs.docker.jp/engine/reference/commandline/run.html#p-expose

コンテナ内で実行しているgolangアプリケーションは、:8080番でリッスンするように書いていました。

http.ListenAndServe(":8080", accessLogMiddleware(mux))

起動できれば、ブラウザ上でhttp://localhost:8080/todosにアクセスしてみましょう。

上のように表示されれば成功です。


これでこの章は終わりです。次章では、kubernetesについて扱います。