kubernetesをローカルの開発に活用する

できる限りプロダクションのクラスタ設定をそのままローカルの開発にも使いたいなー、と思って色々と試行錯誤して、ようやく形になってきたので書いておく。

なぜローカルでkubernetesを動かしたいのか

最近ではInfrastructure as Code、Immutable Infrastructureの考え方と共に、コンテナの上でアプリケーションの環境の構築、運用、開発をすることが増えてきた。

少し前までは、Dockerでローカルの開発環境の構築は楽になったけど、本番にデプロイするのにはハードルがある印象が個人的にはあった。だけど、kubernetesの登場によってそのハードルは大きく下がった。

最近はマイクロサービスアーキテクチャへの注目と共に、様々なコンテナが協調してサービスを形作る構成が増えてきたように思う。kubernetesはこの全てのコンテナを管理する。

kubernetesは最初に学ばなければならないことは多いけれど、一度その概念を掴めばどれだけ楽に構成を管理できるか実感できる。

2年前ぐらい前のものだけど、全体の概念を掴むのはこの動画が良かった。

www.youtube.com

全ての設定はyamlで記述することになるので、構成管理がコード化できるところも良い。(ともすればyaml地獄とも揶揄されるけど)

複数のプロセスを立ち上げて開発をしている場合には、各プロセスを協調させるために、ローカル専用のプロキシサーバを立てるケースもある。

しかし各コンテナのインターナルな通信や、環境変数の設定をkubernetesに任せていると、これがローカルでそのまま動けばプロキシサーバなどいらずにプロダクションと同等の動作をさせることができるのでは、という気持ちになった。

ローカル開発とプロダクションとの差異を解決するためのkustomize

なるべくプロダクションの構成をそのまま使いたいとはいえ、どうしても差異はある。

そもそもプロダクションとローカルではデプロイするイメージは確実に違うし、Webアプリケーション開発においてはクライアントサイドのjsやhtmlなどの静的なファイルをビルドするためのサーバを立てなければいけなかったりもする。

この差異を解決するのには kustomize というツールを使うことにした。

kustomizeにより、環境ごとの差分のみを記述する

kustomizeを用いると、プロダクションの設定とローカルの設定は差分のみを記述するだけで済むようになる。 kustomizeが用意する基本的なレイヤーは、ベースとオーバレイの2つである。

各サービスの環境(プロダクション、ステージング、ローカルなど)ごとの設定は差分のみを記述した上でオーバレイのレイヤーに配置しておき、適用の際にベースの設定にパッチを当てる。

具体的には各環境にkustomization.yamlというファイルを用意し、そこがエントリポイントになる。

kustomizeのリポジトリのファイル配置の例を転記する。

~/someApp
├── base
│   ├── deployment.yaml
│   ├── kustomization.yaml
│   └── service.yaml
└── overlays
    ├── development
    │   ├── cpu_count.yaml
    │   ├── kustomization.yaml
    │   └── replica_count.yaml
    └── production
        ├── cpu_count.yaml
        ├── kustomization.yaml
        └── replica_count.yaml

例えば、overlays/development/kustomization.yamlの内容は以下のようになるだろう。

namespace: dev-someApp
commonLabels:
  stage: development
bases:
  - ../base
resources:
  - cpu_count.yaml
  - replica_count.yaml

basesセクションに記述したものはdevelopment, productionの両方から参照され、その差分のあるファイルは各オーバレイのレイヤーにおいておく。 この例ではファイルごと変えているが、ファイルの中で部分的に差分を適用したい場合には、patchesというセクションを使えば可能だ。

patches:
  - patches/deployment.yaml

環境変数の管理

kubernetesを用いて環境変数を管理するには、SecretsとConfigMapを用いるのが一般的だ。 基本的に機密性の高い情報の管理にはSecrets、それ以外ではConfigMapを使う。

kubernetesを使うとリリースごとにリビジョンをつけることができるので、問題がおきた時にロールバックが容易になる。

しかし、リリースごとに環境変数を変えている場合にはこれもロールバックしなければいけないことになり、直接SecretsやConfigMapをいじってしまうと少し厄介になる。

これを解決するために、kustomizeにより作成されたSecret、ConfigMapにはそれぞれリソースに一意な名前となるようにサフィックスが自動で付与され、それを参照することになる。

