はじめに

3章で用意したk8s manifestを4章ではkindクラスターにデプロイしました。この章では、manifestをEKSクラスターにデプロイする準備として、インフラ構築をしていきます。

そこで、terraformというIaC(Infrastructure as Code)を用います。

AWS上にリソースを構築する手法として、マネコンをポチポチして作っていくのも1つです。しかし、企業だと「インフラリソースもコードで管理する」が主流と言っていいでしょう。

ここでは、terraformの構文にも触れながら進めつつ、主にはAWSリソースやネットワークについて取り上げます。EKS環境を用意すると言っても、「EKSだけ」用意するのではなく、必要最低限に周辺リソースも作っていきます。

このを終えると、以下の構成図が完成します。

Amazon EKS(Elastic Kubernetes Service)

EKSは、AWSから提供されるK8sのマネージドサービスの1つです。

https://docs.aws.amazon.com/ja_jp/eks/latest/userguide/what-is-eks.html

Controle PlaneはAWS側が管理します。これが「マネージド」の意味するところです。これにより、開発者の関心は、アプリケーションPodが動くWorker Nodeのみに限定することができます。

https://docs.aws.amazon.com/ja_jp/eks/latest/userguide/what-is-eks.html

上の画像から分かる通り、Worker NodeにはEC2インスタンスが利用されます。

https://docs.aws.amazon.com/ja_jp/AWSEC2/latest/UserGuide/concepts.html

EC2インスタンスとは、「仮想サーバー」です。AWS物理サーバーの上で動く仮想マシンであり、使用するAMI(Amazon Machine Image)を選択可能です。AMI = OSイメージと捉えてください。また、インスタンスタイプ(= CPUやMemoryといったサーバーの性能のグレードの単位)の選択も可能になっています。

Terraform

Terraformは、HashiCorp社により提供される、IaC(Infrastructure as Code)になります。

https://developer.hashicorp.com/terraform

IaCにより、インフラの構成や設定をコードとして管理できます。

https://cloud.sakura.ad.jp/column/iac/#anc01

Terraformは、主たるクラウドプロバイダーに対応しており、AWSだけでなくAzure, GCPなどのあらゆるインフラの管理が可能です。

Terraformを用いたインフラ設定のフロー

先んじて、Terraformでどのようにインフラ設定を追加/修正していくのかのフローを理解しておきましょう。

  1. tfファイルを記述する
  2. terraform planで、適用予定の差分をプレビューする
  3. terraform applyで、その差分を実際のインフラに適用する

https://developer.hashicorp.com/terraform/intro#how-does-terraform-work

1. tfファイルを記述する(理想の状態を定義)は、この後実際に記述していくのでいいとして、2・3はここである程度理解しておきましょう。

2.terraform planで、適用予定の差分をプレビューする

https://developer.hashicorp.com/terraform/cli/commands/plan

planコマンドの内部では以下のようなプロセスが行われているようです。

  1. Stateファイルと実際のリモートオブジェクトと同期(refresh)
  2. Stateファイル(現在)とtfファイル(理想の構成)を比較する
  3. 差分(実行予定の変更)を算出・表示する

tfファイルを変更した際に、適用前に差分を確認できるため、意図しない変更が含まれていないかを検証でき、安全な運用につながります。

ちなみにStateファイルとは、「現在のインフラ状態」をjsonで表現したものになります。Terraformコードで宣言しているリソースと、実際のリモートリソース(ID, ARNなど)がマッピングされています。

https://developer.hashicorp.com/terraform/language/state

以下のように、tfファイル上のリソースはAWS上のこのリソースを指すという情報が対応づけされているんですね。

{
  "module": "module.eks",
  "type": "aws_eks_cluster",
  "instances": [
    {
      "attributes": {
        "arn": "arn:aws:eks:...:cluster/eks-sandbox" //<-- 実リソースを一意に識別する情報(Terraformが操作対象を特定するために使用)
      }
    }
  ]
}

このように、TerraformはStateファイルを通じて「どのリソースを操作するか」を正確に把握しています。

このStateファイルはデフォルトではローカルで管理されるようになっていますが、S3などリモート上で管理されることが多いです。ローカルだと、以下のような困り点があります。

  • チームで共有できない
  • 誰が最新の状態か分からない
  • 上書きによる競合が発生する
  • Stateファイル消失時のリスクが高い

しかしながら今回は簡単なハンズオンですので、ローカルでこのStateファイルを管理していきます。

3.terraform applyで、その差分を実際のインフラに適用する

apply時にも内部的にplanが実行されているようです。

その上で、以下のように振る舞います。

  1. 実際のリモートリソースに対して、差分に基づいた変更を行う
  2. 変更結果に基づいて、Stateファイルを更新する

なんとなくTerraformをどのように使っていくかイメージがついたでしょうか。では、次節から実際にtfファイルを記述していきます。

AWSリソース、terraformの構文については都度触れていきます。

TerraformでEKS環境を構築する

以下の順番でリソースを宣言していきます。

  1. VPC
  2. EKS Cluster
  3. EKS Node Group
  4. ALB
  5. ECR
  6. VPC Endpoint

以下を初期状態とします。

0. 準備

AWSを利用する準備

aws cliのセットアップをしたことがない方は、以下の記事が詳しいので、これに従って進めましょう。

https://zenn.dev/akkie1030/articles/aws-cli-setup-tutorial

初期に設定される、defaultプロファイルを今回は利用します。プロファイルとは、認証情報+設定値のセットです。

% aws configure list
      Name                    Value             Type    Location
      ----                    -----             ----    --------
   profile                <not set>             None    None
access_key     ******************** shared-credentials-file    
secret_key     ******************** shared-credentials-file    
    region           ap-northeast-1      config-file    ~/.aws/config

Terraformを利用する準備

まず、Terraformプロジェクトの初期化を行います。

/terraformというディレクトリを切ってください。

terraform/backend.tfファイルにterraformブロックを宣言しましょう。terraformブロックでは、Terraformプロジェクト全体の設定を書く場所です。

https://developer.hashicorp.com/terraform/language/block/terraform#terraform-block
# terraform/backend.tf
terraform {
  backend "local" {
    path = "terraform.tfstate"
  }
}

