こんにちは、コドモンSREチームの渡辺です。今年の夏はいつも以上に暑いですね。我が家のゴールデンレトリバーも夏バテ気味ですが、家族みんなで残暑を乗り切っています。
先日私たちのチームでは、EKSでPull Requestごとの動作確認環境(以下、PR環境)を自動構築する仕組みを導入しました。
本記事では、自動化の仕組みや工夫した点について紹介します。EKSにApp Meshを統合する作業など、公式ドキュメントだけでは難しい部分がありますが、本記事が導入の手助けになれば幸いです。
PR環境構築前の課題
弊社では、エンジニアが動作確認などの目的で自由に利用できる開発環境を運用しています。
ただ、開発環境は1セットのインフラリソースしか準備していないため、複数のチームが共有で利用している状態です。
コドモンでは毎月新しいメンバーがジョインしているため、このまま1つの環境を共有する状態を続けていると、エンジニアの開発体験が損なわれてしまうかもしれません。価値あるプロダクトを素早くユーザーに提供し続けるために、開発者体験の向上に着手することにしました。
PR環境自動構築の仕組み
上記の課題を解決するため、自動でPR環境をつくる仕組みを構築しました。
以下の流れになります。
- 開発者がGitHubでPRを作成する
- KubeTempura ControllerがGitHub Webhookを受け取り、必要なKubernetesリソースを作成する
- Github ActionsでアプリケーションイメージをBuildし、ECRにPushする
- 作成されたリソースにより、開発環境が構築される
- 1.2でBuildしたアプリケーションイメージを利用してPodが起動する
- PR環境ごと異なるドメイン(
pr123.pr-env.example.com
など)でPR環境にアクセスできるようになる
- 開発者が動作確認を行う
- PRにcommitをpushすると再度1.2のGithub Actionsが走り、アプリケーションイメージが更新される
- PRをマージするかクローズすると、KubeTempura ControllerがGitHub Webhookを受け取り、Kubernetesのリソースを削除する
PR環境の仕組みを構築する上で重要なPR環境をつくる仕組み
とPR環境が外部からのトラフィックを受け取る仕組み
について説明します。
PR環境をつくる仕組み
PR作成時に必要なリソースを構築するために、KubeTempuraを利用しました。
KubeTempuraとは、株式会社メルカリが公開しているOSSです。あらかじめ設定しておいたGithub Webhookをトリガーにして、KubeTempura ControllerがReviewAppに設定された定義を元に Kubernetesリソースを作成してくれます。詳しくは、株式会社メルカリの記事をご参照ください。
例えば、以下の定義ではNginxを起動するPodを管理するDeploymentが作成されます。githubRepository
にはトリガーとなるGitHubリポジトリを指定します。
apiVersion: kubetempura.mercari.com/v1 kind: ReviewApp metadata: name: reviewapp spec: githubRepository: https://github.com/<Owner>/<Repository> resources: - apiVersion: apps/v1 kind: Deployment metadata: name: nginx spec: replicas: 1 selector: matchLabels: app: nginx template: metadata: labels: app: nginx spec: containers: - name: nginx image: nginx:latest ports: - containerPort: 80
このReviewAppにおいてPR環境に必要なKubernetesリソースを定義することで、PR作成時に自動でPR環境が構築されるようになります。
PR環境が外部からのトラフィックを受け取る仕組み
KubeTempuraを活用することで、PR作成時にKubernetesリソースを作成する仕組みを構築することができましたが、PR環境が外部からのトラフィックを受け取るためには、PR環境ごとにドメインを用意する必要がありました。
EKSでは、PodがPrivateサブネットで構築される設定をしているため、外部通信用のインターフェースとしてALBを構築する必要があります。PR単位でALBを増やしていくとコストや複雑性が増すため、1つのALBですべてのPR環境のリクエストを受けるようにしました。
具体的には、Route53でALBにワイルドカードのサブドメインを紐づけ、各PR環境のルーティングはEKSで管理するようにしています。
EKSのルーティングには、サービスメッシュを提供するApp Meshを活用しました。App Meshでトラフィックを制御するルーティングのルールを作成して、PR環境ごとのドメインで各アプリケーションが起動しているPodへルーティングしています。
App Mesh導入には、App MeshコントローラーをEKSに構築し、各機能に対応したCRDを定義することでApp Meshのリソースを構築することができます。App Meshの各機能とKubernetesリソースの対応は以下のとおりです。
説明 | 対応する Kubernetes リソース | |
---|---|---|
仮想ゲートウェイ | メッシュ内のリソースへのトラフィックエントリーポイントを表す。 | VirtualGateway |
仮想サービス | メッシュの個々のマイクロサービスを表す。仮想ノードまたは仮想ルーターをターゲットにする。 | VirtualService |
仮想ルーター | トラフィックを特定の仮想ノードへとルーティングする。 | VirtualRouter |
仮想ノード | メッシュ内の具体的なタスクやサービスを表す。 | VirtualNode |
今回は、仮想ゲートウェイ(VirtualGateway)、仮想サービス(VirtualService)、仮想ノード(VirtualNode)を活用し、PR環境のネットワークトラフィックを構築しました。
App Meshコントローラー構築後、まずはApp Meshを作成するため以下のマニフェストを適用します。
apiVersion: appmesh.k8s.aws/v1beta2 kind: Mesh metadata: name: pr-env spec: namespaceSelector: matchLabels: mesh: pr-env egressFilter: type: ALLOW_ALL # Podから外部通信を許可する
namespaceSelector
には、mesh: pr-env
のラベルが設定された以下のnamespaceに適用されるようにしています。
apiVersion: v1 kind: Namespace metadata: name: pr-env labels: mesh: pr-env appmesh.k8s.aws/sidecarInjectorWebhook: enabled
次にALBからのリクエストをProxyする仮想ゲートウェイの設定を行います。
apiVersion: appmesh.k8s.aws/v1beta2 kind: VirtualGateway metadata: name: ingress-gw spec: namespaceSelector: matchLabels: mesh: pr-env podSelector: matchLabels: app: ingress-gw listeners: - portMapping: port: 8080 protocol: http logging: accessLog: file: path: /dev/stdout --- apiVersion: v1 kind: Service metadata: name: ingress-gw spec: ports: - port: 80 targetPort: 8080 name: http selector: app: ingress-gw --- apiVersion: apps/v1 kind: Deployment metadata: name: ingress-gw spec: replicas: 1 selector: matchLabels: app: ingress-gw template: metadata: labels: app: ingress-gw spec: serviceAccountName: pr-env-app containers: - name: envoy image: 840364872350.dkr.ecr.ap-northeast-1.amazonaws.com/aws-appmesh-envoy:v1.24.1.0-prod ports: - containerPort: 8080
事前にALBのリスナーに設定するTarget Groupを作成し、TargetGroupBindingリソースでTarget Groupと仮想ゲートウェイ用のServiceを紐付けることで、ALBから仮想ゲートウェイへリクエストを送ることができます。
apiVersion: elbv2.k8s.aws/v1beta1 kind: TargetGroupBinding metadata: name: ingress-gw-tgb spec: serviceRef: name: ingress-gw port: 80 targetGroupARN: <Target Group ARN> targetType: ip networking: ingress: - from: - securityGroup: groupID: <Security Group ID> # ALB security group ports: - protocol: TCP
これで、外部ネットワークからPrivateサブネットのPodにリクエストを送る準備ができました。
次に受け付けたリクエストをPR環境ごとのドメインにルーティングするため、ReviewAppにPR環境ごと必要なリソースを定義します。
apiVersion: kubetempura.mercari.com/v1 kind: ReviewApp metadata: name: reviewapp spec: githubRepository: https://github.com/<Owner>/<Repository> resources: - apiVersion: appmesh.k8s.aws/v1beta2 kind: GatewayRoute metadata: name: pr{{PR_NUMBER}} spec: httpRoute: match: hostname: exact: pr{{PR_NUMBER}}.pr-env.example.com action: target: virtualService: virtualServiceRef: name: pr{{PR_NUMBER}} - apiVersion: appmesh.k8s.aws/v1beta2 kind: VirtualService metadata: name: pr{{PR_NUMBER}} spec: awsName: pr{{PR_NUMBER}}.pr-env.svc.cluster.local provider: virtualNode: virtualNodeRef: name: pr{{PR_NUMBER}} - apiVersion: appmesh.k8s.aws/v1beta2 kind: VirtualNode metadata: name: pr{{PR_NUMBER}} spec: podSelector: matchLabels: app: app-pr{{PR_NUMBER}} listeners: - portMapping: port: 80 protocol: http serviceDiscovery: dns: hostname: pr{{PR_NUMBER}}.pr-env.svc.cluster.local # ServiceのFQDN - apiVersion: v1 kind: Service metadata: name: pr{{PR_NUMBER}} labels: app: app-pr{{PR_NUMBER}} spec: selector: app: app-pr{{PR_NUMBER}} ports: - protocol: TCP port: 80 targetPort: 80 - apiVersion: apps/v1 kind: Deployment metadata: name: pr{{PR_NUMBER}} labels: app: app-pr{{PR_NUMBER}} env: pr-env spec: replicas: 1 selector: matchLabels: app: app-pr{{PR_NUMBER}} template: metadata: labels: app: app-pr{{PR_NUMBER}} spec: serviceAccountName: pr-env-app containers: - name: app image: <AWS ACCOUNT ID>.dkr.ecr.<Region>.amazonaws.com/<ECR Repository>:pr{{PR_NUMBER}}-{{COMMIT_REF}} ports: - containerPort: 80 resources: requests: cpu: 100m memory: 256Mi limits: cpu: 200m memory: 512Mi
{{PR_NUMBER}}
や{{COMMIT_REF}}
は KubeTempuraが自動で置換するので、ECRにPushするイメージタグをpr<Number>-<Commit>
にすることで、PR環境ごとに異なるイメージをデプロイすることができます。
なお、PodはApp Meshの操作権限が必要になるため、IAM RoleをService Accountに紐づける必要があります。
module "pr_env_namespace_pod_role" { source = "terraform-aws-modules/iam/aws//modules/iam-role-for-service-accounts-eks" role_name = "prEnvAppRole" attach_appmesh_envoy_proxy_policy = true oidc_providers = { main = { provider_arn = <EKS OIDC PROVIDER ARN> namespace_service_accounts = ["pr-env:pr-env-app"] } } }
apiVersion: v1 kind: ServiceAccount metadata: annotations: eks.amazonaws.com/role-arn: arn:aws:iam::<AWS ACCOUNT ID>:role/prEnvAppRole name: pr-env-app
PR環境が必要ない場合の対応
KubetempuraはすべてのPRに対してリソースを作成しますが、軽微な修正などPR環境の構築が必要ない場合もあります。
インフラコストを考慮して、GitHub Actionsで行っているアプリケーションのBuildとPushは、PRにpr-env
ラベルが設定された際に実行するようにしています。
if: | ((github.event.action == 'labeled') && (github.event.label.name == 'pr-env')) || ((github.event.action == 'synchronize') && contains(github.event.pull_request.labels.*.name, 'pr-env'))
また、アプリケーションのイメージをつくらない場合でも、Kubernetesリソース自体は作成されるため、CronJobで定期的に不要なリソースを削除するようにしています。
package main import ( "context" "fmt" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/client-go/dynamic" "k8s.io/client-go/kubernetes" "k8s.io/client-go/rest" "k8s.io/client-go/tools/clientcmd" "k8s.io/client-go/util/homedir" "os" "path/filepath" "strconv" "time" ) func main() { namespace, age, config := setup() clientset, err := kubernetes.NewForConfig(config) if err != nil { panic(err.Error()) } dynamicClient, err := dynamic.NewForConfig(config) if err != nil { panic(err.Error()) } pods, err := clientset.CoreV1().Pods(namespace).List(context.TODO(), metav1.ListOptions{}) if err != nil { panic(err.Error()) } for _, pod := range pods.Items { if isPodOldAndFailing(pod, age) { deployment, err := getDeploymentFromPod(clientset, namespace, pod) if err != nil { fmt.Println(err.Error()) continue } deletePRs(dynamicClient, namespace, deployment) } } } func setup() (string, int, *rest.Config) { namespace := os.Getenv("NAMESPACE") if namespace == "" { namespace = "pr-env" } ageStr := os.Getenv("AGE") age, err := strconv.Atoi(ageStr) if err != nil { age = 100 } var config *rest.Config kubeconfig := filepath.Join(homedir.HomeDir(), ".kube", "config") // ローカルの.kube/configが存在しない場合はin-cluster configを使用 if _, err := os.Stat(kubeconfig); os.IsNotExist(err) { config, err = rest.InClusterConfig() if err != nil { fmt.Printf("Failed to get in-cluster config: %s\n", err.Error()) os.Exit(1) } } else { config, err = clientcmd.BuildConfigFromFlags("", kubeconfig) if err != nil { panic(err.Error()) } } return namespace, age, config } func isPodOldAndFailing(pod corev1.Pod, age int) bool { return time.Since(pod.ObjectMeta.CreationTimestamp.Time).Minutes() >= float64(age) && isImagePullBackOff(pod) } func isImagePullBackOff(pod corev1.Pod) bool { for _, containerStatus := range pod.Status.ContainerStatuses { if containerStatus.State.Waiting != nil && containerStatus.State.Waiting.Reason == "ImagePullBackOff" { return true } } return false } func getDeploymentFromPod(clientset *kubernetes.Clientset, namespace string, pod corev1.Pod) (*appsv1.Deployment, error) { for _, ownerRef := range pod.OwnerReferences { if ownerRef.Kind == "ReplicaSet" { rs, err := clientset.AppsV1().ReplicaSets(namespace).Get(context.TODO(), ownerRef.Name, metav1.GetOptions{}) if err != nil { return nil, err } for _, rsOwnerRef := range rs.OwnerReferences { if rsOwnerRef.Kind == "Deployment" { return clientset.AppsV1().Deployments(namespace).Get(context.TODO(), rsOwnerRef.Name, metav1.GetOptions{}) } } } } return nil, fmt.Errorf("no deployment found for pod %s", pod.Name) } func deletePRs(dynamicClient dynamic.Interface, namespace string, deployment *appsv1.Deployment) { // DeploymentのownerReferencesからprリソースを検索し、削除 prGVR := schema.GroupVersionResource{Group: "kubetempura.mercari.com", Version: "v1", Resource: "prs"} for _, ownerRef := range deployment.OwnerReferences { if ownerRef.Kind == "PR" { err := dynamicClient.Resource(prGVR).Namespace(namespace).Delete(context.TODO(), ownerRef.Name, metav1.DeleteOptions{}) if err != nil { fmt.Println(err.Error()) continue } fmt.Printf("Deleted pr: %s\n", ownerRef.Name) } } }
apiVersion: rbac.authorization.k8s.io/v1 kind: Role metadata: name: pr-cleanup rules: - apiGroups: - apps - "" - kubetempura.mercari.com resources: - deployments - pods - prs verbs: - get - list - delete --- apiVersion: rbac.authorization.k8s.io/v1 kind: RoleBinding metadata: name: pr-cleanup subjects: - kind: ServiceAccount name: pr-cleanup roleRef: kind: Role name: pr-cleanup apiGroup: rbac.authorization.k8s.io --- apiVersion: v1 kind: ServiceAccount metadata: name: pr-cleanup --- apiVersion: batch/v1 kind: CronJob metadata: name: pr-cleanup spec: schedule: "0 */1 * * *" # 1hおきに実行 successfulJobsHistoryLimit: 1 failedJobsHistoryLimit: 1 jobTemplate: spec: template: metadata: annotations: appmesh.k8s.aws/sidecarInjectorWebhook: disabled spec: serviceAccountName: pr-cleanup containers: - name: pr-cleanup image: <AWS ACCOUNT ID>.dkr.ecr.<Region>.amazonaws.com/<ECR Repository>:<Tag> env: - name: NAMESPACE value: "pr-env" - name: AGE value: "60" restartPolicy: Never
まとめ
今回、PR環境自動化の仕組みを構築するにあたり、さまざまな技術検証を行い、試行錯誤しながら設計から実装まで対応することができました。
コドモンでのKubernetesの活用は始まったばかりですが、今後も課題を解決できるようSREチームのメンバーと協力しながらやり遂げたいと思います。
最後に
コドモンでは、今後も新しいサービスの開発や既存サービスのリプレイスなどを予定しており、一緒に盛り上げてくださる方を募集しています。
一緒に働くことに興味を持ちましたら、ぜひ求人情報もご覧ください!