kubernetes

Kubernetesクラスタ内に大量のServiceリソースがあるとNginxコンテナが起動しなくなる

kubernetes

こんにちは、技術部プラットフォームグループのそめやポチです。最近はpng形式の画像をjpeg形式に変換する仕事をしています。

この記事では、私が社内のKubernetesクラスタのお世話をしているときに出会ったトラブルとその解決方法、またトラブルが起こった原因について説明します。トラブルの原因についてはKubernetes, Nginx, Linuxの3つのプロダクトについて、コードリーディングをしながら解説します。

直面した事象

急にKubernetesクラスタ内のNginxコンテナが起動しなくなりました。

GMOペパボが提供しているサービスの一つであるminneでは、検証用のKubernetesクラスタを利用しています。本番環境で使用しているKubernetesクラスタを模倣した、開発・検証のための環境です。そのクラスタ内でトラブルが起きていました。

トラブルの説明をするために、まずこの検証用のKubernetesクラスタの構成を簡単に紹介します。

解説図。ユーザーがインターネットを介して本番環境にアクセスでき、開発者はインターネットを介して本番環境と検証環境にアクセスできることを示している。

開発者はそれぞれ、自分が開発中のアプリケーションを検証用のKubernetesクラスタにPodとしてデプロイします。それぞれのアプリケーションPodにはインターネットを経由してアクセスできるため、開発者は自身が本番環境に加えようとしている変更をオンラインで検証できます。

解説図。複数の開発者が検証用のKubernetesクラスタにそれぞれServiceとDeploymentリソースをデプロイできることを示している。

minneのアプリケーションPodにはメインのアプリケーションコンテナの他に、いくつかのサイドカーコンテナが配置されています。その中の一つに、プロキシのためのNginxコンテナがあります。

解説図。一つのアプリケーションPodの中に、Ruby on Rails, Nginx, Fluentdなど複数のコンテナが配置されることを示している。

あるとき、新規にデプロイされたPodが起動しなくなりました。調査してみると、Nginxコンテナが起動していない状態でした。

コンテナのログは次の文で終わっていました。

/docker-entrypoint.d/20-envsubst-on-templates.sh: 26: envsubst: Argument list too long

エラーログによると、どうやら引数のリストが長すぎるそうです。何度かデプロイを試すも、どのPodもNginxコンテナが同じログを出力して起動しません。いったい何が起きたのでしょうか。

解決方法

原因の解説に入る前に、まずは私が採用した解決策をご紹介します。

アプリケーションPodを管理するDeploymentリソースの定義に、spec.template.spec.enableServiceLinks: falseを追加するだけです。

apiVersion: apps/v1
kind: Deployment
metadata:
  labels:
    app: minne-app
  name: minne-app
spec:
  replicas: 1
  selector:
    matchLabels:
      app: minne-app
  strategy: {}
  template:
    metadata:
      labels:
        app: minne-app
    spec:
      enableServiceLinks: false # この行を追加
      containers:
      - args:
        - nginx
        - -g
        - daemon off;
        - -c
        - /nginx/nginx.conf
# 以下省略

enableServiceLinksのbool値は、次のように機能します。

ref: https://kubernetes.io/ja/docs/tutorials/services/connect-applications-service/

KubernetesはServiceを探す2つの主要なモードとして、環境変数とDNSをサポートしています。 前者はすぐに動かせるのに対し、後者はCoreDNSクラスターアドオンが必要です。 備考:もしServiceの環境変数が望ましくないなら(想定しているプログラムの環境変数と競合する可能性がある、処理する変数が多すぎる、DNSだけ使いたい、など)、pod specでenableServiceLinksのフラグをfalseにすることで、このモードを無効化できます。

つまり、Serviceリソースにまつわる環境変数の有無を切り替えるためのフラグです。

ちなみに、enableServiceLinks: falseを設定すると、副作用としてServiceについての環境変数を利用した通信ができなくなります。それらの環境変数そのものがコンテナからなくなるからです。お気をつけください。このパラメータを設定する以外の事象の解決方法としては、Namespaceを適切に分割する手法が考えられます。今回は、関連する環境変数を利用していなかったことから、1行のコードの追加で済む手法を採用しました。

さて、この1行の追加によってNginxコンテナは問題なく起動するようになりました。なぜでしょうか?

原因

Nginxコンテナが正常に起動しなくなった要因は、開発者の増加による影響でした。

