APC 技術ブログ

株式会社エーピーコミュニケーションズの技術ブログです。

株式会社 エーピーコミュニケーションズの技術ブログです。

Terraformを使ってEKSを構築する

始めに

先進サービス開発事業部の山岡です。

Terraformを使ったEKSの構築を試してみる機会があったのでEKSに関する解説とコード、注意が必要と思ったポイントについて書きたいと思います。

EKSの解説

EKSは何をしてくれるのか?

EKSはKubernetesのマスターノードを管理してくれるサービスです。一度やってみるとすぐわかるかと思いますが、自分でマスターを構築・運用するのはかなりの手間でありそれを省くことができます。また、AWSの各種サービスと連携させることができるため、その作り込みに必要な手間も削減することができます。

EKSでマスターを作成するとkubectlで使用する認証情報、マスターのエンドポイントが発行され、内部の実体がどのようなものであるかは隠蔽された形になっています。感覚としてはRDSの扱いに近いでしょう(EKSの場合インスタンスサイズやディスク容量の指定はありませんが)。よってetcdを直接叩いたり、その他コンポーネントの設定を編集することはできません。

EKSは何をしてくれないのか?

基本的には「Kubernetesのマスターに関すること以外は自分で管理する必要がある」と考えれば問題ありません。但しKubernetesの概念と密接に絡む部分については公式から便利なものが提供されていたり、またある程度Kubernetesのリソースとして連携することができます。

公式からの支援の例としてはワーカーノードがあります。ワーカーノードは基本的に自分で構築・管理する必要があり、Kubernetesのワーカーとしての条件を満たす(Kubeletが動く、他)EC2インスタンスを用意し、これをEKSのワーカーとして認識させなければなりません。詳細な手順は公式ドキュメント及び本記事の後半をご覧頂きたいのですが、AWS公式でEKS最適化AMIが配布されていますので自分でゼロから作成する必要はありません。EKS最適化AMIには一般用途の他にGPUをサポートするAMIもありますので、Kubernetes上で機械学習の処理をGPUにオフロードするといった用途にも活用することができるようです(今回はテストしていません)。

リソースと連携されるものについてはロードバランサーや永続化領域が良い例になるかと思います。実例としては後日書きますが spec.type: LoadBalancer をデプロイすることでELBが起動しトラフィックフロー組み込まれたり、StorageClassに provisioner: kubernetes.io/aws-ebs を定義しEBSをベースにしたPersitentVolumeを動的なサイズで発行したりといったことができます。

ネットワーク構成

各パブリッククラウドは似てはいるもののそれぞれ異なる仕様のネットワークになっているため、Kubernetesを使う上でもそこを考慮する必要があります。

EKSは起動時にどのサブネットに所属するか指定する必要があり、当然のことながら複数のAZを使うように構成しなければ可用性が得られません(無理にやろうとしても Subnets specified must be in at least two different AZs とエラーが出るのでテスト目的でも複数必要です)。またこのVPCはDNSによるホスト名・名前解決ができるものでなければなりませんが必ずしもPublicである必要はありません。

Podのネットワークについては、AWS向けのCNI pluginを使うことによりVPCのアドレスをPodに割り当て通信することができるようになります。通常のKubernetesではIngressやNodePortを使わないと外部からアクセスすることはできませんが、この仕組みであれば通常のEC2インスタンスと同じようなやり方でPodにアクセスすることが可能です。

AWSに由来する制約事項

EC2にはインスタンスサイズごとに「割り当てられるENIの数」と「ENIごとに割り当てられるIPアドレスの数」という制限があります(公式資料)。これによりいくらCPU/メモリ等のリソースに余裕があったとしても少数のインスタンスで大量のコンテナを立ち上げることはできません。もし一定数以上コンテナが起動しない、というトラブルが起きた場合はこの制限が原因である可能性が大です。

例えばt2.smallであれば Maximum Network Interfaces: 3 × IPv4 Addresses per Interface: 4インスタンス自身のIPアドレス: 1 の11個が最大数となります。あまり小さいインスタンスを使うといざスケールアウトしようとした時にIPアドレス不足で失敗する可能性があるので、それも見越した上でのスケーリング戦略が必要です。

