Docker on WSL2 環境構築メモ (2/x) - 設計戦略

前提

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

  • 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
  • 環境構築メモ 3/3 を実施した状況

Docker 導入により目指すところ

  • 機械学習環境を構築する
  • アプリケーションやサーバサービスごとにスクラップ&リビルドをできるようにする
  • サービス移行・複製を容易にする (ユーザ定義データのポータブル化)

Docker 内部構成の設計戦略

Docker を解説する書籍は、概念や個々の手順は丁寧に記述されているものの、全体としてサーバサービスをどのように Docker 上に配置・構成すべきかは説明されていない。習熟のためにまず手順を踏んでみることは大事だが、その延長で設計なく環境を構築することはできない *1 ため、少し考えてみた。
赤字部分は Docker を構成する上で実務上は非常に重要であるのにあまり説明されていないところ。

  • サーバサービスごとに1つコンテナをつくる
    • DB サーバとして PostgreSQL サーバを立てる予定
    • Web サーバとして Apache サーバを立てる予定 (当面は必要ないが、将来は学習・推論・メンテの起動トリガーを Web にまとめる)
  • 機械学習の開発環境 (Tensorflow 他) で1つコンテナを作る
    • Tensorflow などはパッケージの想定バージョンが異なると動作しない可能性が高い
    • CPU 動作版、GPU 動作版をコンテナとして分離すべきかどうかは要検討
      (分離する場合は、コンテナイメージをレイヤー設計する必要があるかもしれない)
  • ユーザ定義データは(原則として)各コンテナに 1:1 対応するデータボリュームに格納する
    • 複数のアプリケーション・コンテナから接続されるデータボリューム・コンテナを構築しない
      (各アプリケーション・コンテナがデータボリュームをマウントするデータボリューム・コンテナとなり、そのデータボリュームに自身が扱うユーザ定義データを格納する)
    • ユーザ定義以外のデータはマウントポイントではなく当該コンテナを格納領域とする
      (設定ファイルやログ等は、サービスと一体として管理しないとスクラップ&リビルドした時にサービスやコンテナの状況と整合しなくなるため外部や共有のボリュームに吐き出さず、コンテナイメージとして管理する)
    • 単一のサーバサービスが複数のディレクトリを参照する場合は、複数のデータボリュームをマウントする必要があるかもしれない
  • コンテナおよびユーザ定義データ格納ボリュームは Ubuntu 外にバックアップする
    • ユーザ定義データ格納ボリュームは、データを入れる前の状態 (アクセス権等の初期設定を完了し、データ投入テストをした後に、テストデータをクリーンナップした時点) で1回スナップショットをとる
    • 残念ながら、現行の Windows 10 (20H2) や WSL2 では ext4 形式の外部パーティションを直接マウントすることはまだできないため、バックアップを NTFS 形式別パーティションにおくことにする *2

Docker イメージ & データボリュームのバックアップとリストア

サーバ構築時には手順の検証・確定が必要であり、スクラップ&ビルドするのが常である。Docker を採用する最大の理由がこのスクラップ&ビルドを効率化することである。設計戦略を検証するために、実際にバックアップとリストアをやってみる。

bitnami redmine イメージをサンプルとしてバックアップとリストアしてみる。Redmineプラグインを導入した時などに (バージョン齟齬により) よくサービス起動しなくなるため、頻繁にスクラップ&ビルドする。

前提

Docker Hub から bitnami/redmine イメージを取得する。当該イメージは Docker Compose であるため、coker-compose up -d でコンテナが簡単に立ち上がる。docker-compose.yml の内容は下記のとおり。mariadb がバンドルされており redmine はこれに依拠している。また redmine_data というデータボリュームを作成する。

version: '2'
services:
  mariadb:
    image: 'docker.io/bitnami/mariadb:10.3-debian-10'
    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'
    environment:
      - REDMINE_DB_USERNAME=bn_redmine
      - REDMINE_DB_NAME=bitnami_redmine
    ports:
      - '80:3000'
    volumes:
      - 'redmine_data:/bitnami'
    depends_on:
      - mariadb
volumes:
  mariadb_data:
    driver: local
 redmine_data:
    driver: local

バックアップ

バックアップは簡単。

Docker コンテナのバックアップ

バックアップしたい断面で redmine コンテナを止め、イメージ化する。

docker-compose stop
docker commit {container id} bitnami/redmine:4-debian-10-yyyymmdd
Docker データボリュームのバックアップ

redmine コンテナを止めた断面で下記を実行する。実行前に redmine コンテナがマウントしていたデータボリュームを特定する。

docker run --rm -v {datavolume id}:/bitnami -v $PWD:/backup busybox tar zcvf /backup/bitnami.backup.tgz /bitnami

この1行でやっていることは、以下の通り。コンテナから入 (Docker 内データボリューム) と出 (Docker 外ディレクトリ $PWD) と2方向に接続しているのがポイント。

  • busybox コンテナ (データコンテナ) を走らせる
  • 当該コンテナから redmine のデータボリュームをマウントする
  • 同時に Docker 外のローカルディレクトリ $PWD を当該コンテナ内の /backup にマウントする
  • その後、Docker 内で /bitnami を /backup/bitnami.backup.tgz (コンテナ内 → コンテナ外) へ圧縮バックアップする
  • 処理が終わったら busybox コンテナを殺す

