【日本初導入】AWS Outposts ラックを徹底解説 第3回 〜TerraformによるPrivate EKS構築〜

はじめに

こんにちは、イノベーションセンターの鈴ヶ嶺です。

engineers.ntt.com

engineers.ntt.com

第1回、第2回に引き続きAWS Outposts ラックについて紹介していきます。

本記事では、Terraform を用いてOutposts上でオンプレ環境からのみ管理・アクセス可能なPrivate Elastic Kubernetes Service(EKS) を構築する方法を紹介します。

Terraform

Terraformとは、HashCorpが提供するインフラをコード化して自動構築を可能とするInfrastructure as Code(IaC)を実現するためのツールです。OutpostsをTerraformでIaC化するためには、対応した各リソースに outpost_arn を渡すことでOutposts上のリソースが作成されます。

以下はsubnetの一例です。1

resource "aws_subnet" "main" {
  vpc_id            = aws_vpc.main.id
  cidr_block        = "10.0.3.0/24"
  outpost_arn       = "arn:aws:outposts:ap-northeast-1:1234567890:outpost/op-1234567890"
}

outpost_arn - (Optional) The Amazon Resource Name (ARN) of the Outpost.

CDKと比較してTerraformはAWS APIを利用しているのでOutpostsのリソースは比較的対応されていると思われます。今後新しいリソースがOutpostsで利用可能になった場合も対応スピードは早いと予想されます。ただ一方で、CDKのような高レベルなリソース作成をすることが難しいのでリソースやAPIの仕様を把握する必要があります。またリソースの認証情報・状態管理なども考える必要があります。

オンプレ環境上でマネージドk8sサービスを構築するアーキテクチャ

上記のようなオンプレ環境においてもマネージドk8sサービスを利用するアーキテクチャを構築します。Outposts上にAuto Scale Groupを設置してセルフマネージド型ノードとしてそれぞれEC2をEKS Clusterに登録させます。Outpostsとオンプレ環境との通信は全てLocal Gateway経由で接続されます。

この構成の特徴としてEKS ClusterのAPIサーバをPrivateアクセスのみ許可することで、Bastion(踏み台)経由でのみクラスタの操作を可能とするセキュアな点が挙げられます。さらに、アプリケーションもオンプレ環境のみからアクセス可能なため社内にのみ公開する秘匿性の高いシステムを構築できます。

実装

以下のような構成で、それぞれのtfファイルにリソースを定義します。

  • main.tf
    • Providerや各変数などの全体を記載
  • network.tf
    • ネットワークに関するものを記載
  • eks_cluster.tf
    • EKS Clusterに関するものを記載
  • eks_node.tf
    • EKS Nodeに関するものを記載
  • security_group.tf
    • Security Groupによる通信の許可に関するものを記載
  • bastion.tf
    • 踏み台サーバに関するものを記載
  • output.tf
    • k8sアプリケーションのデプロイ時に使用するものを記載

main.tf

terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 3.0"
    }
  }
}

provider "aws" {
  region = var.region
}

# Name tag に一部使用する値
variable "name" {
  default = "outposts-eks-sample"
}

# EKS Node のインスタンス
variable "instance_type" {
  default = "m5.large"
}

# EKS Node のdisk(GB)
variable "disk_size" {
  default = 100
}

variable "region" {
  default = "ap-northeast-1"
}

# EKS Node の望ましいノード数
variable "desired_node" {
  default = 3
}

# EKS Node の最低ノード数
variable "min_node" {
  default = 3
}

# EKS Node の最高ノード数
variable "max_node" {
  default = 3
}

# Local Gateway経由からEKSにアクセス可能なオンプレ環境のCIDR
variable "local_cidr_blocks" {
  default = ["192.168.0.0/16"]
}

locals {
  # outpostのARN
  outpost_arn              = "arn:aws:outposts:ap-northeast-1:1234567890:outpost/op-1234567890"
  
  # Local GatewayのID
  lgw_id                   = "lgw-1234567890"
  
  # Local GatewayのルートテーブルのID
  lgw_rtb_id               = "lgw-rtb-1234567890"
  
  # 顧客所有のIPアドレス
  customer_owned_ipv4_pool = "ipv4pool-coip-1234567890"

  # GPUを使用しているかどうか
  gpu = replace(var.instance_type, "g4dn", "!") != var.instance_type
}

