Ritolabo
  1. Home
  2. Kubernetes
  3. 自作の API を Kubernetes で動かす

自作の API を Kubernetes で動かす

  • 公開日
  • カテゴリ:Kubernetes
  • タグ:Kubernetes,学習メモ
自作の API を Kubernetes で動かす

自分で書いたプログラムを Kubernetes で動かし、クラスタの外から届くようにしたい。これまで nginx のような既製の image を動かしてきたが、自分のプログラムになると「image を自分で作って Node に置く」手順が 1 つ増える。それ以外の YAML は nginx のときと同じ。

この記事では、HTTP で JSON を返す小さな API(Go)を題材に、次の順で確認する。

  • Go API をそのまま動かし、環境変数で設定値を受け取ること
  • Dockerfile で image demo-api:1.0.0 を作り、kind の Node に入れること
  • ConfigMap と Secret の値が、環境変数として Pod に渡ること
  • Deployment で Pod を 3 つ動かし、Service の名前でクラスタの中から届くこと
  • Ingress で、api.example.com という名前で届くこと

contents

  1. 環境
  2. 作るものと、できあがる構成
  3. デプロイする API
  4. image を作って kind の Node に入れる
    1. Dockerfile
    2. image を作って Node に入れる
  5. ConfigMap と Secret を作る
  6. Deployment を作る
  7. Service を作る
  8. Ingress を作る
  9. 消す
  10. 用語メモ
  11. 参考 URL

環境

kind で作ったクラスタ(Control Plane 1台 + Worker 2台)を使う。(作り方は「Kubernetes の Pod とは?コンテナを動かす最小単位を整理する」を参照)

  • 環境: macOS(Apple Silicon)、Docker Desktop 29.8.0、kind v0.33.0、Kubernetes v1.37.0、kubectl v1.36.1、Go 1.25

クラスタは、localhost の 80 番を Worker の 80 番につなぐ extraPortMappings 付きで作ってあり、Ingress Controller として Contour を入れてある。(どちらも「Ingress: Kubernetes で外からのリクエストをアプリごとに振り分ける」を参照)

$ kubectl get pods -n projectcontour

NAME                            READY   STATUS      RESTARTS   AGE
contour-859c5dcfdc-57grs        1/1     Running     0          45m
contour-859c5dcfdc-qr7lg        1/1     Running     0          45m
contour-certgen-v1-33-7-gchsc   0/1     Completed   0          45m
envoy-8gxkt                     2/2     Running     0          45m
envoy-dgtx4                     2/2     Running     0          45m

作るものと、できあがる構成

この記事でやるのは、http://localhost/ にリクエストを送ると、クラスタの中の自分の API が返事をする、まで。

作るもの何か実体と置き場所
Go API(main.go)HTTP で JSON を返すプログラム。設定値は環境変数から読む最初は手元のプロセス。あとは Pod の中のコンテナ
image demo-api:1.0.0Go API の実行ファイルを入れたコンテナの元手元の Docker と、3 つの Node
ConfigMap api-config設定値 APP_ENV / GREETINGControl Plane の etcd に保存されたデータ
Secret api-secret機密情報 API_KEY同上
Deployment demo-api上の image の Pod を 3 つ保つ定義。環境変数・readiness probe・CPU とメモリの量同上。Pod のコンテナは Worker の上で動く
Service demo-apiPod 3 つの手前の固定の名前と IP同上
Ingress demo-api「Host: api.example.com なら Service demo-api へ」のルール同上。読んで動くのは Contour

できあがる構成を図にすると次のとおり。角が二重の箱は動いているプログラム、それ以外の etcd の中の箱は保存されたデータ。Pod がどの Node にあるかは、この仕組みには関係ないので描いていない。

図を描画しています…
  • 自分で書くのは、Go のコードと Dockerfile、YAML 6 つ(ConfigMap、Secret、Deployment、Service、Ingress、確認用の Pod)
  • nginx のときと違うのは「image を自分で作って Node に入れる」ところだけ。Deployment の image に自分の image の名前を書けば、あとは同じ

