しばやん雑記

Azure とメイドさんが大好きなフリーランスのプログラマーのブログ

Premium V2 が選べない App Service Plan が存在する

タイトルの通りですが、大昔に作った Japan East の App Service Plan を何となくスケールさせようかとしたら、Premium V2 がグレーアウトして選べなくなっていました。

f:id:shiba-yan:20171205163953p:plain

Premium V2 について知りたい方は、Public Preview の時に書いたエントリがあるので、こっちも参考にしてください。今は GA してますが、基本的に変わっていないはずです。

価格は GA してもこれまでの Premium と同じなのに、パフォーマンスは格段に向上しているので移行しない理由は全くありません。Premium を使っている場合は今すぐ移行しましょう。

話を戻します。グレーアウト部分に分かりにくいですが、理由が書いてあります。

Premium V2 is not supported for this scale unit. Please consider redeploying or cloning the App

要するに今の App Service Plan が載っているスケールユニットには、Premium V2 用のインスタンスが入っていないようです。なので Premium V2 を使うためには App Service の再デプロイやクローンを行って、スケールユニットを変える必要があります。

スケールユニットを変更するというのは地味に大変な作業です。サポートに変更リクエストを依頼できる噂もありますが、実行されるスケールユニットが変わると IP アドレスが変わります。A レコードを使っている場合、Outbound IP アドレスを使って制限をかけている場合などは手間がかかります。注意しましょう。

今ある App Service Plan に Premium V2 がデプロイされることを期待しますが、これから新しく作る App Service で Premium V2 を使う予定がある場合には、作成時に Premium V2 を選ぶようにしましょう。

f:id:shiba-yan:20171205165809p:plain

新規作成時に Premium V2 を選んでおけば、当然ながらデプロイされているスケールユニットが優先的に割り当てられるので、変更することがあっても安心して使えます。

今すぐに必要ない場合は、作成後にスケールを下げておけば良いです。後から変更するのは大変なので、予め考えておくか無条件で Premium V2 を選んで作成するのが良いかもしれません。

Application Insights に Cosmos DB で消費された RUs を送信すると非常に捗った話

仕事で Cosmos DB を使ってアプリケーションを書きましたが、最近はあらかじめ割り当てておいた RU を突き抜けることがあって原因の調査を行っていました。

その時に Cosmos DB のメトリックだけではコレクション別でしか RU を確認出来ず、Application Insights では処理時間しか取得されておらず不便だったので、自前で消費した RU を送信するようにしました。

RU を送信する処理は Repository のベースクラスに仕込んだので、少しの修正だけで済みました。

上で挙げたサンプルクラスに以下のような処理を追加して、適当なタイミングで呼び出しているだけです。RU 以外にも送っても良い気がしますが、今回は RU だけで十分でした。

TelemetryClient はサンプルなので DI を使って直接渡しましたが、適当にインターフェースを用意した方が Application Insights への依存関係を含めずに済むのでスマートかもしれません。

protected async Task<IList<T>> ExecuteQueryAsync<T>(IDocumentQuery<T> documentQuery, [CallerMemberName] string methodName = null)
{
    var requestCharge = 0.0;
    var list = new List<T>();

    while (documentQuery.HasMoreResults)
    {
        var response = await documentQuery.ExecuteNextAsync<T>();

        requestCharge += response.RequestCharge;

        list.AddRange(response);
    }

    TrackRequestCharge(requestCharge, methodName);

    return list;
}

private void TrackRequestCharge(double requestCharge, [CallerMemberName] string methodName = null)
{
    Telemetry.TrackEvent($"Executed operation {CollectionId}.{methodName} in {requestCharge} RUs", new Dictionary<string, string>
        {
            { "Collection", CollectionId }
        },
        new Dictionary<string, double>
        {
            { "Request units", requestCharge }
        });
}

ExecuteQueryAsync に関しては前回用意してなかったですが、モリス先輩がタイミングよく書いていたので参考にして組み込みました。

複数回 ExecuteNextAsync を呼び出す可能性があるので RU は集計するようにしてます。

そんなこんなでアプリケーションを実行してみると、カスタムイベントとして Application Insights に Cosmos DB で消費された RUs と実行したメソッド情報が表示されます。

サンプルなのでデータの偏りがなく、RU が一定になっているのでありがたみは感じないかもしれません。

f:id:shiba-yan:20171204230014p:plain

しかし実際のアプリケーションでの調査では、このカスタムイベントと Application Insights の Session Timeline が非常に強力でした。処理の流れが時系列で簡単に表示できる、最高に素晴らしい機能です。

組み合わせることで、どのページからの呼び出しで RU を過剰に消費しているか一目で確認出来ました。

f:id:shiba-yan:20171204230728p:plain

ちなみに、カスタムプロパティとしてコレクション名を送信しているので、Metrics Explorer から簡単にコレクション単位での RU 消費をグラフにすることが出来ます。

f:id:shiba-yan:20171204224345p:plain

グルーピングを設定しないと、関係なく集計してしまってあまり意味がありません。

実際に設定すると、以下のような表示になります。一目で状況を把握することが出来ますね。

