Make AWS LoadBalancer (ApplicationELB) on EKS (AWS Kubernetes Cluster)


EKS (AWS Kubernetes Cluster)Application LoadBalancer (ApplicationELB)를 만드는 것은 쉽지않다. 검색을 하면 관련 자료들이 많은데, out-dated된 것도 많고, 공식문서에서는 eksctl을 사용하는 것을 전제로 하고 있는 경우가 많아서 다른 방법으로 Infrastructure를 관리하는 곳에서는 적용이 쉽지 않다. 무엇보다도 그대로 실행했을 때 안되는 경우가 발생하는데, 그 원인에 대해서 알기가 너무나도 힘들다. 필자도 그런 과정들을 겪으면서 trial error들을 통해서 이렇게 내용을 정리할 수 있게 되었다.

참고로 2022년 1월 기준으로 정상적으로 동작하는 방법이다.

이 과정에서 사용할 도구는 kubectl만을 사용하기로 한다.

그리고 그 과정에서 생성해야할 AWS내의 resource들은 Terraform 코드로 소개하겠다.

EKS에서의 LoadBalancer

EKS에서 LoadBalancer를 생성하는 가장 쉬운 방법은 Service에서 자동으로 생성하게 하는 것이다. 이 방법으로는 Classic LB와 Network LB 생성이 가능하다. 하나의 서비스에 하나의 LB가 생성된다. 너무많은 LB의 생성을 피하고 싶다던지, ingress 관리가 필요한 경우라면 Ingress에서 LB를 생성하는 방법도 있다. 이 경우에도 Classic LB와 Network LB의 생성이 가능하다. Classic LB는 7계층(Application), 4계층(Network)를 모두 지원해주며, Network LB는 4계층(Network)만 지원해준다. 그래서 http/https와 같은 application layer에 대해서는 관리가 힘들다. 4xx, 5xx에 대해서 모니터링도 지원되지 않으며, web socket 통신의 경우에도 자주 끊기는 문제가 발생하고 있다. Classic LB에 대해서는 이제 deprecate되고 있는 중이어서 7계층(Application)을 지원해주는 Application LB를 생성해주는 것이 좋다.

하지만 Application LB를 EKS에 띄우기 위해서는 Ingress Controller가 필수적으로 필요하다. AWS에서는 이것을 AWS LoadBalancer Controller라고 이름을 정했다. 이것에 대한 생성을 우리가 yaml파일을 보고 직접이해하기는 힘들다. 500 line이 넘는 yaml파일을 제공해주는데 그냥 그것을 사용해야한다.

ALB 생성과정에 대한 개요

EKS라는 서비스는 Kubernetes에서 사용하는 여러가지 resource를 AWS 내의 resource로 매핑을 해줘서 편하게 사용할 수 있도록 제공해준다. 그러기 위해서는 AWS 내 resource를 제어/생성/제거 하기 위한 권한이 필요하다. ServiceAccount는 AWS의 policy 또는 role의 권한을 EKS내의 resource에게 부여하기 위해서 사용된다. 그래서 ServiceAccount를 생성하여 EKS 내의 resource에 부여를 함으로써 AWS 내의 resource를 직접 제어할 수 있게 해준다. ALB를 위해서는 주로 VPC, ELB에 대한 권한들이 주어진 Policy를 생성하여 그것을 ServiceAccount로 부여하고 해당 ServiceAccount로 Ingress Controller (즉, AWS LoadBalancer Controller)를 생성해주어야 한다.

그런 다음 Ingress를 생성하면 ApplicationELB가 자동으로 생성되는데, 무조건 생성되는 것이 아니라 Ingress와 여기에 연결된 Service들의 설정에 오류가 하나도 없어야 한다. 오류가 있는 상태에서는 ApplicationELB가 생성되지 않는다. 생성된 이후에는 오류가 있어도 동작에는 문제가 없지만 그 상태에서는 Ingress를 변경하더라도 ApplicationELB에 반영되지 않는다. 다시 오류가 없는 상태를 만들어야 Application ELBTargetGroup에 정상적으로 반영된다. 참고로 Ingress의 각각의 rule별로 TargetGroup이 생성된다.

기존에 작성된 생성 가이드가 정상적으로 동작하지 않는 경우의 대부분은 AWS내의 권한문제(Role, Policy)였다.

1. ServiceAccount 생성

Ingress Controller에서 사용할 ServiceAccount를 먼저 생성해보겠다. 여기에서 사용할 Policy는 아래 링크에서 다운로드 받은 것을 활용하면 된다.

