Zero to JupyterHub with Kubernetes(Z2JH)でユーザデータをS3にバックアップ

背景

EKSでZero to JupyterHub with Kubernetes(Z2JH)で構築したJupyterHubを運用しています。
障害時の回復力を高めるため、AWS外のストレージ(Cloud Storage等)にバックアップの複製を作成することになり、Storage Transfer ServiceRcloneの利用が可能なS3にユーザデータ(ノートブック等)のバックアップを取得する方式を構築しました。

実行環境

  • EKS 1.25
  • Z2JH 3.2.0

IAM ServiceAccountの作成

ノートブックサーバにS3バケットへの書き込み権限を与えるサービスアカウント singleuser を作成します。

$ BUCKET_NAME=<bucket-name>
$ POLICY_FILE=$(mktemp -t s3.iam.policy.json)
$ cat<<EOF> ${POLICY_FILE}
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "s3:PutObject"
      ],
      "Resource": [
        "arn:aws:s3:::${BUCKET_NAME}/*"
      ]
    }
  ]
}
EOF

$ POLICY_ARN=$(
    aws iam create-policy \
      --policy-name AmazonS3BucketWriteOnlyAccess \
      --policy-document file://${POLICY_FILE} \
      --query 'Policy.Arn' \
      --output text
  )

$ CLUSTER_NAME=<cluster-name>
$ NAMESPACE=<namespace>
$ eksctl create iamserviceaccount \
    --cluster=${CLUSTER_NAME} \
    --namespace=${NAMESPACE} \
    --name=singleuser \
    --attach-policy-arn=${POLICY_ARN} \
    --approve

Z2JHの設定

チャートの values.yaml にバックアップ処理を追加します。

singleuser.serviceAccountNameでServiceAccount singleuser を指定し、 バケットへの書き込み権限を付与します。

singleuser:
  serviceAccountName: singleuser

singleuser.lifecycleHooks.preStopでサーバの停止時にS3にユーザデータをコピーする処理を追加します。

singleuser:
  lifecycleHooks:
    preStop:
      exec:
        command:
          - sh
          - -c
          - >
            tar -C /home/jovyan -czf .${JUPYTERHUB_USER}.tar.gz .;
            aws s3 cp .${JUPYTERHUB_USER}.tar.gz s3://<bucket-name>/jhub/singleuser/${JUPYTERHUB_USER}.tar.gz; # <bucket-name>にバケット名を指定
            rm .${JUPYTERHUB_USER}.tar.gz;

preStop の処理が中断されないようにhub.extraConfigc.KubeSpawner.delete_grace_periodを設定し、Pod削除までの時間を延長します。

hub:
  extraConfig:
    kubeSpawnerConfig:
      c.KubeSpawner.delete_grace_period = 30

バックアップの確認

これでノートブックサーバの停止を契機に、バケットにバックアップファイルが作成されるようになりました。

kinitを使わずにPython-GSSAPIでKeytabを指定してTGTを発行

kinit を使わずに済む方法を探していたところ、以下の実装で動きました。
gssapi.Credentials.storeclient_keytabにKeytabファイルのパスを指定します。

import gssapi

gssapi.Credentials(
    usage='initiate',
    name=gssapi.Name(
        '<SERVER_PRINCIPAL_NAME>',
        gssapi.NameType.kerberos_principal
    ),
    store={'client_keytab': '/path/to/keytab'}
)

<SERVER_PRINCIPAL_NAME>klist -k /path/to/keytab で取得できる値です。

カレンダーを和暦にするとDuolingoのデイリークエストができない話

Duolingoで韓国語を勉強しているんですが、なぜかiPhoneだとデイリークエストができない問題に悩んでました。

問題を回避するため、iPad版やブラウザ版をプレイすることでバッジを取得していたんですが、いい加減面倒に感じていたところ、Redditでこんな記事を発見。

