VISASQ Dev Blog

ビザスク開発ブログ

Google Cloud VMのパッケージアップデートをCloud Workflowsで自動化する

はじめに

こんにちは!基盤チームの酒井です。

毎年言っている気がしますが、特に今年の夏は暑すぎますね…。
アイスクリームではなく氷菓を食べると内臓が冷えて涼しくなるという情報を聞いてからガリガリ君ばかり食べています。
この間、なんと久しぶりに当たって小学生の頃を思い出して懐かしくなりました。

さて、そんな夏の思い出に浸っている場合ではありませんね。話は変わりますが、VMのパッケージアップデート、皆さんはどのように行っていますか?
Google CloudにはVM Managerのパッチという便利な機能がありますが、今回はCloud Workflowsを組み合わせて、さらに柔軟な自動化を実現してみました。

VM Managerのパッチ機能とは

cloud.google.com

VM managerのパッチを使えば、簡単にVMのパッケージのアップデートをすることができます。
スケジューリング機能や事前にコマンドを実行したり、diskのsnapshotをとることも可能です。

なぜパッチ機能をCloud Workflowsで実行したのか

VM Managerのパッチ機能は便利ですが、今回はCloud Workflowsを使ってパッチを実行することにしました。
その理由は、定期的に起動をするVMがあり、「起動 → パッケージアップデート → 停止」という一連のフローが必要だったためです。
また、Cloud Workflowsであれば完了後にslackに通知するなど拡張性もあります。

Cloud Workflowsの実行内容

workflow内で行なっている内容としては以下のとおりです。
(ちなみに、この記事を執筆中にVM Managerのパッチでもdiskのスナップショットが取得できることを知りました..。パッチ実行時にオプションを指定するのが良いかもしれません。)

1. 対象VMの起動と待機

停止しているVMを起動してからOS Config エージェントが起動し、
OS Inventoryに最新のパッケージ情報が反映されるまで、目安として1時間待機する設定にしています。

- startVM:
    call: googleapis.compute.v1.instances.start
    args:
        instance: ${instance}
        project: ${project_id}
        zone: ${zone}
    result: startResult
- wait:
    call: sys.sleep
    args:
        seconds: 3600

2. スナップショットの取得と待機

パッチを動かす前に、diskのsnapshotを取得します。
(以前パッチを実行したら、VMのパッケージの整合性が壊れてVMが起動しなくなったことがありました。)

- snapshot_disk:
    call: googleapis.compute.v1.disks.createSnapshot
    args:
        project: ${project_id}
        zone: ${zone}
        disk: ${instance}
        body:
            name: ${unique_snapshot_name} 
            description: ${"Automated snapshot (UTC time" + formatted_datetime_utc + ")"}
    result: snapshot_operation
- wait_for_snapshot:
    call: googleapis.compute.v1.zoneOperations.wait
    args:
        project: ${project_id}
        zone: ${zone}
        operation: ${snapshot_operation.name}
    result: snapshot_result

3. アップデート可能なパッケージ一覧の取得

パッチを実行するにあたり、アップデート可能なパッケージ一覧を取得します。
取得した後、パッチのパラメータに渡しやすい形に加工します。

- get_os_inventory_from_osconfig_full:
    call: http.get
    args:
        url: ${"https://osconfig.googleapis.com/v1/projects/" + project_id + "/locations/" + zone + "/instances/" + instance + "/inventory?view=FULL"}
        auth:
            type: OAuth2
    result: inventory_response
- check_api_response: # APIレスポンスコードを確認
    switch:
        - condition: '${inventory_response.code != 200}'
            raise: '${"OS Config APIからのインベントリ取得(FULL view)に失敗しました。HTTPコード " + string(inventory_response.code) + " レスポンスボディ " + string(inventory_response.body)}'
- prepare_items_map: # itemsマップを安全に取得
    assign:
        - inventory_items_map: ${default(inventory_response.body.items, empty_map_for_default)}
- loop_through_keys:
    for:
        in: ${keys(inventory_items_map)} # マップ変数を直接指定
        value: current_key # ループ変数がキー名になる
        index: i
        steps:
        - check_available_package:
            switch:
                - condition: '${len(text.find_all_regex(current_key, "^availablePackage-")) > 0}'
                    next: extract_package_name
            next: continue_loop
        - extract_package_name:
            assign:
                # "availablePackage-"プレフィックスを削除
                - key_without_prefix: ${text.replace_all(current_key, "availablePackage-", "")}
                # 最初の":"までの部分を取得(パッケージ名のみ)
                - package_name: ${text.split(key_without_prefix, ":")[0]}
                - extracted_package_names: ${list.prepend(extracted_package_names, package_name)}
        - continue_loop:
            assign:
                - dummy: "continue"  # 何もしない(ループを続行)

