砂漠の旅人(たびと)

UNIX / MS-DOS 時代から電脳砂漠を旅しています

【Docker編】.NET 6 で gRPC を作って、IP アドレスで通信する

こんにちは、たびとです。

前々回 Windows 版、前回 Linux 版と作ってきましたが、今回は Docker で作ります。 プログラムの内容は以前と同じで、Docker 用の構築方法だけが異なります。

前々回の Windows 版はこちらから。

sabakunotabito.hatenablog.com

前回の Linux 版はこちらから。

sabakunotabito.hatenablog.com

この記事の対象者

  • gRPC により HTTP/2 通信 (現在 HTTP/3 はプレビュー機能) アプリを作ってみたい。
  • WCF アプリを作ったことがあるが、gRPC アプリはまだ作ったことがない。

docker 準備

ここでは、WSL2/Ubuntu 上にインストールした docker を用いています。 詳細は、以前の記事を参考にしてください。

sabakunotabito.hatenablog.com

gRPC サーバ

ソースコードは前回の Linux 版と同じですが、launchSettings.json は本番用の資材には含まれないため、編集は不要です。 代わりに Dockerfile 内で指定します。

dokcer ディレクトリ構成

今回は、greeter ディレクトリ配下に docker 資材を配置していきます。

$ mkdir -p greeter/src/GrpcGreeter
$ cd greeter
$ touch docker-compose.yml
$ cd src
$ touch Dockerfile
$ cd GrpcGreeter
$ dotnet new grpc
$ dotnet add package NLog.Web.AspNetCore
$ touch nlog.config

証明書作成

前回、作らなかった証明書を作ります。 証明書がないと、ASPNETCORE_URLS に https を指定するとエラーになって、 gRPC が起動しなくなり、docker コンテナも起動しなくなります。

証明書は、greeter ディレクトリ直下に作成します。 docker-compose.yml があるディレクトリに移動してください。

まずは、秘密鍵のファイルを作ります。

$ openssl genrsa 2048 > ssl_greeter.key
Generating RSA private key, 2048 bit long modulus (2 primes)
...............................+++++
.........................................+++++
e is 65537 (0x010001)

次に、証明書発行要求ファイルを作ります。 適当に入力してください。面倒であれば、全て Enter キーのみでも構いません。

$ openssl req -new -key ssl_greeter.key -out ssl_greeter.csr
You are about to be asked to enter information that will be incorporated
into your certificate request.
What you are about to enter is what is called a Distinguished Name or a DN.
There are quite a few fields but you can leave some blank
For some fields there will be a default value,
If you enter '.', the field will be left blank.
-----
Country Name (2 letter code) [AU]:
State or Province Name (full name) [Some-State]:
Locality Name (eg, city) []:
Organization Name (eg, company) [Internet Widgits Pty Ltd]:
Organizational Unit Name (eg, section) []:
Common Name (e.g. server FQDN or YOUR name) []:
Email Address []:

Please enter the following 'extra' attributes
to be sent with your certificate request
A challenge password []:
An optional company name []:

証明書を発行します。

$ openssl x509 -days 3650 -in ssl_greeter.csr -req -signkey ssl_greeter.key -out ssl_greeter.crt
Certificate request self-signature ok
subject=C = AU, ST = Some-State, O = Internet Widgits Pty Ltd
$ ls
docker-compose.yml  src  ssl_greeter.crt  ssl_greeter.csr  ssl_greeter.key

gRPC では、ssl_greeter.key と ssl_greeter.crt ファイルを利用します。

ソースコード

前回同様に Visual Studio Code で作成していきます。 greeter 直下で code コマンドを実行すると、Visual Studio Code が起動します。

$ code .

docker-compose.yml を編集します。 gRPC のポートは http が 5094、https が 7094 とします。 コンテナ内部では、http=80・https=443 とします。

version: '3.8'
services:
  #gRPC (ASP.NET Core)
  app:
    container_name: 'greeter'
    build:
      context: ./src
      dockerfile: Dockerfile
    ports:
      - "5094:80"
      - "7094:443"
    environment:
      TZ: Asia/Tokyo
    volumes:
      - ./ssl_greeter.crt:/etc/ssl/certs/ssl_greeter.crt
      - ./ssl_greeter.key:/etc/ssl/private/ssl_greeter.key
    tty: true
    restart: always
    stdin_open: true

Dockerfile を編集します。 Microsoft の公式サイトに掲載されていた方法は、 構築するときは、sdk:xxxSDK イメージを使ってビルドし、 リリース時は、aspnet:xxx と Runtime イメージを使って軽量化するようです。 これは、そのサンプルをベースに作成しています。