# Bastion, EKS Nodeにアクセス可能な鍵
resource "aws_key_pair" "key" {
  key_name   = "${var.name}-key"
  public_key = file("~/.ssh/id_rsa.pub")
}

main.tfにはAWS Providerと各変数、Bastionの鍵を設定します。 OutpostsのARN outpost_arn や Local GatewayのID lgw_id などはここに設定しておきます。

Outposts上でもAuto Scaling groupは仕様可能です。その設定に用いてる max_node, min_node, desired_node の関係は次の画像のようになっております。 2 今回は3ノードの構成で作成します。

network.tf

resource "aws_vpc" "main" {
  cidr_block = "10.0.0.0/16"

  # VPCエンドポイントを有効にするためにdns hostnames, supportを設定
  enable_dns_hostnames = true
  enable_dns_support   = true

  tags = {
    Name = "vpc-${var.name}"
  }
}

# EKS Clusterのsubnet
resource "aws_subnet" "cluster01" {
  vpc_id     = aws_vpc.main.id
  cidr_block = "10.0.1.0/24"

  tags = {
    Name = "subnet-cluster01-${var.name}"
  }
}

# EKS Clusterのsubnet
resource "aws_subnet" "cluster02" {
  vpc_id     = aws_vpc.main.id
  cidr_block = "10.0.2.0/24"

  tags = {
    Name = "subnet-cluster02-${var.name}"
  }
}

# Outpostsのsubnet
resource "aws_subnet" "outposts_subnet01" {
  vpc_id            = aws_vpc.main.id
  cidr_block        = "10.0.3.0/24"
  outpost_arn       = local.outpost_arn
  availability_zone = "ap-northeast-1a"

  tags = {
    Name                                                 = "subnet-outposts-subnet01-${var.name}"
    "kubernetes.io/cluster/${aws_eks_cluster.main.name}" = "owned"
    "kubernetes.io/role/elb"                             = "1"
  }
}


# デフォルトルートをLocal Gatewayに設定
resource "aws_route_table" "local_gateway" {
  vpc_id = aws_vpc.main.id

  route {
    cidr_block       = "0.0.0.0/0"
    local_gateway_id = local.lgw_id
  }

  tags = {
    Name = "local-gateway-route-table-${var.name}"
  }
}

resource "aws_route_table_association" "outposts_subnet01" {
  subnet_id      = aws_subnet.outposts_subnet01.id
  route_table_id = aws_route_table.local_gateway.id
}

data "aws_ec2_local_gateway_route_table" "main" {
  outpost_arn = local.outpost_arn
}

resource "aws_ec2_local_gateway_route_table_vpc_association" "main" {
  local_gateway_route_table_id = data.aws_ec2_local_gateway_route_table.main.id
  vpc_id                       = aws_vpc.main.id
}

# EKS Nodeに設定するEIPのPool
resource "aws_eip" "main" {
  count                    = var.max_node
  customer_owned_ipv4_pool = local.customer_owned_ipv4_pool

  tags = {
    Name = "eip-node-${count.index}"
  }

}

# Bastion(踏み台)のEIP
resource "aws_eip" "bastion" {
  customer_owned_ipv4_pool = local.customer_owned_ipv4_pool
  tags = {
    Name = "eip-bastion-${var.name}"
  }
}

# 初期起動時にEKS Node自身がEIPを自ら設定するためのEC2エンドポイントを設定
resource "aws_vpc_endpoint" "ec2" {
  vpc_id            = aws_vpc.main.id
  service_name      = "com.amazonaws.${var.region}.ec2"
  vpc_endpoint_type = "Interface"

  subnet_ids = [aws_subnet.outposts_subnet01.id]
  security_group_ids = [
    aws_security_group.vpc_endpoint_ec2.id,
  ]

  private_dns_enabled = true
}

network.tfにはネットワークに関するリソースを定義します。

