🏃‍♂️

Pod Identityフル活用で、EKSのBlue-Green Upgradeを実現する

に公開

このブログを一言で

Pod IdentityをAWS Load Balancer ControllerとExternalDNSで有効化することで、EKSの運用が少しだけ楽になるし、Blue-Green Upgradeが少しだけ簡単になりますよ。
さらにArgo CDの勉強もできるよ。

序文

今回のブログは以下のBlue-Green Upgradeの記事や手順を作成した先駆者の方達に敬意を込めて、Pod Identityフル活用な手順でBlue-Green Upgradeをさらに簡略化したものです。

https://github.com/jnytnai0613/blue-green-upgarade-blueprints

使っている技術ポイント

Pod Identity

EKSのServiceAccountに対してIAMロールを紐づける仕組みとして、 AWS公式のModule terraform-aws-eks-pod-identityの利用も可能です。しかしこのモジュールでは、aws_eks_pod_identity_associationリソースのrole_arn に指定されるロールが、新規作成されることを前提としています。
以下は、該当Module内のコード(該当箇所のリンク)です。

resource "aws_eks_pod_identity_association" "this" {
  for_each = { for k, v in var.associations : k => v if var.create }


  cluster_name    = try(each.value.cluster_name, var.association_defaults.cluster_name)
  namespace       = try(each.value.namespace, var.association_defaults.namespace)
  service_account = try(each.value.service_account, var.association_defaults.service_account)
  role_arn        = aws_iam_role.this[0].arn

  tags = merge(var.tags, try(each.value.tags, var.association_defaults.tags, {}))
}

今回はEKSクラスターをBlue/Greenの2系統で構築し、それぞれで同一のIAMロールを使用する必要があります。そのため、新規ロール作成が前提となっている公式モジュールでは要件を満たせません。 そこで、aws_eks_pod_identity_associationリソースをモジュール経由ではなく直接コード内に記述し、あらかじめ作成済みのIAMロールARNを明示的に指定する形でPod Identityを設定しています。

resource "aws_eks_pod_identity_association" "external-dns-identity" {
  cluster_name    = module.eks.cluster_name
  namespace       = local.external_dns_namespace
  service_account = local.external_dns_serviceaccount
  role_arn        = data.terraform_remote_state.common.outputs.pod_external_dns_role_arn
}

これを今回は、アプリケーション、AWS Load Balancer Controller、ExternalDNSのServiceAccountと、Blue/Greenで同じIAMロールを紐づけて同じ権限を持たせています。

AWS Load Balancer Controller用Policy
{
    "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",
                "ec2:GetSecurityGroupsForVpc",
                "ec2:DescribeIpamPools",
                "ec2:DescribeRouteTables",
                "elasticloadbalancing:DescribeLoadBalancers",
                "elasticloadbalancing:DescribeLoadBalancerAttributes",
                "elasticloadbalancing:DescribeListeners",
                "elasticloadbalancing:DescribeListenerCertificates",
                "elasticloadbalancing:DescribeSSLPolicies",
                "elasticloadbalancing:DescribeRules",
                "elasticloadbalancing:DescribeTargetGroups",
                "elasticloadbalancing:DescribeTargetGroupAttributes",
                "elasticloadbalancing:DescribeTargetHealth",
                "elasticloadbalancing:DescribeTags",
                "elasticloadbalancing:DescribeTrustStores",
                "elasticloadbalancing:DescribeListenerAttributes",
                "elasticloadbalancing:DescribeCapacityReservation"
            ],
            "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",
                "elasticloadbalancing:ModifyListenerAttributes",
                "elasticloadbalancing:ModifyCapacityReservation",
                "elasticloadbalancing:ModifyIpPools"
            ],
            "Resource": "*",
            "Condition": {
                "Null": {
                    "aws:ResourceTag/elbv2.k8s.aws/cluster": "false"
                }
            }
        },
        {
            "Effect": "Allow",
            "Action": [
                "elasticloadbalancing:AddTags"
            ],
            "Resource": [
                "arn:aws:elasticloadbalancing:*:*:targetgroup/*/*",
                "arn:aws:elasticloadbalancing:*:*:loadbalancer/net/*/*",
                "arn:aws:elasticloadbalancing:*:*:loadbalancer/app/*/*"
            ],
            "Condition": {
                "StringEquals": {
                    "elasticloadbalancing:CreateAction": [
                        "CreateTargetGroup",
                        "CreateLoadBalancer"
                    ]
                },
                "Null": {
                    "aws:RequestTag/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",
                "elasticloadbalancing:SetRulePriorities"
            ],
            "Resource": "*"
        }
    ]
}
ExternalDNS用Policy

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "route53:ChangeResourceRecordSets"
      ],
      "Resource": [
        "arn:aws:route53:::hostedzone/*"
      ]
    },
    {
      "Effect": "Allow",
      "Action": [
        "route53:ListHostedZones",
        "route53:ListResourceRecordSets",
        "route53:ListTagsForResources"
      ],
      "Resource": [
        "*"
      ]
    }
  ]
}

Argo CD

App of Appsパターン

通常、Argo CDでアプリケーションを管理する場合、アプリケーションごとに Applicationリソースを用意し、それぞれ個別にデプロイする必要があります。この方法では、Applicationリソースの数だけ管理工数が増えていくため、運用が煩雑になりがちです。さらに、毎回一つずつ手動でデプロイするのは、非常に手間ですよね。そこで登場するのがApp of Appsパターンです。
このパターンでは、親となるApplicationリソースが複数の子Applicationを管理します。つまり、親を一つデプロイするだけで、関連するすべての子を一括で展開できるようになります。