Cant see daily quest
byu/ocean_008 induolingo
> somehow am in 1480
>> That'll be the problem right there. Daily quests won't be introduced until 2022, so you'll need to wait 542 years.
>>> ... i just change the calendar and everything work fine now.

カレンダーの問題っぽいと。確認してみると、なんと4042年を生きていたようです。
二千年後にデイリークエストが廃止されていても不思議はありません。

iOSのカレンダー設定をみると和暦表示になっていました。
これを西暦(グレゴリオ暦)に変えてあげると...

直りました!

3年ぐらい悩んでたんで、感動が大きいです。
てっきりA/Bテストだと思って諦めてたのにこんな簡単な話だったとは...
一向に修正されない様子をみると、発生条件が他にもあるのかもしれません(言語設定を英語にしているとか)。

DRFのDecimalFieldから末尾のゼロを除外

Django REST framework (DRF) において、DecimalFieldから末尾のゼロを除去する方法です。具体的には以下のように挙動を変更します。

# 現在
Decimal('0.001000000') >> "0.001000000"
# 期待する結果
Decimal('0.001000000') >> "0.001"

この機能は以下のPRで追加されましたが、まだリリースされていません。

github.com

https://github.com/Krolken/django-rest-framework/blob/92bcdd021a51d047836c6fdfdc4b981930717131/docs/api-guide/fields.md#decimalfield

・ normalize_output
Will normalize the decimal value when serialized. This will strip all trailing zeroes and change the value's precision to the minimum required precision to be able to represent the value without loosing data. Defaults to False.

また、この機能は DecimalField(normalize_output=True) で有効になるため、全体への適用には少々手間がかかります。
そこで、settingsの拡張を提案しましたが、現在DRFはメンテナンスフェーズ*1にあるため、提案は採用されませんでした。

github.com

そこで、#6514の変更を参考に、 quantized.normalize() を追加したカスタムフィールドを作成しました。
このカスタムフィールドを ModelSerializer.serializer_field_mappingmodels.DecimalFieldマッピングすることで、カスタムフィールドを標準適用します。
(COERCE_DECIMAL_TO_STRING関連の処理は使わないので削っています。)

import decimal

from django.db import models
from django.utils.formats import localize_input
from rest_framework import serializers


class NormalizedDecimalField(serializers.DecimalField):
    def to_representation(self, value):
        if value is None:
            return ''

        if not isinstance(value, decimal.Decimal):
            value = decimal.Decimal(str(value).strip())

        quantized = self.quantize(value)

        quantized = quantized.normalize()

        if self.localize:
            return localize_input(quantized)

        return '{:f}'.format(quantized)


class CustomModelSerializer(serializers.ModelSerializer):
    serializer_field_mapping = (
        serializers.ModelSerializer.serializer_field_mapping.copy()
    )
    serializer_field_mapping[models.DecimalField] = NormalizedDecimalField

あとはシリアライザの基底クラスを CustomModelSerializer に変更するだけで、各DecimalFieldから末尾のゼロを除外できます。

- class MySerializer(serializers.ModelSerializer):
+ class MySerializer(CustomModelSerializer):

モデルのプロパティがDecimal値を返す場合には、シリアライザクラスに NormalizedDecimalField を明示的に定義してください。

class MySerializer(CustomModelSerializer):
    my_property = NormalizedDecimalField(max_digits=..., decimal_places=...)

EKSのマネージド型ノードを並列更新

ノードを並列更新することで、マネージド型ノードグループの更新時間を短縮できるようなので試してみました。

docs.aws.amazon.com

It determines the maximum quantity of nodes to upgrade in parallel using the updateConfig property for the node group.

aws.amazon.com

Now, the amount of nodes that can be upgraded at a time is configurable, and clusters with applications that are more fault tolerant can benefit from reduced time to complete node group upgrades.

ノードグループの設定から「Node Group update configuration」で「Number」または「Percentage」に並列更新するノードの数または割合を設定します(ただし、並列更新できるのは最大で100ノード)。
今回はPercentageを100%とし、一度に全ノードを更新します。