Kubernetesはデフォルトの挙動として、同じNamespace内にあるServiceリソースの数だけ、コンテナに環境変数を設定します。また、Nginxコンテナは起動時に、テンプレートファイル内に記載されている環境変数の置換を実行します。envsubst: Argument list too longは、置換する環境変数の量が閾値より多いときに出るエラーです。

開発者が増えた結果、それぞれのPodへルーティングをするためのServiceリソースも増え、一つのコンテナに設定される環境変数も増加したため、Nginxコンテナが起動する際にenvsubst: Argument list too longのエラーが起きたのでした。

それでは、それぞれのソフトウェアの、今回の事象につながった部分のコードを見ていきましょう。

Kubernetes

kubernetes/kubernetesリポジトリで管理されているコードを探索します。

ref: https://github.com/kubernetes/kubernetes/blob/5d94b2a8e8db3a8e10db792ee4d29df0640183f1/pkg/kubelet/kubelet_pods.go#L558-L565

		if service.Namespace == metav1.NamespaceDefault && masterServices.Has(serviceName) {
			if _, exists := serviceMap[serviceName]; !exists {
				serviceMap[serviceName] = service
			}
		} else if service.Namespace == ns && enableServiceLinks {
			serviceMap[serviceName] = service
		}

先ほど「Kubernetesはデフォルトの挙動として〜」と記述しましたが、正確にはKubeletの機能です。

Kubeletは、Kubernetesクラスタ内のPodのライフサイクルやヘルスチェックを管理するコンポーネントです。提示したコードの後半部分から、enableServiceLinksがtrueのときは同じNamespaceにあるServiceがserviceMapというハッシュに追加されることが分かります。前半部分からは、どうやらdefaultNamespaceにあるmasterServicesに属するServiceについてはenableServiceLinksの値に関係なく追加されるらしいことが分かります。このserviceMapがコンテナへの環境変数設定に使用されます。

Nginx

nginxinc/docker-nginxリポジトリで管理されているコードを探索します。

ref: https://github.com/nginxinc/docker-nginx/blob/f3d86e99ba2db5d9918ede7b094fcad7b9128cd8/entrypoint/20-envsubst-on-templates.sh#L7-L28

auto_envsubst() {
  local template_dir="${NGINX_ENVSUBST_TEMPLATE_DIR:-/etc/nginx/templates}"
  local suffix="${NGINX_ENVSUBST_TEMPLATE_SUFFIX:-.template}"
  local output_dir="${NGINX_ENVSUBST_OUTPUT_DIR:-/etc/nginx/conf.d}"

  local template defined_envs relative_path output_path subdir
  defined_envs=$(printf '${%s} ' $(env | cut -d= -f1))
  [ -d "$template_dir" ] || return 0
  if [ ! -w "$output_dir" ]; then
    echo >&3 "$ME: ERROR: $template_dir exists, but $output_dir is not writable"
    return 0
  fi
  find "$template_dir" -follow -type f -name "*$suffix" -print | while read -r template; do
    relative_path="${template#$template_dir/}"
    output_path="$output_dir/${relative_path%$suffix}"
    subdir=$(dirname "$relative_path")
    # create a subdirectory where the template file exists
    mkdir -p "$output_dir/$subdir"
    echo >&3 "$ME: Running envsubst on $template to $output_path"
    envsubst "$defined_envs" < "$template" > "$output_path"
  done
}

この関数はentrypointで実行されます。終盤の行に次の記述があります。

envsubst "$defined_envs" < "$template" > "$output_path"

テンプレートファイル内の環境変数を実際の値に置換して出力しています。エラーログが示す行からも、この部分でエラーになっていることが分かります。つまり、コンテナ起動時のenvsubst(環境変数置換処理)でエラーが起きたのです。

Linux

せっかくなのでArgument list too longのエラーについてもLinuxカーネルのコードから探していきましょう。

Argument list too longの出所を探す

torvalds/linuxリポジトリで管理されているコードを探索します。ちなみにこのリポジトリはLinuxカーネルのミラーリポジトリです。

https://github.com/torvalds/linux/blob/master/include/uapi/asm-generic/errno-base.h

/* SPDX-License-Identifier: GPL-2.0 WITH Linux-syscall-note */
#ifndef _ASM_GENERIC_ERRNO_BASE_H
#define _ASM_GENERIC_ERRNO_BASE_H

