アイリッジ開発者ブログ

アイリッジに所属するエンジニアが技術情報を発信していきます。

gitlab runnerを実行ジョブ数に応じてオートスケールする

開発部の斎藤です。主にAWSに対する、クラウドインフラの構築・運用保守を担当しています。

この度は弊社内で横断的に利用されているGitLab Shared Runnerのオートスケールを実装した話をしたいと思います。弊社で、GitLab Shared Runner を導入している背景は以前の記事をご参考ください。

課題感

弊社のGitLab Shared Runnerは従来、平日は4台のRunner用EC2インスタンスを常時起動、休日のみ起動しているインスタンス数を1台にするようにスケジュールスケーリングしておりました。しかし、このようなシンプルな運用ですと、以下のような課題がありました。

  • 平日深夜帯などのあまりCI/CDが使われない時間帯にも、過剰数のインスタンスが動き続ける
  • CI/CDの利用要求が多い時に、インスタンス数が足りず、ジョブに長い待ち時間が発生する
  • そもそもいつ、どれだけCI/CDのジョブが実行されているかが監視されていない

このような課題は、日中帯のCI/CDの利用要求が、そもそも予測できない問題に起因しています。したがって、CI/CDの利用要求が多い時、少ない時を検知して、オートスケールができればこの問題は解決します。ですが、GitLab Runnerのオートスケールは、一般的に用意されているCPU使用率・メモリ使用率によるスケーリングでは適切にスケールできず、容易に実現できるものではありません。

💡 通常、CI/CDジョブは、実行されるまでそれがどのくらいのCPUやメモリを必要とするかはわかりません。したがって、1つのGitLab Runnerで複数のジョブを実行してしまうと、既存のジョブの動作が、多くのリソースを必要とする新しいジョブによって妨げられる危険性があります。 このため弊社では、GitLab Runner1台につき、同時に実行できるジョブの数を1つに制限しています。しかし、実行されるジョブは常にCPUやメモリを多く消費するジョブとは限らないため、CPU使用率・メモリ使用率によるスケーリングだと、全てのGitLab Runner上でジョブが動いていたとしても、スケールされない可能性があり、意味のあるオートスケールとはなりません。

ただ幸いなことに、GitLab RunnerはPrometheusのメトリクスを持っていることと、CloudWatch AgentがPrometheusメトリクスを理解し、CloudWatchメトリクスとしてAWSに送信してくれることから、今回オートスケーリングを無事実装することができました。

今回作成したシステムの構成と利用したツール/サービスは以下の通りです。

ツール・サービス名 用途
EC2 - AutoScaling Group GitLab Runnerを実行するインスタンスをグルーピングする
EC2 - Lifecycle Hook GitLab Runnerの障害時、停止時にフックされ、対応するLambda関数を起動する
CloudWatch Metrics GitLab Runner上で実行されているジョブ数をログとして取得し、メトリクスに変換する
CloudWatch Logs GitLab Runner上で実行されているジョブ数をログとして取得する
CloudWatch Alarm GitLab Runnerがスケールすべき時にアラートを発生させ、SNS経由でLambda関数を起動する
CloudWatch Agent GitLab Runnerを実行するインスタンスに同時にインストールされる。GitLab RunnerのPrometheusメトリクスを取得してAWSに送信する
Simple Notification Service (SNS) CloudWatch Alarmのアラートを受け取って、Lambda関数を起動する
Lambda GitLab Runnerをスケールアウト/スケールインするときに起動され、AutoScaling GroupとCloudWatch Alarmを操作する
GitLab Runner GitLab CIにおけるCI/CDジョブを処理するサーバ。サーバ状態をPrometheusメトリクスとして提供する
💡 なお弊社では、GitLab RunnerをECS上のタスクやKubernetes上のPodとして運用することをせず、あえてEC2インスタンス上で実行しています。
ECS上で実行してしまうと、dockerコンテナのビルド時に利用するservicesの記述が.gitlab-ci.yml内でできなくなってしまうため、採用できません。
また、すべてのプロジェクトでKubernetesを利用することが前提となっていればKubernetesを実行環境として検討できます。しかし実際にはそうではなく、Kubernetesを前提としたCI/CDの仕組みは学習コストが高いため、これも採用していません。

対策の検討

GitLab Runnerは、Runnerの起動時オプションとして --listen-address=<ipアドレス>:<port番号> をつけてあげると、Prometheusのメトリクスを提供するサーバ (以降、exporterと呼ぶ) としても動作します。

実際に/metrics に対してcurlを実行してみると以下のような出力が得られます。

sh-4.2$ curl localhost:9252/metrics -s
# HELP gitlab_runner_api_request_statuses_total The total number of api requests, partitioned by runner, endpoint and status.
# TYPE gitlab_runner_api_request_statuses_total counter
gitlab_runner_api_request_statuses_total{endpoint="patch_trace",runner="bsF7hmuz",status="202"} 37
gitlab_runner_api_request_statuses_total{endpoint="request_job",runner="bsF7hmuz",status="201"} 12
gitlab_runner_api_request_statuses_total{endpoint="request_job",runner="bsF7hmuz",status="204"} 13882
gitlab_runner_api_request_statuses_total{endpoint="request_job",runner="bsF7hmuz",status="409"} 1
gitlab_runner_api_request_statuses_total{endpoint="update_job",runner="bsF7hmuz",status="200"} 20

