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 つ欠けると、セキュリティホールか運用負荷の増加のどちらかになるんです。

実装時の優先順位としては、こんな感じです。

  1. ResourceQuota + LimitRange で compute リソースを上限設定 — 1 テナントの暴走が他に影響しない
  2. NetworkPolicy で Namespace 間の通信を deny-all から始める — 最小権限の原則を守る
  3. RBAC で Service Account 単位に権限を制限 — テナント側も信頼できない前提で設計
  4. Pod Label + Kubecost でテナント毎のコスト自動計算 — 経営判断を支える数字を毎月提供
  5. Policy Engine(Kyverno)で規約を自動強制 — 属人的なレビューを減らす

セキュリティと経済性のバランスが取れたマルチテナント設計は、最初は複雑に見えるけど、3ヶ月も運用すると「なぜこんなシンプルな仕組みで動いてるんだ」って感じになります。今からマルチテナント始める人は、最初から 4 つ全部やっちゃう方がいい。後付けは地獄です、ホント。

U

Untanbaby

ソフトウェアエンジニア|AWS / クラウドアーキテクチャ / DevOps

10年以上のIT実務経験をもとに、現場で使える技術情報を発信しています。 記事の誤りや改善点があればお問い合わせからお気軽にご連絡ください。

関連記事