f:id:shiba-yan:20171204225749p:plain

Cosmos DB は RU の消費状態を把握し、最適化を行うかが重要だと再認識しました。そのためには組み込みのメトリックだけでは不十分で、APM と上手く組み合わせると幸せになれるという話です。

実際に仕事で作ったアプリケーションで発生していた RU の過剰消費は一瞬で解決しました。それは余計なデータまで読みに行くという、単純なバグだったというオチでした。

Azure AD B2C を ASP.NET Core で使うと頻繁にログアウトするのを直す

Azure AD B2C を使ってログイン処理を実装した ASP.NET Core なアプリケーションが、何故か頻繁にログアウトしてしまうので調べてました。これもまた地味にはまったポイントです。

とりあえず OpenIdConnect な Middleware のコードを読んで調べました。

どうも Azure AD B2C 側で設定されている期限を優先している気配がありました。

最初は AddCookie で何も設定していないからかと思いましたが、ソースコードを調べた結果デフォルトは 14 日になっていたので、設定自体は問題ないことが分かりました。

前に Azure AD 自体のセッションが 1 時間で切れると聞いたことがあったので、期間を伸ばすしかないのかなーと思ってましたが、ぶちぞう RD が解決策を提示してくれました。流石 Azure 界の抱かれたい男 No.1。

設定を確認すると、確かに UseTokenLifetime = true となっていました。

元々 Azure AD B2C のログイン処理は GitHub のサンプルコードを参考に実装したので、サンプルコードの方を確認すると UseTokenLifetime = true となっていました。

https://github.com/Azure-Samples/active-directory-b2c-dotnetcore-webapp/blob/core2.0/WebApp-OpenIDConnect-DotNet/OpenIdConnectOptionsSetup.cs#L47

ドキュメントコメント曰く、UseTokenLifetime はデフォルトでは false らしいです。サンプルコードはアクセストークンを使う部分があるので、true にしていたみたいです。

設定を変えて、再度ログインすると 14 日有効なクッキーが発行されました。

f:id:shiba-yan:20171129010837p:plain

単にログイン用として使う場合は false にしておく方が良いですが、アクセストークンをログイン中の任意のタイミングで使う場合には true にすると自動的にログアウトされるので便利でしょう。

そういえば LINE Login が OpenID Connect に対応した時にも true にしていました。

LINE Login のアクセストークンは 30 日間有効なので、AD B2C のように問題にはならないはずです。

Web サービスのログインが 1 時間で毎回切れるとか、さすがにこっちが切れそうになるのでサンプルコードにコメントぐらい書いておいてほしかったというのが本音です。気を付けましょう。

Azure Container Services (AKS) のデプロイで苦労した話

ここ数日は AKS のクラスター作成と戦っていたので、ARM ベースでいろいろと調べていました。例によって忘れそうなので、ちゃんとメモしておくことにします。

AKS の最新情報を知りたければ、GitHub を追っかけておけば OK です。

ちなみに以下のエントリで使うために、クラスターを作っていた時の話です。

基本的に Windows と Linux のハイブリッドなクラスターしか作ってないので、話としては偏っているのでそのあたりはあしからず。

West US 2 / UK West は新規デプロイ停止

デプロイ完了していた West US 2 のクラスターが Failed になって止まっていたので、新しくデプロイしようとしても必ず失敗するとか、まあいろいろとはまっていました。

調べていたら GitHub にアナウンスが上がっていました。

https://github.com/Azure/AKS/blob/master/annoucements/service_outage_2017-11-09.md

何らかの障害で West US 2 は新規のクラスター作成を受け付けていません。UK West はおそらくキャパシティの問題で、こちらも作成は出来ない状態となっています。

既に作成済みのクラスターがある場合でも、ステータスが Failed になっている場合は作り直す必要があるかもしれません。自分の場合はどうしても復活しなかったので作り直しました。

失敗後に同じ名前の AKS を作ると挙動が怪しい(気がする)

適当に作成したリソースグループに対して West US 2 に AKS をデプロイしようとして、キャパシティの問題でエラーになった後、再度同じ名前でデプロイすると別のエラーになってました。

リソースグループを新しく作っても変わらなかったですが、AKS を別名にすると通った気がしました。

対応リージョンに East US / West Europe が追加

パブリックプレビューとして公開された時には West US 2 と UK West しか選べませんでしたが、最近 East US と West Europe が追加されました。

https://github.com/Azure/AKS/blob/master/preview_regions.md

Hyper-V Containers が使いたいので Nested Virtualization を有効に出来る Dv3 が使えるリージョンを選ぶ必要がありましたが、East US と West Europe は両方とも対応してるみたいです。

今新しく AKS を作ろうとすると、そもそも UK West は選べなくなってました。

f:id:shiba-yan:20171115223905p:plain

もうちょっと日本に近い場所にデプロイされると嬉しいのですが、時間はかかりそうな気がしています。East Asia か Southeast Asia なら来るかもしれません。

ルートテーブルにルートが追加されない時がある

