ローカルk8sの上に色んなサービスを立てて壊す

ローカルに立ち上げた Kubernetes Dashboard

モダンなサービス開発はとにかく入用のミドルウェアが多い。ちょっとしたものを作るだけで RDB と NoSQL とインメモリキャッシュと監視ダッシュボードが必要になってしまう。遠い記憶に RDB とキーバリューストアとキャッシュと検索とジョブキューをすべて MySQL にやらせていた牧歌的な時代もあったような気がするがもうそんな時代ではないのだった。

実際にサービスをデプロイする段ではなくローカルで開発するときにもそういったミドルウェアがないと開発自体が進まない。かと言って素の OS の上にミドルウェアを入れまくると設定ファイルディレクトリとかデータディレクトリが散逸してアンインストールやトラブルシュートの妨げになってしまう。

おそらくそういった複数のサービスをローカルに立ち上げるときによく使われるのは Docker Compose だと思う。まあそれが正着だと思う。様々なミドルウェアが Docker Compose のサンプルコードを提供している。

そんな Docker Compose にもちょっとした不満があって、開発の成果物として Docker image を作ったならば実際にサービスをデプロイする先は大抵 Kubernetes クラスターに変えないといけない。

(もしかしたら Docker Compose をプロダクション環境で使うケースもあるかもしれないが自分の周りにそういう雰囲気はなかった)

ローカル開発でしか使わない技術を覚えるのも腰が重いし、なら最初からローカルに Kubernetes クラスターを用意してその上でいろいろやればいいのでは、ということで作ったものがこれ。

github.com

これはローカル環境で Kubernetes を実行する方法の一つである kind を利用して様々なサービスを立ち上げたり壊したりできるものだ。

現在のところ以下のサービスを構築&破壊できる。

主要な操作は Makefile に押し込んでいる。まず、クラスター自体の作成と破棄は次のようにする。何かがCPUを食いつぶしているな、というときはクラスター全体を雑に停止できる。

gist1a1da78dc3271f98fda41b05fe858954

そのクラスターの上に各ミドルウェアを立てていく(Redisの例)。

gist59f7240b34ebea34766d95e9c9d8686a

必要であれば Kubernetes Dashboard を見ることもできる(記事上部の画像)。

gist05df9585c8076ac1accd6c27c0b06f69

その他の仕様:

 

さてこれでおよそ目的は達成できた。

  • Docker Compose よりはプロダクションに近い環境で、
  • 構築が面倒なミドルウェアを自由に作成し破棄できる。

と言いたいところだが問題点もある。

  • kind のクラスター設定がどうやら動的に変更できない。つまり公開ポートや永続化ディレクトリを追加したり変更したりするたびにクラスター全体を再作成しないといけない。

やはり Docker Compose の方がよかったかもしれない。

きりたんぽ鍋を作る

きりたんぽ鍋を作ります。

きりたんぽ鍋の具材

具材

  • きりたんぽ
  • さいとうの比内地鶏スープ
  • 鶏肉
  • ネギ
  • ゴボウ
  • マイタケ
  • 糸コンニャク
  • セリ

手順

  1. 鶏肉を手頃な大きさに切る。
  2. 鍋に水1200mlを張って火にかけ、鶏肉を水から煮始める。
  3. ゴボウをささがきにしてボウルの水につける。
  4. ゴボウをボウルから出して鍋に入れる。
  5. 比内地鶏スープを入れる。
  6. 沸騰したら糸コンニャクとマイタケを入れる。
  7. 20分ほど煮たらネギを入れる。
  8. ネギに火が通ったあたりできりたんぽを入れる。
  9. きりたんぽを煮る時間は好みで調節する(1~5分くらい?)。
  10. 最後にセリを投入する。湯気を通す程度でいい。
  11. 完成。

鶏肉とゴボウは水から

糸コンニャクとシイタケ

きりたんぽとネギの投入は後半

セリは最終盤

完成

補足

比内地鶏スープはさいとうのでなくてもいいが稀においしくないスープがある。

比内地鶏スープに含まれているから必要ではないが鶏ガラでダシを取ってもいい。

セリは三関産が望ましい。三関産の場合はセリの根も煮てよい。

きりたんぽは煮る時間が長いと崩れるのであまり長くは煮ない。きりたんぽだけ別の鍋で煮るテクニックがある。

きりたんぽ以外にも「だまこ」を入れてもいい。

秋田県民は家ではきりたんぽ鍋を食べない」と言われて、うちでは作るけどな……となったのでこの記事を書いた。

NixによるHaskell開発環境の構築

Nix package manager によって Haskellスクリプティングおよびパッケージ開発の環境構築をしていく。

こいついつも環境構築してんな

環境構築以外はブログに書くような一般性のあることをやっていないということで……。

ここで触れられないこと

  • cabal.project によるマルチプロジェクトを扱う方法
  • 外部の nix store を使うこと
  • nix の新しいコマンド群 (nix コマンドのサブコマンド群)
  • デプロイや公式 Docker イメージについて
  • Stack integration
  • IOHKのリソース
  • NixOS
  • その他 Nix の深い話

Nix

Nix がなにかよく分からないが様々な環境で使えるパッケージセットとして利用している。

そこだけ聞くと stackage みたいだが全然 Haskell に閉じたものではなくて普通の OS のパッケージマネージャのようなカバー範囲を持っている。 それでいて Haskell のライブラリ依存関係も相当なリソースを投入して安定に保たれているということのようだ。

自分は MacUbuntu on WSL で同じ設定ファイルで Haskell の環境を構築できている。

さて Nix で検索すると NixOS というのが出てくるが OS を考えずにパッケージマネージャだけ利用することができる。

