React の SPA を Azure App Service でホストする

2021-07-06 追記

ZipDeploy で OK だった。

tnakamura.hatenablog.com

はじめに

React で実装した Single Page Application(SPA)を Azure App Service でホストしたい。Azure Static Web Apps ではなく、Web Apps。というのも、大人の事情で、GitHub や Azure DevOps が使えず、ローカル Git からデプロイするしかないので。

Kudu のカスタムデプロイスクリプトを書いて、なんとか実現できた。

express で React アプリをサーブするために server.js を作成

不本意だけど、express をプロジェクトに追加し、エントリポイントとして server.js を用意する。

const express = require('express');
const path = require('path');
const app = express();
const port = process.env.PORT || 3000;

app.use(express.static(path.join(__dirname, 'build')));

app.get('/*', function (req, res) {
  res.sendFile(path.join(__dirname, 'build', 'index.html'));
});

app.listen(port);

.deployment を Kudu から入手

Kudu から入手できるやつそのまま。

[config]
command = deploy.cmd

deploy.cmd を Kudu から入手して改変

Kudu から入手できるファイルに React アプリのビルドを追加した。 「Build React SPA」の箇所がそう。

@if "%SCM_TRACE_LEVEL%" NEQ "4" @echo off

:: ----------------------
:: KUDU Deployment Script
:: Version: 1.0.17
:: ----------------------

:: Prerequisites
:: -------------

:: Verify node.js installed
where node 2>nul >nul
IF %ERRORLEVEL% NEQ 0 (
  echo Missing node.js executable, please install node.js, if already installed make sure it can be reached from current environment.
  goto error
)

:: Setup
:: -----

setlocal enabledelayedexpansion

SET ARTIFACTS=%~dp0%..\artifacts

IF NOT DEFINED DEPLOYMENT_SOURCE (
  SET DEPLOYMENT_SOURCE=%~dp0%.
)

IF NOT DEFINED DEPLOYMENT_TARGET (
  SET DEPLOYMENT_TARGET=%ARTIFACTS%\wwwroot
)

IF NOT DEFINED NEXT_MANIFEST_PATH (
  SET NEXT_MANIFEST_PATH=%ARTIFACTS%\manifest

  IF NOT DEFINED PREVIOUS_MANIFEST_PATH (
    SET PREVIOUS_MANIFEST_PATH=%ARTIFACTS%\manifest
  )
)

IF NOT DEFINED KUDU_SYNC_CMD (
  :: Install kudu sync
  echo Installing Kudu Sync
  call npm install kudusync -g --silent
  IF !ERRORLEVEL! NEQ 0 goto error

  :: Locally just running "kuduSync" would also work
  SET KUDU_SYNC_CMD=%appdata%\npm\kuduSync.cmd
)
goto Deployment

:: Utility Functions
:: -----------------

:SelectNodeVersion

IF DEFINED KUDU_SELECT_NODE_VERSION_CMD (
  :: The following are done only on Windows Azure Websites environment
  call %KUDU_SELECT_NODE_VERSION_CMD% "%DEPLOYMENT_SOURCE%" "%DEPLOYMENT_TARGET%" "%DEPLOYMENT_TEMP%"
  IF !ERRORLEVEL! NEQ 0 goto error

  IF EXIST "%DEPLOYMENT_TEMP%\__nodeVersion.tmp" (
    SET /p NODE_EXE=<"%DEPLOYMENT_TEMP%\__nodeVersion.tmp"
    IF !ERRORLEVEL! NEQ 0 goto error
  )
  
  IF EXIST "%DEPLOYMENT_TEMP%\__npmVersion.tmp" (
    SET /p NPM_JS_PATH=<"%DEPLOYMENT_TEMP%\__npmVersion.tmp"
    IF !ERRORLEVEL! NEQ 0 goto error
  )

  IF NOT DEFINED NODE_EXE (
    SET NODE_EXE=node
  )

  SET NPM_CMD="!NODE_EXE!" "!NPM_JS_PATH!"
) ELSE (
  SET NPM_CMD=npm
  SET NODE_EXE=node
)

goto :EOF

::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
:: Deployment
:: ----------

:Deployment
echo Handling node.js deployment.

:: 1. KuduSync
IF /I "%IN_PLACE_DEPLOYMENT%" NEQ "1" (
  call :ExecuteCmd "%KUDU_SYNC_CMD%" -v 50 -f "%DEPLOYMENT_SOURCE%" -t "%DEPLOYMENT_TARGET%" -n "%NEXT_MANIFEST_PATH%" -p "%PREVIOUS_MANIFEST_PATH%" -i ".git;.hg;.deployment;deploy.cmd"
  IF !ERRORLEVEL! NEQ 0 goto error
)

