Kubernetes を手を動かして学ぶ (1) コンテナ化とローカルクラスタの基礎
Kubernetes(K8s)を「なんとなく知っている」から「自分でひととおり動かせる」に持っていくために、小さな Web アプリを題材にして、コンテナ化 → ローカルクラスタ → AWS EKS → CI/CD → 監視 → GitOps までを一段ずつ手を動かして進めた記録です。全4回シリーズの第1回になります。
- 第1回(本記事) コンテナ化とローカルクラスタの基礎
- 第2回 AWS EKS で本番相当の構成をつくる
- 第3回 CI/CD パイプラインと可観測性
- 第4回 ArgoCD による GitOps(Pull 型 CD)
題材のアプリは、Go の net/http だけで / にアクセスカウントを返すだけの Web サーバーです。アプリの作り込みではなく、その周りのインフラ(K8s)の理解が目的なので、アプリ側はとにかく小さくしています。
手元で試すなら、Docker・kind・kubectl の3つがあれば足ります。
コンテナ化:マルチステージビルドと非 root 実行
まずはアプリをコンテナ化します。最初のアプリはこれだけです。
// app/main.go(最初のバージョン)
package main
import (
"fmt"
"log"
"net/http"
"os"
)
func main() {
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Hello, Kubernetes!")
})
log.Printf("listening on :%s", port)
log.Fatal(http.ListenAndServe(":"+port, nil))
}
これを Docker イメージにします。ポイントは2つあります。
1つ目はマルチステージビルド。 ビルド用のステージ(golang イメージで go build)と実行用のステージ(軽量な alpine)を分け、実行イメージにはビルド済みバイナリだけをコピーします。
# Dockerfile
# --- Build stage ---
FROM golang:1.23-alpine AS build
WORKDIR /app
COPY ./app/go.mod ./app/go.sum ./
RUN go mod download
COPY ./app /app
RUN go build -o /hello-world
# --- Deploy stage ---
FROM alpine:3.23.5
WORKDIR /
COPY --from=build /hello-world /hello-world
EXPOSE 8080
RUN addgroup -S nonroot && adduser -S nonroot -G nonroot
USER nonroot:nonroot
CMD ["/hello-world"]
こうする理由は、イメージサイズの削減だけではありません。コンパイラやソースコードといった「実行時には不要なもの」を最終イメージに含めないことで、攻撃対象領域(attack surface)を小さくするというセキュリティ上の意味が大きいです。
2つ目は非 root 実行(上の Dockerfile の addgroup / adduser / USER の部分)。ここは詰まりやすいポイントです。 RUN addgroup ... を書かずにいきなり USER nonroot:nonroot と指定すると、docker build は成功するのに docker run で落ちます。
docker: Error response from daemon: unable to find user nonroot:
no matching entries in passwd file.
ポイントは、USER 命令が docker build 時点では検証されず、docker run で実際にプロセスを起動する際に初めてユーザー名解決が走ることです。alpine には nonroot というユーザーは標準では存在しません(nonroot が最初から入っているのは distroless イメージ特有の話です)。だから build は通り、run した瞬間に「そんなユーザーはいない」と失敗するわけです。
対策は上の Dockerfile のように addgroup / adduser で自分でユーザーを作ってから USER を指定すること。今のアプリの動作に非 root が必須なわけではありませんが、後で Kubernetes の Pod Security Standards の restricted プロファイルなどで runAsNonRoot が要求される場面を見据えた、予防的な設定という位置づけです。
ビルドしておきます。
docker build -t hello-app:v1 .
ローカルクラスタ:kind と imagePullPolicy の罠
ローカルの Kubernetes には kind(Kubernetes IN Docker)を使います。minikube と比べて、実務の構成に近い体験がしやすいためです。まずはクラスタを作ります。
kind create cluster --name study-k8s-app
イメージの配布は、レジストリを立てずに kind load docker-image でノードに直接注入するのが手軽です。
kind load docker-image hello-app:v1 --name study-k8s-app
ここでクイズです。
Q. マニフェストに
image: hello-app:latestと書いて Pod を作ると何が起きる?
答えは ErrImagePull で起動に失敗する、です。理由は imagePullPolicy のデフォルト挙動にあります。タグを省略した場合や :latest を指定した場合、imagePullPolicy はデフォルトで Always になります。すると kubelet は「毎回レジストリから最新を pull しよう」としますが、hello-app はどのレジストリにも存在しないローカルイメージなので失敗します。
kind load で注入したローカルイメージを使いたいときは、imagePullPolicy: IfNotPresent を明示する必要があります(このあとのマニフェストでは明示しています)。
Deployment → ReplicaSet → Pod の3層構造
アプリを動かす最小のマニフェストがこれです。
# k8s/base/app-deployment.yaml(最初のバージョン)
apiVersion: apps/v1
kind: Deployment
metadata:
name: study-k8s-app-deployment
spec:
replicas: 2
selector:
matchLabels:
app: study-k8s-app
template:
metadata:
labels:
app: study-k8s-app
spec:
containers:
- name: study-k8s-app
image: hello-app:v1
imagePullPolicy: IfNotPresent # ← kind load したローカルイメージを使うため
ports:
- containerPort: 8080
これを apply して Pod 名を見ると、study-k8s-app-deployment-<英数字>-<英数字> のような名前になっています。この命名から3層構造が見えてきます。
- Deployment は「Pod テンプレート」と「レプリカ数」を宣言するだけ
- 実際に Pod を作るのは、Deployment が自動生成する ReplicaSet
- Pod 名の真ん中にある値は
pod-template-hashで、Pod テンプレートの内容から計算されるハッシュ
graph TD D["Deployment<br/>replicas とテンプレートを宣言"] --> RS["ReplicaSet<br/>pod-template-hash ごとに1つ生成"] RS --> P1["Pod"] RS --> P2["Pod"]
なぜわざわざ ReplicaSet を挟むのか。理由は2つあります。
- ローリングアップデートを段階的に行うため。 新旧の Pod を一気に入れ替えるのではなく、
maxSurge/maxUnavailableに従って少しずつ入れ替える。 - ロールバック用の履歴を残すため。 更新後も古い ReplicaSet を(レプリカ数 0 で)履歴として残しておき、
kubectl rollout undoで即座に戻せるようにする。
この pod-template-hash は後の回でも繰り返し出てくる重要な概念です。「Pod テンプレートが1文字でも変われば新しい ReplicaSet が作られ、変わらなければ Pod は作り直されない」という性質が、CI/CD の設計(第3回)に直結します。
Service の負荷分散は誰がやっているのか
複数 Pod へのアクセスを1つの窓口にまとめるのが Service です。ここでは ClusterIP にして kubectl port-forward でアクセスします。
# k8s/base/app-service.yaml
apiVersion: v1
kind: Service
metadata:
name: study-k8s-app-service
labels:
app: study-k8s-app
spec:
type: ClusterIP
selector:
app: study-k8s-app # ← このラベルを持つ Pod を宛先にする
ports:
- port: 80
targetPort: 8080
name: http
ここでクイズです。
Q. Service の負荷分散は、誰がやっているのか?
type: LoadBalancer と答えたくなりますが、これは不正解です。Service の種類(type)と、複数 Pod への振り分けの仕組みはまったくの別物です。
実際の振り分けは、次の2つのコンポーネントの協調で実現されています(Virtual IPs and Service Proxies 参照)。
- コントロールプレーンの Endpoint コントローラが、Service の
selectorにマッチする Pod を監視して EndpointSlice を更新する - 各ノードの kube-proxy がそれを見て、iptables / IPVS のルールを書き換える
graph LR EC["Endpoint コントローラ<br/>(コントロールプレーン)"] -->|"selector にマッチする Pod を監視"| ES["EndpointSlice"] ES -->|"変更を検知"| KP["kube-proxy<br/>(各ノード)"] KP -->|"iptables・IPVS ルールを書き換え"| P1["Pod"] KP --> P2["Pod"]
ここまでを動かしてみます。
kubectl apply -f k8s/base/app-deployment.yaml -f k8s/base/app-service.yaml
kubectl port-forward svc/study-k8s-app-service 8080:80
curl http://localhost:8080/ # => Hello, Kubernetes!
ちなみに kubectl port-forward svc/... は、上記の kube-proxy の仕組みを経由しません。API サーバーがバックエンドの Pod を1つ選んで直接トンネルを張るため、port-forward セッションの間はずっと同じ1つの Pod に固定されます(負荷分散は起きません)。これを知らないと「Service なのに1つの Pod にしか繋がらない」と混乱します。
詰まりポイント:ローリングアップデート中に Pod が一瞬 Error になる
イメージのタグを変えて kubectl apply し、kubectl get pods -w で監視すると、新しい Pod が Running になってから古い Pod が Terminating になる、という順序(maxSurge により新旧が一時的に共存する)が観察できます。
このとき、古い Pod が Terminating の途中で一瞬 Error ステータスになることがあります。原因は、アプリが SIGTERM を一切ハンドリングしていないこと。Pod の終了処理で送られてくる SIGTERM に対して、OS のデフォルト動作(プロセス即時終了、非ゼロ終了コード)で落ちてしまうためです。
対策として、実務では http.Server.Shutdown() による graceful shutdown や、preStop フックでの猶予時間確保が必要になります(Probe による死活監視は第3回で扱います。graceful shutdown そのものはシリーズを通しての残課題です)。
複数コンポーネント:バックエンド + DB と PVC
次に、アプリ(バックエンド)と PostgreSQL(DB)の2コンポーネント構成にします。フロントエンドは作らず、K8s の構成理解に集中します。アプリは、アクセスのたびに visits テーブルへ1行 INSERT して件数を返すように変えます。
// app/main.go(DB 対応版・抜粋)
func openDB() (*sql.DB, error) {
dsn := fmt.Sprintf(
"host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
os.Getenv("DB_HOST"), os.Getenv("DB_PORT"), os.Getenv("DB_USER"),
os.Getenv("DB_PASSWORD"), os.Getenv("DB_NAME"),
)
return sql.Open("postgres", dsn) // ドライバは github.com/lib/pq
}
func helloHandler(w http.ResponseWriter, r *http.Request) {
db.Exec("INSERT INTO visits DEFAULT VALUES")
var count int
db.QueryRow("SELECT count(*) FROM visits").Scan(&count)
fmt.Fprintf(w, "Hello, Kubernetes! (visit count: %d)\n", count)
}
なぜコンポーネントごとに別の Deployment / Service に分けるのか。 Pod は「常に一緒にスケジューリング・スケール・再起動される単位」です。スケール特性やライフサイクルが異なるバックエンドと DB を1つの Pod に同居させるべきではありません。別の Deployment にすることで、それぞれ独立してロールアウト・スケールできます。
コンポーネント間の通信は Service 名(K8s の DNS)で行います。 Pod は再作成のたびに IP が変わるため、IP 直指定ではなく Service の DNS 名(下の postgres-service)で接続します。Service は背後の Pod が入れ替わっても変わらない、安定した名前を提供します。
DB のデータを Pod のライフサイクルから独立させるために、PersistentVolume(PV) / PersistentVolumeClaim(PVC) を使います。PVC が「ストレージが欲しい」という要求、PV が実際のストレージです。kind ではデフォルトの StorageClass(名前は standard、実際にボリュームを用意する provisioner は rancher.io/local-path)にそのまま任せられます。
# k8s/base/postgres-pvc.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: postgres-pvc
spec:
accessModes: [ReadWriteOnce]
resources:
requests:
storage: 5Gi
---
# k8s/base/postgres-service.yaml
apiVersion: v1
kind: Service
metadata:
name: postgres-service
spec:
type: ClusterIP
selector:
app: postgres
ports:
- port: 5432
targetPort: 5432
---
# k8s/base/postgres-deployment.yaml(抜粋)
apiVersion: apps/v1
kind: Deployment
metadata:
name: postgres-deployment
spec:
replicas: 1
selector:
matchLabels:
app: postgres
template:
metadata:
labels:
app: postgres
spec:
containers:
- name: postgres
image: postgres:16-alpine
ports:
- containerPort: 5432
volumeMounts:
- name: postgres-data
mountPath: /var/lib/postgresql/data
volumes:
- name: postgres-data
persistentVolumeClaim:
claimName: postgres-pvc
起動順序は保証されない、という落とし穴
kubectl apply で複数の Deployment を同時に作っても、どちらの Pod が先に Ready になるかは保証されません。DB より先にアプリが起動すると接続に失敗します。
そこでアプリ側に接続リトライを入れます。
// 起動時に DB が来るまで待つ(最初の素朴な実装)
func connectWithRetry() *sql.DB {
conn, _ := openDB()
for i := 0; i < 30; i++ {
if err := conn.Ping(); err == nil {
return conn
}
log.Printf("waiting for db... (%d)", i)
time.Sleep(2 * time.Second)
}
log.Fatal("could not connect to db") // ← 待ちきれないとここで落ちる
return nil
}
ここにも詰まりポイントがあります。リトライ回数が足りないと——たとえば10回(約20秒)だと——PostgreSQL の初回起動(イメージ pull と初期化)に間に合わず、アプリが log.Fatal でクラッシュすることがあります。ところが、それでも結果的にアプリは動きます。kubectl logs --previous で前回のログを見ると、次の挙動が起きています。
- アプリがリトライ上限に達してクラッシュ
- kubelet が
restartPolicy: Always(Pod のデフォルト)に従いコンテナを自動再起動 - 2回目の起動時には PostgreSQL が Ready になっており接続成功
「待機」ではなく「クラッシュ → 自動再起動が結果的に待ちになっている」わけです。リトライ回数を増やせばひとまず緩和できますが、より堅牢な解決(readinessProbe と、DB を待たずに起動する作り)は第3回で扱います。
ConfigMap と Secret:設定と機密情報の分離
上の Go コードは DB 接続情報を環境変数(DB_HOST など)から読んでいます。この値を、アプリのイメージから切り離して外から注入します。ここで ConfigMap と Secret を使い分けます。
# k8s/base/configmap.yaml(非機密)
apiVersion: v1
kind: ConfigMap
metadata:
name: app-config
data:
DB_HOST: postgres-service
DB_PORT: '5432'
DB_NAME: study_k8s
---
# k8s/base/secret.yaml(機密。値は学習用のダミー)
apiVersion: v1
kind: Secret
metadata:
name: db-secret
type: Opaque
stringData: # data と違い平文で書ける(API server が受信時に base64 化する)
DB_USER: user
DB_PASSWORD: password
Deployment 側は、環境変数をこれらから参照する形にします。
# app-deployment.yaml の env(抜粋)
env:
- name: DB_HOST
valueFrom:
configMapKeyRef: { name: app-config, key: DB_HOST }
- name: DB_PASSWORD
valueFrom:
secretKeyRef: { name: db-secret, key: DB_PASSWORD }
# DB_PORT / DB_NAME は ConfigMap、DB_USER は Secret から同様に参照する
使い分けの意味は機密性だけではありません。RBAC で「誰が読めるか」の粒度を分けられるというアクセス制御上の意味があります。
ここで大事な誤解を1つ解いておきます。Secret の Base64 は暗号化ではありません。 誰でもデコードできます。Kubernetes の Secret が提供する保護は、あくまで RBAC によるアクセス制御と etcd の保存時暗号化(encryption at rest)の組み合わせです。本質的な対策は「機密情報を Git にコミットしない」運用(Secrets Manager や External Secrets Operator との連携)です。なお、上の secret.yaml は学習用のダミー値です。本物の値は決してこのように Git へコミットしないでください。
ConfigMap を変えても Pod が更新されない
もう1つ重要な落とし穴です。ConfigMap / Secret の中身だけを変更して Deployment のマニフェストを変えない場合、Pod は自動では作り直されません。
理由は、環境変数として注入された値は起動時に一度読まれるだけで、Pod テンプレート(と pod-template-hash)が変化しないからです。実務では kubectl rollout restart を打つ、ConfigMap のハッシュを Deployment のアノテーションに埋め込む、Reloader のようなツールを使う、といった対応をします。
Ingress:宣言と実行者の分離
外部公開には Ingress を使います。ここで最も大事な概念が「宣言と実行者の分離」です。これはこのシリーズを通して何度も出てくる、Kubernetes の核心的な設計パターンです。
Ingress リソース単体では、何も起きません。 Ingress は「こういうルールで振り分けてほしい」という宣言(設定)にすぎず、実際にトラフィックを受けて処理するのは、別途インストールする Ingress Controller(今回は ingress-nginx)です。
kind で Ingress を試すには、ホストの 80/443 をノードへマッピングした設定でクラスタを作り直します。
# kind-config.yaml
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
nodes:
- role: control-plane
kubeadmConfigPatches:
- |
kind: InitConfiguration
nodeRegistration:
kubeletExtraArgs:
node-labels: "ingress-ready=true"
extraPortMappings:
- { containerPort: 80, hostPort: 80, protocol: TCP }
- { containerPort: 443, hostPort: 443, protocol: TCP }
kind create cluster --name study-k8s-app --config kind-config.yaml
# ingress-nginx(kind 向け公式マニフェスト)を入れる
kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v1.15.1/deploy/static/provider/kind/deploy.yaml
Ingress リソースはこれだけです。
# k8s/base/ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: study-k8s-app-ingress
spec:
ingressClassName: nginx
rules:
- host: study-k8s-app.local
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: study-k8s-app-service
port:
number: 80
study-k8s-app.local でアクセスするために /etc/hosts に1行足して、curl します。
echo "127.0.0.1 study-k8s-app.local" | sudo tee -a /etc/hosts
curl http://study-k8s-app.local/
ここで DNS についての誤解を1つ解いておきます。DNS 解決(名前を IP に変換する)と、実際のルーティング(トラフィックが Controller の Pod に届く)は別のレイヤーです。/etc/hosts は「名前を 127.0.0.1 に変換する」ところまでしか担当しません。その先で実際に ingress-nginx-controller の Pod まで届くのは、kind の Docker ポートマッピングと、その Pod 自身が hostPort: 80/443 を持っていることによるものです。
graph LR
U["ブラウザ / curl"] -->|"host:80"| KM["kind の Docker<br/>ポートマッピング"]
KM --> HC["ingress-nginx-controller Pod<br/>hostPort:80"]
HC -->|"Ingress ルールで振り分け"| SVC["Service"]
SVC -->|"kube-proxy 経由"| APP["アプリ Pod(:8080)"]
APP --> DB[("PostgreSQL")]
そして、トラフィックを実際に受け取って処理しているのは ingress-nginx-controller Pod です。コントロールプレーン(API サーバー等)はクラスタの状態管理だけを担い、実際の HTTP トラフィック(データプレーン)には一切関与しません。この「コントロールプレーンとデータプレーンの分離」も、繰り返し登場する重要な整理です。
Kustomize:宣言的パッチでマニフェストを整理
マニフェストが増えてきたので、Kustomize で整理します。Helm と比較して、ローカルと EKS の差分が軽微でテンプレートエンジンほどの複雑さが不要なこと、base / overlay という思想が今回の構成に合っていることから選びます。
Kustomize は テンプレートエンジンではなく「宣言的パッチ」 です。base に共通のマニフェストを置き、overlay で環境ごとの差分だけを重ねます。
# k8s/base/kustomization.yaml(対象ファイルを明示的にリストする。自動スキャンはしない)
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- app-deployment.yaml
- app-service.yaml
- configmap.yaml
- ingress.yaml
- postgres-deployment.yaml
- postgres-pvc.yaml
- postgres-service.yaml
- secret.yaml
---
# k8s/overlays/local/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ../../base/
images:
- name: hello-app
newTag: v1 # ← 環境ごとに変わりうるイメージタグはここに集約する
特に images: transformer が便利で、イメージタグの管理場所を overlay に一元化できます。base の Deployment は元のイメージのままにしておき、環境ごとに変わりうるタグは overlay 側で上書きする。これで「環境依存の値を base から追い出す」という原則を実践できます。適用は -k(kustomize)で行います。
kubectl apply -k k8s/overlays/local/
まとめ
第1回で扱った中で、後の回でも繰り返し登場する重要な概念を挙げておきます。
pod-template-hash:テンプレートが変われば作り直し、変わらなければそのまま。CI/CD のタグ設計(第3回)に直結する。- 宣言と実行者の分離:Ingress ⇔ Ingress Controller。これは ServiceMonitor ⇔ Operator(第3回)、Application ⇔ ArgoCD コントローラ(第4回)とまったく同じ構造。
- コントロールプレーンとデータプレーンの分離:状態管理と実トラフィックは別。
- Service 名(DNS)による安定した接続先:Pod IP は変わる。
次回は、これをローカルの kind から AWS EKS に持っていきます。マネージドなコントロールプレーン、IAM と Kubernetes RBAC の2層構造、そして Terraform の state を失う大きな落とし穴までを扱います。
→ 第2回 AWS EKS で本番相当の構成をつくる