docs.aws.amazon.com

eksctlのYAMLで管理する場合はこちらを参照してください。

managedNodeGroups:
...
    updateConfig:
      maxUnavailablePercentage: 100

設定変更後、試しにAMIを更新してみると、複数のノードが一度に更新される様子を確認できました。

❯ eksctl upgrade nodegroup \
   --name=<node-group-name> \
   --cluster=<cluster-name> \
   --region=<region>
...
❯ kubectl get nodes 
NAME                                                STATUS                     ROLES    AGE     VERSION
ip-192-168-14-112.ap-northeast-1.compute.internal   Ready                      <none>   3m34s   v1.24.15-eks-a5565ad
ip-192-168-18-140.ap-northeast-1.compute.internal   Ready,SchedulingDisabled   <none>   5d21h   v1.24.15-eks-a5565ad
ip-192-168-24-91.ap-northeast-1.compute.internal    Ready,SchedulingDisabled   <none>   5d21h   v1.24.15-eks-a5565ad
ip-192-168-27-53.ap-northeast-1.compute.internal    Ready                      <none>   3m31s   v1.24.15-eks-a5565ad
ip-192-168-32-111.ap-northeast-1.compute.internal   Ready                      <none>   3m34s   v1.24.15-eks-a5565ad
ip-192-168-44-222.ap-northeast-1.compute.internal   Ready                      <none>   3m29s   v1.24.15-eks-a5565ad
ip-192-168-45-236.ap-northeast-1.compute.internal   Ready,SchedulingDisabled   <none>   5d20h   v1.24.15-eks-a5565ad
ip-192-168-53-167.ap-northeast-1.compute.internal   Ready,SchedulingDisabled   <none>   5d21h   v1.24.15-eks-a5565ad
ip-192-168-64-207.ap-northeast-1.compute.internal   Ready                      <none>   3m34s   v1.24.15-eks-a5565ad
ip-192-168-83-46.ap-northeast-1.compute.internal    Ready                      <none>   3m27s   v1.24.15-eks-a5565ad
ip-192-168-88-250.ap-northeast-1.compute.internal   Ready,SchedulingDisabled   <none>   5d21h   v1.24.15-eks-a5565ad
ip-192-168-90-162.ap-northeast-1.compute.internal   Ready,SchedulingDisabled   <none>   5d20h   v1.24.15-eks-a5565ad

なお、スケールダウンは相変わらず1ノードずつ処理するので時間がかかるとのことです。

github.com

python-xmlsecのインストールに失敗(libxmlsec1 1.3)

環境

  • macOS 12.4
  • Homebrew 4.1.2

事象

python-xmlsecをインストールしようとすると以下のエラーが出ました。

Using version ^1.3.13 for xmlsec

Updating dependencies
Resolving dependencies... (0.4s)

Package operations: 1 install, 0 updates, 0 removals

  • Installing xmlsec (1.3.13): Failed

  ChefBuildError

  Backend subprocess exited when trying to invoke build_wheel
  
  running bdist_wheel
  running build
  running build_py
  creating build
  ...
  creating build/temp.macosx-12.4-x86_64-cpython-38/private/var/folders/k3/k7vd_gqj4zq908vsfsc284x40000gn/T/tmpg5t1_yzf/xmlsec-1.3.1
3/src
  clang -Wno-unused-result -Wsign-compare -Wunreachable-code -DNDEBUG -g -fwrapv -O3 -Wall -I/Library/Developer/CommandLineTools/SDK