デプロイする API

URL返すもの
GET /JSON。message(環境変数 GREETING)、env(APP_ENV)、hostname(動いているマシンの名前。Pod の中では Pod の名前)、version(1.0.0)
GET /adminヘッダー X-API-Key が環境変数 API_KEY と一致すれば 200、違えば 401
GET /healthzok(readiness probe 用)
// main.go
package main

import (
	"encoding/json"
	"log"
	"net/http"
	"os"
)

const version = "1.0.0"

type response struct {
	Message  string `json:"message"`  // GREETING(ConfigMap)
	Env      string `json:"env"`      // APP_ENV(ConfigMap)
	Hostname string `json:"hostname"` // Pod の名前(コンテナのホスト名)
	Version  string `json:"version"`
}

func main() {
	greeting := getenv("GREETING", "hello")
	appEnv := getenv("APP_ENV", "local")
	apiKey := os.Getenv("API_KEY") // Secret。空なら /admin は誰も通さない
	port := getenv("PORT", "8080")
	hostname, _ := os.Hostname()

	http.HandleFunc("/", logRequest(func(w http.ResponseWriter, r *http.Request) {
		if r.URL.Path != "/" {
			http.NotFound(w, r)
			return
		}
		writeJSON(w, http.StatusOK, response{greeting, appEnv, hostname, version})
	}))

	http.HandleFunc("/admin", logRequest(func(w http.ResponseWriter, r *http.Request) {
		if apiKey == "" || r.Header.Get("X-API-Key") != apiKey {
			writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
			return
		}
		writeJSON(w, http.StatusOK, map[string]string{"message": "welcome, admin"})
	}))

	http.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
		w.Write([]byte("ok\n"))
	})

	log.Printf("demo-api %s listening on :%s (APP_ENV=%s)", version, port, appEnv)
	log.Fatal(http.ListenAndServe(":"+port, nil))
}

func getenv(key, fallback string) string {
	if v := os.Getenv(key); v != "" {
		return v
	}
	return fallback
}

func writeJSON(w http.ResponseWriter, status int, v any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(v)
}

func logRequest(next http.HandlerFunc) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		log.Printf("%s %s from %s", r.Method, r.URL.Path, r.RemoteAddr)
		next(w, r)
	}
}
$ export GREETING='hello from kubernetes' APP_ENV=production API_KEY=demo-api-key-123

$ go run .

# => 2026/10/12 09:38:34 demo-api 1.0.0 listening on :8080 (APP_ENV=production)

別のターミナルから打つ。

$ curl -s http://localhost:8080/

# => {"message":"hello from kubernetes","env":"production","hostname":"ritonoMacBook-Pro.local","version":"1.0.0"}

$ curl -si http://localhost:8080/admin | head -1

# => HTTP/1.1 401 Unauthorized

$ curl -s -H 'X-API-Key: demo-api-key-123' http://localhost:8080/admin

# => {"message":"welcome, admin"}

message と env が export した値、hostname が手元のマシンの名前。Pod にすると、ここが Pod の名前になる。

image を作って kind の Node に入れる

Deployment の YAML には image: demo-api:1.0.0 と名前を書くだけで、image そのもの(プログラムとファイルの束)は Node が取ってこられる場所に実在していないといけない。

nginx のとき今回の Go API
image はどこにあるかDocker Hub(インターネット上の image 置き場 = レジストリ)にあるまだどこにもない。docker build で手元の Docker の中に作る
Node はどう手に入れるかkubelet が Docker Hub からダウンロードするNode は別のマシン(コンテナ)で、手元の Docker の中は見えない。Node に運ぶ作業が 1 回要る

運ぶ方法は 2 つ。本番ではレジストリに docker push して Node に取らせる。kind には kind load docker-image という、手元の Docker にある image を Node のコンテナの中に直接コピーするコマンドがあり、レジストリを用意しないで済む。