今回は、その中にbackendブロックを置いています。backendは、Stateファイルを保存する場所を設定します。

https://developer.hashicorp.com/terraform/language/backend#define-a-backend-block

先述の通り、今回はlocalを設定します。

https://developer.hashicorp.com/terraform/language/backend/local

pathにterraform.tfstateと記述しているので、Stateファイルはterraform/terraform.tfstateに保存/管理されるようになります。

次に、provider.tfファイルに、providerブロックを宣言しましょう。

# terraform/provider.tf
provider "aws" {
  region  = "ap-northeast-1"
  profile = "default"
}

providerブロックでは、使用するプロバイダーに関する設定を記述します。今回は、AWSを利用するのでawsが設定値となります。

https://developer.hashicorp.com/terraform/language/block/provider

AWS Providerへの設定値のリファレンスは以下です。

https://registry.terraform.io/providers/hashicorp/aws/latest/docs

regionにはap-northeast-1(東京リージョン)を設定します。 AWSは世界各所にデータセンターを置いていて、利用するリージョンを選択することができます。

また、profileにはdefaultを設定します。これにより、初回のaws configureで設定されたdefaultプロファイルを利用してAWSリソースを作成していくということにになります。

最後に、terraform initコマンドを実行し、プロジェクトの初期化を行なっておきます。

https://developer.hashicorp.com/terraform/cli/commands/init
% terraform init

Initializing the backend...
# ...省略
Terraform has been successfully initialized!

成功すると、以下のようにいくつかのディレクトリ、ファイルが生成されていると思います。

.
├── .terraform
│   ├── providers
│   │   └── registry.terraform.io
│   │       └── hashicorp
│   │           └── aws
│   │               └── 6.40.0
│   │                   └── darwin_arm64
│   │                       ├── LICENSE.txt
│   │                       └── terraform-provider-aws_v6.40.0_x5
│   └── terraform.tfstate
├── .terraform.lock.hcl
├── backend.tf
└── provider.tf

Stateファイルはまだありません。

これで、準備が整いました。VPCからリソースを宣言していきましょう。main.tfに記述していきます。

1. VPC

VPC(Amazon Virtual Private Cloud)とは、AWS内に仮想ネットワークを構築できるリソースです。これはつまり、「AWSの物理ネットワークの上に、論理的に隔離されたネットワークを作る」ことと言えます。

https://docs.aws.amazon.com/ja_jp/vpc/latest/userguide/what-is-amazon-vpc.html

リソースを配置していくのは「VPC内」になります。インフラ構成を配置する「枠」をAWS内に用意することから始めるということです。

VPCをmoduleを用いて宣言します。

# terraform/main.tf
module "vpc" {
  source = "terraform-aws-modules/vpc/aws"

  name = "eks-sandbox-vpc"
  cidr = "10.0.0.0/16"

  azs             = ["ap-northeast-1a", "ap-northeast-1c"]
  public_subnets  = ["10.0.0.0/24", "10.0.1.0/24"]
  private_subnets = ["10.0.2.0/24", "10.0.3.0/24"]

  enable_nat_gateway = true
  single_nat_gateway = true

  tags = {
    Name        = "eks-sandbox-vpc"
    Terraform   = "true"
    Environment = "dev"
  }
}
https://registry.terraform.io/modules/terraform-aws-modules/vpc/aws/latest

Terraformのresourceとmodule

Terraformのresource, moduleについてここで触れておきます。

https://developer.hashicorp.com/terraform/language/resources https://developer.hashicorp.com/terraform/language/modules

以下のようにイメージしておきましょう。

  • resource: 単一のインフラストラクチャオブジェクトを定義する最小単位
  • module: 複数のresourceやmoduleをまとめた再利用可能な構成単位

A1・B1という2つの関連リソースを個別に設定していたところを、

resource "a" "a1" {
  name = "A1"
}

resource "b" "b1" {
  name = "B1"
}

groupというモジュールでまとめて設定できる。といった感じです。

module "group" {
  source = "./module_group"
}

sourceは、moduleの定義元を指します。指定方法には以下があります:

  • ローカルパス(例:./modules/vpc)
  • Gitリポジトリ
  • Terraform Registry
https://developer.hashicorp.com/terraform/language/block/module#source

今回は、Terraformレジストリ上で公開されているvpcmoduleを利用するので、terraform-aws-modules/vpc/awsを設定しています。

https://registry.terraform.io/modules/terraform-aws-modules/vpc/aws/latest

Terraformレジストリ上でmoduleに関するドキュメントを参照することができます。設定例やInput、Outputはこのドキュメントを通して知ることができます。

  • Input: moduleに渡す設定値
  • Output: moduleから返ってくる値

また、ドキュメントを参照すると、terraform-aws-modules/vpc/awsmoduleは87個のresourceを含んでいるようです。

This module defines 87 resources.


設定しているInputについて、それぞれ見ていきます。

nameはvpcにつけている名前です。cidrでは、VPC内で使用できるプライベートIPアドレスの範囲を設定しています。

name = "eks-sandbox-vpc"
cidr = "10.0.0.0/16"

VPCにおけるCIDRブロックについては以下に説明があります。

https://docs.aws.amazon.com/ja_jp/vpc/latest/userguide/vpc-cidr-blocks.html

ネットワーク知識としてのCIDRを知らない方は、以下を参照ください。

https://wa3.i-3-i.info/word11989.html

今回の設定ですと10.0.0.0 〜 10.0.255.255のIPアドレス空間、つまり約65000個のIPアドレスを使えるということになります。

以下では、利用するAvailability Zoneのリストを設定しています。

azs             = ["ap-northeast-1a", "ap-northeast-1c"]

リージョンとしてap-northeast(東京)を設定していました。リージョンは、複数のデータセンターからなっています。ここでは、1a 1cの二つを利用することで、冗長化を図ることができています。

冗長化なんて今回必要ないんですが、「複数のAZを利用することが必要」というALBリソース(後述)の制約のため、2つ選択しています。

You must select at least two Availability Zone subnets.

https://docs.aws.amazon.com/elasticloadbalancing/latest/application/application-load-balancers.html