s/MacOSX.sdk/usr/include -DOPENSSL_NO_SSL3 -I/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include -D__XMLSEC_FUNCTION__=_
_func__ -DXMLSEC_NO_FTP=1 -DXMLSEC_NO_MD5=1 -DXMLSEC_NO_GOST=1 -DXMLSEC_NO_GOST2012=1 -DXMLSEC_NO_CRYPTO_DYNAMIC_LOADING=1 -DXMLSEC_
CRYPTO_OPENSSL=1 -DMODULE_NAME=xmlsec -DMODULE_VERSION=1.3.13 -I/usr/local/Cellar/libxmlsec1/1.3.1_1/include/xmlsec1 -I/usr/local/op
t/openssl@3/include -I/usr/local/opt/openssl@3/include/openssl -I/var/folders/k3/k7vd_gqj4zq908vsfsc284x40000gn/T/tmpz659f7xs/.venv/
lib/python3.8/site-packages/lxml/includes -I/var/folders/k3/k7vd_gqj4zq908vsfsc284x40000gn/T/tmpz659f7xs/.venv/lib/python3.8/site-pa
ckages/lxml -I/var/folders/k3/k7vd_gqj4zq908vsfsc284x40000gn/T/tmpz659f7xs/.venv/include -I/Users/hiroki_sawano/.pyenv/versions/3.8.
16/include/python3.8 -c /private/var/folders/k3/k7vd_gqj4zq908vsfsc284x40000gn/T/tmpg5t1_yzf/xmlsec-1.3.13/src/constants.c -o build/
temp.macosx-12.4-x86_64-cpython-38/private/var/folders/k3/k7vd_gqj4zq908vsfsc284x40000gn/T/tmpg5t1_yzf/xmlsec-1.3.13/src/constants.o
 -g -std=c99 -fPIC -fno-strict-aliasing -Wno-error=declaration-after-statement -Werror=implicit-function-declaration -Os
  /private/var/folders/k3/k7vd_gqj4zq908vsfsc284x40000gn/T/tmpg5t1_yzf/xmlsec-1.3.13/src/constants.c:319:5: error: use of undeclared
 identifier 'xmlSecSoap11Ns'
      PYXMLSEC_ADD_NS_CONSTANT(Soap11Ns, "SOAP11");
      ^
  /private/var/folders/k3/k7vd_gqj4zq908vsfsc284x40000gn/T/tmpg5t1_yzf/xmlsec-1.3.13/src/constants.c:304:46: note: expanded from mac
ro 'PYXMLSEC_ADD_NS_CONSTANT'
      tmp = PyUnicode_FromString((const char*)(JOIN(xmlSec, name))); \
                                               ^
  /private/var/folders/k3/k7vd_gqj4zq908vsfsc284x40000gn/T/tmpg5t1_yzf/xmlsec-1.3.13/src/common.h:19:19: note: expanded from macro '
JOIN'
  #define JOIN(X,Y) DO_JOIN1(X,Y)
                    ^
  /private/var/folders/k3/k7vd_gqj4zq908vsfsc284x40000gn/T/tmpg5t1_yzf/xmlsec-1.3.13/src/common.h:20:23: note: expanded from macro '
DO_JOIN1'
  #define DO_JOIN1(X,Y) DO_JOIN2(X,Y)
                        ^
  /private/var/folders/k3/k7vd_gqj4zq908vsfsc284x40000gn/T/tmpg5t1_yzf/xmlsec-1.3.13/src/common.h:21:23: note: expanded from macro '
DO_JOIN2'
  #define DO_JOIN2(X,Y) X##Y
                        ^
  <scratch space>:81:1: note: expanded from here
  xmlSecSoap11Ns
  ^
  /private/var/folders/k3/k7vd_gqj4zq908vsfsc284x40000gn/T/tmpg5t1_yzf/xmlsec-1.3.13/src/constants.c:320:5: error: use of undeclared
 identifier 'xmlSecSoap12Ns'; did you mean 'xmlSecXPath2Ns'?
      PYXMLSEC_ADD_NS_CONSTANT(Soap12Ns, "SOAP12");
      ^
  /private/var/folders/k3/k7vd_gqj4zq908vsfsc284x40000gn/T/tmpg5t1_yzf/xmlsec-1.3.13/src/constants.c:304:46: note: expanded from mac