リストア

リストアは少し難しい。下手にやるとコンテナ内の情報が壊れ、redmine コンテナが再起動を繰り返す。

Docler データボリュームの初期化

下記の手順で Docker の初期イメージからデータボリュームの初期断面を復元する。

  1. docker-compose down で現在のコンテナを捨てる (捨てる前に docker-compose.yml を編集すると、停止やその他のコンテナ操作ができなくなる。)
  2. docker volume ps で残っているデータボリュームを特定し、docker volume rm {volume id} で削除する
  3. redmine を初期イメージで起動するように docker-compose.yml を編集する
  4. docker-compose up -d で初期イメージからコンテナを生成し、データボリュームを新規作成する
  5. docker-compose down でそのコンテナを捨てる
  6. redmine をバックアップ断面イメージで起動するように docker-compose.yml を編集する
    併せて、当該イメージがマウントしようとするデータボリュームを特定する
  7. 下記を実行する
docker run --rm -v {datavolume id}:/bitnami -v $PWD:/backup busybox tar zxvf /backup/bitnami.backup.tgz -C /bitnami

やっていることはバックアップの逆。

設計戦略の評価

事前に策定した戦略の1つ

  • ユーザ定義以外のデータはマウントポイントではなく当該コンテナを格納領域とする

は正しかったように思う。というのは、バックアップ&リストアをする際に、ある断面のイメージとデータボリュームをセットにして保管する必要があるからだ。両者に断面ズレがあるとサーバサービスが立ち上がらなくなる可能性が高い。

bitnami/redmine でも、断面ズレでリストアした場合、コンテナの再起動を永遠に繰り返していた。マウントポイントとなっているのは /bitnami だけだが、ツールやプラグインのインストールなどを行うと、システム設定はここ以外にもなされる。そして、ツール類を一定程度インストールした後のこのディレクトリのバックアップ・ファイルは tar で 20 KB、tar zip で 4 KB ほどだった。高々これだけのものを別管理する必要はない。

マウントに関する考察

Docker は -v オプションで Docker 外ディレクトリも Docker 内データボリュームでもマウントできる。バックアップ&リストアでの挙動を観察して、Docker はディレクトリ・マウントを下記のように実行している (と推測するが、裏付けをとったわけではない)。

  • Docker は (初期) イメージの中に環境の全ディレクトリ構造を保持している
  • docker run 時に -v オプションは下記を実行する

参考記事で読んだが、Docker はコンテナ内部のディレクトリ構造も、データボリューム内部のディレクトリ構造も、(そして当たり前だが Docker 外ディレクトリへの参照も) シンボリックリンクで実現し、実環境上のどこかに格納されているあるディレクトリ・ノード (以降) を Docker コンテナ内においては / (以降) とみなすフルパス・エミュレーションにより環境を分離しているらしい。だから busybox などのデータコンテナは非活性でもワークする。そうであれば、上述の推測は外れてはいないのではないか。

汚さないための環境隔離/コンテナ仮想化であるため、-v オプションで指定したマウントポイント以外に外界との接点はないはず。ならば、やるなら / 以降の (ユーザ定義データを除く) すべてをデータボリュームへ格納するか、(ユーザ定義データを除く) すべてをコンテナ内へ閉じ込めるか *3 だ。

フルパス・エミュレーションなら前者と後者で本質的な違いはなく、データボリュームは複数コンテナ間で共有できることしか意義がない。しかし環境分離の観点からすると、これまたユーザ定義データ以外を共有格納する利点がない。

*1:これが環境構築に二の足を踏んでしまう最大の阻害要因。取り回しのしやすさや将来拡張性などを考えていると初手を打てなくなる。

*2:ext4 形式外部パーティションの直接マウントができるようになったら、それはそれで悩むことになる。virtulized ext4 on NTFS on SSD と native ext4 on HDD のどちらが性能面・耐久性面・耐障害性面トータルで効率的か。

*3:ただし、コンテナ起動とともにサービスが立ち上がり何らかのファイルがロックされるため、コンテナ内へ閉じ込めるとディレクトリの一部または全部を抽出できない。抽出するためにはサービスの停止方法を知っておく必要がある (が、ほいほいお手軽イメージ を Docker Hub から pull すると大抵この辺のことが解説されていなくて困る)。

Docker on WSL2 環境構築メモ (1/x) - Docker 基本操作

Docker 基本操作 (チートシート)

Docker 操作の状態遷移

Docker の状態遷移がわかりづらい (にも関わらず図示した解説を見かけない) ため、誤っている可能性を恐れず、可視化してみた。(利用は自己責任で。)
f:id:Crayon:20210203221706j:plain

Docker レポジトリ (イメージ) の操作

レポジトリ (ID) とは Docker Hub におけるイメージを固有識別子 (ubuntu などのテキスト) で表現したもの。イメージ (ID) とはローカルに保持しているイメージをその固有識別子 (f12345a12b1f などの記号列) で表現したもの。

