電子署名するだけにAWS CloudHSMは高すぎる!~AWS CloudHSM 環境復元の自動化でコスト99%削減~

はじめに

お久しぶりです、オプティムの今別府です。
前回、以下の記事でAWS CloudHSM入門をしましたが、DS Windowsチームではこちらの環境を用いて署名を行うCI環境を構築しました。
今回はAWS CloudHSMを用いた、Windows AgentのビルドCI環境構築とその対応をするに至った課題について記載していこうと思います。

tech-blog.optim.co.jp

なお、実装当初はSDK3でしか構築できませんでしたが、2024年12月ごろからSDK5でもWindowsのKSPが利用可能になったことを確認しております。
すでにSDK5での使用も可能ではありますが、本記事での自動化の環境構築にあたっては CloudHSM ClientのSDK3を使用しています。

docs.aws.amazon.com

また、本記事構成ではAWS CloudHSM において、hsm1.medium のインスタンスタイプを使用していますが、
以下の通知の様に2025 年 12 月 1 日にサポートが終了します。
サポート終了までにhsm2m.medium への移行が必要ですが、本記事では hsm1.medium を使用した構成となるため、
hsm2m.medium を利用する場合は同様の構成が出来ない可能性もあるためご注意ください。

docs.aws.amazon.com

前回のおさらい

前回は物理的なUSBトークンからの脱却をし、署名の利便性がだいぶ上がりました。
CloudHSM環境の構築から署名までのおおまかな手順を並べるとこんな感じです。

  1. クラスターの作成
  2. EC2のセキュリティグループの設定
  3. クラスター内にHSMを作成する
  4. ビルドマシンのEC2とCloudHSMの接続
  5. CloudHSMのアクティベート
  6. 証明書の発行・インポート
  7. ファイルやバイナリの署名

このように並べるとすごく手順が多く見えますが、これらの手順は運用によっては署名を除き本当の初回のみの手順となります。
そう、運用によっては...

CloudHSMの運用コスト

先ほど運用によっては、初回のみの手順としたのには理由があります。
その理由はCloudHSMのコストです。
CloudHSMを使う場合、AWSの他サービスと同じようにサービス使用料を支払う必要があります。

aws.amazon.com

その料金は各HSMごとに1時間当たりで課金されオレゴンを選んだ場合、1.45 USD/hがかかることになります。
1年間このHSMを構築後放置したとすると、
365(day)×24(hour)×1.45(USD)= 12702(USD/year)となります。
2025年2月時点で簡単に150円で換算すると日本円では1905300円になります。
ざっと200万円すごいですね....

CloudHSMの暗号関連機能をメインとして各サービスを展開している場合は、
この費用も経費として考えるしかないのですが、
弊社ではCloudHSMを用いるのはWindowsエージェントのリリース時のみ、
さらにいうなら、1回あたりバイナリビルド時の数時間しか使用しません。

そう考えると、CloudHSMのみに年間200万円といったコストを支払うことは見合いません。

弊社でCloudHSM環境を構築した際、
幸いなことに、CloudHSMは初期構築さえすれば、クラスター内証明書などの情報は消えず、
クラスターに存在するHSMを作成・削除する運用が可能であることがわかりました。
また、CloudHSMで課金されるのはHSMが存在している間であり、クラスター自体はそのまま残っていても問題ないことが判明しました。
そのため、CloudHSMを使用したいときに環境をHSM復元し署名のために使い、使い終わったらCloudHSM環境のHSM削除をする運用が可能です。

HSMの復元作業の手間

CloudHSMは初期構築を行った後、HSMの作成・削除といった運用が可能ですが、
HSMを使用する際には最低でも以下のような前提と手順が必要です。

  1. [前提]初期構築済みのCloudHSMクラスターとそれと接続できるEC2が存在する。
  2. クラスター内にHSMを作成する。
  3. ビルドマシンのEC2とCloudHSMを接続する。

手順的には数手順のみですが、EC2とAWSのCloudHSMコンソールを行き来して毎回行うのは面倒です。
また、このような数手順でさえも人手で行うと、どれだけ気を付けていてもミスが発生し、
トラブルシューティングに時間を要してしまうこともあると思います。
なので、今回はこの手順を社内のWindowsエージェントのビルドCIに組み込み、
ビルドのタイミングで自動でHSMの環境準備を行い、署名までできるようにしました。

