【Scala】Futureの落とし穴と参照透過性

最近同じチームのメンバーからScalaを教えてもらっている。この前は並列処理について教えてもらう機会があったが、彼はScalaの教本で最初に出てくるFutureはなるべく使いたくないという。なぜだろうと思っていたが、実際に小さなサーバをチームで実装してみるとその理由がわかった。

例えば以下のようなサンプルコードを考えてみる。

val future = Future {
    println("aaa")
    "bbb"
}

future.map(println)
Thread.sleep(1000)

// aaa
// bbb

これは当然だろう。では future.map(println) を2回呼び出すとどうだろうか。

val future = Future {
    println("aaa")
    "bbb"
}

future.map(println)
future.map(println)
Thread.sleep(1000)

// aaa
// bbb
// bbb

aaa が一度しか出力されない。Futureは一度値が入って完了状態になると、それ以降に呼ばれた場合中の処理をやり直さず、評価された値を記憶してそのまま使うだけになる。これはFutureが参照透過性を満たしていないということになる。

参照透過性(referential transparency)とは、関数型言語の概念の一つ。ある関数を、その関数の評価結果を格納した変数に置き換えても、コード全体の処理およびその結果が全く変わらない状態を指す。 例えば val add = (x: Int, y: Int) => x + y という簡単な関数があったとして、 add(3, 5) と呼ぶところを単に 8 が入った変数に置き換えても、何の影響もない。
これに対し、関数内で何か副作用のある処理をしている場合は、参照透過性が満たされない。上のFutureの例でいうと、Futureの部分を bbb が入った変数に置き換えると、 println("aaa") の処理がなくなってしまう。
このため、Futureの中に複雑な処理を書いてそれを再利用する場合、参照透過性が満たされないことにより、一部の処理が再実行されない可能性が出てくる。Futureを関数と同じ感覚で使うことはできない。

今回のサーバ実装はFutureを小さく分割することで乗り切ったが、サードパーティの並列処理ライブラリには、Futureに相当する機能を参照透過で使えるものが多いとのこと。Futureを多用すること自体がそもそもアンチパターンなのかもしれない。

dotfilesのためのmake入門

最近自分のdotfilesを整備しているのだが、強い人たちのdotfilesを見ると、 make コマンドで実行するようになっているものが多いことに気がついた。 make は難しそうで敬遠していたが、少し調べてみるとdotfilesを作る時にとても便利であることがわかった。

makeとは

本来は、大規模なプログラムのコンパイルを自動化・効率化するための技術。 Makefile にファイル名・コンパイル用のコマンド・その他の設定などを書いて、 make コマンドで実行する。 Makefile の書き方次第では、変更があった部分だけを再コンパイルする等もできる。make は様々なOSにプリインストールされており、UNIX系のOSには GNU make として最初から入っている。また、特定の言語に依存したものでもない。
つまり、端末が変わりほとんど何も入っていない環境になったとしても、 make なら実行できる。dotfilesではポータビリティが非常に重視されるので、 make がよく使われている。
もちろんシェルスクリプトに全部のコマンドを書いても良いのだが、 make の場合、実行したいタスクごとに名前をつけることができ、全タスクの一括実行や、一部だけの実行もできる。そのため、 Makefile を起点としたdotfiles全体の管理がしやすくなる。成長した Makefile は、dotfiles全体のマニュアルのようになっていく。

Makefileの書き方

dotfilesで使う前に、本来の Makefile の書き方を整理しておく。なお、今回はプログラムをコンパイルする用途ではないので、 make 自体の詳細まで立ち入ることはせず、dotfilesに関係ありそうな機能だけをまとめる。 例は公式ドキュメントから抜粋した。

www.gnu.org

rule

Makefile 内の1つのタスクを rule という。 rule は基本的に以下のような構文で書く。

targets : prerequisites
        recipe
        …

targets はファイル名で、スペースで区切って複数指定できる。 prerequisites にもファイル名を指定する。 recipe には実行したいコマンドを書く。 recipe の冒頭はタブによるインデントが必須であることに注意。
targets のファイルが存在しないか、あるいは targets のファイルの更新時刻が prerequisites のファイルの更新時刻よりも古くなった場合、 recipe に書いたコマンドが実行される。このため、 targets にはコンパイル後に生成されるファイル、 prerequisites には人間がコードを書いたファイルを指定することで、コードが更新されたファイルのみが再コンパイルされるようにできる。

