1923

都内と業界の隅っこで生活しているエンジニアのノート

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 でもできると教えていただきました。

f:id:taka1923:20200328130303p:plain

SPA用にGoogle アナリティクスの設定

import VueAnalytics from 'vue-analytics'
Vue.use(VueAnalytics,
    {
        id: 'UA-XXXXX',
        router
    });

お問い合わせ用のGoogleフォームを追加して完了です。

Azure関連のリソースなどは会社のものを使わせていただき公開(Blank Maps)。今まで自分が旅行したところなどを塗ってみました。 f:id:taka1923:20200328113809p:plain

参考:httpからhttpsにJavaScriptでリダイレクトさせる方法

D3.jsで世界地図を表示

世界地図に行ったところの国でも色塗りしようと思いD3.jsで試してみました。

地図データの準備

NatualEarthからダウンロード

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);
        });
    }
}

日本だけ色塗りされた世界地図の出来上がりです。

f:id:taka1923:20200119183924p:plain

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以外にも同じような感じで取得できます。

docs.microsoft.com

Azure CDNで動的サイトをまるっと配信

今年の2月にAzure CDNカスタムドメインがサポートされたこともあり、WebサイトをまるっとCDN経由にする場面も増えているような気もします。 ということで、各地域からアクセスがあるような動的サイトをまるっとCDN経由にして高速化してみます。

今回は、画像のみBlobから配信しているよくあるサイトを想定しています。

f:id:taka1923:20171216225812p:plain

これをオリジンのWeb Appsには直接アクセスさせず全てCDN経由にして、HTMLはキャッシュなし。bundleしたCSSJavaScriptCDNでキャッシュを保持して配信。
Blobにある静的ファイルも直接配信せずにCDN経由で配信に変更。ということで変更後の構成。

f:id:taka1923:20171216225809p:plain

さて、設定していきます。

Webサイト用のCDN作成

配信元を「Web アプリ」、最適化の対象を「動的サイトの高速化」にしてCDNを作成。DSAではプローブ パスが必要になりますので、サンプル又は適当なファイルをプロジェクトに追加してパスをしています。

f:id:taka1923:20171216225806p:plain

CDNができたらキャッシュ規則を変更します。今回のキャッシュ対象にbundleしたCSSJavaScriptもありますので、クエリ文字列のキャッシュ動作を「一位のURLをすべてキャッシュ」に変更し、カスタム キャッシュ ルールにbundleの場所を適当に追加します。

f:id:taka1923:20171216225802p:plain

設定が完了しましたら適当にアクセスしてCDNになじませて完了。

Blob 用のCDN作成

次は静的なファイルです。配信元を「ストレージ」、最適化の対象を「一般的なWeb 配信」にしてCDNを作成。

f:id:taka1923:20171216225756p:plain

今回は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使用前

f:id:taka1923:20171216225837p:plain

CDN使用後

f:id:taka1923:20171216225834p:plain

ちょっとの手間で、ある程度の結果は出たようです。実際のサイトではカスタムドメインの設定などもあるかと思いますが今回は省略。

最初日本にデプロイして、どこか遠くからパフォーマンスのチェックをしようと思ってたのですが、ヨーロッパにデプロイすれば良いと気が付いてしまい出張できませんでした。残念。

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にしてしまいましょう。

PowerShell

$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に入ってこないので、結果を新しい配列に入れてネストさせてます。 過去のブログに記載されているコードと重複している部分もありますが、まるっと掲載。

PowerShell

# いろいろ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"

これでデータ準備も含めたCosmos DBとPowerShellの3部作は一応終わりの予定。