VisasQ Dev Blog

ビザスク開発ブログ

メンテナンスの仕組みを刷新してみた件

こんにちは!DPE(Developer Productivity Engineering)チームの高畑(Sorarinu (@int_sorarinu) / Twitter)です。

最近は月一のキャンプを楽しみに日々を生きていて、栃木県のとあるキャンプ場が最高すぎてリピートしまくっています。

今回は、ビザスクのメンテナンスを行う際の仕組みを刷新したお話をしてみようと思います!

これまでのメンテナンスの仕組み

ビザスクではアプリケーションの定期メンテナンスを行う際に「メンテナンスモード」へと移行して、メンテナンス期間中にアクセスされた場合にメンテナンス画面を返すようにしています。

その際、アドバイザー用の画面、クライアント用の画面、社内の管理画面のそれぞれでメンテナンス用のインスタンスをデプロイ・トラフィックの切り替えを行なっていました。

メンテナンスの画面を返すだけであればこの仕組みで問題ないのですが、全てのリクエストが一度アプリケーションへと到達することからネットワークレイヤーの変更がかかるメンテナンスを行うことができないという問題がありました。

メンテナンスの仕組みを考える

メンテナンスの仕組みを刷新する上で、まずは要件を GitHub の issues にまとめていきました。

今回の要件としてはざっくり以下の通りです。

  • メンテナンス時にネットワークレイヤーの変更が絡む変更も行えるようにする
    • リクエストがアプリケーションへ到達する前段でメンテナンス画面を返せるように
  • Terraform を活用してコマンド一発叩くことで全ての対象サービスをメンテナスモードに切り替えができるようにする
  • アプリケーション動作確認用として特定の IP アドレスからのアクセスや、特定のパスへのアクセスはメンテナンス画面を返さずにアプリケーションが閲覧できるようにする

GitHub issues

上記の仕組みを作るにあたり、弊社ではネットワークレイヤーに Cloudflare を利用していることから Cloudflare Workers を利用することで要件が満たせるのではと考えました。

メンテナンスの仕組みを整える

Terraform のディレクトリ構成は以下のようにして、検証環境でも試せるように environments ディレクトリの下で環境ごとに Terraform のコードを分けています。

ディレクトリ構成についてのお話は以下の記事に詳しく書いているので見てみてください。

tech.visasq.com

.
├── Dockerfile
├── README.md
├── aqua.yaml
├── docker-compose.yaml
├── maintenance_page
│   ├── img
│   │   ├── logo.svg
│   │   └── favicon.ico
│   └── index.js
└── terraform
    ├── Makefile
    ├── backends
    │   └── prod.tfvars
    └── environments
        └── prod
            ├── Makefile
            ├── data_secrets.tf
            ├── locals.tf
            ├── main.tf
            ├── maintenance.tf
            ├── terraform.tfvars
            └── variables.tf

今回は Cloudflare Workers を利用するため、静的ファイルを maintenance_page ディレクトリに置いて、Terraform からデプロイを行う構成にしました。

index.js はこのような感じで、Base64 エンコードした画像ファイルや期間の文言、ホワイトリストの IP アドレスなど Terraform から差し込めるようにしています。

addEventListener("fetch", (event) => {
  event.respondWith(fetchAndReplace(event.request));
});

async function fetchAndReplace(request) {
  let modifiedHeaders = new Headers();

  modifiedHeaders.set("Content-Type", "text/html;charset=UTF-8");
  modifiedHeaders.append("Pragma", "no-cache");

  // Allow users from configured IPs into site
  if (WHITELIST_IPS !== "null") {
    if (
      WHITELIST_IPS.split(",").indexOf(
        request.headers.get("cf-connecting-ip")
      ) > -1
    ) {
      return fetch(request);
    }
  }

  // Allow users to access paths that are whitelisted using regex expressions
  if (WHITELIST_PATH !== "null") {
    const { pathname } = new URL(request.url);
    if (pathname.match(WHITELIST_PATH)) {
      return fetch(request);
    }
  }

  // Return modified response.
  return new Response(maintenancePage, {
    status: 503,
    headers: modifiedHeaders,
  });
}