※上図は公式ドキュメントから引用:Argo CD - Cluster Bootstrapping
一見すると、「結局、親Application用のYAMLを書く手間が増えるだけで、逆に面倒なのでは?」と感じるかもしれません。ですが、App of Appsパターンを採用する最大のメリットは、構成の一元化と拡張性の高さにあります。
例えば、子Applicationを追加する場合でも、親の Application が監視しているGitリポジトリに対象の子Applicationを定義したYAMLを1つ追加するだけで済みます。個別にkubectl applyする必要もなく、GitOpsの流れに沿って自動的にArgo CDに反映されます。
このように、アプリケーションの規模が大きくなっても一貫した運用ができるという点が、App of Appsパターンの大きな魅力です。

では、子の起動や削除順序はどのように制御するのでしょうか?

AWS Load Balancer Controllerは、Ingressデプロイを検知してAWS側にELBをデプロイします。以下の図のようにAWS Load Balancer Controllerと、Ingressを擁するアプリ、ExternalDNSを管理する親子関係がある場合、AWS Load Balancer Controller→ExternalDNS→Ingressの順に起動する必要があります。

これは、Sync WaveをAnnotationに設定することで順序を制御することができます。
Sync Waveはデフォルト0で何も設定しないと、全ての子は並列で起動します。
そこで、負数を設定することで最初に起動、最後に削除を実現することが可能です。
今回はAWS Load Balancer Controllerを-1、ExternalDNSに0、Ingressを擁するアプリに1を設定することで起動と削除順序を制御することができています。

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: aws-load-balancer-controller
  namespace: argocd
  annotations:
    # 起動を最初に、削除を最後にする
    # https://argo-cd.readthedocs.io/en/stable/user-guide/sync-waves/
    # "Hooks and resources are assigned to wave zero by default.
    # The wave can be negative, so you can create a wave that runs before all other resources."
    argocd.argoproj.io/sync-wave: "-1"
  finalizers:
    - resources-finalizer.argocd.argoproj.io
---
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: externaldns
  namespace: argocd
  annotations:
    argocd.argoproj.io/sync-wave: "0"
  finalizers:
    - resources-finalizer.argocd.argoproj.io
---
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: fastapi
  namespace: argocd
  annotations:
    argocd.argoproj.io/sync-wave: "1"
  finalizers:
    - resources-finalizer.argocd.argoproj.io

Private Repositoryへの接続

今回はパブリックリポジトリにコードを置いているので利用はしていません。
Argo CDはパブリックリポジトリの場合は認証なしで接続できます。
しかしプライベートリポジトリの場合、GitHubの場合はPersonal access token(PAT)、またはGitHub Appを利用して認証する必要があります。PATの場合は個人に紐づきますし、本番運用では推奨されません。GitHub App認証を利用しましょう。
以下のように、GitHub Appの情報を記載したSecretを作成して認証させましょう。(SecretのYAMLをリポジトリで管理する場合は、SOPS: Secrets OPerationSなどで情報を暗号化しておくのをお勧めします)
Argo CDはargocd.argoproj.io/secret-type: repo-credsを.metadata.labelsに設定してあげるだけで、認証情報として認識してくれます。

apiVersion: v1
kind: Secret
metadata:
  name: github-app-secret
  namespace: argocd
  labels:
    argocd.argoproj.io/secret-type: repo-creds
type: Opaque
stringData:
  type: githubApp
  url: https://github.com/jnytnai0613/blue-green-upgrade-blueprints
  # GitHub Appの App ID
  appId: "1234567"
  # GitHub AppsのInstall App画面の歯車マークのURLの最後の番号
  # https://github.com/settings/installations/12345678
  installationId: "12345678"
  privateKey: |
    -----BEGIN RSA PRIVATE KEY-----
    MIIEpQIBAAKCAQEA7bPXR0mIyWGStO/8czUWoJAQ/W/7hNk40S3Ql43SaM0kN57i
    :
    K3nLWW0yNsYT0aGoBfo49xjfhkOTWA+jtNcgxzYuXQiEGaouIICZLmc=
    -----END RSA PRIVATE KEY-----

デプロイ手順

リポジトリを参照ください。

なお、EKSの実体は以下のModuleに実装しています。
https://github.com/jnytnai0613/blue-green-upgrade-blueprints/tree/main/modules/cluster

Blue-Green切り替え

  1. しばらく両クラスタを稼働させ、Green面の新クラスタに問題がないことを確認します。
  2. Blue面のIngressの .metadata.annotation.external-dns.alpha.kubernetes.io/aws-weigh を 0 に変更し、再デプロイします。
  3. Route53のレコードIDが"test-blue"になっているAレコード、AAAAレコード、TXTレコードを削除し、ExternalDNSによって再度登録されるのを待ちます。
  4. 同じくGreen面のIngressの .metadata.annotation.external-dns.alpha.kubernetes.io/aws-weigh を 100 に変更し、再デプロイします。
  5. Route53のレコードIDが"test-green"になっているAレコード、AAAAレコード、TXTレコードを削除し、ExternalDNSによって再度登録されるのを待ちます。
  6. これでGreen面の新クラスタに全部のトラフィックが流れます。問題ないことを確認後、以下コマンドでBlue面を削除します。
# Argo CDのパスワード確認
$ kubectl -n argocd get secret argocd-initial-admin-secret -o jsonpath="{.data.password}" | base64 -d

# App of Appsパターンの親Application削除
$ kubectl port-forward service/argocd-server -n argocd 8080:443
$ argocd login localhost:8080 --name admin --password XXXXXX
$ argocd app delete app-of-apps

# クラスタ削除
$ cd system/blue-cluster
$ terraform destroy

さいごに

Pod Identity便利!
これが言いたいだけだったりする。

Discussion