つまり、デプロイするたびにSecretsやConfigMapは増えていく。

試しに見てみる。

$ kubectl get secrets -n dev-application
NAME                             TYPE                                  DATA      AGE
default-token-5lctz              kubernetes.io/service-account-token   3         1d
secrets-environment-4khckhf9c7   Opaque                                27        5h
secrets-environment-9hg6tc462d   Opaque                                29        3h
secrets-environment-bb4k2t7cd9   Opaque                                26        5h

kustomizeを利用しない場合にこの仕組みを実現する方法は、@yuyatさんの以下の記事が大変参考になった。

Kubernetes の ConfigMap を Immutable に管理する | Born Too Late

また、secretsGenerator、configMapGeneratorという仕組みも用意されていて、ここにはリテラルだけでなく、コマンドの出力結果も適用することができる。

僕は環境変数AWS SystemManagerのParameterStore(ssm)で管理しているので、以下のように記述している。(get_env_from_ssmはaws-cliをラップして、ssmから値を取り出して標準出力するだけの簡単なシェルスクリプトです。)

secretGenerator:
- name: server-environment
  commands:
    BAR_API_KEY: "../../scripts/get_env_from_ssm /dev/BAR_API_KEY"
    FOO_SECRET: "../../scripts/get_env_from_ssm /dev/FOO_SECRET"
  type: Opaque

ローカルで利用するソースコードのチェックアウトとイメージのビルド

ローカル環境のソースコードのパスは開発者ごとに違うのが普通だけど、後述のソースコードのコンテナへのマウントや、イメージの一括のビルドスクリプトを簡易にするため、今回は決められたパスに関連ソースコードをチェックアウトし、イメージをビルドするスクリプトを用意した。Dockerfileは各リポジトリに置いている。

工夫すればソースコードのパスは可変にはできると思う。けど結構苦労すると思う。

具体的には以下のようなスクリプトになった。

  • scripts/checkout_all
#! /bin/bash

mkdir -p ${HOME}/awesomeApps

echo "local setup start..."

if [ -d "${HOME}/awesomeApps/bar" ]; then
  echo "bar already exists."
else
  # git clone ...
fi

if [ -d "${HOME}/awesomeApps/foo" ]; then
  echo "foo already exists."
else
  # git clone
fi

# ...
# ...

echo "finished."
  • scripts/build_all_images
#! /bin/bash

docker build -t foo:dev ${HOME}/awesomeApps/foo
docker build -t bar:dev ${HOME}/awesomeApps/bar
# ...
# ...

開発用のbuildのイメージのタグをバージョン管理せずにdevのみで作っていると、Dockerfileに修正が発生した時に更新が少し面倒になる。

なので開発用のタグもバージョン管理した方が良いかもしれないけど、ローカルのソースコードのマウントのところで後述する通り、kubernetesyamlプログラマブルにするのは少し面倒なので、複雑性とのトレードオフになる。

人数が多くて変更が発生した際に周知が大変な場合には、バージョン管理する価値はあると思う。

ローカルのソースコードkubernetes上のコンテナにマウントする

kubernetesにはvolumesにhostPathを指定することができる。

Volumes - Kubernetes

これを用いればホストのソースコードをマウントすることは可能だが、ここは絶対パスで指定する必要があることに注意が必要だ。

さらにkubernetesはDeclarativeであることを崩さないため、templateの機能は提供されておらず、ここでhostの環境変数で設定を置き換えることはできない。

Feature: Support using environment variables inside deployment yaml file · Issue #52787 · kubernetes/kubernetes · GitHub

そこで、置き換えにはenvsubstを用いることにした。 具体的には以下のようなコマンドでデプロイすることになる。

kustomize build ./someApp/overlays/development/kustomization.yaml build | envsubst | kubectl apply -f -

別にenvsubstでなくても、置き換えられればいいだけなのでsedでも良い。 ここで1ステップ追加されてしまうのは僕の調べた限りでは回避できなかった。 具体的なDeploymentのファイルは以下のようになる。

apiVersion: extensions/v1beta1
kind: Deployment
metadata:
  labels:
    service: server
  name: server