その他注意

  • 本記事執筆の時点では下記4リージョンにしか対応しておらず東京では利用できません
    • us-east-1 (ノースバージニア)
    • us-east-2 (オハイオ)
    • us-west-2 (オレゴン)
    • eu-west-1 (アイルランド)

Terraformファイルの解説

コードはGitHubのリポジトリにもありますので、実際に試してみる場合はこちらをcloneすると楽かと思います。

VPC

クリックすると展開されます

resource "aws_vpc" "test-k8s-eks" {
  cidr_block       = "10.5.0.0/16"
  instance_tenancy = "default"

  tags {
    Name = "test-k8s-eks"
  }
}

resource "aws_subnet" "public-2a" {
  vpc_id                  = "${aws_vpc.test-k8s-eks.id}"
  cidr_block              = "10.5.0.0/24"
  availability_zone       = "us-west-2a"
  map_public_ip_on_launch = true

  tags {
    Name = "eks-public-2a"
  }
}

resource "aws_subnet" "public-2b" {
  vpc_id                  = "${aws_vpc.test-k8s-eks.id}"
  cidr_block              = "10.5.2.0/24"
  availability_zone       = "us-west-2b"
  map_public_ip_on_launch = true

  tags {
    Name = "eks-public-2b"
  }
}

resource "aws_subnet" "public-2c" {
  vpc_id                  = "${aws_vpc.test-k8s-eks.id}"
  cidr_block              = "10.5.4.0/24"
  availability_zone       = "us-west-2c"
  map_public_ip_on_launch = true

  tags {
    Name = "eks-public-2c"
  }
}

resource "aws_subnet" "private-2a" {
  vpc_id            = "${aws_vpc.test-k8s-eks.id}"
  cidr_block        = "10.5.1.0/24"
  availability_zone = "us-west-2a"

  tags {
    Name = "eks-private-2a"
  }
}

resource "aws_subnet" "private-2b" {
  vpc_id            = "${aws_vpc.test-k8s-eks.id}"
  cidr_block        = "10.5.3.0/24"
  availability_zone = "us-west-2b"

  tags {
    Name = "eks-private-2b"
  }
}

resource "aws_subnet" "private-2c" {
  vpc_id            = "${aws_vpc.test-k8s-eks.id}"
  cidr_block        = "10.5.5.0/24"
  availability_zone = "us-west-2c"

  tags {
    Name = "eks-private-2c"
  }
}

resource "aws_internet_gateway" "test-k8s-eks-igw" {
  vpc_id = "${aws_vpc.test-k8s-eks.id}"
}

resource "aws_route_table" "public-route" {
  vpc_id = "${aws_vpc.test-k8s-eks.id}"

  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = "${aws_internet_gateway.test-k8s-eks-igw.id}"
  }
}

resource "aws_route_table" "private-route" {
  vpc_id = "${aws_vpc.test-k8s-eks.id}"
}

resource "aws_route_table_association" "public-2a" {
  subnet_id      = "${aws_subnet.public-2a.id}"
  route_table_id = "${aws_route_table.public-route.id}"
}

resource "aws_route_table_association" "public-2b" {
  subnet_id      = "${aws_subnet.public-2b.id}"
  route_table_id = "${aws_route_table.public-route.id}"
}

resource "aws_route_table_association" "public-2c" {
  subnet_id      = "${aws_subnet.public-2c.id}"
  route_table_id = "${aws_route_table.public-route.id}"
}

resource "aws_route_table_association" "private-2a" {
  subnet_id      = "${aws_subnet.private-2a.id}"
  route_table_id = "${aws_route_table.private-route.id}"
}

resource "aws_route_table_association" "private-2b" {
  subnet_id      = "${aws_subnet.private-2b.id}"
  route_table_id = "${aws_route_table.private-route.id}"
}

resource "aws_route_table_association" "private-2c" {
  subnet_id      = "${aws_subnet.private-2c.id}"
  route_table_id = "${aws_route_table.private-route.id}"
}

これといって特別な構成ではありません。テスト用なので具体的な要件はなく、各AZにそれぞれPublic(インターネット疎通可)とPrivate(インターネット疎通不可)のSubnetを2つずつ、合計6つ作成しています。DBが必要ならRDSをPrivateなSubnetにデプロイし、アプリケーションのPodをPrivateにデプロイすることを想定しています。

