1クール続けるブログ

とりあえず1クール続けるソフトウェアエンジニアの備忘録

EKSにおけるPodレベルでのIAMロール割当

記事一覧はこちら

NodeレベルでのIAMロール割当しちゃってる…

という私用クラスタを使ってしまっている僕がPodレベルでの権限割当について学んだので、その備忘録です。ALB Controllerの利用をするときに、Pod単位での権限割当を行う導入フローになっているのを見て、そろそろちゃんと勉強せねばと思った次第です。
学ぶときに利用したリソースは下記です。

Kubernetesの中での権限制御

これはRole Based Access Control(RBAC)を利用することになると思います。
Podに割り当てるServiceAccount リソースと「どのリソースに対してどの操作を許すか」という宣言を行ったRoleもしくはClusterRoleを用意し、RoleBindingClusterRoleBindingで紐付けることで制御します。ArgoCDなどのCDツールでは様々なk8sリソースの参照/変更権限が必要になるため、デフォルトのServiceAccountでは権限が足らず、必要な権限を付与したServiceAccountを用意することになりました。

では、podがk8sリソースではなくAWSのリソースを操作するときには、どのような仕組みで制御されているか(例えばS3からデータを取得するなど)。

Kubernetesからawsリソースを操作する時の権限制御

ノード単位での権限制御

EKSローンチ当初はノード単位でのIAMロール割当しか出来ませんでした。
なので、least-privilegedを守るためには、権限の大きいノードグループを別で作成し、podのnodeSelectorでスケジューリングされるノードグループを別々にする必要がありました。
podはスケジューリングされているノードのインスタンスプロファイルを利用してAWSリソースにアクセスします。EC2メタデータサービスを利用して、アクセスキーとシークレットアクセスキーを取得できる例のやつです(instance-metadata-security-credentials)。

pod単位での権限制御

EKSのv1.13から利用できるようになった、podごとにIAMロールを割り当てる方法です。
といっても公式で出たのがそのタイミングというだけで、記事にもありますが、3rd Partyでは kube2iam などがEKSローンチ当初から利用されていたように感じます(kopsで利用されてて実績があったんですかね)。
それらの3rd Partyは「EC2 メタデータ API へのリクエストをインターセプトし、STS API を呼び出して一時クレデンシャルを取得する」という手法を取っていたようです。EC2メタデータのエンドポイントである 169.254.169.254 へのアクセスをノードのiptablesでポート8181で待ち構えているdaemonsetにルーティングさせて、podのiam.amazonaws.com/roleアノテーションを見に行って指定したRoleにAssumeRoleさせるようです。

AWS公式の方法は、IAM Roles for Service Accounts (IRSA) という方法で上記の方法とはアプローチが違うようです。
前述の方法だとiptableをいじることにコストとか、KubernetesのRBACがIAMロールと結びつかないことによって直感的でないことがもしかしていまいちだったのかもしれません(多分もっとちゃんとした理由がある)。

IAM Roles for Service Accounts

OIDC (OpenID Connect)

AWSがpodに権限を付与するために用いた手段では、IAM OIDCプロバイダを利用することが前提になっています。恥ずかしながら、認証周りに疎いのでOIDC知りませんでした。

OpenID Connect 1.0 is a simple identity layer on top of the OAuth 2.0 protocol. - OpenID Connect | OpenID

OAuth2.0 の上にあるシンプルなidentityレイヤなんですね(分からん)。identityというのはset of attributes related to an entity を指すそうで、ここでのentityは人や機械、サービスのことのようです。identityレイヤがもたらすのはIDトークンとユーザ情報のAPIの仕様ということなんだそうな(参考)。OAuth 2.0はUser IDの取得方法やProfile APIの仕様については定めてなかったのか!

そして、AWSではIAMでウェブIDフェデレーションをサポートしています。IDトークンを AWS アカウントのリソースを使用するためのアクセス許可を持つ IAM ロールにマッピングすることが可能です(参考)。APIでいうと、AssumeRoleWithWebIdentityが呼ばれるそうです。
OIDC互換のプロバイダといえば、FacebookGoogleなどがそれにあたりますが、そのプロバイダのIAMエンティティが「IAM OIDC identity provider」です。これにIdP とその設定について埋め込むことで、AWS アカウントと IdP の間の「信頼」が確立されます。

Amazon EKS now hosts a public OIDC discovery endpoint per cluster containing the signing keys for the ProjectedServiceAccountToken JSON web tokens so external systems, like IAM, can validate and accept the OIDC tokens issued by Kubernetes.
- Technical overview - Amazon EKS

このPodに権限を付与するという文脈で用いられるIdPは、EKSクラスタのことになります。
なので、EKSが公開するOIDCディスカバリエンドポイントを設定した「IAM OIDC identity provider」作成する作業がまず必要になります。そうすることで、OIDC プロバイダーを使った認証が有効化され、IAM ロールの引き受けを可能とする JSON Web Token (JWT) の取得ができるようになります。

