Kubelet の Dynamic Kubelet Configuration について

Kubelet の Dynamic Kubelet Configuration の概要

Dynamic Kubelet Configuration は kubelet の設定を kubernetesAPI ( ConfigMap ) を利用して変更できるようにする機能です。

Dynamic Kubelet Configuration Dynamic Kubelet Configuration

Dynamic Kubelet Configuration による Kubelet の設定変更の流れは下記の様になります。

  1. Dynamic Kubelet Configuration を有効化する(設定ファイルのパスを指定し、Kubelet の ConfigMap を参照するように Node のマニフェストを変更する)
  2. Kubelet の設定を持つ ConfigMap を新規に作成、または変更する
  3. Kubelet は ConfigMap の変更を検知し、設定を1で指定したパスに展開させる
  4. Kubelet が再起動され設定が反映される

なぜ必要?

元々 Kubelet の設定は起動時のオプションでしか設定できなかったので、設定を変更するためには VMssh してオプションを直接変更して再起動させるなどの方法しかありませんでした。 kubernetes の v1.10 で Kubelet の設定をファイルで指定できるようになり ( --config )、設定のバージョン管理などは可能となりましたが、設定ファイルを各 VM に反映させる必要がありました。 Dynamic Kubelet Configuration は、Kubelet の設定を ConfigMap に持たせることでKubelet を kubernetes の他のリソースと同じ用に扱うことができるようにする機能で、これにより Kubelet の設定のバージョン管理ができるようなるだけでなく、設定の変更の適用や設定に不備があった場合のロールバックが容易になりました。

準備