IAM Role

クリックすると展開されます

resource "aws_iam_role" "eks-master-role" {
  name = "eks-master-role"

  assume_role_policy = <<EOS
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Action": "sts:AssumeRole",
      "Principal": {
        "Service": "eks.amazonaws.com"
      },
      "Effect": "Allow"
    }
  ]
}
EOS
}

resource "aws_iam_role_policy_attachment" "eks-cluster-policy" {
  policy_arn = "arn:aws:iam::aws:policy/AmazonEKSClusterPolicy"
  role       = "${aws_iam_role.eks-master-role.name}"
}

resource "aws_iam_role_policy_attachment" "eks-service-policy" {
  policy_arn = "arn:aws:iam::aws:policy/AmazonEKSServicePolicy"
  role       = "${aws_iam_role.eks-master-role.name}"
}

resource "aws_iam_role" "eks-node-role" {
  name = "eks-node-role"

  assume_role_policy = <<EOS
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Action": "sts:AssumeRole",
      "Principal": {
        "Service": "ec2.amazonaws.com"
      },
      "Effect": "Allow"
    }
  ]
}
EOS
}

resource "aws_iam_role_policy_attachment" "eks-node-role-worker-policy" {
  policy_arn = "arn:aws:iam::aws:policy/AmazonEKSWorkerNodePolicy"
  role       = "${aws_iam_role.eks-node-role.name}"
}

resource "aws_iam_role_policy_attachment" "eks-node-role-cni-policy" {
  policy_arn = "arn:aws:iam::aws:policy/AmazonEKS_CNI_Policy"
  role       = "${aws_iam_role.eks-node-role.name}"
}

resource "aws_iam_role_policy_attachment" "eks-node-role-ecs-policy" {
  policy_arn = "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly"
  role       = "${aws_iam_role.eks-node-role.name}"
}

resource "aws_iam_instance_profile" "eks-node-role-profile" {
  name = "eks-node-role-profile"
  role = "${aws_iam_role.eks-node-role.name}"
}

マスター用、ワーカー用のロールを作成しています。

マスターには "arn:aws:iam::aws:policy/AmazonEKSClusterPolicy""arn:aws:iam::aws:policy/AmazonEKSServicePolicy" 2つのポリシーを割り当てる必要があります。また "Service": "eks.amazonaws.com" に対する信頼が無いとEKSからこのロールを参照することができませんのでこれも忘れずに設定しましょう。

ワーカー用のロールには "arn:aws:iam::aws:policy/AmazonEKSWorkerNodePolicy", "arn:aws:iam::aws:policy/AmazonEKS_CNI_Policy", "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly" が必要です。こちらには "Service": "ec2.amazonaws.com" への信頼が無いと上手く動かないので設定しておきます。

EKS本体

クリックすると展開されます

locals {
  cluster_name = "test-eks-master"

  kubeconfig = <<KUBECONFIG
apiVersion: v1
clusters:
- cluster:
    server: ${aws_eks_cluster.test-eks-master.endpoint}
    certificate-authority-data: ${aws_eks_cluster.test-eks-master.certificate_authority.0.data}
  name: kubernetes
contexts:
- context:
    cluster: kubernetes
    user: aws
  name: aws
current-context: aws
kind: Config
preferences: {}
users:
- name: aws
  user:
    exec:
      apiVersion: client.authentication.k8s.io/v1alpha1
      command: aws-iam-authenticator
      args:
        - "token"
        - "-i"
        - "${local.cluster_name}"
KUBECONFIG

  eks_configmap = <<CONFIGMAPAWSAUTH
apiVersion: v1
kind: ConfigMap
metadata:
  name: aws-auth
  namespace: kube-system
data:
  mapRoles: |
    - rolearn: ${aws_iam_role.eks-node-role.arn}
      username: system:node:{{EC2PrivateDNSName}}
      groups:
        - system:bootstrappers
        - system:nodes
CONFIGMAPAWSAUTH
}

resource "aws_eks_cluster" "test-eks-master" {
  name     = "${local.cluster_name}"
  role_arn = "${aws_iam_role.eks-master-role.arn}"

  vpc_config {
    security_group_ids = ["${aws_security_group.cluster-master.id}"]

    subnet_ids = [
      "${aws_subnet.public-2a.id}",
      "${aws_subnet.public-2b.id}",
      "${aws_subnet.public-2c.id}",
    ]
  }

  depends_on = [
    "aws_iam_role_policy_attachment.eks-cluster-policy",
    "aws_iam_role_policy_attachment.eks-service-policy",
  ]
}

