EKSでマルチテナント地獄にハマった話。1年で実装した隔離戦略
複数顧客のワークロードをEKS上で運用してたら、リソース暴走で全テナント巻き添え。3ヶ月の失敗を経て、実装した隔離設計パターンを全公開します。
先日のプロジェクトでマルチテナント地獄にハマった
うちのチームがEKS上で複数の顧客向けワークロードを回すことになったんだけど、最初は「Namespaceを分ければいいじゃん」くらいに考えてた。その甘さが3ヶ月で爆発した。
ある日、テナント Aが出した悪意あるクエリで、テナント Bのノードが完全に干上がった。リソース隔離がない設計だったから、全テナントが巻き添えを食った。その時点で「本気でマルチテナント設計しないと事業停止するな」と気づいたんです。
以来、1年かけて検証・改善し続けたマルチテナント設計パターンを、失敗含めて全部書きます。
マルチテナント設計の3つの柱を整理する
EKSでマルチテナント環境を作るときは、次の3つを同時に考える必要があります。
- リソース隔離(Compute) — CPUやメモリが暴走しても他のテナントに影響させない
- ネットワーク隔離(Network) — テナント間のトラフィックを制御する
- 権限隔離(Identity) — あるテナントが他のテナントのデータにアクセスできない仕組み
この3つが揃って初めて「マルチテナント」と言えるんです。うちが最初に失敗したのは、1番目だけやってて、2番目と3番目をスキップしてたから。正直、焦りました。
リソース隔離戦略:Namespace + ResourceQuota + NetworkPolicy
Namespace と ResourceQuota の実装
最初の層は Namespace です。でも Namespace だけでは単なる論理的な分割でしかない。そこに ResourceQuota をかぶせることで、テナント単位のリソース上限を設定します。
# tenant-a-namespace.yaml
apiVersion: v1
kind: Namespace
metadata:
name: tenant-a
labels:
tenant: tenant-a
---
apiVersion: v1
kind: ResourceQuota
metadata:
name: tenant-a-quota
namespace: tenant-a
spec:
hard:
requests.cpu: "10"
requests.memory: "20Gi"
limits.cpu: "20"
limits.memory: "40Gi"
pods: "100"
persistentvolumeclaims: "10"
---
apiVersion: v1
kind: LimitRange
metadata:
name: tenant-a-limits
namespace: tenant-a
spec:
limits:
- max:
cpu: "2"
memory: "4Gi"
min:
cpu: "100m"
memory: "128Mi"
type: Container
- max:
cpu: "4"
memory: "8Gi"
min:
cpu: "100m"
memory: "256Mi"
type: Pod
この設定で、Tenant A 全体で CPU 10〜20、メモリ 20〜40Gi を上限にします。個別 Pod レベルでも 2CPU・4Gi 以内に制限する。重要なのは、このクォータに達するとリソースリクエストが拒否される点です。つまり、テナント A の誰かが暴走ワークロードをデプロイしても、クォータを超えたら Pod が生成されない。他のテナントを巻き込まない仕組みってわけです。
実装してみてわかったのは、requests と limits の設定が意外と難しいということ。requests は「このワークロードに最低限必要なリソース」で、これがスケジューリングの基準になります。limits は「これ以上は使わせない」という絶対値。
うちが最初失敗したのは、requests と limits を同じ値で設定してた。これだと Pod が瞬間的なスパイクに対応できず、OOM kill されやすくなるんです。今は requests を 70〜80%、limits を 100% くらいで設定してます。やっぱり余白を持たせることが大事なんだなと痛感しました。
NetworkPolicy で通信制御
リソース隔離だけでは不十分。ネットワークレベルでテナント間の通信を制御する必要があります。
# tenant-a-network-policy.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: tenant-a-deny-all
namespace: tenant-a
spec:
podSelector: {}
policyTypes:
- Ingress
- Egress
# Ingress: 同じ Namespace 内のトラフィックのみ許可
ingress:
- from:
- podSelector: {}
# Egress: DNS(kube-dns)と外部への最小限のトラフィックのみ
egress:
- to:
- namespaceSelector:
matchLabels:
name: kube-system
ports:
- protocol: UDP
port: 53
- to:
- podSelector: {}
- to:
- namespaceSelector: {}
ports:
- protocol: TCP
port: 443
これは「デフォルト deny」の原則です。明示的に許可したものだけ通す。これがないと、テナント A のどのワークロードからでも他のテナントへのアクセスが可能になってしまう。怖い。
実装上の注意点として、EKS で NetworkPolicy を有効にするには、VPC CNI の設定で SecurityGroupForPods を有効化する必要があります。設定しないと NetworkPolicy が無視されちゃうので注意。
# EKS クラスタ作成時に VPC CNI アドオンを有効化
aws eks create-addons \
--cluster-name my-cluster \
--addons name=vpc-cni,version=latest
RBAC 設計:Role と RoleBinding で権限分離
ネクストレイヤーは RBAC です。これは「誰が何をできるか」を定義する部分。
# tenant-a-rbac.yaml
apiVersion: v1
kind: ServiceAccount
metadata:
name: tenant-a-user
namespace: tenant-a
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: tenant-a-role
namespace: tenant-a
rules:
- apiGroups: [""]
resources: ["pods", "pods/logs", "pods/exec"]
verbs: ["get", "list", "watch", "create", "delete"]
- apiGroups: ["apps"]
resources: ["deployments", "statefulsets", "daemonsets"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
- apiGroups: [""]
resources: ["services", "configmaps", "secrets"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
- apiGroups: ["batch"]
resources: ["jobs", "cronjobs"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: tenant-a-rolebinding
namespace: tenant-a
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: tenant-a-role
subjects:
- kind: ServiceAccount
name: tenant-a-user
namespace: tenant-a
この設定で、tenant-a-user サービスアカウントは tenant-a Namespace 内のリソースのみ操作でき、他の Namespace へのアクセスはできません。
実務ではさらに ClusterRole も定義するんですが、そこで注意すべきは「Namespace 越えのリソースアクセス」です。PersistentVolume(クラスタ全体のリソース)へのアクセスは ClusterRole で制御する必要があります。
# クラスタレベルの PV アクセス制御
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: tenant-a-pv-reader
rules:
- apiGroups: [""]
resources: ["persistentvolumes"]
verbs: ["get", "list", "watch"]
- apiGroups: ["storage.k8s.io"]
resources: ["storageclasses"]
verbs: ["get", "list"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: tenant-a-pv-reader-binding
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: tenant-a-pv-reader
subjects:
- kind: ServiceAccount
name: tenant-a-user
namespace: tenant-a
これで「PV の一覧を見られるけど、他のテナントの PVC は削除できない」みたいな制御ができるわけです。
コスト分離戦略:Pod の Label で課金追跡
ここが実務で最も地味だけど重要です。リソース隔離とセキュリティだけ完璧でも、「各テナントにいくら請求するのか」が決まらないと、経営側がマルチテナント運用を許可してくれません。
うちのチームでやってるのは、全 Pod に tenant label を付けて、AWS コスト分析で紐付ける方式です。
# deployment-tenant-a.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: myapp
namespace: tenant-a
spec:
replicas: 3
selector:
matchLabels:
app: myapp
tenant: tenant-a
template:
metadata:
labels:
app: myapp
tenant: tenant-a
cost-center: "tenant-a-001"
spec:
serviceAccountName: tenant-a-user
containers:
- name: app
image: myapp:latest
resources:
requests:
cpu: "500m"
memory: "512Mi"
limits:
cpu: "1"
memory: "1Gi"
このラベルを使って、Karpenter のノード選択とコスト計算を連動させます。
# karpenter-provisioner.yaml
apiVersion: karpenter.sh/v1beta1
kind: NodePool
metadata:
name: tenant-a-pool
spec:
template:
metadata:
labels:
tenant: tenant-a
spec:
requirements:
- key: kubernetes.io/arch
operator: In
values: ["amd64"]
- key: node.kubernetes.io/instance-type
operator: In
values: ["t3.medium", "t3.large"]
- key: karpenter.sh/capacity-type
operator: In
values: ["spot", "on-demand"]
nodeClassRef:
name: default
limits:
cpu: "20"
memory: "50Gi"
disruption:
consolidateAfter: 30s
expireAfter: 720h
budgets:
- nodes: "10%"
これで tenant-a のワークロードだけを専用のノードプール上で動かせます。EC2 インスタンスのコストは、ノードプール単位で AWS Cost Explorer で追跡できるようになるんです。
さらに、ノード Tag を AWS コスト分析フィルターにかけることで、テナント毎のコスト計算が自動化できます。
AWS 構成図:マルチテナント EKS アーキテクチャ
graph TB
subgraph "AWS Region"
subgraph "VPC"
subgraph "AZ-1"
NG1["Node Group<br/>tenant-a"]
NG2["Node Group<br/>tenant-b"]
end
subgraph "AZ-2"
NG3["Node Group<br/>tenant-a"]
NG4["Node Group<br/>tenant-b"]
end
end
subgraph "EKS Control Plane"
KCP["API Server<br/>etcd"]
end
subgraph "Monitoring & Cost"
CW["CloudWatch<br/>Metrics"]
CE["Cost Explorer<br/>by Tenant Label"]
end
end
subgraph "Tenant A Namespace"
RBAC_A["ServiceAccount<br/>RBAC"]
RQ_A["ResourceQuota<br/>10CPU/20Gi"]
NP_A["NetworkPolicy<br/>Deny All"]
POD_A["Pods<br/>tenant=tenant-a"]
end
subgraph "Tenant B Namespace"
RBAC_B["ServiceAccount<br/>RBAC"]
RQ_B["ResourceQuota<br/>10CPU/20Gi"]
NP_B["NetworkPolicy<br/>Deny All"]
POD_B["Pods<br/>tenant=tenant-b"]
end
KCP --> RBAC_A
KCP --> RBAC_B
KCP --> RQ_A
KCP --> RQ_B
KCP --> NP_A
KCP --> NP_B
POD_A --> NG1
POD_A --> NG3
POD_B --> NG2
POD_B --> NG4
NG1 --> CW
NG2 --> CW
NG3 --> CW
NG4 --> CW
CW --> CE
style RBAC_A fill:#f0f0f0
style RBAC_B fill:#f0f0f0
style RQ_A fill:#e8f4f8
style RQ_B fill:#e8f4f8
style NP_A fill:#fff0e8
style NP_B fill:#fff0e8
この図で見える通り、各テナント毎に Namespace、RBAC、ResourceQuota、NetworkPolicy が隔離されてます。同じノードグループを共有しつつも、相互に影響しない仕組みになってるんです。
本番で痛い目を見た失敗パターン
1. クォータ設定が厳しすぎた
最初、CPU 5・メモリ 10Gi で設定してたんです。そしたら「テナント A が突然 Pod デプロイできなくなった」と連絡が来た。調査したら、ログ集約のサイドカーが想定より多くメモリ食ってて、新しいワークロードをデプロイできなくなってた。
結果、テナント側からクレーム。急いでクォータを増やしたんですが、「いつまで増やし続けるんだ」という問題に直面したんです。今は逆に、3ヶ月のメトリクス分析から「このテナントには CPU 10・メモリ 25Gi あれば十分」みたいに根拠ベースで設定してます。データドリブンな方が圧倒的に説得力がある。
2. NetworkPolicy が機能していなかった
NetworkPolicy を設定した直後、テナント A のポッドからテナント B へのアクセスが通ってた。原因は、EKS に VPC CNI アドオンはあったけど、SecurityGroupForPods が有効化されていなかったこと。ちょっと見落としやすいポイントですね。
NetworkPolicy は「設定したら動く」じゃなく、CNI レベルで実装されている必要があります。Calico とか Cilium を別途インストールする方法もありますが、AWS 的には VPC SecurityGroup を活用する方が管理しやすい。
3. RBAC の過度な権限付与
テナント側から「Secrets にアクセスしたい」という要求がありました。セキュリティチームからは「Secrets の閲覧は禁止」という指示。交渉の末、「base64 デコード不可の制限付きで」という条件で Secrets の get/list を許可したんですが、実はそれ意味ないんですよね。base64 は簡単にデコードできるから。
結局、Sealed Secrets や External Secrets を導入して、テナント側は自分たちのシークレットだけ見られるような仕組みに変更しました。やっぱり根本的に設計しないと、後付けセキュリティは全部ザルになる。
4. コスト追跡の自動化なし
最初はスプレッドシートに「テナント A の月額費用:XXX 円」と手書きしてた。1ヶ月は手作業でも、3ヶ月目には明らかに間違ってました。今は Kubecost を導入して、Pod のリソース使用量 → AWS コスト の自動変換をやってます。
# Kubecost インストール
helm repo add kubecost https://kubecost.github.io/cost-analyzer/
helm install kubecost kubecost/cost-analyzer \
--namespace kubecost \
--create-namespace \
--set kubecostModel.warmCache=false
Kubecost を使うと、Grafana ダッシュボード経由で「テナント A の今月のコスト:$1,234」みたいに即座に見られるようになります。もう手計算には戻れない。
2026年時点での最新パターン:Pod Topology Spread と Karpenter の組み合わせ
スケーラビリティの観点から、うちが最近導入したのが Pod Topology Spread です。同じテナントの Pod が複数ノードに分散配置されるようにする。
apiVersion: apps/v1
kind: Deployment
metadata:
name: myapp
namespace: tenant-a
spec:
replicas: 6
selector:
matchLabels:
app: myapp
template:
metadata:
labels:
app: myapp
tenant: tenant-a
spec:
topologySpreadConstraints:
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
app: myapp
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels:
app: myapp
containers:
- name: app
image: myapp:latest
これで、同じ Pod が同じノードに集中しなくなります。さらに Karpenter の consolidation と組み合わせると、ノード効率とテナント間の独立性が両立できるようになった。実装してみて「なんでこんなシンプルな仕組みで動くんだ」と思うくらい効果的です。
次のステップ:Namespace Isolation と Policy Engine
ここまで手作業・YAML でやってる部分が多いんですが、Kyverno みたいな Policy Engine を導入すると、より強力な自動化ができます。
# Kyverno インストール
helm repo add kyverno https://kyverno.github.io/kyverno/
helm install kyverno kyverno/kyverno --namespace kyverno --create-namespace
# 全 Pod に tenant label の付与を強制
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: require-tenant-label
spec:
validationFailureAction: audit
rules:
- name: check-tenant-label
match:
resources:
kinds:
- Pod
validate:
message: "Pod must have tenant label"
pattern:
metadata:
labels:
tenant: "?*"
これで、tenant label がない Pod は デプロイ段階で拒否されるようになります。ポリシーエンジンがあると、属人的なレビューに頼らず、自動で規約を強制できるんです。セキュリティと運用効率の両方が手に入る。
まとめ
EKS マルチテナント設計は、リソース隔離・ネットワーク隔離・権限隔離・コスト追跡の 4 つを同時に考える必要があります。どれか 1 つ欠けると、セキュリティホールか運用負荷の増加のどちらかになるんです。
実装時の優先順位としては、こんな感じです。
- ResourceQuota + LimitRange で compute リソースを上限設定 — 1 テナントの暴走が他に影響しない
- NetworkPolicy で Namespace 間の通信を deny-all から始める — 最小権限の原則を守る
- RBAC で Service Account 単位に権限を制限 — テナント側も信頼できない前提で設計
- Pod Label + Kubecost でテナント毎のコスト自動計算 — 経営判断を支える数字を毎月提供
- Policy Engine(Kyverno)で規約を自動強制 — 属人的なレビューを減らす
セキュリティと経済性のバランスが取れたマルチテナント設計は、最初は複雑に見えるけど、3ヶ月も運用すると「なぜこんなシンプルな仕組みで動いてるんだ」って感じになります。今からマルチテナント始める人は、最初から 4 つ全部やっちゃう方がいい。後付けは地獄です、ホント。