Dockerfile

Dockerfile は image の作り方を書いたテキストファイルで、docker build が上から順に実行する。

# Dockerfile
# 1 段階目(build): Go のコンパイラが入った image の中でビルドする
FROM golang:1.25-alpine AS build
WORKDIR /src
COPY go.mod main.go ./
RUN CGO_ENABLED=0 go build -o /demo-api .

# 2 段階目(run): できた実行ファイルだけを、シェルも何も入っていない小さな image に入れる
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /demo-api /demo-api
EXPOSE 8080
USER nonroot
ENTRYPOINT ["/demo-api"]
  • 段階が 2 つあるのは、Go のコンパイラ(数百 MB)を最終的な image に入れないため。1 段階目でコンパイルし、2 段階目には実行ファイル 1 つだけをコピーする
  • 2 段階目の distroless/static はシェルも入っていない image。なので、あとで kubectl exec deploy/demo-api -- sh はできない。中の様子は API の返事と kubectl logs で見る

image を作って Node に入れる

$ docker build -t demo-api:1.0.0 .

$ kind load docker-image demo-api:1.0.0 --name k8s-study

Image: "demo-api:1.0.0" with ID "sha256:8f83156405c4106b80fcf8bd6c9472aef96985ee6b78c8b15fb4f660683c3d3e" not yet present on node "k8s-study-worker", loading...
Image: "demo-api:1.0.0" with ID "sha256:8f83156405c4106b80fcf8bd6c9472aef96985ee6b78c8b15fb4f660683c3d3e" not yet present on node "k8s-study-control-plane", loading...
Image: "demo-api:1.0.0" with ID "sha256:8f83156405c4106b80fcf8bd6c9472aef96985ee6b78c8b15fb4f660683c3d3e" not yet present on node "k8s-study-worker2", loading...

$ docker exec k8s-study-worker crictl images | grep demo-api

# => docker.io/library/demo-api                      1.0.0                8f83156405c41       11MB
  • -t demo-api:1.0.0 が image の名前とタグ(: の後ろ。バージョンの目印)
  • kind load は 3 つの Node 全部に入れる。Pod がどの Node に置かれても起動できるようにするため
  • crictl images は、Node の中でコンテナを動かしている containerd が持つ image の一覧。レジストリを省いた名前は docker.io/library/ 付きの正式な形で出る

image は Node(Docker コンテナ)の中のディスクに入る。Node を止めて起動し直しても残るが、クラスタを消して作り直すと消えるので、そのときは kind load をやり直す。本番でレジストリを使うのはこのためで、Node が消えても増えても kubelet がレジストリから取ってくる。

kubelet がこの image を使うかどうかは imagePullPolicy で決まり、タグが latest 以外なら既定は IfNotPresent(Node になければ取る。あれば使う)。タグを書かないか latest にすると既定が Always(毎回レジストリに取りに行く)になり、レジストリにない demo-api は ErrImagePull になる。

ConfigMap と Secret を作る

Go API に渡す設定値(APP_ENV、GREETING)と機密情報(API_KEY)を、Kubernetes のデータとして置く。ConfigMap は設定値を キー: 値 で置くデータ。Secret は同じ形で機密情報用で、保存は base64(見た目を変えるだけで暗号化ではない)、kubectl describe で中身を出さないという違いがある。(それぞれ「ConfigMap: Kubernetes で Pod に渡す設定値」「Secret: Kubernetes で機密情報を Pod に渡す」を参照)

export した 3 つの値を、そのままこの 2 つに入れる。

# api-config.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: api-config
data:
  APP_ENV: production
  GREETING: hello from kubernetes
# api-secret.yaml
apiVersion: v1
kind: Secret
metadata:
  name: api-secret
type: Opaque
stringData:
  API_KEY: demo-api-key-123      # この記事用のダミーの値。実際に使っているものではない