kubeadm を使い kubernetes の環境を作成します。(参考: Self-managed Kubernetes in Google Compute Engine (GCE)

$ kubectl get node
NAME         STATUS   ROLES    AGE     VERSION
controller   Ready    master   4h54m   v1.19.4
worker       Ready    <none>   4h50m   v1.19.4

Dynamic Kubelet Configuration の有効化

Dynamic Kubelet Configuration を利用するには↓の2つの flag を設定する必要があります。 - --feature-gates="DynamicKubeletConfig=true" - Dynamic Kubelet Configuration の有効化 - --dynamic-config-dir=<path> - Kubelet の ConfigMap の設定を保存する場所の指定

--feature-gates="DynamicKubeletConfig=true" については、デフォルトで true になってるので問題ないです。

--dynamic-config-dir=<path> では、↑の概要で説明したように Kubelet の ConfigMap の変更を Kubelet で検知し、変更があった場合に一度その変更をローカルのストレージに保存し再起動時に参照するさいのパスを指定します。

kubeadm のインストール時に生成された Kubelet のユニットファイルを見てみると、 Kubelet の起動時に指定されるフラグが /var/lib/kubelet/kubeadm-flags.env に記載されてることがわかる。

Kubelet のユニットファイル

# Note: This dropin only works with kubeadm and kubelet v1.11+
[Service]
Environment="KUBELET_KUBECONFIG_ARGS=--bootstrap-kubeconfig=/etc/kubernetes/bootstrap-kubelet.conf --kubeconfig=/etc/kubernetes/kubelet.conf"
Environment="KUBELET_CONFIG_ARGS=--config=/var/lib/kubelet/config.yaml"
# This is a file that "kubeadm init" and "kubeadm join" generates at runtime, populating the KUBELET_KUBEADM_ARGS variable dynamically
EnvironmentFile=-/var/lib/kubelet/kubeadm-flags.env
# This is a file that the user can use for overrides of the kubelet args as a last resort. Preferably, the user should use
# the .NodeRegistration.KubeletExtraArgs object in the configuration files instead. KUBELET_EXTRA_ARGS should be sourced from this file.
EnvironmentFile=-/etc/default/kubelet
ExecStart=
ExecStart=/usr/bin/kubelet $KUBELET_KUBECONFIG_ARGS $KUBELET_CONFIG_ARGS $KUBELET_KUBEADM_ARGS $KUBELET_EXTRA_ARGS

worker のインスタンス/var/lib/kubelet/kubeadm-flags.env を編集して --dynamic-config-dir=<path> のフラグを下記のように追加する。

KUBELET_KUBEADM_ARGS="--network-plugin=cni --pod-infra-container-image=k8s.gcr.io/pause:3.2 --dynamic-config-dir=/var/lib/kubelet-dynamic" 

Kubelet の service を再起動する

sudo systemctl restart kubelet

Kubelet のプロセスを確認すると --dynamic-config-dir=/var/lib/kubelet-dynamic のフラグが付いた状態で起動していることが確認できる。

$ ps aux | grep kubelet
root     28593 15.2  1.2 1717092 94224 ?       Ssl  14:43   0:00 /usr/bin/kubelet --bootstrap-kubeconfig=/etc/kubernetes/bootstrap-kubelet.conf --kubeconfig=/etc/kubernetes/kubelet.conf --config=/var/lib/kubelet/config.yaml --network-plugin=cni --pod-infra-container-image=k8s.gcr.io/pause:3.2 --dynamic-config-dir=/var/lib/kubelet-dynamic

Kubelet の設定を変更する

概要で説明したように Dynamic Kubelet Configuration は稼働中の Kubelet の設定を持つ ConfigMap の変更を検知してるので、Kubelet の設定を ConfigMap に持たせる必要がある。

現状の Node の設定は apiserver に対して /api/v1/nodes/[NODE_NAME]/proxy/configz の GET リクエストを送ることで取得できるので、apiserver にアクセスするために kubectl proxy をバックグラウンドで実行し jq コマンドを利用して設定ファイルを作成する。

$ curl -sSL "http://localhost:8001/api/v1/nodes/worker/proxy/configz" | jq '.kubeletconfig|.kind="KubeletConfiguration"|.apiVersion="kubelet.config.k8s.io/v1beta1"' > kubelet_configz_worker

kubelet_configz_worker という名前で設定ファイルが作成された。

$ cat kubelet_configz_worker
{
  "enableServer": true,
  "staticPodPath": "/etc/kubernetes/manifests",
  "syncFrequency": "1m0s",
  "fileCheckFrequency": "20s",
  "httpCheckFrequency": "20s",
  "address": "0.0.0.0",
  "port": 10250,
  "tlsCertFile": "/var/lib/kubelet/pki/kubelet.crt",
  "tlsPrivateKeyFile": "/var/lib/kubelet/pki/kubelet.key",
  "rotateCertificates": true,
  "authentication": {
    "x509": {
      "clientCAFile": "/etc/kubernetes/pki/ca.crt"
    },
    "webhook": {
      "enabled": true,
      "cacheTTL": "2m0s"
    },
    "anonymous": {
      "enabled": false
    }
  },
  "authorization": {
    "mode": "Webhook",
    "webhook": {
      "cacheAuthorizedTTL": "5m0s",
      "cacheUnauthorizedTTL": "30s"
    }
  },
  "registryPullQPS": 5,
  "registryBurst": 10,
  "eventRecordQPS": 5,
  "eventBurst": 10,
  "enableDebuggingHandlers": true,
  "healthzPort": 10248,
  "healthzBindAddress": "127.0.0.1",
  "oomScoreAdj": -999,
  "clusterDomain": "cluster.local",
  "clusterDNS": [
    "10.96.0.10"
  ],
  "streamingConnectionIdleTimeout": "4h0m0s",
  "nodeStatusUpdateFrequency": "10s",
  "nodeStatusReportFrequency": "5m0s",
  "nodeLeaseDurationSeconds": 40,
  "imageMinimumGCAge": "2m0s",
  "imageGCHighThresholdPercent": 85,
  "imageGCLowThresholdPercent": 80,
  "volumeStatsAggPeriod": "1m0s",
  "cgroupsPerQOS": true,
  "cgroupDriver": "cgroupfs",
  "cpuManagerPolicy": "none",
  "cpuManagerReconcilePeriod": "10s",
  "topologyManagerPolicy": "none",
  "topologyManagerScope": "container",
  "runtimeRequestTimeout": "2m0s",
  "hairpinMode": "promiscuous-bridge",
  "maxPods": 110,
  "podPidsLimit": -1,
  "resolvConf": "/run/systemd/resolve/resolv.conf",
  "cpuCFSQuota": true,
  "cpuCFSQuotaPeriod": "100ms",
  "nodeStatusMaxImages": 50,
  "maxOpenFiles": 1000000,
  "contentType": "application/vnd.kubernetes.protobuf",
  "kubeAPIQPS": 5,
  "kubeAPIBurst": 10,
  "serializeImagePulls": true,
  "evictionHard": {
    "imagefs.available": "15%",
    "memory.available": "100Mi",
    "nodefs.available": "10%",
    "nodefs.inodesFree": "5%"
  },
  "evictionPressureTransitionPeriod": "5m0s",
  "enableControllerAttachDetach": true,
  "makeIPTablesUtilChains": true,
  "iptablesMasqueradeBit": 14,
  "iptablesDropBit": 15,
  "failSwapOn": true,
  "containerLogMaxSize": "10Mi",
  "containerLogMaxFiles": 5,
  "configMapAndSecretChangeDetectionStrategy": "Watch",
  "enforceNodeAllocatable": [
    "pods"
  ],
  "volumePluginDir": "/usr/libexec/kubernetes/kubelet-plugins/volume/exec/",
  "logging": {
    "format": "text"
  },
  "enableSystemLogHandler": true,
  "shutdownGracePeriod": "0s",
  "shutdownGracePeriodCriticalPods": "0s",
  "kind": "KubeletConfiguration",
  "apiVersion": "kubelet.config.k8s.io/v1beta1"
}

↑の設定ファイルから ConfigMap を作成する

$ kubectl -n kube-system create configmap my-node-config --from-file=kubelet=kubelet_configz_worker
configmap/my-node-config created

$ kubectl -n kube-system get cm my-node-config -o yaml
apiVersion: v1
data:
  kubelet: |
    {
      "enableServer": true,
      "staticPodPath": "/etc/kubernetes/manifests",
      "syncFrequency": "1m0s",
      ...
    }
kind: ConfigMap
metadata:
  creationTimestamp: "2020-12-10T16:48:20Z"
  managedFields:
  - apiVersion: v1
    fieldsType: FieldsV1
    fieldsV1:
      f:data:
        .: {}
        f:kubelet: {}
    manager: kubectl-create
    operation: Update
    time: "2020-12-10T16:48:20Z"
  name: my-node-config
  namespace: kube-system
  resourceVersion: "12622"
  uid: 23771797-73b5-49b7-84f9-15135b03ba5a

作成した ConfigMap を参照するように Node のマニフェストspec 配下に kubectl edit node worker で↓を追加します。

configSource:
  configMap:
    name: my-node-config
    namespace: kube-system
    kubeletConfigKey: kubelet

journalctl -u kubelet で Kubelet のログを見ると Node のマニフェストの変更を終了したタイミングで Node.Spec.ConfigSource が追加されたことが検知されて Kubelet が再起動されたことがわかる。

Dec 10 16:55:02 worker kubelet[28593]: I1210 16:55:02.911350   28593 watch.go:85] kubelet config controller: Node.Spec.ConfigSource was updated
Dec 10 16:55:03 worker kubelet[28593]: I1210 16:55:03.298865   28593 configsync.go:96] kubelet config controller: Node.Spec.ConfigSource is non-empty, will checkpoint source and update config if necessary
Dec 10 16:55:03 worker kubelet[28593]: I1210 16:55:03.299263   28593 download.go:194] kubelet config controller: attempting to download /api/v1/namespaces/kube-system/configmaps/my-node-config
Dec 10 16:55:03 worker kubelet[28593]: I1210 16:55:03.312233   28593 download.go:199] kubelet config controller: successfully downloaded /api/v1/namespaces/kube-system/configmaps/my-node-config, UID: 23771797-73b5-49b7-84f9-15135b03ba5a, ResourceVersion: 12622
Dec 10 16:55:03 worker kubelet[28593]: I1210 16:55:03.413807   28593 configsync.go:205] kubelet config controller: Kubelet restarting to use /api/v1/namespaces/kube-system/configmaps/my-node-config, UID: 23771797-73b5-49b7-84f9-15135b03ba5a, ResourceVersion: 12622, KubeletConfigKey: kubelet
Dec 10 16:55:13 worker systemd[1]: kubelet.service: Service hold-off time over, scheduling restart.
Dec 10 16:55:13 worker systemd[1]: kubelet.service: Scheduled restart job, restart counter is at 1.
Dec 10 16:55:13 worker systemd[1]: Stopped kubelet: The Kubernetes Node Agent.
Dec 10 16:55:13 worker systemd[1]: Started kubelet: The Kubernetes Node Agent.
...