利用形態:

  • Nix package manager のみ
  • NixOS と Nix package manager

Nix ではパッケージセットを channel と呼び、これには何種類かある。
https://nixos.wiki/wiki/Nix_channels

Nix channel:

  • nixpkgs-unstable (= nixpkgs)
  • NixOS-xx.yy (NixOS-21.11 など)

NixOS channel は名前の通り NixOS 用であり Nix package manager だけのときに使うことは推奨されない。 つまり Nix package manager のみの利用のときには nixpkgs を使うことになる。 なお nixpkgs-stable という channel はないようだ。

プロジェクトの環境を固定したいときはこの channel のバージョンを固定すればいい。
https://nixos.wiki/wiki/FAQ/Pinning_Nixpkgs

スクリプティング

まず、Nix を使わなくても Haskell スクリプティングはできる。 cabal および stack のスクリプトはだめぽ氏による記事がとても参考になると思う。
https://zenn.dev/mod_poppo/scraps/e2891dbebb235d

Nix による方法は、まず Nix package manager をお使いの環境にインストールする。

https://nixos.org/download.html

するとシンプルなスクリプトは以下のように書ける (標準入力から1行読んで適当な色をつけて出力するスクリプト)。

> echo "ヤッホー" | ./yamabiko-nix.hs
ヤッホー

nix-shell は Nix の環境の中に入るためのコマンドだが、 shebang に書くことで特殊な動作をしてそのスクリプトを Nix 環境の中で実行するように動く。

コマンドライン引数は複数行に分けて記述できる。

  • -p / --packages: 依存パッケージを指定。デフォルトの channel は nixpkgs が選ばれる。
  • -i / --interpreter: このスクリプトを処理するコマンドを指定。

パッケージ指定が Nix Haskell の特徴的な記述になっている。 これは次のように構成されている。

  • pkgs: nixpkgs のルート
  • haskell.packages.ghc921: GHC 9.2.1 用の Haskell 依存関係の名前空間
  • ghcWithPackages: 依存ライブラリとGHCを同時に指定するための Nix 式 (関数)
  • p: [p.ansi-terminal]: 依存ライブラリのリストを返す無名関数 リストはスペース区切り

GHCバージョンにこだわりがない場合 haskell.packages.ghc921haskellPackages で置き換えてもいい。

選択可能なGHCバージョンは次のコマンドでわかる。

> nix-env -f '<nixpkgs>' -qaP -A "pkgs.haskell.compiler"
pkgs.haskell.compiler.ghc8107                 ghc-8.10.7
pkgs.haskell.compiler.ghc884                  ghc-8.8.4
pkgs.haskell.compiler.ghc902                  ghc-9.0.2
pkgs.haskell.compiler.ghc921                  ghc-9.2.1
...

また channel で管理されている依存ライブラリのバージョンは次のコマンドでわかる。

> nix-env -f '<nixpkgs>' -qaP -A "pkgs.haskell.packages.ghc921.ansi-terminal"
pkgs.haskell.packages.ghc921.ansi-terminal  ansi-terminal-0.11.1

ghcWithPackages の実装は ここ にある。 GHCとライブラリを別々に指定してしまうとGHCがライブラリを見つけられないらしい。

ちゃんとした開発

haskell-language-server の導入

haskell-language-server はエディタが見るものだし、 複数のGHCバージョンに対応するものでもあるので、 他に使いそうなものと一緒にグローバルにインストールしてしまう。

次のファイルを $HOME/nix/haskell/default.nix にでも用意して nix-env -i をたたくとグローバルにインストールされる。 (この default.nix は こちら を参考にさせてもらっている)。

> nix-env -i --file $HOME/nix/haskell/
...
building '/nix/store/knnnspc3dxxbk9nwv9rqznrrf01rrl8d-user-environment.drv'...

overlay によって haskell-language-server にオプションを与えている。 supportedGhcVersions は読んでの通り。

dynamic=true については何のドキュメントも見つけられないが Template Haskell のコードを扱うなら必要なようだ。 ただしこのオプションを有効にすると haskell-language-server のインストールに余計に時間がかかるかもしれない。 オプションの実装は ここ にある。 haskell-language-server 側のドキュメントでは ここ に相当する処理をしていると思われる。

パッケージの設定

Nix での Haskell 開発について調べていると cabal2nix という名前がよく出てくるが、これを直接使う機会はあまりなさそうだ。 今は nixpkgs の Haskell module が cabal2nix をラップした便利関数をいろいろ提供していて、 それらを使うのが主流のようだ。 この記事でも cabal2nix を直接は使わない方法を説明する。

まずはいつものように cabal init でパッケージを初期化する。 (--enable-nix オプションは不要かもしれない)。

> mkdir test-package
> cd test-package
> nix-shell -p "pkgs.cabal-install" -p "pkgs.haskell.compiler.ghc921" --run "cabal --enable-nix init --exe"

初期化ができたら次のような default.nix を用意する。

これで次のようにすればパッケージの executable が実行できる。 (nix-build は初回だけでいいはず)。

> nix-build
> nix-shell --run "cabal run"
"Hello, Haskell!"

あとは普通に .cabal ファイルを編集して開発していけばいい。

さて、この default.nix は nixpkgs の Haskell module が提供する developPackage を呼んで Haskell パッケージの開発のための設定を構築している。

source-overrides で依存関係のオーバーライドができる。 Hackage のバージョンを指定する方法と tarball のURLを指定する方法がある。

modifier では cabal のいくつかのオプションを設定できる。 設定項目の内訳は ここ にある。

なお、ghcBuild 引数に値を与えて別のGHCバージョンを指定することもできる。 (GHCと base のバージョンが合わないときは version-history を参照して調整する)。

