コドモン Product Team Blog

株式会社コドモンの開発チームで運営しているブログです。エンジニアやPdMメンバーが、プロダクトや技術やチームについて発信します!

EKSでPR環境の自動構築を導入した取り組み

こんにちは、コドモンSREチームの渡辺です。今年の夏はいつも以上に暑いですね。我が家のゴールデンレトリバーも夏バテ気味ですが、家族みんなで残暑を乗り切っています。

先日私たちのチームでは、EKSでPull Requestごとの動作確認環境(以下、PR環境)を自動構築する仕組みを導入しました。

本記事では、自動化の仕組みや工夫した点について紹介します。EKSにApp Meshを統合する作業など、公式ドキュメントだけでは難しい部分がありますが、本記事が導入の手助けになれば幸いです。

PR環境構築前の課題

弊社では、エンジニアが動作確認などの目的で自由に利用できる開発環境を運用しています。

ただ、開発環境は1セットのインフラリソースしか準備していないため、複数のチームが共有で利用している状態です。

コドモンでは毎月新しいメンバーがジョインしているため、このまま1つの環境を共有する状態を続けていると、エンジニアの開発体験が損なわれてしまうかもしれません。価値あるプロダクトを素早くユーザーに提供し続けるために、開発者体験の向上に着手することにしました。

PR環境自動構築の仕組み

上記の課題を解決するため、自動でPR環境をつくる仕組みを構築しました。

PR環境の構成図

以下の流れになります。

  1. 開発者がGitHubでPRを作成する
    1. KubeTempura ControllerがGitHub Webhookを受け取り、必要なKubernetesリソースを作成する
    2. Github ActionsでアプリケーションイメージをBuildし、ECRにPushする
  2. 作成されたリソースにより、開発環境が構築される
    1. 1.2でBuildしたアプリケーションイメージを利用してPodが起動する
    2. PR環境ごと異なるドメイン(pr123.pr-env.example.comなど)でPR環境にアクセスできるようになる
  3. 開発者が動作確認を行う
    1. PRにcommitをpushすると再度1.2のGithub Actionsが走り、アプリケーションイメージが更新される
  4. 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チームのメンバーと協力しながらやり遂げたいと思います。

最後に

コドモンでは、今後も新しいサービスの開発や既存サービスのリプレイスなどを予定しており、一緒に盛り上げてくださる方を募集しています。

一緒に働くことに興味を持ちましたら、ぜひ求人情報もご覧ください!