4. パッチジョブの実行と待機

取得したパッケージ一覧を元に、パッチジョブを実行します。
パッチジョブの完了を監視し、終了次第次のstepに進みます。

- execute_patch_job:
    call: http.post
    args:
        url: ${"https://osconfig.googleapis.com/v1/projects/" + project_id + "/patchJobs:execute"}
        auth:
            type: OAuth2
        body:
            description: ${patch_description}
            instanceFilter:
                instanceNamePrefixes:
                - ${instance}
            displayName: test-patch-job
            patchConfig:
                yum:
                    exclusivePackages: ${extracted_package_names}
            # dryRun: true
    result: patch_job_execution_response
- wait_for_patch_job_completion:
    call: sys.sleep
    args:
        seconds: 30  # パッチジョブの開始を待つ
- monitor_patch_job:
    steps:
        - get_patch_job_status:
            call: http.get
            args:
                url: ${"https://osconfig.googleapis.com/v1/" + patch_job_execution_response.body.name}
                auth:
                    type: OAuth2
            result: patch_job_status_response
        - check_patch_job_completion:
            switch:
                - condition: '${patch_job_status_response.body.state == "SUCCEEDED"}'
                    next: patch_job_completed
                - condition: '${patch_job_status_response.body.state == "COMPLETED_WITH_ERRORS"}'
                    next: patch_job_completed
                - condition: '${patch_job_status_response.body.state == "CANCELED"}'
                    next: patch_job_completed
                - condition: '${patch_job_status_response.body.state == "TIMED_OUT"}'
                    next: patch_job_completed
            next: wait_and_retry
        - wait_and_retry:
            call: sys.sleep
            args:
                seconds: 60  # 1分待機してから再確認
            next: get_patch_job_status
        - patch_job_completed:
            assign:
                - final_patch_job_status: ${patch_job_status_response.body}

5. VMを終了する

- stopVM:
    call: googleapis.compute.v1.instances.stop
    args:
        instance: ${instance}
        project: ${project_id}
        zone: ${zone}
    result: stopVMResult

ファイル全体