ro 'PYXMLSEC_ADD_NS_CONSTANT'
      tmp = PyUnicode_FromString((const char*)(JOIN(xmlSec, name))); \
                                               ^
  /private/var/folders/k3/k7vd_gqj4zq908vsfsc284x40000gn/T/tmpg5t1_yzf/xmlsec-1.3.13/src/common.h:19:19: note: expanded from macro '
JOIN'
  #define JOIN(X,Y) DO_JOIN1(X,Y)
                    ^
  /private/var/folders/k3/k7vd_gqj4zq908vsfsc284x40000gn/T/tmpg5t1_yzf/xmlsec-1.3.13/src/common.h:20:23: note: expanded from macro '
DO_JOIN1'
  #define DO_JOIN1(X,Y) DO_JOIN2(X,Y)
                        ^
  /private/var/folders/k3/k7vd_gqj4zq908vsfsc284x40000gn/T/tmpg5t1_yzf/xmlsec-1.3.13/src/common.h:21:23: note: expanded from macro '
DO_JOIN2'
  #define DO_JOIN2(X,Y) X##Y
                        ^
  <scratch space>:83:1: note: expanded from here
  xmlSecSoap12Ns
  ^
  /usr/local/Cellar/libxmlsec1/1.3.1_1/include/xmlsec1/xmlsec/strings.h:34:33: note: 'xmlSecXPath2Ns' declared here
  XMLSEC_EXPORT_VAR const xmlChar xmlSecXPath2Ns[];
                                  ^
  2 errors generated.
  error: command '/usr/bin/clang' failed with exit code 1
  
...

原因

libxmlsec1の1.3に対応できていないことが原因とのことです。

github.com

対策

暫定対策ですがこちらのTapで1.2.37をインストールすることで対処できました。

brew uninstall libxmlsec1
brew install tvuotila/libxmlsec1/libxmlsec1@1.2.37

正式に1.3がサポートされるまではこれで乗り切ります。

github.com

Container InsightsでFluentdをFluent Bitに変更

はじめに

EKS 1.24へのアップグレードに伴い、コンテナのログをFluent BitでCloudWatch Logsに送るように変更しました。

Amazon EKS Kubernetes versions - Amazon EKS

If you already have Fluentd configured for Container Insights, then you must migrate Fluentd to Fluent Bit before updating your cluster. The Fluentd parsers are configured to only parse log messages in JSON format. Unlike dockerd, the containerd container runtime has log messages that aren't in JSON format. If you don't migrate to Fluent Bit, some of the configured Fluentd's parsers will generate a massive amount of errors inside the Fluentd container.

FluentdとFluent Bitの差異

こちらを参照します。

docs.aws.amazon.com

