本番環境へのデプロイはどう行うべきなのか?

ITサービスの開発・運用を行うにあたって避けて通れないのがデプロイである。プロジェクトのたびに一から考えると(そしてドキュメント化するのも)手間がかかるので、よくある方針や例を記しておく。

方針

  1. 手順をなるべく自動化して手作業は最小限にできる
  2. 効率化
  3. オペミスの防止
    • 手動の作業はミスがつきものなので
  4. 環境間で動作を合わせられる
  5. 違っていると不具合や事故の元なので。
  6. ロールバックに対応できる
  7. デプロイした内容にバグが見つかるのはザラ。とりあえず切り戻しができれば被害は最小限になる。
    • できなかったらHotfixとかで直すまでサービスダウンまであり得る。怖すぎ。
  8. 問題はDBのスキーマなど、別に依存がある場合。互換性があればいいが、ないとロールバックは無理。
    • マイグレーションすればいい、という考え方もあるが、本番のDBスキーマデグレードをかけるのは往々にして無理筋。
    • あと分散モノリシックな造りだと、API同士のバージョンに互換性が必要だったりもするのでさらにツライ。
  9. ダウンタイムを極力短くできる。できればゼロ。
  10. ダウンタイム=逸失利益なので、これは当然。
    • ただしゼロといっても厳密には0ミリ秒はありえなくて、ごくわずかな時間繋がらなくなっているはずだが、リトライするなどして認識されないため、ゼロダウンタイム(と思ってもらえる)。
  11. 事前にアプリケーションの動作確認、テストを行える
    • 本番環境で動作確認はなかなか無理がある。
  12. デプロイのログが残る。何かあったら通知が出る。
    • 不測の事態が起きた際、ログがなかったらお手上げ。通知もないと気づけない。

それぞれどう実現すればいいか?

  • 自動化
    • CI/CDツールを使う。CodePipelineやGitHub Actionsなど。
  • 環境間での動作の統一
    • できるだけ同じスクリプトで動作させればよい。環境間の違いはなるべくパラメータでカバー。
  • ロールバックへの対応
    • ECSを使えるならタスクを戻すだけ。
    • EC2に直でデプロイしている場合はシンボリックリンクなどで切り替える。
    • Lambdaを使っているならエイリアスに紐づいたバージョンを戻す。ただし呼び出す際にエイリアス付きで呼び出している必要あり。
    • CloudFront+S3の構成は難しい。どうしてもとあらばLambda@Edgeを使う方法のようにS3の前に切り替える仕掛けを仕込むことになるはず。
      • とはいえ大がかりすぎてコストやオーバーヘッドが気にかかる。前回のデプロイ時のファイル群をs3 syncで戻すくらいが落としどころか。運用でカバーである。
  • ダウンタイムの極小化
    • 切り替え式のデプロイを行っていれば切り替えにかかる時間だけとなる。
      • それが難しいCloudFront+S3だと上書きになってしまう。一瞬表示が崩れるなどのことは覚悟せざるを得ないかもしれない。
    • ただしAuroraのアップデートなどがあると専用の大がかりな仕掛けが必要になるはず。
  • 事前の動作確認
    • 環境を分ける。いわゆるstagingやpreと名のつく環境を本番と同じ設定で用意。
  • デプロイのログが残る。何かあったら通知が出る。
    • ECSのように元からログの記録とセットのものを使う。そういうものがなければ、デプロイスクリプトの中で自らログを残す。通知も同様、既製品の中にあればそれを使い、なければ自前でセット。

構成例

環境の分離

  • 本番環境(product)
  • テスト環境(staging)

デプロイフロー backend(ECSの場合)

  1. 複数の環境用設定(e.g. product.env, staging.env)を格納したコンテナイメージを作成。
  2. それをstaging環境で起動して確認。
  3. 同じソースコードからコンテナイメージを作り直してproduct環境にデプロイ。

※ 上記はCI/CDで実行する。Github Actionsとか。

デプロイフロー backend(API Gateway + Lambdaの場合)

  1. staging環境にデプロイして動作を確認
  2. 問題なければ同じソースコードからproduct環境にデプロイ。

余談:昔話

クラウドがまだ流行していないオンプレの時代は上書き式のデプロイがよく見られた。Dockerはまだ実用に耐える技術として普及しておらず、AWSのように切り替えるポイントがいくつもあるわけでなく、またサーバー自体も気軽に増減などさせられなかったから、ある意味最適でもあった。下手するとバージョン管理システムが使われていないプロジェクトもあったくいらいで、バージョン管理システムとデプロイツールの連携どころではなかった。今から思うと凄い時代である。その時代はstaging->productで同期させるのが堅いやり方だった。が、2022年現在、gitも普及している上、CI/CDのツールも豊富である。当時のノウハウはもう余程のことがなければお蔵入りさせてよさそうだ。