アクションコマンド補足
イメージの取得docker pull {repository}[:{tag}]{tag} 省略時: latest 指定と同等
イメージ (レポジトリ) 検索は こちら
イメージの一覧表示docker images [-a] [{repository}[:{tag}]]-a 省略時:中間イメージ非表示
{repository}, {tag} 省略または * 指定時:全量表示
イメージの削除docker rmi {repository:tag}
docker rmi {image}
「レポジトリ+タグ (イメージに対するリンク)」の削除が先で、
リンク参照が 0 になるときに「イメージ (実体)」が削除される
イメージの一括削除docker image pruneコンテナから利用・参照中のものを除く
イメージへのレポジトリ
(リンク識別子) 追加
docker tag {image} {repository}[:{tag}]{tag} 省略時: latest 指定と同等

Docker コンテナの操作

サービス起動後 bash プロンプトに戻らないコンテナを操作するときは & を付して docker コマンド自体をバックグラウンドで動かすと吉。

アクションコマンド補足
コンテナの生成+起動docker run
   [-itd] [--name=XXX] [--restart=always]
   {repository}[:{tag}] [command]
-i 指定時:標準入力利用
-t 指定時:標準出力利用
-d 指定時:デタッチモード (バックグラウンド実行)
--name=XXX 省略時:イメージが保持している既定コンテナ名が利用される
--restart:always 指定時:docker や Linux 再起動時にコンテナを再起動する *1
docker run -p {from}:{to} {repository}【Tips】
-p 指定時:Port Relay 設定
from はコンテナ外 Port、to はコンテナ内 Port
docker run -v {from}:{to} {repository}【Tips】
-v 指定時:ディスクマウント設定
from はコンテナ外資源、to はコンテナ内マウントポイント
コンテナの起動docker start [-ai] {container}-a 指定時:アタッチモード (フォアグラウンド実行)
-i 指定時:標準入力利用
docker start -ai $(docker ps -lq)【Tips】
最終生成コンテナの再起動
コンテナの再起動docker restart {container}
コンテナの停止docker stop [-t {seconds}] {container}-t 指定時:指定秒数後に停止
コンテナのバックグラウンド化キー入力Ctrl+PCtrl+Qbash が生きているコンテナのみ有効
コンテナへの接続docker exec -it {container} /bin/bash
コンテナの一覧表示docker ps [-alq] [{container}]-a 指定時:停止中コンテナ表示
-l 指定時:最終生成コンテナを返す
-q 指定時:コンテナ ID 情報のみを返す
{container} 省略時:全量表示
コンテナの固有情報表示docker inspect {container}
コンテナの削除docker rm {container}停止中コンテナのみ削除可能
コンテナの一括削除docker container prune停止中コンテナのみ削除可能
コンテナのイメージ保存docker commit {container} {repository[:tag]}停止中コンテナのみ保存可能

Docker データボリュームの操作

アクションコマンド補足
データボリュームの一覧表示docker volume ls
データボリュームの固有情報表示docker volume inspect {volume}
データボリュームの作成docker volume create {volume}
データボリュームの削除docker volume rm {volume}
データボリュームの一括削除docker volume prune不使用データボリュームのみ削除可能

Docker Compose の操作

docker-compose コマンドはすべて、docker-compose.yml に定義されているコンテナしか対象としない。

アクションコマンド補足
定義済みコンテナの一斉起動docker-compose [-f {file}] up [-d]-f 省略時:./docker-compose.yml を指定したとみなす
-d 指定時: デタッチモード (バックグラウンド実行)
定義済みコンテナの一斉停止docker-compose stop [-t: seconds]-t 指定時:指定秒数後に停止
定義済みコンテナの一斉再起動docker-compose restart
定義済みコンテナの一覧表示docker-compose ps [-aq]-a 指定時:停止中コンテナ表示
-q 指定時:コンテナ ID 情報のみを返す
定義済みコンテナの一斉削除docker-compose rm

その他 Tips

レポジトリ・リストのクリーンアップ

Docker コンテナの内外を問わず、環境構築を終わって運用に入るときにはレポジトリ・リストのクリーンアップを図り、ディスクを効率活用する。