バイナリのビルドと一緒にCIに組み込む

先述したように運用コストや構築の手間などがあり、最終的にはWindowsエージェントのビルドCIに組み込む形で自動化を行いました。

構成

今回構築したシステム構成図はこちらです。

各コンポーネント概要

  • GitLab CI/CD:ビルドPipelineの制御
  • EC2インスタンス:Windowsバイナリのビルド環境
  • GitLab CI Runner (Docker executor): HSM作成の基本処理
  • CloudHSMクラスター:証明書管理と署名処理
  • AWS Lambda:HSMの制御

また、処理の大まかな流れは以下のようになります。

処理概要

  1. GitLab上でビルドPipelineを開始
  2. ビルドマシンの起動確認(Check Biz Windows Willow)
    ※Biz Windows WillowはEC2に構築されているWindowsエージェントのビルドマシン
  3. HSMの作成(Create HSM)
  4. HSMとビルドマシンの接続(Config Eni ip)
  5. エージェントビルド
  6. HSMの削除

APIを叩いたり、実行環境をまたいだりと複雑そうに見えますが、 1つ1つの処理自体は数が少なく単純なものになっています。
以降ではそれぞれのフェーズの詳細を記載します。

Pipelineの制御

システムの起点はGitLab CI/CDのPipelineの起動から始まります。
今回はリリースビルドの際のバイナリビルド⇒署名を目的としているため、
コミットや通常のMRマージ起点の発火ではなくバイナリビルドを行いたいブランチへのtag打ちを起点としています。

tag打ちを起点に全部で以下のようなjobが走ることになります。
(正確にはafter_buildのあとにリリース用のjobがありますが割愛)
※after_buildの中にcreate_updといったjobがありますが、今回関係ないjobなのでご放念ください。

ビルドマシンの起動確認[Check EC2 is running]

今回のシステムでは、ビルドによる各生成バイナリを署名していくことが主な作業になります。
ビルドの詳細は割愛しますが基本的には、

  1. バイナリのビルド
  2. ビルドされたバイナリの署名

といった流れになります。
構成上ビルドマシンはEC2(Windows Server)のShell Executor、HSM作成・削除はDocker Executorを使用するため、
ビルドマシンが起動していない状態だと、ただただHSMが使われないまま課金されるといった事態になりえてしまいます。
そういった状態を避けるため、最低限ビルドマシンが起動している状態であることを担保するため確認を行うjobを挟むことで対応しました。
job自体は特に複雑なものではなくEC2上のRunnerでjobを動かし、
「job成功した場合 = EC2は起動済み」といった確認方法で簡単にechoでの出力を行います。

.gitlab-ci.yml例

check_ec2_running:
  stage: prepare_hsm
  script:
    - echo "EC2 is running"

HSMの作成[Create HSM]

HSMの作成jobで行っているのは以下です。

  1. HSM作成Ruby Scriptの実行
    • HSMの作成と作成したHSMのENI IPを出力する
  2. 変数をGitLab CI/CDのdotenvの機能を使って、次のjobにENI IPを渡せるようにする

本jobでの肝は1の処理になるので1の処理を行うRuby Scriptの詳細を後に記載します。
1でHSMを作成した後、EC2とCloudHSMの接続にはHSMごとに付与されるENI IPが必要となります。
今回のシステム構成上接続を行うEC2とHSM作成のRunnerは別物(WindowsのShell ExecutorとDocker Executor)のため、
job間の変数受け渡しのために、2の処理で.envファイルに値を出力し次のjobに渡すような形をとっています。

なお、メインのRuby Script実行前にLAMBDA_FUNCTION_NAMEをexportしていますが、 こちらはスクリプト内で環境変数を使用するためです。他スクリプトで使用するAWS周りの変数(AWS_DEFAULT_REGIONなど)については、
GitLab CI/CDの定数としてリポジトリで事前に登録しているものを使用しています。

.gitlab-ci.yml例