foo.o : foo.c defs.h
        cc -c -g foo.c

実行したいときは、 make ${targets名} の形式で指定する。targets を何も指定しないと、Makefile 内の一番上に書かれた targets が実行される。

phony targets

先程 targets にはファイル名が記載されると書いたが、ファイル削除など、新しいファイルを生成しないコマンドを Makefile 内の recipe として指定したいという場合もある。例えば、以下のような rule を書いたとする。

clean:
        rm *.o temp

このとき、make clean とすればrecipeが実行される。しかし、万が一 clean という名前のファイルができてしまった場合、 prerequisites が指定されていないので、 recipe は二度と実行されなくなる。
そこで、clean.PHONY という特別な targets 名の prerequisites にすることで、 clean というファイルの有無に関係なく recipe が実行されるようになる。

.PHONY: clean
clean:
        rm *.o temp

また、以下のように all を使って複数の targets を指定し、それを .PHONY に渡すことで、 make だけですべての rule を実行できる。もちろん、 make prog1 とすれば prog1 のみの実行もできる。

all : prog1 prog2 prog3
.PHONY : all

prog1 : prog1.o utils.o
        cc -o prog1 prog1.o utils.o

prog2 : prog2.o
        cc -o prog2 prog2.o

prog3 : prog3.o sort.o utils.o
        cc -o prog3 prog3.o sort.o utils.o

dotfilesではどのように使うか

例えば、設定したいパッケージごとに Phony targets を作り、それぞれの recipe にコマンドを記載する。それらのパッケージを all で指定すれば、 make だけですべての設定が完了する。

.PHONY: all
all: git vim tmux

.PHONY: git
git:
    ln -snfv ${PWD}/.gitconfig ${HOME}

.PHONY: vim
vim:
    ln -snfv ${PWD}/.vimrc ${HOME}
    curl -o ${HOME}/.vim/colors/iceberg.vim --create-dirs https://raw.githubusercontent.com/cocopon/iceberg.vim/master/colors/iceberg.vim

.PHONY: tmux
tmux:
    ln -snfv ${PWD}/.tmux.conf ${HOME}

上記のやり方はパッケージ単位で targets を作っているが、それぞれの処理を単位として targets を作っても良い。例えば、上記の例で各 recipe の中に出てきている「設定ファイルをホームディレクトリにリンクする」作業を link.sh 等のシェルスクリプトにまとめ、以下のようにすれば make link で全パッケージのリンク作業が完了する。

.PHONY: link
link:
    bash ${PWD}/link.sh

また、 Makefile の良いところは、 all に含めない targets も作れるところにある。いろいろな人の Makefile を見ると、 helptest 等の Phony targets を作っている人もいるようだ。

環境移行はいつ起きるかわからず、移行作業自体にも時間をかけたくはない。今のうちから管理・実行のしやすいdotfilesを少しずつ作っておきたい。

【Kafka】--bootstrap-serverと--zookeeperの違い

Kafkaのコマンドを扱っていると、コマンドの種類によって --bootstrap-server を指定する場合と --zookeeper を使う場合とがあって少し混乱する。どう違うのかを調べてみた。

bootstrap-serverとは

bootstrap-serverとして指定するのは、他でもないKafkaのbrokerである。brokerはKafkaクラスタを構成するサーバそのものであり、ProducerやConsumerとの間のリクエスト・レスポンスのキューイングや、ディスクへのデータの書き込み・読み出しなどを行っている。すなわち、brokerがTopicやPartitionを作り出しているともいえる。
多くの場合brokerは複数台設定されており、データの分散を可能にしている。

zookeeperとは

zookeeperはKafkaに限ったものではなく、分散データ処理を行う多くのシステムで使われている。zookeeperは分散システムを協調させるためのサービスであり、以下のようなことを行ってくれる。

  • ノード間での設定ファイルの共有
  • リソースのロック
  • 各ノードの状態管理