ASPNETCORE_URLS でポートを指定します。 このサーバは gRPC のみなので、80番と 443番の標準ポートを使います。

証明書がない場合、エラーになって gRPC サーバが起動しないため、 ;https://+:443 の箇所は削除してください。

# 本番用(.NET6 runtime)
FROM mcr.microsoft.com/dotnet/aspnet:6.0-focal AS runtime
ENV ASPNETCORE_URLS=http://+:80;https://+:443
EXPOSE 80
EXPOSE 443

# 構築用(sdk)
FROM mcr.microsoft.com/dotnet/sdk:6.0-focal AS build
ARG DOTNET_SYSTEM_NET_HTTP_USESOCKETSHTTPHANDLER=0
WORKDIR /src
COPY ["./GrpcGreeter/GrpcGreeter.csproj", "GrpcGreeter/"]
RUN dotnet restore "./GrpcGreeter/GrpcGreeter.csproj"
COPY . .
RUN dotnet build "./GrpcGreeter/GrpcGreeter.csproj" -c Release -o /app/build

FROM build AS publish
WORKDIR /src
RUN dotnet publish "./GrpcGreeter/GrpcGreeter.csproj" -c Releasse -o /app/publish

# 本番用(runtime)
FROM runtime AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "GrpcGreeter.dll"]

appsettings.json を編集します。 今回作った証明書を Certificates で指定します。

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*",
  "Kestrel": {
    "EndpointDefaults": {
      "Protocols": "Http2"
    },
    "Certificates": {
      "Default": {
        "Path": "/etc/ssl/certs/ssl_greeter.crt",
        "KeyPath": "/etc/ssl/private/ssl_greeter.key"
      }
    }
  }
}

後は、前回と同様です。

GrpcGreeter.csproj を編集します。

<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>net6.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>

  <ItemGroup>
    <None Include="nlog.config">
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
    </None>
  </ItemGroup>

  <ItemGroup>
    <Protobuf Include="Protos\greet.proto" GrpcServices="Server" />
  </ItemGroup>

  <ItemGroup>
    <PackageReference Include="Grpc.AspNetCore" Version="2.40.0" />
    <PackageReference Include="NLog.Web.AspNetCore" Version="5.1.0" />
  </ItemGroup>

</Project>

nlog.config を編集します。tcpOutlet の IP アドレスは、各自 Windows の IP アドレスを指定してください。

<?xml version="1.0" encoding="utf-8" ?>
<nlog xmlns="http://www.nlog-project.org/schemas/NLog.xsd"
      xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
      autoReload="true"
      internalLogLevel="Info"
      internalLogFile="./logs/internal-nlog-gRPC.log">
    <extensions>
        <add assembly="NLog.Web.AspNetCore"/>
    </extensions>
    <targets>
        <!-- File -->
        <target name="logFile"
                xsi:type="File"
                encoding="UTF-8"
                writeBom="true"
                lineEnding="LF"
                layout="${longdate} ${level:uppercase=true:padding=-5} [${threadid}] ${logger} - ${message} ${exception:format=tostring}"
                fileName="${basedir}/logs/grpcgreeter.log"
                archiveFileName="${basedir}/logs/backup/grpcgreeter_{###}.log"
                archiveEvery="Day"
                archiveNumbering="Sequence"
                maxArchiveFiles="10" />
        <!-- Console -->
        <target name="console" xsi:type="ColoredConsole" layout="${level:uppercase=true:padding=-5}: ${message}" />

        <!-- Log2Console: WSLのアドレスではなく、通常のIPアドレスを指定すること! -->
        <target name="tcpOutlet" xsi:type="NLogViewer" address="tcp4://172.18.149.227:4505"/>
    </targets>
    <!-- rules to map from logger name to target -->
    <rules>
        <logger name="*" minlevel="Trace" writeTo="logFile" />
        <logger name="*" minlevel="Trace" writeTo="console" />
        <logger name="*" minlevel="Trace" writeTo="tcpOutlet" />
    </rules>
</nlog>

Program.cs を編集します。

using GrpcGreeter.Services;
using NLog;
using NLog.Web;

var _logger = NLog.LogManager.Setup().LoadConfigurationFromAppSettings().GetCurrentClassLogger();
_logger.Debug("init main");