#define	EPERM		 1	/* Operation not permitted */
#define	ENOENT		 2	/* No such file or directory */
#define	ESRCH		 3	/* No such process */
#define	EINTR		 4	/* Interrupted system call */
#define	EIO		 5	/* I/O error */
#define	ENXIO		 6	/* No such device or address */
#define	E2BIG		 7	/* Argument list too long */
#define	ENOEXEC		 8	/* Exec format error */
...

どうやらE2BIGというエラーコードがArgument list too longに対応しているようです。

このエラーコードはどこで返されるのでしょうか。

https://github.com/torvalds/linux/blob/708283abf896dd4853e673cc8cba70acaf9bf4ea/fs/exec.c#L434-L458

static int count(struct user_arg_ptr argv, int max)
{
	int i = 0;

	if (argv.ptr.native != NULL) {
		for (;;) {
			const char __user *p = get_user_arg_ptr(argv, i);

			if (!p)
				break;

			if (IS_ERR(p))
				return -EFAULT;

			if (i >= max)
				return -E2BIG;
			++i;

			if (fatal_signal_pending(current))
				return -ERESTARTNOHAND;
			cond_resched();
		}
	}
	return i;
}

調査の結果、E2BIGのエラーコードを返す可能性がある関数が複数ありました。このcount関数は与えられた引数の数がmaxを超えた場合に-E2BIGというエラーコードを返します。

実際に再現してみる

ここで、実際にenvsubstコマンドからArgument list too long.エラーが返る現象を簡単に再現してみましょう。次のようなシェルスクリプトを用意します。

$ cat test.sh
#!/bin/bash
envsubst "$(seq 2 100000)"

これをstraceでトレースしてみます。straceは、指定したプロセスが発行するシステムコールをトレースできるツールです。-fオプションを使用すると、子プロセスのシステムコールもトレースできます。

$ strace -f ./test.sh
execve("./test.sh", ["./test.sh"], 0x7ffcc0fe61f8 /* 23 vars */) = 0
...
[pid 54917] execve("/usr/bin/envsubst", ["envsubst", "2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n13\n14\n1"...], 0x55e7d655ca10 /* 23 vars */) = -1 E2BIG (Argument list too long)
...

envsubstexecveによって呼び出され、E2BIGのエラーコードが返された様子が観察できました。

execveからArgument list too long.までの流れを把握する

再度Linuxカーネルのソースコード内のfs/exec.cを探索します。

https://github.com/torvalds/linux/blob/3669558bdf354cd352be955ef2764cde6a9bf5ec/fs/exec.c#L1888

do_execveat_common関数からcount関数が返されているのを発見しました。

static int do_execveat_common(int fd, struct filename *filename,
			      struct user_arg_ptr argv,
			      struct user_arg_ptr envp,
			      int flags)
{
  ...
	retval = count(argv, MAX_ARG_STRINGS);
  ...

https://github.com/torvalds/linux/blob/3669558bdf354cd352be955ef2764cde6a9bf5ec/fs/exec.c#L2032C1-L2039C2

do_execveat_common関数はdo_execve関数内で呼び出されます。

static int do_execve(struct filename *filename,
	const char __user *const __user *__argv,
	const char __user *const __user *__envp)
{
	struct user_arg_ptr argv = { .ptr.native = __argv };
	struct user_arg_ptr envp = { .ptr.native = __envp };
	return do_execveat_common(AT_FDCWD, filename, argv, envp, 0);
}

https://github.com/torvalds/linux/blob/3669558bdf354cd352be955ef2764cde6a9bf5ec/fs/exec.c#L2109

do_execve関数は、execveという名前のシステムコールとして定義されています。

SYSCALL_DEFINE3(execve,
		const char __user *, filename,
		const char __user *const __user *, argv,
		const char __user *const __user *, envp)
{
	return do_execve(getname(filename), argv, envp);
}

これでexecveという名前のシステムコールからArgument list too long.のエラーが出るまでの流れを掴むことができました。

おわり

以上、Kubernetesクラスタの運用中に出会ったトラブルからLinuxカーネルのコードまで読んでみました。なかなか疲れますがやってみると楽しいですね。みなさんもトラブルシュートにかこつけてOSSのコードリーディングをしてみてはいかがでしょうか。

余談

この記事の初稿ではLinuxカーネルの項目は該当のエラーコードがある場所だけを示して終わっていました。しかし記事のレビューを社内のエンジニアに求めたところ、社内の低レイヤーな人々がわらわらと集まってきたのでシステムコールについて更に詳しく調べる動機が生まれました。

slackでの会話

slackでの会話の続き

ペパボのいるだけで成長できる環境を体感した事例でした。