AWS CLIとSAN証明書を駆使してELB利用料金を毎年50万円節約した話


# 全体の流れ
0. 背景
0. 開発サービスの仕様 と AWSの仕様 について
1. Service Quotas編
    1. ELB：紐づけれる証明書の上限を26→51にアップ
    1. ACM：SANs証明書のサブジェクト別名に指定できるドメイン数を10→100にアップ
1. スクリプト編
    1. ロードバランサー に紐付く証明書を全て取得する
    2. 証明書に設定したドメインを全て取得する
    3. 既に100個にバンドルされたドメインを取得する
    4. 未だ100個にバンドルされていないドメインを100個ごとにバンドルし、SANs証明書を発行する
    5. SANs証明書をロードバランサー に設定する。
    6. ドメインの証明書がSANs証明書に変更されているか確認する。
1. 手作業編
    1. ブラウザチェック
    1. ELBから不要な証明書の紐付け解除
1. スクリプトを組むべき理由



# 1. 背景
三段論法で行きます。

- **顧客企業ごとに独自ドメインを発行** するサービスを開発しています。
- 1つのELBに紐づけられる証明書の**上限数が26個**だった！（AWS仕様）
- 顧客数が500社を超えて**ELBが20個になっていた！！！**

ELBは複数あっても無駄ですし、1台2000円ぐらいの固定費がかかります。
20台もあれば月4万円　→ 年間50万です。
ベンチャーに無駄遣いしてる余裕はないので節約していくことになりました。


# 2. 開発サービスの仕様 と AWSの仕様 について
## 開発サービスの仕様
顧客企業ごとに独自ドメインを発行する必要がある。

## AWSの仕様
1つのELBに紐づけられる証明書の上限数は26個（デフォルト証明書1個　＋　追加25個）
1つのSANs証明書に設定できるサブジェクト別名の上限数は10個


# 3. Service Quotas編
上記で説明したようにAWSサービス毎に様々な制限があります。
**しかし、Service QuotasというAWSサービスを使うことで上限緩和ができます。**
画面遷移図と共に説明します。

## ELB：紐づけれる証明書の上限を26→51にアップ
1. マネジメントコンソールより「Service Quotas」で検索
![スクリーンショット 2021-09-18 23.33.49.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/241219/1ea5fd11-1156-bbeb-2e98-fa7b91aa329c.png)

1. Service Quotasのダッシュボードページの左部サイドメニューより「AWSのサービス」をクリック
![スクリーンショット 2021-09-18 23.36.48.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/241219/243a84f6-f3a0-e14f-0bf2-d018a6d79797.png)

1. 検索窓に「ELB」と入力して検索結果をクリック
![スクリーンショット 2021-09-18 23.40.14.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/241219/3bcdb663-6bb4-5775-a412-72e9f6ad3ac6.png)

1. 検索窓に「certificate」と入力して「Certificates per Application Load Balancer」をクリック
ちなみに「適用されたクォータ」が現在の上限値で、「AWSのデフォルトのクォータ値」がデフォルトの上限値になります。
僕のAWSアカウントは上限緩和済みですので「50」となっていますが、未設定の場合は何も表示されなかったはずです。
![スクリーンショット 2021-09-18 23.52.50.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/241219/527e028a-fd11-1384-2788-e22693a64d43.png)



1. 「クォータの引き上げをリクエスト」をクリック
![スクリーンショット 2021-09-18 23.44.39.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/241219/cfce84d7-03c7-c33c-1203-b9fea171704e.png)

1. 「クォータ値を変更」というフィールドに「50」と入力してリクエスト
**50以上は上げれないと公式から言われました。**
リクストの承認には数日かかることがあります。
これで承認されればあとはなにもしなくても上限がアップされています。
![スクリーンショット 2021-09-18 23.48.22.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/241219/96cd9e75-5cdf-03fd-8777-806c9b81d4f6.png)





## ACM：SANs証明書のサブジェクト別名に指定できるドメイン数を10→100にアップ
1. 同じくService Quotas上のAWSサービス検索窓に「ACM」と入力し「AWS Certificate Manager」をクリック
![スクリーンショット 2021-09-19 0.00.26.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/241219/20548738-9a56-40e8-0598-4bca8d53da50.png)

1. 「Domain names per ACM certificate」をクリック
![スクリーンショット 2021-09-19 0.03.13.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/241219/befe9275-e2cd-860e-5f8d-39542c65350c.png)