sudo apt clean
sudo rm -rf /var/lib/apt/lists/*

*1:ホスト OS の再起動では非有効。

Ubuntu 20.04 LTS on WSL2 環境構築メモ (4/4) - その他アプリ導入

前提

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

  • 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
  • 環境構築メモ 3/4 を実施した状況

その他アプリ導入

VcXsrv をマルチウィンドウモードで起動すれば、Ububtu アプリも Windows アプリと区別なく利用できる (かつ PowerToys Fancy Zone で窓の配置を最適化できる) ため、Docker で仮想化するサーバサービス以外に Ubuntu にインストールするアプリなど不要。と考えていたが、UbuntuWindows 間の疎通確認等でブラウザを含む最小限のアプリはインストールすべきだったため、ここに備忘を残す。

Google Chrome 導入

参考記事の通りに手順を踏めば問題なくインストールされる。

# インストール状況の確認
apt list --installed google*

# ダウンロード元の指定とその確認
sudo sh -c 'echo "deb http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list'
cat /etc/apt/sources.list.d/google.list

# 公開鍵のダウンロードと登録
sudo wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | sudo apt-key add -

# パッケージの最新化
sudo apt update

# ダウンロード元の状況確認
apt list google*

# インストール
sudo apt-get install google-chrome-stable

# インストール状況の確認
apt list --installed google*

# Google Chrome 起動
google-chrome &

Ubuntu 20.04 LTS on WSL2 環境構築メモ (3/4) - Docker 導入

前提

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

  • 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
  • 環境構築メモ 2/4 を実施した状況

Docker 導入

Ubuntu 14.04 より apt-get, apt-chache ではなく apt コマンドの使用が推奨されているため、参考記事に旧コマンドが含まれている場合は注意。特に Docker Package の導入では apt 新コマンド利用を強く推奨されている。

Docker のインストールと設定

前提パッケージのインストール
 sudo apt update
 sudo apt install -y apt-transport-https ca-certificates curl software-properties-common
Docker 公式 GPG 公開鍵のインストール
 curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add -
apt レポジトリの設定

Intel x86-64 だけど arch=amd64 でいいの?」と思うが、x86-64 (= AMD64) とは AMD が開発した 32bit 下方互換 64bit アーキテクチャという意味なのでこれでよい。AMD ではない方と誤って armhf にしてはいけない。

 sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable"
Docker Community Edition のインストール
 sudo apt update
 sudo apt install -y docker-ce

# 最後に更新
sudo apt update; sudo apt upgrade
インストールした Docker のバージョン確認
 docker version
一般ユーザで Docker を起動するための準備

インストールした状態では root しか docker コマンドを実行できないため、起動させたいユーザを docker グループに追加する。現状のユーザをグループ追加するには次のコマンドを実行する。

 sudo usermod -aG docker ${USER}

設定は再ログインまたは次のコマンドを実行した後に有効になる。

 su - ${USER}

次のコマンドを実行し、指定したユーザが docker グループに所属したか確認する。

 id -nG

これで Docker のインストールが完了し、デーモンが起動している、確認するため次のコマンドを実行する、と各解説サイトは説明する。

 sudo systemctl status docker

が、実行結果は下記の通り。デーモンプロセスは動いていない。

System has not been booted with systemd as init system (PID 1). Can't operate.
Failed to connect to bus: Host is down

systemd ≠ pid 1 問題とその解決

ここでデーモンが動かないのは、一般の Unix システムでカーネルから最初に起動され pid = 1 を割り振られる init プロセスが、仮想 OS である Ubuntu on WSL2 においてはカスタマイズされてしまっていることによる。Unix の場合 init = systemd (pid 1) だが、WSL2 の場合、init プロセスは systemd とは別にあり、systemd はインストールされているものの無効化されていて、有効化しても pid = 1 にはならない。という背景により systemctl によるサービス管理が動作しない。

解決方法はいくつかあるらしいが、ここでは Genie というものを導入する方法を採る。

Genie 導入の準備

Genie が依存するパッケージをあらかじめインストールする。

 sudo apt install -y daemonize dbus policykit-1

.NET Runtime が必要となるため、最新の .NET 5.0 for Ubuntu 20.04 LTS をインストールする。(参考記事は .NETLinux も古いため、手順は少しカスタマイズが必要。)

# レポジトリの追加
 wget https://packages.microsoft.com/config/ubuntu/20.04/packages-microsoft-prod.deb -O packages-microsoft-prod.deb

# 更新
 sudo dpkg -i packages-microsoft-prod.deb
 sudo apt update

# .NET Runtime 導入
 sudo apt install -y apt-transport-https && sudo apt update && sudo apt install -y aspnetcore-runtime-5.0

Genie の導入

Genie のインストール

まず、下記の内容を記述した /etc/apt/sources.list.d/wsl-translinux.list というファイルを新規作成する。書き込みには root 権限が必要。本家で説明されている導入手順であるため、解説サイトなどでは説明が端折られていることが多いが、このファイルを作成しないとパッケージが見つからず、インストールが途中で止まる。

 deb [trusted=yes] https://wsl-translinux.arkane-systems.net/apt/ /

Genie をインストールする。

# リポジトリの追加
 curl -s https://packagecloud.io/install/repositories/arkane-systems/wsl-translinux/script.deb.sh | sudo bash

# Genie のインストール
 sudo apt install systemd-genie
getty@tty サービスの停止と systemd への差し替え
 sudo systemd stop getty@tty
Genie の起動確認
# Genie の動作確認 (エラーが出なければ OK)
 genie -s
systemctl による Docker の状況再確認

あらためて systemctl で Docker の状態を再確認する。.NET 入れたり Genie 入れたり、これらも一筋縄ではいかず、苦労したが、やりたかったのはこれ。ふぅ。

 sudo systemctl status docker
Genie の自動起動設定

WSL2 起動時に Genie を自動起動させるために /etc/profile.d/genie.sh に管理者権限で下記の設定をする。(参考記事と少し異なる理由は後述)

# systemd の PID を調べるコマンドにエイリアスをつける
alias getsdpid='ps -eo pid,lstart,cmd | grep systemd | grep -v -e grep -e systemd- | sort -n -k2 | awk '\''NR==1 { print $1 }'\'''

if [ -z "`getsdpid`" ]; then
  echo 'genie : initializing ...'
  genie -i &> /dev/null &
  echo 'genie : waiting for initialization ...'
  sleep 2s  
fi

if [ "`getsdpid`" != "1" ]; then
  echo 'genie : launching bash ...'
  genie -s
fi
Ubuntu 仮想環境初回起動時に systemd を待ち続ける問題

WSL2 起動時に Genie を自動起動させるために .bashrc/etc/profile.d/genie.sh に下記を追記しておけとよく解説されているが、これでは PC 起動直後の Ubuntu 仮想環境初回起動時には Waiting for systemd ... !!!!!!!! (! が次第に増えていく) と出続け、しばらく待っていても機能しない。

if [ "`ps -eo pid,lstart,cmd | grep systemd | grep -v -e grep -e systemd- | sort -n -k2 | awk 'NR==1 { print $1 }'`" != "1" ]; then
  genie -s
fi

この問題にかなり悩まされたがいろいろと調査した結果、わかったことを列挙する。原因は初期化プロセスでかなり待たされるようになっている Genie の作りにあるようだ。

  • genie -s は、①初期化 (systemd 関連プロセス群の起動) を担う genie -i の部分と、②bash シェルを (Ubuntu 仮想環境直上の bash に重ねて) 起動する部分、から成る
  • genie -s は、初期化されていれば①をスキップする
  • genie -i は、systemd 関連プロセス起動をキックした後に Waiting for systemd ... !!!!!!!! と出力してコンソールを占有しつつ、数分間稼働し続ける
  • systemd 関連プロセス群が利用可能になるまでの時間は数秒だが、②の bash シェル起動は①のプロセス完了を待っている
  • /init から systemd への pid = 1 移し替えは genie -s が担っている (genie -i ではない)
  • systemd を pid = 1 にしているのはエミュレーションであり、genie -s が起動した bash シェル上でのみ有効である (Ubuntu 仮想環境直上で実際に systemd が pid = 1 となっているわけではない)
  • genie -i が起動した systemd 関連プロセス群は genie -u でクリーンアウトできる


したがって、回避策としては下記のようになる。

  • genie -s の担う役割を、①初期化、と、②bash シェル起動 + pid エミュレート、とに分離する (初期化を genie -s に頼らない)
  • systemd がなければ genie -i を起動して初期化する
  • genie -i は、出力を捨てつつバックグラウンドで立ち上げる
  • 初期化開始後に genie -s を起動して bash シェルを起動する (genie -i の完了を待たない)


genie.shの冒頭に記述した systemd の pid を返すエイリアス getsdpid を使うと下記のように環境を調べることができる。

  • 1 が返る → genie -s が起動した bash にいる
  • 1 以外が返る → genie -i が起動した systemd は生きているが、Ubuntu 仮想環境直上の bash にいて systemd の pid は 1 ではない
  • 何も返らない → Ubuntu 仮想環境直上の bash にいて systemd も生きていない


.bashrc もレイヤー違いで重ねて走ってしまうため、重ねると害のある部分 (hwclock など) は getsdpid の返り値によってスキップさせるとよい。

Docker Compose のインストール

Docker Compose も使うことになるため、インストールしておく。

# update package list
sudo apt update
sudo apt upgrade

# install
sudo apt install docker-compose

Docker 利用の省力化

テキストタイプ省力化のためによく使う docker コマンドのエイリアス.bashrc に記述しておく。

alias dc='docker'
alias dcc='docker-compose'
alias dce='docker exec -it $(docker ps -lq) /bin/bash'

Ubuntu 20.04 LTS on WSL2 環境構築メモ (2/4) - GUI 導入

更新 2022.10.09

導入保留としていた WSLg preview を Widows 11 が勝手にアップグレード導入し、VcXsrv と競合するようになってしまったため、泣く泣く VcXsrv を外すことにする。
具体的には XLaunch を起動しないようにし、~/.bash_profile の export DISPLAY 設定を外すだけ。
タスクトレイの XLaunch に頼らなくなったことと LinuxWindows 間でコピペができるようになったのは WSLg の長所だが、初回起動が遅い (2 回目以降は速い)、ウィンドウのデザインや操作感がちょっとダサい、PowerToys の FancyZone 対象外になってしまうのが短所。


前提

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

  • 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
  • 環境構築メモ 1/4 を実施した状況

GUI 導入

Ubuntu 14.04 より apt-get, apt-chache ではなく apt コマンドの使用が推奨されているため、参考記事に旧コマンドが含まれている場合は注意。

X-Window Server (VcXsrv) のインストールと設定

VcXsrv インストール

VcXsrv のインストールは参考記事 初心者のためのWSL( 2 ) ~GUI設定,デスクトップ環境設定編~ のとおり。記事を参考にするのはインストール作業まで。VcXsrv 初期設定以降は参考にするだけで実行しない。

VcXsrv の初期設定と起動確認

XLaunch (VcXsrv のアイコン) をクリックし、初期設定と起動確認を行う。

既定のとおり。Multiple windows を選択 *1

既定のとおり。

Disable access control にチェックを入れる。

Save configuration を押し、config.xlaunchshell:startup へ保存する。その後、完了を押す。

タスクトレイに XLaunch のアイコンが表示されれば起動成功。都度、XLaunch 起動・初期設定をするのは面倒であるため、上記の最終ステップでスタートアップメニューに設定を保存している。

UbuntuGUI 設定
# パッケージのインストール
 sudo apt install x11-apps x11-utils x11-xserver-utils dbus-x11

# bash 起動時の設定とその反映
 echo 'export DISPLAY=xx.xx.xx.xx:0.0' >> ~/.bash_profile
 source ~/.bash_profile

上記 3 行目の設定値 xx.xx.xx.xx は X-Windows Server サービスが走っているホスト OS (Windows 10) 側の IP アドレス。その指定方法は2通りある。

X-Window Server 用の通信設定(動的版)

1つはホスト OS の物理イーサネットアダプタの IP アドレスを固定値指定する方法。もう1つは WSL 起動時に動的に決まるイーサネットアダプタ vEthernet (WSL) の IP アドレスを指定する方法。下記のように指定する。この方法は地のゲスト OS
からだけではなく、ゲスト OS 上の Docker コンテナからの接続でも有効であった。

 export DISPLAY=$(cat /etc/resolv.conf | grep nameserver | awk '{print $2}'):0.0
Windows Defender 受信規則設定

ただ、この方法を採るにはある事前準備が必要で、この点をきちんと解説しているサイトは非常に少ない。

WSL2 とホスト OS との通信はパブリック扱いとなるらしく、WSL2 ⇔ ホスト OS 間で行う通信が Windows Defender に塞がれないようセキュリティについて詳細設定する必要がある。既定設定だと Windows Defender に VcXsrv サービスのすべておよび WSL2 から イーサネットアダプタ vEthernet (WSL) への ping echo back が塞がれてしまうため、パブリックプロファイルで許可を与える。 その手順は以下の通り。

  1. ping echo back を許可するルールの作成方法
    1. "Windows Defender ファイアーウォール" を開く
    2. "詳細設定" を押す
    3. "受信の規則" を右クリックし、"新しい規則" を選択する
    4. "規則の種類" で "カスタム" を選択し、"次へ" を押す
    5. "すべてのプログラム" を選択し、"次へ" を押す
    6. プロトコルの種類で "ICMPv4" を選択し、"カスタマイズ" を押す
    7. "特定の種類の ICMP" を選択し、"エコー要求" をチェックして "OK" をクリックし、"次へ" を押す
    8. ローカル IP アドレス (ホスト OS イーサネットアダプタ vEthernet (WSL) 側) で "これらの IP アドレス" を選択し、"追加" を押し、"この IP アドレスまたはサブネット" で "172.0.0.0/8" と入力する *2
    9. リモート IP アドレス (ゲスト OS 側) で "これらの IP アドレス" を選択し、"追加" を押し、"この IP アドレスまたはサブネット" で "172.0.0.0/8" と入力し、"次へ" を押す
    10. "接続を許可する" を選択し、"次へ" を押す
    11. "プライベート" と "パブリック" を選択し、"次へ" を押す
    12. 受信規則の名前を付け、"完了" を押す
  2. VxXsrc プロファイルの作成方法
    1. "アプリに Windows Defender ファイアウォール経由の通信を許可する" を押す
    2. "VcXsrv windows xserver" を選択し、"プライベート" および "パブリック" をチェックする
    3. "VcXsrv windows xserver" を選択してチェック(有効化)し、"OK" を押す
    4. 上述の ping echo back の設定方法のとおり、"詳細設定" から XcXsrv サービスの "受信の規則" における "ローカル IP アドレス" と "リモート IP アドレス" を設定する *3*4
    5. "プロパティ" > "詳細設定" > "エッジトラバーサル" は "アプリケーションに従う" を選択しておく
GUI のテスト

次のコマンドを実行し、Windows デスクトップ上に目玉が描画されたら成功。

 xeyes &

デスクトップ環境 (xfce) インストール

Ubuntu デスクトップ環境のインストール手順は以下のとおり。GNOME のインストールをトライしてみたが、参考記事と同様にうまくいかなかったため、軽量の xfce にしてみる。

なお Ubuntu デスクトップ環境はインストールしなくてもよい。VcXsrv が個別アプリごとに Windows 10 デスクトップ上に窓を描画してくれるため、すべての作業を Ubuntu 内で完結させる必要はない。Windows 10 に PowerToys 導入済みの場合は窓の配置を細かく指定できるため、Ubuntu デスクトップ環境に頼らない方がむしろ便利かもしれない。

インストール

次のコマンドを実行する。

 sudo apt install xfce4-terminal
 sudo apt install xfce4-session
 sudo apt install xfce4
 export LIBGL_ALWAYS_INDIRECT=1
起動確認

次のコマンドを実行し、Ubuntu デスクトップ環境が起動するか確認する。なお、Ubuntu デスクトップ環境を使用するときは VcXsrv の起動オプションを "Multiple windows" ではなく "One large window" に設定しておく方が取り回ししやすい。

 startxfce4

*1:Ubuntu デスクトップ環境を利用する場合は Multiple windows ではなく One large window 推奨。

*2:動的に決まるためサブネット指定する。172.17.xx.xx だったり 172.30.xx.xx だったりするため、上位1オクテットのみの指定。

*3:簡易設定のみだと、任意のリモートアクセスが可能になってしまうため、ネットワークのスコープを限定する。

*4:UDP しか使わないのだが、TCP も自動的に作成されて今うため、こちらも同様にスコープを限定しておく。

Ubuntu 20.04 LTS on WSL2 環境構築メモ (1/4) - 開発環境導入

前提

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

  • 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

開発環境導入

本ページ手順 Ubuntu 20.04 LTS on WSL2 環境構築メモ (1/4) では問題となるところはないが、以降の手順で参考記事の通りに進まない箇所が多々あるため、環境構築メモとして記録しておく。
Ubuntu 14.04 より apt-get, apt-chache ではなく apt コマンドの使用が推奨されているため、参考記事に旧コマンドが含まれている場合は注意。

日本語フォントのインストール

Ubuntu に日本語フォントをインストールする。後の GUI 設定におけるデスクトップ環境インストール前にやっておくとよいらしい。

 sudo apt install fonts-ipafont

Windows 10 にインストールされているフォントの参照設定

Windows 10 にインストールされているフォントを参照するように設定する。エディタに nano を用いているが何でもよい。

 sudo nano /etc/fonts/local.conf

local.conf に追記する内容は下記のとおり。

<?xml version="1.0"?>
<!DOCTYPE fontconfig SYSTEM "fonts.dtd">
<fontconfig>
    <dir>/mnt/c/Windows/Fonts</dir>
</fontconfig>

開発環境インストール

基礎的な開発ツール群をインストールしておく。

 sudo apt install emacs
 sudo apt install make
 sudo apt install gcc
sudo apt install meld   # including diff etc.

基礎設定

Windows 10 から Ubuntu on WSL2 へのドライブマウント

Windows から WSL2 のドライブへはネットワーク共有フォルダ \\wsl$\ として参照可能であるため、下記のようにドライブレターを割り当てておく

net use U: \\wsl$\Ubuntu-20.04 /persistent:yes

.bashrc と .bash_profile の整備

.bashrc の起動設定

.bashrc を有効にするために .bash_profile に下記の記述を追記しておく。理由は後述のとおり。
Ubuntu on WSL2 が既定で用意している .bashrc が読み込まれるとプロンプトがカラーになるため、カラーか否かをもって .bashrc が有効か否かを判定できる。

# if running bash
if [ -n "$BASH_VERSION" ]; then
    # include .bashrc if it exists
    if [ -f "$HOME/.bashrc" ]; then
	. "$HOME/.bashrc"
    fi
fi

bash 起動の際に読み込まれる初期化スクリプトには .profile, .bash_profile, .bashrc の 3 種類がある。が、環境設定を解説する参考記事のとおりに .bash_profile, .bashrc を編集しても .bashrc が読み込まれない。調べてみると、.profile.bashrc を読み込む記述があり、かつ、.profile.bash_profile が存在しているときは読み込まれないらしい。

WSL2 初期状態では .bash_profile がないため、参考記事に従って .bash_profile を新規作成するのみだと .bashrc の起動トリガーであった .profile が無効になってしまい、.bashrc が動作しなくなる *1。したがって、上述のとおり .profile にある .bashrc 起動ロジックを .bash_profile へ移植する。

うっかり対策

Linux を導入すると怖いのが、うっかりフォルダを全削除してしまうこと。Windows / Linux 相互運用などするときには特に注意。いちおう簡易な対策を .bashrc に入れておく。本格的には定期バックアップを仕掛けるべきか。

# rm 実行前確認を強制
alias rm="rm -i"
時刻同期

(WSL2 を立ち上げたまま) スリープから復帰する (ことを繰り返す) と時刻が狂うらしいため、WSL2 起動時に時刻補正をするように .bashrc に記述する。この処理には 2 ~ 3 秒かかる。

echo 'synchronizing hardware clock ...'
sudo hwclock -s
clear

hwclock に対する sudo をパスワード入力なしで許可するため、sudo visudo コマンドにて /etc/sudoers を下記のように編集する。

# Allow members of group sudo to execute any command
%sudo   ALL=(ALL:ALL) ALL
%sudo   ALL=(ALL) NOPASSWD: /usr/sbin/hwclock -s  #この行を追加

*1:この手の解説の省略が結構な罠。初回構築時にかなりの進捗阻害要因となるだけでなく、試行錯誤しているときは偶然に解決してしまったりして再現性がなく後にまた苦労する。当該環境構築メモを記録するのはこうした部分を補うため。

Functional Programming w/ C# LINQ

.NET 5.0 および C# 9.0 リリースによって LINQ 周りの機能が飛躍的に向上するかと思いきやそうではなかった。期待していた record の with 構文は式木にならず *1 IQueryable や LINQ to Entities で利用できないため、生産性向上にいっさい寄与しない *2

しかし、改めて LINQ 周りを調べてみると、わずかではあるが前進が見られるようだ。かつては IEnumerable 専用であった拡張メソッド TakeLast, SkipLast , Index 付き Select, Zip が IQueryable 対応となっている。

ちょっと残念なのは Zip が IQueryable * IEnumerable と第2引数に IEnumerable しか取れないことだ。まあ Zip のような演算をテーブル間で行う SQL 文法はなく、やるとしたらテーブルと持ち込んだデータ列との間でユーザが独自定義するものだろうという前提なのだろう。そこで IQueryable * IQueryable で Zip できないか拡張メソッドを定義してみた。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;

public static class ExtentionIQueryable {
  // IQueryable 版 Zip (IQueryable<T1> * IQueryable<T2> => IQueryable<ValueTuple<T1, T2>>)
  public static IQueryable<ValueTuple<T1, T2>> Zip<T1, T2>(this IQueryable<T1> seq1, IQueryable<T2> seq2) =>
    seq1.Select( (x, i) => new { Item = x, Index = i } )                                        // 第1引数シーケンスに Index を割り振る
      .Join(seq2.Select( (x, i) => new { Item = x, Index = i } ),                               // 第2引数シーケンスに Index を割り振る
            l      => l.Index,
            r      => r.Index,
            (l, r) => new { Value = new ValueTuple<T1, T2>(l.Item, r.Item), Index = l.Index } ) // Index をキーに Inner Join して値ペアを作る
        .OrderBy( x => x.Index )                                                                // IQueryable.Join() は順序を担保しないかもしれないため、ソートする
          .Select( x => x.Value );                                                              // Index を捨てる

  // IQueryable 版 Zip (IQueryable<T1> * IQueryable<T2> => IQueryable<U>)
  public static IQueryable<U> Zip<T1, T2, U>(this IQueryable<T1> seq1, IQueryable<T2> seq2, Expression<Func<T1, T2, U>> func) {
    var t   = Expression.Parameter(typeof(ValueTuple<T1, T2>), "t");                            // ValueTuple 型のパラメータを受ける式木
    var ti  = new [] { 1, 2 }.Select( i => Expression.Field(t, $"Item{i}") ).ToArray();         // ValueTuple の各要素を配列へ展開する式木
    var fnc = Expression.Lambda<Func<ValueTuple<T1, T2>, U>>(Expression.Invoke(func, ti), t);   // 引数配列を func に充て Invoke する式木

    return seq1.Zip(seq2).Select(fnc);
  }
}

IEnumerable 型の第2引数を受ける標準 Zip が2つのシーケンスを受けて値を組にして ValueTuple で返すものと、さらに演算の式木を受けて演算結果を返すものがあるため、それらに似せた。苦労したのは、2引数を ValueTuple で受ける関数とそのまま受ける関数との間の変換を式木で実現するところ。式木を解説する情報が少ないため、想像力で試行錯誤すること2時間(ここだけで所要時間の8割)。

コンパイルは通り、LINQ to Object を AsQueryable() したものを与えると正しい結果を IQueryable で返すため、理論上は IQueryable として機能しているはず。ただし、本当に LINQ to Entities として機能するかは、各 DB の .NET 5.0 用 QueryProvider のでき次第 *3。この点は別の機会に試験してみることとする。

【2021/02/24 追記】
構文は正しいようだが、残念ながら式木から SQL への変換で下記のようにランタイムエラーとなることから、QueryProvider が対応していないように見える。最もシンプルにして第2引数に Enumrable.Range() をとってオリジナルの Queryable.Zip(this System.Linq.IQueryable source1, System.Collections.Generic.IEnumerable source2, System.Linq.Expressions.Expression> resultSelector) を試すもダメ。それどころかインデックス付き Select もダメ。結局、以前と変化なし。

System.InvalidOperationException: The LINQ expression 'DbSet<XXXXXX>()
    .Take(__p_0)
    .Zip(
        source2: __p_1, 
        resultSelector: (x, y) => new { 
            Property1 = x.Property1, 
            y = y
         })' could not be translated. Either rewrite the query in a form that can be translated, or switch to client evaluation explicitly by inserting a call to 'AsEnumerable', 'AsAsyncEnumerable', 'ToList', or 'ToListAsync'. See https://go.microsoft.com/fwlink/?linkid=2101038 for more information.

*1:with 構文を式木で利用できるようにコンパイラ実装を修正すべきと C# 開発チームに提案したものの却下された。言語仕様上は問題なく、他の類似構文 (オブジェクト初期化子) の実装と明らかに非対称かつ不整合な不備であるため、論理的に問い詰めて食い下がってみた。が、めちゃくちゃ明晰な頭脳の持ち主である先方も最後は美しくない実装不備であることを認めたため、あきらめた。C# 開発チームには人口に膾炙する改善テーマが他にたくさんあり、各種演算子の式木対応は優先度がかなり低いようだ。

*2:with 構文は LINQ to Entities で利用できてこそ生産性を向上させる。LINQ to Object であればプログラマがちょっと気の利いた Clone() メソッドを書くだけで with と同等のことができる。右記参照 Functional Programming w/ C# LINQ - Crayon's Monologue

*3:Index 付き Select も IEnumerable と絡める Zip も SQL 的には難しくないため LINQ to Entities でも機能すると思うが、単純なことも意外とできなかったりするのが QueryProvider のため、過信してはいけない。