LoginSignup
1
2

AWS CDKでService Catalogを構築・運用するための9Tips ~事例と共に~

Last updated at Posted at 2023-09-26

はじめに

本記事は、AWS Service Catalog × CDK の Tips 集になります。

私は、社内向けの AWS 共通プラットフォーム構築・運用に携わっており、各アカウントに配布するリソースを CDK で構築しています。元々、CDK で生成されたテンプレートを各アカウントに直接デプロイしていましたが、追加開発や運用面を考慮し、 Service Catalog を取り入れる方針に切り替えました。Service Catalog の導入により、プラットフォーム利用者が都合の良いタイミングで、製品をバージョン更新できる点も利点です。

Service Catalog の利用イメージは以下の通りです。

Service Catalog の利用イメージ

本記事では、AWS CDK で Service Catalog を利用する際に、躓いた点を中心にまとめました。

前提

前提は以下の通りです。

想定読者

  • Service Catalog をこれから使い始めようとしている人
  • AWS CDK における Service Catalog 利用方法に興味がある人
  • AWS Constructs Library1、および CDK におけるテスト2の概念を理解している人

Service Catalog の概要

AWS Service Catalog のよくある質問が分かり易かったので、以下に転載いたします。

Q: AWS Service Catalog とは何ですか?
AWS Service Catalog では、IT 管理者が承認された製品のカタログを作成および管理し、そのカタログをエンドユーザーに配布できます。エンドユーザーは、その後、パーソナライズされたポータルから必要な製品にアクセスできるようになります。管理者は、各製品にアクセスできるユーザーを制御して、組織のビジネスポリシーへのコンプライアンスを実施できます。

略称

本記事では、以下名称を省略して表記します。

  • AWS CloudFormation
    • 概要:AWS の IaC ツールであり、再利用可能な独自言語のテンプレート形式で記述
    • 略名:CFn
  • AWS Service Catalog Product
    • 概要:配布を目的とした AWS サービス群 (CFn テンプレートなどで定義)
    • 略名:製品
  • AWS Service Catalog Portfolio
    • 概要:製品の集合体であり、アクセス権限や起動方法などを制御
    • 略名:ポートフォリオ

その他、Service Catalog 関連の用語については、参考資料もご参照ください。

実行環境

  • CDK v2.93.0
  • Jest v26.6.3

9 Tips の紹介

ここから、AWS Service Catalog × CDK に関する Tips を 9 つ紹介します。
主に製品のスナップショットに関する Tips を中心に扱っていますが、構築で躓いた内容に関しても掲載しています。

  1. 製品に Asset が含まれる場合、クロスアカウントアクセス可能な S3 Bucket を用意する
  2. 製品のバージョン管理で、スナップショットの上書きを禁止する
  3. 製品のスナップショットをコード整形ツールの対象外にする
  4. プロダクトコードとテストコードでスタック名を分ける
  5. 製品のリソースを CDK でテストする場合、製品のスナップショットを利用する
  6. Organizations へのポートフォリオ共有は CFn サポート外であることに留意する
  7. ワイルドカードを使った IAM Principal 関連付けは CFn サポート外であることに留意する
  8. 大阪リージョンに製品を登録する場合、AWS::CDK::Metadata を除外する
  9. 複数環境利用時は TagOption の重複に留意する

なお、5~8 の Tips については、2023年8月末時点で未サポートの部分に対する暫定的な内容になっています。今後、AWS のアップデートに伴い、対応内容が変更になると予想されるため、ご留意ください。

1. 製品に Asset が含まれる場合、クロスアカウントアクセス可能な S3 Bucket を用意する

製品に Lambda 関数ソースコードなどの Asset が含まれる場合、ProductStack (L2 Construct) で assetBucket (S3 Bucket)の指定が必要です。

ポートフォリオを異なるアカウントに共有して、共有先アカウントで製品をデプロイするケースが多いと思われます。この場合、assetBucket でクロスアカウントアクセスを許可していないと、以下のようなエラーが発生します。

エラーメッセージ例
Resource handler returned message: "Your access has been denied by S3, please make sure your request credentials have permission to GetObject for xxbucket/xx.zip. S3 Error Code: AccessDenied. S3 Error Message: Access Denied (Service: Lambda, Status Code: 403, Request ID: xx-xx-xx-xx-xx)"

