LoginSignup
184
165

AWS から OCI に移行してコストを約半額にした話

Last updated at Posted at 2024-05-15

はじめに

少し前までやっていた話を紹介します。

あるWEBサービスを AWS から OCI (Oracle Cloud Infrastructure)環境に移行してコストが半額くらいになったので、苦労した点や工夫した点などを紹介させていただきます。

主な変更内容

ミドルウェア面を中心に、主なものとなります。

内容 変更前 変更後
コスト - AWS時代(RI/SP込み)と比較して約半額程度
Compute EC2 Compute VM
OS Amazon Linux 2 Oracle Linux 8
MySQL Database RDS for MySQL
MySQL 8.0.33
MySQL Database Service(MDS)
MySQL 8.0.36
Redis Amazon ElastiCache for Redis
Redis 7
OCI Cache with Redis
Redis 7
コンテナレジストリ Amazon Elastic Container Registry (Amazon ECR) Oracle Container Registry (OCR)
ロードバランサ CloudFront,
Network Load Balancer(NLB)
Flexible Load Balancer(FLB),
Network Load Balancer(NLB)
WAF AWS WAF OCI WAF (WAFポリシー),
Nginx + GeoLite2 + ngx_http_limit_req_module
共有ストレージ EFS(Elastic File System) ファイル・ストレージ・サービス
オブジェクトストレージ S3 Object Storage
証明書 AWS Certificate Manager (ACM) Certificates
Perlバージョン Perl 5.36.1 Perl 5.38.2
絵文字対応 Unicode 14 Unicode 15

OCIについて知らない方向け

AWSは知ってるがOCIを知らないという方は取り急ぎ以下のようなページを読むとイメージが掴みやすいかと思いますのでリンクを貼っておきます。
本件では細かい用語の違いなどの説明は省略します。


OCIへの移行理由

今回移行した理由はコスト削減が最大の理由でした。
オンプレからAWSに移行したのは3年前の2021年2月で当時のドル円相場は約106円でした。
2021年のAWS移行当時、RDSのReserved InstancesとEC2のSavings Plansを3年で購入していました。(通常は1年などで購入されるケースの方が多いと思いますが、歴史のあるサービスなので急激なリソースの増減はあまり無さそうではと考えたためとなります。結果としては円が強いタイミングで安く買えて助かりました)
移行を検討し始めたのはRI/SPが切れる1年前くらいで、その時点のドル円レートは135円くらいだったと思います。
その後3年満期を迎えて最終的に移行が完了した2024年3月では、なんと150円超えになってきていました。(2024年5月現在はドル円が155円近くになってしまっていますw)

AWSのままコストを下げるには、 Arm/Graviton対応やスポットインスタンスの活用、請求代行の割引活用、オートスケールなど、いろいろ考えられるかとは思いますが、当然それなりの対応コストもかかります。

こういった外的要因の変化などもあり、今回OCIに移行することにしました。

検討開始当初は自分もOCIの知識が無く、どこまで安くなるのは半信半疑でしたが、実際に(AWSでSP/RIの適用と比較しても)半額近くになりました。
半額というのが信じられない方もいるかと思いますので(自分もそうでした)、試しに以下のページをご覧いただくとひと目で分かりやすいかと思います。
(2021年時点の AWS, Azure, GCP, IBM, OCI の料金比較記事のようです)

クラウドなので常に値段の変動もあるかとは思いますので、現時点でのスクショも残しておきます。(問題ありましたら削除します)

コストだけではなく、式年遷宮したことで副次的な効果もいろいろあったので紹介します。

「ベンダーロックインされないか?今後の急激な値上げは大丈夫なのか?」
という話もあるかもしれませんが、なるべく特定のマネージドサービスに依存しないようしています。
今後もしOCIが突然の大幅な値上げとなってしまった場合、再度他のクラウドに移行することも可能なように意識しています。

移行前準備

OCI移行の前にいくつかのステップがあったので、大きな流れを説明させていただきます。

  • 2020年12月頃

    • オンプレのファイルサーバ(NFS)で運用していた画像系の配信箇所を一足先にAmazon S3に移行
      • 従来のNFSからオブジェクトストレージ化の対応を行いました
    • 段階的な移行にしたかったため、DB/バックエンド系はオンプレのままとしていました
  • 2021年2月頃

    • オンプレに残っていたDB/バックエンドなどをこのタイミングですべてAWSに移行しました
  • 2023年03月

    • 画像系のCloudFront + S3 + EC2環境 を Cloudflare R2 + Workers環境に移行
    • 詳細は以下の記事の内容になります

この時点でサービスの中でも特にコストのかかる画像の配信側をCloudflareに切り離すことができ、従来のサーバ運用から開放され、コスト的に相当な削減効果がありました。
コスト面以外にも、サーバレス運用の容易さ、安定性等で非常に満足しており、Cloudflare R2 + Workers環境には今後ともお世話になりたいと考えています。(今回のOCI移行の範囲外です)

  • 2023年08月
    • 老朽化したコンテナの刷新、MySQL 8.0化、ミドルウェア、Perl、ライブラリのアップグレードなど

こちらは以下の記事に書いています。