1. 「クォータの引き上げをリクエスト」をクリック
![スクリーンショット 2021-09-19 0.05.10.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/241219/adc4725a-58cd-c379-b9f4-351352f09cc6.png)

1. 「クォータ値を変更」というフィールドに「100」と入力してリクエスト
**100以上は上げれないと公式から言われました。**
リクストの承認には数日かかることがあります。
これで承認されればあとはなにもしなくても上限がアップされています。
![スクリーンショット 2021-09-19 0.29.46.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/241219/3a10736e-c1b1-1ccd-8167-4c6d5accff05.png)


# 4. スクリプト編
AWS CLIが設定されていることが前提です。
処理がかなり複雑なので、まずは流れをまとめます。

1. ロードバランサー に紐付く証明書を全て取得する（ここで取得できる情報は証明書の Arn のみ。どのドメインの証明書かは記載されていない）。
2. 証明書に設定したドメインを全て取得する（証明書の詳細情報を取得すると、設定されているドメイン情報も全て取得できる）。
3. 既に100個にバンドルされたドメインを取得して無視する（既に100個にバンドル済みなので処理する必要はない）。
4. 未だ100個にバンドルされていないドメインを100個ごとにバンドルし、SANs証明書を発行する（100個に満たない場合は、その時の数でSANs証明書を発行する）。
5. SANs証明書をロードバランサー に設定する。
6. ドメインの証明書がSANs証明書に変更されているか確認する。

まずここまでで、それぞれのロードバランサーに紐づく26個の証明書を1つのSANs証明書にまとめることができます。
今度は、


:::note alert
シェルスクリプトだけで複雑な条件分岐・ループ処理を書くのが難しかったので、AWS CLIコマンドの戻り値を、Rubyで受け取って処理しています。
ちょっと見にくいですが、ご了承ください。。。
:::


## 1. まずはロードバランサー に紐付く証明書を全て取得します。

`aws elbv2 describe-listener-certificates`コマンドに対象のELB HTTPSリスナーArnを渡せば、リスナーに紐づく証明書のArnが全て取得できます。
詳細は以下ドキュメント参照
https://awscli.amazonaws.com/v2/documentation/api/latest/reference/elbv2/describe-listener-certificates.html

:::note info
ここで取得できる情報は証明書の Arn のみ。どのドメインの証明書かは記載されていない。
:::

```ruby
require "json"
require "faraday"

puts `aws --version`
#=> aws-cli/2.2.7 Python/3.8.8 Darwin/20.6.0 exe/x86_64 prompt/off

# ELB のHTTPSリスナーのArnを記入
elb_https_arn = "arn:aws:elasticloadbalancing:ap-northeast-1:#XXXXXX:listener/app/XXXXXXXX/XXXXXX"

# ELBに設定した証明書Arnを取得
elb_certificates = JSON.parse(`aws elbv2 describe-listener-certificates --listener-arn #{elb_https_arn} --query 'Certificates[].CertificateArn'`)
#=> [
# arn:aws:acm:ap-northeast-1:XXXXXX:certificate/XXXXXX,
# arn:aws:acm:ap-northeast-1:XXXXXX:certificate/XXXXXX,
# arn:aws:acm:ap-northeast-1:XXXXXX:certificate/XXXXXX,
# ....
# ]

```



## 2. 証明書に設定したドメインを全て取得する
`aws acm describe-certificate`コマンドに対象の証明書Arnを渡スト、証明書の詳細情報を閲覧できます。
`DomainName`キーにはメインのドメイン名が登録されていて、`SubjectAlternativeNames `キーにはSANsドメインが登録されています。
詳細は以下ドキュメント参照
https://awscli.amazonaws.com/v2/documentation/api/latest/reference/acm/describe-certificate.html

:::note info
Arnを使うことで証明書の詳細情報を取得できる。そこからドメイン情報も全て取得できる。
:::

```ruby
all_domains = elb_certificates.map do |cert|
  res = JSON.parse(`aws acm describe-certificate --certificate-arn #{cert} --query Certificate`)
  main_domain = res.dig("DomainName")
  sans = res.dig("SubjectAlternativeNames")
  [main_domain, sans]