そのため、assetBucket では、バケットポリシーでクロスアカウント設定が必要となります。例えば、CDK で以下のように記述すると、組織 ID に属する全アカウントからのクロスアカウントアクセスが許可されます。

組織 ID のクロスアカウント設定例
const assetBucket = new s3.Bucket(this, "AssetBucket"); // (細かな設定は省略)
assetBucket.grantRead(new iam.OrganizationPrincipal("o-xxxx"));

2. 製品のバージョン管理で、スナップショットの上書きを禁止する

大抵の場合、製品では追加開発・保守などのアップデートにより、複数バージョンを扱います。その際、製品のバージョンに紐づくスナップショットを、適切に管理・運用することが重要です。

製品の複数バージョンを管理したいケースでは、ProductStackHistory (L2 Construct) を使用できます。以下は、API Reference の掲載例をベースにしたサンプルコードです。

// 利用者に配布する CFn テンプレートのリソースを ProductStack 配下で定義
class S3BucketProduct extends servicecatalog.ProductStack {
  constructor(scope: Construct, id: string) {
    super(scope, id);

    new s3.Bucket(this, 'BucketProductV2');
  }
}

// Service Catalog 関連のリソースを通常の cdk.Stack で定義
export class CdkScStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // ポートフォリオ(製品の集合)を定義
    const portfolio = new servicecatalog.Portfolio(this, 'Portfolio', {
      displayName: 'MyPortfolio',
      providerName: 'MyTeam',
    });

    // ProductStack のリソース定義にバージョン名を付与して、スナップショットを出力
    const productStackHistory = new servicecatalog.ProductStackHistory(
      this, 'ProductStackHistory', {
        productStack: new S3BucketProduct(this, 'S3BucketProduct'),
        currentVersionName: 'v2', // バージョン名を指定
        currentVersionLocked: true // 上記バージョン名のスナップショット上書きを禁止
    });

    // 利用者に開示する製品を登録
    const product = new servicecatalog.CloudFormationProduct(this, 'MyFirstProduct', {
      productName: "My Product",
      owner: "Product Owner",
      productVersions: [
        productStackHistory.currentVersion(), 
        // スナップショット生成済みの過去バージョンを文字列で指定すると、製品一覧で選択可能
        productStackHistory.versionFromSnapshot("v1"), 
      ],
    });
    portfolio.addProduct(product);
  }
}

ProductStackHistory の特徴として、CDK コマンドやテスト実行時に、製品(ProductStack)のスナップショットが出力されます。例えば、上記のソースコードで cdk synthを実行すると、以下のスナップショットが出力されます。

ProductStack のスナップショット例
{
  "Resources": {
    "BucketProductV2XXXXXX": {
      "Type": "AWS::S3::Bucket",
      "UpdateReplacePolicy": "Retain",
      "DeletionPolicy": "Retain",
      "Metadata": {
        "aws:cdk:path": "CdkScStack/S3BucketProduct/BucketProductV2/Resource"
      }
    }
  }
}

上記のスナップショットは、CDK プロジェクト内のディレクトリに出力されます3。出力先は以下のイメージです。

product-stack-snapshots ディレクトリ例

もし、同一バージョンのスナップショットで差分が生じた場合、以下のようなエラーが出力されます。

エラーメッセージ例
Error: Template has changed for ProductStack Version v2.
  v2 already exist in product-stack-snapshots.
  Since locked has been set to true,
  Either update the currentVersionName to deploy a new version or deploy the existing ProductStack snapshot.
  If v2 was unintentionally synthesized and not deployed, 
  delete the corresponding version from product-stack-snapshots and redeploy.

CDK プロジェクト内のディレクトリでは全バージョンを確認できますが、利用者に公開する範囲は別途設定が必要です。バージョンの公開範囲は CloudFormationProduct (L2 Construct) で指定できます。Construct Props の productVersions で指定したバージョンの分だけ、利用者側にも表示されます。以下は製品起動時の画面例です。

製品バージョン一覧

追加開発などで製品のバージョンを更新する際に、過去バージョンのスナップショットが変更されないようにするには、ProductStackHistory でcurrentVersionLocked: trueを設定します。製品の過去バージョンを担保するためにも、スナップショットの変更禁止を推奨します。