このタイミングでMySQL 5.7 -> 8.0対応を行っており、これが今回のOCI移行への布石となりました。コンテナ・ミドルウェア・ライブラリなども最新版にビルドしておくことで、影響が少なく移行しやすいように準備していました。

  • 2024年03月
    • AWS環境からOCI環境に移行しました。今回の記事の内容になります

2024年02月でRI/SPが切れたため、このタイミングでOCIに移行となりました。
移行完了から2ヶ月以上が経過していますが、今のところ特に大きな問題も起きていないので今回振り返る意味でまとめてみました。


構成図

移行前(AWS)

ざっくりなので細かいところは省略していますが、ツッコミありましたらお願いします🙇‍♂️

移行後(OCI)

基本的にはEC2をComputeにした感じとなります。

一部を除いてほぼコンテナ化していたので、環境構築自体は比較的シンプルでした。
コンテナなのにECS化していなかったのは深遠な理由があるのですが、
結果として素のコンテナで楽に移行できたので助かりました。

以下からはこの移行でハマった点などを紹介します。


DB移行(RDS for MySQLの移行)

ここが一番ハマった箇所で、かなりのイレギュラー問題に遭遇してちょっと長くなってしまったので、興味のない方はスキップしてください…w

MySQLの移行ですが、2021年にオンプレからRDSに移行した時は、
MySQL 5.1から一気に5.7へのアップグレードというレガシー特有の事情があり、バージョンを複数跨いだのでレプリケーションを何階層かに分けて段階を踏んでやっていました。
基本的には mysqldump でRDS for MySQLにデータをインポートして、
オンプレからインターネット越しにsshトンネルを張ってレプリケーションで同期する流れとなっていました。

当時はオンプレだったのである程度の融通が効いたのですが、歴史の長いサービスであり稼働中のサービスなので今回も mysqldump でやるとなかなかデータ量が多く、直列実行となってしまうためダンプとインポートするだけでも非常に時間がかかって困難な事が予想されました。

mysqldump の後継的な mysqlpump

の方もありましたが、正直自分の方では触ったことがなく、今後MySQL 8.0.20以降ではdeprecatedになったということで他の方法を検討しました。
また、MyDumperというものもあるようでした。

が、現在では MySQL Shell の Instance Dump Utility という良さげなものがあると知りました。

また、このInstance Dump Utilityを紹介した記事

などを読んでみたところでも、MySQL Shell dumpInstance は並列化により非常に高速に Dump / Import が行えるとありました。
そこで、MySQL Shell の Instance Dump Utilityについて調べたところ、以下の記事などが非常に参考になりました。

せっかくなのでこれを使ってみることに。

なお、歴史的な経緯により、なかなかサービス側のRDSを止められないので、GTIDは無しでやることになりました。

基本的な流れは上記の記事の流れになります。
この流れでうまく行った場合、AWS RDSとOCI MDS間でアウトバウンド・レプリケーションを設定することになります。
以下の記事が非常に参考になりました。

特にMDS側のレプリケーションチャネルを設定する際に注意するのが、上記記事内にあるように、

共通フィルタ・テンプレート:AWS RDS MySQL8.0

を選択するというところでしょうか。

なお、GTID を利用してのレプリケーションと、レプリケーションチャネルのトラブルシューティングについては、以下の記事もとても参考になりました。

クラウド間でレプリケーションする際に工夫した点

工夫1. VPNの代わりにsshトンネルを利用

前述の記事では [AWSとOCI間でVPN接続] とありましたが、
ここは手間を省略するために sshトンネルを利用してレプリケーションを維持することにしました。

sshトンネルのsystemd化については以下の記事などが参考になると思います。

工夫2.NAT Gatewayの罠に注意する

ダンプするサイズがそれなりに大きいので、例によってNAT Gatewayの罠があるため、パブリックサブネットのEC2経由でダンプ/レプリケーション等のやり取りを行いました。

工夫3.RDS側のパラメータグループの調整等

RDS側のMySQL ShellでのDumpの取得にはリードレプリカを使用しました。
リードレプリカ側でバイナリログを記録するために、バックアップ保持期間を 0 以外の正の値に設定する必要があったので7日間にしてみました。

が、それだけでは足りず、

SHOW BINARY LOGS;

で確認するとバイナリログがすぐに消えるようになってしまっているので、

などを参考に

call mysql.rds_set_configuration('binlog retention hours', 168);

としました。

MySQL DB インスタンスの場合、binlog retention hours の最大値は 168 (7 日) です。

となっているようなので、ダンプからインポート、レプリケーション開始までに7日以内に行う必要があります。

また、 binlog_formatMIXED になっていたので ROW に変更するのも必要でした。

ここまででステージングのレプリケーション移行は正常に動作することを確認できました。

さて、いよいよ本番環境のレプリケーションの準備のところまで行ったのですが、
ここで大きな問題が発生しました。

本番環境で発生した問題

まず最初のダンプ作成は何事もなく、いい感じで完了しました。
(本番環境でDumpする際は、リードレプリカの遅延に注意する必要があります。)

-- ダンプ作成
util.dumpInstance("xxxx-prd-rds-read-20240202", {osBucketName: "xxxx-production-bucket-db-dump", \
ociConfigFile: "/home/ec2-user/.oci/config", ocimds: true, \
compatibility: ["strip_restricted_grants", "strip_definers", \
"skip_invalid_accounts"], dryRun: false});

