EKSマルチテナント設計で1年地獄を見た|実装した隔離戦略と失敗から学んだこと
複数チームのワークロードを同じEKSクラスタで運用して1年。Namespace分離だけじゃ足りない理由と、RBAC・NetworkPolicy・リソース制限で実装した隔離戦略を、痛い経験とともに共有します。
プロジェクトで1年、マルチテナント地獄にハマった
先日プロジェクトで、複数の事業部のワークロードを同じEKSクラスタで運用することになったんですよ。最初は「Namespaceで分ければ大丈夫」くらいの軽い気持ちで設計してたんですが、3ヶ月経ったあたりから本当にやばくなりました。
あるチームのPodが暴走してメモリリークを起こしたら、他のチームのサービスも一緒に落ちる。誤ったRBAC設定で、開発チームが本番環境を直接削除できてしまう。ネットワーク的に何の隔離もないから、Podから別テナントのServiceに直接アクセスできちゃう。そういう状況が次々と出てきたんですよ。
マルチテナント環境って、単純に「分ける」だけじゃ全然足りないんですよね。僕たちのチームが1年かけて実装した隔離戦略を、実際の痛い経験とともに書きます。
Namespaceだけでは何も解決しない
これが一番ショックだったんですが、Namespaceの分離なんて気休め程度です。正直、最初の実装は本当に甘かった。
# ウチが最初にやってた「甘い」設計
apiVersion: v1
kind: Namespace
metadata:
name: team-a
---
apiVersion: v1
kind: Namespace
metadata:
name: team-b
これだけでマルチテナント?いや、無理ですよ。何も隔離されてません。実際、以下のような問題が次々と起きました。
問題1:リソース枯渇で全テナント巻き添え
team-aが「とりあえず大きなメモリで動かそう」ということで、メモリ無制限でPodをデプロイ。そしたらノードのメモリが枯渇して、他のNamespaceのPodもEvictされ始めたんですよ。team-bのチームから「なぜ落ちた?」って連絡が来て、team-aの暴走が原因だと判明する。こんなん無理です。
問題2:ネットワークに壁がない
デフォルトだと、全Podが全Serviceにアクセスできるんですよ。team-bのPodから、team-aのDBへの接続情報を環境変数で見つけて、直接叩かれました。なんというかセキュリティ的に終わってます。
問題3:権限管理がガバガバ
RBACを何もしていなかったから、team-aのサービスアカウントで、team-bのリソース削除ができちゃいました。これはマジで冷や汗ものです。「あ、もしmaliciousなやつがいたら…」と思うと怖い。
実装した4つの隔離層
ここからが、僕たちが1年かけて実装した対策です。段階的に追加していったんですが、やはり最初にやったほうが楽ですね。
1. リソースクォータとLimits Range
まずはリソースレベルの隔離。Namespaceごとに使えるCPU・メモリを制限しました。
# team-a用のResourceQuota
apiVersion: v1
kind: ResourceQuota
metadata:
name: team-a-quota
namespace: team-a
spec:
hard:
requests.cpu: "20"
requests.memory: "100Gi"
limits.cpu: "30"
limits.memory: "150Gi"
pods: "100"
persistentvolumeclaims: "10"
---
# Podごとのリソース制限
apiVersion: v1
kind: LimitRange
metadata:
name: team-a-limits
namespace: team-a
spec:
limits:
- max:
cpu: "2"
memory: "4Gi"
min:
cpu: "100m"
memory: "128Mi"
default:
cpu: "500m"
memory: "512Mi"
defaultRequest:
cpu: "200m"
memory: "256Mi"
type: Container
これ、最初は「厳しすぎる」って言われました。でも、制限がないと本当に暴走するんですよ。正直、LimitRangeのmax値の設定が一番難しいんですよね。「いや、このサービスは一時的に2CPUまで必要」とかいろいろ言われるので。
うちの場合、初期値は業務要件から逆算して、月次で見直すようにしました。ResourceQuotaが「Namespace全体の総量」で、LimitRangeが「1つのPodあたりの上限」という関係を理解しておくと、設定がグッと楽になります。
2. NetworkPolicyで通信隔離
次はネットワークレベルの隔離。これが本当に大事なんですが、CNIプラグインがNetworkPolicyに対応している必要があります。ちょっと大変ですが、EKS標準のAWS VPC CNIは対応していないので、Calicoを導入しました。
# Calico導入
kubectl apply -f https://raw.githubusercontent.com/projectcalico/calico/v3.28.0/manifests/tigera-operator.yaml
# team-aのデフォルト拒否ポリシー
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: team-a-default-deny
namespace: team-a
spec:
podSelector: {}
policyTypes:
- Ingress
- Egress
---
# team-a内部のPod間通信を許可
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: team-a-allow-internal
namespace: team-a
spec:
podSelector:
matchLabels:
team: a
policyTypes:
- Ingress
- Egress
ingress:
- from:
- namespaceSelector:
matchLabels:
name: team-a
podSelector:
matchLabels:
team: a
egress:
- to:
- namespaceSelector:
matchLabels:
name: team-a
ports:
- protocol: TCP
port: 8080
- to: # DNS許可
- namespaceSelector: {}
podSelector:
matchLabels:
k8s-app: kube-dns
ports:
- protocol: UDP
port: 53
これ、本当に効きます。実装後、意図しない通信が全部ブロックされるようになって、「あ、こんなところでも通信していたのか」ってことがたくさん見えてきました。
ただし、問題も多いんですよ。Calicoを入れると、ノードの負荷が若干上がります。それに、NetworkPolicyって「デフォルト拒否」でテストしないと、設定漏れに気づけないんですよね。うちは3回本番でハマりました。Denyルールだけ追加して、AllowをEgressで見落としてたりして。地味に運用が大変です。
3. RBAC(Role Based Access Control)
これは権限管理の層です。各チームのサービスアカウントには、必要最小限の権限だけを与えます。
# team-aのサービスアカウント
apiVersion: v1
kind: ServiceAccount
metadata:
name: team-a-app
namespace: team-a
---
# team-aが自分のNamespace内のリソースのみ読み取り・削除可能
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: team-a-role
namespace: team-a
rules:
- apiGroups: [""]
resources: ["pods", "services", "configmaps"]
verbs: ["get", "list", "watch"]
- apiGroups: ["apps"]
resources: ["deployments", "statefulsets"]
verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: team-a-rolebinding
namespace: team-a
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: team-a-role
subjects:
- kind: ServiceAccount
name: team-a-app
namespace: team-a
ポイントは、「team-aのサービスアカウントで、他のNamespaceに触らせない」こと。デフォルトではRole/RoleBindingは単一Namespace内にしか効かないので、ClusterRoleBindingは本当に慎重に使います。
実装してみたら、「あ、このチームはDeploymentの削除権が必要だった」みたいなことが出てくるんで、定期的に見直すようにしています。個人的には、「まず拒否から始めて、必要に応じて許可を増やす」というアプローチが一番安全だと思いますね。
4. Pod Security Policy / Pod Security Standards
最後がPodレベルのセキュリティ設定。Kubernetes 1.25以降のPod Security Standardsを使ってます。
# team-aのNamespaceに"restricted"標準を適用
apiVersion: v1
kind: Namespace
metadata:
name: team-a
labels:
pod-security.kubernetes.io/enforce: restricted
pod-security.kubernetes.io/audit: restricted
pod-security.kubernetes.io/warn: restricted
これで、以下のようなPodは作成できなくなります:
- rootユーザーで動作するPod
- PrivilegedコンテナPod
- hostPathマウントするPod
これ、ちょっと厳しいかな…と思うかもしれませんが、正直「restrictedでいい」ですよ。本当に特別な理由がある場合だけ、Namespace単位で緩和します。デフォルトは「No」で始めるのが鉄則です。
AWSのマルチテナント構成図
graph TB
subgraph VPC["VPC (10.0.0.0/16)"]
subgraph AZ1["AZ1"]
Node1["Node 1<br/>Calico daemon"]
Node2["Node 2<br/>Calico daemon"]
end
subgraph AZ2["AZ2"]
Node3["Node 3<br/>Calico daemon"]
end
end
subgraph EKSControl["EKS Control Plane"]
APIServer["API Server<br/>RBAC enforcer"]
ETCD[("etcd<br/>全テナント定義")]
end
subgraph TeamA["Team-A Namespace"]
PodA1["Pod A-1<br/>limits: 2CPU<br/>4GB"]
PodA2["Pod A-2<br/>limits: 1CPU<br/>2GB"]
ServiceA["Service A"]
RBACA["ServiceAccount A<br/>restricted権限"]
end
subgraph TeamB["Team-B Namespace"]
PodB1["Pod B-1<br/>limits: 2CPU<br/>4GB"]
PodB2["Pod B-2<br/>limits: 1CPU<br/>2GB"]
ServiceB["Service B"]
RBACB["ServiceAccount B<br/>restricted権限"]
end
subgraph Monitoring["Monitoring & Control"]
ResourceQuota["ResourceQuota<br/>Team-A: 30CPU/150GB<br/>Team-B: 30CPU/150GB"]
NetworkPolicy["NetworkPolicy<br/>Calico Enforce<br/>Default Deny"]
PSS["Pod Security Standards<br/>restricted mode"]
end
Node1 -->|Calico | TeamA
Node2 -->|Calico | TeamB
Node3 -->|Calico | TeamA
TeamA -->|API calls| APIServer
TeamB -->|API calls| APIServer
APIServer --> ETCD
ResourceQuota -->|apply to| TeamA
ResourceQuota -->|apply to| TeamB
NetworkPolicy -->|intercept| TeamA
NetworkPolicy -->|intercept| TeamB
PSS -->|admit only| TeamA
PSS -->|admit only| TeamB
PodA1 -.->|denied by NetworkPolicy| ServiceB
PodB1 -.->|denied by NetworkPolicy| ServiceA
style TeamA fill:#e1f5ff
style TeamB fill:#fff3e0
style EKSControl fill:#f3e5f5
style Monitoring fill:#e8f5e9
コスト最適化とマルチテナントのバランス
ここまで隔離の話をしてきたんですが、実はコスト効率も大事なんです。別々のクラスタを作るんじゃなく、同じクラスタで複数テナントを運用する理由ですから。
うちの場合、こういう構成にしています。単一テナント向けに複数クラスタを動かす場合と比べて、メリット・デメリットがはっきり出るんですよ:
| 構成パターン | 月額コスト | メリット | デメリット |
|---|---|---|---|
| 単一テナント(クラスタA) | $8,000 | シンプル、隔離完全 | クラスタ間でリソース融通できない |
| 単一テナント(クラスタB) | $7,500 | シンプル、隔離完全 | クラスタ間でリソース融通できない |
| マルチテナント統合 | $12,000 | ノード効率20%向上、管理コスト削減 | 設定複雑、隔離の維持が必要 |
マルチテナント化によって、ノードのパッキング効率が上がって、結果的にコストが20%削減できました。ただし、これは「きちんと隔離できている」という前提です。隔離が甘いと、1つのテナントの暴走で全体が落ちるので、むしろコストが増えますね。ダウンタイムによる損失が出るわけですから。
運用していて地味に困ったこと
1. ログとメトリクスの分離
これ、あんまり語られませんが本当に大事なんですよ。CloudWatch Logsで「全テナントのログを1つに」していると、あるテナントがログ量を爆発させたら、他のテナントが検索パフォーマンスで苦しむんです。
うちは結局、各テナント用にCloudWatch Log Groupを分けました。Prometheus/Grafanaについても、テナント単位でScrape設定を分けて、Alarmも別々に。地味ですが、本番運用ではこれがないと「あ、このメトリクスはどのテナント用なのか」ってことで混乱します。
2. Persistent Volume(ストレージ)の隔離
# EBS用のStorageClass(テナント専用)
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: team-a-ebs
provisioner: ebs.csi.aws.com
parameters:
type: gp3
iops: "3000"
throughput: "125"
tags: |
Team=TeamA
Environment=prod
ストレージについては、各テナント用にStorageClassを分けて、さらにEBSタグで「どのテナントか」を識別できるようにしました。これ、コスト配賦の時に本当に助かります。「team-aはいくらストレージ代がかかったのか」ってのが秒で出るんですよ。
3. Ingressの共有と隔離のバランス
Ingressコントローラーも同じクラスタで動いているので、複数テナントのトラフィックを扱います。
# team-a用Ingress(ALB Controller)
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: team-a-ingress
namespace: team-a
annotations:
alb.ingress.kubernetes.io/group.name: team-a
alb.ingress.kubernetes.io/group.order: "1"
alb.ingress.kubernetes.io/scheme: internet-facing
spec:
ingressClassName: alb
rules:
- host: api-team-a.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: team-a-service
port:
number: 8080
ALB Controllerのalb.ingress.kubernetes.io/group.nameを使って、テナント単位でALBグループを分けました。1つのALBで複数テナントをホストすることもできますが、Ruleが複雑になるので、やめました。テナントごとにALBを用意することで、何か問題が起きた時に「あ、team-aのALBだけ落ちてる」みたいに切り分けやすくなるんですよね。
実装してわかったベストプラクティス
新規テナント追加の時は、以下の手順で自動化する(僕たちはこれをチェックリスト化して、テンプレート化しました):
flowchart TD
A["新規テナント追加"] --> B["Namespace作成"]
B --> C["ResourceQuota設定"]
C --> D["LimitRange設定"]
D --> E["RBAC Role/RoleBinding"]
E --> F["NetworkPolicy: Default Deny"]
F --> G["NetworkPolicy: Internal Allow"]
G --> H["Pod Security Standards label"]
H --> I["StorageClass(テナント専用)"]
I --> J["CloudWatch Log Group"]
J --> K["Prometheus監視設定"]
K --> L["本番テスト:外部からのアクセス確認"]
L --> M["本番テスト:テナント間通信が遮断されているか確認"]
M --> N{"全チェック OK?"}
N -->|いいえ| O["修正"]
O --> N
N -->|はい| P["本番運用開始"]
style A fill:#fff9c4
style N fill:#ffccbc
style P fill:#c8e6c9
個人的に思うのは、このチェックリストを最初から用意しておくと、後々「あ、あの設定忘れてた」みたいなミスがなくなるんですよね。テンプレート化するとなおいいです。
まとめ
EKSマルチテナント設計で1年地獄を見て、ようやく「まあまあ安定している」という段階に来た感じです。正直、完全な隔離は難しいんですが、以下の4つの層があればほぼ大丈夫:
- リソース層:ResourceQuota・LimitRangeでCPU・メモリを制限。暴走を防ぐのが目的
- ネットワーク層:NetworkPolicy(Calico)でデフォルト拒否。意図しない通信をブロック
- 権限層:RBAC で各テナントのServiceAccountに最小限の権限を付与
- Pod層:Pod Security Standardsで、危険なPodの作成そのものをブロック
これらを組み合わせることで、1つのテナントの問題が他のテナントに波及するのをかなり防げます。
ただし、設定が複雑になるので、監視・ログ・メトリクスもテナント単位で分ける必要があります。「どのテナントが原因で何が落ちたのか」を素早く把握できないと、運用が成り立ちません。
次のアクションとして、もし複数チームを同じEKSクラスタで運用する予定があれば、以下の順で実装することをお勧めします:
- まずResourceQuota/LimitRangeを厳しめに設定する
- 次にNetworkPolicyのデフォルト拒否を入れる(本番運用の最初の3ヶ月は本当に大変)
- その後RBAC、Pod Security Standardsと段階的に追加する
- 監視・ログは最初から分ける
正直「あ、こんなにやることあるんだ」と思うかもしれませんが、最初に投資する方が、後で「意図しない通信で本番が死ぬ」みたいな地獄より100倍マシですよ。頑張ってください。