ネットワーク構成として、Public AWS上にsubnetを2つ、Outposts上にsubnetを1つ作成します。OutpostsのsubnetにEKSのWorker Nodeが設置されます。基本的にOutpostsのsubnetはデフォルトルートをLocal Gatewayに設定することでインターネットなどのアクセスはオンプレ環境経由で接続されるようにします。これにより社内のネットワークポリシーの適用や監視などを可能とします。また、Local Gatewayに接続するためには顧客所有のIPアドレスcustomer owned IP address(CoIP)をアタッチする必要があるためEKS Node、Bastion用のCoIPのElastic IP アドレス(EIP)を作成しておきます。

eks_cluster.tf

# EKS Cluster
resource "aws_eks_cluster" "main" {
  name     = var.name
  role_arn = aws_iam_role.cluster_role.arn

  vpc_config {
    security_group_ids      = [aws_security_group.cluster.id]
    subnet_ids              = [aws_subnet.cluster01.id, aws_subnet.cluster02.id]
    
    # Private Accessのみに設定してBastion経由でのみ操作する
    endpoint_public_access  = false
    endpoint_private_access = true
  }

  depends_on = [
    aws_iam_role_policy_attachment.eks_cluster_policy,
    aws_iam_role_policy_attachment.eks_service_policy,
    aws_iam_role_policy_attachment.eks_vpc_resource-controller,
  ]
}

