はじめに
弊社MNTSQではAWS上にMNTSQ CLMをはじめとする複数のサービスを展開していますが、サービス運用が軌道に乗るにつれて、社内利用の環境(AWSアカウント)が開発環境、QA環境、ステージング環境と用途によって増えていき、コストの増加が無視できない問題となってきました。そこで、GitHub Actionsを使用して、ECSサービスを夜間停止する仕組みを導入することにより、コストの削減を行いました。workflow_callを使用することにより、良い感じに再利用性のある仕組みが作れたと思うので、記事にしていきたいと思います。
※ 以降GitHub Actionsは単にワークフローと表記しています
要件の整理
弊社では、社内環境の停止を行うにあたって、以下のような要件を満たす必要がありました。
- 開発環境、QA環境、ステージング環境の3環境を対象に夜間停止を行う必要がある
- 平日は0時 ~ 8時, 休日は終日対象の環境を停止する
- 残業や休日稼働をする人がいるかもしれないので、手動での起動・停止ができる必要がある
構成
この構成のポイントは以下です
- 上位のコンポーネントから「AWS環境」->「管理対象のECSクラスタ」 -> 「ECSクラスタに所属するECSサービス」と操作対象をスコープダウンしている
- フロー全体の基点となる"Start Env", "Stop Env"のワークフローを、スケジュール(自動)とworkflow_dispatch(手動)の両方でキックできる
このような構成にすることにより各コンポーネントを再利用性が高い形で作成することができ、管理しなければならないワークフローの数も少なくすることができました。また、以下のような形に拡張することにより、RDSやEC2などの他のコンピューティングリソースを停止対象に追加することも容易です。
実装例
Stop Env (GitHub Actions)
フロー全体の基点となるワークフローです。スケジュール停止を行いたい場合は開発環境、QA環境、ステージング環境の3つのAWS環境に対して操作を行いたいですが、手動で起動を行う場合は特定のAWS環境のみを対象にしたいです。そのロジックをset-target
のjobに内包し、このjobのoutputについて、後続のstop-ecs-clusters
のjobをmatrixで起動することによって、ワークフローのトリガの種類による対象環境の差分を吸収しています。"Start Env"と"Stop Env"を分けているのは、schedule
をワークフローに記述する必要があったからです。workflow_dispatchで起動する際は環境を選択するだけなので、シンプルな使い心地になっていると思います。
Start Envはこのコードをコピぺして、操作を反転させればOKです。
# .github/workflows/control_stop_env.yml # 指定した環境を停止する # スケジュールで実行される場合は、development~stagingの環境を停止する # 手動で実行される場合は、inputで指定した環境を停止する name: "Stop Env" permissions: # wf_callを実行するためにはこの権限が必要 id-token: write contents: write on: schedule: - cron: '0 15 * * *' # JST 0:00 に自動実行 workflow_dispatch: inputs: environment: type: choice description: 'Environment to Stop' required: true default: 'development' options: - development - qa - staging jobs: # schedule起動の時は"development", "qa", "staging"を対象にする # workflow_dispatch起動の時は、inputで指定されたAWS環境を対象にする set-target: runs-on: ubuntu-latest outputs: target: ${{ steps.set.outputs.target }} steps: - name: Set Target Environment id: set run: | if [ "$GITHUB_EVENT_NAME" = "schedule" ]; then target='["development", "qa", "staging"]' else target='["${{ inputs.environment }}"]' fi echo "target=$target" >> $GITHUB_OUTPUT # 管理対象のECSクラスタを一括管理するWFをCallする stop-ecs-clusters: name: "Stop ECS Clusters" uses: ./.github/workflows/control_ecs_clusters.yml needs: set-target strategy: # 操作対象のAWS環境ごとに並列で起動する matrix: target: ${{fromJson(needs.set-target.outputs.target)}} with: environment: ${{ matrix.target }} action: STOP # stop-ecs-clustersと並列に、別のリソースを管理するWFをcallするjobを追加して拡張できる # stop-rds-clusters: # name: "Stop RDS Clusters" # uses: ./.github/workflows/control_rds_clusters.yml # needs: set-target # strategy: # matrix: # target: ${{fromJson(needs.set-target.outputs.target)}} # with: # environment: ${{ matrix.target }} # action: STOP
Control ECS (GitHub Actions)
管理対象のECSクラスタを一括停止するワークフローです。ただし、"管理対象のECSクラスタ"の部分の実態は、運用により大きく異なるかと思います。対象の判定ロジックをワークフロー, Lambdaのどちらに置くのが適切かは、ケースバイケースになるはずです。今回はシンプルに、ワークフロー内に管理対象のECSクラスタを列挙する形の実装例を置いておきます。
※ 弊社環境は開発環境内に複数の開発面を持っている都合上、管理すべきECSクラスタの数が多く、対象判定ロジックをワークフローとLambdaで分割して持つ、もう少し複雑な実装になっております。このサンプルコードは弊社のコードから余分な処理を削ぎ落としたものであり、実際の動作を確認したわけではないので、参考程度にご覧ください。
inputにはenvironment
(対象AWS環境)とaction
を要求します。action
はSTOP
or START
を渡し、後続のLambdaでECSサービスを停止するのか起動するのかを制御する変数です。別のワークフローから呼ばれるので、workflow_call
のブロックも記述しています。
# .github/workflows/control_ecs_clusters.yml # 指定した環境のECSクラスターを停止する name: "Control ECS Clusters" env: ASSUME_ROLE_ARN: "arn:aws:iam::%AWS_ACCOUNT_ID:role/oidc-gha-role" LAMBDA_UPDATE_ECS_CLUSTER_NAME: "update-ecs-cluster" permissions: id-token: write contents: write on: workflow_dispatch: inputs: environment: type: choice description: 'Environment to apply' required: true default: 'development' options: - development - qa - staging action: type: choice description: 'Action to apply' required: true default: 'STOP' options: - START - STOP workflow_call: inputs: environment: type: string required: true action: type: string required: true jobs: # 必要な変数を組み立てる # AWSアカウント毎の差分が.github/configs/aws/variables.ymlというファイルに記述されていることを前提としている setup-env: runs-on: ubuntu-latest outputs: ASSUME_ROLE_ARN: ${{ steps.setup-env.outputs.ASSUME_ROLE_ARN }} steps: - name: Checkout uses: actions/checkout@v4 - name: Setup ENV id: setup-env run: | ENV=${{ inputs.environment }} # 設定ファイルの<env>.AWS_ACCOUNT_IDというフィールドから、アカウントIDを取得している AWS_ACCOUNT_ID=$(yq -r ".[\"$ENV\"].AWS_ACCOUNT_ID" .github/configs/aws/variables.yml) ASSUME_ROLE_ARN=$(echo $ASSUME_ROLE_ARN | sed -e "s/%AWS_ACCOUNT_ID/$AWS_ACCOUNT_ID/g" -e "s/%ENV/$ENV/g") echo "ASSUME_ROLE_ARN=$ASSUME_ROLE_ARN" >> $GITHUB_OUTPUT # 対象のECSクラスタ毎に、所属するECSサービスに対して一括操作を行うLambdaを呼び出す control-ecs-clusters: needs: setup-env runs-on: ubuntu-latest strategy: matrix: # WF冒頭のenvブロックに記載するとfromJsonで展開できないので、ここに対象ECSクラスタを書く cluster: ${{ fromJson('["service1-cluster","service2-cluster"]') }} steps: - name: Configure AWS credentials uses: aws-actions/configure-aws-credentials@v4 with: aws-region: ap-northeast-1 role-to-assume: ${{ needs.setup-env.outputs.ASSUME_ROLE_ARN }} - name: Invoke Lambda UpdateEcsCluster run: | echo '{ "clusterName": "${{ matrix.cluster }}", "action": "${{ inputs.action }}" }' | jq -c > payload.json aws lambda invoke \ --function-name $LAMBDA_UPDATE_ECS_CLUSTER_NAME \ --payload file://payload.json \ --cli-binary-format raw-in-base64-out \ --invocation-type RequestResponse \ response.json
Update ECS Clusters (Lambda)
"clusterName"と"action"をinputとし、指定されたECSクラスタの全サービスのタスク数を更新するLambdaのサンプルコードを置いておきます。こちらはnode.22.xで動作を確認しているものになります。
import { ECSClient, ListServicesCommand, UpdateServiceCommand } from "@aws-sdk/client-ecs"; const ecsClient = new ECSClient({ region: "ap-northeast-1" }); export const handler = async (event) => { try { const clusterName = event.clusterName; const action = event.action; const desiredCount = action === "STOP" ? 0 : 1; if (!clusterName) { throw new Error("clusterName is required"); } if (action !== "START" && action !== "STOP") { throw new Error("action must be either START or STOP"); } console.log(`Processing cluster: ${clusterName}`); // クラスタ内の全サービスを取得 let nextToken; let serviceArns = []; do { const response = await ecsClient.send(new ListServicesCommand({ cluster: clusterName, nextToken })); serviceArns = serviceArns.concat(response.serviceArns); nextToken = response.nextToken; } while (nextToken); if (serviceArns.length === 0) { console.log(`No services found in cluster: ${clusterName}`); return { message: "No services found", clusterName }; } // 各サービスのタスク数を更新 await Promise.all(serviceArns.map(async (serviceArn) => { await ecsClient.send(new UpdateServiceCommand({ cluster: clusterName, service: serviceArn, desiredCount: desiredCount })); console.log(`Updated service ${serviceArn} to desiredCount: 0`); })); return { message: "Successfully updated services", clusterName, servicesUpdated: serviceArns.length }; } catch (error) { console.error("Error updating services:", error); return { error: error.message }; } };
おわりに
GitHub Actions を綺麗に実装するのはなかなか難しいですが、今回はシンプルで再利用性の高い形にできたと思うので紹介させていただきました。特に「操作対象をスコープダウンしながら設計する」という部分は、他のワークフローを作成する際にも役立つ考え方になるはずです。
SREのように内部改善やプラットフォーム維持を担うエンジニアは、直接的に売上を上げる機会が少ないからこそ、「コスト」に敏感である必要があります。ただし、コスト削減はそう単純ではなく、例えばテクニカルサポートや営業など、サービスを扱うすべての人が、将来に渡ってスムーズに業務を進められるかどうかも、見落としてはいけない「コスト」です。
業務全体を見渡してみると、もっと幅広い場面で GitHub Actions を活用できるはずです。そうした「小さな自動化の積み重ね」が、より良い運用環境を作っていくのだと思います。とりわけ、「夜間も環境が動いているのはもったいないよね」といったシンプルな課題は、落ちているチリ紙を拾うような気持ちでサクッと解決したいですね。
MNTSQ株式会社 SRE 西室