新バージョンの開発中には、スナップショットが頻繁に変更されるため、currentVersionLocked: falseにするということも有効だと思います。その場合でも、新バージョンの開発が完了した段階で、currentVersionLocked: trueにすることを推奨します。

なお、当チームでは git 上で製品バージョンを管理しやすいように、専用のリポジトリを設けて Git タグを使用しています。本タグには、productStackHistory の currentVersionName と同じバージョンを付与しています。

3. 製品のスナップショットをコード整形ツールの対象外にする

本 Tips は、製品のスナップショットを上書き不可(currentVersionLocked: true)に設定している前提の話になります。

製品のスナップショットが自動整形されると、その後の CDK コマンドでエラーが発生してしまいます。そのため、コード整形ツールでスナップショットが整形されないように設定することを推奨します。

Jest のスナップショットでは拡張子が.snapの形式4ですが、ProductStackHistory で出力される製品のスナップショットは.json形式になります。コード整形ツールで JSON の整形は一般的だと思いますが、ProductStackHistory のスナップショットも JSON 形式であるため、整形されてしまいます。整形されると、実リソース上の変更がなくても、Template has changedのエラーが発生してしまいます。

従って、スナップショット出力先ディレクトリについては、コード整形ツールの除外対象にすることを推奨します。
私のチームでは、コード整形に Prettier を利用しています。Prettier での除外設定例は、以下の通りです。

.prettierignore
# Ignore artifacts:
/dist
node_modules
package.json
package-lock.json
tsconfig.json
tsconfig.eslint.json
product-stack-snapshots

4. プロダクトコードとテストコードでスタック名を分ける

前項同様に、本 Tips も製品のスナップショットを上書き不可(currentVersionLocked: true)に設定している前提の話になります。

プロダクトコードとテストコードでスタック名を分けることで、本番資産のスナップショットとは異なるテスト用のスナップショットを出力できます。つまり、本番資産のスナップショットを隔離できるため、テスト用に仮の値を使用するなど柔軟に開発できます。

前述の通り、製品(ProductStack)では CDK コマンドやテスト実行時に、スナップショットが出力されます。CDK コマンドとテスト実行時では、出力されるスナップショットが異なります。

テスト(npm test)で出力されるスナップショット
{
  "Resources": {
    "BucketProductV2XXXXXX": {
      "Type": "AWS::S3::Bucket",
      "UpdateReplacePolicy": "Retain",
      "DeletionPolicy": "Retain"
    }
  }
}
CDK コマンドで出力されるスナップショット
{
  "Resources": {
    "BucketProductV2XXXXXX": {
      "Type": "AWS::S3::Bucket",
      "UpdateReplacePolicy": "Retain",
      "DeletionPolicy": "Retain",
+     "Metadata": {
+       "aws:cdk:path": "CdkScStack/S3BucketProduct/BucketProductV2/Resource"
+     }
    }
  }
}

各スナップショットの相違点は、Metadata の有無です。スタック名が同じ場合、Template has changedのエラーが発生します。対応方法としては、主に以下2パターンがあります。

  1. CDK コマンドに--path-metadata falseオプションを付与
  2. テストコードに、プロダクトコードと異なるスタック名を指定

1 の場合、metadata が付与されなくなりますが、都度 CDK コマンドにオプションを付与する必要があります。当チームでは、2 のスタック名変更案を採用しました。開発時は渡したい値が定まっておらず、テスト用の値をスタックに渡すことが往々にしてあります。スタック名を分けて別のスナップショットを出力する方が、柔軟に開発できると考えたためです。

具体的なスタック名分割のイメージは、以下のようなイメージです。

プロダクトコード(app.ts)の例
const app = new cdk.App();
// 本番で利用するスタック名として「CdkScStack」を指定
new CdkScStack(app, 'CdkScStack',{
  env: {
    account: "123456789012",
    region: "ap-northeast-1",
  },
  // StackProps で本番用の値を定義
  keyArn: "arn:aws:kms:ap-northeast-1:012345678901:key/1234abcd"
});
テストコードの例
describe("ServiceCatalog のテスト", () => {
  let template: Template;
  let stack: cdk.Stack;

  beforeEach(() => {
    // 本番と異なるスタック名として「CdkScStackTest」を指定
    stack = new CdkScStack(new cdk.App(), 'CdkScStackTest',{
      env: {
        account: "100020003000",
        region: "ap-northeast-1",
      },
      // StackProps でテスト用の値を定義
      keyArn: "arn:aws:kms:ap-northeast-1:123412341234:key/test"
    });
    template = Template.fromStack(stack);
  });

  // テストケースを以下で定義
});

