Go のアプリと MySQL を一式デプロイする(総まとめ)
v1.37 時点(2026年10月28日 確認)
目次
この記事では Go で書いた小さな Web アプリケーションと MySQL を組み合わせた 一式のデプロイ を、図で説明します。これまでの回で 1 つずつ見てきた StatefulSet、Secret、Service、Probe などが、1 つのアプリケーションを動かすためにどうつながるのかがわかります。後半では kind のクラスタで、アプリケーションのイメージを作ってから一式を kubectl apply -k で入れ、メッセージを書いて読みます。最後に MySQL の Pod を消し、作り直された Pod でもメッセージが残ることを確かめる手順を示します。
この回は 2 つの回を前提にしています。第28回では StatefulSet の Pod ごとの PVC でデータを残す仕組みを、第34回では readiness Probe で準備のできていない Pod を Service の転送先から外す仕組みを扱いました。まだの方は先にこちらをどうぞ。
StatefulSet と PVC でデータを残すStatefulSet の volumeClaimTemplates が Pod ごとに PersistentVolumeClaim を作り、Pod を作り直しても同じボリュームにつなぎ直す仕組みを図で説明します。PVC の名前の付き方、消したり数を減らしたりしても PVC が残る既定の動き、persistentVolumeClaimRetentionPolicy を押さえ、kind のクラスタでデータが残ることを確かめる手順を示します。以下の図は、後半の手順で作る一式が Namespace message-board の中でどうつながっているかを表しています。
利用者(手元でコマンドを打つ読者)は、kubectl port-forward でアプリケーションの Pod にリクエストを送ります。port-forward は手元の PC のポートに来た通信を、クラスタの中の Pod のポートへ中継する kubectl のコマンドで、このシリーズでは初めて使います。アプリケーションは mysql という名前で MySQL につなぎ、MySQL は PVC data-mysql-0 にデータを書きます。どちらの Pod もパスワードは Secret から受け取ります。
図の読み方を補います。mysql は headless Service なので、名前を引くと Service の IP アドレスではなく mysql-0 の IP アドレスが返り、アプリケーションはそこへ直接つなぎます(第23回)。port-forward の通信がどこを通るかは手順で説明します。MySQL 側の secretKeyRef の線は、それを書いた Pod のひな形を持つ StatefulSet から引いています。図ではアプリケーションの Pod を管理する Deployment を省いています。
参考:kubectl port-forward | Kubernetes、DNS for Services and Pods | Kubernetes
この回で作る一式とは
この回で動かすのは、このメディアが Go で書いた message-board というアプリケーションです。メッセージを書き込むと MySQL に保存し、読み出すと保存したメッセージを返すだけの、小さな Web アプリケーションです。アプリケーションのコードは Kubernetes の公式ドキュメントにはないので、この記事では「このアプリケーションは〜するように書いてある」と書き分けます。
アプリケーションと MySQL を動かすのに使うリソースと、それぞれを扱った回を次の表にまとめます。ここではどのリソースの説明も繰り返さず、扱った回へのリンクを示します。
| リソース | この回での名前 | 役目 | 扱った回 |
|---|---|---|---|
| Namespace | message-board | 一式をほかのリソースと分けて置く | 第37回 |
| StatefulSet と PVC | mysql、data-mysql-0 | MySQL を固定の名前で動かし、データを残す | 第14回、第28回 |
| Deployment | message-board | アプリケーションの Pod を動かし続ける | 第12回 |
| Service | mysql(headless)、message-board | Pod に名前で届くようにする | 第21回、第23回 |
| Secret | mysql-pass-<ハッシュ> | MySQL のパスワードを 2 つの Pod に渡す | 第18回 |
| readiness Probe | /healthz | DB につながるまで、アプリケーションの Pod を Service の転送先に入れない | 第34回 |
このほかに、推奨のラベル(第42回)、requests と limits(第29回)を書き、全体を Kustomize(第43回)でまとめます。アプリケーションのイメージは第35回と同じく docker build で作って kind load docker-image でクラスタに入れます。
なぜ要素を組み合わせるのか
Web アプリケーションとデータベースでは、Kubernetes に求めるものが違います。公式ドキュメントは Deployment を、どの Pod も取り替えのきく、状態を持たない(stateless)アプリケーションに向くものとしています。StatefulSet については状態を持つ Pod 向けで、データを残すワークロードなら Pod ごとに PersistentVolume を結び付けられる、と説明しています。メッセージを受け付けるだけのアプリケーションは前者、データを書く MySQL は後者にあたります。
2 つを動かすだけではまだ足りません。アプリケーションは MySQL の接続先とパスワードを知る必要があり、MySQL が接続を受け付けられるようになるまでは、リクエストを受けないほうがよいはずです。これまでの回の要素は、それぞれがこうした困りごとの 1 つを受け持ちます。この回ではそれらを 1 つの kustomization.yaml にまとめ、1 つのコマンドで入れられる形にします。
一式の仕組み
アプリケーションのコード
このアプリケーションは HTTP の 8080 番のポートで待ち受け、次の 3 つのリクエストに応えるように書いてあります。
| リクエスト | すること | 返すもの |
|---|---|---|
POST /(message を送る) | メッセージを MySQL に保存する | ステータスコード 201 と saved |
GET / | 保存したメッセージを、保存した順に読む | メッセージを 1 行に 1 つ。1 つも無ければ何も返さない |
GET /healthz | MySQL につなぎ、messages のテーブルが無ければ作る | できれば ok。できなければステータスコード 503 |
接続先は環境変数 DB_HOST、DB_USER、DB_PASSWORD、DB_NAME から読み、ポートは 3306 番に決めてあります。GET / と POST / も、MySQL を使えないときはステータスコード 503 と database error を返すように書いてあります。テーブルは一般に、データベースの中で行と列の形にデータを入れる表で、SQL はそれを読み書きする言葉です。
以下が message-board のコード(main.go)です。後半の手順で、このまま保存してイメージにします。先に進むのに要るのは、上の表の 3 つのリクエストと 4 つの環境変数だけなので、コードは読み飛ばしてもかまいません。
package main
import (
"context"
"database/sql"
"fmt"
"log"
"net/http"
"os"
"strings"
"time"
"github.com/go-sql-driver/mysql"
)
type store interface {
ping(ctx context.Context) error
add(ctx context.Context, message string) error
list(ctx context.Context) ([]string, error)
}
type mysqlStore struct{ db *sql.DB }
func (s mysqlStore) ping(ctx context.Context) error {
if err := s.db.PingContext(ctx); err != nil {
return err
}
_, err := s.db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS messages (
id INT AUTO_INCREMENT PRIMARY KEY,
body VARCHAR(200) NOT NULL
)`)
return err
}
func (s mysqlStore) add(ctx context.Context, message string) error {
_, err := s.db.ExecContext(ctx, "INSERT INTO messages (body) VALUES (?)", message)
return err
}
func (s mysqlStore) list(ctx context.Context) ([]string, error) {
rows, err := s.db.QueryContext(ctx, "SELECT body FROM messages ORDER BY id")
if err != nil {
return nil, err
}
defer rows.Close()
var messages []string
for rows.Next() {
var body string
if err := rows.Scan(&body); err != nil {
return nil, err
}
messages = append(messages, body)
}
return messages, rows.Err()
}
func newHandler(st store) http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
if err := st.ping(r.Context()); err != nil {
http.Error(w, "database not ready", http.StatusServiceUnavailable)
return
}
fmt.Fprintln(w, "ok")
})
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
messages, err := st.list(r.Context())
if err != nil {
http.Error(w, "database error", http.StatusServiceUnavailable)
return
}
for _, m := range messages {
fmt.Fprintln(w, m)
}
})
mux.HandleFunc("POST /{$}", func(w http.ResponseWriter, r *http.Request) {
message := strings.TrimSpace(r.FormValue("message"))
if message == "" || len(message) > 200 {
http.Error(w, "message must be 1 to 200 bytes", http.StatusBadRequest)
return
}
if err := st.add(r.Context(), message); err != nil {
http.Error(w, "database error", http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusCreated)
fmt.Fprintln(w, "saved")
})
return mux
}
func main() {
cfg := mysql.NewConfig()
cfg.Net = "tcp"
cfg.Addr = os.Getenv("DB_HOST") + ":3306"
cfg.User = os.Getenv("DB_USER")
cfg.Passwd = os.Getenv("DB_PASSWORD")
cfg.DBName = os.Getenv("DB_NAME")
db, err := sql.Open("mysql", cfg.FormatDSN())
if err != nil {
log.Fatal(err)
}
db.SetConnMaxLifetime(3 * time.Minute)
log.Fatal(http.ListenAndServe(":8080", newHandler(mysqlStore{db})))
}第35回のコードには無かった書き方を、次の表にまとめます。
| 箇所 | 意味 |
|---|---|
"GET /{$}"、"POST /{$}" | Go の net/http のパターンは先頭にメソッドを書ける。{$} は URL の終わりだけに当てはまり、/{$} はパス / ちょうどだけに当てはまる(第35回の / はすべてのパスに当てはまった) |
r.FormValue("message") | リクエストのフォームから message という名前の値を読む。手順の curl はこの名前で送る |
ping の CREATE TABLE IF NOT EXISTS | /healthz のたびに、テーブルが無ければ作る。readiness Probe が成功した時点でテーブルがあるので、最初の書き込みの前にテーブルができている |
store | 保存先を差し替えるための型。このメディアでは、MySQL の代わりにメモリーに保存する部品に差し替えてコードを試験している |
db.SetConnMaxLifetime(3 * time.Minute) | MySQL とつないだ接続を使い回す時間の上限を 3 分にする。接続には go.mod に書いたライブラリ go-sql-driver/mysql を使う |
参考:http package(Patterns)| Go Packages、http package(Request.FormValue)| Go Packages、sql package | Go Packages、go-sql-driver/mysql
以下は go.mod です。第35回と違い、require の行で外部のライブラリを使うことを書いています。// indirect の付いた行は、このコードが直接は使わず、使うライブラリのほうが必要とするものです。
以下はイメージを作る手順を書いた Dockerfile です。
FROM golang:1.27 AS build
WORKDIR /src
COPY go.mod main.go ./
RUN go mod tidy && CGO_ENABLED=0 go build -o /message-board .
FROM gcr.io/distroless/static-debian13:nonroot
COPY --from=build /message-board /message-board
EXPOSE 8080
ENTRYPOINT ["/message-board"]第35回の Dockerfile とほぼ同じで、各行の意味は第35回の表のとおりです。名前のほかに違うのは、RUN の行の go mod tidy だけです。外部のライブラリを使うと、ライブラリの中身を確かめるためのハッシュを並べた go.sum のファイルが要ります。go mod tidy は足りない項目を go.sum に足すので、1 つ目の段の中で go.sum ができます。組み立てはすべて 1 つ目の段(golang のイメージ)の中で行うので、読者の PC に Go を入れたり go.sum を用意したりする必要はありません。ライブラリを取ってくるので、ビルドにはインターネットへの接続が要ると考えられます。土台の 2 つのイメージのタグは、2026年10月現在のものです。distroless のイメージにはシェルが入っていないので、中を調べるときは第41回のエフェメラルコンテナを使うことになると考えられます。
参考:Go Modules Reference | The Go Programming Language、golang | Docker Hub、GoogleContainerTools/distroless(README)
MySQL を StatefulSet で動かす
以下は MySQL の headless Service と StatefulSet のマニフェスト(mysql.yaml)です。形は第28回の web.yaml と同じで、イメージ、環境変数、マウントする場所を MySQL のものにしています。
apiVersion: v1
kind: Service
metadata:
name: mysql
labels:
app.kubernetes.io/name: mysql
app.kubernetes.io/component: database
spec:
clusterIP: None
selector:
app.kubernetes.io/name: mysql
ports:
- name: mysql
port: 3306
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: mysql
labels:
app.kubernetes.io/name: mysql
app.kubernetes.io/component: database
spec:
serviceName: mysql
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: mysql
template:
metadata:
labels:
app.kubernetes.io/name: mysql
app.kubernetes.io/component: database
spec:
containers:
- name: mysql
image: mysql:9.7
env:
- name: MYSQL_ROOT_PASSWORD
valueFrom:
secretKeyRef:
name: mysql-pass
key: root-password
- name: MYSQL_DATABASE
value: board
- name: MYSQL_USER
value: board
- name: MYSQL_PASSWORD
valueFrom:
secretKeyRef:
name: mysql-pass
key: password
ports:
- name: mysql
containerPort: 3306
resources:
requests:
cpu: 250m
memory: 512Mi
limits:
memory: 1Gi
volumeMounts:
- name: data
mountPath: /var/lib/mysql
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: [ "ReadWriteOnce" ]
resources:
requests:
storage: 1Giイメージは Docker Hub の公式イメージ mysql で、2026年10月現在のタグの一覧にある 9.7 を使います。環境変数の意味を、Docker Hub の mysql のページに沿って次の表にまとめます。
| 環境変数 | 意味 | この回の値 |
|---|---|---|
MYSQL_ROOT_PASSWORD | 必須。MySQL の管理者 root のパスワード | Secret の root-password |
MYSQL_DATABASE | 省略可。イメージの起動時に作るデータベースの名前 | board |
MYSQL_USER と MYSQL_PASSWORD | 省略可。2 つそろえて書くと、MySQL の新しいユーザー(アカウント)とそのパスワードを作る | board と Secret の password |
MYSQL_USER で作ったユーザーは、MYSQL_DATABASE のデータベースに対するすべての権限を持ちます。アプリケーションはこの board のユーザーで board のデータベースを使います。ただし Docker Hub のページによると、データディレクトリにすでにデータベースがあるときは、これらの変数は効きません。MySQL はすでにあるデータベースには手を付けずに起動します。MySQL が既定でデータを書く場所は /var/lib/mysql なので、ここに PVC をマウントします。
参考:mysql(Environment Variables、Where to Store Data、Usage against an existing database)| Docker Hub
Service に clusterIP: None を書いたので、この Service は headless Service です(第14回)。公式ドキュメントによると、StatefulSet には Pod ごとの名前(DNS 名)を受け持つ headless Service が必要で、それは自分で作らなければなりません。公式のチュートリアル(Run a Single-Instance Stateful Application)も、Pod が 1 つの MySQL に clusterIP: None の Service を使い、パスワードはマニフェストに直接書かずに Secret を使うよう注意しています。requests と limits の値はこの回で目安として決めたもので、公式ドキュメントの値ではありません。
参考:StatefulSets | Kubernetes、Run a Single-Instance Stateful Application | Kubernetes、Distribute Credentials Securely Using Secrets | Kubernetes
アプリケーションを Deployment で動かす
以下はアプリケーションの Service と Deployment のマニフェスト(app.yaml)です。
apiVersion: v1
kind: Service
metadata:
name: message-board
labels:
app.kubernetes.io/name: message-board
app.kubernetes.io/component: server
spec:
selector:
app.kubernetes.io/name: message-board
ports:
- name: http
port: 80
targetPort: 8080
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: message-board
labels:
app.kubernetes.io/name: message-board
app.kubernetes.io/component: server
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: message-board
template:
metadata:
labels:
app.kubernetes.io/name: message-board
app.kubernetes.io/component: server
spec:
containers:
- name: message-board
image: zukai/message-board:1.0
imagePullPolicy: Never
env:
- name: DB_HOST
value: mysql
- name: DB_USER
value: board
- name: DB_NAME
value: board
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: mysql-pass
key: password
ports:
- name: http
containerPort: 8080
readinessProbe:
httpGet:
path: /healthz
port: 8080
periodSeconds: 5
resources:
requests:
cpu: 50m
memory: 32Mi
limits:
memory: 128Mi参考:Deployment | Kubernetes API Reference、Service | Kubernetes API Reference
imagePullPolicy: Never は第35回と同じく、読み込みに失敗していても、Docker Hub にある同じ名前のほかの人のイメージを取ってきて動かすことがないように書いています。公式ドキュメントによると、Never では kubelet はイメージを取得しようとせず、ノードにイメージがあればコンテナを起動し、無ければ起動に失敗します。DB_HOST には MySQL の Service の名前 mysql だけを書いています。公式ドキュメントによると、Namespace を書かない DNS の問い合わせは、問い合わせる Pod の Namespace に限られます。アプリケーションの Pod と Service mysql は同じ Namespace message-board に置くので、mysql だけで MySQL の Pod の IP アドレスが引けるはずです(第23回)。
参考:Images | Kubernetes、Images | Kubernetes、Quick Start | kind、DNS for Services and Pods | Kubernetes
readiness Probe は 5 秒ごとに /healthz へ GET を送ります。httpGet はステータスコードが 200 以上 400 未満なら成功です(第34回)。このアプリケーションは MySQL につなげないとき 503 を返すように書いてあるので、MySQL が使えるまでアプリケーションの Pod は Ready にならないはずです。Docker Hub のページによると、MySQL はデータベースが無い状態で起動すると、最初の準備(初期化)が終わるまで接続を受け付けません。MySQL のコンテナには Probe を書いていないので、mysql-0 は初期化の途中でも Ready になると考えられます(第34回の「書かないときの扱い」)。そのあいだはアプリケーションの readiness Probe が失敗するので、アプリケーションの Pod は Service message-board の転送先に入りません。
参考:Liveness, Readiness, and Startup Probes | Kubernetes、Liveness, Readiness, and Startup Probes | Kubernetes、mysql(No connections until MySQL init completes)| Docker Hub
ラベルは第42回の推奨のラベルのうち app.kubernetes.io/name と app.kubernetes.io/component を書き、セレクタには name だけを使います。app.kubernetes.io/part-of は次の kustomization.yaml で一式のすべてのリソースに付けます。
Kustomize で一式をまとめる
以下は一式をまとめる kustomization.yaml です。
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: message-board
labels:
- pairs:
app.kubernetes.io/part-of: message-board
resources:
- mysql.yaml
- app.yaml
secretGenerator:
- name: mysql-pass
literals:
- root-password=change-me-root
- password=change-me-appnamespace はすべてのリソースに同じ Namespace message-board を書き込みます。公式ドキュメントの説明はリソースに Namespace を設定することだけで、Namespace のオブジェクトを作るとは書かれていません。そのため第43回と同じく、Namespace は先に別のマニフェストで作ります。labels は includeSelectors も includeTemplates も書いていないので、リソースの metadata.labels にだけラベルを足し、セレクタと Pod のひな形には足しません。secretGenerator は literals の 2 つの値から Secret mysql-pass を作ります。値を base64 にする必要はありません。
参考:Declarative Management of Kubernetes Objects Using Kustomize | Kubernetes、labels | Kustomize、Managing Secrets using Kustomize | Kubernetes
以下の図は、kustomization.yaml から組み立てたリソースが Secret をどの名前で参照するかを表しています。
公式ドキュメントによると、ジェネレーターで作る Secret の名前には、中身から計算したハッシュが付きます(第43回)。Kustomize のドキュメントには、生成した ConfigMap と Secret への参照を Kustomize が見つけて、ハッシュ付きの名前に置き換えると書かれています。ただし公式ドキュメントの例はボリュームでの参照だけなので、環境変数の secretKeyRef も置き換わるかは、後半の手順で組み立てた結果を見て確かめます。図は置き換わった場合の姿を描いていて、2 つの Service を省いています。
参考:Managing Secrets using Kustomize | Kubernetes、configMapGenerator | Kustomize、Declarative Management of Kubernetes Objects Using Kustomize | Kubernetes
手元で試す
ここまでの内容を手元の kind のクラスタで試す手順を示します。クラスタと Namespace を用意し、イメージを作って入れてから、一式を kubectl apply -k で入れます。メッセージを書いて読んだあと、MySQL の Pod を消してもメッセージが残ることを確かめ、最後に片付けます。各段の小見出しはこの順に並んでいます。
各段に書く成功の目安は、公式ドキュメントとアプリケーションのコードから予想される結果です。手元の結果が違ったときは手元の結果を正として、第41回の手順で Pod の状態、イベント、ログを調べてください。コマンドは macOS や Linux のシェル(bash や zsh)で実行する前提で書いています。Windows では WSL(Windows の中で Linux を動かす仕組み)などを使ってください。試す環境は次のとおりです。
| 項目 | 内容 |
|---|---|
| kind | v0.33.0 |
| Kubernetes | v1.37.0(kind v0.33.0 の既定のノードイメージ) |
| ノード構成 | シングルノード(クラスタ名 kind):コントロールプレーン 1 |
| 必要なもの | Docker、kubectl(v1.36〜v1.38)、curl、テキストエディタ、端末(ターミナル)3 つ。イメージを取得するためのインターネットへの接続 |
参考:Release v0.33.0 · kubernetes-sigs/kind、Version Skew Policy | Kubernetes
第28回と同じシングルノードのクラスタを使います。kind でクラスタを作る手順は、以下の記事で解説しています。
kind で検証用のクラスタを作るDocker のコンテナをノードにして、手元のマシンに Kubernetes のクラスタを作るツール kind の仕組みを図で説明します。kind create cluster で何が起きるかを押さえ、シングルノードとマルチノードのクラスタを作って、ノードの正体が Docker のコンテナであることを確かめる手順を紹介します。この回では端末を 3 つ使います。1 つ目はこれまでの回と同じく変化の監視(この回では Namespace message-board の Pod)に使い、片付けの前まで続けます。2 つ目は作業用のディレクトリでそのほかの操作すべてに使います。3 つ目は port-forward を動かすことだけに使います。公式ドキュメントのとおり、kubectl port-forward は終わらずに動き続けるからです。
参考:Use Port Forwarding to Access Applications in a Cluster | Kubernetes
最初に、2 つ目の端末で作業用のディレクトリと、マニフェストを置く k8s のディレクトリを作り、作業用のディレクトリに移ります。名前は一例です。
$ mkdir -p final-deploy/k8s && cd final-deploy
この回で保存するファイルは 7 つで、置き場所は次のとおりです。どれもこのあとの段で 1 つずつ保存するので、ここでは置き場所だけを見ておきます。namespace.yaml だけは k8s に入れません。kubectl apply -k k8s と kubectl delete -k k8s の対象から外し、Namespace を最初に作って最後に消すためです。
シングルノードのクラスタを用意する
2 つ目の端末で、前の回のクラスタが残っていないかを確かめます。
$ kind get clusters
kind が表示されたら、そのまま使えるので、クラスタを作る次のコマンドは飛ばします。表示されなければ、クラスタを作ってから、もう一度一覧します。
$ kind create cluster
$ kind get clusters
kind get clusters に kind が表示されれば、クラスタができています。第3回で決めたとおり、ここからの kubectl のコマンドには --context kind-kind を付けます。続けて、ノードを一覧します。
$ kubectl get nodes --context kind-kind
ノードが 1 つ表示され、STATUS 列が Ready になっていれば準備ができています。Ready になっていなければ、少し待ってからもう一度実行します。
前の回のクラスタを使い続けている場合は、この回の Namespace がまだ無いことも確かめます。
$ kubectl get namespace message-board --context kind-kind
名前を指定したので、無ければエラーの形で、見つからないことを知らせる 1 行が出ると考えられます。この回ではそれが期待どおりの結果です。message-board の行が表示されたら、第37回で見たとおり Namespace を消すと中のリソースも一緒に消えるので、残したいものが無いかを確かめてから kubectl delete namespace message-board --context kind-kind で消します。
Namespace を作って監視する
以下は Namespace のマニフェストです。作業用のディレクトリに namespace.yaml という名前で保存し、2 つ目の端末で渡します。
apiVersion: v1
kind: Namespace
metadata:
name: message-board$ kubectl apply -f namespace.yaml --context kind-kind
namespace/message-board が作られたことを知らせる 1 行が出るはずです。1 つ目の端末で、この Namespace の Pod を監視します。
$ kubectl get pods --watch -n message-board --context kind-kind
まだ Pod は無いので、何も表示されないか、見つからないことを知らせる 1 行が出たあと、止まったように見えると考えられます(第28回)。
イメージを作ってクラスタに入れる
第35回と同じ手順で、アプリケーションのイメージを作ってクラスタに入れます。「アプリケーションのコード」の main.go、go.mod、Dockerfile を、作業用のディレクトリにテキストエディタでプレーンテキストとして保存します。2 つ目の端末で ls を実行し、namespace.yaml と k8s のほかに main.go、go.mod、Dockerfile の 3 つが含まれていることを確かめてから、イメージを作ります。
$ docker build -t zukai/message-board:1.0 .
-t、最後の .、Dockerfile が使われる決まりは第35回と同じです。初めてのときは土台のイメージを取得し、この回は go mod tidy でライブラリも取ってくるので、時間がかかることがあります。エラーで止まったら、第35回の段と同じくエラーの表示を見ます。止まったのが RUN の行なら、インターネットへの接続と、main.go・go.mod の写し間違いを確かめます。終わったら、イメージができたかを確かめます。
$ docker image ls zukai/message-board
参考:docker buildx build | Docker Docs、docker buildx build | Docker Docs、docker image ls | Docker Docs、Docker Engine 29 release notes | Docker Docs
表示の形は Docker のバージョンで違います(29.0.0 からは --tree に似た表示が既定。第35回)。どちらの形でも、zukai/message-board と 1.0 を含む行があればイメージができています。続けて、kind のクラスタのノードに読み込み、ノードにあるイメージを一覧します。kind-control-plane はノードのコンテナの名前です(第35回)。
$ kind load docker-image zukai/message-board:1.0
$ docker exec kind-control-plane crictl images
クラスタの名前を kind 以外にした場合は、kind の公式ドキュメントのとおり --name <クラスタの名前> を付けます。kind load docker-image がエラーで止まったときは、下の補足を見てください。crictl images の一覧に zukai/message-board と 1.0 を含む行があれば、この段は成功です。名前の前に docker.io/ が付いて表示されることもあると考えられます(第35回)。
kubectl kustomize で中身を見てから入れる
「Kustomize で一式をまとめる」の kustomization.yaml、「MySQL を StatefulSet で動かす」の mysql.yaml、「アプリケーションを Deployment で動かす」の app.yaml を、k8s のディレクトリに保存します。note のとおり、パスワードの 2 つの値はここで変えておきます。2 つ目の端末で、組み立てた結果を表示します。
$ kubectl kustomize k8s
組み立てを表示するだけなので、クラスタには何も作りません。すべてのリソースに namespace: message-board と app.kubernetes.io/part-of: message-board のラベルが加わっていれば、予想どおりです。いちばん見たいのは Secret の名前と参照です。出力は長いので、Secret の名前にかかわる行だけを grep(一般に、指定した文字列を含む行だけを取り出すコマンド)で絞って見ると楽です。
$ kubectl kustomize k8s | grep mysql-pass
Secret の name の 1 行と、secretKeyRef の name の 3 行(StatefulSet に 2 つ、Deployment に 1 つ)の、合わせて 4 行が出るはずです。4 行とも同じハッシュ付きの名前なら、参照は置き換わっています。secretKeyRef の行が mysql-pass のままなら、Pod は Secret を見つけられないはずです。その場合は、第43回で見た generatorOptions の disableNameSuffixHash: true を、kustomization.yaml のいちばん上の階層(secretGenerator と同じ深さ)に足すと、Secret の名前が mysql-pass のままになり、参照と合うはずです。
generatorOptions:
disableNameSuffixHash: true参考:Declarative Management of Kubernetes Objects Using Kustomize | Kubernetes
足したら kubectl kustomize k8s | grep mysql-pass をもう一度実行し、4 行とも mysql-pass になっていることを確かめます。このあとの mysql-pass-<ハッシュ> や、成功の目安の表の Secret の行(ハッシュ付きの名前)も、mysql-pass と読み替えてください。この設定では Secret の名前は中身を変えても変わらないので、注意点の表の「Secret の中身を変えると名前が変わる」は当てはまらず、Pod は作り直されずに古いパスワードのまま動き続けると考えられます。4 行が予想どおりなら、一式をクラスタに入れます。
$ kubectl apply -k k8s --context kind-kind
secret/mysql-pass-<ハッシュ>、service/message-board、service/mysql、deployment.apps/message-board、statefulset.apps/mysql がそれぞれ created になったことを知らせる 5 行が出るはずです。1 つ目の端末には mysql-0 と message-board- で始まる Pod の行が書き足されていくはずです。
Pod、PVC、Service を確かめる
1 つ目の端末で、2 つの Pod の READY 列が 1/1 になるのを待ちます。どちらの Pod の行も第28回と同じく Pending、ContainerCreating、Running と書き足されていくはずです。初めてのときは mysql:9.7 のイメージを取ってくるので、時間がかかることがあります。「アプリケーションを Deployment で動かす」で見たとおり、mysql-0 が先に 1/1 になっても、MySQL の初期化が終わるまでアプリケーションの Pod は Running のまま 0/1 のはずです。そのあいだに、1 つ目の端末に出ている message-board- で始まる Pod の名前を使って kubectl describe pod <Pod の名前> -n message-board --context kind-kind を実行すると、第34回と同じく、Events: に Readiness probe failed で始まる Unhealthy のイベントが見えると考えられます。
アプリケーションの Pod の STATUS 列が Running にならず、イメージにかかわる理由が表示されたままなら、ノードにイメージが無いと考えられます。第35回と同じく、「イメージを作ってクラスタに入れる」の段に戻ってイメージを読み込み、kubectl delete pod -l app.kubernetes.io/name=message-board -n message-board --context kind-kind で Pod を消すと、ReplicaSet が作り直します。ほかの理由で 1/1 にならないときは、第41回の手順で調べます。そのときは -n message-board を付けます。
2 つの Pod が 1/1 になったら、2 つ目の端末で一式を一覧します。
$ kubectl get statefulsets,deployments,pods,pvc,services,secrets -n message-board --context kind-kind
成功の目安を次の表にまとめます。
| 種類 | 成功の目安 |
|---|---|
| StatefulSet と Deployment | mysql と message-board の READY 列が 1/1 |
| Pod | mysql-0 と message-board- で始まる Pod の STATUS 列が Running、READY 列が 1/1 |
| PVC | data-mysql-0 の STATUS 列が Bound |
| Service | mysql の CLUSTER-IP 列が None。message-board には IP アドレスがある |
| Secret | mysql-pass- で始まるハッシュ付きの名前 |
PVC の名前の形は第28回で推測した <ひな形の名前>-<Pod の名前> で、公式の Run a Replicated Stateful Application の例にも、同じ StatefulSet mysql とひな形 data から data-mysql-0 ができた出力があります。MySQL の Pod を消したあとで比べるので、VOLUME 列の名前を控えておきます。第27回で推測したとおり kind に既定の StorageClass があれば、PV は自動で用意されているはずです。
port-forward してメッセージを書いて読む
3 つ目の端末で、アプリケーションの Service を指定して port-forward します。8080:80 は「手元の 8080 番を、Service の 80 番のポートへ」という指定です。
$ kubectl port-forward service/message-board 8080:80 -n message-board --context kind-kind
参考:kubectl port-forward | Kubernetes、Use Port Forwarding to Access Applications in a Cluster | Kubernetes
リファレンスによると、Service を指定すると Service が選んだ Pod のうち 1 つが選ばれ、Service のポートの targetPort に転送されます。手元の 8080 番から、選ばれた Pod の 8080 番(targetPort)へ転送することを知らせる 2 行が出るはずです。転送は Service を通らず、選ばれた Pod に直接届きます。この回の Service message-board は、port-forward が転送先の Pod を選ぶのに使うだけです。クラスタの中のほかの Pod や、第24回の Ingress や Gateway API からは、この Service を通して届くことになります。curl でつなぐたびに、この端末に行が書き足されることがあると考えられますが、そのまま動かしておきます。手元の 8080 番がほかのプログラムに使われていてエラーになったら、18080:80 のように手元の番号を変え、このあとの URL の番号も合わせます。
2 つ目の端末で、アプリケーションに 4 回リクエストを送ります。curl の -d は一般にフォームの形のデータを POST で送る指定です。message=hello の message がコードの r.FormValue("message") で読む名前です。
$ curl http://localhost:8080/healthz
$ curl -d message=hello http://localhost:8080/
$ curl -d message=kubernetes http://localhost:8080/
$ curl http://localhost:8080/
アプリケーションのコードのとおりなら、1 行目には ok、2 行目と 3 行目には saved が返ります。POST のステータスコードは 201 ですが、curl は一般に既定では本文だけを表示するので、画面には saved だけが出ると考えられます。最後の GET / には、書いた順に hello と kubernetes の 2 行が返るはずです。ここまで返れば、アプリケーションが名前 mysql で MySQL につながり、Secret のパスワードを使って board のユーザーとしてデータを書けたことになります。database not ready や database error が返るときは、第41回の手順で 2 つの Pod のログとイベントを確かめます。
MySQL の Pod を消してもメッセージが残ることを確かめる
2 つ目の端末で、MySQL の Pod を消します。
$ kubectl delete pod mysql-0 -n message-board --context kind-kind
1 つ目の端末には、第28回と同じく mysql-0 の行が Terminating になり、同じ名前の mysql-0 が作り直される様子が書き足されるはずです。mysql-0 を作り直したのは StatefulSet のコントローラーで、第6回の調整ループの働きです。作り直された Pod は新しい Pod ですが、同じ PVC data-mysql-0 を使うはずです(第28回)。MySQL を使えないあいだは、アプリケーションの readiness Probe が失敗します。失敗が failureThreshold(既定は 3 回)続けば、アプリケーションの Pod の READY 列も 0/1 になることがあると考えられます。0/1 にならないこともあります。
新しい mysql-0 が 1/1 になったら、2 つ目の端末で /healthz を少し間をあけて何度か打ち、ok が返ったらメッセージを読みます。MySQL がまだ使えないあいだ、/healthz は database not ready を返すように書いてあります。
$ curl http://localhost:8080/healthz
$ curl http://localhost:8080/
/healthz はテーブルが無ければ作るので、データが消えていても ok になります。そのときは、続く GET / が何も返さないはずです(次の表の「何も表示されない」の行)。返ったものの見分け方を次の表にまとめます。
| 返ったもの | 考えられる状態 | すること |
|---|---|---|
hello と kubernetes の 2 行 | データが残っている | 成功。PVC を確かめる |
database error | MySQL がまだ起動の途中 | 少し待ってから打ち直す |
/healthz が database not ready のまま変わらない | MySQL につながらない | 第41回の手順で mysql-0 のログとイベントを調べる |
| 何も表示されない | MySQL はつながるが、メッセージが無い。データが残っていない | 下の PVC の VOLUME 列を比べ、第28回を見直す |
| curl が接続できないと知らせる | port-forward が止まっている | 3 つ目の端末で port-forward をもう一度実行する |
2 行が返ったなら、新しい mysql-0 の /var/lib/mysql には同じ PV がマウントされ、Docker Hub のページのとおり、MySQL はすでにあるデータベースに手を付けずに起動したと考えられます。続けて PVC を一覧します。
$ kubectl get pvc -n message-board --context kind-kind
data-mysql-0 の VOLUME 列が「Pod、PVC、Service を確かめる」で控えた名前と同じなら成功です。リファレンスによると転送は選ばれた Pod が終わると止まりますが、消したのは MySQL の Pod なので、転送は続いているはずです。
参考:StatefulSets | Kubernetes、mysql(Usage against an existing database)| Docker Hub、kubectl port-forward | Kubernetes
片付け
3 つ目の端末と 1 つ目の端末で Ctrl+C を押して、port-forward と監視を止めます。2 つ目の端末で、kubectl apply -k で作ったリソースを消してから、PVC を一覧します。
$ kubectl delete -k k8s --context kind-kind
$ kubectl get pvc -n message-board --context kind-kind
参考:Declarative Management of Kubernetes Objects Using Kustomize | Kubernetes
1 つ目のコマンドでは、5 つのリソースを消したことを知らせる行が出るはずです。第28回で見たとおり、StatefulSet を消しても PVC は消えないので、2 つ目のコマンドでは data-mysql-0 が残っているはずです。最後に Namespace を消します。
$ kubectl delete -f namespace.yaml --context kind-kind
$ kubectl get namespace message-board --context kind-kind
$ kubectl get pv --context kind-kind
参考:Share a Cluster with Namespaces | Kubernetes、kubectl delete | Kubernetes、Change the Reclaim Policy of a PersistentVolume | Kubernetes
Namespace を消すと、中に残った PVC も消えます(第37回)。kubectl delete は既定では消えるまで待つので、2 つ目のコマンドでは名前を指定したので、見つからないことを知らせる 1 行がエラーの形で出るはずです。第27回の推測どおり既定の StorageClass の回収ポリシーが Delete なら、PV も消され、3 つ目のコマンドで CLAIM 列が message-board/data-mysql-0 の行は表示されないはずです。残っていたら、少し待ってからもう一度一覧します。
クラスタを削除し、消えたことを確かめます。読み込んだイメージはノードと一緒に消えると考えられます。手元の PC の zukai/message-board:1.0 と作業用のディレクトリは残るので、要らなければ、イメージは docker image rm zukai/message-board:1.0 で消し、作業用のディレクトリはディレクトリごと削除します。補足の手順で /tmp/message-board.tar を書き出していれば、それも rm /tmp/message-board.tar で消します。
$ kind delete cluster
$ kind get clusters
kind get clusters に kind が表示されなくなったことを確かめます。kubeconfig に kind-kind のコンテキストが残っていたら、第3回の片付けと同じく kubectl config delete-context kind-kind で消します。
注意点
アプリケーションと MySQL を一式で動かすときにつまずきやすい点を、次の表にまとめます。
| 項目 | 内容 |
|---|---|
| 入れたあとでパスワードを変えると、つながらなくなる | MySQL の環境変数はデータベースが無いときの初期化でだけ効く。Secret を変えて入れ直すと、アプリケーションは新しいパスワードを使い、MySQL は古いパスワードのままになると考えられる |
| パスワードを変えたいとき | 必ずこの順で、kubectl delete -k k8s --context kind-kind、kubectl delete pvc data-mysql-0 -n message-board --context kind-kind、パスワードの書き換え、kubectl apply -k k8s --context kind-kind と進めると、新しいパスワードで初期化されると考えられる。既定の StorageClass の回収ポリシーが Delete なら、PVC と一緒にデータも消える |
| Secret の中身を変えると名前が変わる | ジェネレーターは新しい名前の Secret を作る。参照も書き換えると考えられ(環境変数の secretKeyRef は手順で確かめる)、Pod のひな形が変わるので、Pod は作り直されると考えられる |
| 環境変数の Secret は再起動まで反映されない | 公式ドキュメントによると、環境変数で受け取った Secret の更新は、コンテナが再起動するまで見えない |
| 読み込み忘れでは Pod が起動しない | imagePullPolicy: Never なので、kubelet はイメージを取得しない。kind load を忘れると、コンテナを起動できないままになるはず |
| port-forward は TCP だけ | v1.37 時点では TCP のポートだけに対応している |
参考:mysql(Usage against an existing database)| Docker Hub、Managing Secrets using Kustomize | Kubernetes、Distribute Credentials Securely Using Secrets | Kubernetes、Images | Kubernetes、Use Port Forwarding to Access Applications in a Cluster | Kubernetes
この回の一式は仕組みを確かめるためのものです。公式の MySQL の例(Run a Replicated Stateful Application)も、MySQL の設定を安全でない既定のままにしており、本番の設定ではないと明記しています。この一式を本番に近づけるなら、この回で使わなかった回の要素も組み合わせることになります。主なものは次の 6 つです。どれもこの回では試していないので、組み合わせ方はそれぞれの回と公式ドキュメントで確かめてください。
- port-forward の代わりに、Ingress や Gateway API でクラスタの外から Service
message-boardに届ける(第24回) - NetworkPolicy で、MySQL にはアプリケーションの Pod からだけ通す(第25回)
- アプリケーションに HPA(第35回)と PodDisruptionBudget(第36回)を付ける
- Namespace に Pod Security の基準を当てる(第39回)。Restricted なら、2 つのマニフェストに securityContext を書き足して基準に合わせる必要がある
- Secret の保存時の暗号化を有効にする(第18回)
- Secret などを変えたときのアプリケーションの Pod の入れ替わり方を、ローリングアップデートの設定で決める(第13回)
参考:Run a Replicated Stateful Application | Kubernetes、Pod Security Standards | Kubernetes
関連記事
この回で基本仕様シリーズは終わりです。読み返す入口として、各部の最初の回と、この回で使った回を次の表にまとめます。
| 部 | 最初の回 | この回で使った回 |
|---|---|---|
| 第0部 準備 | 第1回 Kubernetes とは何か | kind のクラスタと kubectl(第2回、第3回) |
| 第1部 クラスタの仕組み | 第4回 コントロールプレーンとノードの構成 | マニフェスト、調整ループ、kubectl apply(第5回〜第7回) |
| 第2部 ワークロード | 第8回 Pod を動かして理解する | Deployment と StatefulSet(第12回、第14回) |
| 第3部 設定と機密情報 | 第17回 環境変数とボリュームで渡す ConfigMap の違い | Secret(第18回) |
| 第4部 ネットワーク | 第20回 Pod 同士が通信する仕組み | Service と DNS(第21回、第23回) |
| 第5部 ストレージ | 第26回 emptyDir と hostPath の違い | PV、PVC と volumeClaimTemplates(第27回、第28回) |
| 第6部 スケジューリングとリソース | 第29回 requests と limits の仕組み | requests と limits、Probe(第29回、第34回) |
| 第7部 スケーリングと可用性 | 第35回 Horizontal Pod Autoscaler を動かして理解する | Go のアプリのイメージを作り、kind に読み込む手順(第35回) |
| 第8部 セキュリティとアクセス制御 | 第37回 Namespace で分けられるもの、分けられないもの | Namespace(第37回) |
| 第9部 運用の入り口とまとめ | 第41回 起動しない Pod の原因を調べる | Pod の調べ方、ラベル、Kustomize(第41回〜第43回) |
この先は、公式ドキュメント(英語のページ)を直接読むのがおすすめです。この回の続きとして読むとよいページは次の 3 つです。
- Run a Replicated Stateful Application:MySQL を StatefulSet で複製して動かす例
- Good practices for Kubernetes Secrets:クラスタの管理者と開発者のための Secret の扱い方
- Production environment:本番用のクラスタを用意するときに考えること
この回のもとになった、StatefulSet と PVC でデータを残す仕組みと、マニフェストを Kustomize でまとめる仕組みについては、以下の記事で解説しています。
StatefulSet と PVC でデータを残すStatefulSet の volumeClaimTemplates が Pod ごとに PersistentVolumeClaim を作り、Pod を作り直しても同じボリュームにつなぎ直す仕組みを図で説明します。PVC の名前の付き方、消したり数を減らしたりしても PVC が残る既定の動き、persistentVolumeClaimRetentionPolicy を押さえ、kind のクラスタでデータが残ることを確かめる手順を示します。