EKSマルチテナント設計で1年地獄を見た話|Namespace分離からNetworkPolicyまで

「Namespaceで分ければ大丈夫でしょ」と思ってた頃の自分に言いたい。EKS本番マルチテナント1年で踏んだ失敗と、今落ち着いている隔離設計の実装記録。

最初の設計が甘すぎた話

去年の春、社内の複数プロダクトチームが「EKS使いたい」と言い出したタイミングで、僕はEKSクラスターのマルチテナント設計を一手に引き受けることになった。「Namespaceで分ければなんとかなるでしょ」という楽観的な見立てで始めた設計は、3ヶ月後に盛大に崩壊する。

あの頃の自分に言いたいのは「Namespaceは論理的な分離であって、セキュリティ境界ではない」という一言だ。ネットワーク、リソース競合、IAM権限の漏れ、ノードの混在——問題は次々と出てきた。この記事は、その1年間の失敗と、今落ち着いている構成の話を書く。

なお、EKSのコスト最適化についてはEKS コスト最適化2026|Spot/On-Demand戦略とKarpenter活用に詳しく書いたので、この記事ではテナント分離の設計にフォーカスする。


EKSマルチテナントの分離モデルを整理する

まず、マルチテナントの分離レベルには大きく3段階ある。どの段階を採用するかでコスト・運用負荷・セキュリティレベルが全部変わってくる。

分離レベルNamespace共有ノード共有想定ユースケース
Soft Multi-tenancy✅ 分離✅ 共有信頼済み社内チーム間
Hard Multi-tenancy✅ 分離❌ 分離外部顧客や高セキュリティ要件
クラスター分離❌ クラスター自体を分離❌ 分離規制業種・完全分離要件

うちのチームは最初「Soft Multi-tenancy」のつもりで始めたのに、途中から「でもこのチームは外部顧客データを扱うから…」という話が出てきて、設計が歪み始めた。これが最初の失敗だった。要件を最初に固めることの大切さを、身をもって学んだ。

現在の構成は「チームAはSoft、チームBはHardでFargateプロファイル」という混在になっている。正直まだ完全に整理できているとは言えないけど、一応動いている。

実際に構築したAWS構成図

graph TB
    subgraph AWS_Account["AWS Account"]
        subgraph VPC["VPC (10.0.0.0/16)"]
            subgraph AZ_A["Availability Zone A"]
                subgraph Private_A["Private Subnet A (10.0.1.0/24)"]
                    NG_A[NAT Gateway]
                    NODE_A1["Node Group A\n(Team-A/B用 EC2)"]
                    FARGATE_A["Fargate Profile\n(Team-C用)"]
                end
                subgraph Public_A["Public Subnet A"]
                    ALB_A[ALB]
                end
            end
            subgraph AZ_B["Availability Zone B"]
                subgraph Private_B["Private Subnet B (10.0.2.0/24)"]
                    NODE_B1["Node Group B\n(Team-A/B用 EC2)"]
                    FARGATE_B["Fargate Profile\n(Team-C用)"]
                end
                subgraph Public_B["Public Subnet B"]
                    ALB_B[ALB]
                end
            end
            subgraph EKS_Control["EKS Control Plane"]
                API_SERVER[kube-apiserver]
                OIDC[OIDC Provider]
            end
            subgraph NS_TeamA["Namespace: team-a"]
                POD_A1[Pod A1]
                POD_A2[Pod A2]
                SA_A[ServiceAccount-A]
            end
            subgraph NS_TeamB["Namespace: team-b"]
                POD_B1[Pod B1]
                SA_B[ServiceAccount-B]
            end
            subgraph NS_TeamC["Namespace: team-c (Fargate)"]
                POD_C1[Pod C1 - Fargate]
                SA_C[ServiceAccount-C]
            end
        end
        subgraph IAM["IAM"]
            ROLE_A["IAM Role\n(Team-A IRSA)"]
            ROLE_B["IAM Role\n(Team-B IRSA)"]
            ROLE_C["IAM Role\n(Team-C IRSA)"]
        end
        subgraph Addons["EKS Managed Addons"]
            KARPENTER[Karpenter]
            COREDNS[CoreDNS]
            VPC_CNI[VPC CNI]
            ARGOCD[ArgoCD]
        end
        S3_A["S3 Bucket\n(Team-A専用)"]
        S3_B["S3 Bucket\n(Team-B専用)"]
        ECR[ECR]
    end
    SA_A -->|IRSA| ROLE_A --> S3_A
    SA_B -->|IRSA| ROLE_B --> S3_B
    SA_C -->|IRSA| ROLE_C
    OIDC --> ROLE_A
    OIDC --> ROLE_B
    OIDC --> ROLE_C
    ALB_A --> POD_A1
    ALB_B --> POD_B1
    KARPENTER --> NODE_A1
    KARPENTER --> NODE_B1