簡易的な例ですが、プロダクトコードとテストコードで異なるスタック名・StackProps を渡しています。

5. 製品のリソースを CDK でテストする場合、製品のスナップショットを利用する

前項の Tips では、Service Catalog 自身(ポートフォリオなど)に関するテストコードを例示していましたが、本 Tips では利用者に配布する製品リソースのテストコードを紹介します。

2023年8月末時点において、製品のリソースに対する CDK のテストでは一部バグがあります。しかし、製品のスナップショットを利用することで、通常通り CDK のテストを実行可能です。

CDK における通常のテストでは、以下のように fromStack を利用して、スタックからテンプレートを生成するのが一般的です。

cdk.Stack を利用した一般的なテスト例
describe("ServiceCatalog のテスト", () => {
  let template: Template;
  let stack: cdk.Stack;

  beforeEach(() => {
    // テスト用スタックを定義
    stack = new CdkScStack(new cdk.App(), 'CdkScStackTest');
    // スタックを fromStack メソッドで Template 化
    template = Template.fromStack(stack);
  });
});

しかし、2023年8月末時点において、製品のリソース定義で利用する ProductStack では、fromStack メソッドを実行できません。以下 issue でも、バグとして報告されています。

fromStack の代替案として、製品(ProductStack)のスナップショットでテストすることを推奨します。
aws-cdk-lib/assertions の Template クラスでは、JSON を Template 形式にする fromJSON というメソッドが用意されています。製品(ProductStack)のスナップショットは JSON 形式であるため、このメソッドを使うことで通常通りに CDK のテストを実行できます。

具体的なテストコード例は以下の通りです。

製品スナップショットを利用したテスト例
import { Template } from "aws-cdk-lib/assertions";
import { readFileSync } from "fs";
// テストしたいバージョンのスナップショットについてパスを指定
const snapVersionPath = "product-stack-snapshots/CdkScStackTestProductStackHistory12345678.CdkScStackTestS3BucketProductXXX12345.v2.product.template.json";

describe("ServiceCatalog製品リソースのテスト", () => {
  let template: Template;
  
  beforeAll(() => {
    // JSON 形式のスナップショットを読み取り
    const snapshot = JSON.parse(readFileSync(snapVersionPath, "utf-8"));
    // スナップショットを fromJSON メソッドで Template 化
    template = Template.fromJSON(snapshot);
  });
  
  test("S3 Bucketの数が1である", () => {
    template.resourceCountIs("AWS::S3::Bucket", 1);
  });
});

6. Organizations へのポートフォリオ共有は CFn サポート外であることに留意する

製品を他アカウントへ配布するためには、ポートフォリオで共有先のアカウント ID や Organizations の情報を事前設定する必要があります。

ポートフォリオの共有では、CreatePortfolioShareの API が使用されます。Organizations に共有する場合は、OrganizationNode のパラメータを指定する必要があります。

Request Syntax(デベロッパーガイドから引用)
{
  "AcceptLanguage": "string",
  "AccountId": "string",
  "OrganizationNode": { 
    "Type": "string",
    "Value": "string"
  },
  "PortfolioId": "string",
  "SharePrincipals": boolean,
  "ShareTagOptions": boolean
}

しかし、2023年8月末時点において、CloudFormation の AWS::ServiceCatalog::PortfolioShare (L1 Construct) では、OrganizationNode を指定できません。

Syntax(ユーザーガイドから引用)
{
  "Type" : "AWS::ServiceCatalog::PortfolioShare",
  "Properties" : {
    "AcceptLanguage" : String,
    "AccountId" : String,
    "PortfolioId" : String,
    "ShareTagOptions" : Boolean
  }
}

従って、ポートフォリオを Organizations に共有したい場合は、以下のような対応が必要になります。

当チームでは、Service Catalog の環境が複数あり、環境ごとに共有先を分けたかったため、カスタムリソース化しています。