resource "aws_iam_role" "cluster_role" {
  name = "eks-cluster-role-${var.name}"

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

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

resource "aws_iam_role_policy_attachment" "eks_service_policy" {
  policy_arn = "arn:aws:iam::aws:policy/AmazonEKSServicePolicy"
  role       = aws_iam_role.cluster_role.name
}

resource "aws_iam_role_policy_attachment" "eks_vpc_resource-controller" {
  policy_arn = "arn:aws:iam::aws:policy/AmazonEKSVPCResourceController"
  role       = aws_iam_role.cluster_role.name
}

# ALBを使用するためのIAM Policy
resource "aws_iam_policy" "aws_load_balancer_controller" {
  name = "alb-policy-${var.name}"

  # from https://raw.githubusercontent.com/kubernetes-sigs/aws-load-balancer-controller/v2.2.1/docs/install/iam_policy.json
  policy = <<EOF
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "iam:CreateServiceLinkedRole",
                "ec2:DescribeAccountAttributes",
                "ec2:DescribeAddresses",
                "ec2:DescribeAvailabilityZones",
                "ec2:DescribeInternetGateways",
                "ec2:DescribeVpcs",
                "ec2:DescribeSubnets",
                "ec2:DescribeSecurityGroups",
                "ec2:DescribeInstances",
                "ec2:DescribeNetworkInterfaces",
                "ec2:DescribeTags",
                "ec2:GetCoipPoolUsage",
                "ec2:DescribeCoipPools",
                "elasticloadbalancing:DescribeLoadBalancers",
                "elasticloadbalancing:DescribeLoadBalancerAttributes",
                "elasticloadbalancing:DescribeListeners",
                "elasticloadbalancing:DescribeListenerCertificates",
                "elasticloadbalancing:DescribeSSLPolicies",
                "elasticloadbalancing:DescribeRules",
                "elasticloadbalancing:DescribeTargetGroups",
                "elasticloadbalancing:DescribeTargetGroupAttributes",
                "elasticloadbalancing:DescribeTargetHealth",
                "elasticloadbalancing:DescribeTags"
            ],
            "Resource": "*"
        },
        {
            "Effect": "Allow",
            "Action": [
                "cognito-idp:DescribeUserPoolClient",
                "acm:ListCertificates",
                "acm:DescribeCertificate",
                "iam:ListServerCertificates",
                "iam:GetServerCertificate",
                "waf-regional:GetWebACL",
                "waf-regional:GetWebACLForResource",
                "waf-regional:AssociateWebACL",
                "waf-regional:DisassociateWebACL",
                "wafv2:GetWebACL",
                "wafv2:GetWebACLForResource",
                "wafv2:AssociateWebACL",
                "wafv2:DisassociateWebACL",
                "shield:GetSubscriptionState",
                "shield:DescribeProtection",
                "shield:CreateProtection",
                "shield:DeleteProtection"
            ],
            "Resource": "*"
        },
        {
            "Effect": "Allow",
            "Action": [
                "ec2:AuthorizeSecurityGroupIngress",
                "ec2:RevokeSecurityGroupIngress"
            ],
            "Resource": "*"
        },
        {
            "Effect": "Allow",
            "Action": [
                "ec2:CreateSecurityGroup"
            ],
            "Resource": "*"
        },
        {
            "Effect": "Allow",
            "Action": [
                "ec2:CreateTags"
            ],
            "Resource": "arn:aws:ec2:*:*:security-group/*",
            "Condition": {
                "StringEquals": {
                    "ec2:CreateAction": "CreateSecurityGroup"
                },
                "Null": {
                    "aws:RequestTag/elbv2.k8s.aws/cluster": "false"
                }
            }
        },
        {
            "Effect": "Allow",
            "Action": [
                "ec2:CreateTags",
                "ec2:DeleteTags"
            ],
            "Resource": "arn:aws:ec2:*:*:security-group/*",
            "Condition": {
                "Null": {
                    "aws:RequestTag/elbv2.k8s.aws/cluster": "true",
                    "aws:ResourceTag/elbv2.k8s.aws/cluster": "false"
                }
            }
        },
        {
            "Effect": "Allow",
            "Action": [
                "ec2:AuthorizeSecurityGroupIngress",
                "ec2:RevokeSecurityGroupIngress",
                "ec2:DeleteSecurityGroup"
            ],
            "Resource": "*",
            "Condition": {
                "Null": {
                    "aws:ResourceTag/elbv2.k8s.aws/cluster": "false"
                }
            }
        },
        {
            "Effect": "Allow",
            "Action": [
                "elasticloadbalancing:CreateLoadBalancer",
                "elasticloadbalancing:CreateTargetGroup"
            ],
            "Resource": "*",
            "Condition": {
                "Null": {
                    "aws:RequestTag/elbv2.k8s.aws/cluster": "false"
                }
            }
        },
        {
            "Effect": "Allow",
            "Action": [
                "elasticloadbalancing:CreateListener",
                "elasticloadbalancing:DeleteListener",
                "elasticloadbalancing:CreateRule",
                "elasticloadbalancing:DeleteRule"
            ],
            "Resource": "*"
        },
        {
            "Effect": "Allow",
            "Action": [
                "elasticloadbalancing:AddTags",
                "elasticloadbalancing:RemoveTags"
            ],
            "Resource": [
                "arn:aws:elasticloadbalancing:*:*:targetgroup/*/*",
                "arn:aws:elasticloadbalancing:*:*:loadbalancer/net/*/*",
                "arn:aws:elasticloadbalancing:*:*:loadbalancer/app/*/*"
            ],
            "Condition": {
                "Null": {
                    "aws:RequestTag/elbv2.k8s.aws/cluster": "true",
                    "aws:ResourceTag/elbv2.k8s.aws/cluster": "false"
                }
            }
        },
        {
            "Effect": "Allow",
            "Action": [
                "elasticloadbalancing:AddTags",
                "elasticloadbalancing:RemoveTags"
            ],
            "Resource": [
                "arn:aws:elasticloadbalancing:*:*:listener/net/*/*/*",
                "arn:aws:elasticloadbalancing:*:*:listener/app/*/*/*",
                "arn:aws:elasticloadbalancing:*:*:listener-rule/net/*/*/*",
                "arn:aws:elasticloadbalancing:*:*:listener-rule/app/*/*/*"
            ]
        },
        {
            "Effect": "Allow",
            "Action": [
                "elasticloadbalancing:ModifyLoadBalancerAttributes",
                "elasticloadbalancing:SetIpAddressType",
                "elasticloadbalancing:SetSecurityGroups",
                "elasticloadbalancing:SetSubnets",
                "elasticloadbalancing:DeleteLoadBalancer",
                "elasticloadbalancing:ModifyTargetGroup",
                "elasticloadbalancing:ModifyTargetGroupAttributes",
                "elasticloadbalancing:DeleteTargetGroup"
            ],
            "Resource": "*",
            "Condition": {
                "Null": {
                    "aws:ResourceTag/elbv2.k8s.aws/cluster": "false"
                }
            }
        },
        {
            "Effect": "Allow",
            "Action": [
                "elasticloadbalancing:RegisterTargets",
                "elasticloadbalancing:DeregisterTargets"
            ],
            "Resource": "arn:aws:elasticloadbalancing:*:*:targetgroup/*/*"
        },
        {
            "Effect": "Allow",
            "Action": [
                "elasticloadbalancing:SetWebAcl",
                "elasticloadbalancing:ModifyListener",
                "elasticloadbalancing:AddListenerCertificates",
                "elasticloadbalancing:RemoveListenerCertificates",
                "elasticloadbalancing:ModifyRule"
            ],
            "Resource": "*"
        }
    ]
}
  EOF
}