try
{
    var builder = WebApplication.CreateBuilder(args);

    // Additional configuration is required to successfully run gRPC on macOS.
    // For instructions on how to configure Kestrel and gRPC clients on macOS, visit https://go.microsoft.com/fwlink/?linkid=2099682

    // NLog: Setup NLog for Dependency injection
    builder.Logging.ClearProviders();
    builder.Host.UseNLog();

    // Add services to the container.
    builder.Services.AddGrpc();

    var app = builder.Build();

    // Configure the HTTP request pipeline.
    app.MapGrpcService<GreeterService>();
    app.MapGet("/", () => "Communication with gRPC endpoints must be made through a gRPC client. To learn how to create a client, visit: https://go.microsoft.com/fwlink/?linkid=2086909");

    app.Run();
}
catch (Exception ex)
{
    // NLog: catch setup errors
    _logger.Error(ex, "Stopped program because of exception");
    throw;
}
finally
{
    // Ensure to flush and stop internal timers/threads before application-exit (Avoid segmentation fault on Linux)
    NLog.LogManager.Shutdown();
}

ビルド&実行

Docker 資材をビルドします。

$ docker-compose build

ビルドが正常終了したら、起動します。

$ docker-compose up -d

コンテナが正常に動作していることを確認します。

$ docker ps
CONTAINER ID   IMAGE          COMMAND                  CREATED         STATUS         PORTS                                                                            NAMES
c7fd83c99fd1   greeter_grpc   "dotnet GrpcGreeter.…"   3 seconds ago   Up 3 seconds   0.0.0.0:5094->80/tcp, :::5094->80/tcp, 0.0.0.0:7094->443/tcp, :::7094->443/tcp   grpc

docker-compose logs コマンドでログを確認します。

$ docker-compose logs
grpc  | DEBUG: init main
grpc  | TRACE: Discovering gRPC methods for GrpcGreeter.Services.GreeterService.
grpc  | TRACE: Added gRPC method 'SayHello' to service 'greet.Greeter'. Method type: 'Unary', route pattern: '/greet.Greeter/SayHello'.
grpc  | DEBUG: Hosting starting
grpc  | INFO : Now listening on: http://[::]:80
grpc  | DEBUG: Loaded hosting startup assembly GrpcGreeter
grpc  | INFO : Application started. Press Ctrl+C to shut down.
grpc  | INFO : Hosting environment: Production
grpc  | INFO : Content root path: /app/
grpc  | DEBUG: Hosting started

Log2Console を起動しているなら、以下のメッセージが表示されて正常に起動していることが確認できます。

gRPC サーバの正常起動

docker-compose exec コマンドで、コンテナ内に乗り込んで、ログを調べても構いません。

$ docker-compose exec app /bin/bash
または
$ docker-compose exec app bash
# ls
Google.Protobuf.dll                       GrpcGreeter                     NLog.dll
Grpc.AspNetCore.Server.ClientFactory.dll  GrpcGreeter.deps.json           appsettings.Development.json
Grpc.AspNetCore.Server.dll                GrpcGreeter.dll                 appsettings.json
Grpc.Core.Api.dll                         GrpcGreeter.pdb                 logs
Grpc.Net.Client.dll                       GrpcGreeter.runtimeconfig.json  nlog.config
Grpc.Net.ClientFactory.dll                NLog.Extensions.Logging.dll     web.config
Grpc.Net.Common.dll                       NLog.Web.AspNetCore.dll

正しく起動しなかったり作り変えるときは、 docker-compose down コマンドを指定して最初から作り直します。

$ docker-compose down

gRPC クライアント

前々回、Windows 版で使ったものを利用します。 作り方の詳細は、前々回の記事を見てください。

sabakunotabito.hatenablog.com

Visual Studio 2022 でコンソールアプリを GrpcGreeterClinet の名前で作成します。 次に NuGet で以下のパッケージを追加します。

  • Google.Protobuf
  • Grpc.Net.Client
  • Grpc.Tools
  • NLog

nlog.config を追加し、プロパティの出力ディレクトリにコピーを「新しい場合はコピーする」に変更します。

事前準備

docker を作成した Ubuntu の IP アドレスを取得します。

$ ip a show dev eth0
6: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc mq state UP group default qlen 1000
    link/ether 00:15:5d:88:c1:b8 brd ff:ff:ff:ff:ff:ff
    inet 172.18.126.178/20 brd 172.18.127.255 scope global eth0
       valid_lft forever preferred_lft forever
    inet6 fe80::215:5dff:fe88:c1b8/64 scope link
       valid_lft forever preferred_lft forever

ソースコード

Program.cs を編集します。 IP アドレスを Ubuntu の IP アドレス 172.18.126.178 、ポートを 7094 に変更します。

using System.Net.Http;
using System.Threading.Tasks;
using Grpc.Net.Client;
using GrpcGreeterClient;
using NLog;