$ kubectl apply -f api-config.yaml -f api-secret.yaml

configmap/api-config created
secret/api-secret created

$ kubectl get configmap api-config

NAME         DATA   AGE
api-config   2      5s

$ kubectl describe secret api-secret

Name:         api-secret
Namespace:    default
Labels:       <none>
Annotations:  <none>

Type:  Opaque

Data
====
API_KEY:  16 bytes

DATA はキーの数。Secret の describe は値を出さず、長さだけ出す。

Deployment を作る

image demo-api:1.0.0 で Pod を 3 つ動かし、ConfigMap と Secret の値を環境変数で渡す。

# demo-api-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: demo-api
spec:
  replicas: 3
  selector:
    matchLabels:
      app: demo-api
  template:
    metadata:
      labels:
        app: demo-api
    spec:
      containers:
        - name: api
          image: demo-api:1.0.0          # kind に入れた image
          ports:
            - containerPort: 8080
          envFrom:                       # ConfigMap / Secret の全部のキーを環境変数にする
            - configMapRef:
                name: api-config
            - secretRef:
                name: api-secret
          readinessProbe:                # /healthz が 200 を返すまで Service の宛先に入れない
            httpGet:
              path: /healthz
              port: 8080
            periodSeconds: 5
          resources:
            requests:
              cpu: 50m
              memory: 32Mi
            limits:
              cpu: 200m
              memory: 64Mi

nginx の Deployment と違うのは containers[] の中の 4 つ。

フィールド何をするか
image: demo-api:1.0.0Node に入れた image。タグが latest でないので Node のものが使われる
envFromapi-config の 2 つのキーと api-secret の 1 つのキーを、キーの名前そのままで環境変数にする。Go API はそれを os.Getenv で読む
readinessProbekubelet が 5 秒ごとに GET /healthz を打ち、200 が返るまでその Pod を Service の宛先に入れない
resourcesこの Pod が使う CPU とメモリの、確保する量(requests)と上限(limits)
$ kubectl apply -f demo-api-deployment.yaml

# => deployment.apps/demo-api created

$ kubectl get pods -l app=demo-api -o wide

NAME                       READY   STATUS    RESTARTS   AGE   IP            NODE                NOMINATED NODE   READINESS GATES
demo-api-99b5db679-fttjj   1/1     Running   0          6s    10.244.3.9    k8s-study-worker    <none>           <none>
demo-api-99b5db679-jv7zv   1/1     Running   0          6s    10.244.1.10   k8s-study-worker2   <none>           <none>
demo-api-99b5db679-pd7zz   1/1     Running   0          6s    10.244.1.9    k8s-study-worker2   <none>           <none>

$ kubectl describe pod -l app=demo-api | grep -A 3 'Environment Variables from'

    Environment Variables from:
      api-config  ConfigMap  Optional: false
      api-secret  Secret     Optional: false
    Environment:           <none>

3 つが Running、READY 1/1。1/1 は readiness probe が通ったということ。Environment Variables from: に、環境変数の出どころが 2 つ出ている。

環境変数はプロセスの起動時に読まれるので、ConfigMap の値をあとで変えても、動いている Pod には反映されない。反映するには Pod を作り直す(kubectl rollout restart deployment demo-api)。

Service を作る

3 つの Pod に、クラスタの中から届く 1 つの宛先(名前 demo-api)を付ける。クラスタの中から打つための Pod client(busybox、sleep するだけ)も作る。(Service は「Service: Kubernetes で Pod への宛先を固定する」を参照)

# demo-api-service.yaml
apiVersion: v1
kind: Service
metadata:
  name: demo-api
spec:
  selector:
    app: demo-api
  ports:
    - port: 80                 # Service のポート
      targetPort: 8080         # Pod(Go API)のポート
# client.yaml
apiVersion: v1
kind: Pod
metadata:
  name: client
spec:
  containers:
    - name: client
      image: busybox:1.36
      command: ["sleep", "86400"]