Windows 限定っぽいですが、VM などのデプロイが完了しているにもかかわらず、ルートテーブルに新規作成した VM 向けのルートが追加されず、ノードとして認識されない現象が発生しました。

しかも、上手くいったりいかなかったりするので非常に厄介です。ほかにも Custom Script Extension の実行に失敗したり、そもそもデプロイが完了しなかったりといろいろありました。

対処方法としては上手くいくまで VM を作り直すぐらいしかないです。

Kubernetes 1.7.9 / 1.8.2 に対応

ポータルからは 1.7.7 と 1.8.1 しか選べない Kubernetes のバージョンですが、内部的には 1.7.9 と 1.8.2 に対応しているようで、ARM を使って設定値を変えるとアップデートされます。

ARM で upgradeprofile を開くと、対応しているバージョンを取得できます。

f:id:shiba-yan:20171115223145p:plain

ちゃんと Windows にも対応していることが分かります。公式発表は近いのかも。

f:id:shiba-yan:20171117230519p:plain

ARM Explorer でバージョンを書き換えたところ、ちゃんと Kubernetes 1.8.2 にアップデートされたことが確認できました。Windows と Linux の両方ともが自動でアップデートされます。

ちなみにアップデートは VM を作り直すという方法なので、多少時間はかかります。

AKS を使って Windows Containers 対応の Kubernetes クラスターを作成する

既にいろんな人が AKS を触っているみたいですが、個人的には Windows や ACI と組み合わせて使いたかったので、まずは Windows 向けクラスターが作成できるのか確認しました。

ACS Engine を使って作成していたクラスターの管理が面倒だったので、これを機に入れ替えます。

クラスターを作成する

サポートされているとは明記されてませんが、公式のドキュメントを見ると osType には Windows を指定できるみたいでしたし、ARM Template が ACS Engine とそっくりだったので試したらうまくいきました。

f:id:shiba-yan:20171114161753p:plain

Windows と Linux で 2 つの Agent Pool が作成されていることが確認できますね。

最初は Windows のみでクラスターを作成してみましたが、ACS とは異なり AKS では Kubernetes Master 自身で Pod を稼働させることは出来ないみたいです。なので、Windows だけでは必要な Pod が動作しないので Kubernetes Dashboard は稼働しません。

正しく Kubernetes クラスターを動作させるには Windows と Linux のノードが必要です。

f:id:shiba-yan:20171114162732p:plain

ARM Template を見ると、複数の Agent Pool を持つことができるのが一目でわかるので、Windows と Linux のハイブリッドなクラスターを用意することが容易です。

ちなみに ARM Template を使うと、ポータルでは対応していない Dv3 / Ev3 な VM を選べます。

Kubernetes Dashboard にアクセスする

AKS の Kubernetes Dashboard にアクセスするには az aks browse コマンドを使いますが、Azure CLI の不具合で Windows だとうまく動作しないので、互換のあるコマンドを使ってアクセスします。

az aks browse も内部的には kubectl を叩いてるだけなので、以下のコマンドで問題ないです。

# kubernetes dashboard の pod 名を取得
kubectl get pods --namespace kube-system --output name --selector k8s-app=kubernetes-dashboard

# dashboard に対してポートフォワーディングを実行
kubectl --namespace kube-system port-forward kubernetes-dashboard-XXXX 8001:9090

ポートフォワーディングのコマンドは、覚えておいても損はないかもしれません。

これで 127.0.0.1:8001 というアドレスで Kubernetes Dashboard へのアクセスが可能となります。

f:id:shiba-yan:20171114162317p:plain

ちゃんと Windows と Linux のノードが表示されていることが確認できます。Kubernetes Master のノードは表示されないようになっているので、Pod の実行も出来ないようになっています。

ASP.NET アプリケーションをデプロイする

クラスターが完成したので、例によって AppVeyor でビルドした ASP.NET アプリケーションのイメージをデプロイしたいところですが、なんと AKS で Windows ノードを作成すると、デフォルトで Version 1709 が使われるみたいです。

まだ扱いにくいので 1709 は使ってこないと思ってましたが、ノードの情報を見て驚きました。

f:id:shiba-yan:20171114163655p:plain

Version 1709 が使われているということは、Windows Server Containers がデフォルトになっている環境では、1709 の Docker Image を利用したコンテナーしか動作しないことを意味します。

まだ CI SaaS は 1709 に対応したものが存在しないので、仕方なく開発環境に Docker for Windows をインストールして、Windows 10 FCU 上で Docker Image を作成しました。

f:id:shiba-yan:20171114230705p:plain

あとはこれまでと同じようにデプロイすれば、Kubernetes 上で ASP.NET アプリケーションが動作します。Docker Image のサイズが 7GB から 3GB に削減されたので、コールドスタートにかかる時間はかなり短縮されました。実用にはまだかかりそうですが、これくらいなら工夫で何とかなりそうです。

Pod をデプロイすれば、あとは Service を追加すれば外部からアクセス可能になります。

f:id:shiba-yan:20171114230752p:plain

地味ですが、普通の ASP.NET MVC アプリケーションが AKS 上でも動作しました。