以下では、VPCで4つのサブネットを設定しています。

public_subnets  = ["10.0.0.0/24", "10.0.1.0/24"]
private_subnets = ["10.0.2.0/24", "10.0.3.0/24"]

サブネットは一般的なネットワーク用語です。VPCという仮想ネットワークを、4分割するということになります。

https://wa3.i-3-i.info/word11973.html

publicとprivateで分かれていることからもわかりますが、サブネットには主に二つの種類があります。

  • パブリックサブネット:
    • インターネット ↔︎ サブネットで通信が可能
  • プライベートサブネット:
    • インターネット → サブネットの通信が不可能
    • サブネット → インターネットの通信はNATがあれば可能

パブリックサブネットのルートテーブルには、デフォルトルート(0.0.0.0/0)としてInternet Gatewayが設定されているため、VPC外への通信がそのまま可能です。

Internet Gatewayは、VPCとインターネットとの間の通信を可能にするリソースと捉えればいいです。

https://docs.aws.amazon.com/ja_jp/vpc/latest/userguide/VPC_Internet_Gateway.html

また、サブネットは、各AZごとに作成する必要があります。2つのAZに1つずつパブリックサブネット、プライベートサブネットを置いています。

https://docs.aws.amazon.com/ja_jp/vpc/latest/userguide/configure-subnets.html#subnet-basics

ちなみに、vpcmoduleにはInternet Gatewayリソースも含まれています。この時点で以下のような構成図になりました。

最後に、NAT Gateway(Network Address Translation)を1つプライベートサブネットに配置されるようにします。

enable_nat_gateway = true
single_nat_gateway = true
https://docs.aws.amazon.com/ja_jp/vpc/latest/userguide/vpc-nat-gateway.html

プライベートサブネットのデフォルトルートにはNATが設定されます。つまり、プライベートサブネットからインターネットへの通信においては、パケットがNATを経由してInternet Gatewayから出るという形になります。


Internet GatewayとNAT Gateway

ここで、この二つの振る舞いについてまとめておきます。

その前に、AWSの文脈では、2つのIPアドレスがあることを整理しておきましょう。

  • パブリックIP
    • インターネットからルーティング可能なIPアドレス
  • プライベートIP
    • VPC内でのみ有効な仮想のIPアドレス

リソースは基本的にプライベートIPが付与されていますが、加えてパブリックIPアドレスを付与することもできます。

パケットがInternet Gatewayを通過する際に、リソースに付与されているパブリックIPアドレスを送信元IPアドレスとしてインターネットにトラフィックされるようになっています。

例えばEC2インスタンスが「パブリックサブネットに置かれている」 + 「パブリックIPアドレスが付与されている」場合、

  1. パケットがデフォルトルートとしてInternet Gatewayにルーティングされる
  2. 付与されているパブリックIPアドレスを送信元IPアドレスとしてインターネットにトラフィックされる

という感じになります。

NAT Gatewayは、送信元のプライベートIPアドレスをパブリックIPアドレスに書き換えて(SNAT)、Internet Gatewayにルーティングします。

例えばEC2インスタンスが「プライベートサブネットに置かれている」+「パブリックサブネットにNATが配置されている」場合、

  1. パケットがデフォルトルートとしてNAT Gatewayにルーティングされる
  2. NAT Gatewayで送信元IPアドレスがパブリックアドレスとして変換される
  3. パブリックサブネットのデフォルトルートとしてInternet Gatewayにルーティングされる
  4. インターネットにトラフィックされる

という感じになります。


ここまでで完成している構成図は以下です。サブネットは、省略して1つずつ表すように改めています。

2. EKS Cluster

kubelet→Controle Planeの通信経路

EKS Clusterは、k8sクラスターです。概要は先述したので省きますが、ここは再喝しておきます。

Controle PlaneはAWS側が管理します

https://docs.aws.amazon.com/ja_jp/eks/latest/best-practices/subnets.html

これはつまり、Controle Planeは自分の配置したVPCではなく、AWSの管理する別のVPC内にあるということです。

https://docs.aws.amazon.com/ja_jp/eks/latest/best-practices/subnets.html

図にあるように、Controle Plane↔︎Worker Nodeの通信は2通りの方法があります。どちらの方法になるかは、kube-api-serverのエンドポイントをPublicにするかPrivateにするかで変わります。

デフォルトではkube-api-serverのエンドポイントがPublicになるので、デフォルトの設定値前提で今回は進めます。

また、Publicにすることで気軽にkubectlによりapi-serverに接続したいためでもあります。本来は、Privateにするのが望ましいかもしれません。

ここで、k8sクラスター起動時のことを思い出してみます。 Worker Node内のkubeletがkube-api-serverに「私(Node)を登録してください」とリクエストを投げるのでした。つまり、初っ端からWorker Node→Controle Planeへの通信が発生するわけです。そして、Worker NodeはPrivate Subnetに置いています。

つまり、kubeletがPOST /api/v1/nodes(Node登録API)を叩いてクラスターにJoinするには、NATが必要です。

今回、外部APIを叩くようなアプリケーションをdeployするわけではないので、VPC作成時にNATを配置した理由はここにあったというわけです。

それでは、記述していきます。

EKSクラスターのメインの記述

# terraform/main.tf
# eks cluster
module "eks" {
  source = "terraform-aws-modules/eks/aws"

  name               = "eks-sandbox"
  kubernetes_version = "1.33"

  vpc_id     = module.vpc.vpc_id
  subnet_ids = module.vpc.private_subnets

  endpoint_public_access = true

  create_iam_role = false

  addons = {
    coredns    = {}
    vpc-cni    = {}
    kube-proxy = {}
  }

  tags = {
    Name        = "eks-sandbox-cluster"
    Terraform   = "true"
    Environment = "dev"
  }
}
https://registry.terraform.io/modules/terraform-aws-modules/eks/aws/latest

moduleとしてterraform-aws-modules/eks/awsを使っていることには注目しておきましょう。

vpc_idにはクラスタが属するVPCのIDを、subnet_idsには、Worker Nodeの属するSubnetのID配列をおきます。

vpc_id     = module.vpc.vpc_id
subnet_ids = module.vpc.private_subnets

