Fastly の CI/CD パイプライン構成例
デモ用に作成した簡易的な Fastly サービスの CI/CD パイプラインについて備忘録の意味も込めて記事にしておきたいと思います。
Prod と Stage の 2環境があることを想定した構成です。共通のコードを使って両環境を設定するために Terraform モジュールを、CI/CD には Github Actions を使用しています。
この記事にはポイントのみ、かい摘んで記載しています。詳細が気になった方はリポジトリ(live と modules)を確認してみてください。
Git リポジトリとファイルのレイアウト
├── live
│ ├── global
│ │ └── cert
│ │ └── main.tf
│ ├── prod
│ │ └── service <------------------ Use v1.x.x
│ │ └── main.tf
│ └── stage
│ └── service <------------------ Use v2.x.x
│ └── main.tf
└── modules
├── cert
│ ├── main.tf
│ ├── output.tf
│ ├── provider.tf
│ └── variables.tf
└── service
├── main.tf
├── output.tf
├── provider.tf
├── variables.tf
└── vcl
├── main.vcl
└── snippet.vcl
階層の最上位にある live と modules はそれぞれ Git リポジトリです。live は各環境の TF ファイル、modules は TF モジュールをホストしています。リポジトリを分けることで stage には v2.x.x 、prod には v1.x.x のモジュールを適用するといったような段階的なリリースを可能にしています (Module versioning)
↑は live リポジトリ配下にある prod 環境の TF ファイルです。モジュールソースに modules リポジトリのパスを指定し、ref キーワードで適用するモジュールのバージョンを指定しています。v1.x.x
は Git のタグです。
live リポジトリ配下の prod/service/, stage/service/, global/cert/ が terraform コマンドを実行するワーキングディレクトリです。変更頻度が低く問題が発生した場合の影響が大きい証明書は、サービスと分けて管理できるように構成しています。
modules リポジトリの TF ファイルは慣例に従って役割毎にファイルを分けていますが、live リポジトリの TF ファイルは基本的にはモジュールにパラメータを渡しているだけで記述量が少なく、ファイルを分けると寧ろ見通しが悪くなる為、全て main.tf にまとめました。
因みに上記レイアウトや Module versioning については Terraform Up & Running の 4章 を参考にしました。
開発ワークフローのイメージ
各リポジトリでブランチをきって変更を行います。main ブランチへの PR オープンや変更のマージといったイベントをトリガに Github Actions のワークフローを実行するよう設定しています。
テスト
modules リポジトリは main への PR オープンをトリガに変更のあったモジュールに対して静的なコード解析とユニットテストを自動実行するよう設定しています。
静的コード解析
- terraform fmt, terraform validate コマンドで TF ファイルの有効性を確認します
- falco を使って VCL の Linting/Validation を行います
ユニットテスト
変更された TF モジュールを使って実際に Fastly サービスや証明書をデプロイして terraform コマンドの実行結果や、インフラの振る舞いを確認します。
例えば証明書を管理する cert モジュールのユニットテストには以下の項目が含まれています
- 証明書の発行元が lets-encrypt になっていること
- 作成された TF リソースの数が正しいこと
- HTTPS リクエストに対して 200 応答が得られること
TF コマンドの実行やコマンド結果のパースには Terratest を使っています。
今回作成したデモ環境が Fastly の証明書とサービスを管理するだけのシンプルなものということもあり、live リポジトリ側には自動テストは設定していません。stage へ変更を適用した結果問題がなければ prod への変更を検討するような流れを想定しています。
ステートファイルの保存先
Terraform のステートファイルは、こちらのドキュメントに従って設定した S3 remote backend へ保存するように設定しています。
シークレットの管理
Github Actions のタスクランナーへインフラの変更権限を与える為のトークンやその他のセンシティブな情報は Github の Organization Secrets に登録しています。AWS との連携部分(DNS と Remote backend)は時間のあるときに OIDC 化してみたいと思っています。
TF ファイルの記述に関する Tips
CI/CD には直接関係ありませんが Fastly サービスの TF ファイルを記述するときに役立つかもしれない Tips を幾つか紹介します。
環境ごとの設定に対応する記述パターン
特定の環境にだけ○○を設定したいといったことがあるかと思います。全環境から利用する TF モジュールのなかで、環境ごとに設定をかき分けるときの記述パターンを紹介します。
A. 特定の環境にだけ設定したいときのパターン
var.isProd
が true
のときだけ VCL スニペットを作成したい場合の記述例です。var.isProd ? [1] : []
の 1という数字に意味はありません。true
のときに for_each に要素がひとつのリストを渡しているところがポイントです。
dynamic "snippet" {
for_each = var.isProd ? [1] : []
content {
content = file("${path.module}/vcl/recv_prod.vcl")
name = "snippet for prod"
type = "recv"
priority = 100
}
}
B. 各環境に別々の設定をしたいときのパターン1
if-else のロジックは A と同じ要領で以下のように記述することができます。
dynamic "snippet" {
for_each = var.isProd ? [1] : []
content {
content = file("${path.module}/vcl/recv_prod.vcl")
name = "snippet for prod"
type = "recv"
priority = 100
}
}
dynamic "snippet" {
for_each = var.isProd ? [] : [1]
content {
content = file("${path.module}/vcl/recv_stage.vcl")
name = "snippet for stage"
type = "recv"
priority = 100
}
}
C. 各環境に別々の設定をしたいときのパターン2
B と同様のロジックを以下のように記述することもできます。
snippet {
content = var.isProd ? file("${path.module}/vcl/recv_prod.vcl") : file("${path.module}/vcl/recv_stage.vcl")
name = "conditional snippet"
type = "recv"
priority = 100
}
D. 各環境に別々の設定をしたいときのパターン3
環境によってファイルの内容の一部だけが異なるようなケースでは templatefile ファンクションを使うのがよいかもしれません。
snippet {
content = templatefile("${path.module}/vcl/deliver_set_header.vcl", { header_value = var.header_value })
name = "set env specific header value in deliver"
type = "deliver"
priority = 100
}
変数 header_value
を VCL ファイルの中に埋め込んでいます。
if (req.http.fastly-debug) {
set resp.http.fastly-debug-env = "${header_value}";
}
できれば避けたい記述パターン
できれば避けた方がよいと考える記述パターンを紹介します。
A. VCL を TF ファイルに埋め込む
以下のように TF ファイルに複数行に渡る VCL を直接埋め込むのは避けたほうがよいと思います。コードの可読性が悪くなりますし(下記の例くらいの記述量なら大して問題と感じないかもしれませんが)、VCL に対するエディタのシンタックスハイライトも効かなくなってしまいます。
snippet {
content = <<-EOT
if (req.http.fastly-debug) {
set resp.http.fastly-debug-env = "prod";
}
EOT
name = "set env specific header value in deliver"
priority = 100
type = "deliver"
}
VCL は別のファイルに切り出して file ファンクションで読み込ませるのがおすすめです。
snippet {
content = file("${path.module}/vcl/deliver_set_header.vcl")
name = "set env specific header value in deliver"
type = "deliver"
priority = 100
}
B. ログフォーマット を TF ファイルに埋め込む
同様にログフォーマットも TF ファイルに直接埋め込むのは避けたほうがよいと思います。
logging_papertrail {
address = "xxx.papertrailapp.com"
format = <<-EOT
{
"timestamp": "%%{strftime(\{"%Y-%m-%dT%H:%M:%S%z"\}, time.start)}V",
"client_ip": "%%{req.http.Fastly-Client-IP}V",
"geo_country": "%%{client.geo.country_name}V",
"geo_city": "%%{client.geo.city}V",
"host": "%%{if(req.http.Fastly-Orig-Host, req.http.Fastly-Orig-Host, req.http.Host)}V",
"url": "%%{json.escape(req.url)}V",
"request_method": "%%{json.escape(req.method)}V",
"request_protocol": "%%{json.escape(req.proto)}V",
"request_referer": "%%{json.escape(req.http.referer)}V",
"request_user_agent": "%%{json.escape(req.http.User-Agent)}V",
"response_state": "%%{json.escape(fastly_info.state)}V",
"response_status": %%{resp.status}V,
"response_reason": %%{if(resp.response, "%22"+json.escape(resp.response)+"%22", "null")}V,
"response_body_size": %%{resp.body_bytes_written}V,
"fastly_server": "%%{json.escape(server.identity)}V",
"fastly_is_edge": %%{if(fastly.ff.visits_this_service == 0, "true", "false")}V
}
EOT
format_version = 2
name = "accesslog"
port = 12345
}
Fastly のログフォーマット文字列 %{foo}V
が HCL のシンタックスとバッティングするため、%
をひとつ余計につけてエスケープしなければならない点も埋め込みを避けたい理由のひとつです。
ログフォーマットも別ファイルに切り出して file ファンクションで読み込むのがよいと思います。
logging_papertrail {
address = "xxx.papertrailapp.com"
format = file("${path.module}/logformat/format.json")
format_version = 2
name = "accesslog"
port = 12345
}
C. HTML を VCL ファイルに埋め込む
Fastly の Synthetic Response(オリジンに依存することなくエッジでクライアントへのレスポンスを生成する機能)を設定する場合、HTML は VCL に埋め込むことになります。エディタでそのような VCL ファイルを開いても当然 HTML に対するシンタックスハイライトが効きません。
if (obj.status == 600) {
set obj.status = 404;
set obj.response = "Not Found";
synthetic {"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Not Found</title>
</head>
<body>
<h1>Not Found</h1>
</body>
</html>"};
return (deliver);
}
Terraform で設定を管理している場合には templatefile ファンクションを使ってこの問題を解決することができます。
snippet {
content = templatefile("${path.module}/vcl/error_custom_404.vcl",
{ not_found_html = file("${path.module}/html/custom_404.html") })
name = "error_custom_404"
type = "error"
priority = 100
}
if (obj.status == 600) {
set obj.status = 404;
set obj.response = "Not Found";
synthetic {"${not_found_html}"};
return (deliver);
}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Not Found</title>
</head>
<body>
<h1>Not Found</h1>
</body>
</html>
Discussion