spec:
  template:
    spec:
      volumes:
      - name: src
        hostPath:
          path: "${HOME}/awesomeApps/bar"
          type: DirectoryOrCreate
      containers:
      - name: server
        args:
        - yarn
        - run
        - dev
        image: bar:dev
        volumeMounts:
        - mountPath: /opt/app
          name: src
        workingDir: /opt/app

Macではminikubeではなくdocker-for-mackubernetesを使う

windowsの人はdocker-for-windowsになる。linuxの人はminikubeを使ったとしても、driverにVirtualBoxを使わなければ良い。

Macユーザなので試した訳ではないけど、以下の議論がある。

More documentation around vm-driver=none for local use · Issue #2575 · kubernetes/minikube · GitHub

MacでもVirtualBoxを使わなければいけるのかもしれない。

docsをみると、VirtualBoxの他にVMware Fusion, HyperKitの選択肢があるようだ。

Install Minikube - Kubernetes

VirtualBoxを用いると、ファイルシステムの違いにより、inotifyイベントを検知できないためファイルの変更の監視が難しい。 ファイルの変更を検知してビルドの必要がある場合などに、これは致命的だ。

github.com

docker-for-macは体感として少し遅いが、これは今後改善されていくと信じたい。

おわり

まだ構築したばっかりなので、これから問題は出るかもしれない。 とりあえず今の所は便利に開発することができている。 今回紹介していないところで、細かいtips的なものもあるので、またそれは今度書こうと思う。

Socket.IOとのリアルタイムコミュニケーションをVuexのmodulesを利用してハンドリングする

という趣旨の内容で、先週のVue.js Tokyo v-meetupでLTさせてもらった。

vuejs-meetup.connpass.com

発表資料はこちら

speakerdeck.com

背景

僕が今メインで担当しているプロダクトの性質上、ブラウザ上でのリアルタイムコミュニケーションが必要な機能を扱うことが多い。

そこでは、Socket.IOと一部でGraphQLのsubscriptionを主に使っている。

GraphQLのクライアントにはapolloを使っているので、何も考えずともある程度体制の整った(まぁこうなるよねという感じの)コードになるけど、Socket.IOとのコミュニケーションについては、タイムライン上に発生したイベントをいかにして可読性も担保しつつ、Viewで購読可能な状態のStateに落とすか、という所に課題があった。

ContainerComponentでObservableを利用するか、もしくはreduxで言う所のmiddlewareみたいな層を設けてそこに変換を任せるか、というような選択肢も検討したけど、最終的にはVuexのmutation、actionにそのままSocket.IOのイベントをマッピングして、イベントの発生をトリガーにしてVuexの持つStateを構築することにした。

今の所は可読性を保ったまま、スケールしやすい構成にできている実感がある。なのでより詳細な内容をブログにも書いておこうと思った。

利用しているSocket.IOの機能について

Socket.IOは(利用可能な環境において)WebSocketプロトコルを使って双方向通信を実現する。

提供されている機能にNamespacesとRoomsというものがある。

Socket.IO — Rooms and Namespaces

Namespacesを利用することで、利用するストリーム毎に異なるエンドポイントを得ることができる。 Roomsはその中で、さらに利用者のグループを作ることができる。

利用イメージを雑に書くと以下のような感じ。

f:id:joe-re:20180530075415p:plain

Roomはただ単にグループを定義するだけなので、必ずしも1つのクライアントが1つのルームにしか所属できない訳ではない。構造を整えれば、さらに小さい単位のグループを作ることもできる。

Namespaceは僕たちは機能単位で分けている。

これらを利用することで以下の利点を得られる。

  • イベントを送出する対象を簡単にサーバ側で選択することができる
  • 1つの機能(Namespace)に重大なエラーが発生したとしても、他の機能には影響がない
  • Socket.IOサーバのコードは自然と構造化するのでメンテナンスがしやすい

VuexのNamespaced moduleをSocket.IOのNamespaceに割り当てる

Vuexにはmodulesという機能があり、SPAを構成するSingleStateTreeを小さい単位で分割することができる。

Vuex | Modules

それぞれのmoduleの中にそれぞれのstateを構成するためのaction、mutation、getterを持つ。

const moduleA = {
  state: { ... },
  mutations: { ... },
  actions: { ... },
  getters: { ... }
}

