ステージング環境の夜間停止によるコスト削減

こんにちは。SRE の @chaspy です。

今回、ステージング環境のリソースを使用していない時間に削減することで、コストを削減したのでその事例を紹介します。

前提: Pull Request namespace

Quipper では本番環境、開発環境ともに Amazon EKS*1 を使用しています。また、開発環境として、Pull Request ごとに namespace が作られます。そして monorepo*2に存在するすべての Application が Deploy*3されます。これにより Pull Request をマージする前に動作確認をすることができます。Developer だけでなく Designer や Product Manager を含めて確認できるもので、Quipper 名物とも呼べる優れた仕組みです。

一方で、この仕組みによるコストの増加も否めません。Pull Request namespace のリソースに関しては Resource Requests をなるべく小さくしていますが、積み重なると結構なリソース量を要求することになります。

そもそも全ての Application を Deploy するのは富豪的ではないか、というのはもちろん理解していますが、まずは明らかに使用していない時間、夜間や週末に削減する方法を考えてみました。

方法1: AutoScalingGroup の MaxSize を 1 にする

シンプルな方法です。以下のようなスクリプトを書き、MAXSIZE を Jenkins から変更するジョブを実行します。単に aws (autoscaling) cli のラッパーです。

#!/bin/bash
set -eu
: ${MAX_SIZE}
STAGING_CLUSTERS=$(aws eks list-clusters | jq -r '.clusters[] | select(. | contains("staging"))')
for CLUSTER in ${STAGING_CLUSTERS}; do
  NODE_GROUPS=$(aws eks list-nodegroups --cluster-name ${CLUSTER} | jq -r '.nodegroups[]')
  for NODE_GROUP in ${NODE_GROUPS}; do
    ASG_NAME=$(aws eks describe-nodegroup --cluster-name ${CLUSTER} --nodegroup-name ${NODE_GROUP} | jq -r '.nodegroup.resources.autoScalingGroups[].name')
    aws autoscaling update-auto-scaling-group \
      --auto-scaling-group-name "${ASG_NAME}" \
      --max-size "${MAX_SIZE}"
  done
done

*4

とはいえ、緊急時など、夜間や週末に止むを得ず稼働するときはあるでしょう。その場合は MAXSIZE を元に戻すジョブを実行してもらえれば元通りになります。

しかし、柔軟性に欠けるのが問題です。仮に日中 50 台の Instance が要求されている状況で夜間使用する場合を考えてみましょう。その Developer が必要なのはたった1つの namespace 分のリソースだけかもしれませんが、50台すべて稼働されてしまうことになります。

この仕組みをまずは日本以外の Global の開発環境で実施しました。素晴らしいことに、緊急で夜間や休日に利用しているケースはありませんでした。また、タイムゾーンインドネシアの WIB*5、フィリピンの PHT*6、そして日本の JST とそこまで差がなく"夜間"にズレがないためうまくいきました。

方法2: Deployment / Rollouts の Replicas を 0 にする

方法1 と同じ仕組みを日本の開発環境に入れようと考えたのですが、日本側の開発者には PDT*7タイムゾーンで働いているひとがいます。こればっかりはさすがに"夜間"の定義が大きく異なるため、上記の仕組みは機能しないでしょう。namespace 単位で削減、あるいは復活ができる、より柔軟な仕組みが必要です。

そういうわけで、Pull Request namespace と特別な namespace の Deployment / Rollouts*8 の replicas を 0 にするスクリプトを書きました。

#!/bin/bash
set -ex
: "${KUBECONTEXT}"
REPLICAS="0"

function set_current_replicas_to_annotation () {
  # When scaling-in, the current replicas is stored in annotation 'quipper/replicas' and eill be used in scaling-out.
  local kind=$1
  local resource=$2
  local namespace=$3
  local current_replicas
  current_replicas=$(kubectl get "${kind}" "${resource}" -o=jsonpath='{.spec.replicas}' -n "${namespace}" --context "${KUBECONTEXT}")
  kubectl annotate --overwrite "${kind}" "${resource}" quipper/replicas="${current_replicas}" -n "${namespace}" --context "${KUBECONTEXT}" &
}

pr_namespaces=$(kubectl get ns --context "${KUBECONTEXT}" | grep pr- | awk '{print $1}')

other_namespaces="release develop"
namespaces="${pr_namespaces} ${other_namespaces}"

for ns in ${namespaces};
do
  deployments=$(kubectl get deploy -n "${ns}" --context "${KUBECONTEXT}" | awk 'NR>1{print $1}')
  for deploy in ${deployments};
  do
    set_current_replicas_to_annotation deploy "${deploy}" "${ns}"
    kubectl scale --replicas="${REPLICAS}" "deployment/${deploy}" -n "${ns}" --context "${KUBECONTEXT}" &
  done

  rollouts=$(kubectl get rollout -n "${ns}" --context "${KUBECONTEXT}" | awk 'NR>1{print $1}')
  for rollout in ${rollouts};
  do
    set_current_replicas_to_annotation rollout "${rollout}" "${ns}"
    kubectl patch rollout "${rollout}" --type merge -p "{\"spec\":{\"replicas\":${REPLICAS}}}" -n "${ns}" --context "${KUBECONTEXT}" &
  done
done

シンプルですね。Pull Request namespace と加えて特別な namespace を対象に、deployment と rollout の replicas を kubectl scale で 0 にしています。ポイントは現在の replicas を annotation に追加している点です。(set_current_replicas_to_annotation function)元に戻す際に、この値を読み取って復活させます。