:: 2. Select node version
call :SelectNodeVersion

:: 3. Install npm packages
IF EXIST "%DEPLOYMENT_TARGET%\package.json" (
  pushd "%DEPLOYMENT_TARGET%"
  call :ExecuteCmd !NPM_CMD! install --production
  IF !ERRORLEVEL! NEQ 0 goto error

  :: Build React SPA
  call :ExecuteCmd !NPM_CMD! run build
  IF !ERRORLEVEL! NEQ 0 goto error

  popd
)

::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
goto end

:: Execute command routine that will echo out when error
:ExecuteCmd
setlocal
set _CMD_=%*
call %_CMD_%
if "%ERRORLEVEL%" NEQ "0" echo Failed exitCode=%ERRORLEVEL%, command=%_CMD_%
exit /b %ERRORLEVEL%

:error
endlocal
echo An error has occurred during web site deployment.
call :exitSetErrorLevel
call :exitFromFunction 2>nul

:exitSetErrorLevel
exit /b 1

:exitFromFunction
()

:end
endlocal
echo Finished successfully.

デプロイ

git push してからビルドが終わるまでの間は、アプリに繋がらない。 ステージング用のデプロイスロットを用意し、ステージング環境にデプロイした後、本番環境とスワップする必要がある。

git push staging main:master

おわりに

Azure Static Web Apps が使える場合は使いましょう。

パン シャルマン

揚げパンが評判と聞いて、早良区田隈にある『パン シャルマン』に行ってみた。気合入れて開店後ほぼすぐに入店したら、パンはまだ出揃っておらず、少し待つことに。揚げパンは並んでいたけど、ミルクスティックはまだだった。聞いたらすぐ作ってもらえたので、待ったといっても5分くらい。待っている間に他のパンを選んでいれば問題ない。

f:id:griefworker:20210405231855j:plain

お目当てその1の揚げパンは、きな粉たっぷりでこぼさないように注意が必要。自分はこぼした。揚げパンを食べるのなんて、中学校の給食以来かも。パンだけどなんかジューシー。きな粉もいいけどグラニュー糖かけたバージョンも売ってくれないかなぁ。

f:id:griefworker:20210405231904j:plain

子どもがチョイスしたシナモンロールは、ナッツが香ばしくてグッド。温めなくても十分すぎるくらい美味だった。

f:id:griefworker:20210405231913j:plain

お目当てその2のミルクスティックは、濃厚練乳クリームたっぷりで、これは間違いない。出来上がるの待ったかいがあった。パンは今回買った中で一番のハード系。まさにスティックだな。

f:id:griefworker:20210405231923j:plain

チョコチップメロンパンは、表面のクッキー生地がサクサクで、妻に好評価だった。

f:id:griefworker:20210405231931j:plain

子ども用にはトトロのパン。中はチョコクリームだった。関係ないけど、トトロのパンがあるから、カオナシのパンもあるかもね、といったら子どもが怖がって買いに行きたがらなくなったのは困った。いやまぁ自業自得なのだけど。

f:id:griefworker:20210405231939j:plain

アンパンマンのパンも、中はチョコクリームだった。アンパンマンじゃなくて、チョコパンマンだな。中身を聞かずに買ったのは失敗だったな。チョコ好きの子供も、さすがに食べきれなかった。せめてアンパンマンとトトロのどちらかが、カスタードクリームだったらよかったのに。

f:id:griefworker:20210405231947j:plain

今度は、パンが出揃ってる時間帯に買いに行って、違うパンを買ってみたい。ホワイトチョコとピスタチオのパンも目当てだったけど、まだ出てなかったし。リベンジしたい。普段使いに良さそうで、こういうパン屋が家の近くに欲しいと、心底思う。

関連ランキング:パン | 賀茂駅

ストロベリーフェチ

福岡の情報番組で紹介されていたのを見た子供が食べたがったので、キャナルシティに行く用事のついでに立ち寄った。

f:id:griefworker:20210412192604j:plain

イチゴだけでなくブドウも食べたいと欲張ったので、イチゴ&ブドウを購入。表面の飴は薄くパリパリ、イチゴは予想以上にジューシーで驚いた。さすが、フェチと言うだけある。ブドウは美味しいんだけど、中の種が余計だったな。種なしの品種を使って欲しいところ。イチゴオンリーの方がオススメ。

f:id:griefworker:20210412192613j:plain

関連ランキング:スイーツ(その他) | 祇園駅中洲川端駅渡辺通駅

可用性セットを組んだ仮想マシンをバックエンドプールに使うロードバランサーを構築する Terraform サンプル

Azure にて、仮想マシンスケールセットではなく、可用性セットを組んだ仮想マシン 2 台を、ロードバランサーのバックエンドプールにしてみた。