... <中略> ...

# HELP process_virtual_memory_max_bytes Maximum amount of virtual memory available in bytes.
# TYPE process_virtual_memory_max_bytes gauge
process_virtual_memory_max_bytes -1

この中に gitlab_runner_jobs というメトリクスがあります。これは実際にRunner上で動いているジョブが一つ以上あるときに存在するメトリクスで、実際に今動いているジョブの数を提供してくれます。

sh-4.2$ curl localhost:9252/metrics -s | grep gitlab_runner_jobs
# HELP gitlab_runner_jobs The current number of running builds.
# TYPE gitlab_runner_jobs gauge
gitlab_runner_jobs{executor_stage="docker_run",runner="bsF7hmuz",stage="step_script",state="running"} 1
gitlab_runner_jobs{executor_stage="idle",runner="bsF7hmuz",stage="idle",state="idle"} 0

したがって、全てのRunnerがこのメトリクスをCloudWatchメトリクスとして連携し値の合計を出すことで、Shared Runner上で動いている総ジョブ数を得ることができます。

総ジョブ数がRunnerの数に達してしまったら、これ以上ジョブを受け付けることができなくなってしまいますので、スケールアウトを実施します。つまり、設定するCloudWatchアラームの閾値はその時々の Runnerの数 になります。

この動的な閾値を実現するために、AutoScaling Groupに搭載されているスケーリング機能ではなく、アラートを契機にLambda関数を起動し、閾値とAutoScaling Groupの必要数を同時に書き換えるという、自前のスケーリング機能を用意する必要があります。

実装内容

ここからはオートスケール実現に係る実装部分を説明し、必要に応じて周辺の実装もさらっとみていきます。

exporterの有効化

まずはGitLab Runnerを起動するコマンドです。こちらはインスタンスのユーザデータに仕込まれてあり、dockerコンテナを使ってRunnerを起動しています。先に説明したとおり、 --listen-address="0.0.0.0:9252" の部分で、exporterを有効化しています。

docker run \
    -d \
    --restart always \
    -p 9252:9252 \
    -v /home/ec2-user/.gitlab-runner:/etc/gitlab-runner \
    -v /var/run/docker.sock:/var/run/docker.sock \
    --name gitlab-runner \
    ${RUNNER_IMAGE} run \
    --listen-address="0.0.0.0:9252"

CloudWatch Agentの設定

次にCloudWatch Agentがexporterを見て、メトリクスをCloudWatchメトリクスに変換する設定を入れていきます。CloudWatch自体のインストールはAWSの公式ドキュメントをご参照ください。

まず、CloudWatchがexporterを見る設定ですが、以下の prometheus.yaml ファイルを作成します。

global:
  evaluation_interval: 1m
  scrape_interval: 1m
  scrape_timeout: 10s
scrape_configs:
- job_name: 'runner_exporter'
  sample_limit: 10000
  metrics_path: /metrics
  static_configs:
  - targets: ['localhost:9252']

scrape_configs キーの中で、実際にexporterとしてみるサーバを設定していきます。このように設定すると、 http://localhost:9252/metrics をexporterのエンドポイントとしてみるようになります。

次に、CloudWatch Agent本体の設定です。実際に設定しているCloudWatch Agentの設定ファイルは以下のようになります。重要なことは、 emf_processorの下の source_labels の値(ここでは ClusterName )が dimensions に含まれていることです。この条件を満たさないと、CloudWatch Logsにログは貯まりますが、CloudWatchメトリクスに変換されません。

{
    "agent": {
        "metrics_collection_interval": 60,
        "run_as_user": "xxxxx"
    },
    "logs": {
        "metrics_collected": {
            "prometheus": {
                "cluster_name": "gitlab-runners-cluster",
                "log_group_name": "gitlab-runners",
                "prometheus_config_path": "/opt/aws/amazon-cloudwatch-agent/bin/prometheus.yaml",
                "emf_processor": {
                    "metric_declaration_dedup": true,
                    "metric_namespace": "GitlabRunner",
                    "metric_declaration": [
                        {
                            "source_labels": ["ClusterName"],
                            "label_matcher": "^gitlab-runners-cluster$",
                            "dimensions": [["ClusterName"]],
                            "metric_selectors": [".*"]
                        }
                    ]
                }
            }
        },
        "force_flush_interval": 5
    }
}

💡 実はPrometheusのメトリクスを直接CloudWatchメトリクスとして送信することはできません。実際にはPrometheusメトリクスの情報を含む、特定フォーマットのログイベントをCloudWatch Logsに送ります。そうするとAWS側でCloudWatchメトリクスに自動的に変換されます。詳しくはこちらをご覧ください。

オートスケール用のlambda関数