この構成に落ち着くまでに、ノードグループの設計を2回作り直した。最初はKarpenterを全チーム共有で使っていたんだけど、Team-CのFargateへの移行と、Team-A/Bのノードアフィニティ設定が絡み合って地獄を見た。


Namespace設計とNetworkPolicyの実装

Namespaceを分けたとして、デフォルト状態ではPod間の通信は全部通ってしまう。最初これを知らなくて、Team-AのサービスからTeam-Bのエンドポイントに誤って通信できてしまう状況が1ヶ月間続いていた。気づいた時は青ざめた。

NetworkPolicyの基本設定

まず「同じNamespace内からの通信しか受け付けない」というデフォルト拒否ポリシーを各Namespaceに設定する。後から絞ろうとすると影響範囲の把握が本当に大変なので、最初からdeny-allを置く癖をつけた方がいい。

# team-a namespace のデフォルト拒否ポリシー
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny-all
  namespace: team-a
spec:
  podSelector: {}  # namespace内の全Pod
  policyTypes:
    - Ingress
    - Egress
---
# team-a内の通信を許可
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-same-namespace
  namespace: team-a
spec:
  podSelector: {}
  policyTypes:
    - Ingress
    - Egress
  ingress:
    - from:
        - podSelector: {}  # 同一Namespaceのみ
  egress:
    - to:
        - podSelector: {}  # 同一Namespaceのみ
    - ports:               # DNS解決は許可
        - port: 53
          protocol: UDP
        - port: 53
          protocol: TCP
    - to:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: kube-system

共有サービス(監視用のPrometheusなど)からのアクセスを許可するには別途ポリシーが必要になる。これが地味に面倒で、最初は「監視できない」という問題が頻発した。

# monitoring namespaceからのscrapeを許可
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-monitoring
  namespace: team-a
spec:
  podSelector:
    matchLabels:
      scrape: "true"  # 監視対象Podにこのラベルをつける
  policyTypes:
    - Ingress
  ingress:
    - from:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: monitoring
      ports:
        - port: 8080
          protocol: TCP

NetworkPolicyはCNIプラグインが対応していないと動かない点も注意。うちはVPC CNIだったけど、NetworkPolicyを適用するにはAWS VPC CNI Network Policy Controllerを有効化する必要がある。2025年後半からEKS managed addonとして提供されるようになったので、2026年時点では追加インストール不要になっている。これは地味にありがたかった。

# VPC CNI Network Policy有効化確認
aws eks describe-addon \
  --cluster-name my-cluster \
  --addon-name vpc-cni \
  --query 'addon.configurationValues' \
  --output text

# 有効化
aws eks update-addon \
  --cluster-name my-cluster \
  --addon-name vpc-cni \
  --configuration-values '{"enableNetworkPolicy": "true"}'

リソースクォータとLimitRangeの現実

