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つの層があればほぼ大丈夫:

  1. リソース層:ResourceQuota・LimitRangeでCPU・メモリを制限。暴走を防ぐのが目的
  2. ネットワーク層:NetworkPolicy(Calico)でデフォルト拒否。意図しない通信をブロック
  3. 権限層:RBAC で各テナントのServiceAccountに最小限の権限を付与
  4. Pod層:Pod Security Standardsで、危険なPodの作成そのものをブロック

これらを組み合わせることで、1つのテナントの問題が他のテナントに波及するのをかなり防げます。

ただし、設定が複雑になるので、監視・ログ・メトリクスもテナント単位で分ける必要があります。「どのテナントが原因で何が落ちたのか」を素早く把握できないと、運用が成り立ちません。

次のアクションとして、もし複数チームを同じEKSクラスタで運用する予定があれば、以下の順で実装することをお勧めします:

  • まずResourceQuota/LimitRangeを厳しめに設定する
  • 次にNetworkPolicyのデフォルト拒否を入れる(本番運用の最初の3ヶ月は本当に大変)
  • その後RBAC、Pod Security Standardsと段階的に追加する
  • 監視・ログは最初から分ける

正直「あ、こんなにやることあるんだ」と思うかもしれませんが、最初に投資する方が、後で「意図しない通信で本番が死ぬ」みたいな地獄より100倍マシですよ。頑張ってください。

U

Untanbaby

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

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

関連記事