まぁ、最終的には仮想マシンスケールセットを使ったけどね。せっかく試したんでメモを残しておく。

resource "azurerm_resource_group" "example" {
  name     = "rg-example"
  location = "japaneast"
}

# 仮想ネットワーク
resource "azurerm_virtual_network" "example" {
  name                = "vnet-example"
  address_space       = ["10.0.0.0/16"]
  location            = azurerm_resource_group.example.location
  resource_group_name = azurerm_resource_group.example.name
}

# 仮想ネットワークのサブネット
resource "azurerm_subnet" "example" {
  name                 = "snet-example"
  resource_group_name  = azurerm_resource_group.example.name
  virtual_network_name = azurerm_virtual_network.example.name
  address_prefixes     = ["10.0.2.0/24"]
}

# ロードバランサー用パブリックIPアドレス
resource "azurerm_public_ip" "example" {
  name                = "pip-example"
  location            = azurerm_resource_group.example.location
  resource_group_name = azurerm_resource_group.example.name
  allocation_method   = "Static"
  sku                 = "Standard"
}

# ネットワークセキュリティグループ
resource "azurerm_network_security_group" "example" {
  name                = "nsg-example"
  location            = azurerm_resource_group.example.location
  resource_group_name = azurerm_resource_group.example.name
}

# TCP 443 を許可するネットワークセキュリティルール
resource "azurerm_network_security_rule" "example" {
  name                        = "nsrule-example"
  resource_group_name         = azurerm_resource_group.example.name
  network_security_group_name = azurerm_network_security_group.example.name
  priority                    = 1001
  direction                   = "Inbound"
  access                      = "Allow"
  protocol                    = "Tcp"
  source_port_range           = "*"
  destination_port_range      = 443
  source_address_prefix       = "*"
  destination_address_prefix  = "*"
}

# 仮想ネットワークインターフェイスカード
resource "azurerm_network_interface" "example" {
  count               = 2
  name                = "nic-example-${format("%03d", count.index + 1)}"
  location            = azurerm_resource_group.example.location
  resource_group_name = azurerm_resource_group.example.name

  ip_configuration {
    name                          = "nicconf-example"
    subnet_id                     = azurerm_subnet.example.id
    private_ip_address_allocation = "Dynamic"
  }
}

# ネットワークインタフェースをセキュリティグループに接続
resource "azurerm_network_interface_security_group_association" "example" {
  count                     = length(azurerm_network_interface.example)
  network_interface_id      = azurerm_network_interface.example[count.index].id
  network_security_group_id = azurerm_network_security_group.example.id
}


# 仮想マシンの可用性セット
resource "azurerm_availability_set" "example" {
  name                         = "avail-example"
  location                     = azurerm_resource_group.example.location
  resource_group_name          = azurerm_resource_group.example.name
  platform_fault_domain_count  = 2
  platform_update_domain_count = 2
  managed                      = true
}

# 仮想マシン(Windows)
resource "azurerm_windows_virtual_machine" "example" {
  count                 = 2
  name                  = "vmexample${format("%03d", count.index + 1)}"
  resource_group_name   = azurerm_resource_group.example.name
  location              = azurerm_resource_group.example.location
  size                  = "Standard_F2s_v2"
  admin_username        = "Administrator"
  admin_password        = "P@assw0rd12345"
  availability_set_id   = azurerm_availability_set.example.id
  network_interface_ids = [
    azurerm_network_interface.example[count.index].id
  ]

  os_disk {
    name                 = "osdisk-example-${format("%03d", count.index + 1)}"
    caching              = "ReadWrite"
    storage_account_type = "Standard_LRS"
  }

  source_image_reference {
    publisher = "MicrosoftWindowsServer"
    offer     = "WindowsServer"
    sku       = "2019-Datacenter"
    version   = "latest"
  }
}

# ロードバランサー
resource "azurerm_lb" "example" {
 name                = "lb-example"
 location            = azurerm_resource_group.example.location
 resource_group_name = azurerm_resource_group.example.name
 sku                 = "Standard"

 # フロントエンドIP構成
 frontend_ip_configuration {
   name                 = "fipconf-example"
   public_ip_address_id = azurerm_public_ip.example.id
 }
}

# バックエンドプール
resource "azurerm_lb_backend_address_pool" "example" {
 name                = "bep-example"
 loadbalancer_id     = azurerm_lb.example.id
}

# バックエンドプールの仮想マシン
# 厳密にはネットワークインターフェース
resource "azurerm_network_interface_backend_address_pool_association" "example" {
  count                   = length(azurerm_network_interface.example)
  network_interface_id    = azurerm_network_interface.example[count.index].id
  ip_configuration_name   = "nicconf-example"
  backend_address_pool_id = azurerm_lb_backend_address_pool.example.id
}