Node が my-node-config の ConfigMap を参照してるかを確認するには kubectl get node worker -o yaml で status の config を見ればわかる。

config:
  active:
    configMap:
      kubeletConfigKey: kubelet
      name: my-node-config
      namespace: kube-system
      resourceVersion: "12622"
      uid: 23771797-73b5-49b7-84f9-15135b03ba5a
    assigned:
      configMap:
      kubeletConfigKey: kubelet
      name: my-node-config
      namespace: kube-system
      resourceVersion: "12622"
      uid: 23771797-73b5-49b7-84f9-15135b03ba5a
    lastKnownGood:
      configMap:
        kubeletConfigKey: kubelet
        name: my-node-config
        namespace: kube-system
        resourceVersion: "12622"
        uid: 23771797-73b5-49b7-84f9-15135b03ba5a

これで稼働中の Node が ConfigMap を参照するようになったので、次は ConfigMap を変更して Node の設定を変更してみる。

試しに registryPullQPS を変更してみる。

$ kubectl -n kube-system get cm my-node-config -o yaml | grep registryPullQPS
      "registryPullQPS": 10,

Kubelet のログを確認すると ConfigMap を追加したときと同じく変更を検知して Kubelet が再起動していることがわかる。

Dec 10 17:17:35 worker kubelet[20353]: I1210 17:17:35.715928   20353 watch.go:128] kubelet config controller: assigned ConfigMap was updated
Dec 10 17:17:36 worker kubelet[20353]: I1210 17:17:36.324327   20353 configsync.go:96] kubelet config controller: Node.Spec.ConfigSource is non-empty, will checkpoint source and update config if necessary
Dec 10 17:17:36 worker kubelet[20353]: I1210 17:17:36.324384   20353 download.go:181] kubelet config controller: checking in-memory store for /api/v1/namespaces/kube-system/configmaps/my-node-config
Dec 10 17:17:36 worker kubelet[20353]: I1210 17:17:36.324407   20353 download.go:187] kubelet config controller: found /api/v1/namespaces/kube-system/configmaps/my-node-config in in-memory store, UID: 23771797-73b5-49b7-84f9-15135b03ba5a, ResourceVersion: 14953
Dec 10 17:17:36 worker kubelet[20353]: I1210 17:17:36.366558   20353 configsync.go:205] kubelet config controller: Kubelet restarting to use /api/v1/namespaces/kube-system/configmaps/my-node-config, UID: 23771797-73b5-49b7-84f9-15135b03ba5a, ResourceVersion: 14953, KubeletConfigKey: kubelet
Dec 10 17:17:46 worker systemd[1]: kubelet.service: Service hold-off time over, scheduling restart.
Dec 10 17:17:46 worker systemd[1]: kubelet.service: Scheduled restart job, restart counter is at 2.
Dec 10 17:17:46 worker systemd[1]: Stopped kubelet: The Kubernetes Node Agent.
Dec 10 17:17:46 worker systemd[1]: Started kubelet: The Kubernetes Node Agent.

Kubelet の設定を確認すると registryPullQPS が正常に変更されてることが確認できました。

$ curl -sSL "http://localhost:8001/api/v1/nodes/worker/proxy/configz" | jq '.kubeletconfig|.kind="KubeletConfiguration"|.apiVersion="kubelet.config.k8s.io/v1beta1"' | grep registryPullQPS
  "registryPullQPS": 10,

参考

kaniko でコンテナイメージをビルドすると際にキャッシュが聞かない場合がある

kaniko でコンテナイメージをビルドすると際にキャッシュが聞かない場合がある

tl;dr

kaniko の --build-arg にコンテナイメージのビルド毎に異なる値を入れる運用してて、 v0.19.0 より前のバージョンを使用してる場合はキャッシュが聞かないので注意してください。

発生した問題

kaniko の説明については↓を参照してください。 kaniko が解決したいこと

コンテナイメージ用の Dockerfile 内で環境変数を設定するのに ARG を使用してる場合、kaniko でビルドする場合 --build-arg を使用することで外から ARG に値を渡すことが出来ます。

仕事で GKE 用のコンテナイメージを CloudBuild でビルドしており、運用上の関係でソースコード内で Cloud Build の REVISION_ID (毎回のビルドで生成されるハッシュ値) を参照している箇所があるため、それを外から渡してやる必要がありました。