上で宣言したvpcmoduleのoutputにアクセスして設定しています。

以下では、クラスターをPublicエンドポイントで設定する記述をしています。ここは先述のとおりです。

endpoint_public_access = true

以下では、moduleで自動的にIAMロールを作成してくれる設定をfalseにしています。この辺は、ハンズオンということで、IAMロールに関しても理解するために自分で用意する方向で進めます。

create_iam_role = false

IAMロールは後ほど記述します。

以下では、アドオンでvpc-cniとkube-proxy、corednsをマネージドアドオンにする設定を行なっています。

addons = {
    coredns    = {}
    vpc-cni    = {}
    kube-proxy = {}
}

ちゃんとやるなら、{}ブロックにしてデフォルト設定に任せるよりは、明示的にversion指定をするのがいいかと思います。今回は省略します。

それぞれのコンポーネントについては、前章でも取り上げたとおりです。vpc-cniは、前章でいうKindnetに該当するCNIプラグインです。

そして、それらは組み込みアドオンとしてインストールされるようになっています。

https://docs.aws.amazon.com/ja_jp/eks/latest/userguide/eks-networking-add-ons.html

この設定を書くことで、上のコンポーネント管理をAWS EKSに任せられます。

https://docs.aws.amazon.com/ja_jp/eks/latest/userguide/eks-add-ons.html https://dev.classmethod.jp/articles/eks-built-in-networiking-addons/

また、組み込みのアドオンだとしても、明示的にこれらの設定を書いておくことで透明性が生まれ、わかりやすいと筆者は思います。

IAM Role

IAM(Identity Access Management)は、リソースへのアクセスを管理するためのものです。用語がいくつかありますが、一文で以下のように表せます。

  • プリンシパルが、IAMロールを引き受け ( = AssumeRole)、そのロールに付与されたポリシーに基づいてAWSリソースを操作できる
用語 説明
プリンシパル リソースにアクセスするユーザー・アプリケーション
IAMポリシー リソースへのアクセス権限を定義するJSONドキュメント
IAMロール 特定のプリンシパルが引き受けることができる権限の集合(ポリシーを持つ)
AssumeRole プリンシパルがSTSにリクエストし、IAMロールを引き受けるための一時的な認証情報を取得する操作

リソースは、IAMロールの権限なしには他リソースを操作できません。今回で言うと、EKSクラスターは

  • Worker NodeとしてEC2インスタンスを管理
  • ENI(Elastic Network Interface)を作成してEC2にアタッチ

など、他リソースへアクセスする必要があります。

そのため、EKSには必要なリソースを操作できるIAMロールをアタッチしてやる必要があります。

# IAM role for EKS Cluster
resource "aws_iam_role" "eks_cluster_role" {
  name               = "eks-cluster-role"
  assume_role_policy = data.aws_iam_policy_document.eks_cluster_assume_role_policy.json
}

data "aws_iam_policy_document" "eks_cluster_assume_role_policy" {
  statement {
    sid    = "AllowEKSClusterAssumeRole"
    effect = "Allow"
    principals {
      type        = "Service"
      identifiers = ["eks.amazonaws.com"]
    }
    actions = [
      "sts:AssumeRole"
    ]
  }
}

resource "aws_iam_role_policy_attachment" "eks_cluster_AmazonEKSClusterPolicy" {
  policy_arn = "arn:aws:iam::aws:policy/AmazonEKSClusterPolicy"
  role       = aws_iam_role.eks_cluster_role.name
}

dataブロックは、既存リソースや外部情報を読み取り、Terraform内のあらゆるリソース定義で参照されるデータソースを宣言します。

https://developer.hashicorp.com/terraform/language/block/data

上の記述では、

  1. IAMロール(eks_cluster_role)を用意
  2. IAMロールを引き受けることができるプリンシパルを定義した信頼ポリシー(eks_cluster_assume_role_policy)を定義し、eks_cluster_roleに設定
  3. IAMロールを引き受けて何ができるかの権限を定義したポリシー(eks_cluster_AmazonEKSClusterPolicy)をIAMロールにアタッチ

といったことを行なっています。

そして、これにより以下のことを実現します。

  • EKSがAssumeRoleができる
  • このロールによりAmazonEKSClusterPolicyで許可された操作ができる
https://docs.aws.amazon.com/ja_jp/aws-managed-policy/latest/reference/AmazonEKSClusterPolicy.html

利用したmodule, resourceのリファレンスを以下に記載します。

https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role.html https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment

最後に、eksmoduleにIAMロールを紐づけておきます。

module "eks" {
  # ...
  create_iam_role = false
  iam_role_arn    = aws_iam_role.eks_cluster_role.arn
  # ...
}

Access Entries

次に、kubectlコマンド等によってクラスターにアクセスできるユーザーを管理します。これには、EKS Access Entriesという機能を利用します。

https://docs.aws.amazon.com/ja_jp/eks/latest/eksctl/access-entries.html https://docs.aws.amazon.com/ja_jp/eks/latest/userguide/access-entries.html

これは、IAMユーザーとKubernetes RBACを結びつける機能のようです。RBACは、k8sの世界における、ロールベースで権限を管理する認可(authorization)の仕組みのことです。

EKSの設定のみでk8sクラスターの権限設定まで一気通貫で行えるのが便利とされています。

https://www.eksworkshop.com/ja/docs/security/cluster-access-management/understanding

それをTerraform上どう記述していくかというと、eks moduleでは、access_entriesというinputがあるのでそれを利用します。

https://registry.terraform.io/modules/terraform-aws-modules/eks/aws/latest#cluster-access-entry
# terraform/main.tf
# eks cluster
module "eks" {
# ...
  access_entries = {
    developer = {
      principal_arn     = var.developer_user_arn
      kubernetes_groups = []

      policy_associations = {
        cluster_admin = {
          policy_arn = "arn:aws:eks::aws:cluster-access-policy/AmazonEKSClusterAdminPolicy"
          access_scope = {
            type       = "cluster"
            namespaces = []
          }
        }
      }
    }
  }

# ...
}