$ kubectl apply -f demo-api-service.yaml -f client.yaml

service/demo-api created
pod/client created

$ kubectl get svc demo-api

NAME       TYPE        CLUSTER-IP     EXTERNAL-IP   PORT(S)   AGE
demo-api   ClusterIP   10.96.234.33   <none>        80/TCP    5s

$ for i in 1 2 3; do kubectl exec client -- wget -qO- http://demo-api/; done

{"message":"hello from kubernetes","env":"production","hostname":"demo-api-99b5db679-fttjj","version":"1.0.0"}
{"message":"hello from kubernetes","env":"production","hostname":"demo-api-99b5db679-jv7zv","version":"1.0.0"}
{"message":"hello from kubernetes","env":"production","hostname":"demo-api-99b5db679-jv7zv","version":"1.0.0"}
  • message と env が ConfigMap の値。export だったものが、ConfigMap → 環境変数 → Go API と渡った。main.go は変えていない
  • hostname が Pod の名前になり、打つたびに変わる。Service が 3 つの Pod に振り分けている(順番は決まっていない)

Secret の API_KEY も渡っているか。/admin を鍵なしと鍵ありで打つ。

$ kubectl exec client -- wget -qO- http://demo-api/admin

wget: server returned error: HTTP/1.1 401 Unauthorized
command terminated with exit code 1

$ kubectl exec client -- wget -qO- --header 'X-API-Key: demo-api-key-123' http://demo-api/admin

# => {"message":"welcome, admin"}

Secret に書いた値と同じ鍵で通った。Go API は環境変数 API_KEY と比べているだけで、それが Secret から来たことは知らない。

Ingress を作る

ホスト名 api.example.com で Go API に届くようにする。Ingress は「Host ヘッダーがこの名前の HTTP リクエストは、この Service へ」というルールのデータで、Contour がこれを読んで envoy(Node の 80 番で待っているリバースプロキシ)に設定を配る。

# demo-api-ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: demo-api
spec:
  rules:
    - host: api.example.com    # Host ヘッダーがこの名前なら demo-api へ
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: demo-api
                port:
                  number: 80
$ kubectl apply -f demo-api-ingress.yaml

# => ingress.networking.k8s.io/demo-api created

$ kubectl get ingress

NAME       CLASS    HOSTS             ADDRESS   PORTS   AGE
demo-api   <none>   api.example.com             80      5s

api.example.com は実在しない名前なので、宛先は localhost(Docker のポート転送で Worker の 80 番 = envoy)のまま、-H で Host ヘッダーだけ付ける。本番では DNS がこの名前を入口の IP に向けるので、ブラウザがこのヘッダーを自動で付ける。

$ for i in 1 2 3; do curl -s -H 'Host: api.example.com' http://localhost/; done

{"message":"hello from kubernetes","env":"production","hostname":"demo-api-99b5db679-jv7zv","version":"1.0.0"}
{"message":"hello from kubernetes","env":"production","hostname":"demo-api-99b5db679-jv7zv","version":"1.0.0"}
{"message":"hello from kubernetes","env":"production","hostname":"demo-api-99b5db679-jv7zv","version":"1.0.0"}

$ curl -si -H 'Host: api.example.com' http://localhost/admin | head -1

# => HTTP/1.1 401 Unauthorized

$ curl -s -H 'Host: api.example.com' -H 'X-API-Key: demo-api-key-123' http://localhost/admin

# => {"message":"welcome, admin"}

$ curl -si -H 'Host: api.example.com' http://localhost/ | grep -i '^server'

# => server: envoy

$ curl -si http://localhost/ | head -1

# => HTTP/1.1 404 Not Found
打ち方結果なぜ
Host: api.example.com で /Go API の JSONIngress のルールに合い、envoy が Service demo-api の Pod に送った
/admin を鍵なし / 鍵あり401 / 200クラスタの中から打ったときと同じ。外からでも Secret の値で通る
server: envoy返事のヘッダー間に envoy がいる証拠。Go API 自身は Server ヘッダーを付けていない
Host なし(localhost)404どのルールにも合わないので envoy が返した。Go API には届いていない