steps:
  - name: 'gcr.io/kaniko-project/executor:v0.13.0'
    args:
      - --destination=gcr.io/$PROJECT_ID/app:latest
      - --cache=true
      - --cache-ttl=6h
      - --build-arg=BUILD_ID=${REVISION_ID}

↑のようにコンテナイメージをビルドしてコンテナ内で正常に変数を取得できることを確認できたのですが、2回目のビルドから変更が無い Dockerfile コマンド (COPY など)にもかかわらず全くキャッシュが効かないという事象が発生しました。

原因を調査してみたところ、v0.18.0 以前のバージョンの kaniko には --build-arg にビルド毎に異なる値を入れるとキャッシュが聞かない問題があることがわかりました。

Is there a way not to include build-args in the cache key? · Issue#1008 · GoogleContainerTools/kaniko https://github.com/GoogleContainerTools/kaniko/issues/637 · Issue#637 · GoogleContainerTools/kaniko

func (s *stageBuilder) build() error {
    ...
    compositeKey := NewCompositeCache(dgst)
    compositeKey.AddKey(s.opts.BuildArgs...)
    ...
}

ただ、現在は https://github.com/GoogleContainerTools/kaniko/pull/1085 にて修正されており、v0.19.0 にて変更が取り込まれているので、kaniko を使用する際は v0.19.0 以降のバージョンを使用するのが良いでしょう。

v0.18.0 以前のバージョンを使ってる場合の回避策 (kubernetes の場合)

kubernetes を使ってる場合↓のようにして env で環境を設定を出来ます。

apiVersion: v1
kind: Pod
metadata:
  name: nginx
spec:
  containers:
  - name: busybox
    image: busybox
    env:
    - name: TEST_ENV
      value: "test"

これを利用して ConfigMap をビルド時に動的に作り、↑の Pod の yaml から参照してやることで回避することが可能です。

kaniko が解決したいこと

https://qiita.com/Daniel_Nakano/items/4e4d33ed11b31616bc46 からの転記

この記事は Kubernetes2 Advent Calendar20日目の記事です。

ここで書いたことは 2019/12/20 時点での情報を元にしており、今後新しいバージョンで仕様が変更になる可能性があるのでご注意ください。

はじめに

kaniko とはコンテナビルドツールの一つで、docker のセキュリティやキャッシュの問題を解決するために開発されました。 この記事では、 docker のセキュリティの問題について軽く紹介した後に、それを kaniko がどのように解決したかを説明します。 また、最後に使用中に発生した問題もご紹介します。

kaniko とは?

Kaniko-Logo.png https://github.com/GoogleContainerTools/kaniko

kaniko とは docker daemon に依存せずにコンテナイメージがビルドができるツールです。

コンテナイメージとして提供されているので、コンテナや kubernetes 上で実行することが可能となっています。 また、docker daemon に依存しないのでルート権限が必要となることで引こ起こるセキュリティ問題が発生しないのと、キャッシュ機能も改善されており docker build に比べビルド時間が短縮されるのが大きな特徴です。

なぜ kaniko が作られたのか?

kubernetes が本番環境などで広く利用されるようになりイメージのビルドも kubernets 上でコンテナイメージをビルドしたいという要望が増えてきたからのようです。 https://github.com/kubernetes/kubernetes/issues/1294 https://github.com/kubernetes/kubernetes/issues/4018

(GKE を使ってると Cloud Build 上でイメージをビルドすることが多いと思われるので、kubernetes 上でイメージをビルドしたい場面はあまり思いつきませんでした。。。)

また、yaml 内では、イメージは名前とタグで指定されていおり、ビルドによって新しくイメージが作られた場合以外では変更されないという信頼ベースの運用になっているために何かの拍子で変更されてしまうと Pod の起動に失敗する可能性があります。kubernetes 上でビルドする仕組みがあれば、より確実な参照の仕組みが構築出来るかもという提案もありました。 https://github.com/kubernetes/kubernetes/issues/503

上記で述べた要望と受けて議論された結果、これらの課題を解決するツールとして kaniko が開発されました。 https://github.com/kubernetes/kubernetes/issues/1806

ちなみに Openshift ではすでにイメージのビルドも可能でした。 https://docs.openshift.com/container-platform/4.2/builds/understanding-image-builds.html#builds-strategy-docker-build_understanding-image-builds v3 までは docker daemon に依存することによる危険性はありましたが、v4 で Buildah という docker daemon に依存しないビルドツールが採用されています。

docker build の問題

kubernetes 上でも docker を使えばイメージをビルドすること自体は可能でした。 やり方としては、 kubernetes上に立てた docker daemon に対して /var/run/docker.sock (docker daemonREST API を待ち受けるソケット) をマウントしたコンテナを起動して docker build を実行する方法や、 Docker-in-Docker を利用する方法がありました。

しかし、 /var/run/docker.sock をマウントする方法は docker daemonルート権限で稼働していることホスト上の任意のファイルを読み書きできてしまうというセキュリティの問題がありました。

docker run -t -i -v /var/run/docker.sock:/var/run/docker.sock debian:jessie /bin/bash

Docker-in-Docker についても、ベースとなる docker を --privileged で起動する必要がありホストのデバイスに触れてしまう問題がありました。

また、 docker build の実行の際に Dockerfile の途中のコマンドで変更(差分が有るファイルを COPY など)があるとそれ以降のコマンドではキャッシュが効かなくなります。 https://docs.docker.com/develop/develop-images/dockerfile_best-practices#leverage-build-cache

docker build の問題点をどうやって kaniko は解決したか?

docker daemon の実行にルート権限が必要問題

docker daemon の実行にルート権限が必要になるための起こるセキュリティ問題については、ビルドをユーザー空間で実行するという方法で回避しています。

