Stackdriver MonitoringでGKEのPodを監視する場合
Stackdriver MonitoringでGKEのPod,Containerのリソースを監視する場合、アラートが発生したあとにそのPodが削除された場合、そのアラートがずっと残ってしまうという問題があった。 この問題は、Pod, Containerそのもののメトリクスを監視するのではなく、グルーピングすることがで解決できる。 アラートのポリシーを設定するときには、container_nameでグルーピングする方がよさそう。 グルーピングする場合、集約のルールも設定する必要があるが、99 percentile などを設定すれば特定のPodで異常値が発生したとしても見逃すことはない。
Sidecarを利用しているとk8sのjobが終了しない
問題
Sidecarを使っているとkubernetesのJobが正しく終了できない問題がありました。SIdecarコンテナの仕様によっても変わるのですが、cloudsql-proxy, istio-proxyなどよく使われるSidecarではこの問題が発生します。
これはJobを実行するメインのコンテナが処理を完了して終了しても、Sidecarは変わらず動作を続けているからです。
状況
kubernetesにもIstioにもIssueがあがっており、回避策は挙げられているものの、それが利用できるかどうかは状況次第で、解決には至っていないようです。
Better support for sidecar containers in batch jobs · Issue #25908 · kubernetes/kubernetes · GitHub
現状の解決策
現状の解決策は、
- Sidecarコンテナを自分でコントロールして自死する仕組みを作る(emptyDirをmountしておいて、そこに特定のファイルがあったら終了コード0で終わるとか)
- Jobが長引かないことを祈ってconcurrencyPolicy: Replace
の2択のようです。
前者の方法はistio-proxyのように自動で注入されるsidecarには適用しづらいので、後者が適用できるように実行間隔や処理内容を調整する必要があります。。
また、自分でSidecarコンテナを作成する場合は、別コンテナから安全に終了させることができる仕組みを用意しておくとよさそうです。
追記
CronJobの場合は、concurrencyPolicyで対応できるが、Jobの場合はそれがない。
Jobの場合は、activeDeadlineSeconds を設定することで、指定した時間が来たらPodが終了することを保証することができる。
k8sのreadOnlyRootFilesystemを有効にして、Rackアプリケーションでファイルアップロードをするための設定
kubernetesにはreadOnlyRootFilesystemというオプションがあり、このオプションを有効にするとコンテナ内がreadonlyになるためセキュリティの施策の1つとして有効にされることがあります。その状態でRack(Rails,Sinatra,etc)アプリケーションからファイルアップロードを行うときに思ったより手間取ったのでそのときの対応メモ。
Rubyのバージョンは2.6。kuberntesはGKEを利用していて、1.11.7-gke.6でした。
前提とエラーの内容
WebアプリケーションでPOSTのファイルアップロードを受け付けるにはディスクに一時ファイルを書き込みできる必要があります。readOnlyRootFilesystemを有効にした状態でファイル書き込みを許可する場合には、k8sのemptyDirを作成し、それをマウントするのが一般的に取られる設定です。(おそらくですが)
以下のようなyamlになります。
containers: image: rubyapp name: app securityContext: readOnlyRootFilesystem: true volumeMounts: - name: tmp mountPath: /tmp volumes: - name: tmp emptyDir: {}
nginxのpid書き込みなど、この設定で問題ないのですが、Rackの場合はパーミッションエラーがありうまくいきませんでした。
エラーメッセージをよく見てみると一時ファイルを書き込みしようとしてるディレクトリがなぜかRubyを実行しているカレントディクトリになっています。Rubyの問題のようなので調べて見ると、Rackがファイルアップロード時の一時ファイルを保存するのは Dir.tmpdir で得られるディレクトリということでした。Dir.tmpdirを実行してみたところ、たしかにカレントディレクトリが取得できました。ENV['TMPDIR']を"/tmp"にしても変わらずカレントディレクトリのままでした。
原因
そこで Rubyのソースコードを確認してみたところ、原因がわかりました。Dir.tmpdirは以下のようになっています
def self.tmpdir if $SAFE > 0 @@systmpdir.dup else tmp = nil [ENV['TMPDIR'], ENV['TMP'], ENV['TEMP'], @@systmpdir, '/tmp', '.'].each do |dir| next if !dir dir = File.expand_path(dir) if stat = File.stat(dir) and stat.directory? and stat.writable? and (!stat.world_writable? or stat.sticky?) tmp = dir break end rescue nil end raise ArgumentError, "could not find a temporary directory" unless tmp tmp end end
https://github.com/ruby/ruby/blob/1fae154c07b957278fd336b54256d5c57f21e0d5/lib/tmpdir.rb#L26
問題はこの条件文です。
(!stat.world_writable? or stat.sticky?)
tmpdir候補のディレクトリはこのチェックを突破しないとtmpdirになることができないようです。VolumeMountされたディレクトリを確認したところ、world_writable?はtrueかつ、stickyビットは立っていないことが確認できたので、これが原因で間違いなさそうです。
対応
VolumeMountするディレクトリのパーミッションを変更する方法を調べたのですが見つかりませんでした。
Rubyはどのユーザーでも書き込みできるディレクトリの場合はstickyビットを求めているのに対し、k8sはコンテナ前提なので複数のユーザーがいることはあまり想定していないのでしょう。
仕方ないので、RubyのDir.tmpdirを書き換えてしまいます
class Dir class << self alias org_tmpdir tmpdir end def self.tmpdir dir = ENV['TMPDIR'] return File.expand_path(dir) unless dir.nil? org_tmpdir end end
manifestsは以下のようにします。
containers: - image: rubyapp name: app env: - name: TMPDIR value: /tmp securityContext: readOnlyRootFilesystem: true volumeMounts: - name: tmp mountPath: /tmp volumes: - name: tmp emptyDir: {}
これで RubyのDir.tmpdirが"/tmp"になり、/tmpには書き込みができるのでファイルアップロードができるようになりました。
k8sクラスタのsecretsを別のクラスタにコピーしたいとき
secretsといってもbase64encodeされているだけなので、簡単にコピーできます。
kubectl get secrets -o yaml > current_secrets.yaml
で取り出して
kubectl apply -f current_secrets.yaml
でok。
このときTYPE:kubernetes.io/service-account-tokenなど、おそらくコピーすべきでないsecretsも含まれていることに注意が必要です。
アプリケーションで利用するsecretsにラベルを付与しておけば、secretsを取り出すときにラベルでフィルタできるので便利です。次この作業をするかどうかはわからないのですが、念のため僕はsecretsにlabelを付与しておくことにしました。
複数のファイルをまとめてkubectl apply する
kubectl にはその機能がなかったのでfindしてxargsする
find . -name "*.yaml"|xargs -I {} kubectl apply -f {}
kubectl execの仕組みを追える範囲で追ってみた
kubernetesを利用しているとき、 kubectl exec -it "pod_name" /bin/bash で目的のコンテナにbashログインすることができますが、これがなぜ動いているのか理解できていなったので調べてみました。
kubectl execとsshと何が違うのか?(sshと同じセキュリティ対策が必要ではないのか??)と聞かれたときに違うとは思うんだけどどう違うのかうまく答えられなくて気になってしまったのがきっかけです。
概要
調べてみたところ既に質問していた人がいました。
このフォーラムによると、kubectl exec は以下のように流れていくようです。
- kube-apiserver(以下apiserver)にPOSTリクエストを投げる( /v1/namespaces/{ns}/pods/{pod}/exec )
- このリクエストには SPDYへのアップグレードが含まれている(SPDYによる多重化通信が必要なため)
- apiserverはPodに接続するため、kubeletとの通信を確立させる
- kubeletは有効期限の短いtokenを発行し、CRIにリダイレクトさせる
- CRIハンドラーがリクエストを実行し、docker exec APIコールを行う
kubectl execコマンドは最終的にはPodが存在するNodeでdocker exec コマンドが実行される、と覚えておけばよさそうです。(CRIインタフェースに対応していればコンテナランタイムは問わないので、docker execとは限りませんが)
ソースコードを追ってみる
Google Forumに書かれていることが本当なのかわからなかったので、念のためソースコードを追ってみます。最近仕事でGo使いだしたのできっと読めるはず・・
kubectl
POSTして
kubernetes/exec.go at master · kubernetes/kubernetes · GitHub
SPDY通信してそう。
kubernetes/exec.go at master · kubernetes/kubernetes · GitHub
/v1/namespaces/{ns}/pods/{pod}/execでPOSTしている箇所は見つけることができました
api-server
/v1/namespaces/{ns}/pods/{pod}/execで実行されるコードを見つけることはできませんでした。。
しかし、おそらくapiserverとkubeletが通信するendpointを作成しているコードを見つけることができました。
kubernetes/strategy.go at 152b09ac550d50deeeff7162093332b4f7f0397d · kubernetes/kubernetes · GitHub
loc := &url.URL{
Scheme: nodeInfo.Scheme,
Host: net.JoinHostPort(nodeInfo.Hostname, nodeInfo.Port),
Path: fmt.Sprintf("/%s/%s/%s/%s", path, pod.Namespace, pod.Name, container),
RawQuery: params.Encode(),
}
/exec/{namespace}/{name}/{container} というpathでNodeと通信するURLを作成していそうです。Nodeに存在しているのはkubeletのはずなので、次はkubeletで /exec/{namespace}/{name}/{container} というエンドポイントで待ち受けているコードを探してみます。
kubelet
https://github.com/kubernetes/kubernetes/blob/master/pkg/kubelet/server/server.go#L352
// getExec handles requests to run a command inside a container. なんかそれっぽい!
https://github.com/kubernetes/kubernetes/blob/master/pkg/kubelet/server/server.go#L707
func (s *Server) getExec(request *restful.Request, response *restful.Response) {
~~~
url, err := s.host.GetExec(podFullName, params.podUID, params.containerName, params.cmd, *streamOpts)
~~~
}
hostのgetExecを読んでいます。Hostに何が入るのかわからないですが、getExecを実装している箇所をさらに検索します。
hostは省略しますが、
func (kl *Kubelet) GetExec(podFullName string, podUID types.UID, containerName string, cmd []string, streamOpts remotecommandserver.Options) (*url.URL, error) { container, err := kl.findContainer(podFullName, podUID, containerName) if err != nil { return nil, err } if container == nil { return nil, fmt.Errorf("container not found (%q)", containerName) } return kl.streamingRuntime.GetExec(container.ID, cmd, streamOpts.Stdin, streamOpts.Stdout, streamOpts.Stderr, streamOpts.TTY) }
Kubelet.streamingRuntimeのGetExecが呼ばれています。
streamingRuntimeの定義はこれです
runtime, err := kuberuntime.NewKubeGenericRuntimeManager( ~~~ ) if err != nil { return nil, err } klet.containerRuntime = runtime klet.streamingRuntime = runtime
streamingRuntime.GetExecでは下記のGetExecが実行されることになります
func (m *kubeGenericRuntimeManager) GetExec(id kubecontainer.ContainerID, cmd []string, stdin, stdout, stderr, tty bool) (*url.URL, error) { ~~ resp, err := m.runtimeService.Exec(req) ~~ }
ここでは、runtimeServiceのExec(req) を実行しています。runtimeServiceが何なのか調べます。
// gRPC service clients runtimeService internalapi.RuntimeService
runtimeService internalapi.RuntimeService とあるので、 internalapi を見てみると
internalapi "k8s.io/kubernetes/pkg/kubelet/apis/cri
ついにCRIまでたどり着きました。
switch containerRuntime { case kubetypes.DockerContainerRuntime: // Create and start the CRI shim running as a grpc server. streamingConfig := getStreamingConfig(kubeCfg, kubeDeps, crOptions) ds, err := dockershim.NewDockerService(kubeDeps.DockerClientConfig, crOptions.PodSandboxImage, streamingConfig, &pluginSettings, runtimeCgroups, kubeCfg.CgroupDriver, crOptions.DockershimRootDirectory, !crOptions.RedirectContainerStreaming) if err != nil { return nil, err } if crOptions.RedirectContainerStreaming { klet.criHandler = ds } // The unix socket for kubelet <-> dockershim communication. klog.V(5).Infof("RemoteRuntimeEndpoint: %q, RemoteImageEndpoint: %q", remoteRuntimeEndpoint, remoteImageEndpoint) klog.V(2).Infof("Starting the GRPC server for the docker CRI shim.") server := dockerremote.NewDockerServer(remoteRuntimeEndpoint, ds) if err := server.Start(); err != nil { return nil, err } ~~~~
criHandlerにDockerServiceが設定されています。
DockerServiceの定義はこれです。 ここで詰まってしまったのですが、kubeletがexec用にtokenを発行しているという手掛かりを元にコードを検索してみます。
endpoints := []struct { path string handler restful.RouteFunction }{ {"/exec/{token}", s.serveExec}, {"/attach/{token}", s.serveAttach}, {"/portforward/{token}", s.servePortForward}, }
見つかりました。
exec/{token} というURLでserveExec を実行しています。どこかでこの streaming packageのNewServer(config, runtime)が実行されていると思うのですが見つかりませんでした。 GetExecが実行されてからどこかでこのserveExecにたどり着くんだと思います。
remotecommandserver.ServeExec( resp.ResponseWriter, req.Request, s.runtime, "", // unused: podName "", // unusued: podUID exec.ContainerId, exec.Cmd, streamOpts, s.config.StreamIdleTimeout, s.config.StreamCreationTimeout, s.config.SupportedRemoteCommandProtocols)
serveExecはremotecommandserverのServeExecを呼び出します。
importに
remotecommandserver "k8s.io/kubernetes/pkg/kubelet/server/remotecommand"
が含まれているので remotecommandを見に行きます。
func ServeExec(w http.ResponseWriter, req *http.Request, executor Executor, podName string, uid types.UID, container string, cmd []string, streamOpts *Options, idleTimeout, streamCreationTimeout time.Duration, supportedProtocols []string) { ~~ err := executor.ExecInContainer(podName, uid, container, cmd, ctx.stdinStream, ctx.stdoutStream, ctx.stderrStream, ctx.tty, ctx.resizeChan, 0) ~~ }
executorのExecInContainerが実行されています。
func (a *criAdapter) ExecInContainer(podName string, podUID types.UID, container string, cmd []string, in io.Reader, out, err io.WriteCloser, tty bool, resize <-chan remotecommand.TerminalSize, timeout time.Duration) error { return a.Runtime.Exec(container, cmd, in, out, err, tty, resize) }
ExecInContainerはRuntimeのExec(container, cmd, in, out, err, tty, resize)を実行しています。 dockershimにExecがあればそれが処理の実態になっていそうです。
dockershim
func (r *streamingRuntime) Exec(containerID string, cmd []string, in io.Reader, out, err io.WriteCloser, tty bool, resize <-chan remotecommand.TerminalSize) error { return r.exec(containerID, cmd, in, out, err, tty, resize, 0) } // Internal version of Exec adds a timeout. func (r *streamingRuntime) exec(containerID string, cmd []string, in io.Reader, out, errw io.WriteCloser, tty bool, resize <-chan remotecommand.TerminalSize, timeout time.Duration) error { container, err := checkContainerStatus(r.client, containerID) if err != nil { return err } return r.execHandler.ExecInContainer(r.client, container, cmd, in, out, errw, tty, resize, timeout) }
Execから execHandlerのExecInContainerを呼んでいます。
func (*NativeExecHandler) ExecInContainer(client libdocker.Interface, container *dockertypes.ContainerJSON, cmd []string, stdin io.Reader, stdout, stderr io.WriteCloser, tty bool, resize <-chan remotecommand.TerminalSize, timeout time.Duration) error { done := make(chan struct{}) defer close(done) createOpts := dockertypes.ExecConfig{ Cmd: cmd, AttachStdin: stdin != nil, AttachStdout: stdout != nil, AttachStderr: stderr != nil, Tty: tty, } execObj, err := client.CreateExec(container.ID, createOpts)
exec handler のExecInContainerで docker execしてるコードにたどり着きました。長かった。。ほとんど自己満足ですが、前述のフォーラムの情報は間違ってなさそうだと思えるところまで調べることが出来ました。
CreateExecの中身も気になりましたがさすがに気力なくなりました。。 getExecからServceExecのところは完全に流れを見失ってしまったのでいずれ補完したいです。
Goは書いてると大量のコードを書くことになってつらいですが、読むのは楽ですね。途中詰まってしまいましたが、API通信を挟むところでコードだけでは追いきれなくなってしまうのは言語問わずそうなのでGoの問題ではないと思います。
参考
api-server, kubeletについてはこちら
Kubernetes: 構成コンポーネント一覧 - Qiita
CRIってなんだっけ?というの調べるのにはここを参照しました
GKEでIstioのmetricsをStackdriverに連携する方法
すごくわかりにくいのですが、mixier配下のoperatorconfigディレクトリにあるyamlを適用すればとりあえず連携できました。
istio/stackdriver.yaml at master · istio/istio · GitHub
現在のreleaseバージョン(1.0.5)ではエラーが出るので注意が必要です。 このPRで修正されていました。 github.com
ちなみに、Istio on GKE(https://cloud.google.com/istio/docs/istio-on-gke/overview) を利用する場合は、何の設定もいらないようです。 いまのところIstio on GKEの制約が強すぎてやりたいことができないので、このままOSS版を利用する予定です。