【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に関係ありそうな機能だけをまとめる。 例は公式ドキュメントから抜粋した。
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
を見ると、 help
や test
等の 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の管理など。詳細は以下のページが詳しい。
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を触って学ぶ
以前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
指定となっている id
と done
のフィールドも持たなければいけない。
ちなみに、 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
これらを分けた理由は、新しくタスクを作る場合や編集する場合に、 id
と done
を指定させたくなかったためだ。 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
で表示されるマニュアルが参考になった。
コピーモードには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以降では必要なくなったそうだ。
Kafkaのメッセージング方式の特長を学び直した
自社サービスでKafkaを使っており、自分でも少し触ったことがあるのだが、Kafkaというのがメッセージングにおいてどんな特長を持ったサービスなのかについては、topicを介してproducerとconsumerがメッセージをやり取りする、という程度の理解しかなかった。これではいけないと思い、年末年始に公式ドキュメントを読み直したのでまとめておく。
メッセージングサービスの大まかな分類
世の中のメッセージングサービスのモデルにはいろいろあるが、最もよくあるモデルは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に割り当てている。
(出典: https://kafka.apache.org/intro)
これにより、各partitionを読むconsumerが1つだけになり、各partiton内のデータは必ず順序通りconsumeされる。(もちろん、複数のpartition間の順序は保証されない)
なお、この方法の制約として、consumerがpartitionの数以上存在することはできない。