手元のプロセスだった Go API が、Pod 3 つ → Service → Ingress の後ろで動き、外から名前で届くようになった。

消す

$ kubectl delete -f demo-api-ingress.yaml -f demo-api-service.yaml -f client.yaml -f demo-api-deployment.yaml -f api-secret.yaml -f api-config.yaml

ingress.networking.k8s.io "demo-api" deleted from default namespace
service "demo-api" deleted from default namespace
pod "client" deleted from default namespace
deployment.apps "demo-api" deleted from default namespace
secret "api-secret" deleted from default namespace
configmap "api-config" deleted from default namespace

手元の image は docker rmi demo-api:1.0.0。Node の中の image は、クラスタを消す(kind delete cluster --name k8s-study)と一緒に消える。

用語メモ

用語読み方意味
imageイメージプログラムと必要なファイルをひとまとめにしたコンテナの元。名前:タグ で指す
tagタグimage 名の : の後ろ。バージョンの目印。latest は「最新」の意味の既定値で、imagePullPolicy の既定が Always になる
registryレジストリimage を置いておくサーバー。Docker Hub(docker.io)が既定。レジストリを省いた名前は docker.io/library/名前 の意味
Dockerfileドッカーファイルimage の作り方(元にする image、コピーするファイル、実行するコマンド)を書いたテキストファイル
multi-stage buildマルチステージ ビルドDockerfile に FROM を 2 つ以上書き、前の段階で作ったファイルだけを次の段階にコピーする書き方。コンパイラを最終 image に入れないため
distrolessディストロレスシェルもパッケージ管理も入っていない、実行ファイルを置くだけの小さな image
kind load docker-imageカインド ロード手元の Docker にある image を、kind のクラスタの Node にコピーするコマンド。レジストリの代わり
crictlシーアールアイ シーティーエルNode の中で containerd(コンテナを実際に動かすプログラム)に問い合わせるコマンド。crictl images で Node が持つ image の一覧
imagePullPolicyイメージ プル ポリシーkubelet が image をいつレジストリから取るか。IfNotPresent(なければ取る)、Always(毎回)、Never(取らない)。書かないときはタグで決まる
envFromエンブ フロムConfigMap / Secret の全部のキーを、キー名のまま環境変数にする Deployment のフィールド
readiness probeレディネス プローブkubelet が定期的に Pod を確認し、通った Pod だけを Service の宛先に入れる仕組み

まとめ

  • 自分のプログラムを Kubernetes で動かすのに増える手順は「Dockerfile で image を作り、タグを付けて Node(本番ならレジストリ)に置く」の 1 つ。あとは nginx と同じ YAML
  • kind では kind load docker-image で手元の Docker から Node に image をコピーする。タグが latest 以外なら、kubelet は Node にある image を使う
  • 設定値と機密情報は、プログラムには環境変数で渡す。ソースコードは置き場所を知らない(手元では export、Kubernetes では ConfigMap / Secret + envFrom)
  • readiness probe が通った Pod だけが Service の宛先に入る。READY 1/1 がその印
  • Service の名前(demo-api)はクラスタの中だけ。外からはホスト名(api.example.com)で Ingress が受け、Ingress Controller(Contour の envoy)が Service の Pod に渡す。ルールに合わないリクエストは envoy が 404 を返し、アプリには届かない
  • シェルの入っていない image では kubectl exec で中を見られない。API の返事と kubectl logs で見る

参考 URL


[Prev] Ingress: Kubernetes で外からのリクエストをアプリごとに振り分ける

Author

rito

rito

  • Backend Engineer
  • Tokyo, Japan
  • PHP 5 技術者認定上級試験 認定者
  • 統計検定 3 級