# Variables for IAM user
variable "developer_user_arn" {
  description = "IAM user ARN for developer"
  type        = string
  default     = "arn:aws:iam::<YOUR_ACCOUNT_ID>:user/<YOUR_NAME>"
}

variableブロックは、その名の通り変数です。terraform apply実行時にdefault値を上書きして使うことも可能という特徴があります。

https://developer.hashicorp.com/terraform/language/block/variable

上の記述によって、開発者のIAMユーザー(= プリンシパル)をAccess Entryに登録し、そのユーザーにAmazonEKSClusterAdminPolicyを紐づけています。

AmazonEKSClusterAdminPolicyは、RBACにおけるcluster-adminにマッピングされます。

https://docs.aws.amazon.com/ja_jp/eks/latest/userguide/access-policy-permissions.html#access-policy-permissions-amazoneksadminpolicy

指定のIAMユーザーはEKSクラスターに対して、RBACでいうcluster-admin相当のKubernetes操作権限を持つようになり、kubectl経由でクラスター内のあらゆるリソースを作成・更新・削除できるようになるということですね。

cluster-adminというスーパー権限を持たせることは、最小権限の原則に反していますが、あくまでハンズオンで色々できるようにするためということでご了承ください。

RBACは「追加型」の権限設定であり、デフォルトはDenyとなります。そのため、明示的に権限が付与されていないIAMユーザー(またはロール)は、Kubernetes APIへの操作ができないということになります。

Kindクラスターの場合は、そういった認可制御を入れてなくてもkubectlができていました。これの理由はシンプルで、RBACの制御が有効でなかったというだけになります。


このセクションでEKSクラスタを配置しました。

3. EKS Node Group

Node Group

先では、EKSクラスターを用意しました。その中にはControle Planeも含まれています。次に、そのクラスターの中にWorker Nodeを配置していきましょう。

eks moduleの中でも、inputによってWorker Nodeを設定することができましたが、関心を分けておきたかったので別moduleに切り出しておくことにしました。

モジュール 役割
eks control plane
eks_node_group worker node

さて、Node Groupとは、k8sのPodを実行する複数のEC2インスタンスを、Auto Scaling Groupとしてまとめて管理する単位です。

https://docs.aws.amazon.com/ja_jp/eks/latest/userguide/managed-node-groups.html

Auto Acaling(ASG)は、EC2インスタンス数を自動で増減させるための仕組みです。

https://docs.aws.amazon.com/ja_jp/autoscaling/ec2/userguide/auto-scaling-groups.html

そして、そもそもk8sにはCluster Autoscalerというコンポーネントがあり、Pending状態のPodを検知すると、Cloud ProviderのAPIを操作してNodeを増減させ、Podを配置できるようにします。

https://kubernetes.io/ja/docs/concepts/cluster-administration/node-autoscaling/#autoscalers https://github.com/kubernetes/autoscaler/tree/master/cluster-autoscaler

EKSにおいてk8sのCluster Autoscallerは、AWSのAuto Scalling Groupの仕組みを用いてNodeのスケーリングを実現するということになります。以下の図がまとまっていてわかりやすいです。

https://docs.aws.amazon.com/ja_jp/eks/latest/best-practices/cas.html

以下に記述していきます。

# terraform/main.tf
# node group
module "eks_node_group" {
  source = "terraform-aws-modules/eks/aws//modules/eks-managed-node-group"

  name         = "eks-sandbox-node-group"
  cluster_name = module.eks.cluster_name

  kubernetes_version = "1.33"

  subnet_ids   = module.vpc.private_subnets
  desired_size = 1
  max_size     = 2
  min_size     = 1

  instance_types = ["t3.micro"]

  cluster_service_cidr = module.eks.cluster_service_cidr

  cluster_primary_security_group_id = module.eks.cluster_primary_security_group_id

  tags = {
    Name        = "eks-sandbox-node-group"
    Terraform   = "true"
    Environment = "dev"
  }
}

もちろん、Nodeはプライベートサブネットにおくようにしています。

subnet_ids   = module.vpc.private_subnets

以下はNode数のスケーリング設定です。金額を抑えたいので基本1台にしておきます。

desired_size = 1  # 通常(期待値)
max_size     = 2  # 最大
min_size     = 1  # 最低

Nodeのインスタンスタイプの設定は、t3.microとします。こちらも、コストカットのため高性能を求めず、できるだけしょぼいスペックにします。

instance_types = ["t3.micro"]
https://aws.amazon.com/jp/ec2/instance-types/t3/

t3.nanoではダメだった

上のAWSのサイトの表を見てもらうと、一番性能が低く安く済ませられるのはt3.nanoです。筆者もこのハンズオン教材を自分で構築している当初はこのインスタンスタイプにしていました。

しかし、実はこれだとEKSは一向に起動が完了せず、以下のようにNodeがClusterに参加できないというエラーが発生しました。

│ Error: waiting for EKS Node Group (eks-sandbox:eks-sandbox-node-group-2025120610543551860000000d) create: unexpected state 'CREATE_FAILED', wanted target 'ACTIVE'. last error: i-066aec86617f34fb6: NodeCreationFailure: Instances failed to join the kubernetes cluster

実は、この問題はEC2の仕様とkubeletの仕様が絡み合っていました。

  • EC2のメモリが800MB未満である場合、デフォルトでswapを有効にする
  • kubeletはデフォルトで--fail-swap-on = trueで起動しており、Nodeでswapが有効であれば失敗する

t3.nanoは500MBなので、デフォルトでswapが有効になり、そのせいでkubeletがエラーを吐く、といった感じになってたんですね。以下はNodeに入って出力してみたkubeletのログです。

sudo journalctl -u kubelet | tail -50

E1206 12:17:04 ip-10-0-3-36.ap-northeast-1.compute.internal kubelet[3290]: E1206 12:17:04.405473    3290 run.go:72] "command failed" err="failed to run Kubelet: running with swap on is not supported, please disable swap or set --fail-swap-on flag to false"

RAM使用率をもとにPodをスケールさせたいのに、swapによってメモリ不足が隠されてしまい、kubeletやHPAによる制御が遅れたり不正確になるという相性の悪さがあるのかなという理解をしています。