kankko では↓の流れでビルドが行われます

  1. Dockerfile をパース
  2. ベースイメージのファイルシステムをルートに展開
  3. Dockerfile 内のコマンドを順番に実行
  4. 実行後のファイルシステムのスナップショットを作成
  5. スナップショットを tarball として保存しベースイメージにレイヤーとして追加
  6. 3~5 を Dockerfile のすべてのコマンドで実行し最終的なイメージを作成
  7. 作成されたイメージを指定のコンテナイメージのレジストリにプッシュ

ビルドをすべてユーザー空間で行っているために docker build のようにカーネル空間で行うよりはパフォーマンスは悪くなりますが、よく使用されるような Dockerfile のコマンドでは無視できる程度のパフォーマンス劣化のようです。

Dockerfile のコマンドの途中で変更があるとそれ以降でキャッシュが効かない問題

ビルドの流れでも言及しましたが、 kaniko では Dockerfile の FROM 以降のコマンドごとにキャッシュを作成します。2回目以降のビルドでは、コマンドの実行前にレジストリにキャッシュがあるかを見て、有ればレジストリから取得して展開、無ければコマンドを実行するので、キャッシュが無いためにコマンドが実行された以降のレイヤーでもキャッシュが効くようになりビルド時間が短縮されます。

Cloud Build で kaniko を使う

ここまでは、 kaniko の開発経緯やビルドの仕組みについて書いてきましたが、 Cloud Build での docker のマルチステージビルドを紹介しつつ、それを kaniko で行う方法を説明します。

ちなみに Cloud Build というのは google が提供しているの CI/CD サービスのことです。 https://cloud.google.com/cloud-build/ また、Cloud Build での kaniko の詳しい使い方についてはドキュメントが用意されてるのでそちらをご覧ください。 https://cloud.google.com/cloud-build/docs/kaniko-cache

docker でマルチステージビルド

docker にはマルチステージビルドというより軽量なイメージをビルドできる機能がありますが、 それを Cloud Build で行う場合にキャッシュを効かせるために工夫が必要になります。

↓はキャッシュを効かせる工夫を施した Cloud Build の yaml の例です。 それぞれのステージ毎にビルドをし、 --cache-from でそれを明示的に指定する必要がある分ステップが増え可読性が落ちてしまいます。

steps:
  # それぞれのステージのイメージを pull する。
  - name: 'gcr.io/cloud-builders/docker'
    args: ['pull', 'gcr.io/$PROJECT_ID/app_builder:latest']
    waitFor: ['-']
    id: 'pull-builder'
  - name: 'gcr.io/cloud-builders/docker'
    args: ['pull', 'gcr.io/$PROJECT_ID/app:latest']
    waitFor: ['-']
    id: 'pull-app'

  # pull したイメージを --cache-from で指定してキャッシュを効かせるようにし、 --target でビルドするステージの指定をする。 target は Dockerfile の FROM コマンドで as で指定することが出来る。
  - name: 'gcr.io/cloud-builders/docker'
    args: ['build', '--cache-from', 'gcr.io/$PROJECT_ID/app_builder:latest', '-t', 'gcr.io/$PROJECT_ID/app_builder:latest', '--target', 'app_builder', '.']
    waitFor: ['pull-builder']
    id: 'build-builder'

  # キャッシュを効かせるために --cache-from で各ステージのイメージを指定する。
  - name: 'gcr.io/cloud-builders/docker'
    args: ['build', '--cache-from', 'gcr.io/$PROJECT_ID/app_builder:latest', '--cache-from', 'gcr.io/$PROJECT_ID/app:latest', '-t', 'gcr.io/$PROJECT_ID/app:latest',  '.']
    waitFor: ['build-builder']
    id: 'build-app'

  # 各ステージ用にビルドしたイメージをそれぞれ push する。
  - name: 'gcr.io/cloud-builders/docker'
    args: ['push', 'gcr.io/$PROJECT_ID/app_builder:latest']
    waitFor: ['build-builder']
    id: 'push-builder'
  - name: 'gcr.io/cloud-builders/docker'
    args: ['push', 'gcr.io/$PROJECT_ID/app:latest']
    waitFor: ['tag-app']
    id: 'build-app'

kaniko でマルチステージビルド

kaniko では、内部でマルチステージビルドしてくれる機能があるので特に何かを設定する必要がなく yaml が非常にシンプルになります。

steps:
  - name: 'gcr.io/kaniko-project/executor:v0.13.0'
    args:
      - --destination=gcr.io/$PROJECT_ID/app:latest
      - --cache=true
      - --cache-ttl=6h

まとめ

ここまで書いてきたように、kaniko は docker のセキュリティやキャッシュの問題を解決してたり、イメージで提供されてることから非常に使いやすいビルドツールです。

しかし、活発に開発が進んでることもあり issue を見ると不具合の報告がたくさんあるので、使用する際は動作確認などをしっかり行う必要があります。 特にアップグレードの際は差分をよく確認することをおすすめします。

以前に kaniko のイメージのタグを latest で使用してしまっており、 v0.13.0 から v0.14.0 に上がったタイミングでキャッシュが効きすぎてしまい変更が反映されないという不具合が発生した、バージョンについてはタグで明示的に設定するしたほうが良いでしょう。(具体的な原因については調査中で、分かり次第 issue に上げる予定です。)

時間切れで docker 謹製の buildkit との違いが追えなかったので別途まとめます。

参考

電話番号の正規化を Google 製電話番号ライブラリ libphonenumber に置き換えたはなし

https://qiita.com/Daniel_Nakano/items/75b653fc398d78570e41 からの転記

これはなに?

トレタのAPIでは、電話番号の正規化をするのにcountries gemを使用しています。countries gemを利用することで各国の情報(国、通貨、電話番号など)が取得できるようになります。

