Training in Commercial Pilotage with FTD/FFS - Constant-Rate-and-Speed Climb / Descent, Lead Design for Level Off

羽田空港内某所で Flight Simulator による F/O 訓練。B737-Max で 2.0H。本日は N 教官。

5 ヶ月ぶりの F/O 訓練。ブランクが長い上に、この間イメトレをまったくやっていない。さすがに技量は低下しているだろうと覚悟して訓練に臨む。

Acceleration / Deceleration

f:id:Crayon:20210410151738j:plain:right:w320まずはいつもどおり RJTT RWY 34R から 6,000 ft 240 kt を目指す。きっとへたくそだろうと思っていたら、Take Off Climb は Pitch 20°、所定 A/S 165 kt, V/S 4,500 ft/min で 4,000 ft まで安定して昇る。4,000 ft で教官が中座し自主練習になると A/S は所定 +6 kt オーバー, V/S 500-1,000 ft/min くらいになりペースが狂い始め、6,000 ft Level Off も +7-8 kt オーバー。一回 Power Balance が整ってしまうと A/S と Alt が安定してしまい Power 調整が難しい。1kt = 1% Power で調整するという B737 初回操縦時にいただいた助言を遅まきながら思い出し、調整するもラスト 1,000 ft から Stabilize まで 3 分半もかかってしまった。まあまあ、こんなもんでしょ、と教官に言われ、ブランク明けのリハビリ科目 Acceleration / Deceleration へ。

240 kt から 200 kt へ落とす。やはり A/S がズレているままで Power Balance が整ってしまい調整に難航する。速度オーバーから指定諸元に合わせるのが難しい。また 200 kt くらいだと操作性は悪くならないと思いきや、予想外に操作性が悪い。何回か 240 kt と 200 kt を行き来した後、140 kt まで下げる。

操縦に慣れてきたのと、元々 Slow Flight が得意だったことがあり、操縦性が極めて悪いはずの 140 kt でもまあ安定してコントロールする。

Constant-Rate-and-Speed Climb / Descent

次に Constant-Rate-and-Speed Climb / Descent。240 kt を維持したまま 1,000 ft Climb / Descent を V/S Rate = 500 ft/min 維持、750 ft/min 維持、1,000 ft/min 維持 ... とやっていく (本日は 750 ft/min まで)。 まあまあ安定してできていると思ったのだが、教官曰く「では、計時してやってみましょう」。500 ft/min なら 2 分。2 分きっちりで到達できるかというタイムトライアル。500 ft/min ならば動きが穏やかなため「1 分経過時点で 400 ft 移動 = 100 ft 遅行だな」と冷静に考えている余裕があるし、Level Off もうまくいった (... が、HDG xxx へ転針してなどとストレスを掛けられたらグダグダになっていく可能性は大いにある)。

ここで初対面の教官へ交代してタイムトライアルを継続。自衛隊 C-1 哨戒機の現役パイロットらしい。Constant-Rate-and-Speed Climb / Descent を継続して訓練。この中で教わった手順は "本日の重要ポイント" の通り。また Idle Descent にしてもオーバースピードで定速になってしまう場合の Constant-Rate-and-Speed Descent のやり方として、一旦 Descent Rate 維持から離れ、Pitch を上げて A/S を殺してから、また Rate を維持する、という方法を教わった。

最後に振り返り。Climb / Descent の開始と Level Off のときの操作が決定的に弱い。これが粗いと Stabilize せず、長くかかる。技量を高く見せるコツは Pitch 操作の開始・終了の繊細さなのではないかと思った。

本日の重要ポイント

Climb / Descent の手順
  • Climb / Descent の開始と Level Off を繊細に Pitch 急操作厳禁!
  • Climb / Descent の初動は Power ではなく Pitch *1
  • 最初に Pitch 目標を意識し、そこに向けて緩やかに持っていく
  • Pitch の目標が定まってから A/S のトレンドを見て Power を入れる
Constant-Rate-and-Speed Climb / Descent の手順
  • VSI で所望 Climb / Descent Rate を確認し、その時の Pitch を以降ずっと維持する
  • Pitch による Energy Trade を常に行う
  • A/S を定期チェックし Energy Trade で調整できない分を Power で調整する
  • Constant-Rate-and-Speed Climb / Descent の A/S Allowance は ± 3 kt *2
その他 Tips
  • Power Balance が整ってしまうと調整が難しい
    • Energy Trade をうまく利用し、無駄な Power 調整をしない 調整は常に Pitch が先、Power が後
    • Power がズレたまま Level Flight で安定してしまった場合は 1 kt = 1% Power を目安に微調整する
  • Constant-Rate-and-Speed Descent で増速してしまったら、一旦 Descent Rate 維持から離れ、Pitch を上げて A/S 調整してから、また Rate を維持する

*1:レシプロ小型機の実機操縦経験者がまず Power を操作してしまうというのは Private Pilot のあるある失敗。

*2:Allowance を逸脱すると即 fail ではなく、収めようとする修正意識を無くすと fail。

Functional Programming w/ C# LINQ - with 初期化子の IQueryable<T> 対応版代替簡易記法

C# 9.0 / .NET 5.0 に長らく期待していたことの1つに record の with 初期化子がある。これは record の一部のプロパティを異なる値に置き換えるためのもので y = x with { Value = x.Value * 10 } と書くと x の Value プロパティだけを 10 倍にしたオブジェクトを新規生成して y に代入してくれる。

ところが .NET 5.0 で使ってみると、この with 初期化子が式木にならず、したがって LINQ to Entities で機能しない。with 初期化子は式のように見えてコンパイラ内部では .Clone() してから指定されたプロパティを上書きしているため、文 (手続) 扱いになっている。

この with 初期化子を式にしろと C# 開発チームに改良を提案するも却下される。一方で、C# 開発チームのメンバー曰く、式木になる x = new MyRecord { Value = 1 } というオブジェクト初期化子も 0 埋めデータで class / record の側を作ってからプロパティを1つ1つ上書き代入していると。おいおい同じ手順を踏んでいるのに片方が式になりもう片方が式にならないのは非対称でおかしいじゃないか、と食い下がったものの、醜い仕様であることを認めた (、でもたぶん彼らはもっと優先度の高い課題をたくさん抱えている) ため、議論はそこでおしまいにした。

しかし、with 初期化子は、本来、データ加工を頻繁に繰り返す LINQ to Entities でこそ活きるはずの生産性向上ツールだ。これがあると置換対象ではないプロパティに値代入する記述を省ける。オンメモリでオブジェクトを扱う LINQ to Object よりも DB を扱う LINQ to Entities の方がプロパティ (カラム) 数が断然多い。LINQ to Object ならば .Clone() をちょっと加工すれば with と同等のことが簡単に実現できる。with 初期化子が LINQ to Object でのみ有効というのは趣旨からいって本末転倒だ。

そこで LINQ to Entities で利用可能な代替手段を作ることに挑戦してみた *1

目標

LINQ to Entities (IQueryable<T>) で

  .Select( x => x with { Value = x.Value * 10 } ) 

と書いても、コンパイルエラー "error CS8849: An expression tree may not contain a with-expression." が出て機能しないため

  .SelectWith( x => new { Value = x.Value * 10 } ) 