以下では、EKSのControl PlaneとNode(EC2)が通信できるようにするためのSecurity Groupを渡しています。 Security Groupについてはこの後で説明します。

cluster_primary_security_group_id = module.eks.cluster_primary_security_group_id

Security Group

Security Groupは、リソースに紐づけることができる「通信ルールのまとまり」です。よく仮想のファイアウォールだとも表現されます。

通信ルールには2種類あります。

  • Inbound
    • 外→内の通信。Igressともいう
  • Outboud
    • 内→外の通信。Egressともいう。

例えば、Security Group Aに、

  • Security Group Bからの通信を許可
  • Security Group Aから発する通信は制限しない

ルールを付与した場合、以下のようなイメージです。

以下の記述で、次のようなルールを持つSecurity GroupをNode Groupに付与します。

  • Inbound Rule:
    • ALBのSecurity Groupから、Nodeの30000〜32767ポートへの通信を許可する
  • Outbound Rule:
    • Nodeから発する通信は全て許可
# terraform/main.tf
module "eks_node_group" {
  # ...
  create_security_group = true
  security_group_ingress_rules = {
    alb_nodeport = {
      description                  = "Allow ALB to communicate with Node Group"
      from_port                    = 30000
      to_port                      = 32767
      protocol                     = "tcp"
      referenced_security_group_id = aws_security_group.alb_sg.id
    }
  }

  security_group_egress_rules = {
    all = {
      description = "Allow all outbound traffic"
      from_port   = 0
      to_port     = 0
      protocol    = "-1" // 全てのprotocolを許可。ref: https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/vpc_security_group_ingress_rule#ip_protocol-1
      cidr_ipv4   = "0.0.0.0/0" // 全てのアドレス範囲
    }
  }
  # ...
}

ALBに関しては後述します。そもそもALBのSecurity Groupは宣言してないのでまだ存在しませんが、aws_security_group.alb_sg.idと書いておいてください。

また、30000〜32767はk8sのNodePortのデフォルト範囲です。やや広いですが、ハンズオンとしては一旦これでよしとします。

IAMロール

同様、Node GroupにもIAMロールを紐づけていきます。最低限、EKSでNodeが動作するために必要なIAMロールがドキュメントに書いてあるので、それを書いていきます。

https://docs.aws.amazon.com/ja_jp/eks/latest/userguide/create-node-role.html
# terraform/main.tf
module "eks_node_group" {
  # ...
  create_iam_role = false
  iam_role_arn    = aws_iam_role.eks_node_group_role.arn
  # ...
}

# IAM role for EKS Node Group
resource "aws_iam_role" "eks_node_group_role" {
  name               = "eks-node-group-role"
  assume_role_policy = data.aws_iam_policy_document.eks_node_group_assume_role_policy.json
}

data "aws_iam_policy_document" "eks_node_group_assume_role_policy" {
  statement {
    sid    = "AllowEKSNodeGroupAssumeRole"
    effect = "Allow"
    principals {
      type        = "Service"
      identifiers = ["ec2.amazonaws.com"]
    }
    actions = [
      "sts:AssumeRole"
    ]
  }
}

resource "aws_iam_role_policy_attachment" "eks_node_group_AmazonEKSWorkerNodePolicy" {
  policy_arn = "arn:aws:iam::aws:policy/AmazonEKSWorkerNodePolicy"
  role       = aws_iam_role.eks_node_group_role.name
}
resource "aws_iam_role_policy_attachment" "eks_node_group_AmazonEC2ContainerRegistryPullOnly" {
  policy_arn = "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly"
  role       = aws_iam_role.eks_node_group_role.name
}
resource "aws_iam_role_policy_attachment" "eks_node_group_AmazonEKS_CNI_Policy" {
  policy_arn = "arn:aws:iam::aws:policy/AmazonEKS_CNI_Policy"
  role       = aws_iam_role.eks_node_group_role.name
}
resource "aws_iam_role_policy_attachment" "eks_node_group_ssm" {
  policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
  role       = aws_iam_role.eks_node_group_role.name
}

このセクションでは、Node Groupを配置しました。

4. ALB

続いて、ALB(Application Load Balancer)を配置します。

https://docs.aws.amazon.com/ja_jp/elasticloadbalancing/latest/application/introduction.html

ALBは、アプリケーション層(L7)でのルーティングが可能なリバースプロキシ型のロードバランサです。

用語を整理します。

  • L7ルーティング
    • OSI参照モデルの7層目 = アプリケーション層
    • データ通信の最上レイヤー
    • API呼び出しはこのレイヤーに属すると考えれば良い
      • 通信プロトコルはHTTPなど
  • リバースプロキシ
    • 複数以上のアップストリームサーバー( = 主となるアプリケーション・バックエンドサーバー)の前段に立つ
      • 複数のサーバーへの負荷分散 / ルーティングを行う
      • また、主サーバーを保護できるなどのメリットも。
    • nginxなどもこれに該当
  • ロードバランサ
    • 負荷の偏りを防ぐ(スケーラビリティ向上)
    • 障害時の可用性向上
      • ヘルスチェックを行っており、正常なアップストリームサーバーにのみリクエストを流す

こちらも提供されている公式moduleを利用します。https://registry.terraform.io/modules/terraform-aws-modules/alb/aws/latest

# terrafrom/main.tf
module "alb" {
  source = "terraform-aws-modules/alb/aws"

  name    = "eks-sandbox-alb"
  vpc_id  = module.vpc.vpc_id
  subnets = module.vpc.public_subnets

  enable_deletion_protection = false # ハンズオン用なのでALBを削除できるようにする

  tags = {
    Name        = "eks-sandbox-alb"
    Terraform   = "true"
    Environment = "dev"
  }
}

ポイントとしては、ALBリソースをパブリックサブネットに置いていることです。

subnets = module.vpc.public_subnets

Node Groupをプライベートに置いていたので、インターネットには直接アクセスできません。そのため、パブリックサブネットに配置したALBを入口として、そこから内部のNodeへルーティングするようにします。

リスナールール

リスナールールは、リクエストの条件(パスやホスト名など)に基づいて、適切なターゲットグループへルーティングを行います。