> nix-shell --argstr "ghcBuild" "ghc902" --run "cabal run"

高度な依存関係オーバーライド

この項目を書くときに力尽きた。 依存関係オーバーライドはそれほど困難な道だった。 どうしてもオーバーライドしたいなら次の記事が参考になる。

Nix recipes for Haskellers – Sridhar Ratnakumar

How to override dependency versions when building a Haskell project with Nix

トラブルシューティング

nix-shell の立ち上がりが遅い

direnv Nix integration を導入するといいかもしれない。 direnv 自体は Nix と関係なく使えるひとつのツールだが、 Nix がこれと連携するのにはいくつかのオプションがある。

https://github.com/direnv/direnv/wiki/Nix

自分はシンプルそうな nix-direnv を使っている。

VSCode に direnv 拡張を導入すると VSCode からも direnv の環境が見えるようになる。 ただ過渡期にあるようで何を選ぶか注意が必要そうだ。

cabal run で cabal 自体の出力を無視したい

これは Nix というより cabal の Tips だが次のようにするといいかもしれない。

> nix-shell --run "cabal build && \$(cabal list-bin test-package) | clip.exe"

Mac で nix-env -i が失敗する

すでに修正されたようだが Mac 環境で nix-env -i が失敗することがある。 そのときは以下の issue にある方法で回避できることがある。

https://github.com/NixOS/nixpkgs/issues/163374

Mac で channel が消滅する

理由は分からないが Mac で channel 設定が消滅することがある (nix-channel --list が何も返さない)。 nixpkgs を自分で再設定する必要があるかもしれない。

> nix-channel --add https://channels.nixos.org/nixpkgs-unstable nixpkgs 
> nix-channel --update nixpkgs

家から出ない生活4週間

出社非推奨になったのを機に家から出ない生活を始めて4週間が経った。今までやったことをまとめていく。

食料品

せっかくだから生活で縛りプレイをしてみようと思って Amazon と Yodobashi を禁止にしている。この2社は最近評判が良くないこともあるし。

業務スーパーの通販

いわゆるネットスーパーはサイトが激重になっていたり配達可能日がすべて☓になっていたりで機能不全を起こしているようだった。そこで飲食店がメインターゲットであろう業務スーパーなら今空いているだろうという読み。ここではピザ材料、冷凍野菜、割り箸などを入手している。

とにかく業務スーパーの通販を最大限利用するには冷凍室の容積が必要で、製氷皿とズブロッカには戸棚待機をお願いすることとなった。

日清オールインパス

栄養が偏って脚気とか壊血病になるのが怖いのでやや高いしかさばるが栄養補助の目的で食べている。

森永製菓の通販

もともと健康食品用のサイトだったのか矢鱈とコラーゲンを推してくるが、普通のお菓子もいくらか買える。inゼリー、プロテインバー、ラムネなど。

コーヒー

マクドナルドのコーヒーを入手できなくなって家のコーヒー豆消費が増えた。そしたら実家が秋田の『薫』と『アラジン』の豆を送ってくれた。ありがたい。

通販だとだいたい『ネルソンコーヒーロースター』か『たまこのコーヒー』を利用している。

ネルソンコーヒーロースター

仙台所在のロースター。同市の『ネルソンコーヒー』とは別らしい。まじかよ。よくふざけた名前のパックを出している。500g1300円のパックが安い。

たまこのコーヒー

『多摩ワールドコーヒー』の通販。キャラクターがかわいい。vtuber にもなっている。

リモートワークあるいはWFM

椅子

ずっと家にいると腰がやばそうなので椅子は買うことにした。ところが部屋の片付けをしないうちに届いてしまったため倉庫番ゲームが発生し結局腰は痛めた。

『座面と背面が細かいメッシュ』という条件だけで10万円超えるんだよね……。会社の補助制度っぽいものを利用できたとはいえ試座してから決めたかった。

テレビ会議

カメラはラップトップ備え付けのものでいいとしてもマイクが雑音源と近すぎて憚られたため、マイクは買うことにした。が、すでに低価格帯のマイクの在庫はなくなっていたのだった。

BGM

あるていどの騒がしさがないと作業に集中できない人は vtuber の歌枠でも流しておけばいいんじゃないかな。雑に最近のをまとめた再生リストを置いておく。 https://www.youtube.com/playlist?list=PL1bkZdoXAsdpO3zlsyU5zDUdqh8fS2jmG

運動

品薄になる前にリングフィットアドベンチャー買えた勢。

余暇

FF14 と STEAM と vtuber があるので暇はない。

未解決問題と所感

散髪に行けないのが辛い。さいわいなぜかパナソニックの長めに切れるバリカンみたいなの(これ)を持っているので自分で切れないこともない。

窓から遠くが見える立地でないので近眼がさらに進みそう。HMDをかぶると視力が良くなる説を検証してみるべきか。今HMD手に入るのか知らないが……。

あとは4週間も在宅勤務できるなら今までも毎日出社する必要はなかったのではと思う。たとえ出社非推奨が解かれたとしても前のように毎日全員が出社する前提には戻らないんじゃないだろうか。個人的にも満員電車がもたらす不快指数に加担するのは心苦しい。

とは言え

とは言えそろそろ外に出たくもなってきた。記憶が確かなら歩いていける距離に卵の自販機があったはずだ。さしあたってそこを目標地点にして人のいない時間帯に出歩いてみることにしよう。

HaskellやっていくGHC8.8.1令和元年白露の候

GHC 8.8.1 が出たので雑に環境を整えていきます。

OS: Ubuntu 18.04 LTS on VirtualBox on Windows

