多くの企業が、今年中にWebサービスの暗号化を進めなくてはいけなくなったかと思います。
ということで、基本的な内容ではありますが、AWSにおけるSSL証明書の扱いについて復習してみます。
AWSで扱う証明書の種類
ここでいう種類とは、EV SSL だの ワイルドカードだの、証明書の製品としての種類ではありません。1つは AWS Certificate Manager(以下、ACM)の無償証明書、もう1つは従来のSSLサーバ証明書販売サイトで購入する有償証明書、の2種類となります。
それぞれの証明書を、AWSのリソースにどのように登録し、運用していくかについてまとめていきます。
ACMの証明書を利用する
2016年1月にリリースされ、5月にはTokyoリージョンにもきてくれたACMは、CloudFront・ELB/ALBで利用することができます。対象リソースが限られているとはいえ、無償ですし、更新も自動ですし、ワイルドカードにも対応しているしで、使わない理由は全くありません。証明書の作成
使い方は簡単で、普通にACM管理画面でドメイン情報を入力することで作成し、そのドメインの管理者E-mailアドレスに送られてくるURLをポチるだけで証明書を有効化できます。APIも対応していますが、どうせURL承認があるのであんまり使わなさそうです。ワイルドカードの証明書にしたいならば、example.com をメインに入力しつつ *.example.com を2つ目に入れればよいでしょう。問題なく cdn.example.com などを利用することができます。
CloudFrontに登録
ACMの証明書をCloudFrontで利用する場合、証明書が us-east-1 リージョンに登録されている必要があります。東京(ap-northeast-1)に登録していても、管理画面ではACM証明書が選べないようになっています。が、すぐ横に注意書きで書いてあるので気づくことができます。Terraform 0.7.0 からは aws_cloudfront_distribution でCloudFront を作成できるようになったので、ACM管理画面から ARN をコピペしてきてこのように acm_certificate_arn に直書きして作ればよいと思います。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 |
resource "aws_cloudfront_distribution" "example" { enabled = true aliases = ["cdn.example.com"] price_class = "PriceClass_All" comment = "CDN of example.com" origin { domain_name = "gedowfather-example-com.s3.amazonaws.com" origin_id = "S3-gedowfather-example-com" } default_cache_behavior { allowed_methods = ["GET", "HEAD"] cached_methods = ["GET", "HEAD"] target_origin_id = "S3-gedowfather-example-com" forwarded_values { query_string = false cookies { forward = "none" } } viewer_protocol_policy = "allow-all" min_ttl = 0 default_ttl = 3600 max_ttl = 86400 } restrictions { geo_restriction { restriction_type = "none" } } viewer_certificate { acm_certificate_arn = "arn:aws:acm:us-east-1:1234567890:certificate/12342345-3456-4567-5678-67897890" ssl_support_method = "sni-only" minimum_protocol_version = "TLSv1" } } |
ELB/ALBに登録
ELB/ALB で管理画面の場合は、リスナー項目で 443(HTTPS) を追加すると、登録済みのACM証明書から選択するだけなので簡単です。ただし、CloudFront は us-east-1 の証明書だったのに対し、ELB/ALB が ap-northeast-1 ならばACMも ap-northeast-1 で登録していなくては選択肢に出てきません。そのため、CloudFrontと両方使いたい場合は、2つのリージョンに同じドメインで登録しておかなくてはならないので、最初だけですが若干面倒といえば面倒です。
Terraform でやる場合……これからは、もうELB(クラシックロードバランサー)を使うことは少ないでしょうから、ALBの例にしておきましょう。CloudFront 同様、ACMのARNをTerraform のコードに埋め込んで利用します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 |
resource "aws_alb" "example" { name = "${var.service_name}-alb-01" internal = false security_groups = ["${aws_security_group.web.id}"] subnets = ["${aws_subnet.public1.id}", "${aws_subnet.public2.id}"] enable_deletion_protection = false tags = { env = "production" service = "${var.service_name}" } } resource "aws_alb_listener" "example" { load_balancer_arn = "${aws_alb.example.arn}" port = 443 protocol = "HTTPS" ssl_policy = "ELBSecurityPolicy-2015-05" certificate_arn = "arn:aws:acm:ap-northeast-1:1234567890:certificate/12342345-3456-4567-5678-67897890" default_action { target_group_arn = "${aws_alb_target_group.example.arn}" type = "forward" } } resource "aws_alb_target_group" "example" { name = "${var.service_name}-alb-target-01" port = 80 protocol = "HTTP" vpc_id = "${aws_vpc.default.id}" health_check { protocol = "HTTP" port = 80 interval = 30 timeout = 3 healthy_threshold = 4 unhealthy_threshold = 2 } } |
ELBの時は aws_autoscaling_group の load_balancers で直接ELBにぶら下げる感じでしたが、ALBでは target_group_arns が間に入る使い方になりました。
有償証明書を利用する
メインのサーバーは他のパブリッククラウドなどに置くけどCloudFrontは使いたい、とか、Webサーバーで直にHTTPSで受けたい、とかいう場合にワイルドカードの証明書を購入してあるから、AWSでもその有償証明書を使いたい、ということはあるかもしれません。(それでも、CloudFront・ALB だけでも更新の手間を省略できる分、ACMにしてしまったほうがいいと思いますが、政治的な理由があるかもですしね)その場合は、細かいことはズバッと抜いていきますが、SSL証明書の機能と費用、そして証明書提供会社の知名度などから購入場所を決めて購入します。
購入の際には、Linuxなど適当な場所で サーバー鍵(秘密鍵)と CSR (Certificate Signing Request) を生成し、CSRを送りつけて、もろもろ処理を完了することで、証明書 と 中間証明書 を受け取ることができます。
受け取ったら、どのファイルが何だかわからなくならないよう、整理しておきます。私の場合はこんな感じのファイル名にしています。なんでもいいですが、少なくとも社内で統一しておいた方が幸せです。
CSR | csr.pem |
サーバー鍵 | key.pem |
証明書 | cert.pem |
中間証明書 | ca.pem |
1 2 3 4 5 6 |
aws iam upload-server-certificate \ --server-certificate-name example.com \ --private-key file://key.pem \ --certificate-body file://cert.pem \ --certificate-chain file://ca.pem \ --path /cloudfront/ |
しかしやはり、Terraform で管理した方が、ファイルの管理場所にも困らなくなるのでオススメです。更新時にややこしくならないよう、ディレクトリなどに年代を付けておくとよいですね。lifecycle:ignore_changes をつけているのは、何故かterrafromさんが certificate_chain が変わっていないのに何度も上書き更新しようとするからです……
1 2 3 4 5 6 7 8 9 10 11 12 |
resource "aws_iam_server_certificate" "example_com-2016" { name = "example.com" certificate_body = "${file("ssl/example.com-2016/cert.pem")}" certificate_chain = "${file("ssl/example.com-2016/ca.pem")}" private_key = "${file("ssl/example.com-2016/key.pem")}" path = "/cloudfront/" lifecycle { ignore_changes = ["certificate_chain"] } } |
CloudFrontに登録
管理画面の場合は、作成の際にACMとIAMから選べるので、IAMから登録済み証明書を選択するだけになります。Terraformの場合は、ACMと違うのは acm_certificate_arn が iam_certificate_id になるだけです。SSLと関係ないですが、ここの例では Origin をS3ではなく、外部コンテンツの書き方にしています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 |
resource "aws_cloudfront_distribution" "example" { enabled = true aliases = ["cdn.example.com"] price_class = "PriceClass_All" comment = "CDN of example.com" origin { domain_name = "contents.example.com" origin_id = "Custom-contents.example.com" custom_origin_config { http_port = 80 https_port = 443 origin_protocol_policy = "http-only" origin_ssl_protocols = ["TLSv1", "TLSv1.1", "TLSv1.2"] } } default_cache_behavior { allowed_methods = ["GET", "HEAD"] cached_methods = ["GET", "HEAD"] target_origin_id = "Custom-contents.example.com" forwarded_values { query_string = false cookies { forward = "none" } } viewer_protocol_policy = "allow-all" min_ttl = 0 default_ttl = 3600 max_ttl = 86400 } restrictions { geo_restriction { restriction_type = "none" } } viewer_certificate { iam_certificate_id = "${aws_iam_server_certificate.example_com-2016.id}" ssl_support_method = "sni-only" minimum_protocol_version = "TLSv1" } } |
証明書の動作確認
ここは極普通のWebサービスの運用的な話ですが、CloudFront なり ELB/ALB のドメイン名がわかったら、CNAMEレコードを登録しに行きます。ドメインを取得したサイト内のDNSサービスや、権限を移譲した先のDNSサービスにて、必要なゾーン(example.com)を作成し、CNAMEレコード(ex: cdn.example.com = abcdefg012345.cloudfront.net)を登録します。
登録したら数分待って──Linuxならば host cdn.example.com コマンドで名前解決できることを確認します。
名前解決できたら、ブラウザで https://cdn.example.com にアクセスしてみます。コンテンツがないとエラーHTTPステータスコードが返りますが、構いません。URL欄の頭にある錠マークから証明書のオールグリーンと有効期間を確認できればOKです。
証明書の更新
証明書の期限が切れてしまうと、クライアントにエラーが出てしまうので、切れる前に入れ替える必要があります。ACMの場合
今更ですが、利用するなら よくある質問 – AWS Certificate Manager をちゃんと読んでおいた方がよいです。早い場合、期限の60日前から自動的に更新してくれるという恐るべき便利さなのですが、それだけに更新通知は行われない(可能性が高い)と思っておいたほうがよさげです。まぁ、この辺はAWSを信用するしかないのですが、これまで少なからずSSL証明書を扱ってきた身としては、完全放置するのも気持ち悪いというか、何かあった時に、ここが原因かもしれないことを認識したいというのもあり、こんな Lambda監視 を入れてみました。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 |
from datetime import datetime import boto3 TOPIC_NAME = 'mail-acm' SNS_SUBJECT = 'ACM update CERT' def lambda_handler(event, context): acms = [boto3.client('acm'), boto3.client('acm', region_name="us-east-1")] sns = boto3.client('sns') iam = boto3.client('iam') now = datetime.today() notice_days = [60, 30, 10, 3, 1] updates = {'updated': [], 'expire': []} updated_count = 0 expire_count = 0 date_format = '%Y/%m/%d %H:%M:%S' for acm in acms: cert_list = acm.list_certificates()['CertificateSummaryList'] if not cert_list: continue for cert in cert_list: arn = cert['CertificateArn'] region = arn.split(":")[3] desc = acm.describe_certificate(CertificateArn=arn)['Certificate'] domain_name = desc['DomainName'] aliases = desc['SubjectAlternativeNames'] expire_start = desc['NotBefore'].replace(tzinfo=None) expire_end = desc['NotAfter'].replace(tzinfo=None) run_days = (now - expire_start).days expire_days = (expire_end - now).days info = { 'type': None, 'number': None, 'region': region, 'domain_name': domain_name, 'aliases': aliases, 'expire_start': expire_start, 'expire_end': expire_end, 'run_days': run_days, 'expire_days': expire_days, } if run_days == 0: type = 'updated' updated_count += 1 info['number'] = updated_count info['type'] = type updates[type].append(info) continue if expire_days in notice_days: type = 'expire' expire_count += 1 info['number'] = expire_count info['type'] = type updates[type].append(info) if not updates['updated'] and not updates['expire']: print "Not found notice cert." return # # SNS # topics = sns.list_topics()['Topics'] if not topics: print "Not found topic list." return tmp = filter(lambda x: x['TopicArn'].split(':')[-1] == TOPIC_NAME, topics) if not tmp: print "Not found topic name." return topic_arn = tmp[0]['TopicArn'] account_alias = "NoName" list_account_aliases = iam.list_account_aliases()['AccountAliases'] if list_account_aliases: account_alias = list_account_aliases[0] count = len(updates['updated']) + len(updates['expire']) subject = "[%s] %s (%d certs)" % (account_alias, SNS_SUBJECT, count) message = subject + "\n\n" for update in updates['updated'] + updates['expire']: title = 'Updated today' if update['type'] == 'updated' else 'Near expiration' message += "#\n" message += "# %s (%d)\n" % (title, update['number']) message += "#\n" message += "Region : %s\n" % update['region'] message += "Domain Name : %s\n" % update['domain_name'] message += "Aliases : %s\n" % ", ".join(update['aliases']) message += "Expire Start : %s\n" % update['expire_start'].strftime(date_format) message += "Expire End : %s\n" % update['expire_end'].strftime(date_format) message += "Run Days : %s\n" % update['run_days'] message += "Expire Days : %s\n" % update['expire_days'] message += "\n" sns.publish( TopicArn = topic_arn, Subject = subject, Message = message, ) print "Send message to SNS." |
一日一回実行して、該当の日になったら、こんなメールがくる感じです。これが発動するのはまだだいぶ先の話ですが、だいたい何日前に更新されるかが把握できますし、もし3日前とか切ったらAWSサポートに対してアクションするとかできますゆえ。
1 2 3 4 5 6 7 8 9 10 |
# # Near expiration (1) # Region : ap-northeast-1 Domain Name : example.com Aliases : example.com, *.example.com Expire Start : 2016/09/29 00:00:00 Expire End : 2017/10/29 12:00:00 Run Days : 335 Expire Days : 60 |
まだ出来たてのサービスなので、更新後はブラウザでの有効期限確認くらいはやっておきたいところです。とはいえ、数年後には放置安定が当たり前になっていくのでは、とも思います。
有償証明書の場合
従来のこちらは更新というよりは、新規に同様の内容で取得し直し、入れ替える感じです。新規に鍵・証明書一式を整理したら、IAMに年代を新しくした別名で登録し、CloudFrontやALBで選択リストで変更するだけです。Terraformでやる場合も、別名で新規登録した上で、指定名を変更して更新し、確認するだけです。
期限切れについては、購入時のE-mailアドレスに通知がくるのが普通ですが、ウチの場合はZabbixで直接 https:// のURLにアクセス監視して、一定期間内になったらアラートが飛ぶようにもしています。
HTTPからHTTPSへの切り替え
CloudFront
稼働中のHTTPサービスをHTTPSに切り替える場合、できるだけ安全に切り替えたいと思うはずです。CloudFront の場合、HTTP から HTTPS に変更したり、HTTPS (*.cloudfront.net) から HTTPS (cdn.example.com) へドメインを変更したい場合、はたまた HTTPS only にすることもあるでしょう。
多くの場合、アプリケーションが生成するURLを http:// から https:// へ、*.cloudfront.net から cdn.example.com などの変更が伴いますので、CloudFront の変更と同時に行うというのは、あまり筋が良いとはいえません。
まずは既存のCloudFrontの設定変更で安定していけるのか、それとも新規にCloudFrontを作成し、該当ドメインでのHTTPSの動作確認まで完了させた上で、任意のタイミングでDNSレコードの変更やアプリケーションのデプロイをする方が安定するのか、を案件ごとに考えます。CloudFrontはしょせんCDNなので、焦ったり楽な方に逃げる必要はありません。一手一手が安全である道を選びましょう。
ELB/ALB
バランサはリスナーが80番だけならば、単に443番を追加するだけであり、80番で待機しているバックエンドのWebサーバーには何も影響ありません。443番のHTTPSで問題なくアクセスできることを確認した上で、URLを https:// に変更したアプリケーションをデプロイするだけになります。また、もしEC2のWebサーバーで直接443番を受けてSSL処理をしている場合、ACMができたことを受けて、有償証明書からACMへ切り替えたほうが手間も費用も少なくなってよいでしょう。ALBを挟むことにはなりますが、証明書の値段に比べたらALBの費用など、おそらく格段に安くすむからです。
その場合は、Webサーバーで443番をそのままに80番でも受けれるようにリロードし、ALB経由の https:// → EC2:80番アクセスを確認した上で、DNSのAレコードをCNAMEにしてALBに向けることで完了します。
まとめると、ACM 万歳\(^o^)/ ってことですな。
一昔前までは、SSL証明書の購入から導入、更新までがインフラエンジニアの1つの仕事だったのに、これからは無償だし数回ポチるだけで放置安定だしで、どんどん食いっぱぐれないか不安になっちゃう今日このごろですよ、と;-)