const moduleB = {
  state: { ... },
  mutations: { ... },
  actions: { ... },
  getters: { ... }
}

const store = new Vuex.Store({
  modules: {
    a: moduleA,
    b: moduleB
  }
})

store.state.a // -> `moduleA`'s state
store.state.b // -> `moduleB`'s state

ここにもNamespacingという機能があり、これを有効にしている場合にはmoduleのaction、mutation、getterへのアクセスする際のパスにmodule名が付与される。

const moduleA = {
  namespace: true,
  state: { ... },
  mutations: { ... },
  actions: { ... },
  getters: { ... }
}

const moduleB = {
  namespace: true,
  state: { ... },
  mutations: { ... },
  actions: { ... },
  getters: { ... }
}

const store = new Vuex.Store({
  modules: {
    a: moduleA,
    b: moduleB
  }
})

// --- View --- //

Vue.extend({
  computed: {
    getterFromModuleA() {
      return this.$store.getters['moduleA/fooGetter']
    },
    getterFromModuleB() {
      return this.$store.getters['moduleB/fooGetter']
    },
  },
  methods: {
    dispatchToModuleA() {
      this.$store.dispatch('moduleA/fooAction')
    },
    dispatchToModuleB() {
      this.$store.dispatch('moduleB/fooAction')
    }
  }
})

Namespacingを利用しない場合には分割されるのはStateだけなのでactionとgetterにおいて名前の衝突を気にしないといけないが、これを有効にすることでその心配はなくなる。

今回はVuexのNamespaced moduleにSocket.IOのNamespaceを割り当てることにした。 そのためにvuex-socketio-pluginという、Vuexのプラグインを作った。

github.com

Vue-Socket.ioというプラグインがあり、もともとはこれを利用しようと思っていたんだけど、マルチチャンネルをサポートしていなかったのとSocket.IOのNamespaceをVuexのNamespaced moduleに割り当てる着想があったので自作することにした。このプラグインのVuex integrationの部分にIFはかなり影響を受けている。

vuex-socketio-pluginの提供するcreateSocketioPluginという関数を利用し、Vuexのpluginを作成できる。

const plugin = createSocketioPlugin([
  'http://localhost:3000/moduleA',
  'http://localhost:3000/moduleB'
])
const store = new Vuex.Store({
  plugins: [plugin],
  modules: { moduleA, moduleB }
})

このプラグインを使うと、Socket.IOの/moduleAというNamespaceはmoduleAに、/moduleBというNamespaceはmoduleBに割り当てられ、そのモジュールの中でイベントを通じたコミュニケーションが利用できるようになる。

Action、MutationをSocket.IOのイベントにマッピングする

vuex-socketio-pluginで作成したpluginを通じてSocket.IOサーバからイベントを受信すると、Namespaceがあれば同名のVuexのNamespaced moduleのActionとMutationが発火される。 Action名にはsocket_、Mutation名にはSOCKET_のプレフィックスがそれぞれデフォルト付与され、その後ろはイベント名となる。(プレフィックスはオプションで変更可能)

const moduleA = {
  namespace: true,
  state: { ... },
  mutations: {
    SOCKET_CONNECT (state, payload) {
      //...
    },
    SOCKET_EVENT1 (state, payload) {
      //...
    }
  },
  actions: {
    socket_connect (context, payload) {
      //...
    },
    socket_EVENT1 (context, payload) {
      //...
    }
  }
}

mutationのCONNECTは大文字で、actionのconnectは小文字なのはこれがSocket.IOが用意しているイベントだからで、ユーザ定義ではないイベントであればmutationは大文字にactionは小文字にすることにしている。(設定で変更できるか、どちらであっても発火できるようにする方が親切なのかもしれない。)

利用できる標準のイベントはSocket.IOのdocsを参照。 Socket.IO — Client API

VuexのNamespaced moduleを利用していない場合のため、globalで以下のような命名規則をしてもイベントを受け取れるようにしている。

  • Mutation: SOCKET_moduleA_EVENT1
  • Action: socket_moduleA_EVENT1

これによって/moduleAというnamespaceのストリームはmoduleA内でハンドリングを完結させることができる。