create_hsm:
  image:
    name: ruby:3.3.0
  stage: prepare_hsm
  before_script:
    - cd .gitlab/ci/script/CreateHsmRuby
    - gem install bundler
    - bundle config set path 'vendor/bundle'
    - bundle install
    - export LAMBDA_FUNCTION_NAME="createHsm"
  script:
    # CreateHSM.rbの実行には以下の環境変数が存在することが前提になっているので設定すること
    # AWS_DEFAULT_REGION : ec2のリージョン
    # AWS_ROLE_FOR_LAMBDA_INVOKE : lambdaのarn
    # AWS_ACCESS_KEY_ID : accesskeyのid
    # AWS_SECRET_ACCESS_KEY : secret access key
    # LAMBDA_FUNCTION_NAME : 実行したいlambda function name
    - HSM_ENIIP=$(bundle exec ruby create_hsm.rb)
    - echo "HSM_ENIIP=$HSM_ENIIP" >> $CI_PROJECT_DIR/hsm.env
    - cat $CI_PROJECT_DIR/hsm.env
  artifacts:
    reports:
      dotenv: hsm.env
HSM作成Ruby Script

このScriptの中ではHSM作成にかかわる処理として主に以下のことを行っています。

  1. AWS Lambda Functionを実行するために、AssumeRoleを行い、STS(AWS Security Token Service)で一時トークンを発行
  2. ループ処理でHSM作成用Lambda Functionを実行
    • HSMが作成されていない場合、1分間隔で再実行
    • HSMが作成された場合ループを抜ける
  3. HSM作成Lambda Functionのresponseに含まれるENI IPを出力

実際のスクリプト例は以下です。

# frozen_string_literal: true

require 'json'
require './aws_sts'
require './aws_lambda'

# 環境変数が存在していることが前提なので、定義されているかチェック
def check_aws_envs
  [
    'AWS_DEFAULT_REGION',         # ec2のリージョン
    'AWS_ROLE_FOR_LAMBDA_INVOKE', # lambdaのarn
    'AWS_ACCESS_KEY_ID',          # accesskeyのid
    'AWS_SECRET_ACCESS_KEY',      # secret access key
    'LAMBDA_FUNCTION_NAME',       # 実行したいlambda function name
  ].each do |key|
    raise "please set env : #{key}" unless ENV[key]
  end
end

check_aws_envs

temp_credentials = assume_role(
  ENV['AWS_ROLE_FOR_LAMBDA_INVOKE'],
  create_sts_client(ENV['AWS_DEFAULT_REGION'], ENV['AWS_ACCESS_KEY_ID'], ENV['AWS_SECRET_ACCESS_KEY']),
  'used-at-gitlab-ci',
  900 # 30minは要らないので15minにする
)

# lambdaから帰ってくるresponseの形式例は以下
# {
#   "statusCode": 200,
#   "body": {
#     "Hsm": {
#       "AvailabilityZone": "us-west-2d",
#       "ClusterId": "cluster-hogehogehugahuga",
#       "SubnetId": "subnet-hogehogehugahuga",
#       "EniId": "eni-hogehogehugahuga",
#       "EniIp": "172.31.77.66",
#       "HsmId": "hsm-2m3z7dqwamj",
#       "State": "ACTIVE",
#       "StateMessage": "HSM created."
#     }
#   }
# }

hsm_state = nil
hsm_eniip = nil

loop do
  # はじめのsleepはスキップ
  sleep 60 if hsm_state

  warn "invoke lambda #{ENV['LAMBDA_FUNCTION_NAME']}"
  resp = invoke_lambda(ENV['AWS_DEFAULT_REGION'], temp_credentials, ENV['LAMBDA_FUNCTION_NAME'])
  resp_payload = JSON.parse(resp.payload.string)

  warn "lamda response : #{resp_payload}"
  raise 'create hsm is error!!' if resp_payload['statusCode'] != 200

  hsm_info = resp_payload['body']['Hsm']

  # API Reference(https://docs.aws.amazon.com/cloudhsm/latest/APIReference/API_Hsm.html#CloudHSMV2-Type-Hsm-State)によると
  # hsm_info["State"]が取りうるのは以下
  # Valid Values: CREATE_IN_PROGRESS | ACTIVE | DEGRADED | DELETE_IN_PROGRESS | DELETED
  # CREATE_IN_PROGRESS | DELETE_IN_PROGRESS | DELETED の場合は、HSMを作りたいのでloopを続けるが
  # DEGRADEDは状態が謎なのでエラーを出すことにする。
  hsm_state = hsm_info['State']
  hsm_eniip = hsm_info['EniIp']
  warn "hsm_state: #{hsm_state}, hsm_eniip: #{hsm_eniip}"
  raise 'HSM state is DEGRADED. Pelase check cloudHSM in aws console.' if hsm_state == 'DEGRADED'

  break if hsm_state == 'ACTIVE'