locals.kubeconfig ですが、これはEKSの起動完了後に認証に必要な情報( ~/.kube/config )に必要な情報を出力するために必要です。

locals.eks_configmap は起動後に権限の関係で適用しなければならないConfigMapを出力します(後述)。

見ての通り、EKS自体に必要な設定は名前、割り当てるロール、サブネット程度なので特に問題は無いと思います。 depends_on を使うことでIAM Policyの作成後にEKSを起動するように設定していますが、これは私が試した限りでは3回に1回くらいはIAM Policyが無いというエラーで立ち上がらない事象が発生したのでその防止のためです。

Autoscaling Group

クリックすると展開されます

resource "aws_autoscaling_group" "eks-asg" {
  name                 = "EKS worker group"
  desired_capacity     = 3
  launch_configuration = "${aws_launch_configuration.eks-lc.id}"
  max_size             = 3
  min_size             = 3

  vpc_zone_identifier = [
    "${aws_subnet.public-2a.id}",
    "${aws_subnet.public-2b.id}",
    "${aws_subnet.public-2c.id}",
  ]

  tag {
    key                 = "Name"
    value               = "eks-worker"
    propagate_at_launch = true
  }

  tag {
    key                 = "kubernetes.io/cluster/${local.cluster_name}"
    value               = "owned"
    propagate_at_launch = true
  }

  lifecycle {
    create_before_destroy = true
  }
}

ひとまず検証ということで3台のインスタンスが起動するようになっており、AZが3つあるため各AZに1台ずつインスタンスが起動してくるはずです。もし必要があれば desired_capacity max_size min_size を調整することでインスタンスの数を変更することができますが、今回はCloudWatchの設定を入れていないため全て同じ数に設定するのが基本になるかと思います。

min_size を下回ると新たにインスタンスが立ち上がって来るのでこれによりオートヒール的な挙動で可用性を維持することができます。

Launch Config

クリックすると展開されます

locals {
  ami_id        = "ami-0a54c984b9f908c81" # us-west-2, EKS Kubernetes Worker AMI with AmazonLinux2
  instance_type = "t2.medium"
  key_name      = "your-eks-worker-key" # AWS SSH key
  volume_type   = "gp2"
  volume_size   = 50

  userdata = <<USERDATA
#!/bin/bash
set -o xtrace
/etc/eks/bootstrap.sh --apiserver-endpoint "${aws_eks_cluster.test-eks-master.endpoint}" --b64-cluster-ca "${aws_eks_cluster.test-eks-master.certificate_authority.0.data}" "${aws_eks_cluster.test-eks-master.name}"
USERDATA
}

resource "aws_launch_configuration" "eks-lc" {
  associate_public_ip_address = true
  iam_instance_profile        = "${aws_iam_instance_profile.eks-node-role-profile.id}"
  image_id                    = "${local.ami_id}"
  instance_type               = "${local.instance_type}"
  name_prefix                 = "eks-node"
  key_name                    = "${local.key_name}"
  enable_monitoring           = false
  spot_price                  = 0.0464

  root_block_device {
    volume_type = "${local.volume_type}"
    volume_size = "${local.volume_size}"
  }

  security_groups  = ["${aws_security_group.cluster-nodes.id}"]
  user_data_base64 = "${base64encode(local.userdata)}"

  lifecycle {
    create_before_destroy = true
  }
}

locals.ami_id には検証時点で最新なEKS Optimized AMIのIDが設定してありますので必要があれば変更して下さい。 locals.userdata はAWS公式提供のインスタンス起動時にEKSクラスターへ参加するスクリプトの実行を記述しています。認証に必要な鍵やクラスター名はTerraform内でやり取りしています。その他の項目については既存のEC2の設定にまつわることでみればわかると思いますので割愛します。

また特段深い意味は無いのですが、スポットインスタンスで起動することにより費用を抑えられるようにしています。

Security Group

クリックすると展開されます