僕たちはSocket.IOのNamespaceは機能単位になるように設計しているので、この仕組みによってリアルタイムコミュニケーションによるStateの変更について非常に見通しの良いコードにすることができた。

さらにSocket.IOのイベントハンドリングを子のmoduleに分割する

しばらくこの構成で運用していたのだけど、機能を拡張していくにつれて可読性の部分に問題が生じた。

Stateが変更される要因はSocket.IOのイベントのみではないし、ユーザの操作をトリガーにしてSocket.IOサーバにイベントを送ることももちろんある。

そうするとNamespaced moduleにSocket.IOのイベントのハンドリングとそれ以外が混在することになる。

const moduleA = {
  namespace: true,
  state: { ... },
  mutations: {
    SOCKET_CONNECT (state, payload) {
      //...
    },
    SOCKET_EVENT1 (state, payload) {
      //...
    },
    FOO_MUTATION (state, payload) {
       //...
    },
    BAR_MUTATION (state, payload) {
       //...
    },
    FOO_BAR_MUTATION (state, payload) {
       //...
    }
  },
  actions: {
    socket_connect (context, payload) {
      //...
    },
    socket_EVENT1 (context, payload) {
      //...
    },
    fooAction (context, payload) {
      //...
    },
    barAction (context, payload) {
      //...
    },
    fooBarAction (context, payload) {
      //...
    },

  }
}

機能がシンプルなうちはあまり問題にならなかったが、これが増えると非常に煩雑で可読性が失われていった。

Vuexのmoduleは多重にネストすることができるので、Socket.IOのイベントハンドリングは更に子のモジュールに落とすことでこれを解決した。

Socket.IOのイベントに付与されるプレフィックスは変更することができるようにしているので、これを子のモジュール名とすることでこれを実現できる。

const plugin = createSocketioPlugin([
  'http://localhost:3000/moduleA',
  'http://localhost:3000/moduleB'
], {
  actionPrefix: 'socket/',
  mutationPrefix: 'socket/'
})

const store = new Vuex.Store({
  plugins: [plugin],
  modules: { moduleA, moduleB }
})

この場合にはmoduleAは以下のようになる。

const socket = {
  namespace: true,
  state: { ... },
  mutations: {
    CONNECT (state, payload) {
      //...
    },
    EVENT1 (state, payload) {
      //...
    }
  },
  actions: {
    connect (context, payload) {
      //...
    },
    EVENT1 (context, payload) {
      //...
    }
  }
}

const moduleA = {
  namespace: true,
  modules: { socket },
  state: { ... },
  mutations: {
    FOO_MUTATION (state, payload) {
       //...
    },
    BAR_MUTATION (state, payload) {
       //...
    },
    FOO_BAR_MUTATION (state, payload) {
       //...
    }
  },
  actions: {
    fooAction (context, payload) {
      //...
    },
    barAction (context, payload) {
      //...
    },
    fooBarAction (context, payload) {
      //...
    },

  }
}

これによって一目でSocket.IOのイベントとそれ以外とを区別できるようになり、可読性を取り戻すことができた。

この場合には親(moduleA)にViewに通知するStateを持ち、子(moduleA/socket)はほぼState-lessとなる。(Socket.IOとのコネクションの有無などは子に持つけど)

そうすると子のmoduleから親のmoduleのstateを更新する必要があり、アンチパターンっぽくて少し気持ち悪い。 mutationを発行する際にも{ root: true } のオプションが必要になり少し面倒になる。

  actions: {
    EVENT1 (context, payload) {
      context.commit('moduleA/FOO_MUTATION', { root: true })
    }
  }

これは仕方ないトレードオフだし、局所的にここでしか発生しないので目を瞑ることにしている。(良い解決法があるなら知りたい)

おわり

もしこれからVueアプリケーションでSocket.IOのNamespaceを利用しようとしている方は、試してみていただけると嬉しいです。 今のところは自分のアプリケーションに必要な部分しか実装していないので、色々な意見をいただけると非常にありがたいです。

もちろん他にもやり方はたくさんあると思いますので、もしより良い方法や上手くいっている方法などをお知りの方はぜひ教えてください。色々語り合いたいです。

Vue + TypeScriptなプロジェクトにESLintを導入する