これにより、分散システム自体は各ノード間の状態の整合性を気にすることなく、データ処理に集中できる。

Kafkaでのzookeeperの役割は、既存Topicの設定確認や変更、ACLの管理など。詳細は以下のページが詳しい。

https://www.quora.com/What-is-the-actual-role-of-Zookeeper-in-Kafka-What-benefits-will-I-miss-out-on-if-I-don

Kafkaのコマンドにおける両者の役割

両者の役割をふまえると、求める操作の違いによってbootstrap-serverとzookeeperのどちらに接続すべきか異なることがわかる。基本的に、上記ページにあるzookeeperの役割に含まれる操作の際は --zookeeper が必要になる。
例えば、 kafka-configs.sh を使ってTopicの設定を変更したい場合は --zookeeper を指定する。一方、 kafka-topics.sh でTopicを新たに作る場合や、 kafka-console-producer.sh , kafka-console-consumer.sh でデータの送受信を行う場合は、brokerに接続すれば良いので --bootstrap-server を指定すればよい。

なお、1.0.0より前のKafkaでは、kafka-console-consumerを使う際にもzookeeperに接続していた。さらに、2.2.0より前は kafka-topics.sh もzookeeper接続が必須だった。Zookeeperに依存した操作をKafkaに寄せていく流れがあるようだ。

KubernetesのServiceAccountを触って学ぶ

udomomo.hatenablog.com

以前RBACについての記事を書いたが、Rolebindingで権限を付与できる対象は複数ある。以前の記事では人に対して与えられるUser Accountについて書いたが、Pod内で動くコンテナのプロセスに対して与えられるService Accountというものもある。
RBACでは、このService Accountごとの権限設定もできる。例えば同じNamespaceの中で異なる役割のPodがあるとき、ServiceAccountを使って権限を付与すれば、より細かな権限管理が可能になり、セキュリティ上のリスクを減らすことができる。

Service Accountを設定する

普段はほとんど意識しないが、Service Account自体は常に使われている。デフォルトでは、全てのアプリケーションはそれぞれのNamespaceに固有である default という名前のServiceAccountで起動される。

特にServiceAccount関連の設定をせずにPodを起動してみる。

# nginx.yaml
apiVersion: v1                                                                                                                                       
kind: Pod
metadata:
  name: nginx
spec:
  containers:
    - name: nginx-container
       image: nginx:1.17-alpine
       ports:
       - containerPort: 80
$ kubectl apply -f nginx.yaml
pod/nginx created

$ kubectl get pod -o yaml
apiVersion: v1
items:
...
spec:
  containers:
  ...
    volumeMounts:
        - mountPath: /var/run/secrets/kubernetes.io/serviceaccount
          name: default-token-h7nhq
          readOnly: true
  ...
  serviceAccount: default
  serviceAccountName: default
  ...
  volumes:
    - name: default-token-h7nhq
      secret:
        defaultMode: 420
        secretName: default-token-h7nhq

default という名前のServiceAccountが使われていることがわかる。そして、ServiceAccount用のSecretが作られ、Volumeとしてマウントされている。このSecretの中に、各ServiceAccountに付与されたAPIアクセス権限(トークン)が保存される。
これを変えるには、マニフェスト内で別のServiceAccountを指定すれば良い。

# nginx-serviceaccount.yaml
apiVersion: v1                                                                                                                                       
kind: ServiceAccount
metadata:
  name: nginx-serviceaccount
  namespace: default
# nginx.yaml
apiVersion: v1                                                                                                                                       
kind: Pod
metadata:
  name: nginx
spec:
  containers:
    - name: nginx-container
       image: nginx:1.17-alpine
       ports:
       - containerPort: 80
  serviceAccountName: nginx-serviceaccount
$ kubectl get pod -o yaml
apiVersion: v1
items:
...
spec:
  containers:
    ...
    volumeMounts:
      - mountPath: /var/run/secrets/kubernetes.io/serviceaccount
        name: nginx-serviceaccount-token-2qlq4
        readOnly: true
  ...
  serviceAccount: nginx-serviceaccount
  serviceAccountName: nginx-serviceaccount
  ...
  volumes:
    - name: nginx-serviceaccount-token-2qlq4
      secret:
        defaultMode: 420
        secretName: nginx-serviceaccount-token-2qlq4