eks_cluster.tfでは、EKS Clusterに関するリソースを定義しています。

ここでは、EKS ClusterのPrivate Accessを設定することで内部からのみAPIサーバにアクセス可能となります。この設定をすることでよりセキュアなk8s環境が実現できます。運用の際にはBastion経由でkubectl, helmなどの操作をします。

eks_node.tf

locals {
  # 初期起動時にEKS Node自身がEIPを自ら設定してオンプレ環境に対して通信する
  node_user_data = <<EOF
  #!/bin/bash
  set -o xtrace
  
  export AWS_DEFAULT_REGION=${var.region}

  instance_id=$(curl 169.254.169.254/latest/meta-data/instance-id/)

  # EIP Check function
  function check_eip () {
    RES=`aws ec2 describe-addresses --filters Name=allocation-id,Values=$1 | jq .Addresses[0].InstanceId`
    if [ "$RES" = "null" ]; then
        return 0
    fi
    return 1
  }

  eips="${join(" ", aws_eip.main.*.id)}"

  # EIP Attach
  for eip in $eips; do
    check_eip $eip
    if [ $? = 0 ]; then
      aws ec2 associate-address --no-allow-reassociation --instance-id $instance_id --allocation-id $eip
      if [ $? = 0 ]; then
        break
      fi
    fi
  done

  # EKS Node Bootstrap
  /etc/eks/bootstrap.sh ${aws_eks_cluster.main.name}
  EOF

  node_role_name = "node_role_${var.name}"
}

data "aws_ssm_parameter" "eks_cpu_ami" {
  name = "/aws/service/eks/optimized-ami/1.21/amazon-linux-2/recommended/image_id"
}

data "aws_ssm_parameter" "eks_gpu_ami" {
  name = "/aws/service/eks/optimized-ami/1.21/amazon-linux-2-gpu/recommended/image_id"
}

resource "aws_launch_template" "node_template" {
  name_prefix = "eks_sample_node_group_template"

  image_id = local.gpu == true ? data.aws_ssm_parameter.eks_gpu_ami.value : data.aws_ssm_parameter.eks_cpu_ami.value

  instance_type          = var.instance_type
  vpc_security_group_ids = [aws_security_group.node.id]
  key_name               = aws_key_pair.key.id


  block_device_mappings {
    device_name = "/dev/xvda"

    ebs {
      volume_size = var.disk_size
    }
  }

  iam_instance_profile {
    name = aws_iam_instance_profile.instance_role.name
  }

  tag_specifications {
    resource_type = "instance"

    tags = {
      Name = "eks-sample-node"
    }
  }


  user_data = base64encode(local.node_user_data)
}

# EKS NodeのAutoScaleGroup
resource "aws_autoscaling_group" "node_group" {
  vpc_zone_identifier       = [aws_subnet.outposts_subnet01.id]
  desired_capacity          = var.desired_node
  max_size                  = var.max_node
  min_size                  = var.min_node
  health_check_type         = "EC2"
  wait_for_capacity_timeout = "120m"

  launch_template {
    id      = aws_launch_template.node_template.id
    version = "$Latest"
  }

  tag {
    key                 = "kubernetes.io/cluster/${aws_eks_cluster.main.name}"
    value               = "owned"
    propagate_at_launch = true
  }
}