TypeScript + VueなプロジェクトでESLintを使ってみて、現状必要なモジュールが複数あって少し複雑だったのでまとめておきます。

サンプルは以下です。 github.com

内容はどうでも良いんですが、こんな感じのすごく簡単なTODO風のアプリケーションです。 f:id:joe-re:20180102214723p:plain

なぜEslintを使うか

JavaScriptのためのLintingツールはたくさんありますが、Vueのroadmapにもある通り、Vueの公式スタイルガイドをサポートするESLintプラグインがESLintのメンテナによる公式プラグインとして作られています。

GitHub - vuejs/roadmap: Roadmap for the Vue.js project

これからもVueの公式としてサポートされていくと思うので、特にこだわりがなければESLintを使うのが良いかと思います。

ESLint for TypeScript

ESLintはJavaScript(ECMAScript)のためのLintingツールですが、TypeScriptをESTree互換の形に変換し、ESLintを適用できるようにするパーサがプラグインとして提供されています。TypeScriptのプロジェクトにESlintを使う場合にはこのプラグインが必要です。

github.com

以下のようにparserに指定すれば動きます。

.eslintrc.json

{
   "parser": "typescript-eslint-parser",
   "extends": [
    "eslint:recommended"
   ]
}

ただし、TypeScriptはもともとESTree互換ではないので、現在(v11.0.0)いくつかのルールが思うように動きません。

Discussion: Extending core ESLint rules to "just work" with TS-specific nodes · Issue #77 · eslint/typescript-eslint-parser · GitHub

eslint-plugin-typescriptというプラグインを使うと、TypeScriptの用のルールを導入することができます。

github.com

今回導入したプロジェクトも冒頭のサンプルもTypeScriptのみで構成されているので問題になりませんが、ピュアなJavaScriptと併用する場合にはルールがぶつかってしまうかもしれません。 ESLintは適用するルールをファイルタイプごとに設定ができるので、これを用いてJavaScriptとTypeScriptを分ければ回避できるはずです。

eslint.org

ESLint for Vue Single File Component

VueのSingleFileComponent(.vue)にESLintを適用するためのパーサを ESLintのメンテナのmysticateaさんが公開しています。

github.com

これを使うと、.vueファイルにESLintを適用することができます。 TypeScriptと併用する場合には、以下のようにparserOptionセクションにtypescript-eslint-parserを指定します。

.eslintrc.json

{
  "parser": "vue-eslint-parser",
  "parserOptions": {
    "parser": "typescript-eslint-parser"
  },
  "extends": [
    "eslint:recommended",
    "typescript"
  ]
}

サンプルを作った時点(2017/1/1)では、現状のstable(1.0.0)ではなくbetaをinstallする必要がありました。

mysticateaさんに教えていただきまして、現在はstableでokだそうです。

npm install vue-eslint-parser -D

Linting according to Vue StyleGuide

Vue.jsの公式として、ESLintプラグインが提供されています。

github.com

これは以下のStyle Guideを提供するための追加ルールと、ルールを組み合わせたテンプレートを提供しています。 vuejs.org

これにはテンプレート部分のシンタックスチェックも含まれるので、以下のようにテンプレート部分の違反を検出することができるようになります。 f:id:joe-re:20180102230305p:plain

最終的に.eslintrc.jsonは以下のようになりました。

{
  "parser": "vue-eslint-parser",
  "parserOptions": {
    "parser": "typescript-eslint-parser"
  },
  "extends": [
    "eslint:recommended",
    "plugin:vue/recommended",
    "typescript"
  ]
}

追記

mysticateaさんから指摘をいただきまして、plugin:vue/recommendedはvue-eslint-parserのparserの指定を含むので、parserの行は削除可能だそうです。 ただし、今回の例だとeslint-plugin-typescriptもparserの設定を上書きするので、以下のようにextendsの順番でplugin:vue/recommendedが後に来るように設定する必要があります。

{
  "parserOptions": {
    "parser": "typescript-eslint-parser"
  },
  "extends": [
    "eslint:recommended",
    "typescript",
    "plugin:vue/recommended"
   ]
}

個人的にはextendsの順番で適用されるparserが変わるのは気持ち悪いので、明示的に指定しておいた方がいいかなーと思います。

参考