これまでも ACS や ACS Engine を使うことで Windows Containers に対応した Kubernetes 環境を用意することは出来ましたが、AKS はなんて言ってもマネージドサービスなので、今後 GA に向けていろいろと改善されていくはずです。

最近は VMSS に対して Automatic OS Image Upgrade が提供されましたし、AKS も GA までにはアップデート含め面倒を見てくれるサービスになることを期待しています。

Cosmos DB では Repository パターンを使うのが楽だった話

Cosmos DB の API は地味にくせがあるというか、コレクション毎の違いは UriFactory のパラメータ以外あまりなくて、大体は同じような処理を書く感じだったので Repository を用意して共通化しました。

勿論これが正解とか言うわけではなく、あくまでも一例として捉えて貰えばと思って紹介します。実際のところ Cosmos DB に限らない話ではあるんですが、今回は Cosmos DB を使ったというだけです。

今回のケースについて

実際に仕事で Cosmos DB を使ってたわけですが、コレクション数が 10 ぐらいの中規模ぐらい?なアプリケーションでした。Cosmos DB は非正規化されたデータが入るので、SQL の時みたいにコレクションの数が規模に直結しないのが特徴ですね。

公式ドキュメントが揃っているので、この辺りを読んで事前に知識を入れておきました。

Cosmos DB はコレクション間での JOIN とか出来ないですし、LINQ で書いたり SQL で書いたり、UDF やストアドを使ったりといろいろありそうなので、あまりガッツリとラップしない方が良いなと考えました。

Document を用意

Cosmos DB のドキュメントには必ず id が存在するのと、Optimistic Concurrency 用に Etag も欲しかったので、全てのドキュメントが継承する AbstractDocument クラスを用意しました。

相変わらず名前付けが適当ですね。作成・更新日時が欲しかったので日付型を 2 つ用意しておきました。

public abstract class AbstractDocument
{
    [JsonProperty("id")]
    public string Id { get; set; }

    [JsonProperty("createdOn")]
    public DateTime CreatedOn { get; set; }

    [JsonProperty("updatedOn")]
    public DateTime UpdatedOn { get; set; }

    [JsonProperty("_etag")]
    public string Etag { get; set; }
}

Cosmos DB に保存するクラスは AbstractDocument を継承するようにします。Entity Framework でも同じような作りにしていた気がするので、個人的にはこういう設計にするのが好きなのかも知れません。

実際に MemberDocument というクラスを用意しました。名前の通り会員の情報を持つクラスです。

public class MemberDocument : AbstractDocument
{
    [JsonProperty("email")]
    public string Email { get; set; }

    [JsonProperty("lastName")]
    public string LastName { get; set; }

    [JsonProperty("firstName")]
    public string FirstName { get; set; }

    [JsonProperty("prefectureId")]
    public int PrefectureId { get; set; }
}

実装とはあまり関係ないですが、全てのプロパティの定義に JsonProperty 付けるかどうかは好みの問題という気もします。とりあえず今回は camelCase に合わせました。

Repository を作成

Entity Framework の時に割と使った気がする Repository ですが、Cosmos DB の場合は用意した方が良いなと今回実際に書いていて思いました。面倒だったのでインターフェースまでは最初から用意しなかったですが、ASP.NET Core の DI と相性は良い感じがしました。

これも Document と同様に AbstractRepository クラスを用意しました。基本的な CRUD 操作と DocumentDB API の操作に必要な情報を提供します。Upsert の方が良いかも知れません。

public abstract class AbstractRepository<TDocument> where TDocument : AbstractDocument
{
    protected AbstractRepository(DocumentClient documentClient, string databaseId, string collectionId)
    {
        DatabaseId = databaseId;
        CollectionId = collectionId;

        Client = documentClient;
    }

    protected string DatabaseId { get; }
    protected string CollectionId { get; }

    protected DocumentClient Client { get; }

    public async Task CreateAsync(TDocument document)
    {
        document.CreatedOn = DateTime.UtcNow;

        var response = await Client.CreateDocumentAsync(UriFactory.CreateDocumentCollectionUri(DatabaseId, CollectionId), document);

        document.Id = response.Resource.Id;
    }