しかし、一部電話番号周りで正しくデータが入ってない箇所があったので、libphonenumberベースのgemであるphonelibに置き換えるために調査をしたメモです。

libphonenumberとは?

countries gemを使ってるのになぜlibphonenumberが必要だったのか?

countries gemを利用すれば基本的に問題がないのですが、一部の国で正しく情報が取得できないために問題が発生したので、今回電話番号周りのみをlibphonenumberに置き換えることにしました。

問題となっていたのは、台湾の国内プレフィクスです。

ruby2.4.2、countries gemは2.1.2バージョンで実際の取得しようとすると以下のようになります。

[1] pry(main)> require 'countries'
=> true
[2] pry(main)> tw = ISO3166::Country.new('TW')
=> #<ISO3166::Country:0x00007fe3c3bc4770
 @country_data_or_code="TW",
 @data=
  {"continent"=>"Asia",
   "address_format"=>"{{recipient}}\n{{street}}\n{{city}} {{region_short}} {{postalcode}}\n{{country}}",
   "alpha2"=>"TW",
   "alpha3"=>"TWN",
   "country_code"=>"886",
   "international_prefix"=>"002",
   "ioc"=>"TPE",
   "gec"=>"TW",
   "name"=>"Taiwan, Province of China",
   "national_destination_code_lengths"=>[2],
   "national_number_lengths"=>[7, 8],
   "national_prefix"=>"None",
   "number"=>"158",
   "region"=>"Asia",
   "subregion"=>"Eastern Asia",
   "world_region"=>"APAC",
   "un_locode"=>"TW",
   "nationality"=>"Taiwanese",
   "postal_code"=>true,
   "unofficial_names"=>["Taiwan", "Taiwán", "台湾"],
   "languages_official"=>["zh"],
   "languages_spoken"=>["zh"],
   "geo"=>
    {"latitude"=>23.69781,
     "latitude_dec"=>"23.685789108276367",
     "longitude"=>120.960515,
     "longitude_dec"=>"120.89749145507812",
     "max_latitude"=>26.4545,
     "max_longitude"=>123.5021012,
     "min_latitude"=>20.5170001,
     "min_longitude"=>116.6665,
     "bounds"=>{"northeast"=>{"lat"=>26.4545, "lng"=>123.5021012}, "southwest"=>{"lat"=>20.5170001, "lng"=>116.6665}}},
   "currency_code"=>"TWD",
   "start_of_week"=>"monday",
   "translations"=>{"en"=>"Taiwan"},
   "translated_names"=>["Taiwan"]}>
[3] pry(main)> tw.national_prefix
=> "None"

本来、台湾の国内プレフィックは0なのですが、"None"が返ってきてしまい正しく電話番号の正規化が出来ませんでした。

暫定的に国が台湾の場合に0を差し込むことも考えたのですが、今後別の国でも同じような問題が発生する可能性もありました。そこで、電話番号の部分を別のライブラリに置き換えることが出来ないかと調査したところ、googleで作成と管理をしている電話番号のライブラリであるlibphonenumberがあり使おうということに決めました。

じゃあphonelibとは?

libphonenumberベースのgemはいくつか存在しているのですが、その中で最もアクティブに開発がされているphonelibといgemを採用しました。

ちなみに、他にもglobal_phonetelephone_numberなどがありました。

phonelibの使い方

今回紹介するのは、実際にトレタで使用してる部分のみです。 他にもいろんなことが出来るので、興味が有る方はphonelibのGithubのリポジトリを御覧ください。

インストール

bundlerを利用する場合はGemfileに以下を追加します。

gem 'phonelib'

直接インストールする場合は、

$ gem install phonelib

電話番号のフォーマット

まず、Phonelibクラスのインスタンスを作ります。

[1] pry(main)> phone = Phonelib.parse('09011112222', :jp)
=> #<Phonelib::Phone:0x0055b475198ed8
 @data=
  {"JP"=>
    {:id=>"JP",
     :country_code=>"81",
     :international_prefix=>"010",
     :national_prefix=>"0",
     :national_prefix_formatting_rule=>"$NP$FG",
     :mobile_number_portable_region=>"true",
     :national=>"9011112222",
     :format=>{:pattern=>"(\\d{2})(\\d{4})(\\d{4})", :leading_digits=>"[2579]0|80[1-9]", :format=>"$1-$2-$3"},
     :valid=>[:mobile],
     :possible=>[:toll_free, :voip, :pager, :mobile]}},
 @extension="",
 @national_number="9011112222",
 @original="09011112222",
 @original_s="09011112222",
 @sanitized="09011112222">

電話番号が指定の国に対して有効かどうかの判断

[2] pry(main)> phone.valid?
=> true

国内表記

  • ハイフンとかが入った表記
[3] pry(main)> phone.national
=> "090-1111-2222"
  • 電話番号のみの表記
[4] pry(main)> phone.national(false)
=> "09011112222"

国際表記

  • ハイフンとかスペースが入った表記
[5] pry(main)> phone.international
=> "+81 90-1111-2222"
  • 電話番号のみの表記
[6] pry(main)> phone.international(false)
=> "819011112222"

国を指定しない場合

国を指定しない場合でも、電話番号が国際表記であり、かつ正しければ国を判別することが可能です。