と書くと同等の効果を得られるようにする。

with 初期化子の IQueryable<T> 対応版代替簡易記法

以前C# 8.0 以前用に with 初期化子相当となる .Clone() メソッドを定義した。これは LINQ to Object (IEnumerable<T>) にしか対応しておらず LINQ to Entities では機能しないが、まったく同じコンセプトを IQueryable<T> へ応用して、シーケンスの各オブジェクトの一部プロパティを置換する .SelectWith() という LINQ メソッドを定義する。

LINQ to Object を対象とした前回は実行効率の観点から式木にしたが、LINQ to Entities を対象とする今回はクエリプロバイダが解釈・実行可能な形での式木化が必須である。ポイントは以下のとおり。

  • 前回の .Clone() メソッド定義から引き継ぐコンセプト
    • ユーザは、オリジナルのオブジェクトのうち置換したいプロパティのみを含有するよう定義された匿名型オブジェクトを引数に指定する
    • オリジナルをクローンして新しいオブジェクトを生成したうえで、引数に与えられた匿名型のプロパティの名称・型がオリジナルのそれと一致していれば、匿名型オブジェクト側のインスタンス値を採用してプロパティへ代入して返す
    • オリジナルと名称・型が一致していないプロパティを匿名型に指定した場合は、ランタイムエラーとする *2
    • ただし、オリジナル側が匿名オブジェクト側を Nullable 形式にした型になっている、というプロパティはその差異を許容して Nullable 型にキャストして代入する *3
    • 与えられる引数は辞書にキャッシュしておき、同じものが指定された場合は辞書を検索する
  • 今回の進化
    • Null 参照許容型を有効化した記述にしている *4
    • 引数で与えるのは、匿名型オブジェクトそのものではなく、オリジナルのオブジェクトから置換候補となる匿名型オブジェクトを導出するラムダ式

この「式木を用いて、オリジナルのオブジェクトとそれを入力としてラムダ式で導出する置換候補匿名型オブジェクトの双方を参照しつつ、新しいオブジェクトをワンショットで初期化生成する」というところが最大のポイントであり、難所となる *5

#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using System.Reflection;
using System.Runtime.CompilerServices;
using static System.Linq.Expressions.Expression;

public static partial class ExtensionIQueryable {
  /// メンバーバインド・ラムダの辞書
  private static Dictionary<Expression, Expression> DicMemberBind { get; set; } = new();

  ///
  /// 一部のプロパティを置き換える Select
  /// C# 9 with 式が 式木 / IQueryable<T> 非対応のため、代替としての IQueryable<T> 版 Select( x => x with { Prop = ... } )
  ///
  public static IQueryable<T> SelectWith<T, U>(this IQueryable<T> source, Expression<Func<T, U>> lambdaReplace) where T : notnull, new() where U : notnull {
    var original = Parameter(typeof(T), "original");
    var subst    = Invoke(lambdaReplace, original);
    var propT    = typeof(T).GetProperties();
    var propU    = typeof(U).GetProperties();
    var mismatch =
      propU
        .GroupJoin(propT,
                   l      => l.Name,
                   r      => r.Name,
                   (l, r) => new { SubstPropName = l.Name, IsMismatched = ! r.Any() })
          .Where( x => x.IsMismatched )
            .Select( x => x.SubstPropName )
              .ToList();

    if (mismatch.Any()) // 存在しないプロパティの置換を指定された場合
      throw new ArgumentException($"ラムダ式の返り値に置換候補として指定されたオブジェクトの次のプロパティは置換対象に存在しないプロパティです。" +
                                    $"-- {string.Join(", ", mismatch)}");

    if (! DicMemberBind.TryGetValue(lambdaReplace, out var lambda)) {
      var bind =
        propT
          .GroupJoin(propU,
                     l      => l.Name,
                     r      => r.Name,
                     (l, r) => new {
                       Prop  = typeof(T).GetMember(l.Name)[0],
                       Value = (l, r.FirstOrDefault()) switch {
                         (    _, null )                                       => (Expression)        PropertyOrField(original, l.Name),                  // t.Prop =                            t .Prop
                         (var o, var s) when o.PropertyType == s.PropertyType => (Expression)        PropertyOrField(subst   , l.Name),                  // t.Prop =              lambdaReplace(t).Prop
                         (var o, var s) when o.IsNullableOf(s)                => (Expression)Convert(PropertyOrField(subst   , l.Name), l.PropertyType), // t.Prop = (Nullable<>) lambdaReplace(t).Prop
                         (var o, var s)                                       =>
                           throw new ArgumentException($"{o.PropertyType.FullName} 型のプロパティを {s.PropertyType.FullName} 型で置き換えようとしました。"),
                       },
                     })
            .Select( mb => Bind(mb.Prop, mb.Value) ); // IEnumerable<MemberBinding>

      DicMemberBind.Add(lambdaReplace, lambda = Lambda<Func<T, T>>(MemberInit(New(typeof(T)), bind), original));
    }

    return source.Select( (Expression<Func<T, T>>)lambda ); // 辞書に入っている値の型は決まっており常にキャスト可能
  }

  /// あるプロパティの型が別のプロパティの Nullable<> 型であるか判定する
  [MethodImpl(MethodImplOptions.AggressiveInlining)]
  private static bool IsNullableOf(this PropertyInfo o, PropertyInfo s) =>
    (o.PropertyType, s.PropertyType) switch {
      (var ot,      _) when ot.IsGenericType                != true               => false,
      (var ot,      _) when ot.GetGenericTypeDefinition()   != typeof(Nullable<>) => false,
      (var ot, var st) when ot.GenericTypeArguments.First() != st                 => false,
      (     _,      _)                                                            => true , // T = U? の場合
    };
}

IQueryable<T> のラムダ式評価時 (ランタイムでの SQL コンパイル時) に、

  1. 引数に与えた「オリジナルから置換候補となる匿名型オブジェクトを導出するラムダ式」を Expression.Invoke() で評価して置換候補オブジェクトを求め、
  2. 入力と同じ型のオブジェクトを Expression.New() で新規作成し、
  3. オリジナルのプロパティと置換候補のプロパティとを LINQ to Object の .GroupJoin() メソッドを用いて比較して名前と型をチェックし、
  4. 必要としている側のインスタンス値を採用するようにプロパティ・アクセスを Expression.PropertyOrField() で選択する MemberBinding を構築し、
  5. この MemberBinding を使って新規作成オブジェクトのメンバーを Expression.MemberInit() で初期化して返す、というラムダ式を導出し、
  6. このラムダ式によって求めるべきオブジェクトを生成して出力する

という手順を踏んでいる。


このように使う。

var context = new MyDbContext(); // DB アクセス用クラス (ユーザ定義クラス)
var query   =
  context.MarketData
    .Where( x => x.TickerCode == "NKY" && x.Indicator == "Volume" )
      .Take(5)
        .SelectWith( x => new { Value = Math.Floor(x.Value * 10.0 ?? 0.0) } );

Console.WriteLine(query.ToQueryString());  // DB への発行 SQL 文を表示
query.Display();                           // SQL 実行結果をクライアント側に持ってきて表示 (ユーザ定義メソッド)


出力結果は下記のとおり。(裏で動かしている DB は PostgreSQL。)