    public async Task<TDocument> GetAsync(string id, string partitionKey)
    {
        try
        {
            var response = await Client.ReadDocumentAsync<TDocument>(UriFactory.CreateDocumentUri(DatabaseId, CollectionId, id), new RequestOptions
            {
                PartitionKey = new PartitionKey(partitionKey)
            });

            return response.Document;
        }
        catch (DocumentClientException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
        {
            return default(TDocument);
        }
    }

    public async Task UpdateAsync(TDocument document)
    {
        try
        {
            document.UpdatedOn = DateTime.UtcNow;

            var condition = new AccessCondition
            {
                Condition = document.Etag,
                Type = AccessConditionType.IfMatch
            };

            await Client.ReplaceDocumentAsync(UriFactory.CreateDocumentUri(DatabaseId, CollectionId, document.Id), document, new RequestOptions { AccessCondition = condition });
        }
        catch (DocumentClientException ex) when (ex.StatusCode == HttpStatusCode.PreconditionFailed)
        {
            // optimistic concurrency に失敗
            throw;
        }
    }

    public async Task DeleteAsync(TDocument document, string partitionKey)
    {
        try
        {
            var condition = new AccessCondition
            {
                Condition = document.Etag,
                Type = AccessConditionType.IfMatch
            };

            await Client.DeleteDocumentAsync(UriFactory.CreateDocumentUri(DatabaseId, CollectionId, document.Id), new RequestOptions
            {
                AccessCondition = condition,
                PartitionKey = new PartitionKey(partitionKey)
            });
        }
        catch (DocumentClientException ex) when (ex.StatusCode == HttpStatusCode.PreconditionFailed)
        {
            // optimistic concurrency に失敗
            throw;
        }
    }
}

何の変哲もない、基本的な実装のみ提供しています。特に説明も要らない気はしますが、Create 時に生成された Id を呼び出し元に反映するようにしました。この辺りは Entity Framework 的ですね。

更新処理はデフォルトで Optimistic Concurrency を有効にしています。同時実行制御を無効にする必要って正直あまりないかなと考えたので、こういう実装にしました。本来は専用の例外を用意するべきでしょうね。

そして先ほど作成した MemberDocument に対する Repository を作成してみます。

public class MemberRepository : AbstractRepository<MemberDocument>
{
    public MemberRepository(DocumentClient documentClient, string databaseId)
        : base(documentClient, databaseId, "Member")
    {
    }

    public async Task<MemberDocument> GetByEmailAsync(string email)
    {
        var feedOptions = new FeedOptions
        {
            MaxItemCount = 1,
            EnableCrossPartitionQuery = true
        };

        var documentQuery = Client.CreateDocumentQuery<MemberDocument>(UriFactory.CreateDocumentCollectionUri(DatabaseId, CollectionId), feedOptions)
                                    .Where(x => x.Email == email)
                                    .AsDocumentQuery();

        return (await documentQuery.ExecuteNextAsync<MemberDocument>()).FirstOrDefault();
    }
}

コンストラクタでは DI で DocumentClient が注入されることを期待していて、databaseId は IOption とかで渡せば良いと思います*1。会員情報というのはメールアドレスで引きたいことが多いと思うので、専用のメソッドを追加しています。

パーティションとかはサンプルに付き検討してないので、とりあえず EnableCrossPartitionQuery = true でお茶を濁しました。この場合はパーティション固定でも良いかも知れません。

使い方など

ASP.NET Core アプリケーションで使う場合には DI を使って簡単に対応できます。ConfigureServices で DocumentClient と作成した Repository を Singleton として追加します。

本当なら接続先やプライマリキーは IConfiguration から取るようにしましょう。

public void ConfigureServices(IServiceCollection services)
{
    services.AddSingleton(provider =>
        {
            var connectionPolicy = new ConnectionPolicy
            {
                ConnectionMode = ConnectionMode.Direct,
                ConnectionProtocol = Protocol.Tcp
            };

            return new DocumentClient(new Uri("https://xxxx.documents.azure.com:443/"), "PRIMARYKEY", connectionPolicy);
        });

    services.AddSingleton<MemberRepository>();

    services.AddMvc();
}

必要なクラスを DI に登録したので、後はコントローラのコンストラクタで Repository を受け取るだけです。

テストなので大したコードではないですが、Repository を使うことでコレクション単位でのアクセスは扱いやすくなりました。裏側が SQL Database に変わっても違いはあまりないでしょう。

public class DemoController : Controller
{
    public DemoController(MemberRepository memberRepository)
    {
        _memberRepository = memberRepository;
    }

    private readonly MemberRepository _memberRepository;