Namespace分離だけでは「あるチームが大量のCPU/メモリを使ってほかのチームのPodがEvictされる」という問題は防げない。ResourceQuotaとLimitRangeの設定は必須だ。

とはいえ、初期値の設定が難しい。緩すぎると意味がないし、厳しすぎるとチームの開発がブロックされる。3ヶ月試行錯誤した結果、チームごとにメモリ使用量を2週間監視してから設定するプロセスにした。最初から「適切な値」を決めようとするのはほぼ無理なので、観測ファーストで考えた方がいい。

apiVersion: v1
kind: ResourceQuota
metadata:
  name: team-a-quota
  namespace: team-a
spec:
  hard:
    requests.cpu: "8"
    requests.memory: 16Gi
    limits.cpu: "16"
    limits.memory: 32Gi
    count/pods: "50"
    count/services: "20"
    count/persistentvolumeclaims: "10"
    count/configmaps: "50"
    count/secrets: "100"
---
apiVersion: v1
kind: LimitRange
metadata:
  name: team-a-limitrange
  namespace: team-a
spec:
  limits:
    - type: Container
      default:            # Limitsが未指定の場合のデフォルト
        cpu: "500m"
        memory: 512Mi
      defaultRequest:     # Requestsが未指定の場合のデフォルト
        cpu: "100m"
        memory: 128Mi
      max:
        cpu: "4"
        memory: 8Gi
      min:
        cpu: "50m"
        memory: 64Mi

LimitRangeのdefaultRequestを設定しておくのが地味に重要で、これがないと開発者がrequestsを書かなかった場合にschedulingが変な動作をする。チームに「必ずrequests書いて」と言い続けても守られないので、LimitRangeで強制するのが現実的だった。個人的には、運用ルールより仕組みで縛る方が絶対うまくいくと思っている。

リソース使用量の可視化はどうしてる? うちはNamespace別のメトリクスをDatadogで取っているけど、CloudWatch Container Insightsでも似たことはできる。CloudWatch vs Datadog 2026|コスト・機能比較ガイドで比較を書いたので参考にしてほしい。

ResourceQuota消費率の実測推移

xychart-beta
    title "チーム別 CPU Request 使用率推移 (2026年1月〜6月)"
    x-axis ["1月", "2月", "3月", "4月", "5月", "6月"]
    y-axis "使用率 (%)" 0 --> 100
    line [45, 52, 78, 91, 85, 72]
    line [30, 35, 40, 55, 60, 65]
    line [20, 25, 35, 48, 52, 58]

3月〜4月にTeam-Aが90%超えて、他チームへの影響が出始めた。この時にResourceQuotaを引き上げる代わりにKarpenterのNodePoolを分離したことで、5月以降は安定している。


IRSAによるIAM権限の最小化

マルチテナントで一番ハマったのが権限管理だった。最初は「EC2のInstance ProfileでS3アクセスすればいいか」と思っていたら、当然ながら全テナントが同じS3バケットにアクセスできてしまう。IRSA(IAM Roles for Service Accounts)を使ったPod単位の権限制御は必須だった。

# OIDC Providerの設定確認
aws eks describe-cluster \
  --name my-cluster \
  --query 'cluster.identity.oidc.issuer' \
  --output text

# Team-A用 IAM Roleの作成(Terraform例)
# Team-A専用のIAM Role
module "irsa_team_a" {
  source  = "terraform-aws-modules/iam/aws//modules/iam-role-for-service-accounts-eks"
  version = "~> 5.0"

  role_name = "eks-team-a"

  oidc_providers = {
    main = {
      provider_arn               = module.eks.oidc_provider_arn
      namespace_service_accounts = ["team-a:team-a-sa"]  # namespace:serviceaccount形式
    }
  }
}