# 正常性プローブ
resource "azurerm_lb_probe" "example" {
 name                = "probe-example"
 resource_group_name = azurerm_resource_group.example.name
 loadbalancer_id     = azurerm_lb.example.id
 port                = 443
}

# 負荷分散規則
resource "azurerm_lb_rule" "example" {
   name                           = "lbrule-example"
   resource_group_name            = azurerm_resource_group.example.name
   loadbalancer_id                = azurerm_lb.example.id
   protocol                       = "Tcp"
   frontend_port                  = 443
   backend_port                   = 443
   backend_address_pool_id        = azurerm_lb_backend_address_pool.example.id
   frontend_ip_configuration_name = "fipconf-example"
   probe_id                       = azurerm_lb_probe.example.id
}

Azure App Service で Managed Certificate を使う Terraform サンプル

カスタムドメインは Azure DNS ゾーンに登録してある前提。

resource "azurerm_resource_group" "example" {
  name     = "rg-example"
  location = "japaneast"
}

resource "azurerm_app_service_plan" "example" {
  name                = "plan-example"
  location            = azurerm_resource_group.example.location
  resource_group_name = azurerm_resource_group.example.name

  sku {
    tier = "Standard"
    size = "S1"
  }
}

resource "azurerm_app_service" "example" {
  name                = "app-example"
  location            = azurerm_resource_group.example.location
  resource_group_name = azurerm_resource_group.example.name
  app_service_plan_id = azurerm_app_service_plan.example.id
}

# App Service でカスタムドメインを使う
resource "azurerm_app_service_custom_hostname_binding" "example" {
  hostname            = "your-custom-domain.example.com"
  app_service_name    = azurerm_app_service.example.name
  resource_group_name = azurerm_resource_group.example.name

  # Managed Cetificate により自動更新されるので変更を無視
  lifecycle {
    ignore_changes = [ssl_state, thumbprint]
  }
}

# App Service で Managed Cetificate を使う
resource "azurerm_app_service_managed_certificate" "example" {
  custom_hostname_binding_id = azurerm_app_service_custom_hostname_binding.example.id
}

# Managed Cetificate をカスタムドメインにバインドする
resource "azurerm_app_service_certificate_binding" "example" {
  hostname_binding_id = azurerm_app_service_custom_hostname_binding.example.id
  certificate_id      = azurerm_app_service_managed_certificate.example.id
  ssl_state           = "SniEnabled"
}

渡くんの××が崩壊寸前(10)

石原さんに釣り合うために変わろうとした渡くんは素晴らしい心がけなんだけど、お互い自分を良く見せようとしていて、それをずっと続けて行くのはシンドそう。石原さんは暴走機関車だし、渡くんは流されやすいしで、ハッピーエンドが想像できないな。ただ、石原さんは、周りに流されそうになるところをこらえて、ちゃんと自分の意思を表明したので、成長が窺える。一縷の望み。

紗月の方が渡くんにとって気やすそうで、お似合いだけどなぁ。でもまぁ渡くんは石原さんに未練ありまくりだから、復縁するんだろうな。と思いきや、渡くんまさかの爆弾発言。そして11巻に続く。え、どういうこと。家族みたいなものだからって言葉が続いたりする?まったく、良いところで切ってくれるもんだ。

それはそうと、マキナ結構好きなんだけど、完全に噛ませ犬というか、子守役というか。どうしてこうなった。残念。

ぼくたちは勉強ができない(21)

ぼく勉はとうとう最終巻。トリを飾るのは人気投票で1位を獲得した真ヒロイン真冬センセのルートだった。

クールなようで、実はポンコツで生活力皆無なため、結局成幸に助けられてしまう。年上らしくあろうとするけど、教師と元教え子という関係に悶々とする、そのギャップが愛おしいヒロイン。本作のコスプレ担当でもある。その集大成はズバリ、ウエディングドレス。いやこれはコスプレ違うか。今までヒロインが着ている姿はなかったな。真冬センセのために取っておいたのかと邪推してしまう。そのお美しい姿はトリに相応しい。

一方で成幸は、そんな真冬センセが学生時代に送れなかった青春を取り戻させようと、当時のクラスメイトや教え子たちに掛け合って、見事に実現させた。5人のヒロインのルート中で一番の行動力を発揮したかも。そりゃあ、真冬センセもトゥクンってなるわ。

マンガでマルチエンディングに挑戦するなんて前代未聞。でも、成幸と結ばれるのは3人娘の誰かだと思っていたので、無いと思っていた真冬先生ルートが読めて自分的に僥倖。アリかナシかで言ったら、断然アリ。他のマンガも真似して、これが当たり前の手法になっていいくらいだ。