f:id:kazeula:20190914233248p:plain
screenshot

GHC, Cabal

最近は ghcup で入れている。

$ ghcup upgrade
$ ghcup list --tool all
$ ghcup install 8.8
$ ghcup install-cabal latest

~/.ghcup/bin と ~/.cabal/bin にPATHが通っていない場合は通しておく。

stack は手になじまない感じがして最近使っていない。 stack のいいところとしてスクリプトが書けるというのがあったが、最近は cabal でもいい感じに shebang を書けばスクリプティングできるようになったというのもある。

Haskellツール

使いそうなHaskellツールをインストールする。

$ cabal new-update
$ cabal new-install doctest hlint ghcid fast-tags --installdir=$HOME/.cabal/bin

まだ GHC 8.8.1 が世に出て日が浅いのでインストールできないパッケージも多い。 自分が試した時点では以下のものはインストールできなかった。

  • apply-refact
  • hasktags
  • stylish-haskell
  • hhp (HEADでは対応済みだがhackageに上がっていない?)
  • haskell-ide-engine (rc版は出ている?)

ツールによってはわざわざGHC8.8.1でインストールする必要がないものもある。 そういうものは ghcup setGHCを切り替えてインストールするといいかもしれない(試してはいない)。

エディタ

モーダルでカスタマイザブルなエディタがいいので Spacemacs を使う。 おそらく Emacs 26 が必要になるが素の apt だと入らないかもしれない。 そのときは apt repository を追加するなどする。 あと Spacemacs は master がだいぶ古くて皆 develop を使っているようなので自分もそうしている。

自分の.spacemacs: https://gist.github.com/syocy/5ea4d65f3f819a2a2e82e3f8ff2f3d82

シェルは fish シェルを使っているが、そのせいか Spacemacs がうまくPATHを継承しないことがあった。 そのときは SPC f e e で環境設定ファイルを開いて直接編集すればいいようだ。

Haskell layer の補完バックエンドは company-ghci を使っている。 dante は自分の環境では動かなかった(戦いの爪痕が.spacemacsに残っている)。 company-ghci は SPC m s b で ghci をロードしておかないと動作しないので注意。

インタラクティブなエラーチェックは Haskell layer に含まれる flycheck-haskell を使っている。 ghci のほか、hlint があるとそれも走らせてくれる。 タイル型ウィンドウマネージャ(i3wm)を使っていた頃は ghcid を使っていた。 flycheck-haskell を使っていくうちに不都合があれば戻るかもしれない。

そのほか細かい設定は .spacemacs を見てほしい。

サンプルプロジェクト

cabal init が最近強化されたらしく、プロジェクトを作るのがより楽になったようだ。 cabal init --help を見るとオプションが増えている。 たとえばテストのあるライブラリとしてプロジェクトを作るには以下のようにする。

$ cabal init --lib --source-dir=src --tests --test-dir=test -p sample-project

ちなみに冒頭の画像を再現するには、

  • SPC e L で flycheck のエラーウィンドウを開く
  • SPC f t (SPC p t) で treemacs ウィンドウを開く
  • SPC w でウィンドウをお好みで配置する

などすればできる。 なおウィンドウレイアウトの保存は SPC l でできる(昔の eyebrowse layer?)。

以上、GHC 8.8.1 でも Haskell やっていくぞ。

追記: Cabal 3.0 から一部オプションのデフォルト値が変わった関係で flycheck-haskell や doctest のようなツールが動作しないということがあった。そういうときは cabal のオプションに --write-ghc-environment-files=ghc8.4.4+ を指定するとよい。 stackoverflow にもその旨を書いた。

GHCの線形型プロトタイプを試すだけ

GHCに線形型を導入すると以下のような良い事があるらしい。

  • リソース安全性: ファイルハンドル、ソケット、DBコネクションのようなリソースについて、これらを提供するAPIの設計者が安全な使用を強制できる。リソース解放後のアクセス、二重解放、解放忘れを防止することができる。
  • レイテンシ: リソースAPIの実装をうまくやるとoff-heap(GCの対象外)でリソースを確保・解放できる。GC対象が少なくなることによりGCによってプログラムが停止する時間を減らせる。
  • 並列性: 過剰な直列化を強要しない。リソース安全性を保ちつつもできる限り並列化できる。

詳しくはproposal

この記事では線形型GHCのプロトタイプ実装および線形型ファイル操作ライブラリのプロトタイプ実装を試してみる。 ここで紹介するものが正式リリースで変更されている可能性は大いにある。

なおcabalのnew APIを使うのがマイブームなので今回はstackを使わない方法でやっていく。

GHCのビルド準備

まずGHCのビルドに必要なものをインストールする。

  • autoconf
  • automake
  • ncurses
  • happy
  • alex
  • cabal-install
  • ghc

このうち、autoconf, automake, ncursesはapt等でインストールできるはず。

ghcUnix系環境であればghcupで導入するのがやりやすいと思う。 異なるバージョンのghcを切り替えることもできる。 (今回のように独自ビルドしたghcも管理対象に追加できるのかは未確認)

ghcup install 8.6.3
ghcup set 8.6.3
ghcup install-cabal
ghcup new-install cabal-install

happy, alexはcabalで入れればよい: cabal new-install happy alex

linear-typesブランチをビルド

GHCのソースはGitHubのミラーから取得するのが速い。

git clone https://github.com/ghc/ghc

linear-typesブランチは別のところで開発されているのでリモートソースに追加してチェックアウトする。 (この記事でのcommit id: 782869e3d1a25b4a84c405be346ef5b9c1fbfc8b)

cd ghc
git remote add tweag https://github.com/tweag/ghc.git
git fetch tweag linear-types
git checkout tweag/linear-types