-- @__p_0='5'
SELECT b.tickercode AS "TickerCode", b.indicator AS "Indicator", b.basedate AS "BaseDate", floor(COALESCE(b.value * 10.0, 0.0)) AS "Value"
FROM marcketdata AS b
WHERE (b.tickercode = 'NKY') AND (b.indicator = 'Price')
LIMIT @__p_0
MarketData { TickerCode = NKY, Indicator = Volume, BaseDate = 07/01/2011 00:00:00, Value = 1115 }
MarketData { TickerCode = NKY, Indicator = Volume, BaseDate = 07/04/2011 00:00:00, Value = 1313 }
MarketData { TickerCode = NKY, Indicator = Volume, BaseDate = 07/05/2011 00:00:00, Value = 1325 }
MarketData { TickerCode = NKY, Indicator = Volume, BaseDate = 07/06/2011 00:00:00, Value = 1377 }
MarketData { TickerCode = NKY, Indicator = Volume, BaseDate = 07/07/2011 00:00:00, Value = 1331 }

オリジナルの Value は double? 型 (not null 制約なし) で 111.59, 131.38, 132.58 ... と小数点以下第2位までの値が格納されている。これが .SelectWith() メソッドで 10 倍されて小数点以下切り捨てとなって出力される。ポイントは C# のメソッド Math.Floor() や演算子 ?? がきちんと DB 関数の floor() や coalesce() に翻訳されていることと、double 入力・double 出力である Math.Floor() が、引数においては double? 型の Value を ?? を用いて明示的に double 型に強制する必要がある一方、Value への代入においては返値を明示的に double? へ型変換する必要はないということ。

また、プロパティの名や型が合致しないと以下のようなランタイムエラーとなる。

// ランタイムエラー「double? 型のプロパティを string 型で置換しようとしました」となる
.SelectWith( x => new { Value = "" + Math.Floor(x.Value * 10.0 ?? 0.0) } ) 

// ランタイムエラー「ラムダ式の返り値に置換候補として指定されたオブジェクトの次のプロパティは置換対象に存在しないプロパティです -- Value2」となる
.SelectWith( x => new { Value2 = Math.Floor(x.Value * 10.0 ?? 0.0) } )

代替簡易記法の限界

定義した .SelectWith() は

var multiplier = 10;

DataRecords
  .Select( x => x with { Value = x.Value * multiplier } );

のように、生成したいオブジェクト自体のプロパティやシーケンス内で不変の変数や定数を参照して取り込む分には問題がないが、

// 例 1. ブジェクトを新規生成してそのシーケンスを返すにあたり、別タイプのシーケンス Calendar のプロパティを取り込みたい
Calendar
  .Select( c => new DataRecord() with { Date = c.Date } );

// 例 2. オブジェクトをプロパティ修正したシーケンスを返すにあたり、別タイプのシーケンス Calendar のプロパティを取り込みたい
Calendar
  .Join(DataRecords,
        l      => l.Date,
        r      => r.Date,
        (l, r) => r with { IsBusinessDay = l.IsBusinessDay });

という (シーケンス内で変化する) 外部変数を巻き込むクロージャー的な使い方には対応できない。

with に絡む部分のラムダ式を返す式木を作成すればクロージャー的な用途にも対応できる *6 が、巻き込む外部変数の数や位置に応じてラムダ式を作成しなければならないため、汎用性がない。やはりコンパイラが with を手続ではなく式として解釈して MemberBinding & MemberInit して IQueryable 対応してくれるのが望ましい。

*1:LINQ to Object では動くが LINQ to Entities では動かないというありがちな事象で苦しむこと 1 ヶ月。さきの .LeftJoin() 簡易記法を定義する際に解決法を発見した。LINQ to Object で動き LINQ to Entities で動かなかったのは、Expression.Block() を用いて式木構築してしまったため。

*2:置換候補となる匿名型のプロパティをミススペルしている可能性を警告するため。

*3:double? Value というプロパティに対する代入式では、new { Value = 1.0 } が NG となり、new { Value = (double?) 1.0 } と冗長に記述しなければならなくなるため。

*4:より厳しい型チェックでも通るように記述した。

*5:何もないところから構築するのはかなり試行錯誤を要する難しいパズルだったが、できたものを眺めると大したことないように見える。コロンブスの卵。

*6:例1 は実現可能であることを確認済み。

Functional Programming w/ C# LINQ - 左結合 Left Join の簡易記法

C# 9.0, .NET 5.0, .Entity Framework Core 5.0 を使って数ヶ月。当初抱いていた LINQ to Entities の機能向上への期待は打ち砕かれたが、それでも EF 6.0 や EF Core 2.0 に比べたら使いやすくなっているような気がする。本格的に使うにあたり、少し工夫をする。

左結合 Left Join の簡易記法 (syntax sugar)

LINQ to Entities での左結合は .GroupJoin() 1つでは済まず、 .GroupJoin() と .SelectMany() の合わせ技になるのだが、読みづらいし、書きづらい。

TableA
  .GroupJoin(TableB,
             l      => new { JoinKey1 = l.JoinKey1, ... },
             r      => new { JoinKey1 = r.JoinKey1, ... },
             (l, r) => new { Left = l, Right = r })
    .SelectMany(x => x.Right.DefaultIfEmpty(),    // .SelectMany() を駆り出した2段構成が美しくない
                (l, r) => new ResultRecord {
                  Prop1 = l.Left.Prop1,               // 結果作成時に左だけ .Left プロパティ・アクセスする非対称性が美しくない
                  Prop2 = l.Left.Prop2,
                  Prop3 = r == null ? null : r.Prop3, // r?.Prop3 は式木非対応
                } );