const maintenancePage = `
<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=0">

  <link href="//netdna.bootstrapcdn.com/font-awesome/4.1.0/css/font-awesome.min.css" rel="stylesheet" type="text/css">
  <link rel="shortcut icon" type="image/png" href="data:image/png;base64,${favicon}"/>

  <style>
    * {
      box-sizing: border-box;
    }

    html, body {
      margin: 0;
      padding: 0;
      background-color: #f1f5fa;
    }

    p {
      color: #2f353f;
      margin: 0;
      text-align: center;
    }
    p:not(:last-child) {
      margin-bottom: 20px;
    }
    p:not(:first-child) {
      margin-top: 20px;
    }

    a {
      color: #2ba;
    }

    hr {
      border: 0;
      border-top: 1px solid #d1dbeb;
      margin: 30px 0;
    }

    img {
      margin: 0;
      padding: 0;
      line-height: 0;
      vertical-align: bottom;
      border: none;
    }

    .header {
      background: #fff;
      box-shadow: 0 2px 10px rgba(34, 102, 170, 0.15);
      height: 60px;
      padding: 16px 30px;
    }
    .header .logo {
      width: 120px;
    }

    .wrapper {
      width: 674px;
      max-width: 100%;
      min-height: calc(100vh - 320px);
      margin: 80px auto;
    }

    .card {
      background: #fff;
      border-radius: 10px;
      box-shadow: 0 1px 5px rgba(34, 102, 170, 0.15);
      padding: 40px 45px;
    }

    .heading_large {
      font-size: 24px;
      font-weight: bold;
      line-height: 1em;
      text-align: center;
      height: 50px;
    }

    .description_large {
      font-size: 16px;
      line-height: 1.7em;
    }

    .description_middle {
      font-size: 14px;
      line-height: 1.7em;
    }

    .footer {
      height: 100px;
      padding: 30px;
      background: #2f353f;
    }
    .footer a {
      color: #fff;
      font-size: 14px;
      text-decoration: none;
    }
    .footer a:hover {
      text-decoration: underline;
    }
    .footer .copy {
      margin-top: 5px;
      color: #7d889a;
      font-size: 14px;
    }
  </style>

  <title>ただいまメンテナンス中です | ビザスク</title>
</head>

<body>
  <header class="header">
    <img class="logo" src="data:image/svg+xml;base64,${logo}" alt="ビザスク VisasQ" />
  </header>

  <div class="wrapper">
    <h1 class="heading_large">ただいまメンテナンス中です</h1>
    <div class="card">
        <p class="description_large">
          ビザスクをご利用いただきまして、誠にありがとうございます。<br/>
          現在システムメンテナンスを行っております。<br/>
          ご不便をおかけいたしますが、メンテナンス終了までしばらくお待ち下さい。<br/>
          お問い合わせ先: <a href="https://help.visasq.com/contact" target="_blank">https://help.visasq.com/contact</a>
        </p>

        <p class="description_middle">
          Our service is currently not available due to ongoing maintenance.<br/>
          We apologize for any inconveniences caused. Thank you for your understanding.<br/>
          Contact: ${email_connect}
        </p>

        <hr />

        <p class="description_large">
          Maintenance schedule:
          <strong>${schedule}</strong>
        </p>
    </div>
  </div>

  <footer class="footer">
    <p>
      <a target="_blank" href="http://corp.visasq.co.jp">株式会社ビザスク</a><br/>
      <span class="copy">
        Copyright (C) VisasQ Inc. All Rights Reserved.
      </span>
    </p>
  </footer>
</body>
</html>
`;

上記の index.js を Cloudflare Workers へデプロイする Terraform コードは以下のようになりました(実際にはファイルを分けていますが、分かりやすいように一つのコードとして表示しています)。

画像ファイルを外部に置くことも検討しましたが、今回は手間がかからないように Base64 エンコードしたものを差し込むようにしています。

また、アプリケーションが参照しているドメインが複数あった場合にも対応できるようになっています。

