PostgreSQL、Redis、Kafka、MinIO、Keycloak、Prometheus、Loki、Grafanaに、バックエンドとワーカー、フロントエンド2つまで全部合わせて、1台のローカルPCで16個のPodが動いていて、月のインフラ費用は0円だ。
なぜAWSを使わなかったのか
個人プロジェクトで毎月お金が出ていくのが嫌で、今作っているサイドプロジェクトに必要なインフラをAWSに乗せると仮定して一度計算してみた。
| サービス | AWSの対応サービス | 予想月額費用 |
|---|---|---|
| PostgreSQL | RDS t3.micro | ~$15 |
| Redis | ElastiCache t3.micro | ~$12 |
| Kafka | MSK(最小2ブローカー) | ~$150+ |
| MinIO | S3 | ~$3 |
| Keycloak | ECS/EC2 | ~$10 |
| Prometheus + Grafana | AMP + CloudWatch | ~$40 |
| 合計 | ~$230/月 |
毎月3万円で、1年で36万円——ノートPC1台分がそのまま消えていく計算だ。
ただ、正直お金だけを見てローカルを選んだわけではない。AWSコンソールで「Create Database」ボタンを一発押せばインスタンスが立ち上がるが、便利な分だけPVCがどう確保されるか、バックアップスナップショットがどこに保存されるか、ヘルスチェックがどの周期で回るかが見えなくなる。たとえばRDSではパラメータグループを1つ変えるのもコンソールで2クリックで済むが、PostgreSQLを自分で立ててみるとpostgresql.confをConfigMapとしてマウントする必要があり、変更後にreloadするかrestartするかを決めなければならず、その過程でコネクションプールがどのように切れるかを目で確認することになる。同じ「パラメータ変更」でも、手に残るものが全然違う。
エンタープライズ規模なら当然AWSが正解だし、可用性99.99%、マルチリージョンDR、24時間サポートなどは個人PCで真似できない。しかし1人でインフラを学びながらプロトタイプを動かす段階なら、ローカルサーバーがこれまで試してきた組み合わせの中で最も効果的で、壊しながら学ぶ学習量が圧倒的に多かった。
PCのスペックと実際の負荷
自分が使っているPCは普通のデスクトップだ。
CPU: 12コア
RAM: 16GB
Disk: 512GB SSD
OS: Ubuntu + k3s
このPCで16個のPodを同時に立ち上げたまま一日中つけっぱなしにしているが、計測した指標はこうだ。
CPU使用率: 5〜11% (kubectl top node 基準)
RAM使用量: 9.5GB / 16GB(約60%)
Disk: 123GB / 512GB(約28%)
余裕がかなりあって、ゲーミングPCでもない普通の開発用デスクトップ1台で十分だった。特殊な機材が必要な構成でもない。
全体アーキテクチャ:16個のPod、1台のPC
合計16個のPodで、インフラ7 + アプリ4 + モニタリング4 + 点検用maintenance 1に分かれる。
- インフラ7個: PostgreSQL、Redis、Kafka、MinIO、Keycloak、Kafka UI、Docker Registry
- アプリ4個: FastAPIバックエンド、Kafka Worker、ユーザー向けフロントエンド、管理者向けフロントエンド
- モニタリング4個: Prometheus、Loki、Grafana、Promtail(DaemonSet)
- メンテナンス1個: maintenance(デプロイ中にメンテナンスページへトラフィックを流すための静的nginx)
なぜdocker-composeではなくk3sなのか
最初の1〜2ヶ月はdocker-composeで始めたが、サービスが6個までは問題なかったのに7〜8個になってから限界が出てきた。一番辛かったのは「Podが死んでも自動で復活しない」という点だった。
| 項目 | docker-compose | k3s |
|---|---|---|
| 自動復旧 |
restart: always(限定的) |
Podクラッシュ → 即再生成 |
| ヘルスチェック | 手動設定 | liveness/readiness probe 内蔵 |
| 宣言的管理 | なし |
kubectl apply一発で全体同期 |
| ストレージ | バインドマウント(パス管理が面倒) | PVC抽象化 |
| クラウド移植性 | なし | マニフェストそのままEKS/GKEへ |
| Ingress + TLS | 別途nginx設定 | Traefik内蔵 + cert-manager |
| モニタリング | 自前で構成 | Prometheus ServiceDiscovery |
たとえばdocker-composeのrestart: alwaysはコンテナプロセスが死んだときだけ再起動するが、本当に危険なのはコンテナが生きているのに応答できない場合(deadlock、OOM直前、DBコネクション枯渇など)だ。k3sのlivenessProbeはHTTPレスポンスが返ってこなければコンテナ自体を強制終了して新しく立ち上げるので、同じ「自動復旧」でも動作そのものが違う。
k3s自体も軽くてメモリ512MBで動き、インストールもcurl -sfL https://get.k3s.io | sh -の1行で終わる。それでいてPVC、ConfigMap、Secret、Ingress、DaemonSetといったk8sのコアリソースが全部使える。
一番決定的なのは移植性だ。ここで書いたマニフェストは後でEKS、GKE、AKSにほぼそのまま持っていける。 docker-composeで積み上げていたら、クラウドへ移行する時点で最初から書き直しになっていたはずだ。たとえるなら、docker-composeは「使い捨て紙コップ」でk8sマニフェストは「繰り返し使えるタンブラー」——最初に作るコストは似たようなものだが、再利用性が全く違う。
実際にどうやって立てたか
アプリサービスのコンテナ化
バックエンドとワーカー、フロントエンド2つを全部Dockerイメージとしてビルドして、ローカルレジストリ(localhost:30050)にpushし、k3sがそこからpullしてくる構成だ。外部のDocker HubやECRなしで、PC内で閉じたループが回っている。
# ビルド + デプロイ一発
bash infra/build-and-deploy.sh all
# 個別デプロイ
bash infra/build-and-deploy.sh api # バックエンドのみ
bash infra/build-and-deploy.sh user # ユーザーアプリのみ
バックエンドとワーカーは同じイメージを使い、ワーカー側でcommandだけオーバーライドしてエントリーポイントを変えている。
# worker.yaml
containers:
- name: worker
image: localhost:30050/app-api:latest # 同じイメージ
command: ["python", "-m", "app.workers.receipt_worker"] # エントリーポイントだけ変える
こうした理由は2つある。まずビルドすべきイメージ数が半分になり、毎回2つビルドする時間を節約できる。そしてバックエンドのコードとワーカーのコードが同じドメインオブジェクトを共有するため、別イメージに分離すると両方の依存関係を別々に管理しなければならず、かえって面倒になる。1人開発ではイメージ1セット・エントリーポイント2つが一番スッキリした。
フロントエンドはViteでビルドした成果物をnginxがサーブするマルチステージ構成だ。ビルドステージでNode.jsイメージを使ってReactをコンパイルし、成果物(dist/)だけをnginxイメージにコピーすると、最終イメージが30MB以内に収まる。
# ビルドステージ(〜1GB、最終イメージには入らない)
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --ignore-scripts
COPY . .
RUN npm run build
# サービングステージ(〜30MB)
FROM nginx:alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /app/dist /usr/share/nginx/html
マルチステージなしでNodeイメージから直接サーブしていたら、イメージサイズが1GBを超えていた。30MBと1GBの差はpush/pullの時間に換算すると数十秒の差になる。
Kafka:ZooKeeperなしのKRaftモード
Kafka 3.xからZooKeeperなしで単独で動くKRaftモードが正式に入ったが、これを使うことでインフラの複雑さが半分になった。
従来のKafkaはZooKeeperという別の分散システムがメタデータを管理する必要があり、開発環境でもZooKeeper 1台 + Kafka 1台、最低2つのPodが必要だった。KRaftはKafkaプロセスがcontrollerの役割も直接担うので、Pod 1個で完結する。
env:
- name: KAFKA_PROCESS_ROLES
value: "broker,controller" # 1台で両方処理
- name: KAFKA_LISTENERS
value: "INTERNAL://:9092,EXTERNAL://:29092,CONTROLLER://:9093"
- name: KAFKA_ADVERTISED_LISTENERS
value: "INTERNAL://kafka-0.kafka:9092,EXTERNAL://localhost:30092"
ここでのポイントはデュアルリスナーだ。Kafkaはクライアントが接続すべきアドレスを自分から通知する(「俺に繋ぐならこのアドレスに来い」)advertised listenerという仕組みがある。問題は、同じKafkaに2種類のクライアントが接続するという点だ。k3s内部の別のPod(例:APIコンテナ)はk3s内部DNS名のkafka-0.kafka:9092で接続しなければならない。一方、ホストのデバッグツール(例:PC上で直接起動したKafkaクライアント)はNodePortのlocalhost:30092で接続しなければならない。
この2つを1つのアドレスに統合する方法がないため、listenerを2つ(INTERNAL、EXTERNAL)作り、それぞれ異なるアドレスでアドバタイズする。k3s内部のPodにはINTERNALアドレス(kafka-0.kafka:9092)を、ホストツールにはEXTERNALアドレス(localhost:30092)をアナウンスするという仕組みだ。
最初はこの構造を知らずにlistenerを1つだけ設定し、localhost:30092でアドバタイズしていた。ホストからは繋がるのにk3s内部のPodからは接続できなくて長時間悩んだが、Pod内のlocalhostはそのPod自身を指すため、Kafkaブローカーがそこにいるはずがないと気付いた時、頭を一発殴られたような感覚だった。
用語整理
- KRaft: Kafka Raft Metadataモード。Kafka 3.xから追加された、ZooKeeperなしにKafkaプロセスが直接メタデータを管理するモード。
- broker: 実際にメッセージを保存・転送するKafkaノード。
- controller: どのbrokerがどのパーティションのリーダーかというクラスタメタデータを管理する役割。従来はZooKeeperが担い、KRaftではbrokerが兼任する。
- listener: Kafkaが外部接続を受け付けるネットワークエントリーポイント(IP+port)。1つのKafkaに複数設定できる。
- advertised listener: Kafkaがクライアントに「次から俺に繋ぐときはこのアドレスに来い」と伝えるアドレス。クライアントが最初に接続したアドレスと異なる場合があるため、デュアルリスナーのようなパターンが必要になる。
- NodePort: k3s/k8sのサービス公開方式の1つ。クラスタのノード(ここでは自分のPC)のIPの30000〜32767番ポートを通じてクラスタ内部サービスにアクセスできるようにするオプション。
PostgreSQL:Pod1つにDB2つ
KeycloakがDB専用を要求したため、別途PostgreSQL Podを立てようとしたが、RAMがもったいなくバックアップ対象が増えるのも負担だった。結局PostgreSQL 1つにデータベースを2つ作る方向にした。
ConfigMapで初期化SQLを入れておくと、PostgreSQLコンテナが初回起動時に一度だけ実行する。
apiVersion: v1
kind: ConfigMap
metadata:
name: postgres-init
data:
init-keycloak-db.sql: |
SELECT 'CREATE DATABASE keycloak'
WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'keycloak')\gexec
\gexecはpsqlのメタコマンドで、前のSELECTの結果(ここではCREATE DATABASE keycloakという文字列)を再度SQLとして実行してくれる。「keycloak DBがなければ作り、あれば何もしない」というidempotentな動作になるため、Podが再起動しても同じスクリプトが安全に再実行できる。
結果的にPostgreSQL Pod 1つの中に、メインドメインデータ用のDBとKeycloak認証情報用のDBの2つが共存している。本番環境ならblast radiusを減らすため分離するのが正しいが、1人開発環境ではこれが一番軽い構成だった。
用語整理
- ConfigMap: k3s/k8sで平文の設定値(スクリプト、環境変数、設定ファイルなど)を保存し、Podにファイルや環境変数として注入できるリソース。パスワードなどの機密情報はSecretを別途使う。
- idempotent: 「何度実行しても結果が1回実行したものと同じ」という性質。Podが再起動して同じ初期化スクリプトが再実行されても安全でなければならないため重要。
\gexec: psql(PostgreSQL CLI)のメタコマンドで、直前のSELECTが生成した文字列を再度SQLとして実行するトリック。条件付きDDLを作る際によく使う。- blast radius: 「爆発半径」、あるコンポーネントが壊れたときにその影響が及ぶ範囲。ここではPostgreSQLインスタンス1つが落ちると2つのDBが同時に落ちるという意味で使った。
MinIO:initialDelaySecondsと無限再起動
MinIOを初めて立ち上げた日、PodがCrashLoopBackOff状態に落ち続けた。不思議だったのはコンテナのログが正常だったことで、「Server started」やバケット初期化中などの正常メッセージが流れ続けた後、突然SIGKILLを受けて終了するパターンだった。
原因はlivenessProbeだった。最初に書いたマニフェストはこうだった。
livenessProbe:
httpGet:
path: /minio/health/live
port: 9000
# initialDelaySeconds なし → デフォルト値 0
initialDelaySecondsがなければデフォルト0になり、コンテナが立ち上がった瞬間からk3sがヘルスチェックを叩き始める。MinIOは起動直後の約20秒間、バケットのメタデータを初期化するためHTTPサーバーがまだ応答できない。その間にヘルスチェックが失敗するとk3sは「このPod死んだ」と判断してSIGKILLを送る。新しいPodが立ち上がってまた死んで、また立ち上がる——無限ループが回り続けた。
解決はシンプルで、30秒の猶予を与えるだけだ。
livenessProbe:
httpGet:
path: /minio/health/live
port: 9000
initialDelaySeconds: 30 # コンテナ起動後30秒はヘルスチェックしない
periodSeconds: 20
readinessProbe:
httpGet:
path: /minio/health/ready
port: 9000
initialDelaySeconds: 10
periodSeconds: 10
これを知らずに1時間を無駄にした。以来、ヘルスチェックの設定はマニフェストを書く時に一番最初に書くようになった。起動時間が長いサービス(MinIO、Keycloak、KafkaなどJVM/Go/Erlang系)は特にinitialDelaySecondsを余裕を持って設定する必要がある。
用語整理
- livenessProbe: コンテナが生きているか周期的に確認するヘルスチェック。失敗するとk8sがコンテナを落として再起動する。
- readinessProbe: コンテナがトラフィックを受け取る準備ができているか確認するヘルスチェック。失敗してもコンテナは落とさず、サービスから一時的に外すだけ。
initialDelaySeconds: コンテナ起動後、最初のヘルスチェックまで待機する秒数。デフォルト0なので、起動に時間がかかるサービスは必ずこの値を増やす必要がある。periodSeconds: ヘルスチェックを繰り返す間隔(秒)。- CrashLoopBackOff: コンテナが繰り返し死んで再起動するパターンをk8sが検知したときに付けるステータス。再起動の間隔が徐々に長くなる。
- SIGKILL: プロセスを強制終了するシグナル(番号9)。クリーンアップなしで即座に終了させる。
Traefik + cert-manager:無料HTTPS
k3sはTraefikをデフォルトで内蔵しているため、cert-managerを別途追加するだけでLet's Encryptの証明書を自動で取得できる。Traefikは入ってくるHTTPSリクエストを適切なPodにルーティングする役割を担い、cert-managerはLet's Encryptにドメイン所有者の証明をしてから取得したTLS証明書をKubernetes Secretに保存し、有効期限30日前ごろに自動更新する。この2つのツールが連携すると、人手が入る場面がほぼなくなる。
IngressリソースにCert-managerのアノテーションを1行追加するだけで、2つのツールが自動的に連携する。
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: app-cert
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
ingressClassName: traefik
tls:
- hosts:
- app.example.com
- admin.example.com
secretName: app-tls
これだけでcert-managerが自動的にACME HTTP-01 challengeを実行して証明書を取得し、app-tlsというSecretに保存する。TraefikがそのSecretを読み込んでHTTPSをサーブするので、nginx設定もcertbotのcronジョブも不要だ。
実際のルーティングはTraefikのIngressRoute CRDでより細かく設定した。認証エンドポイント(/api/v1/auth)はBrute Forceを防ぐため5 req/s、一般APIはグローバル100 req/s、それ以外のパスはフロントエンドの静的ファイルへ転送する構成だ。
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
spec:
entryPoints: [websecure]
routes:
# 認証API:厳しいRate Limit
- match: Host(`app.example.com`) && PathPrefix(`/api/v1/auth`)
middlewares: [{name: rate-limit-auth}] # 5 req/s
services: [{name: app-api, port: 19090}]
# 一般API:通常のRate Limit
- match: Host(`app.example.com`) && PathPrefix(`/api/v1`)
middlewares: [{name: rate-limit-global}] # 100 req/s
services: [{name: app-api, port: 19090}]
# フロントエンド
- match: Host(`app.example.com`)
services: [{name: web-user, port: 80}]
tls:
secretName: app-tls
Rate Limitとセキュリティヘッダーをミドルウェア(Middleware CRD)として切り出しておくと、ポリシーの差し替えが楽になる。たとえば一時的に新規登録を止めたいときは、ミドルウェアに新しいルールを1つ追加してkubectl applyを1回叩けば終わりだ。nginx設定ファイルを直接書き換えてreloadしていた頃と比べると、運用負荷が明らかに減った。
用語整理
- Traefik: クラウドネイティブ環境でよく使われるリバースプロキシ兼Ingressコントローラー。k3sにデフォルト内蔵。
- Ingress: k8sの標準リソースで「あるドメインのどのパスへのリクエストをどのサービスに転送するか」のルーティングルールを定義する。実際のトラフィック処理はIngressコントローラー(ここではTraefik)が担う。
- CRD(Custom Resource Definition): k8sの標準リソースではないユーザー定義リソースを作れるようにする拡張メカニズム。TraefikのIngressRouteやMiddlewareもCRDだ。
- IngressRoute: Traefikが標準Ingressよりも細かいルーティング(パスごとのミドルウェア、優先順位など)を設定するために作った独自CRD。
- Middleware(CRD): TraefikのもうひとつのCRD。Rate Limit、認証、ヘッダー追加などのインターセプトロジックを事前に定義しておき、ルーティングで差し込んで使えるようにする。
- cert-manager: k8sでTLS証明書の発行と更新を自動化するツール。ACME、Vault、独自CAなど複数の発行者をサポート。
- ACME HTTP-01 challenge: Let's Encryptなどのアクメ発行機関がドメイン所有者であることを証明するよう求める手続きの1つ。特定のパスにトークンファイルを配置し、発行機関が直接HTTPでそのファイルを取得できるか確認する。
- Cluster Issuer: cert-managerが証明書をどこから発行するか(Let's Encrypt本番/ステージング、独自CAなど)を定義するクラスタレベルのリソース。
- Rate Limit: 単位時間あたりのリクエスト数を制限するポリシー。Brute forceやトラフィック急増対策に使う。
モニタリング:Prometheus + Loki + Grafana
ローカルインフラを最初に動かしたとき、一番怖かったのは「何かが壊れても気付けない」ということだった。AWSならCloudWatchが勝手にログを集めてアラームを上げてくれるが、ローカルにはそれがない。そのため、モニタリングスタックも同じk3sの中に一緒に立てた。
ログパイプライン:
Pod stdout → Promtail(DaemonSet) → Loki → Grafana
メトリクスパイプライン:
アプリ /metrics → Prometheus → Grafana
PromtailをDaemonSetにしているのは、k3sがマルチノードに拡張されたとき各ノードのコンテナログを漏れなく収集するためだ。今はノードが1つなので意味はないが、後でノードを追加してもマニフェストをそのまま使い続けられる。
バックエンドでは全ログをJSON形式でstdoutに吐いている(ファイルには書かない——コンテナ環境ではstdoutが標準だ)。1行はこんな形だ。
{"timestamp":"2026-04-11T12:34:56Z","level":"INFO","trace_id":"abc123","method":"POST","path":"/api/v1/receipts","status":200,"duration_ms":124}
Promtailがこれを拾ってLokiに流すと、GrafanaでLogQLを使って検索できる。{app="app-api"} |= "ERROR"でエラーだけ絞り込んだり、trace_id="abc123"で特定のリクエストを最初から最後まで追ったりできるため、運用中のSaaSとほぼ同等のデバッグ環境がローカルにそのままある。
用語整理
- Prometheus: 時系列メトリクスの収集・保存に特化したモニタリングシステム。アプリが公開した
/metricsエンドポイントを定期的にスクレイピングする。- Loki: Grafana Labsが作ったログストア。Prometheusと似たラベルモデルを使い、LogQLという独自クエリ言語で検索する。
- Grafana: Prometheus、Lokiなどのデータソースを1画面で可視化し、アラームを設定できるダッシュボードツール。
- Promtail: ノード単位でコンテナのstdoutログをスクレイピングしてLokiに送り込むエージェント。
- DaemonSet: クラスタの各ノードにちょうど1つずつPodを立てるk8sのデプロイ方式。ノードのログ収集などに適している。
- LogQL: Loki専用クエリ言語。
{label="value"} |= "キーワード"のような形でラベルマッチと本文検索を組み合わせる。- Trace ID: 1つのリクエストが複数サービスを経由して生成するログをまとめる識別子。分散デバッグの基本ツール。
NodePortポリシー
ホストからデバッグするときの利便性のために、主要サービス全てにNodePortを開けた。
インフラ:
PostgreSQL → 30543 Kafka → 30092 MinIO API → 30900
Redis → 30637 Kafka UI → 30880 MinIO Web → 30901
Keycloak → 30808 Registry → 30050
モニタリング:
Prometheus → 30090 Grafana → 30030 Loki → 30031
完全に規則的ではないが、最初は「30000 + 元のポート番号の下3桁」というルールを作ろうとしたものの、Grafana(3000)、Loki(3100)、Registry(5000)のようにポートが短いサービスでは衝突が起きることもあり(例:30030と30031を同時に作れないなど)、結局「全部30xxx帯で覚えやすい番号」程度の統一感に落ち着いた。
こうしてNodePortを開けておくと、ホストから直接デバッグできる。
# ホストから直接psql接続(k3sを経由しない)
psql -h localhost -p 30543 -U app
# ホストから直接redis-cli
redis-cli -h localhost -p 30637
# ブラウザでGrafanaを開く
open http://localhost:30030
同じホスト内でデバッグするときkubectl execを使わずに直接接続できるのでサイクルが速い。クラスタ内部の通信は普通通りpostgres-db:5432のようなサービスDNSで繋がるので、2つのルートが共存している。
用語整理
- NodePort: クラスタ外部からノードIPの30000〜32767番ポートで直接サービスにアクセスできるようにするk8sのサービスタイプ。
- ClusterIP: クラスタ内部通信用の仮想IPだけを付与される基本のサービスタイプ。外部からは直接アクセスできない。
- サービスDNS: k8sの中で同じネームスペースのサービスは
サービス名:ポートのような短い名前で自動的にDNS解決される(例:postgres-db:5432)。kubectl exec: Pod内に入ってコマンドを実行するデバッグコマンド。コンテナ内から直接psql/redis-cliなどを起動するときに使う。
失敗談
ディスク94%で全サービスダウン(318GBの幽霊ファイル)
平凡な火曜日の午後、突然全てのPodがEvicted状態に落ち始めた。kubectl describe podを見ると理由が書いてあった。
Reason: DiskPressure
Message: The node was low on resource: ephemeral-storage.
dfを叩くとディスク94%だったが、昨日まで30%だったのがおかしい。duも一緒に見てみると、結果がさらに不思議だった。
$ df -h /
Filesystem Size Used Avail Use% Mounted on
/dev/nvme0n1p2 512G 481G 31G 94% /
$ sudo du -sh /
105G /
dfは481GB使ったと言っているのに、duで合算すると105GBしかない。376GBがどこかに消えた計算だ。初めて見るとOSがおかしくなったかと思う。
原因は削除されたファイルをプロセスが掴み続けている状態だった。Linuxではファイルが削除(unlink)されても、そのファイルを開いているプロセスがあればinodeとデータブロックはすぐに回収されず、プロセスがfdを閉じた瞬間に初めて解放される。duはディレクトリツリーを辿って合算するため、こういう「幽霊ファイル」は見えない。一方dfはファイルシステムのスーパーブロックから直接使用量を読むため、幽霊ファイルも全部カウントする。
犯人を見つけるコマンドはこれだ。
lsof | grep deleted | awk '{print $1, $2, $7}' | sort -k3 -rn | head -5
結果:
python 3627740 159365892532 (deleted) ← 159GB
python 3626853 159084635880 (deleted) ← 159GB
バックグラウンドで数日前に立ち上げて放置していたpytestプロセス2つが、それぞれ/tmpの何かにログファイルを書き続けていた。おそらくrmでそのファイルを削除したが、rmはディレクトリエントリを削除するだけで開いているfdには触れない。そのためpytestはすでに削除されたファイルにログを書き続け、その量が159GBにまで膨れ上がっていたのだ。
kill 3627740 3626853
この1行で318GBが即座に回収された(プロセスが死んでfdが閉じられ、その時点でinodeとデータブロックが本当に回収される)。残りの58GBは他の細かいdeleted fileを整理しながら一緒に解放された。dfを再度叩くと使用量が28%に戻っていた。
この経験をしてからディスクモニタリングにアラームを設定し、dfとduの差が10GBを超えたら即座にlsof | grep deletedを実行するスクリプトを作った。CloudWatchならアラームが代わりに検知してくれていただろうが、ローカルではこういったことを1つずつ自分の手で作っていく過程自体が学習だった。
用語整理
- DiskPressure: ノードのディスクが閾値を下回ったときにkubeletがノードに自動で付けるステータスフラグ。新しいPodのスケジューリングがブロックされ、既存のPodも優先度の低いものからEvictedされる。
- Evicted: k3s/k8sがリソース不足(メモリ、ディスクなど)でPodを強制終了させたという表示。実際のコンテナプロセスは死んでいるが、Podオブジェクトだけが残る。
- ephemeral-storage: コンテナの一時ディレクトリ(
/tmpなど)やログが書き込むノードのディスク領域を指すk8sのリソース種別。dfvsdu:dfはファイルシステムのスーパーブロックから直接使用量を読み、duはディレクトリツリーを辿って合算する。「削除されたがプロセスがfdを握っているファイル」はdfには引っかかりduには引っかからない。- fd(file descriptor): プロセスがファイルを開くと受け取る整数のハンドル。プロセスが明示的に閉じるか終了するまで、OSはそのファイルのinodeを維持する。
- inode: ファイルシステムでファイル1つ1つのメタデータ(所有者、権限、データブロックの位置など)を格納する構造体。ファイル名はinodeを指すディレクトリエントリに過ぎない。
unlink: ファイルのディレクトリエントリを削除するシステムコール。rmコマンドが内部的に呼び出す。実際のデータブロックは最後のfdが閉じられたときに初めて回収される。lsof: list open files。システムでどのプロセスがどのファイル/ソケットを開いているかを表示するコマンド。
Kafkaデュアルリスナーの失敗談
上でKafkaの設定を説明する際に少し触れたが、もう少し詳しく書く価値がある。
最初のKafkaマニフェストはこうだった。
- name: KAFKA_LISTENERS
value: "PLAINTEXT://:9092"
- name: KAFKA_ADVERTISED_LISTENERS
value: "PLAINTEXT://localhost:30092"
シンプルで、ホストからlocalhost:30092で繋ぐとうまく繋がった。しかしAPI PodからKafkaに接続しようとすると、こんなエラーが出た。
kafka.errors.NoBrokersAvailable: NoBrokersAvailable
最初はネットワークポリシーの問題かと思い、kubectl execでPod内に入ってnc -zv kafka 9092を試みるとうまく繋がった。TCPレベルは正常なのに、なぜKafkaクライアントは繋がらないのかわからなかった。
答えはadvertised listenerの動作にあった。Kafkaクライアントはまず任意のブローカーにTCPで接続してメタデータを受け取る。その際ブローカーが「俺の本当のアドレスはこれだ、次からはそっちで通信しろ」と伝えると、クライアントはそのアドレスに再接続する。この「本当のアドレス」がadvertised listenerだ。自分がlocalhost:30092でアドバタイズしていたため、Pod内のKafkaクライアントはlocalhost:30092に再接続しようとする。Pod内のlocalhostはそのPod自身を指すのでKafkaブローカーはそこにいない——だからNoBrokersAvailableが出ていたのだ。
解決はlistenerを2つに分けることだった。
- name: KAFKA_LISTENERS
value: "INTERNAL://:9092,EXTERNAL://:29092,CONTROLLER://:9093"
- name: KAFKA_ADVERTISED_LISTENERS
value: "INTERNAL://kafka-0.kafka:9092,EXTERNAL://localhost:30092"
- name: KAFKA_INTER_BROKER_LISTENER_NAME
value: "INTERNAL"
これでKafkaは2つのアドレスを持つことになった。k3s内部のPodにはINTERNALアドレス(kafka-0.kafka:9092)で来るよう伝え、ホストにはEXTERNALアドレス(localhost:30092)でアドバタイズする。同じブローカーが2つの顔を持つ仕組みだ。
この失敗を経験して「ネットワークが繋がる」と「プロトコルが動作する」は別物だと体で学んだ。ncでポートが開いていても、その上で動くアプリケーションプロトコルが独自のルーティング情報を持っていれば、それがまた別の変数になる。以来、分散システムのデバッグをするときは常に「アドバタイズしているアドレスは何か」を最初に確認するようになった。
DiskPressure Taintが解除されない
ディスクの整理を終えた直後、dfでは28%と出ているのにPodがずっとPending状態だった。kubectl describe nodeを見るとこんな記述が残っていた。
Taints: node.kubernetes.io/disk-pressure:NoSchedule
まだDiskPressure状態のままだった。kubeletがディスクの状態を解除していなかったのだ。kubeletはディスク状態をリアルタイムに監視するのではなく、一定周期(デフォルト10秒)ごとに確認する。その周期内に状態が正常に戻っても、内部キャッシュが更新されるまで時間がかかる場合や、何らかの理由でスタックすることがある。
sudo systemctl restart k3s
これが一番確実だった。k3sが再起動するとkubeletの内部状態がリセットされ、taintが消えてPodが正常にスケジューリングされた。
もっとスマートな方法(kubectl taint nodes ... node.kubernetes.io/disk-pressure-)もあるにはあったが、kubeletが次のヘルスチェックで再びtaintを付け直す場合があるため、結局再起動が一番早い道だった。以来、ディスク危機の際は「整理しただけで安心せず、kubeletの状態も一緒に確認する」をチェックリストに加えた。
用語整理
- kubelet: 各ノードで動くk8sエージェント。Podを立ち上げ、ヘルスチェックを実行し、ノードの状態(ディスク/メモリなど)をコントロールプレーンに報告する役割を担う。
- Taint: ノードに付ける拒否のマーク。特定の条件を満たすPodだけがそのノードにスケジューリングされるよう制限できる。
disk-pressureのようなシステムが自動で付けるtaintと、運用者が手動で付けるtaintがある。- Toleration: 逆にPod側で「このtaintは無視して入ります」と表明する設定。
NoSchedule: Taintのエフェクトの1つ。該当taintをtolerationで受け入れていないPodは、そのノードに新たにスケジューリングされない(すでに動いているPodは影響を受けない)。
コスト比較
| 項目 | AWS | ローカルサーバー |
|---|---|---|
| 月のインフラ費用 | ~$230 | $0(電気代除く) |
| 初期投資 | $0 | PC購入費(すでに持っていれば$0) |
| 可用性 | 99.9%+ | 電源を切れば終わり |
| スケーリング | 自動 | 手動(RAM追加) |
| バックアップ | 自動(S3、RDSスナップショット) | 手動 |
| インフラ理解度 | 抽象化の裏に隠れている | 直接触って壊しながら学ぶ |
可用性だけ見ればAWSが圧倒的だが、個人プロジェクトで99.9%の可用性が必要なケースはほとんどない。その分のお金で、より多くの失敗経験を買える計算だ。
学んだこと
-
個人プロジェクトにおいてローカルサーバーは「コスト節約 + インフラ学習」の組み合わせとして最も効率的だということ。 AWSコンソールでボタンを押すことと、マニフェストを手で書くことでは手に残るものが全然違う。ローカルでPVCが一度飛んで初めて、バックアップがなぜ必要かを身に染みて感じることができる。
-
k3sのマニフェストは後でEKSにそのまま持っていけるということ。 docker-composeから出発していたら、クラウドへの移行時点で最初から書き直しになっていたはずだ。k8sマニフェストは一度書けばEKS、GKE、AKSどこへでも持っていけるので、最初にかけた時間が後の時間を節約する。
-
ローカルインフラの本当の価値は失敗経験にあるということ。 ディスクが満杯でサービス全体がダウン、Kafkaのリスナー設定のミス、MinIOのヘルスチェック無限ループといった経験は、マネージドサービスが隠しているためAWSでは遭遇しない。後に「ディスクが満杯になるとk8sはどう反応しますか?」と聞かれたとき、本から暗記した答えではなく、直接経験した話として答えられるようになる。
-
モニタリングはローカルでもクラウドでも同様に必要だということ。 CloudWatchがなければPrometheus + Loki + Grafanaを手で立てなければならない。面倒ではあるが、一度自分の手で構築してみると、マネージドサービスが裏でどんな作業を代わりにやってくれていたかが見えてくる。その視点が生まれると、後でCloudWatchを使うときもより適切に使えるようになる。
-
エンタープライズでは当然AWSが正解だということ。 可用性、DR、マルチリージョン、コンプライアンスはローカルでは解決できない。しかしそのAWSをきちんと使いこなすにはインフラの基本原理を理解している必要があり、その原理を最も速く身につける方法が、結局ローカルで自ら構築してみることだった。

