Azure Storage での静的 Web サイト ホスティング
前回作ったD3.jsの世界地図をベースに簡単な地図の色塗りサイト作りましたので、Azure Storage + Azure CDN の静的 Web サイトでホスティングします。
もともとAzure Repos でソース管理していましたので、Azure Pipelines でビルドからデプロイの自動化までは作業は大まかに3つ。しばやんのブログの通り行うだけですので簡単です。
- Azure Storage 設定
- Azure Pipelines 設定
- Azure CDN 設定
今回、Vue.js, Vuetify, TypeScriptで作っていますので、Azure Pipelinesの設定はほぼ同じです。
https://github.com/shibayan/daruyanagi.com/blob/master/azure-pipelines.yml
Pipeline caching が GA したので Cache@2 を使って、切り戻し用のバックアップ処理を追加している程度の違いです。
azure-pipelines.ymlにバックアップを追加
- task: AzureCLI@1 inputs: azureSubscription: 'Subscription1' scriptLocation: 'inlineScript' inlineScript: | end=`date -d "5 minutes" '+%Y-%m-%dT%H:%M:%SZ'` sas1=`az storage container generate-sas -n '$web' --account-name $1 --https-only --permissions dlr --expiry $end -otsv` sas2=`az storage container generate-sas -n 'archives' --account-name $1 --https-only --permissions dlrw --expiry $end -otsv` ./azcopy sync "https://$1.blob.core.windows.net/\$web?$sas1" "https://$1.blob.core.windows.net/archives/$(Build.BuildNumber)?$sas2" --recursive --delete-destination=true arguments: 'storage1' displayName: 'Archive to Storage'
バックアップは Azure Blob のライフサイクル管理)で7日で削除しています。
Azure Blob Storage のライフサイクルの設定
{ "rules": [ { "enabled": true, "name": "LifeCycle1", "type": "Lifecycle", "definition": { "actions": { "baseBlob": { "delete": { "daysAfterModificationGreaterThan": 7 } } }, "filters": { "blobTypes": [ "blockBlob" ], "prefixMatch": [ "archives" ] } } } ] }
Microsoft Learn にAzure CDN と Blob Service を使用して、Web サイト用のコンテンツ配信ネットワークを作成する がありますので、サンドボックスで試すことも出来ます。
そのほか、サーバーサイドが使えない静的サイト+SPAということなので、HTTPS へのリダイレクトを JavaScript で追記。
<script type="text/javascript"> if(location.protocol == 'http:') { location.replace(location.href.replace(/http:/, 'https:')); } </script>
しばやんから、Microsoft Standard やVerizon Premium なら、https へのリダイレクトは CDN でもできると教えていただきました。
SPA用にGoogle アナリティクスの設定
import VueAnalytics from 'vue-analytics' Vue.use(VueAnalytics, { id: 'UA-XXXXX', router });
お問い合わせ用のGoogleフォームを追加して完了です。
Azure関連のリソースなどは会社のものを使わせていただき公開(Blank Maps)。今まで自分が旅行したところなどを塗ってみました。
D3.jsで世界地図を表示
世界地図に行ったところの国でも色塗りしようと思いD3.jsで試してみました。
地図データの準備
NatualEarthからダウンロード
今回は、110m Cultural Vectors の Admin 0 – Details / map units を使用。 https://www.naturalearthdata.com/downloads/110m-cultural-vectors/
データの変換
ダウンロードしたshapefileをtopojsonに変換します
使用するパッケージのインストール
npm install -g topojson shapefile
変換(shapefile > geojson > topojson)
shp2json ne_110m_admin_0_map_units.shp | geo2topo -q 1e6 > countries.topojson
ちょい圧縮
toposimplify -P 0.5 -f countries.topojson > countries.min.topojson
不要言語などを削除する場合は、ndjson-cli をインストールして「ndjson-filter "delete d.properties.NAME_AR" 」などでフィルタ。
地図を表示
データが出来たので、あとはD3.jsを使って表示すれば完了です。今回はTypeScript (+Vue.js)で地図を表示。
index.vue
<template> <div id="inspire"> <svg id="svg" :width="width" :height="height" :viewBox="viewBox"> </svg> </div> </template> <script src="./index.ts"></script>
index.ts
import * as d3 from 'd3'; import * as topojson from "topojson-client"; import { Vue, Component } from "vue-property-decorator"; @Component export default class DefaultComponent extends Vue { private width: number = 900; private height: number = 450; private viewBox: string = "0, 0, 900, 450"; private mounted() { // projectionを定義 let projection = d3.geoEquirectangular() // 正距円筒図法(Equirectangular) .translate([this.width / 2, this.height / 2]) // svgの中心 .scale(150); let path = d3.geoPath(projection); // svgに描画 let svg = d3.select("#svg"); d3.json("assets/map.json").then(function (json) { var countries = (topojson.feature(json, json.objects.countries) as any); var view = svg.append("g").selectAll("path") .data(countries.features) .enter() .append("path") .attr("stroke", "black") .attr("stroke-width", 0.5) .attr("fill", function (c: any) { if (c.properties.GU_A3 === "JPN") { return "#0277BD"; } else { return "#ffffff"; } }) .attr("d", path as any); }); } }
日本だけ色塗りされた世界地図の出来上がりです。
Vuetifyできちんと作ってみました(2020/03/27)。
地図の色塗り
IdentityServer4 でトークンエンドポイントの変更
IdentityServer4 でトークンの発行を「connect/token」ではなく「api/token」にしたかったのですが、設定では変更できないようなので Url Rewrite で対応。と合わせて Discovery のエンドポイントも新しいパスに変更です。
Endpoints
URL Rewriting Middleware の設定を追加し、api/ を connect/ にRewrite 。
IISUrlRewrite.xml
<rewrite> <rules> <rule name="Rewrite1" stopProcessing="true"> <match url="^api/(.*)" /> <action type="Rewrite" url="connect/{R:1}" appendQueryString="true"/> </rule> </rules> </rewrite>
Startup.cs / Configure
using StreamReader iisUrlRewriteStreamReader = File.OpenText("IISUrlRewrite.xml"); var options = new RewriteOptions() .AddIISUrlRewrite(iisUrlRewriteStreamReader);
Discovery
Discovery のオプション設定でデフォルトのエンドポイントを非表示にして、新しいエンドポイントを追加。
Startup.cs / ConfigureServices
var builder = services.AddIdentityServer( options => { options.Discovery.ShowEndpoints = false; options.Discovery.CustomEntries.Add("token_endpoint", "~/api/token"); // 他にも必要なら追記 })
RBAC を使って Azure Monitor の Azure Storage メトリックからBLOBストレージの容量を取得
Azure Monitor から 使用しているBLOB ストレージの容量を取得してみます。今回は .NET SDK で取得しますので以下とほぼ同じです。
Azure Monitor の Azure Storage メトリック- .NET SDK でメトリックにアクセスする
上記を参考に、今回は RBAC を使って取得してみます。
といっても、.NET SDK を使用して多次元メトリック値を読み取る にあるサンプルコードの MonitorManagementClient を作ってる部分を基本的には変更するだけです。
Azure ADに登録したアプリケーションを使用する場合
private async Task<MonitorManagementClient> AuthenticateWithReadOnlyClientAsync(string tenantId, string clientId, string secret, string subscriptionId) { var credentials = await ApplicationTokenProvider.LoginSilentAsync(tenantId, clientId, secret); var monitorClient = new MonitorManagementClient(credentials, _client, false) { SubscriptionId = subscriptionId }; return monitorClient; }
Azure リソースのマネージド ID と RBAC を使用する場合
private async Task<MonitorManagementClient> AuthenticateWithReadOnlyClientAsync() { var azureServiceTokenProvider = new AzureServiceTokenProvider(); var accessToken = await azureServiceTokenProvider.GetAccessTokenAsync("https://management.azure.com/"); var credentials = new Microsoft.Rest.TokenCredentials(accessToken); var monitorClient = new MonitorManagementClient(credentials, _client, false); return monitorClient; }
Azure Functions で動かす場合は、全体ではこんな感じでしょうか。
Function1.cs
namespace MetricsInAzureMonitorFunction { public class Function1 { private readonly HttpClient _client; public Function1(IHttpClientFactory httpClientFactory) { _client = httpClientFactory.CreateClient(); } [FunctionName("Function1")] public async Task Run([TimerTrigger("0 */30 * * * *")]TimerInfo myTimer, ILogger log) { log.LogInformation($"C# Timer trigger function executed at: {DateTime.Now}"); await ReadStorageMetricValueTest(log); } private async Task ReadStorageMetricValueTest(ILogger log) { var subscriptionId = "XXXXXXXXXXXXXXXXXXX"; var resourceGroupName = "XXXXXXXX"; var storageAccountName = "XXXXXXXXXXXXXXXXXXXXXX"; var resourceId = $"/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Storage/storageAccounts/{storageAccountName}/blobServices/default"; using (var readOnlyClient = await AuthenticateWithReadOnlyClientAsync()) { var startDate = DateTime.UtcNow.AddDays(-1).ToString("o"); var endDate = DateTime.UtcNow.ToString("o"); var timeSpan = startDate + "/" + endDate; var odataFilterMetrics = new ODataQuery<MetadataValue>("BlobType eq 'BlockBlob'"); var response = readOnlyClient.Metrics.List( resourceUri: resourceId, timespan: timeSpan, interval: TimeSpan.FromHours(1), metricnames: "BlobCapacity", odataQuery: odataFilterMetrics, aggregation: "Average", resultType: ResultType.Data); foreach (var d in response.Value.SelectMany(m => m.Timeseries).SelectMany(m => m.Data)) { if (d.Average == null) continue; log.LogInformation($"TimeStamp={d.TimeStamp}, Average={d.Average.Value}"); } } } private async Task<MonitorManagementClient> AuthenticateWithReadOnlyClientAsync() { var azureServiceTokenProvider = new AzureServiceTokenProvider(); var accessToken = await azureServiceTokenProvider.GetAccessTokenAsync("https://management.azure.com/"); var credentials = new Microsoft.Rest.TokenCredentials(accessToken); var monitorClient = new MonitorManagementClient(credentials, _client, false); return monitorClient; } } }
Startup.cs
[assembly: FunctionsStartup(typeof(MetricsInAzureMonitorFunction.Startup))] namespace MetricsInAzureMonitorFunction { public class Startup : FunctionsStartup { public override void Configure(IFunctionsHostBuilder builder) { builder.Services.AddHttpClient(); } } }
サポートされてるメトリックは以下に記載されていますので、BLOB以外にも同じような感じで取得できます。
Azure CDNで動的サイトをまるっと配信
今年の2月にAzure CDNでカスタムドメインがサポートされたこともあり、WebサイトをまるっとCDN経由にする場面も増えているような気もします。 ということで、各地域からアクセスがあるような動的サイトをまるっとCDN経由にして高速化してみます。
今回は、画像のみBlobから配信しているよくあるサイトを想定しています。
これをオリジンのWeb Appsには直接アクセスさせず全てCDN経由にして、HTMLはキャッシュなし。bundleしたCSSとJavaScriptはCDNでキャッシュを保持して配信。
Blobにある静的ファイルも直接配信せずにCDN経由で配信に変更。ということで変更後の構成。
さて、設定していきます。
Webサイト用のCDN作成
配信元を「Web アプリ」、最適化の対象を「動的サイトの高速化」にしてCDNを作成。DSAではプローブ パスが必要になりますので、サンプル又は適当なファイルをプロジェクトに追加してパスをしています。
CDNができたらキャッシュ規則を変更します。今回のキャッシュ対象にbundleしたCSSやJavaScriptもありますので、クエリ文字列のキャッシュ動作を「一位のURLをすべてキャッシュ」に変更し、カスタム キャッシュ ルールにbundleの場所を適当に追加します。
設定が完了しましたら適当にアクセスしてCDNになじませて完了。
Blob 用のCDN作成
次は静的なファイルです。配信元を「ストレージ」、最適化の対象を「一般的なWeb 配信」にしてCDNを作成。
今回はsvgもあるので圧縮形式に「image/svg+xml」を追加します。BlobにアップしているファイルにCache-Control が設定されていなければ、ついでなので設定。 Cache-Control も含めた静的コンテンツの配信については、しばやん雑記の「Azure で静的コンテンツを効率よく配信する手順をまとめた」をご覧ください。
既にアップしているファイルをまとめて設定するときはPowerShellで。
$context = New-AzureStorageContext -StorageAccountName "アカウント" -StorageAccountKey "アカウントキー" $blobs = Get-AzureStorageBlob -Context $context -Container "images" -Blob "*" foreach ($blob in $blobs) { $blob.ICloudBlob.Properties.CacheControl = "public, max-age=31536000" $blob.ICloudBlob.SetProperties() }
HTMLを修正して画像をCDNから取得するようにパスを変更してデプロイすれば完成です。ヨーロッパ北部にデプロイしたサイトを日本からアクセスした結果です。
CDN使用前
CDN使用後
ちょっとの手間で、ある程度の結果は出たようです。実際のサイトではカスタムドメインの設定などもあるかと思いますが今回は省略。
最初日本にデプロイして、どこか遠くからパフォーマンスのチェックをしようと思ってたのですが、ヨーロッパにデプロイすれば良いと気が付いてしまい出張できませんでした。残念。
Cosmos DBでバルクアップデート
追加でCosmos DBのバルクアップデート。
複数のidに対して同じ値で更新する簡単なストアドです。取得・更新を1件ごとにREST APIで処理すると12RU/件前後(IDで取得=1, 更新=11)。10件程度をストアドで一括更新したときは75.18でした。やはりお得ですね。
bulkUpdate.js
function bulkUpdate(keys, value) { var context = getContext(); var collection = context.getCollection(); var collectionLink = collection.getSelfLink(); if (!keys) throw new Error("The array is undefined or null."); var count = 0; var query = "SELECT * from root r WHERE r.id IN " + keys; collection.queryDocuments(collectionLink, query, function (err, docs) { if (err) throw new Error(err.message); for (var i = 0; i < docs.length; i++) { docs[i].JobTitle = value; tryUpdate(docs[i]) } context.getResponse().setBody(count); }); function tryUpdate(doc) { var isAccepted = collection.replaceDocument(doc._self, doc); if (!isAccepted) context.getResponse().setBody(count); count++; } }
ステータスの一括更新的な用途で使用したかったのですが、サンプルデータにステータスがなかったので、idの1から10までをCEOにしてしまいましょう。
$json = "[[""('1', '2', '3', '4', '5', '6', '7', '8', '9', '10')""], [""Chief Executive Officer""]]" exec -EndPoint $endPoint -DataBaseId $dataBaseId -MasterKey $keys.primaryMasterKey -ResourceType "sprocs" -ResourceLink "dbs/$databaseId/colls/$collectionId/sprocs/$storedProcedureId" -BodyJson $json -Uri "dbs/$databaseId/colls/$collectionId/sprocs/$storedProcedureId"
execなどはここと同じ
Cosmos DBにストアドプロシージャでバルクインサート
前回は1ドキュメント/APIでインポートしました。
Cosmos DBはJavaScriptでストアドプロシージャを記述することができますので、ストアドプロシージャを使って一括でインポートしてみます。
今回290件ほどデータを追加していますが、個別追加はRUが6.2-6.6/件くらい(合計1800超)、一括だと1227.86でした。RUも処理時間もお得ですね。
ストアドプロシージャは、Azure Cosmos DB server-side programming: Stored procedures, database triggers, and UDFs の Example: Bulk importing data into a database program にあるサンプルコードをコピーしてbulkImport.jsとして保存して使用。
bulkImport.js
function bulkImport(docs) { var collection = getContext().getCollection(); var collectionLink = collection.getSelfLink(); var count = 0; if (!docs) throw new Error("The array is undefined or null."); var docsLength = docs.length; if (docsLength == 0) { getContext().getResponse().setBody(0); } tryCreate(docs[count], callback); function tryCreate(doc, callback) { var isAccepted = collection.createDocument(collectionLink, doc, callback); if (!isAccepted) getContext().getResponse().setBody(count); } function callback(err, doc, options) { if (err) throw err; count++; if (count >= docsLength) { getContext().getResponse().setBody(count); } else { tryCreate(docs[count], callback); } } }
PowerShellではストアドプロシージャをAPIで登録し、FOR JSON PATH付きの取得結果をBodyに設定するだけです。ただ、結果なJSONをそのままストアドに渡すと1番目のみしかdocsに入ってこないので、結果を新しい配列に入れてネストさせてます。 過去のブログに記載されているコードと重複している部分もありますが、まるっと掲載。
# いろいろExec function exec($endPoint, $masterKey, $resourceType, $resourceLink, $bodyJson, $uri) { $verb = "POST" $dateTime = [DateTime]::UtcNow.ToString("r") $authHeader = Generate-MasterKeyAuthorizationSignature -verb $verb -resourceLink $resourceLink -resourceType $resourceType -key $masterKey -keyType "master" -tokenVersion "1.0" -dateTime $dateTime $header = @{authorization=$authHeader;"x-ms-version"="2017-02-22";"x-ms-date"=$dateTime} $contentType= "application/json" $queryUri = "$endPoint$uri" Echo $queryUri $result = Invoke-RestMethod -Method $verb -ContentType $contentType -Uri $queryUri -Headers $header -Body $bodyJson $result | ConvertTo-Json -Depth 10 } # キーを取得 $keys = Invoke-AzureRmResourceAction -Action listKeys -ResourceType "Microsoft.DocumentDb/databaseAccounts" -ApiVersion "2016-03-31" -ResourceGroupName "RG01" -Name "cosmosdbtest" # 作るモノの設定 $endPoint = "https://cosmosdbtest.documents.azure.com/" $dataBaseId = "hogedb1" $collectionId = "hogecoll1" $storedProcedureId = "bulkImport" # ストアド登録 $js = Get-Content "bulkImport.js" $json = @{ "id"="$StoredProcedureId"; "body"="$js"; } | ConvertTo-Json exec -EndPoint $endPoint -DataBaseId $dataBaseId -MasterKey $keys.primaryMasterKey -ResourceType "sprocs" -ResourceLink "dbs/$databaseId/colls/$collectionId" -BodyJson $json -Uri "dbs/$databaseId/colls/$collectionId/sprocs" # データ登録 Set-Location SQLSERVER:\SQL\localhost\DEFAULT\Databases\AdventureWorks2016CTP3 $result = Invoke-Sqlcmd -InputFile "ex2.sql" $json = "[" + $result.ItemArray + "]"; exec -EndPoint $endPoint -DataBaseId $dataBaseId -MasterKey $keys.primaryMasterKey -ResourceType "sprocs" -ResourceLink "dbs/$databaseId/colls/$collectionId/sprocs/$storedProcedureId" -BodyJson $json -Uri "dbs/$databaseId/colls/$collectionId/sprocs/$storedProcedureId"
- ex2.sqlは、Cosmos DBにSQL Serverのデータをインポートに記載
- Generate-MasterKeyAuthorizationSignatureは、Cosmos DB はじめましたに記載
これでデータ準備も含めたCosmos DBとPowerShellの3部作は一応終わりの予定。