resource "aws_iam_role" "node_group_role" {
  name = "node-group-role-${var.name}"

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

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

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

resource "aws_iam_role_policy_attachment" "eks-container-registry-read-only" {
  policy_arn = "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly"
  role       = aws_iam_role.node_group_role.name
}

resource "aws_iam_instance_profile" "instance_role" {
  name = local.node_role_name
  role = aws_iam_role.node_group_role.name
}

# EKS NodeにEIPをAttachするためのIAM Policy
resource "aws_iam_policy" "eip_attach_policy" {
  name = "eip-attach-policy-${var.name}"

  policy = <<EOF
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "ec2:DescribeAddresses",
                "ec2:AssociateAddress"
            ],
            "Resource": "*"
        }
    ]
}
  EOF
}

resource "aws_iam_role_policy_attachment" "eip_attach_policy" {
  role       = aws_iam_role.node_group_role.name
  policy_arn = aws_iam_policy.eip_attach_policy.arn
}

# セルフマネージド型ノードがEKSに登録されるための設定、BastionでのAPIサーバに対するkubectl操作の許可設定
resource "local_file" "aws_auth" {
  content  = <<EOF
apiVersion: v1
kind: ConfigMap
metadata:
  name: aws-auth
  namespace: kube-system
data:
  mapRoles: |
    - rolearn: ${aws_iam_role.node_group_role.arn}
      username: system:node:{{EC2PrivateDNSName}}
      groups:
        - system:bootstrappers
        - system:nodes
    - rolearn: ${aws_iam_role.bastion.arn}
      username: bastionrole
      groups:
        - system:masters
EOF
  filename = "_aws-auth.yaml"
}

eks_node.tfでは、EKS Nodeに関するリソースを定義しています。

Outposts上ではAuto Scale Groupの利用が可能なためその設定を定義しています。ここでの特徴はAuto Scale Groupの各ノードに対して user_data の中の処理でCoIPを設定しているところです。Auto Scale Groupには自動でEIPを設定が現状できないため、この設定によりLocal Gateway経由でインターネットやオンプレ環境と通信することが可能となります。

また、aws_auth ではBastionのRoleがAPIサーバにアクセスするための設定ファイルを作成しています。

security_group.tf