terraform {
  required_version = "1.4.2"

  required_providers {
    google = {
      source  = "hashicorp/google"
      version = "4.61.0"
    }
    cloudflare = {
      source  = "cloudflare/cloudflare"
      version = "4.3.0"
    }
  }

  backend "gcs" {} # terraform init 時に ../../backends/xxx.tfvars で override する
}

provider "google" {
  project = var.project
  region  = "xxx"
  zone    = "xxx"
}

provider "cloudflare" {
  email   = "xxx"
  api_key = "xxx"
}

locals {
  maintenance = {
    enable = false  # メンテナンスモードの切り替えフラグ

    zones = {
      example-com = {
        zone_id    = "xxx"
        account_id = "xxx"
        patterns = [
          "a.example.com/*",
          "b.example.com/*",
          "c.example.com/*",
        ]
        whitelist_path = [
          "^\\/hoge\\/fuga\\/.*"
        ]
      }
      example-net = {
        zone_id    = "xxx"
        account_id = "xxx"
        patterns = [
          "a.example.net/*",
          "b.example.net/*",
        ]
        whitelist_path = []
      }
    }
  }

  # IPv4 or IPv6 を指定する(IPv4 over IPv6 などが有効になっている場合は IPv6 を指定する)
  whitelist_ips = [
    "xxx.xxx.xxx.xxx",
  ]

  template = {
    # 画像ファイルを base64 エンコードして埋め込む
    favicon = filebase64("${path.module}/../../../maintenance_page/img/favicon.ico")
    logo    = filebase64("${path.module}/../../../maintenance_page/img/logo.svg")

    email = {
      connect = "xxx@example.com"
    }
    schedule = "Jun 22 22:00PM ~ Jun 23 00:00AM (JST)"
  }
}

locals {
  all_zone_patterns = local.maintenance.enable ? flatten([
    for zone_key, zone_value in local.maintenance.zones : [
      for pattern in zone_value.patterns : {
        zone_key = zone_key
        zone_id  = zone_value.zone_id
        pattern  = pattern
      }
    ]
  ]) : []
}

resource "cloudflare_worker_script" "this" {
  for_each = local.maintenance.zones

  account_id = each.value.account_id

  name = "${var.project}-maintenance-${each.key}"

  content = templatefile("${path.module}/../../../maintenance_page/index.js", {
    favicon       = local.template.favicon
    logo          = local.template.logo
    email_connect = local.template.email.connect
    schedule      = local.template.schedule
  })

  plain_text_binding {
    name = "WHITELIST_IPS"
    text = local.whitelist_ips != [] ? join(",", local.whitelist_ips) : "null"
  }

  plain_text_binding {
    name = "WHITELIST_PATH"
    text = each.value.whitelist_path != [] ? join("|", each.value.whitelist_path) : "null"
  }
}

resource "cloudflare_worker_route" "this" {
  for_each = { for zone_pattern in local.all_zone_patterns : "${zone_pattern.zone_key}.${zone_pattern.pattern}" => zone_pattern }

  zone_id     = each.value.zone_id
  pattern     = each.value.pattern
  script_name = cloudflare_worker_script.this[each.value.zone_key].name
}

これで、メンテナンスを実行する際は local.maintenance.enable = true とした上で terraform apply することで、対象のアプリケーション全てが同時にメンテナンスモードへ移行することができるようになりました。

メンテナンスページはリクエストがアプリケーションに到達する前に Cloudflare Workers から返すため、ネットワークレイヤーの変更も行えるようになっています。

実際のメンテナンス画面

おわりに

いかがでしたでしょうか!

これまでメンテナンスを行う際に各アプリケーション担当へ連携してそれぞれデプロイ・切り替えを行う必要があったり、ネットワークレイヤーの変更が行えないという問題がありましたが、仕組みを刷新することでコマンド一つで各アプリケーション全てをメンテナンスへ移行することができるようになり利便性が向上しました。

まだまだ改善できる点はあるとは思いますが、少しでも皆さんの参考になれば幸いです。