自作の API を Kubernetes で動かす
- 公開日
- カテゴリ:Kubernetes
- タグ: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
- 環境
- 作るものと、できあがる構成
- デプロイする API
- image を作って kind の Node に入れる
- ConfigMap と Secret を作る
- Deployment を作る
- Service を作る
- Ingress を作る
- 消す
- 用語メモ
- 参考 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.0 | Go API の実行ファイルを入れたコンテナの元 | 手元の Docker と、3 つの Node |
ConfigMap api-config | 設定値 APP_ENV / GREETING | Control Plane の etcd に保存されたデータ |
Secret api-secret | 機密情報 API_KEY | 同上 |
Deployment demo-api | 上の image の Pod を 3 つ保つ定義。環境変数・readiness probe・CPU とメモリの量 | 同上。Pod のコンテナは Worker の上で動く |
Service demo-api | Pod 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 /healthz | ok(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.0 | Node に入れた image。タグが latest でないので Node のものが使われる |
envFrom | api-config の 2 つのキーと api-secret の 1 つのキーを、キーの名前そのままで環境変数にする。Go API はそれを os.Getenv で読む |
readinessProbe | kubelet が 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 の JSON | Ingress のルールに合い、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
- [official] イメージ | Kubernetes
- [official] ConfigMap | Kubernetes
- [official] Secret | Kubernetes
- [official] Podを構成してConfigMapを使用する | Kubernetes
- [official] Liveness Probe、Readiness ProbeおよびStartup Probeを使用する | Kubernetes
- [official] Deployment | Kubernetes
- [official] Ingress | Kubernetes
- Quick Start | kind
- Multi-stage builds | Docker Docs

