Kubernetes を手を動かして学ぶ (2) AWS EKS で本番相当の構成をつくる
Kubernetes を手を動かして学ぶシリーズの第2回です。第1回でローカルの kind 上に一通りの構成をつくったので、今回はそれを AWS EKS(本番相当)に持っていきます。マネージドなコントロールプレーン、Terraform での構築、そして「ローカルで動いた」と「本番で動く」の間にある谷を埋めていきます。この記事でも、要点となる Terraform コードは抜粋して載せます(AWS アカウント ID などは <AWS_ACCOUNT_ID> のようなプレースホルダにしているので、自分の値に置き換えてください)。
- 第1回 コンテナ化とローカルクラスタの基礎
- 第2回(本記事) AWS EKS で本番相当の構成をつくる
- 第3回 CI/CD パイプラインと可観測性
- 第4回 ArgoCD による GitOps(Pull 型 CD)
なお、この回には Terraform の state をまるごと失いかねない大きな落とし穴が出てきます。実務でもやりがちなので、避け方まで含めて扱います。
マネージドなコントロールプレーンと、責任の分界点
kind では、1つのノードにコントロールプレーン(API サーバー、etcd、スケジューラ等)も同居しています。EKS では、コントロールプレーンは AWS が管理します。ユーザーが用意するのは、その「外側」——VPC、ワーカーノード、IAM ロールなどです。
この責任分界を意識すると、「自分が何を用意しなければならないか」が整理されます。今回 Terraform で用意するのは、ざっくり以下です。
- VPC(public / private を2 AZ、コスト優先で NAT Gateway は1つ)
- EKS クラスタとマネージドノードグループ(
t3.medium× 2) - ECR(イメージレジストリ)
- 各種アドオンと IAM ロール
graph TB
subgraph AWS["AWS が管理(EKS コントロールプレーン)"]
API["kube-apiserver"]
ETCD[("etcd")]
CM["scheduler / controller-manager"]
end
subgraph USER["ユーザーが用意する(責任分界の外側)"]
VPC["VPC / サブネット / NAT"]
NG["マネージドノードグループ"]
IAM["IAM ロール・Access Entry"]
ADDON["アドオン:vpc-cni / CoreDNS / EBS CSI"]
end
NG -->|"kubelet が接続"| API
Terraform:デファクトスタンダードなモジュールで組む
IaC は Terraform で、VPC / EKS はいずれも、Terraform Registry でデファクトスタンダードとして広く使われている terraform-aws-modules のモジュール(vpc / eks)を使います(AWS や HashiCorp の「公式」ではなく、コミュニティ管理のモジュールです)。フルスクラッチと比べて、入力変数と生成物の理解に集中できます。
VPC はこれだけです。サブネットに付けている kubernetes.io/role/elb などのタグは、後で EKS / ロードバランサがサブネットを自動認識するために必要です。
# vpc.tf
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 6.6"
name = "study-k8s-vpc"
cidr = "10.0.0.0/16"
azs = ["ap-northeast-1a", "ap-northeast-1c"]
public_subnets = ["10.0.1.0/24", "10.0.2.0/24"]
private_subnets = ["10.0.3.0/24", "10.0.4.0/24"]
enable_nat_gateway = true
single_nat_gateway = true # コスト優先で NAT Gateway は1つ
public_subnet_tags = { "kubernetes.io/role/elb" = 1 }
private_subnet_tags = { "kubernetes.io/role/internal-elb" = 1 }
}
EKS はこの1モジュールで、クラスタ・ノードグループ・アドオン・Access Entry までまとめて宣言できます(access_entries と addons は後の節で意味を説明します)。
# eks.tf(抜粋)
module "eks" {
source = "terraform-aws-modules/eks/aws"
version = "~> 21.24"
name = "study-k8s-cluster"
kubernetes_version = "1.35"
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.private_subnets
endpoint_public_access = true
enable_cluster_creator_admin_permissions = true
eks_managed_node_groups = {
default = {
instance_types = ["t3.medium"]
capacity_type = "ON_DEMAND"
min_size = 1
desired_size = 2
max_size = 3
}
}
addons = {
vpc-cni = { before_compute = true } # ← 後述。ノードより先に入れる
kube-proxy = {}
coredns = {}
aws-ebs-csi-driver = {
service_account_role_arn = module.ebs_csi_irsa.arn # ← IRSA。後述
}
}
}
ここで1つ、Terraform 初学者がよく取り違えるポイントをクイズにします。
Q.
terraform initで生成されるファイルのうち、.gitignoreすべきなのはどれ? その理由は?
terraform.tfstate(state ファイル)→.gitignoreする。 理由は、認証情報を含みうること、そして Git での共有では state ロックが効かず、複数人が同時に apply すると壊れること。.terraform.lock.hcl(プロバイダのロックファイル)→ コミットする。 これは「チーム全員が同じプロバイダバージョンを使う」ための固定であり、package-lock.jsonに近いもの。
「複数 PC で別のリソースを操作してしまうから」gitignore する、という理由付けを見かけますが、これは誤りで、正しくは上記です。そして——理屈で分かっていても、この state の扱いこそ後で大きな事故につながります(後述)。頭では理解していても油断しやすいポイントです。
IAM 権限と Kubernetes RBAC は独立したレイヤー
EKS で最初につまずくのが、この2つの権限レイヤーの独立性です。
- AWS IAM … AWS の API を叩く権限(例:
eks:DescribeCluster) - Kubernetes RBAC … クラスタの中で何ができるか(例:Pod を作る)
この2つは別物で、EKS は Access Entry という仕組みで橋渡しします。「どの IAM プリンシパルを、どの Kubernetes 権限に紐付けるか」を宣言するものです。
graph LR P["IAM プリンシパル<br/>(ユーザー / ロール)"] -->|"① IAM ポリシー"| A["AWS API を叩ける<br/>eks:DescribeCluster 等"] P -->|"② Access Entry + Access Policy"| B["クラスタ内で操作できる<br/>kubectl が通る"] A -.->|"①だけでは kubectl は通らない"| B
見落としやすいのは、AWS の AdministratorAccess を持つ IAM ユーザーであっても、Access Entry が無ければ kubectl は一切通らないことです。たとえば root ユーザー自身は Access Entry を持たないため、AWS コンソールの EKS の「Nodes」タブが空に見えます。「IAM で最強権限を持っているのに、クラスタの中は何も見えない」——最初に必ず引っかかるポイントです。
チーム運用では、個々の IAM ユーザーに Access Entry を付けるのではなく、IAM ロール単位で Access Entry を設定し、各メンバーはそのロールを Assume する構成が一般的です。
EKS は「素のクラスタ」——アドオンは自分で入れる
kind との大きな違いとして、EKS では CNI / kube-proxy / CoreDNS が自動では入りません。 terraform-aws-modules/eks の addons で明示的にインストールする必要があります。
これを知らずに apply すると、ノードグループの作成そのものが CREATE_FAILED になります。セキュリティグループやルートテーブルを疑いたくなりますが、たいていそこは正常で、kubectl get pods -n kube-system が0件——つまり vpc-cni アドオンが無く、ノードが Pod ネットワークを構成できずに NotReady のまま張り付いているのが真因です。
対策は、アドオンに before_compute = true を指定し、ノードグループ作成の前にアドオンをインストールするよう依存関係を組むこと。あわせて覚えておきたいのは、EKS のノードグループは一度 CREATE_FAILED になると自己回復しないという点です。ノードが後から健康になっても自動で ACTIVE には戻らないので、terraform destroy して作り直すことになります。
EBS CSI Driver と IRSA
DB の PVC を EKS で使おうとすると、Pending のまま止まります。EKS では、kubernetes.io/aws-ebs の in-tree プロビジョナーは現行バージョンでは機能せず、EBS CSI Driver というアドオンを別途入れる必要があります(vpc-cni と同じ「明示的アドオン」パターン)。
ここで IRSA(IAM Roles for Service Accounts) が登場します。EBS CSI Driver のコントローラは、EC2 の CreateVolume などの AWS API を呼ぶ必要があります。その権限を、ノード全体の IAM ロールにまとめて持たせるのではなく、特定の Kubernetes ServiceAccount 単位で細かく持たせるのが IRSA です。クラスタ作成時に生成される OIDC Provider を土台に、ServiceAccount と IAM ロールを紐付けます。この「ServiceAccount に AWS の権限を持たせる」考え方は、第3回の GitHub Actions の OIDC 認証と根っこが同じです。
詰まりポイント:lost+found で initdb が失敗する
EBS CSI Driver を入れて PVC が Bound になっても、今度は PostgreSQL の initdb が失敗することがあります。
新しく作られた EBS ボリュームは ext4 でフォーマットされ、そのルートには lost+found ディレクトリが自動生成されます。PostgreSQL の initdb は「データディレクトリが空でない」と判断して初期化を拒否します。kind の local-path(ノード上の既存ディレクトリを bind mount しているだけ)ではこの問題は起きません。「ボリュームの実体が、既存ディレクトリなのか、新規フォーマット済みブロックデバイスのルートなのか」の違いです。
対策は、PGDATA 環境変数でマウント先の1つ下のサブディレクトリを指定することです。これは kind / EKS 両対応なので base に置きます。
EKS 上での外部公開:AWS Load Balancer Controller
第1回の ingress-nginx に相当するものとして、EKS では実務で主流の AWS Load Balancer Controller(ALB Controller)を使います。Ingress リソースを見て、AWS の ALB を自動でプロビジョニングしてくれます。
ここで大事なのは、ALB Controller は EKS のマネージドアドオンでは入れられないという点です。vpc-cni や EBS CSI Driver とは違い、addons ブロックには書けず、Helm chart 経由でインストールします(IRSA ロール作成 → Helm chart インストール → Ingress にアノテーション付与、の3段階)。Helm の導入も、手で helm install を叩くのではなく再現性のため Terraform の helm プロバイダで IaC 化します。
# alb-controller.tf(抜粋)
resource "helm_release" "aws_load_balancer_controller" {
# ← この depends_on が後述の「地雷」を避けるための肝
depends_on = [module.eks]
name = "aws-load-balancer-controller"
repository = "https://aws.github.io/eks-charts"
chart = "aws-load-balancer-controller"
namespace = "kube-system"
values = [yamlencode({
clusterName = module.eks.cluster_name
vpcId = module.vpc.vpc_id
region = "ap-northeast-1"
serviceAccount = {
name = "aws-load-balancer-controller"
annotations = {
# ServiceAccount に紐付ける IAM ロール(IRSA)。OIDC Provider の ARN と
# 取り違えやすいので注意(ここは "ロール" の ARN)。
"eks.amazonaws.com/role-arn" = module.load_balancer_controller_irsa.arn
}
}
})]
}
Ingress 側には、ALB 用のアノテーションと ingressClassName: alb を Kustomize のパッチで足します(第1回の base はそのまま、EKS overlay で差分だけ重ねる)。
# k8s/overlays/eks/ingress-patch.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: study-k8s-app-ingress
annotations:
alb.ingress.kubernetes.io/scheme: internet-facing
alb.ingress.kubernetes.io/target-type: ip # Pod IP に直接転送
alb.ingress.kubernetes.io/healthcheck-path: /readyz
spec:
ingressClassName: alb
第1回で学んだ「Ingress は宣言、Ingress Controller が実行者」の構図が、そのまま AWS に持ち込まれた形です。ただし ALB Controller の場合、実行者がクラスタの外(AWS 側)に実リソース(ALB)を作る、という点が後で牙をむきます(後述の「取り残されるリソース」問題)。
大きな落とし穴:Terraform の state を失う
ここは、実務でもやりがちな一番の落とし穴です。
ある日 terraform plan を実行したら、EKS クラスタ・VPC を含む数十のリソース全てが「新規作成」として計画される——こんなことが起こり得ます。実際の AWS リソースは動いているのに、Terraform から見ると「何も無い」状態です。
原因は state の保存場所にあります。よくあるのは、state をローカルファイル(terraform.tfstate、しかも .gitignore 対象)で持ったまま、その置き場所ごと消してしまうパターンです。たとえば作業ごとに git worktree を切って terraform apply し、作業が終わって worktree を消すと、state ファイルも一緒に消えます(実リソースは AWS に残ったまま)。
sequenceDiagram participant W as worktree participant S as tfstate(ローカル) participant AWS as AWS 実リソース W->>AWS: terraform apply(数十リソース作成) Note over S: state は worktree 内にだけ存在 W-->>S: 作業後に worktree を片付け Note over S: state ごと消滅 W->>AWS: 次の plan は全て「新規作成」判定 Note over AWS: 実リソースは生きたまま
これは構造的な問題です。「state をローカルファイルで持つ」設計と、「worktree を作っては消す」ような運用は、根本的に相性が悪いです。恒久対策は、S3 リモートバックエンド(S3 ネイティブロックを使うため Terraform 1.10 以降)に切り替えることです。
# main.tf
terraform {
required_version = ">= 1.10, < 2.0"
backend "s3" {
bucket = "study-k8s-tfstate-<AWS_ACCOUNT_ID>"
key = "aws/terraform.tfstate"
region = "ap-northeast-1"
use_lockfile = true # S3 ネイティブロック(DynamoDB 不要)
encrypt = true
}
}
つまり、state は「worktree を消しても消えない場所」に最初から置くのが鉄則です。
state を失ったら:destroy ではなく import で復旧する
では、実際に state を失ってしまったら(あるいは state と実リソースがズレてしまったら)どう復旧するか。ここで「destroy して作り直せばいい」と考えたくなりますが、これは罠です。空の state に対する terraform destroy は何もしません(state に無いものは destroy できない)。かといって Terraform を経由しない手動削除は、依存関係の順序を気にする必要があり import より危険です。正攻法は、terraform import で実リソースを state に取り込むことです。
やり方は2段構えにするとよいです。
- 最初の1個(VPC)は、仕組みを理解するために手動
terraform importコマンドで実行する - 残りは、宣言的な
importブロック(Terraform 1.5+)で1回のplan/applyにまとめる
なぜ使い分けるのか。レガシーな terraform import コマンドは設定全体のプロバイダ評価を要求するため、まだ import していないリソースへの参照が随所で Invalid count argument エラーを引き起こし、個別実行が破綻します。import ブロックならこれを回避できます。
import では、ドキュメント通りの ID では通らないリソースにいくつも当たります。ここも詰まりやすいので、代表例を挙げておきます。
aws_security_group_ruleの import ID はsgr-xxxx形式では通らず、セキュリティグループID_タイプ_プロトコル_開始ポート_終了ポート_ソースという複合形式が必要(自己参照ルールはソース部分にself)aws_default_route_tableの import ID は、ドキュメント上は「ルートテーブル ID」だが、実際は VPC ID を渡す必要がある(TF_LOG=DEBUGで実際の AWS API 呼び出しを追うと分かる)
そして最大の注意点。import 完了後の plan で、aws_eks_access_entry["cluster_creator"] が「replace(作り直し)」と判定されることがあります。原因は、enable_cluster_creator_admin_permissions = true が「クラスタ作成者」を固定 ARN ではなくその時 Terraform を実行している IAM アイデンティティとして都度評価するためです。AWS_PROFILE の指定漏れで意図しないプロファイルを使うと、ここに引っかかります。plan を精査せず apply すると、実クラスタの管理者アクセス権限を意図せず付け替えてしまいます。
ポイント:
terraform planの差分は、apply の前に必ず1行ずつ精査する。
もう1つの落とし穴:destroy で AWS リソースが取り残される
後片付けで terraform destroy すると、最後に VPC・サブネット・IGW の削除が DependencyViolation で失敗することがあります。
残るのは、Terraform が一度も作成していないリソース——ALB 本体、ターゲットグループ、セキュリティグループです。これらは ALB Controller が Kubernetes の Ingress リソースを見て自律的に AWS API を呼んで作ったもので、Terraform の状態管理・依存グラフの外側に存在します。いわば、Terraform の管理から 取り残されたリソース(orphaned resources) です。
terraform destroy は EKS クラスタやノードグループ(= ALB Controller を動かしていた基盤)を先に壊してしまうため、ALB Controller は Ingress の削除に反応して ALB を片付ける機会を失います。結果、ALB・SG・ENI が取り残されて VPC の削除をブロックします。
正しい順序:Kubernetes コントローラが Terraform 管理外で AWS リソースを動的に作る構成(ALB Controller、
type: LoadBalancerの Service、動的 PV など)を destroy する前には、先にkubectl deleteで Ingress / Service を消し、コントローラ自身に後片付けをさせてからインフラを destroy する。
graph TD
subgraph BAD["まずい順序(取り残される)"]
A1["terraform destroy が<br/>先に EKS を破壊"] --> A2["ALB Controller が停止"]
A2 --> A3["Ingress 削除に反応できず<br/>ALB・SG・ENI が取り残される"]
A3 --> A4["VPC 削除が<br/>DependencyViolation で失敗"]
end
subgraph GOOD["正しい順序"]
B1["先に kubectl delete で<br/>Ingress を削除"] --> B2["ALB Controller が<br/>ALB を後片付け"]
B2 --> B3["その後 terraform destroy<br/>が成功"]
end
まとめ
第2回のポイントは、大きく2つに集約されます。
- EKS は「素のクラスタ」であり、責任分界の外側(VPC・ノード・IAM・アドオン)を全部自分で組む必要がある。 IAM と RBAC は独立レイヤーで、Access Entry がその橋渡し。IRSA は ServiceAccount 単位での AWS 権限付与。
- state と、コントローラが動的に作る「Terraform 管理外リソース」の扱いを間違えると事故る。 state は消えない場所へ。destroy はコントローラに後片付けをさせてから。
次回は、この EKS 上のアプリを CI/CD で自動デプロイし、さらに Prometheus / Grafana で可観測性を持たせます。GitHub Actions の OIDC、イミュータブルなイメージタグ、そして admission webhook が絡む厄介な地雷を扱います。
→ 第3回 CI/CD パイプラインと可観測性