[7] pry(main)> phone = Phonelib.parse('81911112222')
=> #<Phonelib::Phone:0x0055b474e961e0
 @data=
  {"JP"=>
    {:id=>"JP",
     :country_code=>"81",
     :international_prefix=>"010",
     :national_prefix=>"0",
     :national_prefix_formatting_rule=>"0$1",
     :mobile_number_portable_region=>"true",
     :national=>"911112222",
     :format=>{:pattern=>"(\\d{2})(\\d{3})(\\d{4})", :leading_digits=>"[2479][1-9]", :format=>"$1-$2-$3"},
     :valid=>[],
     :possible=>[:premium_rate, :toll_free, :personal_number, :uan, :fixed_line]}},
 @extension="",
 @national_number="911112222",
 @original="810911112222",
 @original_s="810911112222",
 @sanitized="810911112222">

指定の国の情報を取得する

Phonelib.phone_data[国コード](国コードはISO 3166-1のalpha-2形式)で指定の国の情報を取得することがでいます。

[10] pry(main)> Phonelib.phone_data['JP']
=> {:id=>"JP",
 :country_code=>"81",
 :international_prefix=>"010",
 :national_prefix=>"0",
 :national_prefix_formatting_rule=>"0$1",
 :mobile_number_portable_region=>"true",
...(省略)
}

国内プレフィクスが必要なときは、

[11] pry(main)> Phonelib.phone_data['JP'][:national_prefix]
=> "0"

指定の国の電話番号のフォーマットをしりたいときは、

[3] pry(main)> Phonelib.phone_data['JP'][:formats]
=> [{:pattern=>"(\\d{3})(\\d{3})(\\d{3})", :leading_digits=>"(?:12|57|99)0", :format=>"$1-$2-$3"},
 {:pattern=>"(\\d{3})(\\d{3})(\\d{4})", :leading_digits=>"800", :format=>"$1-$2-$3"},
 {:pattern=>"(\\d{4})(\\d{4})",
  :national_prefix_formatting_rule=>"$FG",
  :leading_digits=>"0077",
  :format=>"$1-$2",
  :intl_format=>"NA"},
 {:pattern=>"(\\d{4})(\\d{2})(\\d{3,4})",
  :national_prefix_formatting_rule=>"$FG",
  :leading_digits=>"0077",
  :format=>"$1-$2-$3",
  :intl_format=>"NA"},
...(省略)
}

ちなみに、目的である台湾の国内プレフィクスが取得できるかについては、

[4] pry(main)> Phonelib.phone_data['TW'][:national_prefix]
=> "0"

のように正しく取得できます。

所感

基本的な情報を取得する部分でライブラリを使用しているときに、新たな国や地域でサービスを展開する場合は必ず事前調査が必ず必要であることを学びました。 また、libphonenumberのライブラリを知れたのも非常に有益でした。

参考

bootsnap について調べてみた

https://qiita.com/Daniel_Nakano/items/aadeaa7ae4e227b73878 からの転記

これはなに?

今年のRubyKaigi 2017でrailsの起動時間を短縮してくれるbootsnapという便利なgemを知り、これをトレタで開発しているのrailsアプリに導入できないかと思い、このgemの内部処理を調査したメモです。

あ、bootstrapではなくbootsnapです。一応。 日本語版のREADMEはこちら

bootsnapとは?

railsの起動時の処理を最適化する(パスとrubyコンパイル結果をキャッシュ)ことで起動時間を短縮してくれる便利なgemです。

導入方法

Gemfileに

gem 'bootsnap', require: false

を追加して、config/boot.rbrequire 'bundler/setup'の直下に

require 'bootsnap/setup'

を追加するだけです。

また、require 'bootsnap/setup'の代わりに

require 'bootsnap'
env = ENV['RAILS_ENV'] || "development"
Bootsnap.setup(
  cache_dir:            'tmp/cache',          # キャッシュを保存するパス
  development_mode:     env == 'development', # RACK_ENV、RAILS_ENV
  load_path_cache:      true,                 # $LOAD_PATHをキャッシュする
  autoload_paths_cache: true,                 # ActiveSupport::Dependencies.autoload_pathsをキャッシュする
  compile_cache_iseq:   true,                 # rubyの実行結果をキャッシュする
  compile_cache_yaml:   true                  # YAMLのコンパイル結果をキャッシュする
)

のようにして個別の設定を行うこと出来ます。

試してみた

どのぐらい起動時間が短縮するかの実際のrailsアプリで検証してみます。

検証環境

検証方法

rails newrailsアプリを作成し、bootsnapの導入前後で簡単なrails runnerの実行時間を計測します。 また、bootsnap以外による最適化をなるべく避けるためにSpringは無効化しています。

実行するrails runnerのスクリプト

$ time DISABLE_SPRING=1 bundle exec rails runner 'puts 0'

検証

導入前

$ time DISABLE_SPRING=1 bundle exec rails runner 'puts 0'
0
real    2.16s
user    1.52s
sys     0.60s

$ time DISABLE_SPRING=1 bundle exec rails runner 'puts 0'
0
real    2.12s
user    1.51s
sys     0.59s

$ time DISABLE_SPRING=1 bundle exec rails runner 'puts 0'
0
real    2.15s
user    1.53s
sys     0.60s

$ time DISABLE_SPRING=1 bundle exec rails runner 'puts 0'
0
real    2.09s
user    1.48s
sys     0.58s

$ time DISABLE_SPRING=1 bundle exec rails runner 'puts 0'
0
real    2.12s
user    1.51s
sys     0.58s

導入後

$ time DISABLE_SPRING=1 bundle exec rails runner 'puts 0'
0
real    1.27s
user    0.91s
sys     0.32s

$ time DISABLE_SPRING=1 bundle exec rails runner 'puts 0'
0
real    1.31s
user    0.94s
sys     0.33s

$ time DISABLE_SPRING=1 bundle exec rails runner 'puts 0'
0
real    1.22s
user    0.84s
sys     0.28s

$ time DISABLE_SPRING=1 bundle exec rails runner 'puts 0'
0
real    1.26s
user    0.86s
sys     0.28s