GHCのgit運用がGitHubとミスマッチを起こしているところがあるので少し手を加える。

git config --global url."git://github.com/ghc/packages-".insteadOf git://github.com/ghc/packages/
git config --global url."http://github.com/ghc/packages-".insteadOf http://github.com/ghc/packages/
git config --global url."https://github.com/ghc/packages-".insteadOf https://github.com/ghc/packages/
git config --global url."ssh://git\@github.com/ghc/packages-".insteadOf ssh://git\@github.com/ghc/packages/
git config --global url."git\@github.com:/ghc/packages-".insteadOf git\@github.com:/ghc/packages/

ビルドする。makeのNは物理CPUコア+1にするのが良いらしい。

./boot
./configure
make -j N

ビルドできたら、 ./inplace/bin/ghc-stage2 がよく知るghcコマンドになっている。

実際に試す

やっと線形型GHCを実行する準備ができた。 コードを様々に変えて試すには ghci の :reload や ghcid の変更検知のようなインタラクティブな環境を用意するとやりやすい。

ghci は ./inplace/bin/ghc-stage2 --interactive で起動する。

ghcid なら ghcid --command='/path-to-ghc/ghc/inplace/bin/ghc-stage2 --interactive' Main.hs のようにするとよいだろう。

実験用プロジェクトを cabal init で作る場合は cabal new-configure でプロジェクトのビルドで使うghcを変更できる。 このサブコマンドによる変更は cabal.project.local というローカル環境用ファイルに保存される。 プロジェクトを GitHub などに上げるなら .gitignore に追加しておくとリポジトリから環境依存のファイルをなくせる。

cabal new-configure --with-compiler=/path-to-ghc/ghc/inplace/bin/ghc-stage2

それではコードをコンパイラにかけてみよう。