main:
    params: [input]
    steps:
    - init:
        assign:
            - project_id: ${sys.get_env("GOOGLE_CLOUD_PROJECT_ID")}
            - patch_description: "Workflow triggered patch job"
            - current_time_utc: ${sys.now()} # 現在時刻をUTCで取得
            - zone: us-central1-a
            - instance: gce-vm # VM名を変数から取得
            - extracted_package_names: []  # 抽出結果を格納するリストを初期化
            - empty_map_for_default: {}
    - format_snapshot_name:
        assign:
            - current_time_str: ${string(current_time_utc)}
            - year: ${text.substring(current_time_str, 0, 4)}
            - month: ${text.substring(current_time_str, 5, 7)}
            - day: ${text.substring(current_time_str, 8, 10)}
            - hour: ${text.substring(current_time_str, 11, 13)}
            - minute: ${text.substring(current_time_str, 14, 16)}
            # VM名を YYYYMMDD-HHMM (UTC) 形式に修正
            - formatted_datetime_utc: ${year + month + day + "-" + hour + minute}
            - snapshot_base_name: bastion-prod-memorystore-proxy-gce
            # アンダースコアをハイフンに変更し、命名規則に準拠
            - unique_snapshot_name: ${text.to_lower(snapshot_base_name + formatted_datetime_utc)}
    - startVM:
        call: googleapis.compute.v1.instances.start
        args:
            instance: ${instance}
            project: ${project_id}
            zone: ${zone}
        result: startResult
    - wait:
        call: sys.sleep
        args:
            # VMが起動してからOS Inventryが読み込まれるのを待つ(1時間は目安)
            seconds: 3600
    - snapshot_disk:
        call: googleapis.compute.v1.disks.createSnapshot
        args:
            project: ${project_id}
            zone: ${zone}
            disk: ${instance}
            body:
                name: ${unique_snapshot_name} # 修正されたスナップショット名
                description: ${"Automated snapshot (UTC time" + formatted_datetime_utc + ")"}
        result: snapshot_operation
    - wait_for_snapshot:
        call: googleapis.compute.v1.zoneOperations.wait
        args:
            project: ${project_id}
            zone: ${zone}
            operation: ${snapshot_operation.name}
        result: snapshot_result
    - get_os_inventory_from_osconfig_full:
        call: http.get
        args:
            url: ${"https://osconfig.googleapis.com/v1/projects/" + project_id + "/locations/" + zone + "/instances/" + instance + "/inventory?view=FULL"}
            auth:
                type: OAuth2
        result: inventory_response
    - check_api_response: # APIレスポンスコードを確認
        switch:
            - condition: '${inventory_response.code != 200}'
              raise: '${"OS Config APIからのインベントリ取得(FULL view)に失敗しました。HTTPコード " + string(inventory_response.code) + " レスポンスボディ " + string(inventory_response.body)}'
    - prepare_items_map: # itemsマップを安全に取得
        assign:
            - inventory_items_map: ${default(inventory_response.body.items, empty_map_for_default)}
    - loop_through_keys:
        for:
            in: ${keys(inventory_items_map)} # マップ変数を直接指定
            value: current_key # ループ変数がキー名になる
            index: i
            steps:
            - check_available_package:
                switch:
                    - condition: '${len(text.find_all_regex(current_key, "^availablePackage-")) > 0}'
                      next: extract_package_name
                next: continue_loop
            - extract_package_name:
                assign:
                    # "availablePackage-"プレフィックスを削除
                    - key_without_prefix: ${text.replace_all(current_key, "availablePackage-", "")}
                    # 最初の":"までの部分を取得(パッケージ名のみ)
                    - package_name: ${text.split(key_without_prefix, ":")[0]}
                    - extracted_package_names: ${list.prepend(extracted_package_names, package_name)}
            - continue_loop:
                assign:
                    - dummy: "continue"  # 何もしない(ループを続行)
    - execute_patch_job:
        call: http.post
        args:
            url: ${"https://osconfig.googleapis.com/v1/projects/" + project_id + "/patchJobs:execute"}
            auth:
                type: OAuth2
            body:
                description: ${patch_description}
                instanceFilter:
                    instanceNamePrefixes:
                    - ${instance}
                displayName: test-patch-job
                patchConfig:
                    yum:
                        exclusivePackages: ${extracted_package_names}
                # dryRun: true
        result: patch_job_execution_response
    - wait_for_patch_job_completion:
        call: sys.sleep
        args:
            seconds: 30  # パッチジョブの開始を待つ
    - monitor_patch_job:
        steps:
            - get_patch_job_status:
                call: http.get
                args:
                    url: ${"https://osconfig.googleapis.com/v1/" + patch_job_execution_response.body.name}
                    auth:
                        type: OAuth2
                result: patch_job_status_response
            - check_patch_job_completion:
                switch:
                    - condition: '${patch_job_status_response.body.state == "SUCCEEDED"}'
                      next: patch_job_completed
                    - condition: '${patch_job_status_response.body.state == "COMPLETED_WITH_ERRORS"}'
                      next: patch_job_completed
                    - condition: '${patch_job_status_response.body.state == "CANCELED"}'
                      next: patch_job_completed
                    - condition: '${patch_job_status_response.body.state == "TIMED_OUT"}'
                      next: patch_job_completed
                next: wait_and_retry
            - wait_and_retry:
                call: sys.sleep
                args:
                    seconds: 60  # 1分待機してから再確認
                next: get_patch_job_status
            - patch_job_completed:
                assign:
                    - final_patch_job_status: ${patch_job_status_response.body}
    - stopVM:
        call: googleapis.compute.v1.instances.stop
        args:
            instance: ${instance}
            project: ${project_id}
            zone: ${zone}
        result: stopVMResult
    - return_result:
        return:
            snapshot_name: ${snapshot_result.targetLink}
            patch_job_name: ${patch_job_execution_response.body.name}
            patch_job_final_status: ${final_patch_job_status.state}

まとめ

今回は定期的にしか起動しないVMのパッケージアップデートを自動化しました。

Cloud Workflowsを使うことで、「VMの起動・停止」「スナップショット取得」「パッチ実行」など、一連の複雑なプロセスを単一のワークフローとして定義できる拡張性の高さを実感しました!

パッチジョブで基本的には十分だと思いますので使うことは少ないかもしれませんが、参考になれば幸いです!

ビザスクではエンジニアの仲間を募集しています! 少しでもビザスク開発組織にご興味を持たれた方は、ぜひ一度カジュアルにお話ししましょう! developer-recruit.visasq.works