内部結合 .Join() と違って左結合 .GroupJoin() は第5引数の resultSelector ラムダ式内で右テーブル・レコードを要素ではなくシーケンスで扱うことになるため、プロパティ・アクセスが左右非対称となって美しくなく、また、ハンドリングがけっこう面倒である。右側テーブル側に結合対象がなかったレコードは null になるわけで、.Any(), .FirstOrDefault(), null 伝搬演算子 (?.), null 合体演算子 (??), 三項演算子 (? :) を多用する *1 ことになる上に、LINQ to Object では機能するものの式木にできず、LINQ to Entities で機能させるためにさらに冗長に書き換えなければならない表現 *2*3 も頻繁にある。今後、null 許容参照型を有効にすること (#nullable enable) が推奨されるようになると、null に関してエラーや警告が多発し、この null ハンドリングと書き換え問題がさらに面倒になるのは間違いない。


そこで、この際、Syntax Sugar として左結合の簡略記法 .LeftJoin() を定義してみることにする。

#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using static System.Linq.Expressions.Expression;

public static partial class ExtensionIQueryable {
  /// 2要素参照ラムダの辞書
  private static Dictionary<Expression, Expression> DicTwoFactor { get; set; } = new();

  ///
  /// Left Join 簡略記法用ヘルパーレコード
  ///
  private record LeftJoinHelperRecord<T, U> where T : notnull, new() where U : new() {
    public T  Left  { get; init; } = new T();
    public U? Right { get; init; } = default;
  }

  ///
  /// Left Join 簡略記法
  ///
  public static IQueryable<V> LeftJoin<T, U, V>(this IQueryable<T> leftSource, IQueryable<U> rightSource,
                                                Expression<Func<T, object>> leftKeySelector, Expression<Func<U, object>> rightKeySelector,
                                                Expression<Func<T, U?, V>> resultGenerator)
    where T : notnull, new() where U : new() where V : class {
      if (! DicTwoFactor.TryGetValue(resultGenerator, out var lambda)) {
        var typeH   = typeof(LeftJoinHelperRecord<T, U>);
        var objectH = Parameter(typeH, "LeftJoinHelperRecord<T, U>");

        DicTwoFactor
          .Add(resultGenerator,
               lambda = Lambda<Func<LeftJoinHelperRecord<T, U>, V>>(
                 Invoke(resultGenerator, PropertyOrField(objectH, "Left"), PropertyOrField(objectH, "Right")),
                 objectH));
      }

      var lambdaGenerator = (Expression<Func<LeftJoinHelperRecord<T, U>, V>>)lambda; // 辞書に入っている値の型は決まっており常にキャスト可能

      return
        leftSource
          .GroupJoin(rightSource, leftKeySelector, rightKeySelector, (l, r) => new { Left = l, Right = r })
            .SelectMany(x => x.Right.DefaultIfEmpty(), (l, r) => new LeftJoinHelperRecord<T, U> { Left = l.Left, Right = r })
              .Select(lambdaGenerator); // (l, r) => new { l.Prop ... r.Prop ... }
    }
}


こんな使い方をする。

var context = new MyDbContext(); // DB アクセス用クラス (ユーザ定義クラス)
var query   =
  context.MarketData
    .Where( x => x.TickerCode == "NKY" && x.Indicator == "Price" )
      .Take(5)
        .LeftJoin(context.TickerMaster,
          l      => l.TickerCode,
          r      => r.TickerCode,
          (l, r) => new {
            TickerCode = l.TickerCode,
            Indicator  = l.Indicator,
            BaseDate   = l.BaseDate,
            Value      = l.Value,
            Note       = r.Note, // 1要素の結合 (r != null が既知の場合 ... マスタを左結合してタグ付けをするときなど)
            Relation   = r,      // 結合対象の右レコード全体への参照
          });

Console.WriteLine(query.ToQueryString());  // DB への発行 SQL 文を表示
query.Display();                           // SQL 実行結果をクライアント側に持ってきて表示 (ユーザ定義メソッド)


出力結果は下記のとおり。(裏で動かしている DB は PostgreSQL。)

Note のように結合対象右レコード内の1要素を取り込むこともできるし、(Entity Data Model 上は) Relation のように結合対象の右レコード全体への参照として保持することもできる *4。発行 SQL 文をロガーを通さずに .ToQueryString() として参照できるようになったのは EF Core 5.0 での機能向上の1つ。

-- @__p_0='5'
SELECT t.tickercode AS "TickerCode", t.indicator AS "Indicator", t.basedate AS "BaseDate",
       t.value AS "Value", b0.note AS "Note", b0.tickercode
FROM (
    SELECT b.tickercode, b.indicator, b.basedate, b.value
    FROM marketdata AS b
    WHERE (b.tickercode = 'NKY') AND (b.indicator = 'Price')
    LIMIT @__p_0
) AS t
LEFT JOIN tickermaster AS b0 ON t.tickercode = b0.tickercode
{ TickerCode = NKY, Indicator = Price, BaseDate = 04/03/2001 00:00:00, Value = 13124.47, Note = 日経 225, Relation = TickerMaster { TickerCode = NKY, Note = 日経 225 } }
{ TickerCode = NKY, Indicator = Price, BaseDate = 04/02/2001 00:00:00, Value = 12937.86, Note = 日経 225, Relation = TickerMaster { TickerCode = NKY, Note = 日経 225 } }
{ TickerCode = NKY, Indicator = Price, BaseDate = 03/30/2001 00:00:00, Value = 12999.70, Note = 日経 225, Relation = TickerMaster { TickerCode = NKY, Note = 日経 225 } }
{ TickerCode = NKY, Indicator = Price, BaseDate = 03/29/2001 00:00:00, Value = 13072.36, Note = 日経 225, Relation = TickerMaster { TickerCode = NKY, Note = 日経 225 } }
{ TickerCode = NKY, Indicator = Price, BaseDate = 03/28/2001 00:00:00, Value = 13765.51, Note = 日経 225, Relation = TickerMaster { TickerCode = NKY, Note = 日経 225 } }

*1:null 合体演算子 (??) は式木になるが null 伝搬演算子 (?.) はならない。そこで .Any() や三項演算子 (? :) の出番が増え、冗長になる。

*2:状態遷移を伴う switch 式を式木化できないのは仕方ないが、等価の式に変換できるはずの null 伝搬演算子 (?.) と with 式が C# コンパイラ内部で文 (手続) 扱いされ、式木化されないのは間抜け。

*3:with 式の式木化は C# 開発チームに提案したものの却下された。が、最近、LINQ to Entities で機能する .Select( x => x with { Prop1 = ... } ) の代替簡易記法を定義できたため、後日アップする。

*4:画面出力してフラットにすると、連想配列を内包するオブジェクトを JSON シリアライズしたかのような見映えになる。

Docker on WSL2 環境構築メモ (6/x) - Jupyter Notebook への .NET Interactive 追加導入

前提

導入対象とするマシンは下記の通り。

  • Hardware : CPU = i10900, GPU = GeForce RTX 2080Ti, Mem = 32GB, SSD 1 TB + HDD 2TB
  • ベース OS : Windows 10 Pro バージョン 20H2 (ビルド 19042.685)
  • 仮想 OS : Ubuntu 20.04 LTS on WSL2
  • Docker on WSL2 環境構築メモ (4/x) の手順を終えている

Jupyter コンテナへの .NET Interactive (C# / F# / PowerShell) および Jupyter Lab 導入

Jupyter 上で C# 9.0 と Python を同時に利用したいため、Docker on WSL2 環境構築メモ (4/x) - Tensorflow (CPU 版) 導入 - Crayon's Monologue で作成した Tensorflow + Python3 + Jupyter のコンテナへ .NET Interactive を追加導入する。Jupyter で複数言語を扱うなら Notebook ではなく Lab にしておいた方が便利であるため、Lab も追加導入する。

起動ユーザ (UID) 指定がなければ docker は既定では root 権限でコンテナを起動する。ユーザを切り替えてインストールするものが一部あるが、該当箇所までの一連の手順は root 権限で行う。

実施前確認

追加導入するもの

jupyter labpip install
nodejsapt installバージョン 12.0 以上を求められる
wget
dpkg
apt install
apt-transport-httpsapt install
dotnet-sdk-5.0apt installバージョン 5.0 が利用可能
dotnet-interactivedotnet tool installバージョン 1.0.210803 が利用可能
dotnet-interactive Jupyter Kerneldotnet-interactive install

オリジナルの docker-compose.yml
version: '2'
services:
  tensorflow:
    image: 'tensorflow/tensorflow:latest-py3-jupyter'
    restart: always
    ports:
      - 'XXXX:8888'
    volumes:
      - /mnt/... XXXXX .../JupyterNotebooks:/tf
    command: /bin/bash -c "source /etc/bash.bashrc && jupyter notebook --notebook-dir=/tf --ip 0.0.0.0 --no-browser --allow-root --NotebookApp.token='XXXXXXXXXXXXXX'

Jupyter Lab インストール手順

Jupyter Lab インストール手順

ベースとしている tensorflow/tensorflow:latest-py3-jupyter イメージが pip ベースであるため pip をアップデートをしてから Lab をインストールする。

#install
pip install --upgrade pip
pip install jupyterlab

# comfirm
jupyter lab --help

引数を同じにして jupyter notebook の代わりに jupyter lab をすれば Jupyter Lab が起動する。(後ほど、コンテナのスナップショットをとって) docker-compose.yml の command: を書き換える。

node.js インストール手順

Jupyter Lab を快適に使うために Jupyter Lab Extension を入れたくなる。その際に node.js が必要になるため、あらかじめインストールしておく。

cd ~
curl -sL https://deb.nodesource.com/setup_12.x | bash -
apt update && apt upgrade
apt install nodejs

.NET 5.0 インストール手順

作業準備として .NET 5.0 パッケージをインストールする。

# install wget & dpkg 
apt update && apt upgrade
apt install wget && apt install dpkg

# download package management file
cd ~
wget https://packages.microsoft.com/config/ubuntu/20.04/packages-microsoft-prod.deb -O packages-microsoft-prod.deb
dpkg -i packages-microsoft-prod.deb

# install apt-transport-https
apt update && apt upgrade
apt install apt-transport-https -y

# install dotnet sdk 5.0 & dotnet-interactive
apt update && apt upgrade
apt install -y dotnet-sdk-5.0

ユーザ設定手順

ここから先はユーザ依存の環境構築になる。root でも構築できるが、Jupyter Lab はターミナルコンソールも起動可能であり docker コンテナ内を root で何でもできてしまうようになるため、Jupyter 起動用のユーザを作成し、そちらで docker を起動するようにする。

# 1) change root password for security
passwd

# 2) make a new user
adduser {user}
cat /etc/passwd       # check the new user's UID and GID for later use

# 3) add root user to the new user's group 
adduser root {user}
id root               # check whether root belongs to the new group

# 4) delegate jupyter lab extensions directory to the new group
chgrp {user} /usr/local/share/jupyter/lab -R
chmod 775 /usr/local/share/jupyter/lab -R

# 5) change user (to continue installation)
su {user}
echo ''     >> $HOME/.bashrc
echo 'cd ~' >> $HOME/.bashrc
source ~/.bashrc

Jupyter Lab 環境ができ上がると Jupyter のページタブから便利拡張機能を追加できるようになるが、そのままでは Jupyter 起動用ユーザが /usr/local/share/jupyter/lab に書き込み権限を持たないため、上記 3) 4) にて当該ディレクトリを (root も既存権限を保持したまま) 権限移譲する。

