EKSマルチテナント化で1年地獄を見た|RBAC・NetworkPolicy・リソース隔離の落とし穴
EKS本番でマルチテナント化を導入して痛感した失敗。Namespace分離だけでは不十分、RBACの抜け穴、リソース競合の現実。実装した隔離戦略と運用のコツを、失敗から学んだことをベースに語ります。
EKS本番でマルチテナント化を選んだ理由
去年の春、うちのチームが直面した課題は「複数プロダクトチームが同じEKSクラスタを使ってるけど、リソース競合と権限管理が地獄」ということだった。
当時、3つのプロダクトチームが1つのクラスタ上で走ってて、ある日チームAのバグで大量のPodが生成されてしまい、チームBのサービスが巻き込まれて本番止まったんですよ。これを機に「マルチテナント化しよう」という話になった。
正直なところ、当初は「Namespaceで分けりゃいいでしょ」くらいの甘い考えだった。が、実装を始めてすぐに「え、これだけで本当に隔離できるの?」という不安に直面した。
最初の失敗:Namespace分離だけでは足りない
まず最初にやったのが、Namespaceで単純に切り分けること。各プロダクトチームごとにNamespaceを作って、それぞれのDeploymentを配置した。
apiVersion: v1
kind: Namespace
metadata:
name: team-a
---
apiVersion: v1
kind: Namespace
metadata:
name: team-b
それぞれのチームに対してRBACで権限を制限した。
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
namespace: team-a
name: team-a-developer
rules:
- apiGroups: [""]
resources: ["pods", "services", "deployments"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
namespace: team-a
name: team-a-developer-binding
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: team-a-developer
subjects:
- kind: User
name: team-a-user
apiGroup: rbac.authorization.k8s.io
これで「よし、これで隔離できた」と思ってた。実際は全然だったんですよね。
問題1:リソースクォータがないと、あるチームが全リソースを独占する
チームAの開発者が無限ループのバグでPodを大量生成してしまった。Namespaceは分かれてるけど、クラスタ全体のノードはsharedだから、チームAのPodでメモリが埋まってしまう。結果、チームBのPodが起動できなくなるという最悪の自体に。これはマジで焦った。
apiVersion: v1
kind: ResourceQuota
metadata:
name: team-a-quota
namespace: team-a
spec:
hard:
requests.cpu: "10"
requests.memory: "50Gi"
limits.cpu: "20"
limits.memory: "100Gi"
pods: "100"
これを追加して、ようやく「チームAが暴走しても他に影響しない」という最低限の隔離ができた。地味に重要なやつだ。
問題2:NetworkPolicyがないと、あるチームのPodから他チームのPodに勝手にアクセスできる
個人的には、RBACだけで隔離できると思ってたのが間違いだった。RBACはk8s APIへのアクセス権を制御するだけで、Podトラフィックは全然制御しないんですよね。
NS内に.team-b という内部サービスがあって、本来はチームB内だけからアクセスされるべき。でもNSが違うだけで、チームAのPodから完全にアクセス可能な状態だった。これはセキュリティリスクとしては見落とせない。
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: team-b-deny-ingress
namespace: team-b
spec:
podSelector: {}
policyTypes:
- Ingress
ingress:
- from:
- podSelector:
matchLabels:
namespace: team-b
これを入れて、デフォルトDeny + ホワイトリスト方式に切り替えた。正直、最初からやっておくべきだった。
ノード隔離の地獄
数ヶ月後、別の問題が浮上した。チームAとチームBが同じノード上で走ってる状態で、「あるPodのメモリリークが他のテナントのアプリケーション性能を落としてる」という状況が頻繁に起きるようになった。
これを防ぐにはノードも分けるしかないって判断になった。
apiVersion: v1
kind: Node
metadata:
name: node-team-a-1
labels:
tenant: team-a
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: app-a
namespace: team-a
spec:
template:
spec:
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: tenant
operator: In
values:
- team-a
tolerations:
- key: tenant
operator: Equal
value: team-a
effect: NoSchedule
ただノードを完全に分けると、今度はコスト最適化の話が出てくるわけだ。スパイク時のスケーリングはどうするのか。Karpenterを使って動的に追加するとして、テナント間でのノード利用率が落ちるんじゃないか。
実際、運用3ヶ月で「ノード隔離のせいで月80万円コストが増えた」という告発を受けた。これはキツかった。
「完全隔離」と「コスト効率」のバランスを取った構成
悩んだ末に、うちが辿り着いた構成は「段階的隔離」だった。全テナントを同じ基準で隔離するんじゃなくて、要件に応じて分ける、という発想ですね。
- クリティカルなテナント(SLA厳しい): ノード完全専有
- 通常テナント: Namespace + ResourceQuota + NetworkPolicy
- 開発・ステージング: 複数テナント混在でもOK
apiVersion: v1
kind: Namespace
metadata:
name: team-a-prod
labels:
tenant: team-a
environment: production
isolation-level: dedicated-node
---
apiVersion: v1
kind: Namespace
metadata:
name: team-b-prod
labels:
tenant: team-b
environment: production
isolation-level: shared-node
このメタデータを基に、アドミッションコントローラーで自動的にAffinityを注入するようにした。手作業でやると絶対にミスが出るから、ここは仕組みで対応する必要があるんだ。
apiVersion: admissionregistration.k8s.io/v1
kind: MutatingWebhookConfiguration
metadata:
name: tenant-affinity-injector
webhooks:
- name: tenant-affinity.example.com
clientConfig:
service:
name: tenant-webhook
namespace: kube-system
path: "/inject"
caBundle: LS0tLS1CRUdJTi4uLlBLQ1NDRXRpZmljYXRlLS0tLS0K
rules:
- operations: ["CREATE"]
apiGroups: ["apps"]
apiVersions: ["v1"]
resources: ["deployments"]
admissionReviewVersions: ["v1"]
sideEffects: None
RBAC設計の落とし穴
RBACも単純じゃなかった。当初は「各テナントに1ユーザー」という粗っぽい設計をしてたんだけど、実務では「開発者」「デプロイ責任者」「閲覧者」みたいに役割が分かれるじゃないですか。
その上、GitOpsでCDパイプラインを回すときは、CI/CDツール用の専用サービスアカウントも必要。こういう細かい要件が次々と出てくるんですよね。
apiVersion: v1
kind: ServiceAccount
metadata:
name: team-a-ci-cd
namespace: team-a
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: team-a-deployer
namespace: team-a
rules:
- apiGroups: ["apps"]
resources: ["deployments", "statefulsets"]
verbs: ["get", "list", "watch", "update", "patch"]
- apiGroups: [""]
resources: ["secrets"]
verbs: ["get", "list"]
- apiGroups: ["batch"]
resources: ["jobs"]
verbs: ["create", "get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: team-a-deployer-binding
namespace: team-a
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: team-a-deployer
subjects:
- kind: ServiceAccount
name: team-a-ci-cd
namespace: team-a
半年ほど運用してると、ServiceAccountを紛失したり、権限の粒度がおかしくなったりしてた。人間が手作業で管理する限界を感じたんですよ。結局、Karpenterのノードプロビジョニングと同じ感覚で「権限のIaC化」をやることにした。
# ArgoCD経由で自動デプロイ
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: team-a-rbac
namespace: argocd
spec:
project: platform
source:
repoURL: https://github.com/our-org/k8s-config
path: rbac/team-a
directory:
recurse: true
destination:
server: https://kubernetes.default.svc
namespace: team-a
syncPolicy:
automated:
prune: true
selfHeal: true
これ以来、権限の変更は全部GitOpsで管理される。レビューが必須になるし、何が変わったかも完全に追跡できるようになった。
マルチテナント構成図
実装してから現在(2026年7月)までのアーキテクチャはこんな感じですね。
graph TB
subgraph "VPC"
subgraph "AZ-a"
Node1["Node<br/>tenant: team-a"]
Node2["Node<br/>tenant: team-b"]
end
subgraph "AZ-b"
Node3["Node<br/>tenant: team-a"]
Node4["Node<br/>shared<br/>isolation: develop"]
end
end
subgraph "EKS Control Plane"
API["API Server"]
Admit["Admission Controller<br/>tenant-affinity-injector"]
end
subgraph "team-a-prod Namespace"
Deploy_A["Deployment"]
Pod_A["Pod"]
SVC_A["Service"]
NP_A["NetworkPolicy<br/>Deny default"]
end
subgraph "team-b-prod Namespace"
Deploy_B["Deployment"]
Pod_B["Pod"]
SVC_B["Service"]
NP_B["NetworkPolicy<br/>Deny default"]
end
subgraph "Monitoring"
Metrics["Prometheus<br/>Tenant Labels"]
Audit["CloudTrail+CloudWatch<br/>RBAC Audit Log"]
end
API -->|enforces affinity| Admit
Admit -->|injects NodeAffinity| Deploy_A
Admit -->|injects NodeAffinity| Deploy_B
Deploy_A --> Pod_A
Deploy_B --> Pod_B
Pod_A -->|controlled by| NP_A
Pod_B -->|controlled by| NP_B
Pod_A --> Node1
Pod_A --> Node3
Pod_B --> Node2
Pod_B --> Node4
Pod_A -->|scrape| Metrics
Pod_B -->|scrape| Metrics
API -->|logs| Audit
正直に思う、あと何をやっておけばよかった話
1. PodSecurityPolicy(今はPodSecurityStandards)の早期導入
セキュリティコンテキストを強制できるポリシーがあれば、テナント間での権限エスカレーションはかなり防げた。1年目は「Namespaceで分かれてるからいいでしょ」と甘く見てたんですよね。これが一番の失敗だと思う。
apiVersion: security.istio.io/v1beta1
kind: PeerAuthentication
metadata:
name: default
namespace: team-a
spec:
mtls:
mode: STRICT
---
apiVersion: security.istio.io/v1beta1
kind: AuthorizationPolicy
metadata:
name: team-a-authz
namespace: team-a
spec:
rules:
- from:
- source:
principals: ["cluster.local/ns/team-a/sa/default"]
to:
- operation:
methods: ["GET", "POST"]
2. リソースプール(Node Pool)の段階的な分離
NS隔離だけでなく、ノードプールも最初からちゃんと設計しておけば、コスト増加のショックは小さかった。Karpenterを最初から入れておくべきだった。そしたら「開発環境は共有」「本番は分離」みたいな細かい制御が最初からできたはずだ。
3. テナント間の監視メトリクス分離
Prometheusのlabel設計がイマイチで、最初は他テナントのメトリクスが見えたり見えなかったりしてた。運用が始まってから「あのチームのデータが見えてた」と言われて、あわてて修正する羽目になった。こういう細かいことが後で大きな負債になるんですよ。
運用の過程で見つかった課題と対策
うちが実装してから気づいたことを、コスト・セキュリティ・運用性の観点でまとめたのがこれです。
| 時期 | 課題 | 対策 | 効果 |
|---|---|---|---|
| Month 0-3 | リソース競合でサービス障害 | ResourceQuota導入 | テナント間の影響ゼロに |
| Month 3-6 | ネットワーク漏洩 | NetworkPolicy導入 | トラフィック監視可能に |
| Month 6-9 | ノード混在でSLA低下 | ノード専有/共有分け | テナントA SLA 99.99%達成 |
| Month 9-12 | RBAC権限が漂流 | IaC化 + ArgoCD | Auditログで全追跡可能に |
まとめ
EKSマルチテナント化は、決して「Namespaceで分ければ終わり」じゃない。 実務では、段階的に以下を組み合わせる必要があります:
- Namespace + ResourceQuota:リソース競合の防止
- NetworkPolicy:ネットワークトラフィック制御
- RBAC:API権限の最小化
- ノードアフィニティ:SLA要件に応じた物理隔離
- Pod Security Standards:セキュリティコンテキスト強制
- IaC + GitOps:権限ドリフトの防止
これら全部を最初から完璧にやる必要はないけど、テナント数が増えるたび、「あの時から設計しておけば」という後悔が増えるんですよね。実際のコストも月80万円かかったけど、その代わりに本番障害が激減した。隔離って見えない部分だけど、やってないときの方が高くつく。そこまで気づくのに1年かかったのが、今の一番の失敗だと思う。
次やるなら、複数クラスタ構成も視野に入れつつ、複雑性とコストのバランスを最初から考える。テナント数が多いなら、完全隔離よりも「サンドボックス型クラスタ」という選択肢もある。EKS Auto Modeあたりなら、その辺りもラクになるかもしれない。