実際のオートスケールは、以下のようなPythonの関数 scale_outscale_in をLambda関数に登録して実施します。難しいことは特にしてはおらず、scale 関数の中で、与えられた step_size の値だけ、AutoScaling Groupの必要数と、CloudWatchアラームの閾値を増減させています。これにより、スケールアウトする際に利用するCloudWatchアラームの閾値はその時々の Runnerの数 になります。

同じようにスケールインも動的な閾値をとるのですが、 閾値がRunnerの数 - 2 に変化させます。 Runnerの数 - 1 を設定してしまうと、スケールインとスケールアウトを無意味に行ったり来たりする挙動をとってしまうためです。これによりジョブ数が Runnerの数 のときはスケールアウト、 Runnerの数 - 1 のときには何もせず安定、 Runnerの数 - 2 以下の時は剰余インスタンスをスケールインするようになります。

また、閾値を満たしたらすぐにスケールインしてしまうと、急に新たにジョブがやってきたときにまたスケールアウトして、インスタンス内の初期化処理を走らせるオーバヘッドが発生するため、スケールインは閾値を15分間下回り続けたら実行するようにします。

さらに、ジョブが全くない状態の時には一気にスケールインできるように、CloudWatchアラームの状態を強制的に データ不足 にすることで実現しています。

def get_asg_capacities() -> Dict[str, int]:
    response = autoscaling.describe_auto_scaling_groups(
        AutoScalingGroupNames=["AUTO_SCALING_GROUP_NAME"]
    )
    return {
        "desired": response["AutoScalingGroups"][0]["DesiredCapacity"],
        "max": response["AutoScalingGroups"][0]["MaxSize"],
        "min": response["AutoScalingGroups"][0]["MinSize"],
    }

def scale(desired_capacity: int, step_size: int):
    try:
                scale_in_alarm = cloudwatch.describe_alarms(AlarmNames=["SCALE_IN_ALARM_NAME"])[
                    "MetricAlarms"
                ][0]
                scale_out_alarm = cloudwatch.describe_alarms(AlarmNames=["SCALE_OUT_ALARM_NAME"])[
                    "MetricAlarms"
                ][0]
        autoscaling.set_desired_capacity(
            AutoScalingGroupName="AUTO_SCALING_GROUP_NAME",
            DesiredCapacity=desired_capacity + step_size,
        )
        scale_out_alarm["Threshold"] = scale_out_alarm["Threshold"] + step_size
        scale_in_alarm["Threshold"] = scale_in_alarm["Threshold"] + step_size
        cloudwatch.put_metric_alarm(**scale_out_alarm)
        cloudwatch.put_metric_alarm(**scale_in_alarm)
    except Exception as e:
        post_message_to_slack(f"scaleが失敗しました!!: {e}")
        raise e

def scale_out(event, context):
    capacities = get_asg_capacities()
    desired_capacity = capacities["desired"]
    max_size = capacities["max"]
    if desired_capacity + STEP_SIZE <= max_size:
        scale(desired_capacity, STEP_SIZE)

def scale_in(event, context):
    capacities = get_asg_capacities()
    desired_capacity = capacities["desired"]
    min_size = capacities["min"]
    if desired_capacity - STEP_SIZE >= min_size:
        scale(desired_capacity, -STEP_SIZE)

    # スケールインの場合は連続でスケールインができるように
    # アラームの状態を「データ不足」に修正して再アラームさせる
    cloudwatch.set_alarm_state(
        AlarmName="SCALE_IN_ALARM_NAME",
        StateValue="INSUFFICIENT_DATA",
        StateReason="for continuous scale in...",
    )

結果

今までは最大並列数4ジョブ固定でしたが、オートスケールできるようになったことで要求に応じてRunnerの数を増減させ、より多くのジョブをさばくことができるようになりました。下のグラフは青線が実際に動いているjobの数、オレンジ線がRunnerの数を表しています。

数週間様子を見ると、弊社では同時実行ジョブ数の最大は10いかない程度であったため、現状、常に稼働している最低Runner数は3、最大Runner数を10に設定して運用しています。

なお結果的な話にはなりますが、PrometheusメトリクスおよびAWS CDKを利用したGitLab Runnerのデプロイ、管理手法については、執筆中にAWSからブログが出てきました。ぜひ、こちらもご参照いただければ幸いです。

まとめ

CI/CD Runnerの効率的なスケールは予測できない要求に対するスケールになるため、オートスケールが効果的であることが分かっていましたが、適切なメトリクスが取れないためこれまで何度か断念してきました。しかしEC2上で動くPrometheusメトリクスのexporterがいれば、CloudWatch Agentのありがたいアップデートのおかげで、現在はカスタムメトリクスとしてCloudWatchにメトリクスを送信できるようになっています。この手法はEC2上で動くexporterさえあれば、GitLab Runnerだけでなく、どんなPrometheusメトリクスのexporterでも実現できます。この記事がEC2を使ってCloudWatchにカスタムメトリクスを送りたいと考えている方々の参考になれば幸いです。