以上の設定で Jupyter ページタブから extension 自体は追加・削除できるようになるが、ページタブからは (おそらく上述のディレクトリ配下に extension 関連のサブ) ディレクトリを作成するのに失敗するため、初回だけコマンドラインから機能追加しておく。サンプルは目次作成機能。

jupyter labextension install @jupyterlab/toc

.NET Interactive インストール手順

続いて、前節で作成したユーザで .NET Interactive → C#, F#, PowerShell の Jupyter カーネルという順にインストールしていく。

# confirm in advance
jupyter kernelspec list

# expected result is ...
#    python3            /usr/local/share/jupyter/kernels/python3

# install dotnet-interastive
dotnet tool install --global Microsoft.dotnet-interactive --version 1.0.210803

# install .NET-related Jupyter kernels
~/.dotnet/tools/dotnet-interactive jupyter install

# reconfirm
jupyter kernelspec list

# expected result is ...
#    .net-csharp        $HOME/.local/share/jupyter/kernels/.net-csharp
#    .net-fsharp        $HOME/.local/share/jupyter/kernels/.net-fsharp
#    .net-powershell    $HOME/.local/share/jupyter/kernels/.net-powershell
#    python3            /usr/local/share/jupyter/kernels/python3

# return to root
exit

環境設定

インストールは完了なのだが、コンテナを再起動して Jupyter にアクセスすると C# / F# / PowerShell がメニューにあるものの、コンパイルしてくれず、Starting Kernel. Please Wait ... と表示される。参考記事/ブログ等にはインストールは簡単だと書いてあるだけで起動しない事例は見当たらず、ここからが悩みどころ。結論としては、機能させるために次の2つが必要となる。

  • dotnet-interactive にパスが通っていないため、環境変数設定をしてパスを通す
  • docker-compose.yml が command: で起動するプロセス (コンテナ内の pid = 1) にその環境変数設定を反映させる

Jupyter のプロセスに環境変数が効いておらず dotnet-interactive コマンドが command not found になっているのは容易に想像がつくが、この 2 点目に気付くのがなかなか難しい。

Jupyter が当該コンテナでどう起動されているか、/etc/init.d/ にシェルスクリプトをおいても自動起動しないこと、コンテナ内に入ってからサービス起動すると pid = 1 にならないこと *1、docker-compose.yml を編集するために (コンテナのスナップショットをとらずに) docker-compose down してしまうとコンテナ内で行った環境変数設定が消えてしまう、等々を理解してようやくたどりつく結論である。

結局、環境変数command: で起動される jupyter 以前に、余分な fork をしないで行うのがよく、/etc/bash.bashrc の末尾に追記するのがよいということになった。

# 環境変数の設定
echo ''                                         >> /etc/bash.bashrc
echo '# Environmental settings'                 >> /etc/bash.bashrc
echo 'export PATH=$PATH:$HOME/.dotnet/tools'    >> /etc/bash.bashrc
echo 'export DOTNET_TRY_CLI_TELEMETRY_OPTOUT=1' >> /etc/bash.bashrc

というわけで、上記のとおり環境変数設定の準備をする。2つめの環境変数 DOTNET_TRY_CLI_TELEMETRY_OPTOUTMicrosoft .NET Interactive の利用状況を情報提供しないという設定。

docker-compose restart をすれば環境変数が反映された形で Jupyter が起動する、が、この設定はいま alive であるコンテナでしか活きていないため restart (してサービスが動き出し、永続化されたものと勘違いして docker-compose down) する前にコンテナのスナップショットを取っておく。

コンテナの整備

環境変数保全してコンテナ稼働開始時に最初に起動するプロセスに反映させなければならないため、意外に大事であるコンテナ整備。その手順をここに記す。

コンテナ内部の整理

コンテナのスナップショットをとる前に、コンテナ内の不要なファイルは削除する。