resource "aws_security_group" "cluster-master" {
  name        = "cluster-master"
  description = "test k8s EKS cluster nodes security group"

  tags {
    Name = "test-k8s-eks-cluster-master-sg"
  }

  vpc_id = "${aws_vpc.test-k8s-eks.id}"

  ingress {
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

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

resource "aws_security_group" "cluster-nodes" {
  name        = "cluster-nodes"
  description = "test k8s EKS cluster nodes security group"

  tags {
    Name = "test-k8s-eks-cluster-nodes-sg"
  }

  vpc_id = "${aws_vpc.test-k8s-eks.id}"

  ingress {
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  ingress {
    description = "Allow internet facing LB, internal LB, and control plane"
    from_port   = 1025
    to_port     = 65535
    protocol    = "tcp"

    security_groups = [
      "${aws_security_group.inet-lb.id}",
      "${aws_security_group.internal-lb.id}",
      "${aws_security_group.cluster-master.id}",
    ]
  }

  ingress {
    description = "Allow internet facing LB, internal LB, and control plane"
    from_port   = 1025
    to_port     = 65535
    protocol    = "udp"

    security_groups = [
      "${aws_security_group.inet-lb.id}",
      "${aws_security_group.internal-lb.id}",
      "${aws_security_group.cluster-master.id}",
    ]
  }

  ingress {
    description = "Allow inter pods communication"
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    self        = true
  }

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

resource "aws_security_group" "inet-lb" {
  name        = "inet-lb"
  description = "test k8s EKS internet facing LB security group"

  tags {
    Name = "test-k8s-eks-inet-lb-sg"
  }

  vpc_id = "${aws_vpc.test-k8s-eks.id}"

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

  ingress {
    from_port   = 8080
    to_port     = 8080
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

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

resource "aws_security_group" "internal-lb" {
  name        = "internal-lb"
  description = "test k8s EKS internal LB security group"

  tags {
    Name = "test-k8s-eks-internal-lb-sg"
  }

  vpc_id = "${aws_vpc.test-k8s-eks.id}"

  ingress {
    description = "temporary"
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

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

マスターにはkubectlを行うためにTCP443のIngressとTCPのレジスタードポート及びエフェメラルポート(つまりTCP1025~65535)のEgressが必要です。公式ドキュメントによればEgressはTCP10125が最低限の要件のようですが、推奨はレジスタード+エフェメラルとなっています。今回は指定の書式が面倒だったのでEgressは全解放にしてあります。

またノード用のセキュリティグループとしてマスター・ELBとの通信でレジスタード+エフェメラルポートの通信を開いてあります。またPod間の通信も無制限に行えます。

検証の利便性のために緩めな設定になっていますが、最低限の要件に絞りたい方は公式ドキュメントを参照して絞って下さい。

main.tf

クリックすると展開されます

locals {
  region      = "us-west-2"
}

variable "access_key_id" {}
variable "secret_access_key" {}

provider "aws" {
  access_key = "${var.access_key_id}"
  secret_key = "${var.secret_access_key}"
  region     = "${local.region}"
}

output "kubectl config" {
  value = "${local.kubeconfig}"
}

output "EKS ConfigMap" {
  value = "${local.eks_configmap}"
}

Outputとしてkubectl用の認証情報とEKS構築後に適用するConfigMapが出力されるようにしています。他には起動するリージョン名も指定してありますが、これを変更する場合は先に設定したEKS Optimized AMIのIDを変更する必要がありますのでご注意下さい。

構築の手順

terraform apply を実行すれば環境一式が作成され認証に必要な情報が出力されますので output "kubectl config" の内容を ~/.kube/config に保存することでkubectlが使えるようになります。その後 output "EKS ConfigMap" の内容を適当なyamlファイルへと保存し kubectl apply -f xxx.yaml で適用することでEKSのワーカーを認識するようになるはずです。

もし権限回りでエラーが発生するようであればaws-iam-authenticatorがインストールされているか、端末の環境変数にAPIキーが正しくセットされているかを疑ってみて下さい。

終わりに

ひとまずこれで最低限の基盤となるEKSが作成されアプリケーションのデプロイが可能になります。しかしこれは本当に最低限で、ロードバランサーや永続化ディスク等、AWS側との擦り合わせが必要な部分はまだたくさんありますので後日紹介したいと思います。