f:id:jrywm121:20210129220241p:plain

AssumeRoleWebIdentityではJWTトークンを渡すことになります(参考)。IDトークンに含まれるフィールドissでプロバイダのエンドポイントが分かるので、それがIAM OIDCプロバイダに存在するのかを確認するはず。もし存在すれば、STSからEKSにTokenの検証リクエストを投げます。検証し問題なければ、AssumeRoleの際にリクエストしたRoleを被ることができるユーザかどうかの確認が走るはず(この辺はドキュメントベースでないのでかなり怪しい)。エンドユーザの識別子はIDトークンのsubフィールドに含まれるそうな。
AssumeRole先のIAMロールには下記のようなポリシーをアタッチすることでk8sのサービスアカウントがどのIAMロールをAssumeできるかを制限します。

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::<AWS_ACCOUNT_ID>:oidc-provider/<OIDC_PROVIDER>"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          "<OIDC_PROVIDER>:sub": "system:serviceaccount:<SERVICE_ACCOUNT_NAMESPACE>:<SERVICE_ACCOUNT_NAME>"
        }
      }
    }
  ]
}

最終的には、STSからAssumeしたRoleのACCESS_KEYSECRET_ACCESS_KEYをpodに返却し、podはその認証情報を利用してAWSリソースの操作を行います。

Service Account Token Volume Projection

では、そもそもEKSからpodにOIDC互換のIDトークンをどう渡しているのでしょうか?
Kubernetes v1.12からサポートされたProjectedVolumeをつかって受け渡しているそうです。ServiceAccountのアノテーションに、eks.amazonaws.com/role-arnで引き受けるIAMロールの名称を宣言するわけですが、(おそらく)そのアノテーションが付与されていると、Mutating Admission Controllerが、環境変数projectedVolumepodSpecに付与してくれます。なので、運用者がk8s環境で行うことはサービスアカウントをアノテートするだけですね。

以下、参考ドキュメントから引用

apiVersion: v1
kind: ServiceAccount
metadata:
  annotations:
    eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/eksctl-irptest-addon-iamsa-default-my-serviceaccount-Role1-UCGG6NDYZ3UE
  name: my-serviceaccount
secrets:
  - name: my-serviceaccount-token-m5msn
---
apiVersion: apps/v1
kind: Pod
metadata:
  name: myapp
spec:
  serviceAccountName: my-serviceaccount
  containers:
  - name: myapp
    image: myapp:1.2
    env:
    - name: AWS_ROLE_ARN
      value: arn:aws:iam::123456789012:role/eksctl-irptest-addon-iamsa-default-my-serviceaccount-Role1-UCGG6NDYZ3UE
    - name: AWS_WEB_IDENTITY_TOKEN_FILE
      value: /var/run/secrets/eks.amazonaws.com/serviceaccount/token
    volumeMounts:
    - mountPath: /var/run/secrets/eks.amazonaws.com/serviceaccount
      name: aws-iam-token
      readOnly: true
  volumes:
  - name: aws-iam-token
    projected:
      defaultMode: 420
      sources:
      - serviceAccountToken:
          audience: sts.amazonaws.com
          expirationSeconds: 86400
          path: token

ミドルウェアもどんどんpod単位の権限制御に

自分は気づけてなかったのですが、external-dnsaws-load-balancer-controllerなどのリソースは既にpod単位の権限制御前提で導入手順とか記載されていますね。
セキュリティ的にも重要なのでしっかり理解しておきたいです。以上!

mtail事始め

記事一覧はこちら

mtail使っているけどちゃんと理解できていないかも…

という僕がmtailを改めて勉強したので書き留めておこうという記事です。
mtail自体は、apacheのログフォーマットをパースしてPrometheusで収集させるのに利用しているのですが、構築などは自分がプロジェクトにjoinしたときには既にあったため、なんとなくでも使えてはいました。
ですが不具合調査をすることになり、この機会に一から学んでみることにしました。

mtailについて

概要

github.com

mtailはアプリケーションのログからメトリクスを抽出してエクスポートしてくれるツール
メトリクスはPrometheusのようなPull型アーキテクチャを持つツールがスクレイプできるように、JSON形式もしくはPrometheus形式でHTTPサーバで公開できます。また、定期的にcollectdなどのツールに送ることも可能のようです。

入手方法

GitHubのリリースページから直接入手できます。2020年10月のリリースからは、armで動くバイナリも配布されています。
コンテナイメージですが、DockerHub上で公式イメージは手に入らないようです。GCRの公開リポジトリとかにありそうだなあと思ったんですがありませんでした。

実行方法