apt clean
rm -rf /var/lib/apt/lists/*
コンテナ整備の手順

コンテナから外に出て、下記の作業を行う。docker-compose stopdocker-compose down を間違わないように。

# stop the running container (DO NOT make it down)
docker-compose stop

# preserve snapshot
docker commit {container id} {image:tag}

# abandon the container brefore editing docker-compose.yml
docker-compose down

# edit docker-compose.yml
editor docker-compose.yml

# start a new container derived from the preserved image
docker-compose up -d

docker-compose.yml の書き換えるべきは3か所。

  • 参照するイメージを変更する
    image: 'tensorflow/tensorflow:latest-py3-jupyter-yyyymmdd'
  • Docker 起動ユーザを変更する
    user: "{uid}:{gid}"
  • 起動コマンドを jupyter note から jupyter lab に変更し --allow-root オプションを外す
    command: /bin/bash -c "source /etc/bash.bashrc && jupyter lab --notebook-dir=/tf --ip 0.0.0.0 --no-browser --NotebookApp.token='XXXXXXXXXXXXXX'

これで Jupyter Lab + C# 9.0 + Python3 + Tensorflow の同時利用ができる。ちなみに python で次のように書くことにより JavaScript も実行できる。

from IPython.display import HTML

javascript = '''
<script type="text/javascript">
    alert("alert output");
    document.write("html output");
</script>
'''

HTML(javascript)

*1:ユーザが docker exec -it /bin/bashインタラクティブに実行した場合はもちろん、command: 起動のプロセスがコンテナ内部でシェルスクリプトをキックしても fork になるせいかダメらしい。

Docker on WSL2 環境構築メモ (5/x) - Bitnami/Redmine 導入

前提

導入対象とするマシンは下記の通り。

  • Hardware : CPU = i10900, GPU = GeForce RTX 2080Ti, Mem = 32GB, SSD 1 TB + HDD 2TB
  • ベース OS : Windows 10 Pro バージョン 20H2 (ビルド 19042.685)
  • 仮想 OS : Ubuntu 20.04 LTS on WSL2

Bitnami/Redmine + MariaDB 導入

インストール手順 & 実行手順

Docker Hub での Bitnami/Redmine の解説の通り。Docker Compose での提供であるため、起動は簡単。

# install
docker pull bitnami/redmine

# execute
docker-compose up -d

初期設定状況と docker-compose.yml

初期設定は下記のとおり。docker-compose.yml の環境変数設定に追記することで変更可能 (だが、ユーザ関連情報は平文保存になってしまうため docker-compose.yml には書かない)。

初期ユーザ user 初回起動・ログイン直後に変更する
初期パスワード bitnami1 初回起動・ログイン直後に変更する
初期メールアドレス user@example.com
初期言語 en 初回ログイン前に ja にしておくのが無難

設計戦略 のところで述べたように、サーバサービスやシステム設定を格納するディレクトリ構造はコンテナとデータボリュームの間で分割せず一体とした方がバックアップ&リストア等の管理をしやすい。というわけで docker-compose.yml を下記のように編集しておく。

version: '2'
services:
  mariadb:
    image: 'docker.io/bitnami/mariadb:10.3-debian-10'
    restart: always                                    # 常に再起動するように設定しておく
    environment:
      - ALLOW_EMPTY_PASSWORD=yes
      - MARIADB_USER=bn_redmine
      - MARIADB_DATABASE=bitnami_redmine
    volumes:
      - 'mariadb_data:/bitnami'
  redmine:
    image: 'docker.io/bitnami/redmine:4-debian-10'
#   image: 'docker.io/bitnami/redmine:4-debian-10-XXX' # スナップショットをとった場合はイメージを切り替える
    restart: always                                    # 常に再起動するように設定しておく
    environment:
      - REDMINE_DB_USERNAME=bn_redmine
      - REDMINE_DB_NAME=bitnami_redmine
      - REDMINE_LANGUAGE=ja                            # 日本語に設定しておく
    ports:
      - 'XXXX:3000'                                    # Port を変更しておく
    volumes:
#     - 'redmine_data:/bitnami'                        # データボリュームは使わない (システム設定情報の分断を回避するため)
      - '/mnt/.... /redmine_plugins:/redmine_plugins'  # 外部ディスク格納のプラグインパッケージをインストール
                                                       # する場合は参照先をマウントする
    depends_on:
      - mariadb
volumes:
  mariadb_data:
    driver: local
# redmine_data:                                        # データボリュームは使わない
#   driver: local

Redmine のカスタマイズ

本メモを記述する理由はインストール/実行手順の記録ではなく、むしろこちら。

カスタマイズした Redmine の挙動を確認する上で Docker コンテナをサンドボックスとして使い倒す。その際の操作・設定手順を記録することにある。 コンテナ内部の構成が Windows 版 Bitnami Redmine と少々異なるため、また、コンテナにエディタや日本語フォント等がないことをちょっとした工夫で凌ぐため、メモしておくことが重要。

Redmine 標準項目名の編集

日本語の標準項目名は /opt/bitnami/redmine/conf/locales/ja.yml に定義されている。これを (エディタなしで) 編集して挙動確認してみる。

テストケースとして assigned_to (日本語名:"担当者") 項目を "SendTo" にしてみる。 類似項目に assigned_to_role というものがあるため、これを比較対象として編集が exact になされたかを確認する。

# ディレクトリ移動
cd /opt/bitnami/redmine/conf/locales

# ファイルのバックアップ
cp ja.yml ja.yml.bak

# ファイルの編集
cat ja.yml.bak | awk '{ sub(/field_assigned_to:.*/, "field_assigned_to: SendTo"); print }' > ja.yml

# 確認
cat ja.yml | grep field_assigned_to

編集後に docker-compose restartとしてコンテナを立ち上げ直し、Redmine サービスが起動すれば、項目名が変更になっている。他の項目を含めて、あるべき項目を壊したりしてしまうとサービスが起動しなくなる。

docker-compose downdocker-compose up -dとするとコンテナを捨てて作り直すため、この設定は無かったことになる。しかし、プロジェクトやチケットの状況は MariaDB (のマウント先 = データボリューム) に保存されているため、無くならない。

プラグイン・インストールの事前準備

bitnami/redmine はお手頃なのだが、プラグイン・インストールの事前準備に大変手間がかかった *1 ため、ここに記録する。

必要なパッケージ

パッケージインストール方法補足
gcc
pkg-config
apt installビルド用
build-essential
curl
file
git
apt installbrew の依存パッケージ
libmariadbd-devapt installmysql2 (mariadb) の依存パッケージ
(mysql の依存する今は亡き libmysqld-dev の mariadb 版)
brewcurl でバッチのダウンロード&実行apt ではないパッケージ管理コマンド (Mac OS 由来)
nokogiri が apt でまともにインストールできないため必要
libxml2
libxslt
brew installnokogiri (Ruby 用 XML/HTML 用パーサ) の依存パッケージ
bundlergem installgem 管理コマンド
rakegem installRuby on Rails ビルドツール (要バージョン 13.0.1)

事前準備手順

事前準備の手順は下記のとおり *2

ディレクトリ移動
cd /opt/bitnami/redmine/
apt install によるパッケージ導入

docker コンテナ内では sudo 不要で下記のとおり進める。

apt update && apt upgrade
apt install gcc
apt install pkg-config
apt install build-essential curl file git
apt install libmariadbd-dev
brew のインストール

Ubuntu でのインストールパスは linuxbrew であるが、ダウンロード元は linuxbrew という名称が消え、本家 (Mac OS 用) の Homebrew と統合されたらしい *3

brew は root でインストールできない。curl で取り寄せたスクリプトの実行にはかなり時間がかかり、途中でエラー停止したように見えるところがあるが、辛抱強く待つ。