API を使用した Organizations 共有の注意点

CreatePortfolioShare API で Organizations へ共有する場合、OrganizationNode の指定で注意が必要です。

{
  OrganizationNode: { 
    Type: "ORGANIZATION" || "ORGANIZATIONAL_UNIT" || "ACCOUNT",
    Value: "STRING_VALUE"
  }
}

o-から始まる組織 ID に共有する場合と、ou-から始まる組織単位 ID に共有する場合で、Type に指定する内容が異なります。各共有において、OrganizationNode に指定すべき値は以下になります。

  • 組織 ID 共有時
    • Type: "ORGANIZATION"
    • Value: "{o-から始まる組織 ID}"
  • 組織単位 ID 共有時
    • Type: "ORGANIZATIONAL_UNIT"
    • Value: "{ou-から始まる組織単位 ID}"

当チームでは、組織内の全アカウントにポートフォリオを共有したかったものの、Type に "ORGANIZATION" ではなく "ORGANIZATIONAL_UNIT" を指定してしまうミスがあり、API からエラーが返されました。API で Organizations 共有する際は、Type の指定にご注意ください。

7. ワイルドカードを使った IAM Principal 関連付けは CFn サポート外であることに留意する

ポートフォリオでは、アクセスさせたいIAM ユーザー/ロール/グループの Arn を事前に設定する必要があります。この事前設定を「IAM Principal 関連付け」と呼びます。

2023年5月末に、ポートフォリオの IAM Principal 関連付けでワイルドカードがサポートされるようになりました。

本アップデートにより、IAM Identity Center の Role などを一括でポートフォリオに関連付けできます。例えば、PrincipalARNに以下の ARN を指定できます。

"arn:aws:iam:::role/aws-reserved/sso.amazonaws.com/*/AWSReservedSSO_*"

ポートフォリオの IAM Principal 関連付けでは、AssociatePrincipalWithPortfolioの API が使用されます。ワイルドカードを使用する場合は、PrincipalType のパラメータをIAM_PATTERNに指定する必要があります。

しかし、2023年8月末時点において、CloudFormation (L1 Construct) では、PrincipalType に IAM のみ指定可能です。つまり、AWS 標準 Construct において、IAM Principal 関連付けでワイルドカードの指定はできません。

PrincipalType

従って、ワイルドカードを使用したい場合は、以下のような対応が必要になります。

当チームでは、ポートフォリオ共有と同様にカスタムリソース化しています。

8. 大阪リージョンに製品を登録する場合、AWS::CDK::Metadata を除外する

2023年8月末時点において、大阪リージョンに Service Catalog の製品をデプロイする場合、注意が必要です。製品の CFn テンプレートに AWS::CDK::Metadata が含まれていると、以下のようなエラーが出力されます5

Invalid templateBody. Please make sure that your template is valid (Service: AWSServiceCatalog; Status Code: 400; Error Code: InvalidParametersException; Request ID: xxxx; Proxy: null)

上記の対策として、AWS::CDK::Metadataの無効化対応が必要です。公式ガイドで案内されている無効化方法は、以下2点です。

  1. cdk.json で "versionReporting": false を指定
  2. cdk deployなどのコマンドに--no-version-reportingオプションを付与

前述の ProductStackHistory でスナップショットを出力している場合、ローカルでのcdk lscdk synth実行時にも、スナップショットでAWS::CDK::Metadata が含まれてしまいます。都度、CDK コマンドで--no-version-reportingオプションを指定するのはミスの原因になるため、cdk.json で無効化することを推奨します。

なお、本件は AWS サポートでエラーの解決方法をご教示いただきました。ご担当者様、誠にありがとうございました…!

9. 複数環境利用時は TagOption の重複に留意する

TagOption とは、ポートフォリオ、または製品に紐づけできる機能です。Service Catalog で TagOption を使用すると、利用者が製品起動時のタグを指定することができます6

TagOption ライブラリ
出典: Service Catalog 管理者ガイド

タグ設定を管理する TagOption ライブラリでは、アカウントのリージョン内で、Key・Value の組合せが一意である必要があります。そのため、シングルスタック構成で TagOption をシンプルに実装した場合、本番・開発環境で各スタックの論理 ID を分けていたとしても、Key・Value の重複が発生します。