end.flatten.compact.uniq
#=> ["example1.com",
# "example2.com",
# "example3.com",
# ...
# ]
```

## 3. 既に100個にバンドルされたドメインを取得
:::note info
既に100個にバンドル済みなので処理する必要はないので、後々の処理で無視する為に取得する。
:::


```ruby
already_bundled_domains = elb_certificates.map do |cert|
  res = JSON.parse(`aws acm describe-certificate --certificate-arn #{cert} --query Certificate`)
  main_domain = res.dig("DomainName")
  sans = res.dig("SubjectAlternativeNames")
  # SubjectAlternativeNames の配列には MainDomain も含まれるので、[main_domain + sans].size と記述しなくてOK。
  [main_domain, sans] if [sans].flatten.size == 100
end.flatten.compact.uniq

```

## 4. 未だ100個にバンドルされていないドメインを100個ごとにバンドルし、SANs証明書を発行する
:::note info
100個に満たない場合は、その時の数でSANs証明書を発行する。
:::

```ruby
require 'active_support/core_ext/array/grouping'
not_bundled_domains = (all_domains - already_bundled_domains)
sans_certificates = not_bundled_domains.in_groups_of(100, false).map do |domains|
  main_domain = domains.first
  sans = domains[1..-1]
  sans_certificate = JSON.parse(`aws acm request-certificate \
                       --domain-name #{main_domain} \
                       --validation-method DNS \
                       --subject-alternative-names #{sans.join(" ")} \
                       --tags #{tags}`)
  sans_certificate
end

# SANs証明書が発行できたかどうかステータス確認
sans_certificates.each do |sans_certificate|
  puts 'レスポンスJSONの ["Certificate"]["DomainValidationOptions"] で各ドメインの検証ステータスを確認できる。検証成功している場合は「SUCCESS」となっている。'
  loop.with_index(1){ |_, i|
    puts i
    # 100ドメインの検証をするので、発行完了まで1分ぐらいかかる。
    # 検証成功するまで証明書を使えないので、成功するか随時チェックする。
    # 5分経っても検証成功しない場合は何かおかしい可能性があるのでコンソールからチェックしてください。
    raise if i == 10
    sleep 30
    res = JSON.parse`aws acm describe-certificate --certificate-arn #{sans_certificate["CertificateArn"]}`
    if res.dig("Certificate", "DomainValidationOptions").select{|v| v["ValidationStatus"] != "SUCCESS"} == []
      break
    end
  }
end
```

## 5. SANs証明書をロードバランサー に設定する。

```ruby
sans_certificates.map.with_index(1) do |sans_certificate, index|
  puts index
  JSON.parse(`aws elbv2 add-listener-certificates --listener-arn #{elb_https_arn} --certificates CertificateArn=#{sans_certificate["CertificateArn"]}`)
end
```


## 6. ドメインの証明書がSANs証明書に変更されているか確認

```ruby
require 'socket'
require 'openssl'
def get_certificate(domain)
  certificate = nil
  TCPSocket.open(domain, 443) do |tcp_client|
    ssl_client = OpenSSL::SSL::SSLSocket.new(tcp_client)
    ssl_client.hostname = domain
    ssl_client.connect
    certificate = ssl_client.peer_cert
    ssl_client.close
  end
  certificate
end

used_certificates = []
error_domains = []
all_domains.each.with_index(1) do |domain, index|
  domain = domain.gsub("*.", "")
  puts index, domain
  begin
    used_certificates << [ get_certificate(domain.gsub("*.", "") ).subject.to_s.gsub("/CN=", "") ]
  rescue => e
    error_domains << [ domain, e ]
  end
end
used_certificates.flatten.compact.uniq.sort
```

# 5. 手作業編
## ブラウザチェック
どっちでもOKです。
スクリプトだけでのチェックが怖い人はやってください。

## ELBから不要な証明書の紐付け解除
間違って使用中の証明書を削除してしまうのでは怖いので、流石にここは手作業でします。



# 6. スクリプトで処理を実行するべき理由
- ヒューマンエラーの回避するため
AWS ACMのコンソールからSANs証明書を発行する場合、100個のドメインを全て入力しないといけない。
人間は単純作業が苦手なので機械にやらせましょう。

- SANs証明書は更新できないから
SANs証明書は更新できません。
例えば、1つのSANs証明書に99個の証明書がバンドルされていて、
証明書を1つ追加・削除したくなっても、**できません。編集ができません。**
1つ削除したくなったら98個の証明書を入力し、1つ追加したくなったら100個を入力する必要があります。
人間は単純作業が苦手なので機械にやらせましょう。

上記の理由から、スクリプトを組むメリットしかないです。