--progsでログをどうパースして、どのようにエクスポートするかを宣言したファイルのパスを指定する。一方で、--logsでは入力となるログファイルのパスを指定する。必須なのはこの2つ(のはず)。
--logtostderrはログをファイルではなく標準エラー出力に履く設定。基本的にmtailはサイドカーコンテナとして動かすケースがほとんどだと思うので、ログ出力は標準出力に履いてCloudWatch Logsなりでキャプチャ/転送してあげるのが易しいはず。
--progsではディレクトリを渡すこともできる。ファイルの名称は、末尾に.mtailがついていないと読み込んでくれない点について注意が必要です。

mtail --progs ./progs.mtail --logs /var/log/apache/access.log --logtostderr

メトリクスの公開先

デフォルトでポートは3903で公開されるので、それに合わせてPrometheusなりでサービスディスカバリしてあげるように設定して上げると良い。--port xxxxのようにコマンドの引数でポートはオーバーライド可能です。

mtailの設定DSL

exportする変数の定義がまず必要です。ここはPrometheusなどの収集ツール側から見れるメトリクス名になります。
counter, gauge, histogram が指定できます。

counter lines_total by path, method  # by を使うことでdimentionを追加できる
gauge queue_length

pattern(正規表現)を満たしたエントリに対してactionを行います
またpatternでキャプチャした変数はactionブロックで利用することが可能です

pattern {
   action
}

# 例: 1行ごとにカウントするシンプルなもの(行末ごとにactionが行われる)
/$/ {
   lines_total++
}

# + でpatternの結合、#より右はコメントなので無視する
/(?P<method>[A-Z]+) / + # %m - The request method.
/(?P<code>\d{3}) / + # %>s - Status code.
/(?P<sent_bytes>\d+) / + # %O - Bytes sent, including headers.
/$/ {
   http_response_size_bytes_total[$method][$code][$protocol] += $sent_bytes
}

mtailのテスト

mtailには構文のテスト方法と実際に得られる値までを計算して確認する方法があります。

  • 構文チェック
mtail --compile_only --progs ./progs
  • 実際にファイルを食わせて出力される値を確認
mtail --one_shot --progs ./progs.mtail --logs testdata/foo.log 
# jsonの出力が吐かれます

他にも色々機能がある…

中間値を保持する機能やHistogramをエクスポートする機能まではまだ追いきれていない状態ですが、なんとなく大枠はつかめたかと思います。
examplesが充実しているのはありがたいですね

mavenキャッシュをDockerコンテナに閉じ込めてビルド高速化を図る

最近、全く更新出来ていなかった。
もう少し一つ一つの記事を軽い気持ちで書いてみてもいいのかもしれない(個人ブログだし)。

記事一覧はこちら

概要