serviceAccountの名前が変わり、マウントされているSecretも更新された。

あとはRoleとRolebindingを作り、このServiceAccountに権限を付与することで、Pod単位での権限管理が可能となる。

OpenAPIでRestfulAPIの仕様を定義する

一時期会社の他チームで、OpenAPIを取り入れようと検討されていた時期があった。結局他の開発業務が立て込んだため採用はされなかったのだが、API開発の一手法として興味があったのでちょっと使ってみた。

OpenAPIとは

OpenAPIは、RestfulAPIのインターフェースを、人間とコンピュータ両方によって読みやすい形で定義するための一手法だ。YAML形式またはJSON形式で定義を行う。
様々な言語やフレームワークによって、OpenAPIの定義ファイルをコードに変換するプラグインが用意されていることが多い。そのため、仮にソフトウェアの言語・フレームワークを変更することになっても、定義ファイルを共通で使うことができれば開発を効率化できる。また、定義ファイルをもとにドキュメントも自動生成できてしまう。

OpenAPIを書いてみる

ちょっとしたTodoアプリのAPIを定義してみた。なお、今回使っているのはOpenAPI v3.0の方だ。

paths

基本的には、 paths の子要素としてエンドポイントを定義し、それぞれのエンドポイントごとにリクエストパラメータ・リクエストボディ・レスポンス等を書いていけば良い。例えば、Todoアプリのタスク一覧をGETで取得するエンドポイントは以下のように定義する。

  /tasks:
    get:
      summary: List all tasks
      operationId: listTasks
      responses:
        '200':
          description: array of tasks
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Tasks'
        default:
          description: unexpected error
          content: 
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

また、タスクのIDを指定して編集を行うエンドポイントはこのように定義できる。 リクエストパラメータは paramter 以下で定義したうえで、エンドポイントの中で {} で囲んで使用する。

  /tasks/{taskId}:
    put:
      summary: edit a task
      operationId: editTask
      parameters: 
        - name: taskId # taskIdパラメータの定義
          in: path
          required: true
          description: id of task
          schema:
            type: string
      requestBody:
        description: task to edit
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TaskRequest'
      responses:
        '201':
          description: edited
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Task'
        default:
          description: unexpected error
          content: 
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

components

各エンドポイントのリクエストボディ・レスポンスの定義は、それぞれのpathオブジェクト内の content 内で行える。しかし、共通して使いそうなschemaが多い場合は、 components/schema 以下で定義できる。
先程の例では、$ref というキーの中で、 Tasks, Task, TaskRequest, Error というschemaが指定されていた。このようにすることで、components/schema以下で定義したオブジェクトを参照できる。

例えば、追加・編集したタスクを返すレスポンス用に、以下のようなschemaを定義した。

components:
  schemas:
    Task:
      description: one task object
      type: object
      allOf:
        - $ref: '#/components/schemas/TaskRequest'
        - required:
          - id
          - done
          properties:
            id:
              type: integer
              format: int64
            done:
              type: boolean
      example:
        id: 1
        content: Buy notebook
        urgent: 3
        important: 3
        done: false

required 以下では必須のパラメータを定義している。また、 properties 以下では各パラメータの詳細を記載している。
allOf という指定があるが、これは「子要素のschema全てを満たす」という意味だ。この場合、 TaskRequest schemaで定義されたフィールドに加え、 required 指定となっている iddone のフィールドも持たなければいけない。
ちなみに、 TaskRequest schemaは以下のように書いている。

TaskRequest:
      description: object to create or edit a new task
      type: object
      required:
        - content
        - urgent
        - important
      properties:
        content:
          type: string
        urgent:
          type: integer
          format: int64
        important:
          type: integer
          format: int64
      example:
        content: Buy notebook
        urgent: 3
        important: 3