# change user (brew does not allow to be installed by root)
su bitnami

# download and execute script
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install.sh)"

# return to root
exit

# set path
export PATH='/home/linuxbrew/.linuxbrew/bin:/home/linuxbrew/.linuxbrew/sbin':"$PATH"

# confirm brew
brew doctor
nokogiri 依存パッケージのインストール
brew install libxml2 libxslt
bundler と rake のインストールと設定

各 gem が要求する rake のバージョンが /opt/bitnami/redmine/Gemfile.lock に記載されているため、制約にかからないバージョン *4 をインストールし、設定する。また bundle install--no-deployment が廃止予定で使えないため、代わりの設定をする。

# install ruby on rails administrative tools
gem install bundler
gem install rake --version 13.0.1

# confirm available versions of rake
gem list rake

# set rake version to use
rake _13.0.1_ routes
bundle install による gem インストール

bundle install はこのディレクトリで実行しなければならないようだ。

# set configuration preliminarily, instead of using depreciated option '--no-deployment'
bundle config set --local deployment 'false'

# all I want to do is ...
cd /opt/bitnami/redmine/
bundle install

### preserve a docker snapshot, here ###

この節でやりたかった本質は bundle install の 1 コマンド。いままでやってきたのは、このコマンドが通らないための事前準備の事前準備。ふぅ。

プラグインのインストール方法

プラグインのインストール

bundle exec もこのディレクトリで実行しなければならないようだ。

# download plugin package (sample)
cd /opt/bitnami/redmine/plugins
git clone https://github.com/onozaty/redmine-view-customize.git view_customize

# gem install
cd /opt/bitnami/redmine/
bundle config set --local deployment 'false'
bundle install

# package install
bundle exec rake redmine:plugins:migrate RAILS_ENV=production

redmine の再起動

アプリごとの再起動方法を覚えるのが面倒なため、docker から再起動してしまう。

docker-compose restart

*1:手間がかかる理由は依存パッケージが多いこと、とさらに、解説サイトの通りにインストールできないものが多く頻繁に止まってしまうこと。

*2:わかってしまえば簡単だが、解説サイトを読み進めていくと途中途中で作業途中で課題が発生し、その解決のためにさらに解説サイトを探して課題がネストしていき、右往左往するハメになる。

*3:こういう点も、手順が (古い) 解説サイトの通りにいかない理由。

*4:今回の事例では rake 自身が 13.0.1 を要求していた様子。

Docker on WSL2 環境構築メモ (4/x) - Tensorflow (CPU 版) 導入

前提

導入対象とするマシンは下記の通り。

  • Hardware : CPU = i10900, GPU = GeForce RTX 2080Ti, Mem = 32GB, SSD 1 TB + HDD 2TB
  • ベース OS : Windows 10 Pro バージョン 20H2 (ビルド 19042.685)
  • 仮想 OS : Ubuntu 20.04 LTS on WSL2

Tensorflow (CPU 版) + Python3 + Jupyter Notebook 導入

Tensorflow には CPU 版と GPU 版があるのだが、2021 年 2 月初旬現在、GPU 版を WSL2 上で動かすには Windows 10 Build 21292 (InsiderPreview) が推奨らしい。そこで Build 21292 がプロダクト・リリースされるまで、CPU 版をインストールして準備しておく。OS のバージョンアップを待ちながらアプリを先行導入するなんて通常は考えられないが、それを気楽にできるのが Docker のよいところ。

インストール手順

インストールは簡単。

# tensorflow + python3 + jupyter のイメージを pull
docker pull tensorflow/tensorflow:latest-py3-jupyter

実行手順

実行方法は何を使うかによって 3 通りある。

# Tensorflow Only
docker run -it --rm tensorflow/tensorflow:latest-py3-jupyter bash

# Tensorflow + Python3
docker run -it --rm tensorflow/tensorflow:latest-py3-jupyter python

# Tensorflow + Python3 + Jupyter Notebook Server
docker run -it --rm -v (個人フォルダへのパス):/tf/notebooks -p 8888:8888 tensorflow/tensorflow:latest-py3-jupyter &

注意点がいくつかある。

  • イメージにタグをつけないと Python3 + Jupyter Notebook を包含しない latest イメージを新たに pull してきてしまう
  • Jupyter Notebook サーバーが port 8888 を要求するため Port Relay (さらに必要に応じて Port Forward と Windows Defender) を設定する
  • Jupyter Notebook サーバーが作業フォルダを要求するため個人フォルダへのパス *1 を設定する 
  • Jupyter Notebook へのログイン時にトークンを要求されるが、これが Docker コンテナを作るたびに変化する一方でトークン/パスワード無効化までしてしまうと危険であるため、固定化する設定をする

docker-compose.yml の編集

Jupyter Notebook 関連の引数指定がちょっとめんどうであるため、(見よう見まねで) docker-compose.yml に記述することにした。

  tensorflow:
    image: 'tensorflow/tensorflow:latest-py3-jupyter'
    restart: always
    ports:
      - '8888:8888'
    volumes:
      - '/mnt/... (個人フォルダへのパス) .../JupyterNotebooks:/tf/notebooks'
    command: /bin/bash -c "source /etc/bash.bashrc && jupyter notebook --notebook-dir=/tf --ip 0.0.0.0 --no-browser --allow-root --NotebookApp.token='(固定化するトークン)'"

docker-compose.yml に書くべき情報は、いったん既定通りに走らせてみて docker inspect {container} とすれば取得できる。
Jupyter Notebook 実行時に有効になっているトークンは docker exec -it {container} jupyter notebook list とすれば取得できる。

*1:Jupyter Notebook の作業フォルダの配置は、ゲスト OS Ubuntu 上でもよいが、ホスト OS Windows NTFS を指定している。

Docker on WSL2 環境構築メモ (3/x) - ネットワーク設定

更新 2022.10.19

Windows 11 + WSLg preview 化に伴い、ポートフォワーディングを設定ファイル1つで実行できるようになり、バッチファイルは不要になった。後述 "Port Forwarding 設定の前提" 節、"Port Forwarding 設定" 節の作業の代わりに %UserProfile% フォルダに下記内容のファイル .wslconfig を置く。

[wsl2]
localhostForwarding=true



前提

導入対象とするマシンは下記の通り。

  • Hardware : CPU = i10900, GPU = GeForce RTX 2080Ti, Mem = 32GB, SSD 1 TB + HDD 2TB
  • ベース OS : Windows 10 Pro バージョン 20H2 (ビルド 19042.685)
  • 仮想 OS : Ubuntu 20.04 LTS on WSL2

ネットワーク設定の目的

Docker コンテナで何らかのサーバサービスを立ち上げる場合、Docker コンテナの外部からアクセスできるようにする必要がある。Docker on Ubuntu on WSL2 場合の外部とは

  1. Docker コンテナ外・ゲスト OS (Ubuntu 仮想端末) 内
  2. ゲスト OS (Ubuntu 仮想端末) 外・ホスト OS (Windows 物理端末) 内
  3. ホスト OS (Windows 物理端末) 外