今回は、

  • ALBに到達したHTTP:80のリクエストを指定のターゲットグループにルーティングする

というふうにしたいと思います。リスナールールはL7を見るコンポーネントなので、ドメイン名やパスでも制御は可能です。が、今回は独自ドメインも発行しませんし、デプロイするサービスも1つなので、単純にすべてのリクエストを単一のターゲットグループへ転送するシンプルな構成とします。

https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lb_listener
# terraform/main.tf
resource "aws_lb_listener" "http" {
  load_balancer_arn = module.alb.arn
  port              = 80
  protocol          = "HTTP"
  default_action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.alb_tg_to_ng.arn # 後ほど設定
  }
}

httpはデフォルトで80ポートを使用します。そのため、この設定はHTTP(ポート80)での通信を受け付けるリスナーを定義していることになります。

今回は、単にHTTP通信を受け入れ指定のターゲットグループへ渡すという記述をしています。

他にも、ALBはL7ルーティングということで

  • パスベースルーティング
  • ホストベースルーティング
  • HTTPヘッダベースルーティング

といったようなルーティング設定が可能です。

ターゲットグループ

ターゲットグループは、リスナールールによってルーティングされる宛先(= ターゲット)の集合体です。

今回用意しているアプリケーションサーバーは、k8sのNodePort Serviceによって、Worker Node(= EC2)の30080ポートを開いていました。

そのため、「EC2インスタンスの30080ポートにHTTPリクエストを転送する」というターゲットグループを設定します。

https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lb_target_group
# terraform/main.tf
resource "aws_lb_target_group" "alb_tg_to_ng" {
  name        = "eks-sandbox-alb-tg"
  port        = 30080
  protocol    = "HTTP"
  target_type = "instance"
  vpc_id      = module.vpc.vpc_id

  health_check {
    path                = "/healthz"
    protocol            = "HTTP"
    matcher             = "200-399"
    interval            = 30
    timeout             = 5
    healthy_threshold   = 2
    unhealthy_threshold = 2
  }

  tags = {
    Name        = "eks-sandbox-alb-tg"
    Terraform   = "true"
    Environment = "dev"
  }
}

また、その宛先ターゲットグループに対するヘルスチェック設定を記述しています。Node A, Bの2つがあったとして、Node Aのヘルスチェックが失敗したとします。すると、Node Aはターゲットグループのルーティング対象から除外されるようになります。

ちなみに、アプリケーションコードにヘルスチェック用エンドポイントは設定していました。

// app/main.go
func main() {
    // 省略
   mux.Handle("GET /healthz", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusOK)
	}))

	http.ListenAndServe(":8080", accessLogMiddleware(mux))
}

これで、ターゲットグループの設定ができました。

そしてさらに、ターゲットグループにはターゲットの実体を「登録」しておく必要があります。

https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/autoscaling_attachment
# terraform/main.tf
# Attach ALB target group to ASG of EKS Node Group
resource "aws_autoscaling_attachment" "eks_node_group_attachment" {
  autoscaling_group_name = module.eks_node_group.node_group_autoscaling_group_names[0]
  lb_target_group_arn    = aws_lb_target_group.alb_tg_to_ng.arn
}

上の記述では、Auto Scaling Group(=Node Group)をターゲットグループに紐づけています。これにより、Auto Scaling Groupに属するEC2インスタンス(Node)がターゲットとして登録されます。

Security Group

ALBの属するセキュリティグループを作成し、Inbound/Outboundルールを設定します。

  • Inbound Rule:
    • 80ポートのhttp通信を受け入れる
  • Outbound Rule:
    • どこにでもリクエストしてOK

最小限にするなら、Outboundの方は、ポート30080番に絞るのが適切かもしれません

# terraform/main.tf
# ALB
module "alb" {
  # ...
  security_groups = [aws_security_group.alb_sg.id]
  # ...
}