resource "aws_security_group" "cluster" {
  vpc_id = aws_vpc.main.id
  name   = "eks-cluster-security-group-${var.name}"

  tags = {
    Name = "eks-cluster-security-group-${var.name}"
  }

  ingress {
    from_port       = 1025
    to_port         = 65535
    protocol        = "tcp"
    security_groups = [aws_security_group.node.id]
  }

  ingress {
    from_port       = 443
    to_port         = 443
    protocol        = "tcp"
    security_groups = [aws_security_group.node.id, aws_security_group.bastion.id]
  }

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


resource "aws_security_group" "node" {
  vpc_id = aws_vpc.main.id
  name   = "eks-node-sg-${var.name}"

  tags = {
    Name = "eks-node-sg-${var.name}"
  }

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

resource "aws_security_group_rule" "node_ingress_443" {
  security_group_id        = aws_security_group.node.id
  type                     = "ingress"
  from_port                = 443
  to_port                  = 443
  protocol                 = "tcp"
  source_security_group_id = aws_security_group.cluster.id
}

resource "aws_security_group_rule" "node_ingress_1025_65535" {
  security_group_id        = aws_security_group.node.id
  type                     = "ingress"
  from_port                = 1025
  to_port                  = 65535
  protocol                 = "tcp"
  source_security_group_id = aws_security_group.cluster.id
}

resource "aws_security_group_rule" "node_ingress_localgateway" {
  security_group_id = aws_security_group.node.id
  type              = "ingress"
  from_port         = 0
  to_port           = 0
  protocol          = "all"
  cidr_blocks       = var.local_cidr_blocks
}

resource "aws_security_group_rule" "node_ingress_self" {
  security_group_id = aws_security_group.node.id
  type              = "ingress"
  from_port         = 0
  to_port           = 0
  protocol          = "all"
  self              = true
}


resource "aws_security_group" "vpc_endpoint_ec2" {
  vpc_id = aws_vpc.main.id
  name   = "vpc-endpoint-ec2-sg-${var.name}"

  tags = {
    Name = "vpc-endpoint-ec2-sg-${var.name}"
  }

  ingress {
    from_port       = 0
    to_port         = 0
    protocol        = "all"
    security_groups = [aws_security_group.node.id]
  }

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

resource "aws_security_group" "bastion" {
  vpc_id = aws_vpc.main.id
  name   = "bastion-sg-${var.name}"

  tags = {
    Name = "bastion-sg-${var.name}"
  }

  # オンプレ環境からのみアクセス可能な設定にする
  ingress {
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = var.local_cidr_blocks
  }

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

security_group.tfにはSecurity Groupに関するリソースを定義します。

こちらでは、EKSやオンプレ環境とのSecurity Groupについて設定されています。 local_cidr_blocks については適宜それぞれのオンプレ環境に適した設定が必要となります。EKSについての詳細は次の公式ドキュメントを参照してください。

Amazon EKS セキュリティグループの考慮事項

bastion.tf

locals {
  # APIサーバを操作するためのkubectl, helmなどの各種ツールをインストールする
  bastion_user_data = <<EOF
  #!/bin/bash
  set -o xtrace

  # install kubectl
  curl --retry 180 --retry-delay 1 -LO "https://storage.googleapis.com/kubernetes-release/release/$(curl -s https://storage.googleapis.com/kubernetes-release/release/stable.txt)/bin/linux/amd64/kubectl"
  chmod +x ./kubectl
  sudo mv ./kubectl /usr/local/bin/kubectl
  
  # install helm
  curl -sSL https://raw.githubusercontent.com/helm/helm/master/scripts/get-helm-3 | bash
  EOF
}

data "aws_ssm_parameter" "amzn2_ami" {
  name = "/aws/service/ami-amazon-linux-latest/amzn2-ami-hvm-x86_64-gp2"
}

data "aws_iam_policy_document" "assume_role" {
  statement {
    actions = ["sts:AssumeRole"]

    principals {
      type        = "Service"
      identifiers = ["ec2.amazonaws.com"]
    }
  }
}

resource "aws_iam_role" "bastion" {
  name = "bastion-role-${var.name}"

  assume_role_policy = data.aws_iam_policy_document.assume_role.json

}

# APIサーバ認証のためのIAM Policy
resource "aws_iam_policy" "bastion" {
  name = "bastion-policy-${var.name}"

  policy = <<EOF
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "eks:UpdateClusterConfig",
                "eks:DescribeUpdate",
                "eks:DescribeCluster"
            ],
            "Resource": "${aws_eks_cluster.main.arn}"
        },
        {
            "Effect": "Allow",
            "Action": [
                "eks:ListClusters"
            ],
            "Resource": "*"
        }
    ]
}
  EOF
}

resource "aws_iam_role_policy_attachment" "bastion" {
  role       = aws_iam_role.bastion.name
  policy_arn = aws_iam_policy.bastion.arn
}


resource "aws_iam_instance_profile" "bastion" {
  role = aws_iam_role.bastion.name
}

resource "aws_instance" "bastion" {
  ami                    = data.aws_ssm_parameter.amzn2_ami.value
  subnet_id              = aws_subnet.outposts_subnet01.id
  instance_type          = var.bastion_instance_type
  key_name               = aws_key_pair.key.key_name
  vpc_security_group_ids = [aws_security_group.bastion.id]
  iam_instance_profile   = aws_iam_instance_profile.bastion.name
  user_data              = local.bastion_user_data

  tags = {
    Name = "bastion-instance-${var.name}"
  }
}

resource "aws_eip_association" "bastion" {
  instance_id   = aws_instance.bastion.id
  allocation_id = aws_eip.bastion.id
}

bastion.tfにはBastion(踏み台)に関するリソースを定義します。

こちらでは、BastionにEKSのAPIサーバを操作可能とするためのIAM Roleを設定していることがわかると思います。また通信もCoIPを設定してオンプレ環境からのみ接続できるように設定します。

output.tf

output "cluster" {
  value = aws_eks_cluster.main.name
}

output "alb_arn" {
  value = aws_iam_policy.aws_load_balancer_controller.arn
}

output "region" {
  value = var.region
}

output "ip_pool" {
  value = local.customer_owned_ipv4_pool
}