# Team-A専用のS3アクセスポリシー
resource "aws_iam_role_policy" "team_a_s3" {
  name   = "team-a-s3-policy"
  role   = module.irsa_team_a.iam_role_name

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Action = [
          "s3:GetObject",
          "s3:PutObject",
          "s3:DeleteObject"
        ]
        Resource = "arn:aws:s3:::team-a-bucket/*"
      },
      {
        Effect = "Allow"
        Action = ["s3:ListBucket"]
        Resource = "arn:aws:s3:::team-a-bucket"
      }
    ]
  })
}
# team-a namespace内のServiceAccount
apiVersion: v1
kind: ServiceAccount
metadata:
  name: team-a-sa
  namespace: team-a
  annotations:
    eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/eks-team-a
    eks.amazonaws.com/token-expiration: "86400"  # 24時間

IRSAを使うと、Team-AのPodはTeam-A用のS3バケットにしかアクセスできない。当たり前に聞こえるけど、これが最初できていなかったのでセキュリティ的にかなりまずい状況だった。正直、よく何も起きなかったと思う。セキュリティ設計についてはコンテナセキュリティ完全ガイド2026|eBPF・SBOM・サプライチェーン対策でも詳しく扱っているので合わせて読んでほしい。

ノード分離:Taint/TolerationとNodeSelector

Hard Multi-tenancyが必要なテナントには、専用ノードグループを割り当てる。Karpenterを使ったNodePool単位の分離がシンプルで管理しやすかった。

# Team-C専用のKarpenter NodePool
apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
  name: team-c-nodepool
spec:
  template:
    metadata:
      labels:
        team: team-c
    spec:
      taints:
        - key: team
          value: team-c
          effect: NoSchedule  # team-c以外のPodはスケジューリングされない
      nodeClassRef:
        group: karpenter.k8s.aws
        kind: EC2NodeClass
        name: team-c-nodeclass
      requirements:
        - key: karpenter.sh/capacity-type
          operator: In
          values: ["on-demand"]  # Team-CはSpot禁止(SLA要件)
        - key: node.kubernetes.io/instance-type
          operator: In
          values: ["m6i.xlarge", "m6i.2xlarge"]
  limits:
    cpu: "100"
    memory: 200Gi
  disruption:
    consolidationPolicy: WhenEmptyOrUnderutilized
    consolidateAfter: 30m
---
# Team-C Podの設定
# deployment.yamlのspec.template.spec部分
tolerations:
  - key: team
    value: team-c
    effect: NoSchedule
nodeSelector:
  team: team-c

ここで地味にハマったのが、system componentsの扱い。CoreDNSやkube-proxyもTeam-C専用ノードで動かす必要があるか、という問いが出た。結論としては「system componentsは共有ノードに乗せて、アプリワークロードだけ専用ノードに閉じ込める」構成で落ち着いた。完全な分離を求めると運用コストが跳ね上がる。好みが分かれるところかもしれないけど、現実的な妥協点としてはここだと思っている。


1年運用して見えた本当の課題

技術的な実装はある程度固まったけど、実際に1年運用してみると「人的・組織的な課題」の方が大きかった。

各テナントのオンボーディングが毎回手動だった問題

最初の半年間、新しいテナントを追加するたびに以下の手順を手動でやっていた。

  • Namespace作成
  • ResourceQuota/LimitRange設定
  • NetworkPolicy適用
  • ServiceAccount作成
  • IAM Role作成
  • ArgoCD Project設定

当然ミスが発生する。ある時、NetworkPolicyの適用を忘れて3日間そのままになっていたことがあった。あの時はさすがにヒヤッとした。

これを解決するためにTerraformのモジュールとHelm chartを組み合わせた「テナントオンボーディング自動化」を実装した。

# テナント追加スクリプト(簡略版)
#!/bin/bash
TEAM_NAME=$1
MEMORY_QUOTA=${2:-"16Gi"}
CPU_QUOTA=${3:-"8"}