デプロイフロー frontend(Cloudfront + S3)

  1. staging環境にデプロイして動作を確認
  2. 問題なければ同じソースコードからproduct環境にデプロイ。

CORSで困ったときのリンク

先日CORSのエラーに散々悩んだので、解決のための参考になったリンクを記載。

ちなみに構成はFront(Next.js) + Back(AWS API Gateway + Lambda(Python)) 。

リンク

たとえば

Front(Next.js) -> AWS API Gateway -> Lambda(Python) という構成だと、どこか一つにミスがあるだけでCORSに失敗する。当たり前ではあるが。

そのため、OPTIONで投げるリクエストとレスポンス、そして本命のリクエストとレスポンス、と細分化して一つ一つ切り分けていくのが遠回りなようで確実な道。CORSとひとまとめにくくって調べるとトラブルシューティングには苦労する。というか、苦労した。

CloudFrontのCNAMEは重複不可

問題

新旧の個人用サイトを並行で動かそうとしたところ、CloudFrontの作成でエラー発生(CNAMEAlreadyExists)。

module.personal_website_frontend.aws_cloudfront_distribution.front_cdn: Creating...
╷
│ Error: error creating CloudFront Distribution: CNAMEAlreadyExists: One or more of the CNAMEs you provided are already associated with a different resource.
│       status code: 409, request id: d5f61e56-8789-4443-bd73-5928d7f0f2de
│
│   with module.personal_website_frontend.aws_cloudfront_distribution.front_cdn,
│   on ..\modules\personal-website-frontend\main.tf line 45, in resource "aws_cloudfront_distribution" "front_cdn":
│   45: resource "aws_cloudfront_distribution" "front_cdn" {
│
╵

CloudFrontは同じCNAMEを使ったDistributionを作成するのはダメな仕様らしい。

しかしそれではダウンタイムなしに切り替えができない。

解決

この辺ClassMethodさんにダウンタイムなしでの実行方法があった。

ただ、かなり複雑な作業なので、客先の本番環境で動いているサービスにこれを実行するのはかなりの慎重さがいりそう。

意外な弱点があるもの。

Terraformの*.tfstateファイルを保存するためのS3のバケットをCLIで作る

TerraformのtfstateファイルをS3に保存する場合、そのバケット自体はTerraformを使わずに作成することが推奨されている。

というわけで、CLIを使ってバケットを作成することにした。

コマンド

先にコマンドを記載する。結論、以下のようにした。

aws s3api create-bucket --region ap-northeast-1 --create-bucket-configuration LocationConstraint=ap-northeast-1 --bucket [BUCKET_NAME]
aws s3api put-bucket-versioning --bucket [BUCKET_NAME] --versioning-configuration Status=Enabled
aws s3api get-bucket-versioning --bucket [BUCKET_NAME]
aws s3api put-public-access-block --bucket [BUCKET_NAME] --public-access-block-configuration BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true
aws s3api get-public-access-block --bucket [BUCKET_NAME]
aws s3api put-bucket-encryption --bucket [BUCKET_NAME] --server-side-encryption-configuration "{\"Rules\":[{\"ApplyServerSideEncryptionByDefault\":{\"SSEAlgorithm\":\"AES256\"}}]}"
aws s3api get-bucket-encryption --bucket [BUCKET_NAME]
aws s3api put-bucket-tagging --bucket [BUCKET_NAME] --tagging "TagSet=[{Key=Environment,Value=[ENVIRONMENT]}]"
aws s3api get-bucket-tagging --bucket [BUCKET_NAME]

[BUCKET_NAME]には実際のバケット名が入る。また、[ENVIRONMENT]には環境名が入るのだが、タグのつけ方は状況にあわされたし。

コマンドの説明

バケットは以下の方針で作っている。

  • バケット名は[環境名]-[用途]-[ユニークにするための文字列]とする。
    • e.g. product-log-abcdefgh
  • バージョニングを有効化する。
    • うっかり消してしまうと悲惨なので。
  • セキュリティのため、パブリックアクセスブロックは全部ブロック。またサーバーサイド暗号化も有効化する。
    • 個人で使うTerraformなのでAmazon S3 マネージドキーによる暗号化で済ませているが、より強固にしたいならKMSタイプを。
  • タグも付けておく。

S3のバケットをどのように分けるか?

S3を利用するにあたっては、通常、いくつかのバケットに分けて利用することになる。一つのバケットに全部おさめるとバケットの中がカオスになるからだ。

だが思いつき次第にバケットを作っていくのも不味い。今度はS3自体がカオスになってしまう。そもそも1アカウントで作れるバケットの数も上限がある。(緩和できるけど)

従って最適な分け方をしたいわけだが、どうすればいいだろうか。

基準は?

AWSの中の人が考え方を公開してくれている。

  1. 人が見て直感的に分かりやすい単位で分ける。
  2. バケット単位の設定を変えたい場合はバケットを分ける。
    • バージョニング、ライフサイクルポリシー、オブジェクトロックなど
  3. バケット単位のサービス上限に達しないよう分ける。

基本、これを元に考えていけばよいと思う。基本は1でまず分け、次に必要に応じて2の条件の違いで分ければよいのではないか。3は制約条件だから、抵触しそうなら気を付ける、で良いと思う。

具体的には?

私見ながら次のように考える。

  • まず環境、特に本番用途は必ず分けたい。オペミス防止のためのフールプルーフとして、保全の必要性が高いデータは分離しておくべきである。
  • 一般公開用も同じ理由でその他の非公開用バケットから分離したい。機密情報と公開用HTMLが一緒に入っている状況はあまり嬉しいものではない。おそらくバケットの設定も異なる。
  • ログも他と混ぜずに分離しておきたい。Athena等から別途使うことがありえるから、ということもあるし、ライフサイクルルールやバケットポリシーも設定したいからでもある。

それを踏まえ、環境が仮にproduct/staging/developの3点、単位を公開用(public)/ログ(log)/その他(general)の3種で分けるとすると、最低でも3*3で9つ必要となる。

  • product-public
  • product-log
  • product-general
  • staging-public
  • staging-log
  • staging-general
  • develop-public
  • develop-log
  • develop-general

さらにCloudTrailやCodePipeline、CloudFrontなど、AWS側で運用するバケットがあり、そちらが加わる。

S3は他のアカウントも含めてバケット名をユニークにしなければならず、従って上述の名前でバケットを作ることは困難だが、そこはそれ、後ろに乱数文字列をつけるなどしてなんとかかわすしかない。

参考

Next.jsで静的HTMLエクスポートをするとリンク先が変わる

課題

Next.jsで静的HTMLエクスポートをすると、各ページごとに*.htmlの拡張子が付いたHTMLファイルが作られる。

ところが、このためにhttps://www.example.com/contactではリンクが繋がらず、https://www.example.com/contact.htmlとしなければいけなくなってしまう。

解決

仕方ないので.htmlをリンク先に付与する。

<Link href="contact.html">Contact</Link>

こうすると今度はnpm run devで開発しているときにリンク切れになるのだが――リンク先は以下のように指定するため――

<Link href="contact">Contact</Link>

どちらかしか選べないらしく、基本的にどうしようもなさそうである。

一応他の解決策としては、CloudFrontでLambda@Edgeを使うなどしてリネームするとか、或いはWEBサーバー側でrewriteする、あるいは自分でLINKを拡張する、といったやり方が考えられるが、今のところそこまでするほど困っていないため、しぶしぶながら今回は拡張子を付与する方向で対応した。実用上さほど問題ではないのだが、敗北感が残る結果に。

MUIを使って個人サイトをリニューアル

Next.jsで個人サイトをリニューアルするにあたり、MUIのテンプレートがFreeかつ便利そうであったため、これを採用することにした。WEBデザインまでは手が回らない自分にとり、テンプレートはありがたい存在である。自前で一からデザインするより早いし高クオリティなのだから使わない手はない。

とにもかくにも動作させるまで

  1. 使うのはここ
  2. そこでgitから一度まとめてダウンロードし、該当のディレクトリをNext.jsのプロジェクト内にあるpagesに*.tsxだけコピーする。
  3. ライブラリをインストール

    npm install @mui/material @emotion/react @emotion/styled @mui/icons-material react-final-form

  4. npm run devで起動し、初期ページに手打ちで飛ぶと、無事画面が表示される。画像のリンク切れが発生しているが、これは修正前なのである意味当然。

20221031125747

修正

不要な部品を削除

自分の場合コンテンツはブログメインで行くつもりなので、個人サイトはリンクと問い合わせフォームがあればひとまず事足りる。

というわけで、index.tsxを残し、後は全て部品も含めて削除する。

コードを追加・修正

問い合わせフォームを追加するとともに、個人用サイトに合わせてindex.tsxおよびその部品も修正。

細かい内容は逐一書いているとキリがないので省略するが、モノはこちらに置く予定。

参考