https://raw.githubusercontent.com/kubernetes-sigs/aws-load-balancer-controller/main/docs/install/iam_policy.json

현재 일자 기준으로 동작하는 policy 생성 코드이다.

resource aws_iam_policy load_balancer_controller {
  name = "AWSLoadBalancerControllerIAMPolicy"

  policy = jsonencode(
    {
        "Version": "2012-10-17",
        "Statement": [
            {
                "Effect": "Allow",
                "Action": "iam:CreateServiceLinkedRole",
                "Resource": "*",
                "Condition": {
                    "StringEquals": {
                        "iam:AWSServiceName": "elasticloadbalancing.amazonaws.com"
                    }
                }
            },
            {
                "Effect": "Allow",
                "Action": [
                    "ec2:DescribeAccountAttributes",
                    "ec2:DescribeAddresses",
                    "ec2:DescribeAvailabilityZones",
                    "ec2:DescribeInternetGateways",
                    "ec2:DescribeVpcs",
                    "ec2:DescribeVpcPeeringConnections",
                    "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": "*"
            }
        ]
    })

    tags = local.module_tags
}

resource aws_iam_role_policy_attachment worker_load_balancer_controller {
  role       = aws_iam_role.worker.name
  policy_arn = aws_iam_policy.load_balancer_controller.arn
}

aws_iam_role.worker는 EKS의 node group에서 사용하는 role이다. 즉, worker node로 실행되는 EC2들이 가지는 role이다. 공식문서에는 worker node에는 해당 권한을 부여한다는 내용이 없었는데, 이 권한을 부여하지 않으니 ApplicationELB가 생성되지 않았다.

위와 같이 policy를 생성했다면 그것을 사용하는 ServiceAccount를 생성하자.

apiVersion: v1
kind: ServiceAccount
metadata:
    labels:
        app.kubernetes.io/component: controller
        app.kubernetes.io/name: aws-load-balancer-controller
    name: aws-load-balancer-controller
    namespace: kube-system
    annotations:
        eks.amazonaws.com/policy-arn: arn:aws:iam::{ACCOUNT}:policy/AWSLoadBalancerControllerIAMPolicy

위 과정들을 eksctl로 하면 한줄이면 생성된다. 하지만 필자의 경우 최초 1회는 정상적으로 생성되었지만, 삭제 후 다시 시도해보니 계속해서 생성되지 않았다.

2. cert-manager 설치

https://cert-manager.io

인증서 구성을 웹훅에 삽입할 수 있도록 cert-manager를 설치하자. Cert-manager는 쿠버네티스 클러스터 내에서 TLS인증서를 자동으로 프로비저닝 및 관리하는 오픈 소스이다. 현재 기준으로 v1.6.1 까지 존재한다. 필자는 더 상위버전을 시도해보지는 않았다.

kubectl apply --validate=false -f https://github.com/jetstack/cert-manager/releases/download/v1.4.1/cert-manager.yaml

3. AWS LoadBalancer Controller 설치

제공해주는 AWS LoadBalancer Controller yaml 파일을 다운로드한다. 무조건 최신버전을 다운로드 받는 것이 아니라 현재 사용하는 EKS Cluster 버전에 맞는 것으로 다운로드해야한다.

wget https://raw.githubusercontent.com/kubernetes-sigs/aws-load-balancer-controller/v2.1.2/docs/install/v2_1_2_full.yaml

다운로드 받은 후 2가지를 수정해야 한다.

  1. --cluster-name= 부분을 찾아서 EKS Cluster이름으로 변경
  2. ServiceAccount를 생성하는 부분을 찾아서 삭제 (이미 생성한 ServiceAccount를 사용해야 함)

그런 다음 배포하자.

kubectl apply -f v2_1_2_full.yaml

이제 모든 것이 끝났다. 그럼 정상적으로 동작하는지 Service와 Ingress를 배포해보자.

4. Ingress, Service 배포

아래와 같은 형식의 yaml 파일을 배포하자.

apiVersion: v1
kind: Service
metadata:
  name: { .Chart.Name }
  labels:
    app.kubernetes.io/name: { .Chart.Name }
    app.kubernetes.io/instance: { .Release.Name }
spec:
  type: { .Values.service.type }
  ports:
    - port: { .Values.service.port }
      targetPort: http
      protocol: TCP
      name: http
  selector:
    app.kubernetes.io/name: { .Chart.Name }
    app.kubernetes.io/instance: { .Release.Name }
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: { .Chart.Name }
  namespace: { .Values.namespace }
  annotations:
    kubernetes.io/ingress.class: alb
    alb.ingress.kubernetes.io/scheme: internet-facing
    alb.ingress.kubernetes.io/target-type: ip
    alb.ingress.kubernetes.io/listen-ports: '[{"HTTPS": 443}, {"HTTP": 80}]'
    alb.ingress.kubernetes.io/ssl-policy: ELBSecurityPolicy-2016-08
    alb.ingress.kubernetes.io/certificate-arn: { .Values.acmArn }
    alb.ingress.kubernetes.io/healthcheck-path: /ping
    alb.ingress.kubernetes.io/healthcheck-interval-seconds: '60'
spec:
  rules:
    - host: "api.{ .Values.domain }"
      http:
        paths:
          - path: /*
            pathType: Prefix
            backend:
              service:
                name: { .Chart.Name }
                port: 
                  number: 80
---

위 yaml파일은 helm chart에서 사용하는 template이다. helm을 사용하지 않는다면 { }로 감싸진 부분에 직접 해당하는 값을 채워넣어서 kubectl로 배포하면 된다. Github Markdown에서 { { 2개가 붙어있는 문자열을 표현하는 방법을 찾지 못해서 { 1개로 표현하였다. 위에 따로 적어두진 않았지만 Service에서 selector로 가리키는 deployment 및 pod가 배포되어 있어야 한다.

정상적으로 Service, Ingress의 정의에 오류가 없는 상태라면 ApplicationELB가 생성된다. 이름은 k8s-{ClusterName}-{Namespace}{hash1}-{hash2}이런 형식이며, DNS Name은 이름뒤에 -{hash3}.{region}.elb.amazonaws.com라는 것이 더 붙는 식이다.

e.g. k8s-test-testap-d75b45a052-1360948395.ap-northeast-2.elb.amazonaws.com

그리고 tag로 추가정보들이 기록되어 있다.

  • ingress.k8s.aws/resource: LoadBalancer
  • ingress.k8s.aws/stack: Namespace/Service
  • elbv2.k8s.aws/cluster: ClusterName

이름으로 ApplicationELB를 식별하기는 어렵고 tag를 보고 식별하는 것이 훨씬 쉽다. 아니면 kubectl 또는 Lens(https://k8slens.dev)를 통해서 생성한 Ingress 정보에서 ADDRESS 확인이 가능하다.

kubectl get ingress -n {namespace}

Route53에서 ApplicationELB를 연결하는 Terraform 코드는 아래와 같은 식으로 작성이 가능하다.

data aws_lb alb_api {
  tags = {
    "ingress.k8s.aws/stack" = "${var.namespace}/${var.service_name}"
    "elbv2.k8s.aws/cluster"  = var.eks_name
  }
}

resource aws_route53_record record_a {
  zone_id = data.aws_route53_zone.route53.zone_id
  name    = "api.${var.domain_name}"
  type    = "A"

  alias {
    name                   = replace("${data.aws_lb.alb_api.dns_name}", "/[.]$/", "")
    zone_id                = data.aws_lb.alb_api.zone_id
    evaluate_target_health = true
  }
}

맺음말

EKS에서 ApplicationELB를 생성하는 방법에 대해서는 아직까지도 stable하다는 느낌이 들지않는다. Ingress Controller도 아직은 beta같다는 느낌이다. 그래서 명시적으로 확실히 알고 만드는 것이 아니라 제공해주는 방법을 이용해서 생성을 하고 있다. ApplicationELB도 원래 우리가 알던 ALB의 역할이 아니라 그냥 단순히 KubernetesExternal IP를 제공해주고 Ingress에서 정의한 rule별로 TargetGroup을 만들어주는 용도 정도인것으로 보인다.

위에 기록한 과정들이 최적의 과정인지에 대한 확신은 없다. 필요없는 resource 생성 또는 필요없는 권한부여 같은 것들이 있을 수 있다. 혹시 여기에 대해서 정보가 있는 독자가 계신다면 댓글로 피드백 부탁드리겠다. 그럼 해당 내용에 대해서 팩트체크 후 수정반영하도록 하겠다.

이 방법이 언제까지 유효할지는 모르겠다. EKS뿐만 아니라 AWS내의 서비스들의 사용법 또는 정책등이 지속해서 변하고 있기 때문이다.

이 글이 도움이 되셨다면 공감 및 광고 클릭을 부탁드립니다 :)