Kubernetes を手を動かして学ぶ (1) コンテナ化とローカルクラスタの基礎

Kubernetes(K8s)を「なんとなく知っている」から「自分でひととおり動かせる」に持っていくために、小さな Web アプリを題材にして、コンテナ化 → ローカルクラスタ → AWS EKS → CI/CD → 監視 → GitOps までを一段ずつ手を動かして進めた記録です。全4回シリーズの第1回になります。

題材のアプリは、Go の net/http だけで / にアクセスカウントを返すだけの Web サーバーです。アプリの作り込みではなく、その周りのインフラ(K8s)の理解が目的なので、アプリ側はとにかく小さくしています。

手元で試すなら、Dockerkindkubectl の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 Standardsrestricted プロファイルなどで 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つあります。

  1. ローリングアップデートを段階的に行うため。 新旧の Pod を一気に入れ替えるのではなく、maxSurge / maxUnavailable に従って少しずつ入れ替える。
  2. ロールバック用の履歴を残すため。 更新後も古い 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 参照)。

  1. コントロールプレーンの Endpoint コントローラが、Service の selector にマッチする Pod を監視して EndpointSlice を更新する
  2. 各ノードの 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、実際にボリュームを用意する provisionerrancher.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 で前回のログを見ると、次の挙動が起きています。

  1. アプリがリトライ上限に達してクラッシュ
  2. kubelet が restartPolicy: Always(Pod のデフォルト)に従いコンテナを自動再起動
  3. 2回目の起動時には PostgreSQL が Ready になっており接続成功

「待機」ではなく「クラッシュ → 自動再起動が結果的に待ちになっている」わけです。リトライ回数を増やせばひとまず緩和できますが、より堅牢な解決(readinessProbe と、DB を待たずに起動する作り)は第3回で扱います。

ConfigMap と Secret:設定と機密情報の分離

上の Go コードは DB 接続情報を環境変数(DB_HOST など)から読んでいます。この値を、アプリのイメージから切り離して外から注入します。ここで ConfigMapSecret を使い分けます。

# 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 で本番相当の構成をつくる

参考文献