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をさらに簡略化したものです。
- EKS Pod Identity を活用して Terraform でプロビジョニングした EKS を Blue/Green アップグレードしてみた
- Amazon EKS Blueprints for Terraform
使っている技術ポイント
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に実装しています。
Blue-Green切り替え
- しばらく両クラスタを稼働させ、Green面の新クラスタに問題がないことを確認します。
- Blue面のIngressの .metadata.annotation.external-dns.alpha.kubernetes.io/aws-weigh を 0 に変更し、再デプロイします。
- Route53のレコードIDが"test-blue"になっているAレコード、AAAAレコード、TXTレコードを削除し、ExternalDNSによって再度登録されるのを待ちます。
- 同じくGreen面のIngressの .metadata.annotation.external-dns.alpha.kubernetes.io/aws-weigh を 100 に変更し、再デプロイします。
- Route53のレコードIDが"test-green"になっているAレコード、AAAAレコード、TXTレコードを削除し、ExternalDNSによって再度登録されるのを待ちます。
- これで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