の3種類ある。1つめの Docker コンテナ ⇔ Ubuntu 仮想端末の通信は Docker 構築の定番テーマである Port Relay (ubuntu -p オプション) の話。3つめの Windows 物理端末外から Ubuntu 仮想端末間の通信は OS 仮想化で定番テーマである Port Forwarding の話。

個人で環境構築している際には外部公開するほどのことはやらないし、かといって Ubuntu 仮想端末内で作業が完了するわけでもない。コンサーンとなるのは2つめの同一端末内の Windows 物理端末 (内) と Ubuntu 仮想端末の間でのみ相互通信したいということになる。

ここまでの構築メモの手順を踏むだけでも Windows 物理端末 (内) と Ubuntu 仮想端末の間の通信が阻まれることはないのだが、後述のとおり仮想端末起動ごとに仮想端末 IP アドレスが揺らぐため、名前解決が面倒くさい。単純に思いつく解決法は hosts ファイルを書き換えることだが、試行錯誤した結果、hosts を動的に書き換えるとセキュリティ・リスクを高め、運用しにくいことから断念することにした *1

結論として、スコープを小さく限定して利用する場合でも Port Forwarding が最も手っ取り早い。Windows 物理端末外からのアクセスまで考慮するか否かはホスト OS (Windows 物理端末) でパケットフィルタリング (Windows Defender) を使うかどうかの違いでしかない。

ネットワーク設定の手順

仮想 OS の IP アドレス

いろいろと調べたところ、ゲスト OS 側 Ubuntu 仮想端末としての IP アドレスはUbuntu 20.04 LTS on WSL2 環境構築メモ (2/4) - GUI 導入で調べたネットワークアダプタ関連情報には登場せず、Ubuntu 側からip a show dev eth0 とやると取得できるらしい。そこで IPv4 アドレスを下記のようにして取得する。

ip a show dev eth0 | awk '$1 == "inet" && $2 ~ /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+/ { sub(/\/[0-9]+$/, "", $2); print $2 }'

仮想端末の IP アドレスはよく調べたくなるため、whereami で調べられるように .bashrcエイリアスを追記しておく。bash ではクォーテーションのエスケープが少し特殊であることに注意。'\''エスケープされた ' だと思えばよい。

alias whereami='ip a show dev eth0 | awk '\''$1 == "inet" && $2 ~ /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+/ { sub(/\/[0-9]+$/, "", $2); print $2 }'\'''

Port Forwarding 設定の前提

Docker で apache web サービスのコンテナを port 80 → 8080 で Port Relay しつつ走らせており、これにホスト OS (Windows) のブラウザから接続するというシナリオを前提とする。実環境で Port を変える必要はないのだが、サーバサービスを Docker で本番/検証/開発環境並行運用するような場面を想定した仮想実験。構成図 (最終形) はこちら

docker run -p 8080:80 -d httpd

Port Forwarding 設定

WSL2 で実行するシェルスクリプトの作成

まず、Ubuntu 仮想端末において管理者権限で port-forward.sh というファイルを作成する。

sudo touch /opt/port-forward.sh

その中身の記述は次のとおり。xx.xx.xx.xxWindows 物理端末の実 IP アドレス。

#!/bin/bash

# obtain ipv4 on virtual os
IP=$(ip a show dev eth0 | awk '$1 == "inet" && $2 ~ /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+/ { sub(/\/[0-9]+$/, "", $2); print $2 }')

# reset and set port forwarding configuration on wsl2
netsh.exe interface portproxy delete v4tov4 listenport=80 listenaddress=localhost
netsh.exe interface portproxy delete v4tov4 listenport=80 listenaddress=xx.xx.xx.xx
netsh.exe interface portproxy add    v4tov4 listenport=80 listenaddress=localhost   connectport=8080 connectaddress=$IP
netsh.exe interface portproxy add    v4tov4 listenport=80 listenaddress=xx.xx.xx.xx connectport=8080 connectaddress=$IP
シェルスクリプトをキックするバッチの作成

Windows 物理端末において %UserProfile%\ あたりに port-forward.batというバッチファイルを作成する。その中身の記述は次のとおり。wsl -u オプションで root 指定必須。wsl -d オプションは仮想 OS ディストリビューションの指定。事前に wsl --list --verbose で正式な識別名を調べておく。

@echo off
wsl -d Ubuntu-20.04 -u root --exec /bin/bash /opt/port-forward.sh

rem コマンドラインから実行した場合に、設定状況を一覧表示する
netsh interface portproxy show v4tov4

このファイルは管理者権限で実行する必要があるため、ショートカットを作成し、その詳細プロパティで「管理者として実行」にチェックを入れる。

実行

このショートカットを実行しておけば、Port Forwarding は有効になっている。 (Ubuntu 仮想 OS をユーザが起動しなくても) WSL2 は Windows 起動時からバックグラウンドで動いているため、Windows 起動時に自動キックとなるようタスクスケジューラに仕込んでおけばよい。

Windows 物理端末のブラウザから http://localhost/ (Port 80) 指定で Docker コンテナの httpd (Port 8080) へアクセスできる。
Ubuntu 仮想端末のブラウザからは http://localhost:8080/ (Port 8080) 指定でなければアクセスできない。仮想端末内では Port Forwarding 設定 (Windows Port 80 → Ubuntu Port 8080) が効いておらず、一方で Port Relay 設定 (Ubuntu Port 8080 → Docker Port 80) が効いているため。

Windows 物理端末外からのアクセス許可

では Windows 物理端末外のブラウザから http://xx.xx.xx.xx/ 物理端末 IP & Port 80 指定でアクセスできるか、というとできない。これは Windows Defender のパケットフィルタリングに阻まれるためである。そこでローカルネットワークや VPN のセグメントからのみ、http (port 80 TCP) アクセスを受け入れるルールを Windows Defender に設定する。

http (port 80 TCP) inbound をローカルネットワークや VPN に許可するルールの作成方法
  1. "Windows Defender ファイアーウォール" を開く
  2. "詳細設定" を押す
  3. "受信の規則" を右クリックし、"新しい規則" を選択する
  4. "規則の種類" で "ポート" を選択し、"次へ" を押す
  5. TCP/UDP は "TCP" を選択、ローカルポートは "特定のローカルポート" を選択、ポート番号に "80" を指定し、"次へ" を押す
  6. "接続を許可する" を選択し、"次へ" を押す
  7. "ドメイン" と "プライベート" を選択し、"次へ" を押す
  8. 受信規則の名前を付け、"完了" を押す
  9. "受信の規則" からいま設定したルールを選択、右クリックでプロパティを選択、 ”スコープ" タブを開く
  10. リモート IP アドレス (Windows 物理端末外側) で "これらの IP アドレス" を選択し、"追加" を押し、"この IP アドレスまたはサブネット" で "xx.xx.xx.xx/xx" (ローカルネットワークや VPN のセグメント) を入力し、"次へ" を押す

構成図 (最終形)


*1:hosts 格納フォルダ内は Windows 側の管理者権限がないと操作できず、かつ、hosts ファイル自身が Users グループ Readable/Executable でないと機能しない。アクセス権の緩いフォルダで制御して本来のフォルダからシンボリックリンクを設定することもできるがセキュリティ・リスクを高めてしまう。いずれにせよ NTFS アクセス権限を Ubuntu 側から制御するのはかなり面倒であった。