重複エラーメッセージ例
| CREATE_FAILED | AWS::ServiceCatalog::TagOption | TagOptionXXXX
TestKey|TestValue already exists in stack

従って、以下などの対策が必要になります。

  1. 本番環境のみ TagOption を利用(シングルスタックで条件分岐)
  2. 本番・開発環境で異なる Key・Value を設定(シングルスタックで条件分岐)
  3. スタック分割した TagOption を、クロススタック参照で本番・開発環境に共有する(マルチスタック)
  4. TagOption を CDK 対象外とし、GUI などで管理

当チームでは TagOption を今後変更する可能性があり、TagOption の内容・関連付けを GUI で管理したくなかったことから、4 を除外しました。その他案で悩みましたが、できる限り本番と開発環境の差異をなくしたかったため、当チームでは 3 のマルチスタック案を採用しました。今後の開発・運用で問題が生じた際は、シングルスタック化することも視野にいれています7

マルチスタックの実装例は、以下の通りです。

TagOption のマルチスタック実装例

以下のような TagOption 専用のスタックを作成します。

TagOption スタック例
export class TagOptionsStack extends Stack {
  public readonly tagOptions: servicecatalog.TagOptions;

  constructor(scope: Construct, id: string, props: TagOptionsProps) {
    super(scope, id, props);

    this.tagOptions = new servicecatalog.TagOptions(
      this, "TagOption",
      {
        allowedValuesForTags: {
          "TestKey": ["Value1", "Value2", "Value3", "Value4"],
        },
      }
    );
  }
}

Tag Options (L2 Construct) には、fromPortfolioArn のような外部リソースを参照する関数はありません。従って、app レイヤーの各環境では、以下のようにクロススタック参照します。

app.ts の例
const tagStack = new TagOptionsStack(app, "TagOptionsStack");

// 開発環境
new CdkScStack(app, "ServiceCatalogDevStack", {
  tagOptions: tagStack.tagOptions,
});

// 本番環境
new CdkScStack(app, "ServiceCatalogProdStack", {
  tagOptions: tagStack.tagOptions,
});

ServiceCatalog 用スタックでは、app レイヤーから受け取った tagOptions をポートフォリオに紐づけます。

ServiceCatalog スタックの例
export interface ServiceCatalogProps extends StackProps {
  tagOptions: servicecatalog.TagOptions;
}

export class CdkScStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props: ServiceCatalogProps) {
    super(scope, id, props);

    const portfolio = new servicecatalog.Portfolio(this, 'Portfolio', {
      displayName: 'MyPortfolio',
      providerName: 'MyTeam',
    });
    portfolio.associateTagOptions(props.tagOptions);

    // (その他定義は省略)
  }
}

おわりに

本記事では、AWS Service Catalog × CDK に関する 9 つの Tips を紹介しました。
今回対象外としたトピックもあるので、また別の機会に紹介できればと思います。

本記事がどなたかのお役になれば幸いです。

参考資料

  1. AWS Constructs Library の解説については、AWS Black Belt Online Seminar AWS CDK 概要 (Basic #1)をご参照ください。

  2. CDK のテストについては、デベロッパーガイドに詳細な解説が記載されています。AWS CDK Workshop では、Construct のテストを体感することができます。

  3. デフォルトでは、product-stack-snapshotsというディレクトリ名で自動出力されます。Construct Props のdirectoryで出力先を変更できます。

  4. Jest を使用している CDK のスナップショットテストも、.snapの形式になります。

  5. 東京リージョンやバージニア北部リージョンなどの主要なリージョンでは AWS::CDK::Metadataを付与しても問題ありません。

  6. 2023年8月末時点において、TagOption を共有されたアカウントでは、マネジメントコンソールから「タグをコピーしてTagOptionsを有効化」をクリックしない限り、TagOption の制約が有効になりません。AWS サポートに問い合わせたところ、マネジメントコンソール以外で TagOptions を有効化する方法は現状ありませんでした。

  7. BLEA開発チームが学んだAWS CDKの開発プラクティス 2023年版では、シングルスタック化が推奨されています。条件分岐を含めてシングルスタック化する場合は、アサーションテストで期待通りの値になっているか確認することをオススメします。

1
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
2