output "vpc_cidr" {
  value = aws_vpc.main.cidr_block
}

output "bastion_ip" {
  value = aws_eip.bastion.customer_owned_ip
}

output.tfにはk8sアプリケーションのデプロイに使用する値を定義します。

Terraform Apply

上記のtfファイルを次のようにapplyすることで一連のリソースの自動構築が可能です。

terraform init
terraform plan # dry run
terraform apply

Setup EKS

初期には、APIサーバにはEKSクラスタ作成者のみがアクセス可能なためsshuttleというsshでBastion経由の通信を可能とするツールを用います。aws-authをapplyした後はBastion上での操作が可能となります。まず初めにhelmを用いてEKS上でALB利用するためのOIDCやServiceAccountなどの設定をして、サンプルアプリケーションgame2048をデプロイしてみます。

事前に使用する sshuttle, kubectl, helm などのツールをインストールします。

動作確認環境: Apple M1 Max MacBookPro18,4 macOS 12.2.1 Darwin 21.3.0

brew install sshuttle
brew install kubectl
brew install helm
brew tap weaveworks/tap
brew install weaveworks/tap/eksctl
CLUSTER="`terraform output -raw cluster`"
ALB_ARN="`terraform output -raw alb_arn`"
REGION="`terraform output -raw region`"
IP_POOL="`terraform output -raw ip_pool`"
BASTION_IP="`terraform output -raw bastion_ip`"
VPC_CIDR="`terraform output -raw vpc_cidr`"

sshuttle -r ec2-user@$BASTION_IP $VPC_CIDR &
PID=$!
aws eks --region $REGION update-kubeconfig --name $CLUSTER
kubectl apply -f _aws-auth.yaml

# oidc
eksctl utils associate-iam-oidc-provider \
    --region=ap-northeast-1 \
    --cluster=$CLUSTER \
    --region=$REGION \
    --approve

# attach policy
eksctl create iamserviceaccount \
    --cluster=$CLUSTER \
    --namespace=kube-system \
    --name=aws-load-balancer-controller \
    --attach-policy-arn=$ALB_ARN \
    --override-existing-serviceaccounts \
    --approve

# ssh bastion
ssh ec2-user@$BASTION_IP

# ALB
helm install aws-load-balancer-controller eks/aws-load-balancer-controller \
  -n kube-system \
  --set clusterName=$CLUSTER \
  --set serviceAccount.create=false \
  --set image.repository=602401143452.dkr.ecr.ap-northeast-1.amazonaws.com/amazon/aws-load-balancer-controller \
  --set serviceAccount.name=aws-load-balancer-controller 

# ALB Application game2048
curl -s https://raw.githubusercontent.com/kubernetes-sigs/aws-load-balancer-controller/v2.3.1/docs/examples/2048/2048_full.yaml | \
    sed "51s|.*|    alb.ingress.kubernetes.io/customer-owned-ipv4-pool: $IP_POOL|"  > _2048_full.yaml
kubectl apply -f _2048_full.yaml

kill -9 $PID

ingressの設定の中に alb.ingress.kubernetes.io/customer-owned-ipv4-pool 3 を設定することでオンプレからアクセス可能なALBを作成することが可能となります。

上記のようにgame-2048をdeployすることで以下のようにingressが立ち上がります。表示されたURIにオンプレ環境からアクセスすることでサンプルのアプリケーションが表示されます。

kubectl get ingress -n game-2048
NAME           CLASS    HOSTS   ADDRESS                                                                        PORTS   AGE
ingress-2048   <none>   *       k8s-game2048-ingress2-xxxxxxxxx-1234567890.ap-northeast-1.elb.amazonaws.com   80      4d10h

以上のようにサンプルアプリケーションを実行できました。

まとめ

本記事では、Terraformを用いてOutposts上でオンプレ環境からのみ管理・アクセス可能なPrivate EKSを構築する方法を紹介しました。

開発面では、オンプレ環境でもIaCを用いてマネージドk8sを利用することが大きなメリットかと思います。利用面でもセキュアな可用性の高いアプリケーションを構築することが可能となるため非常に有用であると思います。

© NTT Communications Corporation All Rights Reserved.