{-# LANGUAGE LinearTypes #-}

module Main where

flugal :: a ->. (a, a)
flugal a = (a, a) -- Error!

wasteful :: a ->. b ->. a
wasteful a b = a -- Error!

main = putStrLn "Hello, LinearTypes"

flugalwastefulコンパイルエラーになる。 ->.LinearTypes で有効になる型レベル演算子で、 -> とほぼ同じだが左の値を必ず一度だけ使わなければならないという制限がつく。 flugal は a を2回使っているのでエラー、 wasteful は b を1回も使っていないのでエラーとなる。

より実用的な例も見てみよう。 linear-base パッケージは線形型によるファイル操作API(とそれに必要な諸々)を提供する。 これは現時点で Hackage にアップロードされていないので GitHub から入手する必要がある。 実験用プロジェクトをcabalプロジェクトで作っている場合は、以下の内容で cabal.project というファイルを作ると cabal new-buildGitHub からパッケージを持って来てくれる。

source-repository-package
    type: git
    location: https://github.com/tweag/linear-base
    tag: 0d6165fbd8ad84dd1574a36071f00a6137351637

packages: ./

ファイルを2つオープンし、片方から1行読んでもう片方に書き込むプログラムは以下のようになる。 cabalプロジェクトの場合は cabal new-run で実行できる。 これはちょっと込み入っている。

{-# LANGUAGE LinearTypes #-}
{-# LANGUAGE RebindableSyntax #-}
{-# LANGUAGE RecordWildCards #-}

module Main where

-- linear-base
import qualified Control.Monad.Linear.Builder as Linear
import qualified System.IO.Resource as RIO
import Prelude.Linear (Unrestricted(Unrestricted))

-- base
import System.IO (IOMode(ReadMode, WriteMode))
import qualified System.IO as System

mainRIO :: RIO.RIO (Unrestricted ())
mainRIO = do
  inHandle <- RIO.openFile "Main.hs" ReadMode
  outHandle <- RIO.openFile "dup.txt" WriteMode
  (inHandle', outHandle') <- dupOneLine inHandle outHandle
  RIO.hClose inHandle'
  RIO.hClose outHandle'
  return (Unrestricted ())
    where
      Linear.Builder{..} = Linear.monadBuilder -- for do-notation

      dupOneLine :: RIO.Handle ->. RIO.Handle ->. RIO.RIO (RIO.Handle, RIO.Handle)
      dupOneLine inHandle outHandle = do
        (Unrestricted l, inHandle') <- RIO.hGetLine inHandle
        outHandle' <- RIO.hPutStrLn outHandle l
        return (inHandle', outHandle')

main :: System.IO ()
main = RIO.run mainRIO

いくつかの要素が登場している。 要素ごとに見ていこう。

RebindableSyntaxRecordWildCards は do 記法の実装を通常の Control.Monad から別のものに変更するためにつけている。 なんでそんなことをするのかというと Control.Monad は線形型向けに作られたものではないため評価に線形型の制限が入っていない。 線形型の制限が入ったバージョンの Monad として linear-base は Control.Monad.Linear を提供しており、 線形型 do を使いたい関数の where 節で Linear.Builder{..} = Linear.monadBuilder のようにすると、 RebindableSyntaxRecordWildCards の働きにより do の実装を線形型版 Monad に変更することができる。

RIO は Resource-aware IO の意味で、線形型 Monad 版の IO となっている (rioとは関係ない)。 IOという名前だが現状提供されているのはいくつかのファイル操作だけである。 基本的なAPIと型シグネチャは通常のIOのものと似ているが、 hClose 以外の関数はすべてハンドルも返すようになっているのがポイント。

RIORIO.run 関数によって通常のIOの中で起動することができる。 ところどころに出てくる Unrestricted というのは線形型の制限の中から制限のない値を取り出すときに使うデータ型である。

さて、このプログラムで線形型の制限がちゃんと働いているか確かめるには hClose を削ったり2回呼んでみるとか、 dupOneLinehGetLinehPutStrLn が返したハンドルではなく引数のハンドルをそのまま返すなどしてみるとよい。 コンパイルエラーのメッセージがどのようなものになるかぜひ確認してみてほしい。

所感

線形型の導入によって一般のHaskellプログラマーは影響を受けるだろうか?

私は影響は限定的だと思う。 線形型は LinearTypes をONにして型シグネチャ->. を使ってはじめて有効になる。 線形型の導入による実行時システムへの変更もない。 今後リソース系ライブラリで線形型APIを提供するものが現れて、 それを使うのであれば線形型Haskellの書き方を学習する必要はあるだろう。

別に今までのHaskellのリソース管理が危険だったということもなく、 以下の記事に紹介されているようにHaskellにはすでに様々なリソース管理のツールがある。

qiita.com

あえて線形型を必要とするのは、 上記のように低レイヤーのAPIを提供するリソース系ライブラリか、 レイテンシが特に気になるサーバやゲームの開発くらいだろうか。

ひとつ気になることがあるとすれば、線形型GHCを主導しているのが Tweag I/O だということだ。 Tweag はGHCを拡張して asterius という Haskell to WebAssembly コンパイラを開発している。 GHCへの線形型の導入が、将来の Haskell によるWebプログラミングを見据えてのものだという可能性はあると思う。 クライアントサイドWeb開発はまさにレイテンシが重要な領域だからだ。

Dhallによるリッチな設定ファイル体験

(この記事のレギュレーション: lts-11.9)

Dhall という設定記述用言語があり、使ってみたところ良い感じだったので紹介します。

なお、この記事は先日某所で発表したものの拡大版になります。

speakerdeck.com

Dhallとは何か

Dhallについて短かく表現するなら公式サイトの以下の説明が分かりやすいです。

You can think of Dhall as: JSON + functions + types + imports

データ表現にプログラマブルさと静的な検査とファイルのインポートを加えたものというわけです。

まだ開発中のためかあまりアピールされていませんがツールチェインも充実しており、 ちょっとした処理を確かめるためのREPLや、 今どきの言語らしく公式フォーマッタもあります。

あと大事なのはチューリング完全ではないということです。 具体的にはループなどは書けません。

設定ファイルの役割というものはソフトウェアの成長に従って肥大化していく宿命にあり、 最初は単純なJSONYAMLで済んでいたものの上に独自形式のマクロが追加され、 単純な文字列置き換えで済んでいるうちはいいものの要求の向上に従ってマクロの役割は増えていき、 屋上屋を重ねた末にはシンタックスハイライトが効かない・実行時にならないとどういう設定になるか分からない・下手すれば無限ループして停止しない設定ファイルができあがってしまいます。

Dhallは意図的にパワーを抑えて設定ファイルの領分を守りながらも「簡単な関数くらいは使いたいよね」という要望に応えてくれる、 とても良いバランスを達成していると思います。

Dhallはすでにいくつかの 導入事例 もまとまっており、プロダクションで十分に利用可能と言えるでしょう。 (僭越ながら先日私も会社で使われている長大なYAMLをDhallから生成する仕組みを作りました。 とても便利)。

導入方法

Dhallには dhall コマンドなどのコマンドラインツール群と、 dhall-json や dhall-to-cabal といったアプリケーション、 そして各プログラミング言語から Dhall ファイルを読み込むためのいくつかのバインディングがあります。 サポートされている言語には今のところ Haskell と Nix があり、他にも Scala と Rust が開発中のようです。

Dhallコマンドラインツール

Stackがない場合はインストールします。 例として Un*x の場合:

$ curl -sSL https://get.haskellstack.org/ | sh
$ stack setup --resolver=lts-11.9

必要に応じて ~/.local/bin にPATHを通してください。

Dhallツール群は以下のようにしてインストールします。

$ stack install dhall --resolver=lts-11.9
$ ls ~/.local/bin/dhall*
dhall    dhall-format    dhall-hash    dhall-repl

(注意: 最近コマンドラインツールの構成に変更があり dhall-formatdhall-hashdhall のサブコマンドになりました (issue)。 この変更は今回採用している lts-11.9 にはまだ入っていないので、この記事ではこれらはまだ独立したコマンドとして扱います。)

dhall コマンドは Dhall ファイルの評価をするもので、関数の展開や型チェックをします (つまり、アプリケーションに投入する前にどのような設定になるか&形式が間違っていないかが分かる!)。

dhall-format コマンドは Dhall ファイルのフォーマッタです。 Dhall ファイルの保存時にこれが走るようにエディタを設定しておくのがおすすめです。 Emacsであれば dhall-mode を入れておけば勝手にそのような設定にしてくれます。

Dhallアプリケーション

現在のところ Dhall を利用したアプリケーションとしては以下のものがあるようです。

dhall-json
Dhall ファイルを JSONYAML に変換します。 既に JSONYAML の設定ファイルを使用しているのであれば、 これを使えば静的検査や関数のある Dhall を本番フローに導入しやすいかもしれません。 stack install dhall-json --resolver=lts-11.9 とすると dhall-to-jsondhall-to-yaml の2つのコマンドがインストールされます。

dhall-to-cabal
Dhall ファイルを cabal ファイルに変換します。 またそれだけでなく、cabal ファイルのための(おそらく cabal を完全にカバーする**多くの型定義、 そして記述を楽にするための多くの関数を提供します。 Dhall を本格利用する上でのノウハウの宝庫ですので cabal ファイルに興味がない人でも参考文献として覚えておくとよいでしょう。 stack install dhall-to-cabal --resolver=lts-11.9 でインストールできます。

dhall-kubernetes Dhall ファイルで Kubernetes の設定を記述できるようにするツールです。 Kubernetes は長大な YAML 設定ファイルで知られるのでまさに Dhall が生きる領域と言えるでしょう。 dhall-kubernetes 自体は Dhall ファイルのみで構成され、YAMLへの変換には dhall-json を用います。 ただ、要求する Dhall のバージョンがより新しいもののため LTS-11.9 環境ではインストールできません。 stack instal dhall-json --resolver=nightly で最新の dhall-json をインストールする必要があります。

バインディング

Dhall をプログラミング言語から利用する場合はその言語での Dhall のバインディングが必要です。

たとえば Dhall ファイルを Haskell アプリケーションから読む場合は Haskell の dependencies に dhall パッケージを加えます。

Dhall基礎編

それではDhallの各要素を見ていきましょう。

データ表現

データの表現は一般的な設定ファイルを表現するのに十分なものを揃えています。

プリミティブとして Bool, Integer, Double, Text (あとそれほど使わない気がするけど正の整数を表す Natural) があります。

$ echo 'True || False' | dhall
Bool

True
$ echo '1' | dhall
Integer

1
$ echo '2.0' | dhall
Double

2.0
$ echo '"Hello, World"' | dhall
Text

"Hello, World"

複合表現として List, Optional, Record, Union があります。

$ echo '[1, 2]' | dhall
List Integer

[ 1, 2 ]
$ echo '[] : Optional Integer' | dhall
Optional Integer

[] : Optional Integer
$ echo '{ x = 1, y = 2 }' | dhall
{ x : Integer, y : Integer }

{ x = 1, y = 2 }

$ echo '<Number = 1 | Name : Text>' | dhall
< Name : Text | Number : Integer >

< Number = 1 | Name : Text >

Union の値の書き方が煩雑(使わない方の識別子も書かなければならない)ですがこれには解決策が用意されています。 後で述べます。

型定義

複合表現に名前をつけて独自の型を定義することができます。

$ echo 'let Point2d = {x:Integer, y:Integer}: Type in {x=1, y=2}: Point2d' | dhall
{ x : Integer, y : Integer }

{ x = 1, y = 2 }

もちろん型が合っていなければ教えてくれます(出力にあるように dhall の代わりに dhall --explain を使えばより詳細なメッセージを出してくれます)。

$ echo 'let Point2d = {x:Integer, y:Integer}: Type in {x=1, y="2"}: Point2d' | dhall

Use "dhall --explain" for detailed errors

Error: Expression doesn't match annotation

{x=1, y="2"}: Point2d

(stdin):1:47

外部ファイルのインポート

Dhallでは外部のファイルをインポートして使うことができます。

たとえば以下のような Point2d 型を定義する Point2d.dhall を用意します。

$ cat Point2d.dhall
{ x : Integer, y : Integer } : Type
$ cat Point2d.dhall | dhall
Type

{ x : Integer, y : Integer }

これを以下のようにインポートすれば別のファイルで Point2d 型を使って型チェックすることができます。

$ dhall
{ x = 1, y = 2 } : ./Point2d.dhall [Enter]
[Ctrl-d]
{ x : Integer, y : Integer }

{ x = 1, y = 2 }
$ dhall
{ x = "1", y = 2 } : ./Point2d.dhall [Enter]
[Ctrl-d]
Use "dhall --explain" for detailed errors

Error: Expression doesn't match annotation

{ x = "1", y = 2 } : ./Point2d.dhall

(stdin):1:1

なおここではローカルのパスからインポートしましたがURLからインポートすることもできるようです。

関数

関数は無名関数として作ることができます。 以下は Integer の引数を1つ受け取ってそのまま返す関数です。

$ echo 'let id = \(x:Integer) -> x in id 10' | dhall
Integer

10

複数の引数を取る関数は1つの引数を取る関数をネストすることで記述できます。 これは一見面倒にも見えますが、無名 Record を引数に取ることもできるので実際のところ問題にはなりません。

また、Dhallの関数は 型を引数に取る ことができます。 引数に取る型は標準のものだけでなく自作の型でもOKです。 以下のようにすれば前述の id 関数が任意の型の値を取れるようになります。

$ echo 'let id = \(t:Type) -> \(x:t) -> x in id Double 1.0' | dhall
Double

1.0

便利な演算子・標準関数

演算子 // は2つの Record を併合します。 実用上は自作型のデフォルト値を少し書き換えるような場合によく使います。

$ echo '{x=1, y=2} // {y=-1, z=-2}' | dhall
{ y : Integer, z : Integer, x : Integer }

{ y = -1, z = -2, x = 1 }

上の方で Union の値を記述するときは使わない識別子も書かねばならず煩雑であることを述べました。 constructors 関数は Union のコンストラクタを生成して値の記述を楽にしてくれます。

$ echo 'let NN = constructors <Number:Integer | Name:Text> in NN.Number 1' | dhall
< Name : Text | Number : Integer >

< Number = 1 | Name : Text >

merge 関数は Union を何らかの特定の型に変換するときに使います。 実用上は、人間が設定を記述するときは変な値が入らないように型で制限したいけども最終的に欲しいのは文字列であるような場合によく使います({=} は空の Record 値を表します)。

$ dhall[Enter]
let OS = <iOS:{} | Android:{}>
in let OSs = constructors OS
in let handlers = {iOS=\(_:{})->"iOS", Android=\(_:{})->"Android"}
in let osToText = \(o:OS) -> merge handlers o
in osToText (OSs.Android {=}) 
[Ctrl-d]
Text

"Android"

あと Dhall の Text は string interpolation ができます。

$ echo 'let hello = \(name:Text) -> "Hello, ${name}!" in hello "Dhall"' | dhall
Text

"Hello, Dhall!"

Dhall実践編: Kubernetes設定ファイル

Dhall 実践編として、 dhall-kubernetes ではありませんが簡単な KubernetesYAML 設定ファイルを Dhall で作成してみます。 目標とする YAMLhttps://kubernetes.io/docs/concepts/services-networking/service/ にある以下のものです。

kind: Service
apiVersion: v1
metadata:
  name: my-service
spec:
  selector:
    app: MyApp
  ports:
  - protocol: TCP
    port: 80
    targetPort: 9376

まずはこれを型チェックもなにもないシンプルな Dhall で表してみます。

service.dhall:

{ kind =
    "Service"
, apiVersion =
    "v1"
, metadata =
    { name = "my-service" }
, spec =
    { selector =
        { app = "MyApp" }
    , ports =
        [ { protocol = "TCP", port = 80, targetPort = 9376 } ]
    }
}

これを dhall-json に通してみます。

$ cat service.dhall | dhall-to-yaml 
apiVersion: v1
kind: Service
spec:
  selector:
    app: MyApp
  ports:
  - targetPort: 9376
    protocol: TCP
    port: 80
metadata:
  name: my-service

目標とする YAML が得られました。

ただこれだと、例えば "Service" を "Servise" と typo してしまったとしてもエラーを教えてくれたりはしません。

そこで Dhall の持つ静的チェック、関数、インポートの機能を駆使して記述をサポートしていきましょう。 設定ファイルの記述をサポートするためのファイルは k8s_types.dhall という名前で作り、 実際の設定を記述するファイルがそれをインポートするようにします。

k8s_types.dhall:

    let Kind_ = < Service : {} | Deployment : {} | Pod : {} >

in  let kindHandlers =
          { Service =
              λ(_ : {}) → "Service"
          , Deployment =
              λ(_ : {}) → "Deployment"
          , Pod =
              λ(_ : {}) → "Pod"
          }

in  let ApiVersion = < v1 : {} >

in  let apiVersionHandlers = { v1 = λ(_ : {}) → "v1" }

in  let Metadata : Type = { name : Text }

in  let Selector : Type = { app : Text }

in  let Protocol = < TCP : {} | UDP : {} >

in  let protocolHandlers = { TCP = λ(_ : {}) → "TCP", UDP = λ(_ : {}) → "UDP" }

in  let Port
        : Type
        = { protocol : Protocol, port : Integer, targetPort : Integer }

in  let PortMerged
        : Type
        = { protocol : Text, port : Integer, targetPort : Integer }

in  let _mergePort =
            λ(p : Port)
          → { protocol =
                merge protocolHandlers p.protocol
            , port =
                p.port
            , targetPort =
                p.port
            }

in  let Spec : Type = { selector : Selector, ports : List Port }

in  let SpecMerged : Type = { selector : Selector, ports : List PortMerged }

in  let List/map =
          https://raw.githubusercontent.com/dhall-lang/Prelude/35deff0d41f2bf86c42089c6ca16665537f54d75/List/map 

in  let _mergeSpec =
            λ(s : Spec)
          → (   { selector =
                    s.selector
                , ports =
                    List/map Port PortMerged _mergePort s.ports
                }
              : SpecMerged
            )

in  let Config
        : Type
        = { kind :
              Kind_
          , apiVersion :
              ApiVersion
          , metadata :
              Metadata
          , spec :
              Spec
          }

in  let ConfigMerged
        : Type
        = { kind :
              Text
          , apiVersion :
              Text
          , metadata :
              Metadata
          , spec :
              SpecMerged
          }

in  let _mergeConfig =
            λ(c : Config)
          → (   { kind =
                    merge kindHandlers c.kind
                , apiVersion =
                    merge apiVersionHandlers c.apiVersion
                , metadata =
                    c.metadata
                , spec =
                    _mergeSpec c.spec
                }
              : ConfigMerged
            )

in  { Kinds =
        constructors Kind_
    , ApiVersions =
        constructors ApiVersion
    , Protocols =
        constructors Protocol
    , Config =
        Config
    , mergeConfig =
        _mergeConfig
    }

これを使って実際の設定を記述します。 ({=} は空の Record 値を表します)。

service_typed.dhall:

    let k8s = ./k8s_types.dhall 

in  let myService =
            { kind =
                k8s.Kinds.Service {=}
            , apiVersion =
                k8s.ApiVersions.v1 {=}
            , metadata =
                { name = "my-service" }
            , spec =
                { selector =
                    { app = "MyApp" }
                , ports =
                    [ { protocol =
                          k8s.Protocols.TCP {=}
                      , port =
                          80
                      , targetPort =
                          9376
                      }
                    ]
                }
            }
          : k8s.Config

in  k8s.mergeConfig myService

先ほどと同じように dhall-to-yaml に通します。

$ cat service_typed.dhall | dhall-to-yaml
apiVersion: v1
kind: Service
spec:
  selector:
    app: MyApp
  ports:
  - targetPort: 80
    protocol: TCP
    port: 80
metadata:
  name: my-service

結果を変えずに静的なチェックを追加することができました! もちろん "Service" などを typo したらエラーが出て教えてくれます。

なお、今回はあくまで設定ファイルに間違った値が入り込まないようにすることに主眼を置きましたが、 設定ファイルがより長いものになってくると、

  • 型のデフォルト値を定義する
  • 繰り返し使う値を定数にする
  • 雑多なボイラープレートな記述をヘルパー関数に切り出す

など、 Dhall のさらなる恩恵を受けることができます。

参考資料