$ time DISABLE_SPRING=1 bundle exec rails runner 'puts 0'
0
real    1.26s
user    0.89s
sys     0.32s

検証結果

導入前後の実行時間の平均から算出したところ、40.6%起動時間が短縮されるという結果がでました。 また、トレタのrailsアプリでも同じように実行した結果、約30%起動時間が短縮されました。

仕組み

bootsnapは大きく分けて2つの最適化の処理をしています。

  • Path Pre-Scanning
  • Compilation caching

基本的な処理方法としては、どちらともキャッシュすることでシステムコールを叩く回数が減らし起動時間を短くしています。

Path Pre-Scanning

$LOAD_PATHActiveSupport::Dependencies.autoload_paths をキャッシュすることで起動の度に実行されるパスのフルスキャンをスキップしています。

$LOAD_PATHのキャッシュ

railsでは起動時にコード中に require 'hoge' があると目的のファイルが見つかるまで $LOAD_PATH をフルスキャンが実行します。 bootsnapを導入することで起動時に事前に $LOAD_PATH をキャッシュしておき、フルスキャンの結果ではなくキャッシュから目的のファイルをロードするようになります。そうすることで、毎回行われるフルスキャンがスキップされ不必要なシステムコールの実行が減り起動時間が短縮されます。 これらの処理は、Kerenelモジュールの require メソッドと load メソッドにオーバライドしてキャッシュを見に行く処理を差し込むことで実現しています。

ActiveSupport::Dependencies.autoload_pathsのキャッシュ

処理方法としては$LOAD_PATH のキャッシュと同様で、事前に ActiveSupport::Dependencies.autoload_paths をキャッシュをし、それを読みに行くようにしています。 この処理は、 ActiveSupport::Dependencies モジュールの autoloadable_module? メソッド、 load_missing_constant メソッド、 depend_on メソッドをオーバーライドしキャッシュされた ActiveSupport::Dependencies.autoload_paths を見るようにして実現しています。

全てのパスを永続的にキャッシュするわけではない

キャッシュする対象となるパスはstableとvolatileの2種類に分けられています。 stableに分類されるパスは、一度スキャンされたら永続的にキャッシュされます。一方、volatileに分類されるパスは、railsの起動の度に新しく作成され30秒後に削除されます。

stableなパスとvolatileなパスの分類

パスの分類については、以下の2つの条件の何れかに合致していればstableに分類します。

  • gemのパス
  • 使用しているバーションのrubyのインストール先の基準ディレクトリのパス( RbConfig::CONFIG['prefix']

異なる場合には全てvolatileに分類されます。

Compilation caching

rubyのコードのコンパイル結果(YARV命令列)とYAMLファイルのコンパイル結果(MessagePackファイル、またはMarshalファイル)をキャッシュすることで、railsの起動時に走るこれらの処理がスキップされ起動時間が短縮されます。

rubyコンパイル結果のキャッシュ

全く知らなかったのですが、ruby2.3.0から導入されたクラスでRubyVM::InstructionSequeというのがあり、rubyのコードのYARV命令列を取得したり、それをバイナリデータに変換したりすることが出来ます。

RubyVM::InstructionSequeを利用してrailsの起動時にrubyのコードをYARV命令列にコンパイルし、その結果をバイナリの変換してtmp/cache配下に保存します。キャッシュするデータの保存場所はデフォルトではconfig/boot.rbになっています。もし変更したい場合は、 Bootsnap.setup(cache_dir: 保存するフォルダ) のようにして指定することができます。

rubyのコードを事前にコンパイルする仕組みについては、Compilation cachingのベースとなっているyomikomu gemるびまのrubyのプレコンパイルの記事に詳しく書かれているので是非読んでみて下さい。

YAMLファイルのコンパイル結果のキャッシュ

railsアプリを書いてると、コードベースが大きくなるに連れYAMLファイルで管理している設定ファイルを増えてくることはよくあると思うのですが、これも起動に時間がかかってしまう大きな原因の一つとなっています。

これを最適化するために、YAMLファイルもrubyのコードの場合と同じように別の形式に変換しキャッシュとして利用することで起動時のYAMLファイルのロードをスキップさせることが可能になります。

処理方法としては、YAMLファイルをMessagePackデータ、もし不可能な場合にはMarshalデータにコンパイルした結果をtmp/cache配下に保存してキャッシュとして利用しています。

キャッシュの再作成

bootsnapでは、キャッシュを作成する際に幾つかのkeyを含んだヘッダを付与します。 railsの起動の際にこのヘッダを見て、有効であればキャッシュされたデータをロードし、有効でなければ再作成をします。

使い所

bootsnapは、元々巨大なモノリシックなrailsアプリの開発の際に起動に時間がかかりすぎるために開発の速度が遅くなってしまうのを解消するために作られたという経緯があります。使い所としては正にここで、development環境での開発時に一番効力を発揮します。個人的には、特にspecを頻繁に回したりするときに起動時間が短くなるので、あのちょっとした待ち時間(あれ、結構いらいらしませんか?)が短縮されるので非常に期待しています。

また、productionの環境で使用する場合は、デプロイの時間が短縮されるので効果的かとは思いますが、まだ実例が少なくキャッシュが原因で何か予期せぬ不具合が発生したりする可能性も考えられます。なので、もし使用する場合は事前にテストをしたりしてよく調査する必要があるでしょう。

所感

Bootsnapを調査する過程でRubyVM::InstructionSequenceの存在や起動時間を短縮するために用いる手法などを知れたのは、個人的には非常に得るものが大きかったです。 これらの調査を元に、実際にトレタでの開発環境にbootsnapを導入してみて、おそらく良い部分や悪い部分が出てくると思うので、その結果をまた共有できたらなと思っています。

参考