# Terraformでテナントリソース作成
terraform -chdir=./tenants apply \
  -var="team_name=${TEAM_NAME}" \
  -var="memory_quota=${MEMORY_QUOTA}" \
  -var="cpu_quota=${CPU_QUOTA}" \
  -auto-approve

# Helm ChartでK8sリソース適用
helm upgrade --install ${TEAM_NAME}-tenant ./charts/tenant \
  --namespace ${TEAM_NAME} \
  --create-namespace \
  --set team.name=${TEAM_NAME} \
  --set quota.memory=${MEMORY_QUOTA} \
  --set quota.cpu=${CPU_QUOTA}

echo "テナント ${TEAM_NAME} のオンボーディング完了"

これで手作業ミスが激減した。3テナント目あたりで自動化に踏み切るべきだったと今でも後悔している。

各テナントのオブザーバビリティ

「自分のNamespaceのメトリクスだけ見れるダッシュボード」をチームごとに要求されるようになったのも想定外だった。Grafanaのデータソース分離とRBAC設定が意外と複雑で、2週間かかった。こういう「チームごとの権限分離」は、技術的な分離と同じくらい大事だと実感した。

インシデント対応フローも整備しないといけなくて、インシデント対応の最新ベストプラクティス2026|DevOps・SRE必読で紹介されているような仕組みをEKSマルチテナントに合わせて調整した。

2026年時点のEKS関連機能アップデート

2026年に入って、EKS Auto Mode(参考:EKS Auto Mode完全ガイド2026|運用負荷90%削減のサーバーレスK8s)がマルチテナントに適用しやすくなった。Auto ModeではNodePoolをベースにした設計が基本なので、上述のKarpenter NodePool分離と概念が近い。ただし、Auto Modeに切り替えると既存のカスタムKarpenter設定が一部使えなくなるケースがあって、うちはまだ完全移行できていない。正直まだ検証中で、来期の課題として積み残している。

インシデント件数の推移

NetworkPolicy適用・ResourceQuota設定・IRSAによる権限分離・オンボーディング自動化を順番に実装していくにつれて、インシデント件数が着実に下がっている。数字で見るとこんな感じだ。

xychart-beta
    title "マルチテナント設計改善による月次インシデント件数推移"
    x-axis ["2025-Q2", "2025-Q3", "2025-Q4", "2026-Q1", "2026-Q2"]
    y-axis "インシデント件数" 0 --> 25
    bar [22, 18, 12, 7, 4]

Q2(2025年)の22件から2026年Q2の4件まで減らせた。完璧ではないけど、これだけ変わると設計を直し続けた甲斐があったと思える。


まとめ

1年間EKSマルチテナントを運用して、痛みを伴いながら学んだことをまとめる。

  1. 最初にテナント分離レベルを決める: Soft/Hardを途中で混在させると設計が歪む。チームごとのセキュリティ要件を先に整理する
  2. NetworkPolicyはデフォルト拒否から始める: 後から絞るのは地獄。最初からdeny-allを置いて、必要な通信だけ開ける
  3. IRSAは面倒でも必ずPod単位で設定する: Instance Profileで済ませると全テナントが同じ権限になる
  4. ResourceQuota/LimitRangeの初期値は観測してから設定する: 最初から「適切な値」は決められない。2週間モニタリングして決める
  5. オンボーディング自動化は早めにやる: 手動でやり続けると必ずミスが出る。3テナント目あたりで自動化に踏み切るべきだった

次のアクション候補:

  • EKS Auto Modeへの段階的移行検証(現在進行中)
  • Karpenter NodePool分離のコスト効果測定
  • Grafana Namespace別RBACの改善
  • Pod Security AdmissionによるPSP代替設定の見直し

マルチテナント設計は「1回作って終わり」じゃなくて、チームが増えるたびに継続的に改善が必要なものだと感じている。皆さんのチームではどういう分離戦略を取っていますか?参考になるアプローチがあればぜひ聞いてみたい。

U

Untanbaby

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

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

関連記事