スケールアウトはこんな感じです。

#!/bin/bash
set -ex
: "${KUBECONTEXT}"

function get_replicas () {
  local kind=$1
  local resource=$2
  local namespace=$3
  replicas=$(kubectl get "${kind}" "${resource}" -o=jsonpath='{.metadata.annotations.quipper/replicas}' -n "${namespace}" --context "${KUBECONTEXT}")
  if [ -z "${replicas}" ]; then
    echo "1" # If the annotation "quipper/replicas" is not set, return 1 as default.
  else
    echo "${replicas}"
  fi
}

# If you want to enable only a specific namespace, specify namespace and replicas
# If not specified, all pr namespaces are targeted.
if [ -z "$pr_namespaces" ]; then
  pr_namespaces=$(kubectl get ns --context "${KUBECONTEXT}" | grep pr- | awk '{print $1}')

  other_namespaces="release develop develop-tara edge rdev-1 rdev-2 rdev-3 rdev-4"
  namespaces="${other_namespaces} ${pr_namespaces}"
else
  namespaces="${pr_namespaces}"
fi

for ns in ${namespaces};
do
  deployments=$(kubectl get deploy -n "${ns}" --context "${KUBECONTEXT}" | awk 'NR>1{print $1}')
  for deploy in ${deployments};
  do
    replicas=$(get_replicas deploy "${deploy}" "${ns}")
    kubectl scale --replicas="${replicas}" "deployment/${deploy}" -n "${ns}" --context "${KUBECONTEXT}" &
  done

  rollouts=$(kubectl get rollout -n "${ns}" --context "${KUBECONTEXT}" | awk 'NR>1{print $1}')
  for rollout in ${rollouts};
  do
    replicas=$(get_replicas rollout "${rollout}" "${ns}")
    kubectl patch rollout "${rollout}" --type merge -p "{\"spec\":{\"replicas\":${replicas}}}" -n "${ns}" --context "${KUBECONTEXT}" &
  done
done

スケールアウトを行うスクリプトでは、pr_namespaces という環境変数を事前に指定すれば、その namespace のみスケールアウトさせることができます。

これによって、日本で夜間の時間に、自分が使いたい namespace だけ使用できる柔軟性を持つことができました。

効果

なんということでしょう。夜間と週末は見事に Instance 数が減っています。

f:id:quipper-ja:20201115234231p:plain
Global 側のインスタンス

f:id:quipper-ja:20201115234250p:plain]
日本側のインスタンス

コストは Global 側で月間 $8800 、日本側で月間 $7000 *9の削減に成功しました。このお金をより効果的なものに使えますね。ハッピー。

今後

特別に維持したい namespace をスケールインから除外する

開発者からのリクエストとして、負荷試験のように、数日使いっぱなしにしたいケースがあるそうです。

これに対応するためには、Pull Request に特別な Label をつけてもらい、その namespace は除外するような処理を入れる必要があります。

変更のない Application は Deploy しない

前述したように、各 Pull Request namespace には monorepo に含まれているすべての Application が Deploy されています。Resource Request が少量だとしても、この影響はかなり支配的であり、Application が増えれば増えるほどこの影響は顕著になります。

単純に Pull Request でのコード変更有無で Deploy を制御するのであれば簡単ですが、Application 間の依存関係を考慮する必要があります。Frontend Application を変更したら Backend Application も合わせて動作確認したいでしょう。

もともと Dependency Graph を作成するために、依存関係を表現する yaml を一定の規約で配置していたので、それをうまく利用したいと考えています。

namespace の生存期間を短縮し、復活を簡単にする

現在、Pull Request namespace の生存期間は3日に設定されています。これを1日に短縮することで、さらにコストの削減を行いたいと考えています。この環境は再度 Deploy*10すれば復活しますが、待ち時間が発生してしまいます。

開発者の生産性を落とさないために、一瞬で復活できるスクリプトを提供することでバランスを取りたいと考えています。原理的には CI scripts で行っているように Image ID などいくつかの環境変数を埋め込んで kustomize build を行い、apply をすれば良いはずです。

おわりに

今回、Developer Productivity をなるべく落とさずにコストを削減しました。インフラのコストはビジネスの継続性に影響を与える重要な要素です。今後も定期的にコストの変化をモニタリングし、削減可能な部分を技術で削減していきます。

Quipper では世界の果てまで学びを届けたい仲間を募集しています。なお、筆者が所属する SRE Team も募集中です。カジュアル面談もしくは応募お待ちしています。

*1:EKS への移行に関してはSelf-Hosted Cluster から EKS への移行と Platform の Production Readinessをご覧ください

*2:中央 git レポジトリ

*3:この仕組みについてはKubernetes 導入で実現したい世界とその先にある Microservices / モノレポの導入をご覧ください。

*4:Cluster 名に "staging" が含まれているものを jq の select でフィルタリングしています。

*5:Western Indonesian Time (Standard Time)

*6:Philippine Time (Standard Time)

*7:Pacific Daylight Time / Pacific Daylight Saving Time (Daylight Saving Time)

*8:Quipper は Argo Rollouts 本番導入企業です

*9:日本側は10月17日より開始して、$3500 削減したため、月間換算で単純に2倍しました

*10:CI を Rerun するか新たな commit を push すれば良い