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万円コストが増えた」という告発を受けた。これはキツかった。

「完全隔離」と「コスト効率」のバランスを取った構成

悩んだ末に、うちが辿り着いた構成は「段階的隔離」だった。全テナントを同じ基準で隔離するんじゃなくて、要件に応じて分ける、という発想ですね。

  1. クリティカルなテナント(SLA厳しい): ノード完全専有
  2. 通常テナント: Namespace + ResourceQuota + NetworkPolicy
  3. 開発・ステージング: 複数テナント混在でも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-12RBAC権限が漂流IaC化 + ArgoCDAuditログで全追跡可能に

まとめ

EKSマルチテナント化は、決して「Namespaceで分ければ終わり」じゃない。 実務では、段階的に以下を組み合わせる必要があります:

  1. Namespace + ResourceQuota:リソース競合の防止
  2. NetworkPolicy:ネットワークトラフィック制御
  3. RBAC:API権限の最小化
  4. ノードアフィニティ:SLA要件に応じた物理隔離
  5. Pod Security Standards:セキュリティコンテキスト強制
  6. IaC + GitOps:権限ドリフトの防止

これら全部を最初から完璧にやる必要はないけど、テナント数が増えるたび、「あの時から設計しておけば」という後悔が増えるんですよね。実際のコストも月80万円かかったけど、その代わりに本番障害が激減した。隔離って見えない部分だけど、やってないときの方が高くつく。そこまで気づくのに1年かかったのが、今の一番の失敗だと思う。

次やるなら、複数クラスタ構成も視野に入れつつ、複雑性とコストのバランスを最初から考える。テナント数が多いなら、完全隔離よりも「サンドボックス型クラスタ」という選択肢もある。EKS Auto Modeあたりなら、その辺りもラクになるかもしれない。

U

Untanbaby

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

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

関連記事