これらを分けた理由は、新しくタスクを作る場合や編集する場合に、 iddone を指定させたくなかったためだ。 id はタスク作成の際にデータベース側で付与され、編集の際もリクエストパラメータで指定する。また、 done についてはタスクを完了にするための別のエンドポイント( /tasks/{taskId}/done )に切り出したいと考えた。このように、一部のフィールドが重なる複数のリクエストを使いたいとき、 allOf などでschemaを結合させるとうまく書ける。

example

example以下に記載した内容は、ドキュメントを生成した際の、各リクエスト・レスポンスの例として使われる。例が書かれていないドキュメントは非常に分かりにくいので、適切なexampleを記載することも重要だ。
exampleを書ける場所はいくつかある。

  • paths以下の各エンドポイント内にある content 内で定義
  • components/schema 以下の各schemaの子要素として定義
  • components/examples 以下に定義

どれを使うかは、examplesをどれほど共通化したいかによる。ただ実際に書いてみて思ったのだが、例を共通化しすぎるとかえって煩雑になるときが結構ありそうだ。例えば、1つのpathの中に場合分けして複数の例を書きたい場合や、同じschemaでもpathごとに使う文脈が異なるため例を分けたいという場合がある。例は様々なパターンをカバーしている方がユーザにとって分かりやすくなるので、あまり共通化に囚われすぎない方が良いかもしれない。

tmux 3.0でコピーモードの設定を行う

先日、tmuxを久しぶりに使う機会があった。 tmux.conf の中身は、以前ネットで拾ってきたものをそのまま使っていたのだが、設定内容を全然使いこなせていないうえ、あまりに久しぶりすぎて中身の意味もわからない状態になってしまっていた。そこで、思い切って tmux.conf を消して作り直すことにした。
今回はコピーモードの設定を行う。tmuxを仕事で使う時で最も重要な場面は、ssh接続で時間のかかる作業を行う時であり、その場合は出力結果をコピーしてドキュメントに残しておかなければいけないことが多い。そのため、クリップボードとつなげておくことは不可欠だ。

コピーモードでのKey Table

man tmux で表示されるマニュアルが参考になった。

man7.org

コピーモードには2種類のKey Tableが用意されており、それぞれviとemacsと名付けられている。名前の通り、viやemacs風のキーバインドを使うことができ、デフォルトではemacsの方が使われる。
自分はviの方がなじみがあるので、viのKey Tableを使うことにした。 tmux mode-keys コマンドでKey Tableを設定できる。

setw -g mode-keys vi

余談だが、 tmux.conf に書く内容は、全て tmux コマンドに続ける形でコマンドラインから実行できる。単に実行させたいコマンドの一覧を書いているだけなので、設定ファイルの書き方を調べるよりもマニュアルを見る方がわかりやすかった。

コピーモードでのキーバインド

次に、よりviに近くなるようなキーバインドに変更する。ビジュアル選択は v キーで行い、コピーは y キーにしたい。
マニュアルによると、 tmux bind-key コマンドでキーバインドを変えることができる。 bind-key [-nr] [-T key-table] key command [arguments] の形式である。また、エイリアスとして bind コマンドでも良い。

また、コピーモードでのキーバインドを設定する際は、 send-keys コマンドが必要になる。マニュアルの以下の部分が非常にわかりやすい。

Commands are sent to copy mode using the -X flag to the send-keys com‐
     mand.  When a key is pressed, copy mode automatically uses one of two
     key tables, depending on the mode-keys option: copy-mode for emacs, or
     copy-mode-vi for vi.  Key tables may be viewed with the list-keys com‐
     mand.

     The following commands are supported in copy mode:

           Command                                      vi              emacs
           append-selection
           append-selection-and-cancel                  A
           back-to-indentation                          ^               M-m
           begin-selection                              Space           C-Space
           bottom-line                                  L
           cancel                                       q               Escape
           clear-selection                              Escape          C-g
           copy-end-of-line [<prefix>]                  D               C-k
...

コピーモードで使えるコマンドは、 begin-selection などあらかじめ定義されている。指定したキー(例えば v キー)を押すと、 send-keys コマンドが呼び出され、対応するコピーモードコマンドが呼び出されるという流れになっている。
デフォルトでのキーバインド設定は、 tmux list-keys コマンドで見ることができる。