OCIのオブジェクトストレージ側にいい感じのサイズで分割・圧縮されてDumpファイルが格納されていました。
よしよし、と思い先ほどの記事にある

のように、OCI MDSの作成時に「データのインポート」タブを選択し、「既存のバケットに対するPAR URLを作成~」を選択し、オブジェクトストレージのPARソースURLを指定して作成してみました。

すると、、

CleanShot 2024-05-11 at 18.56.24@2x.png

ImportInvalidCharString

MySQL Shell dump contains an invalid character string. Create a valid dump and try again, or contact Oracle Support.

のようなエラーで作成が失敗。。

どうやらダンプ内に不正な文字列が混入しているようでした。
ちょっとしたオプションが足りていないだけなのかな、と思うも、ここから長いトラブルシューティングが始まりました。
(OCIのSRでMySQLサポートなどもありがたく使わせていただきつつ、なかなか苦戦しました)

不正文字の場所を特定する

さきほどの手順だとエラーメッセージが曖昧なため、やり方を変えて、MDS作成時にバケットのPARソースURLを使わずに空のMDSインスタンスを立てた上で util.loadDump を使ってどこのエラーで止まっているのかを確認しました。

util.loadDump時のエラー出力
 MySQL  10.0.1.205:3306 ssl  JS > util.loadDump("xxxx-prd-rds-read-20240202", {osBucketName: "xxxx-production-bucket-db-dump",  loadUsers: true});
Loading DDL, Data and Users from OCI ObjectStorage bucket=xxxx-production-bucket-db-dump, prefix='xxxx-prd-rds-read-20240202' using 4 threads.
Opening dump...
Target is MySQL 8.0.36-cloud (MySQL HeatWave Service). Dump was produced from MySQL 8.0.33
WARNING: The dump was created on an instance where the 'partial_revokes' system variable was disabled, however the target instance has it enabled. GRANT statements on object names with wildcard characters (% or _) will behave differently.
Fetching dump data from remote location...
Listing files - done
Scanning metadata - done
Checking for pre-existing objects...
Executing common preamble SQL
Executing DDL - done
Executing user accounts SQL...
NOTE: Filtered statement with restricted grants: GRANT APPLICATION_PASSWORD_ADMIN,BACKUP_ADMIN,FLUSH_OPTIMIZER_COSTS,FLUSH_STATUS,FLUSH_TABLES,FLUSH_USER_RESOURCES ON *.* TO `admin`@`%` WITH GRANT OPTION; -> GRANT APPLICATION_PASSWORD_ADMIN, BACKUP_ADMIN, FLUSH_OPTIMIZER_COSTS, FLUSH_STATUS, FLUSH_TABLES, FLUSH_USER_RESOURCES ON *.* TO `admin`@`%` WITH GRANT OPTION;
NOTE: Filtered statement with restricted grants: GRANT BACKUP_ADMIN,FLUSH_OPTIMIZER_COSTS,FLUSH_STATUS,FLUSH_TABLES,FLUSH_USER_RESOURCES ON *.* TO `blog`@`10.0.%`; -> GRANT BACKUP_ADMIN, FLUSH_OPTIMIZER_COSTS, FLUSH_STATUS, FLUSH_TABLES, FLUSH_USER_RESOURCES ON *.* TO `xxxx`@`10.0.%`;
Executing view DDL - done
Starting data load
ERROR: [Worker002] xxxx_new@xxxx_child@644.tsv.zst: MySQL Error 1300 (HY000): Invalid utf8mb4 character string: 'TITLE:
SECRET: 0
PASS: 3da3cfaa8b7f6cb26bd5e7cba6e7c532
�� ': LOAD DATA LOCAL INFILE 'xxxx-prd-rds-read-20240202/xxxx_new@xxxx_child@644.tsv.zst' REPLACE INTO TABLE `xxxx_new`.`xxxx_child` CHARACTER SET 'utf8mb4' FIELDS TERMINATED BY '   ' ESCAPED BY '\\' LINES STARTING BY '' TERMINATED BY '\n' (`id`, `xxxx_id`, `yyyy_id`, `writer`, `aaaa`, `bbbb`, `ccccc`, `dddd`, `eee`, `createstamp`, `timestamp`)
ERROR: Aborting load...
2% (43.51 GB / 1.74 TB), 1.20 MB/s, 4 / 428 tables done
1203 chunks (115.46M rows, 43.51 GB) for 428 tables in 5 schemas were loaded in 32 min 55 sec (avg throughput 22.44 MB/s)
6 accounts were loaded
1 errors and 0 warnings messages were reported during the load.
Util.loadDump: Error loading dump (MYSQLSH 53005)
 MySQL  10.0.1.205:3306 ssl  JS >

これにより実際にエラーが発生した tsv ファイルとテーブル、だいたいのレコードまで特定出来ました。

tsvを見てみると、2010年頃に入ったレコードのようで、このサービスでは当時MySQL 4.0(ujis = EUC-JP)から MySQL 5.1(utf8mb3) への過渡期に入ったものであることが想定されました。
さらにエラーが再現する最小単位まで絞り込みを行いました。