# Security Group for ALB
resource "aws_security_group" "alb_sg" {
  name        = "alb-sg"
  description = "Security group for ALB"
  vpc_id      = module.vpc.vpc_id

  ingress {
    description = "Allow HTTP traffic"
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  egress {
    description = "Allow all outbound traffic"
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = {
    Name        = "eks-sandbox-alb-sg"
    Terraform   = "true"
    Environment = "dev"
  }
}

おさらいですが、Node Groupの方のSecurity Groupでは、ALBのSecurity Groupを参照してInbound Ruleを設定していました。これにより、ALB➡︎Node Groupの通信経路が許可されるようになっています。

# node group
module "eks_node_group" {
  # ...
  security_group_ingress_rules = {
    alb_nodeport = {
      description                  = "Allow ALB to communicate with Node Group"
      from_port                    = 30000
      to_port                      = 32767
      protocol                     = "tcp"
      referenced_security_group_id = aws_security_group.alb_sg.id
    }
  }
  # ...
}

このセクションで、ALBを配置しました。

5. ECR

ここまででEKSで動くための環境があらかたできています。

このセクションでは、ECR(Elastic Container Registry)を利用するための記述をします。 ECRは、コンテナイメージを保存/管理するためのレジストリです。

EKSのWorker Node内で動くPodの中で動くコンテナは、kubeletによってECRからPullしたコンテナイメージを参照して実行されます。

AmazonEC2ContainerRegistryPullOnlyのIAMポリシーをNode Groupにつけていましたが、これによって、kubelet➡︎ECRへの操作が許可されます。

https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ecr_repository
# terraform/main.tf
# ecr repository
resource "aws_ecr_repository" "ecr_repository" {
  name = "eks-sandbox-ecr-repository"

  image_scanning_configuration {
    scan_on_push = true # pushされたコンテナイメージを自動で脆弱性チェック
  }

  force_delete = true # ハンズオン用に、イメージが残っていてもリポジトリを削除可能に

  tags = {
    Name        = "eks-sandbox-ecr-repository"
    Terraform   = "true"
    Environment = "dev"
  }
}

ECRは構築したVPC外のマネージドサービスであり、リージョンごとに存在します。

6. VPC Endpoint

VPC Endpointは、構築したVPC内➡︎AWSマネージドサービスへプライベート接続するためのものです。

https://docs.aws.amazon.com/ja_jp/vpc/latest/privatelink/create-interface-endpoint.html https://docs.aws.amazon.com/ja_jp/vpc/latest/privatelink/what-is-privatelink.html

今回でいえば、ECRがそれに当たります。

もちろん、NAT & Internet Gatewayを用意しているので、インターネット経由でアクセスすることはできます。が、VPC Endpointを使うことで通信がAWS内部で完結するのでその点でセキュアだと言えます。

VPC Endpointは2種類あり、

  • インターフェース型
    • AWS PrivateLinkという、VPCの中から、インターネットを使わずにAWSサービスや他のVPCサービスへプライベートIP経由で直接つなぐことができるネットワーク基盤を利用したインターフェース。
    • 実体としては、プライベート接続するためのENIを作成するイメージ
  • ゲートウェイ型
    • VPC内のルートテーブルに、AWSサービス宛のトラフィックをAWS内部ネットワークに流す設定をする
    • インターネット(NAT/IGW)を経由せず、ルート制御によってAWS内部経路へ振り分けるイメージ
    • S3 / DynamoDBのみ
https://dev.classmethod.jp/articles/vpc-endpoint-gateway-type/ https://dev.classmethod.jp/articles/aws-vpcendpoint-privatelink-beginner/ https://qiita.com/miyuki_samitani/items/9d9f7a0c417cb75a6c85

とにかく、VPC Endpointを立てれば、AWSサービスにインターネットを経由せずにアクセス可能だと理解すればいいと思います。

EKS Controle Planeも一応、AWSマネージドなリソースでしたが、こちらはVCP Endpoint経由でのアクセスは不可能です。

Controle Planeのkube-api-serverは、単なるk8s クラスター管理のためのAPIサーバーであり、AWSとして公開されているサービスではありません。ただマネージドであるというだけです。そのため、PrivateLinkの対象として提供されておらず、VPC Endpoitは利用できないのではないかと筆者は理解しています。

以下で記述していきます。

https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/vpc_endpoint.html
# terraform/main.tf
# vpc endpoint for accessing ECR from private subnet
resource "aws_vpc_endpoint" "vpc_endpoint_for_ecr_api" {
  vpc_id             = module.vpc.vpc_id
  service_name       = "com.amazonaws.ap-northeast-1.ecr.api"
  vpc_endpoint_type  = "Interface"
  subnet_ids         = module.vpc.private_subnets
  security_group_ids = [aws_security_group.vpc_endpoint_sg.id] # 後に設定

  private_dns_enabled = true

  tags = {
    Name        = "eks-sandbox-ecr-api-endpoint"
    Terraform   = "true"
    Environment = "dev"
  }
}

resource "aws_vpc_endpoint" "vpc_endpoint_for_ecr_dkr" {
  vpc_id             = module.vpc.vpc_id
  service_name       = "com.amazonaws.ap-northeast-1.ecr.dkr"
  vpc_endpoint_type  = "Interface"
  subnet_ids         = module.vpc.private_subnets
  security_group_ids = [aws_security_group.vpc_endpoint_sg.id] # 後に設定

  private_dns_enabled = true

  tags = {
    Name        = "eks-sandbox-ecr-dkr-endpoint"
    Terraform   = "true"
    Environment = "dev"
  }
}

# vpc endpoint for accessing S3 from private subnet
resource "aws_vpc_endpoint" "vpc_endpoint_for_s3" {
  vpc_id            = module.vpc.vpc_id
  service_name      = "com.amazonaws.ap-northeast-1.s3"
  vpc_endpoint_type = "Gateway"
  route_table_ids   = module.vpc.private_route_table_ids

  tags = {
    Name        = "eks-sandbox-s3-endpoint"
    Terraform   = "true"
    Environment = "dev"
  }
}

ECR操作のために3つのVPC Endpointを設定しています。

ECRへのPrivateLink接続をするためには、上の3つのVPC Endpointが必要だということが書かれています。

Amazon EC2 インスタンスでホストされる Amazon ECS タスクの場合、Amazon ECR エンドポイントと Amazon S3 ゲートウェイエンドポイントの両方が必要です。

https://docs.aws.amazon.com/ja_jp/AmazonECR/latest/userguide/vpc-endpoints.html https://dev.classmethod.jp/articles/vpc-endpoints-for-ecs-2022/

Security Group

VPC内ならどこからでもVPC Endpointへの通信を受け付けるようにしておきます。

# terraform/main.tf
# Security Group for VPC Endpoints
resource "aws_security_group" "vpc_endpoint_sg" {
  name        = "vpc-endpoint-sg"
  description = "Security group for VPC endpoints"
  vpc_id      = module.vpc.vpc_id

  ingress {
    description = "Allow HTTPS from VPC"
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = [module.vpc.vpc_cidr_block]
  }

  egress {
    description = "Allow all outbound"
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = {
    Name        = "vpc-endpoint-sg"
    Terraform   = "true"
    Environment = "dev"
  }
}

また、HTTPS接続のみを許可する必要があるとドキュメントに記載があります。

VPC エンドポイントにアタッチされたセキュリティグループでは、VPC のプライベートサブネットから、ポート 443 で着信接続を許可する必要があります。

https://docs.aws.amazon.com/ja_jp/AmazonECR/latest/userguide/vpc-endpoints.html

S3 VPC EndpointにはSecurity Groupをアタッチできない

記述を見てもらえるとわかりますが、3つのVPC Endpointのうちvpc_endpoint_for_s3のみ、Security Groupをアタッチしていません。これは、S3のVPC Endpointがゲートウェイ型だからです。

対するインターフェース型だと、実体としてENIが作成されます。そして、Security GroupはENIに紐づくものです。

ゲートウェイ型は、ルートテーブルによって経路変更をしているだけなので、Security Groupを紐づけることができないといった感じになります。

このハンズオンでは行っていませんが、ゲートウェイ型だとIAM・バケットポリシーでアクセス制御を行う設計になっているようです


このセクションでは、VPC Endpointを設定しました。


これで、最低限のEKS環境を構築するための記述が終わりました。次章では、実際にEKSクラスターを起動させてみます。

⚠️次章では料金が発生します