tmux list-key
bind-key    -T copy-mode    C-Space               send-keys -X begin-selection
bind-key    -T copy-mode    C-a                   send-keys -X start-of-line
bind-key    -T copy-mode    C-b                   send-keys -X cursor-left
bind-key    -T copy-mode    C-c                   send-keys -X cancel
bind-key    -T copy-mode    C-e                   send-keys -X end-of-line
bind-key    -T copy-mode    C-f                   send-keys -X cursor-right
bind-key    -T copy-mode    C-g                   send-keys -X clear-selection
bind-key    -T copy-mode    C-k                   send-keys -X copy-end-of-line
bind-key    -T copy-mode    C-n                   send-keys -X cursor-down
bind-key    -T copy-mode    C-p                   send-keys -X cursor-up
...

tmux.conf にも、この形式の通りに書けば良い。 v キーと y キーであれば、以下のように書ける。

bind-key -T copy-mode-vi v send-keys -X begin-selection
bind-key -T copy-mode-vi y send-keys -X copy-pipe-and-cancel "pbcopy"

なお、OSのクリップボードと連携させるためには、以前は reattach-to-user-namespace ライブラリが必要だったが、 tmux2.6以降では必要なくなったそうだ。

blog.nijohando.jp

Kafkaのメッセージング方式の特長を学び直した

自社サービスでKafkaを使っており、自分でも少し触ったことがあるのだが、Kafkaというのがメッセージングにおいてどんな特長を持ったサービスなのかについては、topicを介してproducerとconsumerがメッセージをやり取りする、という程度の理解しかなかった。これではいけないと思い、年末年始に公式ドキュメントを読み直したのでまとめておく。

kafka.apache.org

メッセージングサービスの大まかな分類

世の中のメッセージングサービスのモデルにはいろいろあるが、最もよくあるモデルはqueueとpub-subだろう。
queueモデルは、複数のconsumerがqueue内のレコードを読むが、それぞれのレコードは1つのconsumerの所にしか行かない。queueから出したらもうそのレコードはqueueの中にはない。
対してpub-subモデルは、複数のconsumerそれぞれに全レコードが配信される。ラジオの放送局が流す電波をどのラジオでも受け取って放送を聴けるように、1つのconsumerがレコードを受け取っても、そのレコードは他のconsumerも受け取ることができる。

これらのモデルには一長一短がある。queueモデルの場合、複数のconsumerを用意するだけで簡単に処理速度を上げることができる。しかし、1つのrecordは1つのconsumerにしか行かないので、障害発生時にデータロストしやすい。また、queueから渡される時点ではデータは順序どおりだが、各consumerは非同期で処理を行っているので、受け取る際にはデータの順序が入れ替わってしまったり、重複して受け取られてしまったりする可能性がある。
これに対してpub-subモデルの場合、どのconsumerも全データを受け取ることができるので、障害にも比較的強い。しかし、consumerの数を増やしても処理速度に跳ねにくい。

Kafkaの特長

Kafkaはqueueモデルとpub-subモデルを合わせ持ったような性質がある。まず、Kafkaは複数のconsumerをconsumer groupとして論理的に区分けすることができる。そして、同じconsumer group内のinstanceに対しては、recordはロードバランシングされる。違うconsumer groupには、それぞれに対して全recordが送られる。
すなわち、同じconsumer group内のconsumer instance同士では、queueモデルのようにレコードを読む処理を分担できる。一方で、異なるconsumer groupの間ではpub-subが成り立っている。

またKafkaの場合、topic内に複数のpartitionを持っている。topicは論理的な区分けでしかなく、partitionが物理的な区分けである。(そのため、1topic内のpartitionが複数のサーバに分離していることもある)
そしてconsumer groupに対し、1つのpartitionを1つのconsumer instanceに割り当てている。

f:id:Udomomo:20200105220629p:plain

(出典: https://kafka.apache.org/intro)

これにより、各partitionを読むconsumerが1つだけになり、各partiton内のデータは必ず順序通りconsumeされる。(もちろん、複数のpartition間の順序は保証されない)
なお、この方法の制約として、consumerがpartitionの数以上存在することはできない。