mysql> LOAD DATA LOCAL INFILE '/tmp/invalid_1.tsv' INTO TABLE test_xxxx_child;
ERROR 1300 (HY000): Invalid utf8mb4 character string: 'TITLE:
SECRET: 0
PASS: 3da3cfaa8b7f6cb26bd5e7cba6e7c532
�� '
mysql>

このTSVを1文字単位でコードポイントを出力するスクリプトを作成したところ、以下の文字まで絞り込むことができました。

UTF-8 "\xED\xA4\xA4" does not map to Unicode

"\xED\xA4\xA4" は、UTF-16のサロゲートペアを表す範囲(D800-DFFF)にマップされるバイト列で、UTF-8ではこの範囲のコードポイントは使用されないという事でした。
このサロゲートペアの文字が、utf8mb4のテーブルに入った時点で、U+FFFD の代替文字に差し替わって不正文字になってしまう(?)ようです。(このあたり、自分も詳しくないので識者の方がいたら教えて下さい)

その上で、このテーブルを中心に該当レコードの全部の文字のコードポイントを調査していきました。

char : [ ä ] : codepoint : [ 228 ]
char : [ Ȥ ] : codepoint : [ 548 ]
char : [ � ] : codepoint : [ 65533 ]
char : [ � ] : codepoint : [ 55588 ]
char : [ � ] : codepoint : [ 65533 ]

この場合、codepoint 55588 の文字は

で、 U+D924 の lead surrogate-D924 という文字のようです。
サロゲートペア文字のため、本来は「上位サロゲート+下位サロゲート」のペアになっているはずですが、これ1文字だけ単独で出てくることはありえない、という事ではないかと思われます。

High Surrogates U+D800 - U+DB7F
High Private Use Surrogates U+DB80 - U+DBFF
Low Surrogates U+DC00 - U+DFFF

このため、最初は