JenkinsやCodeBuildなど、Dockerデーモンを叩ける環境でMavenを使ったJavaアプリケーションのビルドの高速化をする方法です
利用するJava, Mavenのバージョンを統一しつつ、Maven~/.m2/repository/* をキャッシュさせたいという需要がありました
上で名前を上げましたが、CodebuildとかGithub Actionsとかではビルドするときのイメージを指定する形になっているし、S3キャッシュなどのマネージドのキャッシュ機構があるので、前述の需要は簡単に満たせたりします
なので、どちらかというと、VMから直で泥臭くコンテナビルドするときに使える方法になります

方法

この手法でビルドを動かしてみている環境はJenkinsなのですが、Dockerの --cache-from を一緒に利用することで、ビルド時間を半分以下にすることができました。

MY_PRIVATE_REPOSITORY=aws_account_id.dkr.ecr.region.amazonaws.com:sample-app/maven

# 最初の1回と定期的に下記を実施
# ベースイメージのmavenコンテナにセキュリティアップデートとかを入れるため
docker pull maven:3.6-amazoncorretto-8
docker tag maven:3.6-amazoncorretto-8 ${MY_PRIVATE_REPOSITORY}:sample-app 
docker push ${MY_PRIVATE_REPOSITORY}:sample-app

# ビルド
CONTAINER_ID=$(docker run -d --rm --volume $(pwd):/workspace --workdir '/workspace' ${MY_PRIVATE_REPOSITORY}:sample-app sleep 1800s)  # バックエンドで起動
docker exec ${CONTAINER_ID} mvn -U package -Pdev 
docker commit ${CONTAINER_ID} ${MY_PRIVATE_REPOSITORY}:sample-app # ダウンロードしたライブラリをコンテナに閉じ込める
docker stop ${CONTAINER_ID}
docker push ${MY_PRIVATE_REPOSITORY}:sample-app

linux/amd64でlinux/arm64アーキテクチャのイメージをPullする

AWSでm6g/r6g/c6gとarmのCPUが乗った新世代のインスタンスタイプが続々とGAになりましたね。
自分の運用している環境でコストとパフォーマンスにどのくらいに改善が見られるのか楽しみですね。EKSのOptimized AMIのARM版はまだGAされていませんが、もしされることになれば、是非そちらにも導入してみたいところ!

皆さんは、node-exporterなど、DockerHubから直接Pullしても動かせるものはどうしているのでしょうか。
Amazon ECRなどはSLAが存在しているし、障害があったときにサポートへの問い合わせから状況把握が出来たりするので、マネージドなプライベートコンテナレジストリにPushしておくのが良いのではないかなと思っております。

今後出てくるユースケースとして、「linux/amd64アーキテクチャインスタンス(例えばJenkinsとか)からlinux/arm64のコンテナイメージをDockerHubからpullして、ECRなりにpushする」というのが出てくるのではと思います。

pushはともかく、docker pull imageで取得してくるイメージはdockerコマンドを発行するアーキテクチャに依存してしまうので、linux/amd64でarmイメージをpullするにはひと手間加える必要がありますね。

experimentalの --platform オプションを使う

Docker for Macの場合には下記のようにPrefernces > Docker Engineexperimentaltrueにしておく。

f:id:jrywm121:20200613150207p:plain

Linuxの場合は、--config-fileの指定が無ければ、/etc/docker/daemon.jsonを編集する。

docs.docker.com

docker pull --platform linux/arm64 nginx

docker inspect nginx@sha256:21f32f6c08406306d822a0e6e8b7dc81f53f336570e852e25fbe1e3e3d0d0133 | grep -E "Os|Architecture" 
        "Architecture": "arm64",
        "Os": "linux",,

コンテナイメージハッシュを指定してpullする

DockerHubのAPIを理由してlinux/arm64のイメージを取得する。

ARM64_DIGEST=$(curl -s 'https://registry.hub.docker.com/v2/repositories/prom/node-exporter/tags/' |
    jq -r '.results[] | select(.name == "v1.0.0") | .images[] | select(.os == "linux" and .architecture == "arm64") | .digest')
docker pull prom/node-exporter@${ARM64_DIGEST}

platformがexperimentalでなくなってくれると非常に嬉しいなあと思っています。

TerraformでGKEクラスタ構築

記事一覧はこちら

業務ではdeployment managerというGCPから公式が提供している、Google Cloudリソースの作成と管理を自動化するインフラストラクチャデプロイサービスを利用して、Kubernetesクラスターやノードプールの作成を行っています。

ですが、GCPの環境構築においてはTerraformを利用している企業さんがかなり多いという認識をしております。 せっかくなのでプライベートでクラスターを作成する際には、Terraformを利用してみたいと思います。

作成したい構成

結構ケチケチしている性分なので、なるべく安くしたいところ…!

  • Google Kubernetes Engineのゾーンクラスタ
    • デフォルトで作成されるのがゾーンクラスタで、1アカウントにつき1つはAlwaysFree枠で無料になる
    • 実際に本番利用するときには、リージョンクラスタにしてMasterの冗長性を確保した方が良い、EKSのデフォルトはリージョンクラスタのはず
  • n1-standard1のノードプール
    • f1-microは30日間分無料なのだけれどスペックが満たせない

無料トライアルでもらえる$300のクレジットを利用して、はみ出したGCEとロードバランサの料金を払いたいと思います。
…待って。Stackdriverの料金もここに乗っかりそう🤔 Stackdriverのログも保存させないように設定を変更させておかないと!と思っていたのですが、50GBまでは無料なので一旦無視します。

TerraformでProvisionする準備

参考

https://learn.hashicorp.com/terraform/gcp/introlearn.hashicorp.com

CLIをインストール

Download Terraform - Terraform by HashiCorp にアクセスして、アーキテクチャ/OSを選択し、CLIのリンクアドレスをコピーする。

$ wget https://releases.hashicorp.com/terraform/0.14.7/terraform_0.14.7_darwin_amd64.zip
$ unzip terraform_0.14.7_darwin_amd64.zip                                                                                                
$ vi $HOME/.zshrc    # CLIのバイナリにPATHを通しておく
export PATH=$HOME/.terraform/bin:$PATH

$ terraform    # 叩けるか確認                                                                                                                                       
Usage: terraform [-version] [-help] <command> [args] ...(省略)

GCPをセットアップ

試しにTerraformでリソースを作成してみる

下記のように、ファイルを作成する。

terraform {
  required_providers {
    google = {
      source  = "hashicorp/google"
      version = "~> 3.58"
    }
  }
}


// リソースの作成と管理を担当するproviderを設定する
// 複数のクラウドベンダなどを利用する場合には、複数のproviderブロックが存在しうる
provider "google" {
  credentials = file("<クレデンシャルキーのファイル>.json")

  project = "<PROJECT_ID>"
  region  = "us-central1"
  zone    = "us-central1-c"
}

// インフラストラクチャ内にあるリソースを定義
// ブロック開始前の構成は、<リソースタイプ> <リソース名> となっていて、
// リソースタイプ google_compute_network の接頭辞がproviderを表す。
// <リソースタイプ>.<リソース名> はリソースIDとして扱われ、他のリソースから参照できる
resource "google_compute_network" "vpc_network" {
  name = "terraform-network"
}

下記のようにターミナルで操作を進めていく。
新しい設定の場合もしくはバージョン管理化にあるファイルをチェックアウトした後には、terraform initを実行する。このコマンドは、ローカルの設定やデータを初期化する。これは、main.tfのあるディレクトリで実行する。
terraform planでこの設定を適用した場合に、どのような操作が行われるかが確認できる。kubectlにおける--dry-runオプションのようなもの。
terraform applyで実際にリソースの作成/変更/削除が行われる。

$ terraform init                                                                  

Initializing the backend...

Initializing provider plugins...
- Checking for available provider plugins...
- Downloading plugin for provider "google" (hashicorp/google) 3.5.0...

Terraform has been successfully initialized!
...(省略)

$ terraform plan                                                                  
...(省略)
Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:

  # google_compute_network.vpc_network will be created
  + resource "google_compute_network" "vpc_network" {
      + auto_create_subnetworks         = true
      + delete_default_routes_on_create = false
      + gateway_ipv4                    = (known after apply)
      + id                              = (known after apply)
      + ipv4_range                      = (known after apply)
      + name                            = "terraform-network"
      + project                         = (known after apply)
      + routing_mode                    = (known after apply)
      + self_link                       = (known after apply)
    }

Plan: 1 to add, 0 to change, 0 to destroy.

$ terraform apply
...(省略)
Do you want to perform these actions?
  Terraform will perform the actions described above.
  Only 'yes' will be accepted to approve.

  Enter a value: yes

google_compute_network.vpc_network: Creating...(省略)

実際に出来ているのが確認できました!

f:id:jrywm121:20200607081807p:plain

インスタンスの作成もやってみる。下記の設定をmain.tfに付け加える。f1-microなのでAlways Free枠です。お値段はかかりません。

resource "google_compute_instance" "vm_instance" {
  name         = "terraform-instance"
  machine_type = "f1-micro"

  boot_disk {
    initialize_params {
      image = "debian-cloud/debian-9"
    }
  }

  network_interface {
    // ここで別のリソースの参照ができる
    network = google_compute_network.vpc_network.name
    access_config {
    }
  }
}

VMインスタンスも作成できていますね

f:id:jrywm121:20200607084935p:plain

最後にお片付けをしておきましょう

$ terraform destroy

Kubernetesクラスタを作成する

https://www.terraform.io/docs/providers/google/guides/using_gke_with_terraform.htmlwww.terraform.io

https://www.terraform.io/docs/providers/google/r/container_cluster.htmlwww.terraform.io

クラスターを作成する上で下記の2つのような方針を考えています。

  • kubectlコマンドで必要となるkubeconfigはgcloud container clusters get-credentials cluster-nameにて取得する
  • terraformのデフォルトはルートベースでのクラスター作成だが、今回はGCPのデフォルトであるVPC-Nativeのクラスターを作成する

実際にファイルを作成してapplyする前に APIの有効化をやっておくべき。

ファイルは下記のように作成した

terraform {
  required_providers {
    google = {
      source  = "hashicorp/google"
      version = "~> 3.58"
    }
  }
}


provider "google" {
  credentials = file("<クレデンシャルキーのファイル>.json")

  project = "<PROJECT_ID>"
  region  = "us-central1"
  zone    = "us-central1-c"
}

resource "google_container_cluster" "primary" {
  name = "my-gke-cluster"

  // ゾーンを指定するとゾーンクラスタ、リージョンを指定するとリージョナルクラスタ
  location = "us-central1-c"

  // ノードプールのリソース設定を包含すると密結合になり変更が難しくなる場合がある
  remove_default_node_pool = true
  initial_node_count       = 1 // ↑がtrueのとき使用されないが"1"をセットしておく必要あり

  network    = "default"
  subnetwork = "default"

  // VPC-Nativeの場合には指定
  ip_allocation_policy {
    cluster_ipv4_cidr_block  = "/16" // podのIPアドレス範囲
    services_ipv4_cidr_block = "/22" // ServiceのIPアドレス範囲
  }

  // private_cluster_config も入れたほうが良い

  // usernameとpasswordを空で作ればBasic認証はdisableになる
  // このブロックを指定しなければ、GCPのユーザ名を利用し自動生成する
  master_auth {
    username = ""
    password = ""

    client_certificate_config {
      issue_client_certificate = false
    }
  }
}

resource "google_container_node_pool" "primary_preemptible_nodes" {
  name     = "my-node-pool"
  location = "us-central1-c"
  cluster  = google_container_cluster.primary.name

  // node_countと同時に使用するべきではない
  autoscaling {
    min_node_count = 0
    max_node_count = 1
  }

  management {
    auto_repair  = true  // ノードの自動修復は有効化
    auto_upgrade = false // 自動アップグレードは無効化
  }

  node_config {
    preemptible  = true
    machine_type = "n1-standard-1"

    labels = {
      app = "web"
    }

    // taintsも設定できるが、このフィールドの変更がノード再生成のトリガーと
    // なるので、ここで管理すべきではない

    metadata = {
      disable-legacy-endpoints = "true"
    }

    oauth_scopes = [
      "https://www.googleapis.com/auth/logging.write",
      "https://www.googleapis.com/auth/monitoring",
    ]
  }
}

いざ!Provisionだ!

$ terraform apply
$ gcloud container clusters get-credentials my-gke-cluster
$ kubectl get svc                                                                        
NAME         TYPE        CLUSTER-IP   EXTERNAL-IP   PORT(S)   AGE
kubernetes   ClusterIP   10.112.0.1   <none>        443/TCP   11m

kubectlが通りました!コンソールも確認してみましょう!想定どおりです!

f:id:jrywm121:20200607103350p:plain

感想

普段利用しているdeployment-managerよりもシンプルで分かりやすい印象を受けました。
テンプレート化して変数とか当て込んだりしようとすると構成がもう少し複雑になってしまうのかもしれませんが、各クラウドベンダが同じ記法でインフラストラクチャを構成できるのは魅力ですね。

Accept-CharsetとContent-Typeの不一致でJerseyは404を返す

かなりハマってしまったので残しておきたいと思います。
とあるWebクライアントから通信を行った際に、ChromeFirefoxなどのモダンブラウザではステータスコード 200 OK を返していたリクエストパスで、ステータスコード 404 NotFound を返す事象と遭遇しました。使用していたフレームワークはJerseyで、前段にWebサーバとしてApacheを利用していました。

原因調査

今回、対象のWebクライアントが手元にない状態でした。リモートでの在宅勤務ということもあり、手元に持ってくることも難しい状況です。そのため少し泥臭くサーバサイドから調査を行っています。下記のように調査を進めていきました。

  • tomcatアクセスログを出力し、「tomcatまで通信は来ているか?」「tomcatの時点で返却されているステータスコードは何か?」を確認
  • Javaアプリケーションでどこまで処理が到達しているかをデバッグ出力で確認
    • WriterInterceptorまで到達しているので、レスポンスの書き込み時に404が返却されていることがわかる
    • JerseyのFilter系を理解するときには毎回、こちらの資料にお世話になっている、とっても分かりやすい
  • リクエストのヘッダ差分を確認
    • 直上の調査結果からJersey側で処理している層で404を返却していることがわかったので、X- で始まる独自定義ヘッダでない部分から見ていく
    • 他のWebクライアントから送信していないヘッダaccept-charsetが見つかる、以前にReal World HTTPを読んだ際に「モダンブラウザではほとんど送信していない」とあったので臭いなと感じる
    • accept-charsetを404のリクエストと同じように設定しcurl叩くと再現が出来た!

accept-charsetで指定している文字コードと、Content-Typeで指定している文字コードは同じだが、それぞれUPPRER_CASEとlower_caseとなっており厳密には異なっていた。その事実から、Content-Typeで指定している文字コード以外の文字コードaccept-charsetヘッダに設定してリクエストを投げると100%再現することができ、Accept-CharsetとContent-Typeの不一致が原因であることが分かりました。

Accept-CharsetとContent-Type

一旦立ち止まり、それぞれについて調べてみる

リクエストヘッダ:Accept-Charset

Accept-Encodingヘッダなどと同じコンテントネゴシエーションのためのもの。MDNを観て分かる通り、モダンブラウザでは送信していないです。どのブラウザも全Charsetのエンコーダーを内包するので事前にネゴシエーションする必要はなくなったからだそう、なるほどだ。

developer.mozilla.org

Charsetの指定は、IANAで管理されている。このIANAで指定/推奨されている値になっていれば不一致は起こらなかったはず…! ちなみに本来、このヘッダは無視されるか、ネゴシエーションに失敗した場合には406 Not Acceptableを返すのだそう。

www.iana.org

レスポンスヘッダ:Content-Type

このヘッダには、MIMEタイプとキャラクターセットの宣言が含まれます。WEBブラウザがどのようにファイルの種類、文字コードを区別し読み込むために必要なものです。HTMLの場合はドキュメント内に記述することも可能。ローカルに保存して再表示することもあるので、併用するべきとのこと。
Jerseyではリクエストハンドラとなるメソッドに付与する@Produces()で指定する。

Jersey はどこで404を返したのか

例外発生箇所は下記です。templateが解決できなかったとして404 NotFoundとしています。
同じリクエストパスで他のWebクライアントからのリクエストはtemplateが解決できているので、不思議です。もう少し深堀りしていきます。

jersey/ViewableMessageBodyWriter.java at master · jersey/jersey · GitHub

if (resolvedViewable == null) {
    final String message = LocalizationMessages.TEMPLATE_NAME_COULD_NOT_BE_RESOLVED(viewable.getTemplateName());
    throw new WebApplicationException(new ProcessingException(message), Response.Status.NOT_FOUND);
}

templateProcessorsとmediaTypesという2重ループの中でViewが解決されるのですが、404 NotFoundになるときにはmediaTypesが要素数が0で返り、Viewが解決できていないということになります。
そのmediaTypesを生成しているのが下記メソッドです。この処理で呼ばれているselectVariantsが肝になります。

jersey/VariantSelector.java at master · jersey/jersey · GitHub

public static List<Variant> selectVariants(final InboundMessageContext context,
                                            final List<Variant> variants,
                                            final Ref<String> varyHeaderValue) {
    LinkedList<VariantHolder> vhs = getVariantHolderList(variants);

    final Set<String> vary = new HashSet<>();
    vhs = selectVariants(vhs, context.getQualifiedAcceptableMediaTypes(), MEDIA_TYPE_DC, vary);
    vhs = selectVariants(vhs, context.getQualifiedAcceptableLanguages(), LANGUAGE_TAG_DC, vary);
    vhs = selectVariants(vhs, context.getQualifiedAcceptCharset(), CHARSET_DC, vary);      // ここの処理でvhsの要素が0になる
    vhs = selectVariants(vhs, context.getQualifiedAcceptEncoding(), ENCODING_DC, vary);

    if (vhs.isEmpty()) {
        return Collections.emptyList();
    } else {
        // 省略
    }
}

selectVariantsの処理が下記です。dimensionChecker.isCompatible(a, d)) というメソッドで、Accpet-CharsetのヘッダValueとContent-Typeで指定しているcharsetが一致しているかを確認しています。

jersey/VariantSelector.java at master · jersey/jersey · GitHub

for (final T a : acceptableValues) {
    // 省略
    while (iv.hasNext()) {
        final VariantHolder v = iv.next();

        // Get the dimension  value of the variant to check
        final U d = dimensionChecker.getDimension(v);  // dに文字コードが入る、例えば "utf-8"

        if (d != null) {
            vary.add(dimensionChecker.getVaryHeaderValue());
            // Check if the acceptable entry is compatable with
            // the dimension value
            final int qs = dimensionChecker.getQualitySource(v, d);
            if (qs >= cqs && dimensionChecker.isCompatible(a, d)) {  // ここで判断
                // 省略
            }
        }
    }
}

dimensionChecker.isCompatible(a, d)の処理は実際どんな感じかというと、下記のようになっており単純なString.equalsを使用しているだけです。そのため大文字/小文字を厳密に判定する形となっています。

jersey/Token.java at master · jersey/jersey · GitHub

public final boolean isCompatible(String token) {
    if (this.token.equals("*")) {
        return true;
    }

    return this.token.equals(token);
}

まとめ

ググっても全然出てこなかったので調査がとても大変だった。フレームワーク側の挙動を見るのかなり大変だ。
Jerseyのバージョンが2.11で結構古めだったので起こってしまったのかもなあとも思います。
貼ったJerseyのリンクは旧リポジトリのほうで、現在は以下のようにEclipse Foundataion管理になっています。

github.com

Sassもさらっと学んだ

いつもはDockerやKubernetesとべったりなのですが、最近はWEBフロントエンド+サーバサイドの開発をやっています。
サーバサイドは新卒で入った会社から慣れ親しみがありましたが、その会社のクライアントサイドがWindowsのデスクトップアプリ(※)だったため、WEBフロントエンドは未経験でした。
※ ちなみにWPFやUWPではなく、WinForms でした

昨今、cssメタ言語はWEBフロントエンド開発には欠かせないと聞きました。Vue.jsで対話的にテンプレートとか作成するときにも「Sass使う?それともStylus?」なんて聞かれた覚えがあります。
Stylusの方がパット見好みっぽいですが、会社で使っているのはSassなので勉強することにします。すでに軽く学んで実装とかも行っているんですが、学びなおしてリファクタに活かしたいというお気持ちです。

Sassにはいくつか種類がある

風の噂でSassはRubyが無いと動かせないという話を聞いていたのですが、それは全くの間違いで、むしろ2019年3月26日をもってサポートを終了していました。主な理由としてあげられているのは、要求されるパフォーマンスにRubyでの実装が追いつかなかったことのようです。また、Node.jsがフロントエンドの主流になっていったことも理由の一つとして挙げられています。

サポートされているのは以下の2つ

  • DartSass
  • LibSass

DartSassは現在のメイン実装で、最も早く新しい機能が追加されます。
Dartに書き換える理由は Sass: Announcing Dart Sass にて記載があります。少し掻い摘まみます。

RubySassは遅くRubyユーザ以外にはインストールが面倒であるものの機能追加が簡単、対してLibSassは速くて移植性が高いものの機能追加が難しいといった特徴を持っていました。2つは補完関係にあったが、どちらのソリューションも必要とされるほど良いものではなかったそうです。
Dartを選んだのは、高速かつ作業が簡単であったこと、JavaScriptとの互換性があるため既存のツールやビルドシステムにJS版を取り入れることができるという点だそうです(作者がDartチームだったことも大きそう)。

Sassの環境構築

webpackで1つのjsファイルにバンドルするケース
初めてwebpackを直接触った気がします、以前にAngularとかちょろっと触ったときには設定ファイルが自動生成されて、直接書き換えたことはなかったためです。
webpack自体は、英語のドキュメントを読み解けなかったので、こちらで勉強しました。

参考

webpack.js.org

$ npm init
$ npm install sass-loader style-loader css-loader sass webpack webpack-cli fibers --save-dev
$ mkdir -p src/js
$ mkdir src/scss
$ code webpack.config.js
$ webpack --mode development --watch   # 変更をウォッチ
const path = require('path');

module.exports = {
    mode: 'development',
    entry: './src/js/app.js',
    output: {
        filename: 'bundle.js',
        path: path.join(__dirname, 'dist')
    },
    module: {
        rules: [{
            test: /\.s[ac]ss$/i,
            use: [
                // Creates `style` nodes from JS strings
                'style-loader',
                // Translates CSS into CommonJS
                'css-loader',
                // Compiles Sass to CSS
                'sass-loader',
            ],
        }, ],
    },
};

Sassの構文

Sassの構文紹介の記事って日本語でもかなり落ちているんですが、パパっと観ていった中で一番分かりやすいのが公式のこのページでした。 分かりやすさとは関係ないけど、ページが軽量なのと広告無いのが地味に大きいんですかね。

sass-lang.com

ここでSCSSをトランスパイルした結果を観ることができる

www.sassmeister.com

変数 / ネスト

変数は$var: hogeの形で宣言することができ、利用するときにも$varで利用できる。
HTMLと同じように階層的にスタイルを書くことができる。ネストは深すぎると視認性を損なうため、3階層がまで良いようです。

I suggest you to not go more than 3 levels deep.

ベストプラクティスの記事 にありました。

$primary-color: #333;
$secondary-color: #fff;

nav {
  color: $primary-color;
  background-color: $secondary-color;
  
  li {
    width: 100%;
    
    &.primary-items {
      font-weight: bold;
    }
    
    // 隣接セレクタや兄弟セレクタも&を使って表現できる
    &+#selected {
      cursor: none;
    }
    
    // 疑似要素も然り
    &:hover {
      background-color: #666;
    }
  }
}

ファイルの分割

ソースコードの再利用性や保守性を高めるために、部分的にファイルを分けて、別のscssファイルからincludeすることができます。
そのincludeされる側のファイルをpartialというようにSassでは呼称しており、ファイル名の戦闘にアンダースコアが付きます。例えば、_partial.scssといった形です。そういったファイル名にすることによって、Sassはそのファイルをcssファイルにトランスパイルすべきでないことを知ります。

これらの仕組みは@useルールとともに利用することができ、Modulesと呼称されます。@useルールを使うことで、他のsassファイルをモジュールとしてロードすることができます。つまり、ロードしたファイルの変数や関数、mixinを参照することができます。
@useルールはDartSassにしか今のところ実装がなく、LibSassやRubySassの利用者を従来までの@importを利用する他なさそうです。@importは、下記の記事にあるように、名前空間が同一であったことから多くの問題を引き起こしたがために廃止する流れとなったとあり、DartSassを利用している人は@useを利用するべきのようです。@useの場合は、ファイル名.mixin名などのように利用しますが、@importはグローバル名前空間に共有されるので、簡潔な名前が使いづらい状態でした。

sass-lang.com

@extend

事前に定義したCSSプロパティのセットを複数のセレクタに共有することができるので、DRY原則の遵守に役立てることができる。
関連性のあるグルーピングに適用させるのがポイントで、そうしない場合に、下記のような負債を生み出してしまうので注意。ソースはこのブログextendという文字の通り継承させるものにのみ適用するのが良いということでしょう。ボタンなどが分かりやすい例と思っています。
関連性のないグルーピングに対してDRY原則を適用させたい場合には、引数なしの@mixinを利用するのが良さそうです。

@extendを使ったことで、スタイルシートが倍以上のサイズになってしまったり、ソースの順序がめちゃくちゃになってしまったり、セレクタが4,095個に達してしまったりしたケースをたくさん目にしてきました

f:id:jrywm121:20200518011649p:plain

@mixin

関数に近い機能を持ちます。下記のようにベンダープレフィックスなどが必要になるものに関してはかなり有効のようです。

@mixin transform($property) {
  -webkit-transform: $property;
  -ms-transform: $property;
  transform: $property;
}
.box { @include transform(rotate(30deg)); }

まとめ

@extendのネガティブな面について調べるまで想像することが出来ていませんでした。
保守性の高いコードを書くためにも、命名方法論のBEMなど周辺知識も合わせてしっかり勉強していこうと思います。
とりあえずリファクタですね。プロパティの順番などもある程度基準があるようなので合わせていきたいと思います。

余談ですけど、VSCodeでWEBサーバ簡単に建てられるんですね。Live ServerっていうExtensionであっさり出来すぎて笑っちゃいました。