Kubelet の Dynamic Kubelet Configuration について
Kubelet の Dynamic Kubelet Configuration の概要
Dynamic Kubelet Configuration は kubelet の設定を kubernetes の API ( ConfigMap
) を利用して変更できるようにする機能です。
Dynamic Kubelet Configuration による Kubelet の設定変更の流れは下記の様になります。
- Dynamic Kubelet Configuration を有効化する(設定ファイルのパスを指定し、Kubelet の ConfigMap を参照するように Node のマニフェストを変更する)
- Kubelet の設定を持つ ConfigMap を新規に作成、または変更する
- Kubelet は ConfigMap の変更を検知し、設定を1で指定したパスに展開させる
- Kubelet が再起動され設定が反映される
なぜ必要?
元々 Kubelet の設定は起動時のオプションでしか設定できなかったので、設定を変更するためには VM に ssh してオプションを直接変更して再起動させるなどの方法しかありませんでした。 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 Calendar の20日目の記事です。
ここで書いたことは 2019/12/20 時点での情報を元にしており、今後新しいバージョンで仕様が変更になる可能性があるのでご注意ください。
はじめに
kaniko とはコンテナビルドツールの一つで、docker のセキュリティやキャッシュの問題を解決するために開発されました。 この記事では、 docker のセキュリティの問題について軽く紹介した後に、それを kaniko がどのように解決したかを説明します。 また、最後に使用中に発生した問題もご紹介します。
kaniko とは?
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 daemon が REST 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 では↓の流れでビルドが行われます
- Dockerfile をパース
- ベースイメージのファイルシステムをルートに展開
- Dockerfile 内のコマンドを順番に実行
- 実行後のファイルシステムのスナップショットを作成
- スナップショットを tarball として保存しベースイメージにレイヤーとして追加
- 3~5 を Dockerfile のすべてのコマンドで実行し最終的なイメージを作成
- 作成されたイメージを指定のコンテナイメージのレジストリにプッシュ
ビルドをすべてユーザー空間で行っているために 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とは?
- https://github.com/googlei18n/libphonenumber
- 電話番号のパースやフォーマット、バリデートができるライブラリ
- C+、Java、JavaScript用
- googleが作成、保守している
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_phoneやtelephone_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.rb
のrequire '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 new
でrailsアプリを作成し、bootsnapの導入前後で簡単なrails runnerの実行時間を計測します。
また、bootsnap以外による最適化をなるべく避けるためにSpringは無効化しています。
$ 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_PATH
と ActiveSupport::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に分類します。
異なる場合には全て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を導入してみて、おそらく良い部分や悪い部分が出てくると思うので、その結果をまた共有できたらなと思っています。