if ($text =~ /\x{D924}/) {

のようにして不正文字があるレコードを調査していきました。

これは推測ですが、この文字を含むテーブルは、
mysqldump のDump経由だとエクスポート/インポート自体は可能(多少おかしくても入ってしまう?)だが、
MySQL Shellの util.dumpInstance & util.loadDump だと厳格に文字列の範囲がチェックされインポートが行えない、という事かもしれません。

ともあれ、仮にmysqldumpだとインポート出来たとしても、不正な文字が混入したままインポートしてしまうのは今後を考えると非常によろしくないため、この機会に全テーブルの全データをクレンジングするようにしました。

ここから後半は省略しますが、

util.loadDumpexcludeTables オプションなどを使って、エラーになるテーブルを全部特定しつてみると、さきほどの U+D924 以外のサロゲートペアが2010年頃のデータを中心に多種混入しており、
最終的には

s/[\x{D800}-\x{DFFF}]//g

のようにして不正なサロゲートペア文字は全部除去することで、すべて utf8mb4 にしてインポートする事が出来ました。(結果的に数十テーブル、数十万レコードがこういった不正文字のレコードが入ってしまっていました。古くからのデータのため、この調査で時間がかかってしまいました)

このような不正文字が混入してしまった原因の推測

不正文字が混入してしまった原因ですが、オンプレ時代の mysqld に原因があったものと思われます。

2009年〜2010年頃、当時のMySQL 4.0(ujis) から MySQL 5.1(utf8mb3) に変更しつつアップグレードしていたのですが、このあたりの経緯により、壊れた範囲外のデータが混入してしまっていたのかもしれません。

古い話なので詳しい資料は見つからないのですが、以下のMLでは

4.0まではJISコード範囲外の文字でもテーブルに挿入することができたが、
4.1からはコード範囲外の文字があると、当該文字以降のすべての文字が消失してしまう。

とありました。
普通にMySQL 4.1 以降(?)でmysqldをパッケージでインストールし、utf8だけでMySQLを使っている限りではおそらくこのような事は起きないのではないかと思います。
(なお、オンプレ MySQL 5.1 からAWS RDS for MySQL 5.7に移行後した2021年以降のデータでは、このような不正な文字は入っていませんでした。)

GTID無しでレプリケーションチャネルを作ろうとした事による問題?

さきほどの不正文字の問題が解決し、後は問題なく行けるかな、と思ったのですが最後にレプリケーションチャネルを繋ぐ際に1点問題が発生しました。

GTIDを有効にしていないため、 util.dumpInstance で生成された @json の中のバイナリログのポジション binlogFile, binlogPosition を元にレプリケーションチャネルに指定したのですが、いくつかの不可思議なエラーが出てレプリケーションが停止してしまう事がありました。

何パターンかのエラーがありました。

最初に出たのが以下の感じでした。

             Slave_IO_Running: No
            Slave_SQL_Running: Yes
(snip)
               Last_IO_Errno: 13114
                Last_IO_Error: Got fatal error 1236 from source when reading data from binary log: 'bogus data in log event; the first event 'mysql-bin-changelog.003890' at 1843405, the last event read from '/rdsdbdata/log/binlog/mysql-bin-changelog.003890' at 126, the last byte read from '/rdsdbdata/log/binlog/mysql-bin-changelog.003890' at 1843424.'
               Last_SQL_Errno: 0

このエラーを調査してみると、

などにあるような、

  • innodb_flush_log_at_trx_commit
  • sync_binlog

などの設定も問題ないようでした。
このエラーは移行リハーサルのときに一回出ただけで、次回は再現せず。

最後に出たのが

             Slave_IO_Running: No
            Slave_SQL_Running: Yes
(snip)
                Last_IO_Errno: 13114
                Last_IO_Error: Got fatal error 1236 from source when reading data from binary log: 'log event entry exceeded max_allowed_packet; Increase max_allowed_packet on source; the first event 'mysql-bin-changelog.004325' at 12762283, the last event read from '/rdsdbdata/log/binlog/mysql-bin-changelog.004325' at 126, the last byte read from '/rdsdbdata/log/binlog/mysql-bin-changelog.004325' at 12762302.'
               Last_SQL_Errno: 0

これは max_allowed_packet となっているものの、 max_allowed_packet はおそらく関係なく、
開始ポジションの指定などの問題の可能性が高そうでした。

$ mysqlbinlog  --read-from-remote-server     --host=xxxx-prd-fmdi.sub00000000000.xxxxxproductionvcn.oraclevcn.com  \
--port=23306 --user repl_oci  --password     --raw   \
--verbose   --result-file=/tmp/binlog/    mysql-bin-changelog.004325

のように手元にバイナリログを持ってきて、

$ mysqlbinlog --base64-output=DECODE-ROWS --verbose /tmp/binlog/mysql-bin-changelog.004325  | less

のように中身を丁寧に見て、トランザクションが開始するポジションに調整したところ、無事にすべてのレプリケーションがつながってくれました。(このレプリケーション接続前後のポジションを確認して不整合などが起きていないかチェックも行っています)
推測ですが、最初からGTIDがONでレプリケーションを開始できていれば、こちらのような作業も不要であったかと思われます。

以上、RDSからMDSへの移行はトラブルもありつつも、なんとか無事にレプリケーションチャネルを繋ぐことが出来ました。

少なくとも mysqldump で昔のように頑張るよりも MySQL Shell の Instance Dump Utility を使ったことによってとても手間が減って楽ができました。

(だいぶ長くなってしまいましたが、万が一同じような環境の方がおられましたらということで、ハマった時の調査メモでした。)


Amazon EFS から OCI File Storageへの移行

EFS(NFS)はもともと適用範囲を一部に限定しており、利用範囲も昔よりも減らしていたため、素朴に移行しました。

あたりをざっと読んで、NFSv3 でマウントする感じでした。
使い勝手・パフォーマンス等もテストして特に問題なかったため、移行当日はシンプルにrsyncで差分シンクして行いました。


AWS WAFから OCI WAFへ移行

OCI WAFはAWS WAFと比較すると結構な違いがありました。
OCI WAFは以下の資料がコンパクトにまとまっています。

今回利用したのは、FLBに設定するWAFポリシーの方になります。

このWAFはいろいろ深遠な理由があり、以前の要件を一部満たせない箇所があったため、
自前のNginx側である程度の対応を行うようにしました。

例えば、特にスパム・不正クロール・攻撃が多い国に対してのみの厳しめのIPレート制限をかける事や、Captchaを適用したりするといった事が設定しにくい点などがありました。
そこでNginx側で少し改修を行いました。

Nginx側で特定の地域のみをIPレート制限などする仕組み

NginxでIPレート制限をする仕組み自体はいろいろな方法が考えられると思います。

有名どころでは、サイボウズさんの nginx-maxconn-module

や、matsumotoryさんの ngx_mruby を使った http-dos-detector

あるいは Nginx 公式の ngx_http_limit_req_module

あたりでしょうか。

今回はなるべく組み込みがシンプルになるようにしたかったので、この ngx_http_limit_req_module を使ってやってみることにしました。

ngx_http_limit_req_module とは

このモジュールについて日本語のブログでは

の記事がとても参考になりました。

有名どころでは、rubygems.org などでも以下のように使われているようです。

国判定を行うモジュール : ngx_http_geoip2_module

国などの判定にはMaxMindさんのGeoIP2を利用した

を使うようにしました。

ngx_http_limit_req_module と ngx_http_geoip2_module を組み合わせる

この2つを組み合わせるようにしてみました。
特定の対象の国からのリクエストのみ、 limit_req でIPレート制限を行う例になります。
以下のようになります。

    # geoip2を定義
    geoip2 /etc/GeoLite2-Country.mmdb {
        $geoip2_data_country_code default=JP source=$remote_addr country iso_code;
    }
    map $geoip2_data_country_code $allowed_country {
        default yes;

        XX no; # 対象の国コード
        YY no; # 対象の国コード
    }

    limit_req_zone $binary_remote_addr zone=req_limit_per_ip:10m rate=180r/m;
    #limit_req_status 429;
    limit_req_status 503;
    limit_req_log_level warn;
    location = / {

        # 特定の対象国のみrewrite
        if ($allowed_country = no) {
            rewrite ^ /geo_restricted_access last;
        }

        # それ以外の正常なリクエストの処理
        proxy_pass http://varnish/;
    }

    # 特定の対象国のリクエストのみ、 limit_req を作動させる
    location /geo_restricted_access {
        internal;
        limit_req zone=req_limit_per_ip burst=10 nodelay;

        # ここで $request_uri を別変数にすることで自動でURIエスケープを回避
        set $proxy_uri $request_uri;
        if ($request_uri ~ ^/geo_restricted_access(.*)$) {
           set $proxy_uri $1;
        }

        # プロキシ経由で元通りのリクエストにする
        proxy_pass http://varnish$proxy_uri;
    }

この中で set $proxy_uri $request_uri;
としているのは、Nginx側でproxy時に勝手にURIエンコードされてしまう問題の対応で、以下の記事を参考にさせていただきました。

これによって、WAFが無くてもNginx側だけで特定国からのリクエスト時のみ厳しめのIPレート制限をかけるなどの操作が行えるようになりました。

なお、GeoLite2の定義ファイルについては以下のような感じで定期的に更新するようにしています。

download_maxmind_geolite2_mmdb.sh

OCI WAFで特定のログを定期的に取得・通知する仕組み

NginxからOCI WAFの話に戻ります。
OCI WAFへの切替時、使うのが初物となるため、誤って弾いてはいけないリクエストなどが無いかどうか、定期的にログを確認するようにしました。

OCI WAFでブロックしたリクエストのログは、OCI側の「ロギング」の仕組みで確認する事が可能です。

例えばコンソールから日本のIP、かつブロックしたログのみを出す場合は以下のようになります。

CleanShot 2024-05-12 at 17.25.48@2x.png

定期的にコンソールにログインするのは手間なので、超雑ですが定期的にスクリプトを叩いてログを確認出来るようにしていました。OCIにはCLIでLogging Searchというものがあります。

これを利用して、定期的にPerlスクリプトから logging-search を叩いて、直近15分などのブロックしたログをSlackに通知するようにしてみました。(WAF導入初期のみ)

$ oci logging-search search-logs --search-query '$search_query' \
--time-start $formatted_time_start --time-end $formatted_time_end

search-queryのエスケープが厄介で、

data.action=\'\\\'\'block\'\\\'\' and data.countryCode=\'\\\'\'jp\'\\\'\' | sort by datetime desc

のように鬼のようなエスケープになってしまったので、これはもっといいやり方がありそうです…。

WAFを必要なタイミングのみ動作させる仕組み

WAFはリクエスト数によってそれなりのコストになるため、例えば平日の日中は動作させないでコストを節約する、といった方法もあると思います。(当然プロダクトの性質にもよるかと思います。)

OCI WAFの場合、WAFポリシーの中に「ファイアウォール」というものがあり、WAFが必要な時だけファイアウォールを停止・起動のようにしたいと考えましたが、「停止」の概念は無さそうだったため、OCI CLIを使用して、

oci waf web-app-firewall delete

oci waf web-app-firewall create-for-load-balancer

をスクリプトで行うような仕組みを念の為用意しておきました。


ロードバランサについて

ロードバランサについては以下の資料

を見ていただくのが分かりやすいのですが、
AWSのALBと、OCIのFLBは似ていますが、地味に違う箇所があったりしました。

  • ALBと違い、FLBでもL7だけでなくTCPをパススルーで使える(が、NLBとどう使い分けるのか不明)
  • ALBと違い、ALIASレコード無しでIPv4アドレスをAレコードで固定可能

また、FLB/NLBの混在環境、かつ http/https 混在の場合、アプリケーション側での環境変数の判定に若干注意が必要でした。
今後変わるかもしれませんが、現状では以下の挙動となっていました。

  • FLB
    • HTTP_X_FORWARDED_PORT のみを信頼する。
    • HTTP_X_FORWARDED_PROTOhttp 固定なので無視
  • NLB
    • HTTP_X_FORWARDED_PORT は存在しない
    • HTTP_X_FORWARDED_PROTO のみを信頼する。

(とは言え、普通のアプリケーションであればこれを意識する必要は無いかと思います。)


Perlのバージョンアップ

昨年は Perl 5.36.1 でしたが、このタイミングでアプリケーションコンテナのPerlバージョンも 5.38.2 にアップデートしました。
昨年バージョンアップしたばかりなので、この作業はサクッと完了しました。
これでUnicode 15の絵文字、

  • 🫨震える顔
  • 🫎ヘラジカの顔
  • 🪼くらげ

なども使えるようになりました。めでたい🫨


ElastiCache for Memcached の移行

こちらは若干注意が必要かもしれません。
後述するRedisはOCIにもマネージドサービスがありますが、
Memcachedは現状ではマネージドサービスは無いので、必要があればComputeなどで適宜立てる必要があります。
冗長化などを考えるとちょっと手間がかかりそうなので、改修が可能であればRedisなどに寄せたほうが良いかもしれません。


ElastiCache for Redis の移行

Memcachedの他にRedisも使用していたため、どうしようかと思っていたところ、
ちょうどよいタイミングでOCIにもフルマネージドサービス OCI Cache with Redis が 2023-10 にGAされました。

Redisと言えば、最近ライセンスの問題で Valkey にfork されるようなニュースが出ていましたが、OCIもサポートを表明されたようです。

さて、Redisの方に戻りますが、
ElastiCache for RedisではRedis 7.0 で、
OCI Cache with RedisでもRedis 7.0 をサポートしています。

が、OCIのRedisはTLSが必須となっているようでした。
dnf でインストールする redis の redis-cli だとTLSの問題で疎通テストが行えない事がわかったため、
redis-cliだけを入れてテストするには、

にあるように
make redis-cli BUILD_TLS=yes
の対応が必要となっていましたので若干注意が必要かもしれません。

Redisの監視について

Redisの監視として Mackerelmackerel-plugin-redis を使っていたのですが、
この検証時点ではTLSのサポートが無くてどうしようかなぁと思っていたところ、

と、本番環境構築の段階でちょうどタイミング良くTLSサポートの対応がされ、非常に助かりました🙇‍♂️


ACM、証明書の管理の移行

CloudFront + AWS Certificate Manager (ACM) は証明書の更新を意識しなくて良く、とても便利に使わせていただいていたのですが、OCIには現状では同じような仕組みは存在していません。

OCI Certificatesという仕組みとなっており、基本的には証明書を外部から持ち込むという形になるのではないかと思います。

例えばCloudflareのような外部のCDNなどを使うとTLS証明書も意識しないで済むと思いますが、
あいにく現状ではすぐにCDNを使えないため、自前でLet's Encrypt証明書を持ち込んで使うようにしました。
ここでいくつかハマりポイントがありましたので、参考までに紹介します。

OCI Certificates のLet's Encrypt証明書のインポートの罠

当初、動作検証の際に dehydrated と、lego を使ってOCI Certificatesに証明書のインポートを試しました。
すると The given PEM of the private key cannot be parsed. というエラーになってしまいました。
その後、試しに certbot で発行してみたところ、今度はインポートに成功。
違いは何だろうと調査していると、dehydratedとlegoはECDSA証明書、certbotはRSA証明書であったことがおそらく原因のようでした。

このため、dehydrated でLet's Encryptを発行する際に、

KEY_ALGO=rsa

と指定することで、デフォルトのECDSA証明書から RSA証明書に変更してインポートを試したところ、正常にインポートが行え、FLBからTLS証明書を利用することが出来るようになりました。
(こちらは今後変わるかもしれません。)

今後はOCI CLIを利用して、自動でLet's Encrypt証明書の更新が出来るように対応を検討しています。


サービス監視について

さきほどRedisの際にちらっと書いたのですが、サービスの監視にはオンプレ時代からMackerelを利用しており、結果的にAWSからOCIになってもほぼ同じような使用感で監視を設定することができました。

Mackerelには AWSインテグレーションという仕組み

があり、EC2やRDSなどのタグなどを設定することで楽にロールを設定することなどが出来ていました。

現状ではOCI用のインテグレーションの仕組みは無いため、OCIのコンポーネント単位で置き換えたものは公式プラグインではないものの、GitHubで代替可能な方法を探して監視を行うようにしました。

また、ダッシュボードなどもいい感じに設定することで、OCI移行による監視的な問題は現状発生していません。
Amazon Linux 2からOracle Linux 8になった程度なので、以前の監視ルールはほぼ使えています。
また、年月の経過で既に使わなくなったメトリックなどがいくつかあったので削除するきっかけになりました。

CleanShot 2024-05-12 at 19.38.22@2x.png

CleanShot 2024-05-12 at 19.47.46@2x.png


コストの監視

AWS時代は、デイリーで前日にかかったコストを毎日Slackで通知するようにしていました。

OCIには予算、予算アラートの仕組みがあります。

しかし、予算アラートだとあらかじめ設定した予算を超えた際に通知するというものだと考えられたので、
通知が来た時点で既に大問題になってしまっている可能性があるため、やはりデイリーでかかっているコストがわかるようにしたほうが良いと考えました。
毎日コンソールにログインするのはさすがに厳しいので探してみたところ、以下の記事が見つかりました。

これを使わせていただきました。
キモの箇所は以下のような感じになっています。

docker run --rm -v /home/opc/.oci:/oracle/.oci ghcr.io/oracle/oci-cli raw-request \
--http-method POST --target-uri https://usageapi.ap-tokyo-1.oci.oraclecloud.com/20200107/usage \
--request-body '{"tenantId":"ocid1.tenancy.oc1..xxxxxxxxxxxxxxxxxxx","granularity":"DAILY","timeUsageStarted":"${start_date}T00:00:00Z","timeUsageEnded":"${end_date}T00:00:00Z","filter": \
{"operator":"AND","dimensions":[{"key":"compartmentId","value":"$compartment_id"}]}}' \
 | jq -r '.data.items[] | select(.currency == "JPY") | .computedAmount'

OCIは現状ドルではなく円でコストが出るので、ドル円レートが揺れる時はとても分かりやすいです。
これを毎日Slackに通知することで、何か異変があったときにすぐに調査出来るようにしました。


Terraform, IaC

もともとレガシーなサービスであったため、AWS時代のterraformは志半ばで止まっており、中途半端な管理となってしまっていました。
OCI移行にあたり、せっかくなので terraformでの管理をしたいとも考えましたが、
初物が多く、まだシステム構成が固まっていなかったため、取り急ぎ以下の OCI Terraformプロバイダ terraform-provider-oci を使ってgit管理するに留めました。

まだ現状では terraform-provider-oci-generate_state も出力結果が不安定な箇所があるように見えたため、今後時間の余裕があれば対応していきたいと考えています。


ステージング環境のコスト削減の工夫

OCI CLIを使って、ステージング環境のComputeとMDSを定期的に落とすように設定しました。
平日の早朝・深夜や休日は自動で停止するようにしています。


当時のissueを見ながら書いていたらだいぶ長くなってしまったので、そろそろまとめに入ります。
箇条書きで失礼します…

OCIのメリット

  • とにかくコスト最強

  • 支払いはドル決済ではなく円決済が可能

    • 最近ドル円レート変動が激しいので、今後の価格改定時期は不明ですが、1年単位で契約すればひとまず問題ないのではないかと考えています
  • サポートが無料

    • AWSの場合、(請求代行などを使わないと)サポートのコストだけでなかなかのお値段ですが、OCIはサポートが無料で付いてきました
  • MySQLサポートが強力

    • MySQLの中の人に直接サポートを受けられる
  • Computeのスペックを柔軟に選べる

    • メモリ特化とかCPU特化を自分でカスタマイズ
    • 例えばVarnish Cache, Memcachedなどは思ったよりもCPUは使わない
    • 逆も然りでCPUに極端に特化とかも柔軟にカスタムできる
      • 今回最適なスペックの見直しができた
  • 移行後も大きな問題もなく運用できており、安定している

  • トラフィック・通信量の計算がシンプル


OCIのデメリット

あくまでも普通の典型的なWEBサービスを作るうえでの感想です。

  • 情報がまだまだ少ない
    • 事前知識が無い場合、学習コストが若干必要
      • しかし、実際のコストがそれを十分に補える
  • ACMのような証明書自動更新の仕組みがまだこれから
  • WAFの細かい制御もこれから
  • CDNはこれから
    • ユースケースにもよるが、CDNとWAFは前面に Cloudflare などをかませるとかも十分あり
    • 実際、途中までCloudflare経由(Proxied)で動作検証をしていたが、動作自体は問題なくいけそうな感じだった
  • MDSのバージョンのアップデート猶予期間がRDSよりは短い
    • MySQL Serverのアップグレード
      • このため、MySQL 8.4 LTS などの情報は常にチェックしておく必要がありそう
      • 言い換えるとバージョンの塩漬けをしたいなら自前で立てたほうが良い

とは言え、上記の点は工夫によってどれも対応は行えると考えています。

移行が初めての場合、多少の学習コストなどはかかりますが、最終的にサービスを継続する上で最も重要なコストを大幅に削減できたのが良かったと考えています。
今後、これらの点も他のクラウドサービスのようにどんどん良くなっていくのを期待しています。


移行してよかったこと(コスト以外で)

さきほどのメリットと一部重複しますが、コスト以外で良かった事として、

  • AWSは移行してからまだ3年だったので、そういう意味ではそこまで老朽化してなかったが、改めて構成を見直すきっかけになった
  • 式年遷宮のタイミングでこれまで存在していなかった範囲のドキュメントを作成するきっかけになった
    • 3年前のAWS移行時に苦労した時の記録があまりドキュメントとして残せていなかったので、今回整理することが出来た
  • 外部と連携している箇所がブラックボックス化してしまっていた
    • 当時の担当者が退職している問題
    • このタイミングで改めて見直すことになり、ドキュメント化できた
  • また、この機にTerraform化などの足がかりが出来た
    • ここはまだ今後の課題

まとめ

長くなってしまいましたが、AWSからOCIに移行してコストが半額程度になったという話を紹介させていただきました。
古いレガシーサービスであっても、必ずしもこのようにコストが下がらないケースもあるかと思います。

また、今回の場合はオンプレからAWSに移行してそこまで時間が経過していなかったため、オンプレ時代のアーキテクチャが多く残っていました。特定のマネージドサービスにロックインするような設計になっていなかったのが移行しやすかったというのが大きいと思います。

OCI以外のクラウドでないと実現できない機能も多くあると思います。
(このサービス内の一部ではBigQueryなども使っていますし、画像配信側は Cloudflare Workers + R2に依存しています)
特定のベンダーだけに依存するのではなく、サービスの特性・要件などを踏まえて柔軟にマルチクラウドにしていくのが良いのではないかと考えています。

また、もし仮にこのような急激な円安になっていなかったら、積極的に今回の移行を検討したか、と言われるとなかなか回答が難しいところです。。今回の案件以外では昔から現在もAWSには大変お世話になっていますし、今後も引き続きお世話になろうと考えています。

とは言えOCIは現時点でのコストは本当に安いと思いますので、円安でコストに困っている方は一度OCIを検討されるのは良いと思います。(今回やってみて、まだまだOCI関連の情報が少なすぎると思ったので、他の方にももっとOCIを採用していただいてノウハウを共有して欲しいですw)

また、今回の移行にあたってOracle Japanの皆様の多大なサポートには大変お世話になりました。しかしながら、本記事のほとんどは自己流の解釈で書いてしまっているため、間違っている箇所が多くあるかと思います。誤っている箇所などは適宜修正させていただきますので、ご指摘をいただけると助かります。

本件は一つの事例として共有させていただきましたが、この場を借りて改めてお礼申し上げます。

※本記事は個人の経験と意見に基づいて執筆されたものであり、所属する企業の見解や立場を示すものではありません。また、記事の内容は業務時間外の個人的な時間に作成したものです。

184
165
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
184
165