var _logger = LogManager.GetCurrentClassLogger();

var server = "https://172.18.126.178:7094";
_logger.Info(server);

try
{
    var option = new GrpcChannelOptions()
    {
        HttpClient = new HttpClient(new HttpClientHandler
        {
            // SSL証明書の検証で常に True を返す
            ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator,
        })
    };
    var channel = GrpcChannel.ForAddress(server, option);
    var client = new Greeter.GreeterClient(channel);

    var response = await client.SayHelloAsync(new HelloRequest { Name = "World" });

    _logger.Info(response.Message);
}
catch (Exception ex)
{
    _logger.Error(ex);
}

GrpcGreeterClinet.csproj を編集します。 GrpcServices="Client" に変更することを忘れないでください。

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net6.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

  <ItemGroup>
    <Protobuf Include="Protos\greet.proto" GrpcServices="Client" />
  </ItemGroup>

  <ItemGroup>
    <PackageReference Include="Google.Protobuf" Version="3.21.5" />
    <PackageReference Include="Grpc.Net.Client" Version="2.47.0" />
    <PackageReference Include="Grpc.Tools" Version="2.47.0">
      <PrivateAssets>all</PrivateAssets>
      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
    </PackageReference>
    <PackageReference Include="NLog" Version="5.0.2" />
  </ItemGroup>

  <ItemGroup>
    <Folder Include="Protos\" />
  </ItemGroup>

  <ItemGroup>
    <None Update="nlog.config">
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
    </None>
  </ItemGroup>

</Project>

nlog.config を編集します。

<?xml version="1.0" encoding="utf-8" ?>
<nlog xmlns="http://www.nlog-project.org/schemas/NLog.xsd"
      xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
      autoReload="true"
      throwExceptions="false"
      internalLogLevel="Info"
      internalLogFile="../logs/internal-nlog-Console.log">
    <targets>
        <!-- File -->
        <target name="logFile"
                xsi:type="File"
                encoding="UTF-8"
                writeBom="true"
                lineEnding="CRLF"
                layout="${longdate} ${level:uppercase=true:padding=-5} [${threadid}] ${logger} - ${message} ${exception:format=tostring}"
                fileName="../logs/${processname}.log"
                archiveFileName="../logs/backup/${processname}_{###}.log"
                archiveEvery="Day"
                archiveNumbering="Sequence"
                maxArchiveFiles="10" />

        <!-- Console -->
        <target name="console" xsi:type="ColoredConsole" layout="${level:uppercase=true:padding=-5}: ${message}" />

        <!-- Log2Console -->
        <target name="tcpOutlet" xsi:type="NLogViewer" address="tcp4://localhost:4505"/>
    </targets>

    <rules>
        <logger name="*" minlevel="Debug" writeTo="logFile" />
        <logger name="*" minlevel="Trace" writeTo="console" />
        <logger name="*" minlevel="Trace" writeTo="tcpOutlet" />
    </rules>
</nlog>

greet.proto を編集します。csharp_namespace を今回作ったプロジェクト名に変更します。

syntax = "proto3";

option csharp_namespace = "GrpcGreeterClient";

package greet;

// The greeting service definition.
service Greeter {
  // Sends a greeting
  rpc SayHello (HelloRequest) returns (HelloReply);
}

// The request message containing the user's name.
message HelloRequest {
  string name = 1;
}

// The response message containing the greetings.
message HelloReply {
  string message = 1;
}

ビルド&実行

実行結果は Windows 版と同様なので、Log2Console の結果を掲載します。 Hello World が最終行に表示されています。 ただし、Windows を再起動したので、UbuntuIPアドレスは変更されています。

gRPC クライアントの正常終了

まとめ

今回は、WSL2/Ubuntu で作った gRPC サーバを docker で実現しました。 前回の課題だった証明書も自作証明書を作って使いました。

前回まで、IP アドレスでアクセスするには、launchSettings.json localhost を0.0.0.0 に置き換える 必要がありましたが、 docker 版では、環境変数 ASPNETCORE_URLS に http://+:80(443) のように 特別なことをしなくても、IPアドレスからアクセスすることができました。

docker-compose.yml と Dockerfile を使って作ると、 どちらに定義した方がいいのか悩むことがありますが、 両方に定義してみて、どちらでも動くようにすると理解が深まると思います。

続編として、Nginx サーバによるリバースプロキシを追加しました。

sabakunotabito.hatenablog.com

最後に参考サイトを掲載しておきます。 では、皆さん、よい旅を。

参考サイト