設定はFluent Bit optimized configurationFluentd-compatible configurationが提供されていますが、Fluentd-compatible configurationは containerd に対応していなさそう(#139)ですし、Fluent Bit optimized configurationを使用します。

主な違いは以下の通りです。

  • ログストリーム名
  • kube-proxyaws-node のログを格納するロググループ
  • メタデータ
    • 変更: docker.container_id -> kubernetes.docker_id
    • 削除: kubernetescontainer_image_idmaster_urlnamespace_idnamespace_labels

Fluentdの削除

まずは既存のリソースを削除します。
メトリクスを収集するCloudWatchエージェントについては、必要であれば除外するか、こちらの手順で再インストールしてください。

docs.aws.amazon.com

curl https://raw.githubusercontent.com/aws-samples/amazon-cloudwatch-container-insights/latest/k8s-deployment-manifest-templates/deployment-mode/daemonset/container-insights-monitoring/quickstart/cwagent-fluentd-quickstart.yaml | sed "s/{{cluster_name}}/Cluster_Name/;s/{{region_name}}/Region/" | kubectl delete -f -
namespace "amazon-cloudwatch" deleted
serviceaccount "cloudwatch-agent" deleted
clusterrole.rbac.authorization.k8s.io "cloudwatch-agent-role" deleted
clusterrolebinding.rbac.authorization.k8s.io "cloudwatch-agent-role-binding" deleted
configmap "cwagentconfig" deleted
daemonset.apps "cloudwatch-agent" deleted
configmap "cluster-info" deleted
serviceaccount "fluentd" deleted
clusterrole.rbac.authorization.k8s.io "fluentd-role" deleted
clusterrolebinding.rbac.authorization.k8s.io "fluentd-role-binding" deleted
configmap "fluentd-config" deleted
daemonset.apps "fluentd-cloudwatch" deleted

Fluent Bitのインストール

こちらの手順を行います。

docs.aws.amazon.com

YAMLリポジトリで管理・カスタマイズしたいので、手元に取得します。 各リソースの説明についてはリンク先を参照してください。

# cloudwatch-namespace.yaml
wget https://raw.githubusercontent.com/aws-samples/amazon-cloudwatch-container-insights/latest/k8s-deployment-manifest-templates/deployment-mode/daemonset/container-insights-monitoring/cloudwatch-namespace.yaml

# fluent-bit-cluster-info.yaml
ClusterName=cluster-name
RegionName=cluster-region
FluentBitHttpPort='2020'
FluentBitReadFromHead='Off'
[[ ${FluentBitReadFromHead} = 'On' ]] && FluentBitReadFromTail='Off'|| FluentBitReadFromTail='On'
[[ -z ${FluentBitHttpPort} ]] && FluentBitHttpServer='Off' || FluentBitHttpServer='On'
kubectl create configmap fluent-bit-cluster-info \
--from-literal=cluster.name=${ClusterName} \
--from-literal=http.server=${FluentBitHttpServer} \
--from-literal=http.port=${FluentBitHttpPort} \
--from-literal=read.head=${FluentBitReadFromHead} \
--from-literal=read.tail=${FluentBitReadFromTail} \
--from-literal=logs.region=${RegionName} -n amazon-cloudwatch -o yaml --dry-run > fluent-bit-cluster-info.yaml

# fluent-bit.yaml
wget https://raw.githubusercontent.com/aws-samples/amazon-cloudwatch-container-insights/latest/k8s-deployment-manifest-templates/deployment-mode/daemonset/container-insights-monitoring/fluent-bit/fluent-bit.yaml

マニフェストを適用します。

namespace/amazon-cloudwatch created
serviceaccount/fluent-bit created
clusterrole.rbac.authorization.k8s.io/fluent-bit-role created
clusterrolebinding.rbac.authorization.k8s.io/fluent-bit-role-binding created
configmap/fluent-bit-cluster-info created
configmap/fluent-bit-config created
daemonset.apps/fluent-bit created

CloudWatch Logsでログストリームの作成を確認します。 先頭にノード名がついてますね。

ログのフォーマットは以下です。

{
    "log": "...",
    "stream": "...",
    "time": "...",
    "kubernetes": {
        "pod_name": "...",
        "namespace_name": "...",
        "pod_id": "...",
        "host": "....",
        "container_name": "...",
        "docker_id": "...",
        "container_hash": "...",
        "container_image": "..."
    }
}

ログストリーム名の変更

アプリケーション( /aws/containerinsights/<Cluster_Name>/application )のログストリームについては、従来の単位(Pod、Namespace、コンテナ、コンテナID)で構成したいので、log_stream_templateで名前を変更します(区切り文字には制限があります)。

apiVersion: v1
kind: ConfigMap
metadata:
  name: fluent-bit-config
  namespace: amazon-cloudwatch
  labels:
    k8s-app: fluent-bit
data:
...
  application-log.conf: |
...
    [OUTPUT]
        Name                cloudwatch_logs
        Match               application.*
        region              ${AWS_REGION}
        log_group_name      /aws/containerinsights/${CLUSTER_NAME}/application
        log_stream_prefix   ${HOST_NAME}-
+       log_stream_template $kubernetes['pod_name'].$kubernetes['namespace_name'].$kubernetes['container_name'].$kubernetes['docker_id']
        auto_create_group   true
        extra_user_agent    container-insights