end

puts hsm_eniip

以下でそれぞれの手順での詳細を説明していきます。

1. AWS Lambda Functionを実行するために、AssumeRoleを行い、AWS STS(AWS Security Token Service)で一時トークンを発行

今回のHSMの作成では1の処理のようにAWS STS(AWS Security Token Service)を利用しました。
STSとは一時的なトークを発行してくれるサービスとなります。
AWSでAPIを使用する際に使えるトークンとしては例としては以下のようにいくつか存在します。

  • 対象の操作ができるユーザーの無期限のアクセストークン
  • IAM Identity Center(SSO)で発行した期限付きトークン
  • STSの一時トークン

ユーザーごとに発行し、ずっと使えるようなトークンを使用する方法もありましたが、 これはセキュリティ上好ましくない方法なので、候補から外しました。
そうすると、一時的なトークンが欲しくなりますが、毎回SSOでの発行は手間でしたので、 先に述べたように一時トークンを発行でき、IAMによるRole制限も可能なセキュリティ的にも安全なSTSの方法を採用しました。

docs.aws.amazon.com

STSを使ったLambda Functionの実行では以下のような仕組みで処理を行っています。

  1. 事前準備

    1. STSのkey発行用AssumeRole用ロール(ロール1)を作成し、そのロールを付与したユーザを作成 ロール1の例

         {
             "Version": "2012-10-17",
             "Statement": {
                 "Effect": "Allow",
                 "Action": "sts:AssumeRole",
                 "Resource": "*"
             }
         }
      
    2. Lambda Functionを実行することができるロール2を作成し、手順1-1で作成したAssumeRoleにのみ使用できるような信頼ポリシーを付与する ロール2の例

       {
         "Version": "2012-10-17",
         "Statement": [
             {
                 "Effect": "Allow",
                 "Action": "lambda:InvokeFunction",
                 "Resource": "*"
             }
         ]
       }
      

      ロール2の信頼ポリシーの例

       {
         "Version": "2012-10-17",
         "Statement": [
             {
                 "Effect": "Allow",
                 "Principal": {
                     "AWS": "AssumeRoleに使うUserのarn",
                     "Service": "lambda.amazonaws.com"
                 },
                 "Action": "sts:AssumeRole"
             }
         ]
       }
      
  2. AssumeRoleができるユーザーのAccesskeyを保持しておく

  3. 2のAccessKeyを使ってAssumeRoleを行い、STSによる一時トークンの発行
  4. 一時トークンを使って、Lambda Functionを実行する

こちらの方法ではAssumeRoleを使用しています。
AssumeRoleは一時的に対象のアクションに必要なRoleをAssumeする(引き受ける)ことができる機能です。
これによって、ここでは一時的にAWS Lambda Functionを実行できるロールを持ったトークンを発行し、それを使用して実行を行っています。

docs.aws.amazon.com

なお、こちらの方法ではAssumeRoleができるユーザーのAccesskeyを持つことにはなりますが、
万が一漏れたとしてもAssumeRoleをして一時トークンを発行するのが難しい、かつ、
一時トークンを手に入れたとしても期限(デフォルトだと15分)が経過すると使えなくなるため、
安全に対象の操作を行うことができるトークン管理ができるといえます。

なお、STSを使ったスクリプトのAssumeRole実装は以下を参考に行いました。

docs.aws.amazon.com

2.ループ処理でHSM作成用Lambda Functionを実行

2のLambda FunctionでのHSM作成は以下のようなスクリプトを利用しています。
boto3でCloudHSMクライアント(v2)を用いてAPIを叩いています。
今回のLambdaFunctionではResponseを返すことにより、呼び出し元に情報を渡せるようにしています。

HSM作成用Pythonスクリプト例