    public async Task<IActionResult> Index()
    {
        var kazuakix = await _memberRepository.GetByEmailAsync("me@kazuakix.jp");

        kazuakix.LastName = "syachiku";

        await _memberRepository.UpdateAsync(kazuakix);

        return View();
    }
}

実際に仕事で Cosmos DB を使った時には Repository の上に Service 層を追加する形で実装しました。最近の仕事の中では上手く実装できた方かなと自分では思ってますが、次はもっと上手く作れそうです。

とまあ、長々と書いてきましたが、こういうのは既にみんな作ってますよね。私の Cosmos DB の初心者っぷりを発揮したところで、今回は終わりにしたいと思います。

*1:CollectionId と同様に定数でも良いのではないかとも思う

App Service on Linux を本番環境で運用する際の注意点

最近は App Service on Linux を使った仕事もしていたので、本番環境で使う場合の注意点を簡単にまとめておきます。地味にはまるポイントが多くて忘れそうなので、ほぼ自分用のメモですね。

2 コア以上のインスタンスを選ぶ

1 core 1.75GB の B1 / S1 インスタンスから選べますが、明らかにコンテナの起動時間が遅いので最低でも B2 / S2 を選ぶべきです。これは本番環境だけではなく、テスト用でも同じです。

f:id:shiba-yan:20171016170213p:plain

CPU リソースというよりもメモリが足りていない気もするので、Premium V2 が App Service on Linux でも使えるようになれば幸せになれそうです。

Docker Image のタグを必ず指定する

App Service on Linux はホスト側のメンテナンスなど、様々な事情で実行中のコンテナが再起動されることがあります。なので、タグを指定せずに latest のまま使うと、再起動時に pull が走って意図せずバージョンアップしてしまいます。

f:id:shiba-yan:20171016175031p:plain

ACR はタグを指定しないと設定できないので良いですが、Docker Hub を使っている場合には注意です。

当然ながら CI で Docker Image を作る場合にもビルド番号などでタグを指定します。

Always On は有効化する

App Service on Linux の仕組みとして、最初にリクエストがあったタイミングでコンテナが起動されるので、初回アクセス時に割と時間がかかってしまいます。

なので、Always On を有効にして、出来るだけコンテナを常に立ち上げた状態にしておきます。

f:id:shiba-yan:20171016170613p:plain

このあたりは Windows と同じですね。設定することで意図しない再起動の影響を最小限に出来ます。

Staging Slot を使って Swap でリリースする

軽量な Alpine ベースの Docker Image を使っている場合には気にならないぐらいの時間でしょうが、ASP.NET Core アプリケーションの Docker Image は 100MB を超えてしまうので、pull で時間がかかります。

Image 自体は App Service on Linux 側にキャッシュされるので、Staging Slot を使って時間のかかる pull を回避しつつ、リリースする方法を取る必要があります。

f:id:shiba-yan:20171016170423p:plain

Swap を行うと、今の実装では Production / Staging Slot 共に新しいコンテナが起動されて、起動完了後に入れ替わるという仕組みみたいなので、両方で同じバージョンになるタイミングが発生します。

両方の Slot 設定をキッチリと合わせていても、新しいコンテナが必ず起動されるみたいなので、暖めて出すという使い方が出来ないのが少し残念ではあります。

ACR を利用した CD を使わない

Azure Container Registry の Webhook を使った CD 機能は非常に便利そうに思うのですが、Webhook の実装がコンテナを再起動するだけという、非常に残念なものになっています。

なので、CI では常に同じタグを push せざるを得ず、Swap のタイミングで再起動されるという実装と相まって、Swap を実行すると両方の Slot が最新になるという致命的欠点があります。

f:id:shiba-yan:20171016171214p:plain

ACR 連携の CD は Webhook が push された Docker Image とタグの情報を読み込んで、その通りに Docker Image を更新するようになるまで使い物にならないと考えた方が良いでしょう。

Azure CLI を使った更新や、VSTS のタスクでは Docker Image 名を変更するので、こういった問題は発生しません。Docker Image の情報は ARM 側に持っているっぽいので、Webhook での対応は望み薄です。

Visual Studio Team Services と Web App for Container を使用した ASP.NET Core アプリケーションの CI/CD

最近は仕事で Visual Studio Team Services と Web App for Container を使っていましたが、地味にデプロイ周りで苦労をしたので軽くまとめておきます。

デプロイだけでは面白くないので、ASP.NET Core アプリケーションのビルドから行ってみます。Web App for Container には VSTS を使った CD を設定する機能がありますが、実際に試してみたところ中途半端な感じだったので、手動でビルドタスクを組みました。

VSTS で必要なものは Docker でタスクを検索すると出てきます。

f:id:shiba-yan:20171014225200p:plain

そして ASP.NET Core アプリケーションのビルドに必要なものは、Visual Studio 2017 で Docker サポートを追加すると全て追加されるので、準備することはあまりありません。

f:id:shiba-yan:20171014230118p:plain

VSTS には .NET Core のビルドを行うタスクもありますが、現在のビルドワーカーに入ってるランタイムバージョンなどを調べるのが面倒だったので、Docker Image を使ってビルドを行います。

予めリポジトリを作成して、ASP.NET Core アプリケーションのソースがプッシュされている前提で進めます。あとビルドタスクはテンプレートを使いません。

ASP.NET Core アプリケーションのビルド

Visual Studio 2017 で Docker サポートを有効にしてプロジェクトを作ると、Docker Compose を利用したビルド用の yaml がいくつか追加されます。ちゃんと CI 用の定義も用意されているので、これを使います。

f:id:shiba-yan:20171014235618p:plain

Docker Compose がインストールされている環境であれば、CI 用の定義を読み込んで docker-compose up すればアプリケーションのビルドが完了します。非常にシンプルで良いですね。

出力先は /obj/Docker/publish 固定になっていますが、ここは絶対に変更してはいけないです。

version: '3'

services:
  ci-build:
    image: microsoft/aspnetcore-build:1.0-2.0
    volumes:
      - .:/src
    working_dir: /src
    command: /bin/bash -c "dotnet restore ./WebApplication20.sln && dotnet publish ./WebApplication20.sln -c Release -o ./obj/Docker/publish"

ここで分かりにくいのが microsoft/aspnetcore-build:1.0-2.0 というタグのイメージを使わないと Visual Studio の Docker 向け SDK がインストールされておらず、ビルド時にエラーとなってしまうので注意。

素直に Visual Studio 2017 で生成されたまま使うのが正解という話でした。

VSTS では Docker Compose タスクを追加して、Action を "Run service images" に、Docker Compose File を docker-compose.ci.build.yml に変更するだけです。

f:id:shiba-yan:20171015002118p:plain

ルートにあるので ** は必要ないですが、あっても害はないので追加したままにしてます。

Docker Image のビルドと ACR へのプッシュ

アプリケーションのビルド後は Docker Image を作成します。デフォルトで生成されている Dockerfile は、ちゃんと Docker Compose を使ってビルドされた前提のパスになっているので簡単です。

FROM microsoft/aspnetcore:2.0
ARG source
WORKDIR /app
EXPOSE 80
COPY ${source:-obj/Docker/publish} .
ENTRYPOINT ["dotnet", "WebApplication20.dll"]

VSTS では Docker タスクを追加して、Action を "Build an image" にするだけです。Dockerfile のパスは最初から適切な値になっているはずなので、特に弄る必要はありません。

f:id:shiba-yan:20171015002455p:plain

Docker Image のビルド後はそれを ACR にプッシュする必要がありますが、これも Docker タスクを追加して Action を "Push an Image" に変えるだけで完了です。GUI での設定なので、あまりやることがないですね。

App Service へのデプロイ

ASP.NET Core アプリケーションが含まれている Docker Image はここまでで ACR までプッシュされているはずなので、最後は Web App for Container にデプロイを行います。

VSTS には非常に便利な Azure App Service Deploy というタスクがあるので、これを利用します。説明文をよく読むと Linux にも対応していると書いてありました。

f:id:shiba-yan:20171014230523p:plain

App Service on Linux では Staging スロットを使ってスワップする運用が必須なので、デプロイ先のスロットの選択が簡単に行えるのは非常に良いです

CircleCI で同じようにデプロイした時はサービスプリンシパルを用意したり、Azure CLI をインストールしたりと手間がかかりましたが、VSTS だとポチポチと設定すれば終わるのが楽ですね。

f:id:shiba-yan:20171014224721p:plain

今回作成した VSTS のビルド定義は上のようになりました。ビルドからデプロイに必要なのは 4 ステップだけというシンプルさです。App Service へのデプロイタスクのおかげですね。

ビルドを行いデプロイを確認

最後にちゃんとビルドを実行して、Web App for Container にデプロイされることを確認しておきました。

f:id:shiba-yan:20171014230859p:plain

VSTS は Docker Image をキャッシュしてくれないようで、アプリケーションのビルドに地味に時間がかかりましたが、それでも 3,4 分で完了しました。aspnetcore-build とかは予め持っておいて欲しいですね。

Azure Portal で staging スロットにデプロイされている Docker Image を確認すると、ちゃんとビルドされたバージョンに切り替わっていました。

f:id:shiba-yan:20171014231743p:plain

ここで ACR 扱いになっていない場合は、ACR の Admin User が有効になっていないか、Web App に ACR の情報が設定されていないかのどちらかです。予め設定しておく必要があります。

おまけ:自動でスワップを行って本番リリース

VSTS には App Service のスロットをスワップするタスクもあるので、これを使えば新しい Docker Image をデプロイした後、自動的にスワップを実行してリリースすることも出来ます。

f:id:shiba-yan:20171014232921p:plain

Source slot を指定してビルドすれば、Production とスワップされて最新のビルドがリリースされます。

Windows 版の App Service では Auto Swap という機能がありましたが、同じことを Web App for Container でも実現できます。癖のあるデプロイ周りですが、便利に使っていきましょう。

Cosmos DB を利用する上で最初にはまった部分のメモ

Twitter でおーみさんに Cosmos DB についていろいろ聞いたので、忘れないようにメモっておきます。kyrt.in に書いてあればみんな幸せになりそうなんですが、いつ書いてくれるかわからないので。

HTTP GW ではなく TCP Direct を使う

デフォルト設定のまま Cosmos DB を使っていると、どうも遅いような気がしたので聞いたところ、TCP Direct を使うように設定を変えるのが良いと教えて貰いました。

DocumentClient を作るときに ConnectionPolicy を渡してやればよいです。

var connectionPolicy = new ConnectionPolicy
{
    ConnectionMode = ConnectionMode.Direct,
    ConnectionProtocol = Protocol.Tcp
};

var documentClient = new DocumentClient(new Uri("ENDPOINT"), "PRIMARYKEY", connectionPolicy);

これで TCP Direct モードが使われるようになるようです。デフォルトを TCP にしておいて欲しいですね。

DocumentClient はシングルトンに

TCP Direct にしてもまだ遅いような気がしたので、更におーみさんに聞いてみました。DocumentClient は TCP Direct の場合はコネクションをプールするらしいので、シングルトン推奨のようです。

確かに毎回接続を作り直して、初期情報を受信してたら遅いよねという感じです。

幸いにも今回は ASP.NET Core アプリケーションで Cosmos DB を使っていたので、DI を使って簡単にシングルトンを実現出来ました。Factory を使って実現します。

services.AddSingleton(provider =>
{
    var connectionPolicy = new ConnectionPolicy
    {
        ConnectionMode = ConnectionMode.Direct,
        ConnectionProtocol = Protocol.Tcp
    };

    return new DocumentClient(new Uri("ENDPOINT"), "PRIMARYKEY", connectionPolicy);
});

使う場合にはコンストラクタで DocumentClient を受け取るようにするだけです。

public class DemoService
{
    public DemoService(DocumentClient documentClient)
    {
        // documentClient は DI で解決される
    }
}

これでアプリケーションで 1 つの DocumentClient を利用することが出来ます。慣れてくると ASP.NET Core の DI は結構使いやすく感じるようになってきました。

Optimistic Concurrency は ETag を使う

データを扱う上で大体問題になってくるのがロックとかなんですが、Cosmos DB には当然ながらロックとか用意されていないので、必要な場合は Optimistic Concurrency を使って制御を行うことになります。

適当に調べた感じではヒットしなかったので、おーみさんに聞いて ETag を教えて貰いました。

Cosmos DB のドキュメントには全て ETag が含まれていて、Replace 時に AccessCondition に ETag を設定しておくと、一致した時のみ処理を実行するように出来るみたいです。考え方は HTTP 的ですね。

try
{
    var condition = new AccessCondition
    {
        Condition = document.etag,
        Type = AccessConditionType.IfMatch
    };

    await Client.ReplaceDocumentAsync(UriFactory.CreateDocumentUri(DatabaseId, CollectionId, document.id), document, new RequestOptions { AccessCondition = condition });
}
catch (DocumentClientException ex) when (ex.StatusCode == HttpStatusCode.PreconditionFailed)
{
    // optimistic concurrency で失敗
}

処理に失敗した時には PreconditionFailed がステータスコードとして返ってくるので、何かしら処理が必要な場合は行います。新しく追加された catch ~ when は地味に便利でした。

今回のアプリケーションでは Repository Pattern を使って処理をまとめていたので、全ての更新処理を対象に Optimistic Concurrency を入れることが容易でした。

暇になった時にでも、どのように Cosmos DB への処理部分を書いたかまとめたい気がします。

App Service on Linux と Azure Container Registry を使ったデプロイを試したら罠が多かった話

App Service on Linux が GA する少し前に Azure Container Registry を使った自動デプロイが、Azure Portal から簡単に設定できるようになったとブログに上がってました。

Azure Portal から Continuous Deployment を On にすると Webhook の設定をしてくれます。

ドキュメントも用意されてますが、ACR 連携に関しては手動で Webhook を作る必要があると書いてあるので、まだ最新にはなっていないようです。

ACR への Push をトリガーにデプロイが行えると非常に捗りそうなので、いろいろと調べていました。しかし何故か上手くいきませんでしたが、最近いろいろと解決して動作するようになったので書きます。

Webhook はコンテナを再起動するだけ

Docker Hub や Azure Container Registry との連携用に用意されている Webhook は非常に単純な機能しか持っておらず、呼び出されたらコンテナを再起動するだけとなってます。

この挙動は Kudu の実装を見るとすぐにわかります。コンテナの再起動なので、毎回 Docker Image のタグを変更するような運用の場合は正しく動作しません。

タグを変える場合には Azure CLI を使ってコンテナイメージを切り替えるといった、これまで通りの処理が必要になります。CircleCI を使って実現する場合の例は以下の通り。

Docker Image のプッシュ先を ACR に変えるだけですが、サービスプリンシパルを作ったり、Azure CLI をインストールしたりと地味に面倒です。

Docker Image のタグは固定する

Webhook は設定を切り替えてくれることはないので、バージョン管理がそのままでは行えません。

仕方ないので App Service on Linux で Docker Hub や Azure Container Registry を使った CD を行う場合には、バージョン別のタグと latest を含めるようにします。

f:id:shiba-yan:20170918162741p:plain

docker tag を使ってやれば良いだけです。バージョン別のタグを持っているので、最悪戻したい時には latest に push し直したり、設定からイメージを指定し直せばよいかなと思います。

latest タグを含めているので、App Service on Linux の設定からは latest を選びます。

f:id:shiba-yan:20170918161714p:plain

この状態で Continuous Deployment を On にすると Azure Container Registry に Webhook が作成されます。今は Web App 名にハイフンが含まれていると上手く作成されないらしいので注意。

スコープとして Web App に設定した Image と Tag の組み合わせが設定されます。

f:id:shiba-yan:20170918161906p:plain

この状態で latest タグに対してプッシュすると Webhook が実行されて、App Service on Linux 側でコンテナが再起動されます。このときに新しいイメージが pull されます。

f:id:shiba-yan:20170918162836p:plain

なのでしばらくすると新しいコンテナが起動してデプロイが完了するという仕組みです。サービスプリンシパルや Azure CLI が必要なくなったので便利ですが、固定のイメージしか使えないのは少し不便だと思います。

Webhook のペイロードにはイメージの情報が含まれているので、それを読み取ってくれると思っていましたが当てが外れました。フィードバックしていきたい部分です。