import boto3
import traceback


# 以下は基本固定
CLUSTER_ID = 'cluster-hogehogehugahuga'
AVAILABILITY_ZONE = 'us-west-2d'


def lambda_handler(event, context):
    try:
        client = boto3.client('cloudhsmv2')
        response = client.describe_clusters(Filters={'clusterIds': [CLUSTER_ID]})
        print(response)

        # Clusterが存在しない場合はその旨を返す。
        if not response['Clusters']:
            return {
                'statusCode': 404,
                'body': {
                    'message': 'Cluster is not exists. Please check cloudHSM in aws console.'
                },
            }

        hsms = response['Clusters'][0]['Hsms']

        # HSMは1つしか存在させない前提で実装する
        if hsms:
            return {
                'statusCode': 200,
                'body': {
                    'Hsm': hsms[0],
                    'ResponseMetadata': response['ResponseMetadata'],
                },
            }
        else:
            create_response = client.create_hsm(
                ClusterId=CLUSTER_ID, AvailabilityZone=AVAILABILITY_ZONE
            )
            print(create_response)
            return {'statusCode': 200, 'body': create_response}

    except Exception as e:
        print(e)
        return {
            'statusCode': 500,
            'body': {
                'message': traceback.format_exc(),
            },
        }

なお、こちらの処理をループ処理で1分毎に実行していますが、その理由として、HSMの作成に時間がかかることが挙げられます。
これまで使用してきた感覚だと早くて数分はかかってしまいます。
そのため、一度APIを叩いて一瞬でHSM作成処理が完了するわけではないので、
Lambda Functionを実行してすぐに次の処理、はたまた次のjobに移ってしまうと、
HSMの作成が終わる前に署名の処理にたどりついてしまい、HSMの準備ができていないため署名が失敗してしまう恐れがありました。
幸いにも、上記LambdaFunctionの実装ではHSMの作成状況もresponseとして含むことができたため、
1分毎に実行しresponseを確認することで、 HSM作成完了をもって次の処理に移っていくような形としています。

3. HSM作成Lambda Functionのresponseに含まれるEni ipを出力

1,2でHSMが作成されたあとに、作成したHSMと接続するために重要なHSMのENI IPを出力します。
これは単純にRuby Scriptの標準出力を行うことで簡単に行っています。

HSMとビルドマシンの接続[Configure Eni Ip]

※2025/03/11時点のシステムではSDK3を使用してシステムを構築しているので、 SDK3の状態でのビルドマシンとの接続になります。

HSMの作成時にはENI IPが発行されます。
このENI IPを同一ネットワーク内で指定することによって、EC2⇔CloudHSM間の通信経路を確立できます。
こちらのENI IPなどの設定はClientSDK3を使用して行うため、 ここでは、SDK3の設定値の更新を主に行うことになります。

.gitlab-ci.yml例

configure_eniip:
  stage: prepare_hsm
  script:
    - cd "C:\Program Files\Amazon\CloudHSM"
    - Stop-Service AWSCloudHSMClient
    - ./configure.exe -a $env:HSM_ENIIP
    - Start-Service AWSCloudHSMClient

エージェントビルド[Build Agent]

Windowsエージェントのビルドを行います。
ここではビルドの詳細は説明しませんが、こちらのjobでWindowsエージェントのバイナリを生成し、バイナリの署名が動いています。
署名はsigntool signのコマンドを使用して実施しており、 具体的には以下コマンドで署名したいバイナリを指定して実施しています。

署名コマンド例

# 署名コマンド抜粋
# $env:HSM_CERT_THUMBPRINT: HSMで使用するコードサイニング証明書のThumbprint
# $productName: バイナリのプロダクト名
# $file: 署名を行いたいファイル
signtool sign /v /sha1 $env:HSM_CERT_THUMBPRINT /sm /d "$productName" /fd SHA256 /td SHA256 /tr http://timestamp.digicert.com /ph "$file"

HSMの削除[DeleteHSM]

バイナリのビルド(&署名)が終わったら、HSMの削除を行います。
こちらのjobでもSTSの一時トークンを利用して、Lambda Functionを使ってHSMの削除を行う形になっています。
なお、HSMの作成とは異なり、HSMの作成を待つ必要はないため、こちらではRubyではなくAWS-CLIを用いた実装となっています。

docs.aws.amazon.com

.gitlab-ci.yml例

delete_hsm:
  image:
    name: public.ecr.aws/aws-cli/aws-cli:2.15.8
    entrypoint: [""]
  stage: after_build
  before_script:
    - yum update -y && yum install jq -y
    - aws configure list
  script:
    - aws_sts_credentials=$(aws sts assume-role --role-arn $AWS_ROLE_FOR_LAMBDA_INVOKE --role-session-name used-at-gitlab-ci --duration-seconds 900 --query "Credentials")
    - |
      export AWS_ACCESS_KEY_ID="$(echo $aws_sts_credentials | jq -r '.AccessKeyId')"
      export AWS_SECRET_ACCESS_KEY="$(echo $aws_sts_credentials | jq -r '.SecretAccessKey')"
      export AWS_SESSION_TOKEN="$(echo $aws_sts_credentials | jq -r '.SessionToken')"
    - aws lambda invoke --function-name deleteHSMs  out --log-type Tail --query 'LogResult' | tr -d '"' | base64 --decode

Lambda Functionでの実装は以下のようになります。 こちらではClusterに存在するHSMを見つけて削除を行います。

HSM削除用Pythonスクリプト例

import boto3
import traceback


# 以下は基本固定
CLUSTER_ID = 'cluster-hogehogehugahuga'


def lambda_handler(event, context):
    try:
        client = boto3.client('cloudhsmv2')
        response = client.describe_clusters(Filters={'clusterIds': [CLUSTER_ID]})
        print(response)

        # Clusterが存在しない場合はその旨を返す。
        if not response['Clusters']:
            return {
                'statusCode': 404,
                'body': {
                    'message': 'Cluster is not exists. Please check cloudHSM in aws console.'
                },
            }

        hsms = response['Clusters'][0]['Hsms']

        if hsms:
            for hsm in hsms:
                delete_response = client.delete_hsm(
                    ClusterId=CLUSTER_ID, EniIp=hsm['EniIp']
                )
                print(delete_response)
        return {
            'statusCode': 200,
            'body': {
                'message': 'invoked delete hsm.',
            },
        }
    except Exception as e:
        print(e)
        return {
            'statusCode': 500,
            'body': {
                'message': traceback.format_exc(),
            },
        }

自動化の結果

長々と書きましたが今回の自動化により、 GitLabからの操作1つで、バイナリビルドおよび署名を完了させることが可能になりました。
[Pipelineの制御]の節でも記載したように、GitLabのPipelineを起動させるだけで、以下の作業を行うjobが自動で行えます。

  • HSMの作成
  • バイナリのビルドと署名
  • HSMの削除

また、自動化することにより、CloudHSM構築にかかる人的コストの削減や、
署名利用時のみのCloudHSMへの必要最低限の課金を達成できました。

実際にこの自動化を行った場合のエージェントビルド時にかかる具体的な1年のHSMコストを計算すると以下です。

  • 社内での1年間のWindowsエージェントビルド回数: 25(回/year)
  • 再ビルドが行われた場合追加のビルド数: 25回(回/year)
  • 1回のビルド時間: 0.5(h)
  • オレゴンにおけるHSMの1時間の使用料: 1.45 (USD/h)

25(ビルド回数/year) × 2(再ビルド込み) × 0.5(h) × 1.45(USD/h) = 36.25(USD/year) 2025年2月時点で簡単に150円で換算すると日本円では大体5438円になります。
1年間HSMを起動していた場合は12702(USD/year)なので、
削減率を計算すると、(12702(USD/year) - 36.25(USD/year)) ÷ 12702(USD/year) × 100 = 99.71%の削減になります。
半端ないですね...

おわりに

今回の対応により前回の記事からのCloudHSMに関わる環境構築や自動化を完了できました。
本記事ではCloudHSMの環境構築の自動化に着目しましたが、さらっと流したWindowsエージェントのビルド環境などの話についても機会があれば記載できればと思っています。
OPTiMでは自動化で面倒な手作業やAWSのコスト削減をしていけるエンジニアも募集しています。

www.optim.co.jp

なお、本記事における図中の各種アイコンは以下の利用規約に従って利用しています。