0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

AWS Lambda Durable Functionsで、15分の壁を超えてCloudWatch LogsのS3エクスポート完了を待ちぼうけ

0
Posted at

はじめに

こんばんは、mirukyです。

CloudWatch LogsをS3へエクスポートすると、開始要求を送った後に完了を確認する必要があります。今回は、その待ち時間をAWS Lambda Durable Functionsで扱ってみます。

AWSマネジメントコンソールから1GBのログを使い、待機から結果確認まで進めます。では、始めましょう。

目次

  1. Durable Functionsの待機と再開
  2. 今回の構成と検証条件
  3. コンソールで準備する
  4. 関数を作成して実行する
  5. 16分後の再開とS3出力を確認する
  6. 1〜30GBの追加測定
  7. 運用時の待機方法と制約

1. Durable Functionsの待機と再開

Durable Functionsは、処理の進行状況をチェックポイントへ保存し、複数回のLambda呼び出しをまたいで実行を続ける機能です。context.wait()で計算を停止し、指定時間が過ぎると新しい呼び出しで再開します。

再開時にはコードが最初から再生されます。完了したstepは保存済みの結果を返すため、AWS API呼び出しや現在時刻、乱数を使う処理は@durable_stepの中で実行します。

今回は1回の呼び出しのタイムアウトを1分に設定し、エクスポート開始後に16分待ちます(15分超えた想定のためですね)。わざわざこうするのは、CloudwatchのS3エクスポートはGB数によっては15分を超える場合があるためです。

2. 今回の構成と検証条件

Lambdaはエクスポートの開始と状態確認を担当し、ログ本体をS3へ書き込むのはCloudWatch Logsです。Lambdaの実行ロールに加えて、CloudWatch Logsからの書き込みを許可するS3バケットポリシーが必要になります。

Lambdaが開始と状態確認を担当し、CloudWatch LogsがS3へログを書き出す

すべて東京リージョンで作成します。S3バケットとロググループを同じリージョンに置き、次の条件で進めます。

項目 設定
ランタイム・アーキテクチャ Python 3.14・x86_64
メモリ 1024MB
1回の呼び出しのタイムアウト 1分
永続実行のタイムアウト・保持期間 13時間・14日
呼び出し 発行済みバージョンを非同期で実行

疑似ログはランダムバイトをBase64へ変換して生成します。1GBは10進数の1,000,000,000バイトとし、PutLogEventsのイベントごとの26バイトも含めて計算します。

東京のCloudWatch Logsのスタンダードクラスの取り込み単価は1GBあたり0.76USDです。今回の1GBでは取り込み費用が大部分を占め、Lambda、Durable Functions、S3の短時間利用を含む概算は無料枠適用前で1USD未満です。待機中のLambdaコンピューティング料金は発生しませんが、Durable Functionsのオペレーションや保存データには料金がかかります。

3. コンソールで準備する

3-1. ロググループを作成する

エクスポート元になるロググループを用意します。

CloudWatch Logsのロググループ作成設定

ロググループ名を入力し、保持期間を1 日、ログクラスをスタンダードにして作成します。ログの投入は4章の関数が行います。

3-2. S3バケットとバケットポリシーを設定する

書き出し先のバケットを作成します。

S3バケットの種類と名前の設定

汎用のバケットに固有名を入力します。パブリックアクセスは全項目をブロックし、ACL無効、暗号化はSSE-S3の設定で作成します。

作成したS3バケットのオブジェクト一覧

作成後はアクセス許可へ進み、バケットに次のポリシーを設定します。<ACCOUNT_ID>、バケット名、ロググループ名を自分の値へ置き換えてください。

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowCloudWatchLogsGetBucketAcl",
      "Effect": "Allow",
      "Principal": {"Service": "logs.ap-northeast-1.amazonaws.com"},
      "Action": "s3:GetBucketAcl",
      "Resource": "arn:aws:s3:::<YOUR_EXPORT_BUCKET>",
      "Condition": {
        "StringEquals": {"aws:SourceAccount": "<ACCOUNT_ID>"},
        "ArnLike": {
          "aws:SourceArn": "arn:aws:logs:ap-northeast-1:<ACCOUNT_ID>:log-group:<LOG_GROUP_NAME>"
        }
      }
    },
    {
      "Sid": "AllowCloudWatchLogsPutObject",
      "Effect": "Allow",
      "Principal": {"Service": "logs.ap-northeast-1.amazonaws.com"},
      "Action": "s3:PutObject",
      "Resource": "arn:aws:s3:::<YOUR_EXPORT_BUCKET>/durable-export/*",
      "Condition": {
        "StringEquals": {
          "s3:x-amz-acl": "bucket-owner-full-control",
          "aws:SourceAccount": "<ACCOUNT_ID>"
        },
        "ArnLike": {
          "aws:SourceArn": "arn:aws:logs:ap-northeast-1:<ACCOUNT_ID>:log-group:<LOG_GROUP_NAME>"
        }
      }
    }
  ]
}

GetBucketAclは所有者確認、PutObjectはログの書き込みに使います。SourceAccountとSourceArnで、対象アカウントのロググループだけを許可します。

3-3. Lambdaの実行ロールを用意する

Lambdaを信頼する実行ロールを作成し、AWSLambdaBasicExecutionRoleと次のインラインポリシーを追加します。

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "DurableExecutionCheckpoint",
      "Effect": "Allow",
      "Action": [
        "lambda:CheckpointDurableExecution",
        "lambda:GetDurableExecutionState"
      ],
      "Resource": "arn:aws:lambda:ap-northeast-1:<ACCOUNT_ID>:function:<FUNCTION_NAME>:*"
    },
    {
      "Sid": "CreateExportTask",
      "Effect": "Allow",
      "Action": "logs:CreateExportTask",
      "Resource": "arn:aws:logs:ap-northeast-1:<ACCOUNT_ID>:log-group:<LOG_GROUP_NAME>"
    },
    {
      "Sid": "WriteBenchmarkLogs",
      "Effect": "Allow",
      "Action": ["logs:CreateLogStream", "logs:PutLogEvents"],
      "Resource": "arn:aws:logs:ap-northeast-1:<ACCOUNT_ID>:log-group:<LOG_GROUP_NAME>:log-stream:*"
    },
    {
      "Sid": "ReadExportStatusAndMetrics",
      "Effect": "Allow",
      "Action": ["logs:DescribeExportTasks", "cloudwatch:GetMetricStatistics"],
      "Resource": "*"
    },
    {
      "Sid": "ListExportObjects",
      "Effect": "Allow",
      "Action": "s3:ListBucket",
      "Resource": "arn:aws:s3:::<YOUR_EXPORT_BUCKET>",
      "Condition": {"StringLike": {"s3:prefix": "durable-export/*"}}
    },
    {
      "Sid": "WriteBenchmarkResult",
      "Effect": "Allow",
      "Action": "s3:PutObject",
      "Resource": "arn:aws:s3:::<YOUR_EXPORT_BUCKET>/durable-export/*/benchmark-result.json"
    }
  ]
}

s3:ListBucketは出力の集計、s3:PutObjectは結果JSONの保存に使います。エクスポート本体の書き込み権限は、3-2で設定したバケットポリシーが担当します。

IAMインラインポリシーの作成確認

内容を確認してポリシーを作成します。関数の作成時には、この実行ロールを指定します。

4. 関数を作成して実行する

4-1. Durable Functionsを有効にする

Durable Functionsは関数の作成時に有効にする必要があります。

Lambdaの関数作成の入口

関数の作成から新規関数を用意します。関数名はポリシーの<FUNCTION_NAME>と一致させ、アーキテクチャをx86_64にし、3章の実行ロールを指定します。

Python 3.14と永続実行の設定

一から作成、Python 3.14を選び、永続実行を設定を有効にします。実行タイムアウトを13 時、保持期間を14 日にして作成します。

作成したDurable Functions対応関数のコード画面

メモリを1024MB、1回の呼び出しのタイムアウトを1分へ変更します。

4-2. コードを配置する

処理の中心は、エクスポート開始、16分待機、同じtaskIdでの状態確認です。以下は完全版からの抜粋です。

    # エクスポートの開始結果を保存します
    export = context.step(
        start_export_task(
            log_group_name,
            destination_bucket,
            destination_prefix,
            from_ms,
            to_ms,
            export_task_name,
        ),
        config=NO_RETRY_CONFIG,
    )

    # 計算を停止して16分待ち、保存したtaskIdで状態確認へ戻ります
    context.wait(
        duration=Duration.from_minutes(16),
        name="wait-beyond-fifteen-minutes",
    )
    result = context.step(check_export_status(export["taskId"]))

完全版では、先に1GBの疑似ログを投入し、100MBごとにチェックポイントを作成します。エクスポートの完了後にS3のオブジェクト数と容量を集計し、結果をJSONへ保存します。

lambda_function.pyの完全版
import base64
import json
import math
import os
import time
from datetime import datetime, timedelta, timezone

import boto3
from botocore.config import Config
from botocore.exceptions import ClientError
from aws_durable_execution_sdk_python import (
    DurableContext,
    durable_execution,
    durable_step,
)
from aws_durable_execution_sdk_python.config import (
    Duration,
    StepConfig,
    StepSemantics,
)
from aws_durable_execution_sdk_python.retries import RetryDecision


AWS_CONFIG = Config(retries={"max_attempts": 10, "mode": "adaptive"})
logs_client = boto3.client("logs", config=AWS_CONFIG)
cloudwatch_client = boto3.client("cloudwatch", config=AWS_CONFIG)
s3_client = boto3.client("s3", config=AWS_CONFIG)

PUT_LOG_EVENTS_LIMIT_BYTES = 1_048_576
LOG_EVENT_OVERHEAD_BYTES = 26
MAX_MESSAGE_BYTES = PUT_LOG_EVENTS_LIMIT_BYTES - LOG_EVENT_OVERHEAD_BYTES
GENERATION_PART_BYTES = 100_000_000


@durable_step
def create_benchmark_stream(step_context, log_group_name, stream_name):
    # ベンチマーク用のログストリームを一度だけ作成します
    logs_client.create_log_stream(
        logGroupName=log_group_name,
        logStreamName=stream_name,
    )
    return {"streamName": stream_name}


@durable_step
def write_benchmark_part(
    step_context,
    log_group_name,
    stream_name,
    part_number,
    target_api_bytes,
):
    # 外部APIへの書き込みは、再生されないstep内に閉じ込めます
    remaining = target_api_bytes
    event_count = 0
    message_bytes_total = 0
    first_timestamp = None
    last_timestamp = None

    while remaining > 0:
        if remaining <= LOG_EVENT_OVERHEAD_BYTES:
            raise ValueError("A generation part ended with fewer than 27 bytes")

        message_bytes = min(
            MAX_MESSAGE_BYTES,
            remaining - LOG_EVENT_OVERHEAD_BYTES,
        )
        raw_bytes = math.ceil(message_bytes * 3 / 4)
        message = base64.b64encode(os.urandom(raw_bytes)).decode("ascii")[:message_bytes]
        event_timestamp = int(time.time() * 1000)

        response = logs_client.put_log_events(
            logGroupName=log_group_name,
            logStreamName=stream_name,
            logEvents=[{"timestamp": event_timestamp, "message": message}],
        )
        if response.get("rejectedLogEventsInfo"):
            raise RuntimeError(str(response["rejectedLogEventsInfo"]))

        first_timestamp = first_timestamp or event_timestamp
        last_timestamp = event_timestamp
        event_count += 1
        message_bytes_total += message_bytes
        remaining -= message_bytes + LOG_EVENT_OVERHEAD_BYTES

    return {
        "partNumber": part_number,
        "apiBytes": target_api_bytes,
        "messageBytes": message_bytes_total,
        "eventCount": event_count,
        "firstTimestamp": first_timestamp,
        "lastTimestamp": last_timestamp,
    }


@durable_step
def start_export_task(
    step_context,
    log_group_name,
    destination_bucket,
    destination_prefix,
    from_ms,
    to_ms,
    task_name,
):
    # 外部API呼び出しはstep内で実行し、返されたタスクIDを保存します

    try:
        response = logs_client.create_export_task(
            taskName=task_name,
            logGroupName=log_group_name,
            fromTime=from_ms,
            to=to_ms,
            destination=destination_bucket,
            destinationPrefix=destination_prefix,
        )
    except ClientError as error:
        error_code = error.response.get("Error", {}).get("Code", "Unknown")
        step_context.logger.error("CreateExportTask failed: %s", error_code)
        raise

    return {
        "taskId": response["taskId"],
        "taskName": task_name,
        "from": from_ms,
        "to": to_ms,
    }


@durable_step
def check_export_status(step_context, task_id):
    # taskIdで対象を一件に絞り、CloudWatch Logs側の状態を返します
    response = logs_client.describe_export_tasks(taskId=task_id)
    tasks = response.get("exportTasks", [])
    if not tasks:
        return {"status": "UNKNOWN", "message": "Export task was not found"}

    task = tasks[0]
    status = task["status"]
    execution_info = task.get("executionInfo", {})
    return {
        "status": status["code"],
        "message": status.get("message", ""),
        "creationTime": execution_info.get("creationTime"),
        "completionTime": execution_info.get("completionTime"),
    }


@durable_step
def read_incoming_bytes(step_context, log_group_name, first_timestamp):
    # CloudWatchのIncomingBytesをロググループ単位で合計します
    start_time = datetime.fromtimestamp(
        (first_timestamp - 120_000) / 1000,
        tz=timezone.utc,
    )
    end_time = datetime.now(timezone.utc) + timedelta(minutes=1)
    response = cloudwatch_client.get_metric_statistics(
        Namespace="AWS/Logs",
        MetricName="IncomingBytes",
        Dimensions=[{"Name": "LogGroupName", "Value": log_group_name}],
        StartTime=start_time,
        EndTime=end_time,
        Period=60,
        Statistics=["Sum"],
    )
    observed_bytes = int(sum(point["Sum"] for point in response.get("Datapoints", [])))
    return {"incomingBytes": observed_bytes}


@durable_step
def summarize_s3_objects(step_context, bucket_name, prefix):
    # エクスポート先プレフィックス配下の個数と容量を合計します
    object_count = 0
    compressed_bytes = 0
    paginator = s3_client.get_paginator("list_objects_v2")
    for page in paginator.paginate(Bucket=bucket_name, Prefix=prefix):
        for item in page.get("Contents", []):
            object_count += 1
            compressed_bytes += item["Size"]
    return {
        "objectCount": object_count,
        "s3CompressedBytes": compressed_bytes,
    }


@durable_step
def write_benchmark_result(step_context, bucket_name, prefix, result):
    # 集計値を後から比較できるよう、サイズ別プレフィックスへJSONで残します
    s3_client.put_object(
        Bucket=bucket_name,
        Key=f"{prefix}/benchmark-result.json",
        Body=json.dumps(result, ensure_ascii=False, indent=2).encode("utf-8"),
        ContentType="application/json",
    )
    return {"resultKey": f"{prefix}/benchmark-result.json"}


NO_RETRY_CONFIG = StepConfig(
    step_semantics=StepSemantics.AT_MOST_ONCE_PER_RETRY,
    retry_strategy=lambda error, attempt: RetryDecision.no_retry(),
)


@durable_execution
def lambda_handler(event, context: DurableContext):
    # 入力値だけを使う処理は、再生されても同じ結果になるように保ちます
    log_group_name = event["logGroupName"]
    destination_bucket = event["destinationBucket"]
    destination_prefix = event.get("destinationPrefix", "durable-export")
    target_gb = int(event.get("targetGb", 0))
    export_task_name = event["exportTaskName"]

    configured_api_bytes = 0
    configured_message_bytes = 0
    generated_event_count = 0

    if target_gb > 0:
        stream_name = event["streamName"]
        target_bytes = target_gb * 1_000_000_000
        context.step(
            create_benchmark_stream(log_group_name, stream_name),
            config=NO_RETRY_CONFIG,
        )

        first_timestamp = None
        last_timestamp = None
        part_count = math.ceil(target_bytes / GENERATION_PART_BYTES)
        for part_number in range(1, part_count + 1):
            bytes_this_part = min(
                GENERATION_PART_BYTES,
                target_bytes - configured_api_bytes,
            )
            part = context.step(
                write_benchmark_part(
                    log_group_name,
                    stream_name,
                    part_number,
                    bytes_this_part,
                ),
                config=NO_RETRY_CONFIG,
            )
            configured_api_bytes += part["apiBytes"]
            configured_message_bytes += part["messageBytes"]
            generated_event_count += part["eventCount"]
            first_timestamp = first_timestamp or part["firstTimestamp"]
            last_timestamp = part["lastTimestamp"]

            context.wait(
                duration=Duration.from_seconds(1),
                name=f"checkpoint-after-generation-part-{part_number}",
            )

        # IncomingBytesがメトリクスへ現れるまで少し待ちます
        context.wait(
            duration=Duration.from_minutes(3),
            name="wait-for-incoming-bytes-metric",
        )
        from_ms = first_timestamp - 1_000
        to_ms = last_timestamp + 1_000
    else:
        lookback_minutes = int(event.get("lookbackMinutes", 60))
        to_ms = int(event["toMs"])
        from_ms = to_ms - (lookback_minutes * 60 * 1000)
        first_timestamp = from_ms

    export = context.step(
        start_export_task(
            log_group_name,
            destination_bucket,
            destination_prefix,
            from_ms,
            to_ms,
            export_task_name,
        ),
        config=NO_RETRY_CONFIG,
    )

    context.logger.info("Export task started: %s", export["taskId"])

    # 15分の単一呼び出し上限を確実に超えるため、まず16分サスペンドします
    context.wait(
        duration=Duration.from_minutes(16),
        name="wait-beyond-fifteen-minutes",
    )

    for poll_number in range(1, 721):
        result = context.step(check_export_status(export["taskId"]))
        status = result["status"]
        context.logger.info("Poll %d: %s", poll_number, status)

        if status == "COMPLETED":
            s3_summary = context.step(
                summarize_s3_objects(destination_bucket, destination_prefix)
            )
            incoming = context.step(
                read_incoming_bytes(log_group_name, first_timestamp)
            )
            creation_time = result.get("creationTime")
            completion_time = result.get("completionTime")
            export_duration_ms = None
            if creation_time is not None and completion_time is not None:
                export_duration_ms = completion_time - creation_time

            final_result = {
                **export,
                "status": status,
                "creationTime": creation_time,
                "completionTime": completion_time,
                "exportDurationMs": export_duration_ms,
                "pollCount": poll_number,
                "targetGb": target_gb,
                "configuredApiBytes": configured_api_bytes,
                "configuredMessageBytes": configured_message_bytes,
                "generatedEventCount": generated_event_count,
                "incomingBytes": incoming["incomingBytes"],
                **s3_summary,
            }

            result_location = context.step(
                write_benchmark_result(
                    destination_bucket,
                    destination_prefix,
                    final_result,
                )
            )
            return {**final_result, **result_location}

        if status in {"CANCELLED", "FAILED"}:
            raise RuntimeError(
                f"Export task ended with {status}: {result.get('message', '')}"
            )

        # CloudWatch Logs側の完了時刻はミリ秒で取得できるため、確認間隔は1分にします
        context.wait(
            duration=Duration.from_minutes(1),
            name=f"wait-before-poll-{poll_number + 1}",
        )

    raise TimeoutError("Export task did not finish within the polling window")

コードエディタへ完全版を貼り付け、Deployを選びます。PythonのLambdaランタイムには開発用のDurable Execution SDKが含まれています。本番のデプロイパッケージではSDKのバージョンを固定してください。

CreateExportTaskにはクライアント側の冪等性トークンがないため、開始ステップのDurable SDK側の自動再試行を無効にしています。開始直後に応答を失った場合は、既存タスクを確認してから対応します。

4-3. バージョンを発行して非同期で呼び出す

コードを固定するため、バージョンを発行してからイベントを設定します。

発行済みバージョンと非同期イベントの設定

発行済みのバージョン: 1で、呼び出しタイプを非同期にします。同期呼び出しは実行全体が15分以内に制限されるため、今回の16分待機には使いません。イベントJSONは次の内容にします。

{
  "logGroupName": "<LOG_GROUP_NAME>",
  "streamName": "<LOG_STREAM_NAME>",
  "destinationBucket": "<YOUR_EXPORT_BUCKET>",
  "destinationPrefix": "durable-export/01gb",
  "exportTaskName": "<EXPORT_TASK_NAME>",
  "targetGb": 1
}

targetGbは生成する容量、streamNameは新しく作るログストリーム、exportTaskNameはエクスポートタスクの固有名です。ロググループ名とバケット名が、3章の両ポリシーと一致することを確認して保存し、非同期で呼び出します。

5. 16分後の再開とS3出力を確認する

疑似ログの生成後、エクスポートの開始ステップが完了すると、wait-beyond-fifteen-minutesで待機します。16分後の再開後にタスクの状態を確認し、未完了なら1分待って再確認します。

1GBの実行記録では、エクスポートは58.318秒、関数の開始から結果までが21分19.056秒でした。全体にはログ生成やメトリクス反映の待機も含まれます。

1GBの実行全体、固定待機、エクスポート単体の所要時間

結果から確認に使う値を抜粋します。exportDurationMsはCloudWatch Logsが返したcompletionTimeとcreationTimeの差です。

{
  "status": "COMPLETED",
  "targetGb": 1,
  "exportDurationMs": 58318,
  "objectCount": 10,
  "s3CompressedBytes": 753183698
}

S3では、指定したdurable-export/01gb配下のgzipファイルとbenchmark-result.jsonを確認します。上のオブジェクト数と容量は、結果JSONを書き込む前にプレフィックス配下を集計した値です。

1分の呼び出しタイムアウトで、16分の固定待機を挟んだ後も結果を取得できました。チェックポイントへ保存したtaskIdを使って、後続の状態確認とS3集計へ戻れたことが確認できます。

6. 1〜30GBの追加測定

同じ処理で容量を増やしたときのエクスポート時間も測定しました。測定日は2026年8月14日、条件は2章と同じです。

1GBから30GBまで8回のエクスポート所要時間

8回とも15分未満でした。30GBの再測定は初回より2分40.929秒短く、20GBの2回より短い結果です。この記録から、容量に比例する固定の処理速度は求められません。

20GBの初回はエクスポート完了後のS3一覧取得で失敗し、再実行では結果JSONの保存に失敗しました。再実行もエクスポート本体は完了しています。再実行は既存20GBを再利用し、30GBは既存20GBへ10GBを追加したデータを2回書き出しました。

エクスポート時間と実行全体の記録
容量と回 開始から結果/エラーまで エクスポート時間 S3集計
1GB 21分19.056秒 58.318秒 10個、753,183,698バイト
3GB 25分42.382秒 2分24.975秒 35個、2,259,552,374バイト
5GB 30分04.467秒 3分25.245秒 68個、3,765,920,682バイト
10GB 40分51.032秒 9分26.079秒 137個、7,531,839,241バイト
20GB、初回 1時間05分27.055秒 13分07.302秒 集計前に停止
20GB、復旧実行 17分28.896秒 14分26.862秒 301個、15,063,677,936バイト
30GB、初回 16分04.249秒 12分50.980秒 432個、22,595,516,921バイト
30GB、再測定 16分03.803秒 10分10.051秒 432個、22,595,516,921バイト

20GB初回の実行全体には、疑似ログ生成、100MBごとのチェックポイント、3分のメトリクス待機、固定16分待機が含まれます。エクスポート本体は13分07.302秒でした。

測定全体で投入したログは合計49GBで、取り込み費用は無料枠適用前の単価計算で約37.24USDです。疑似データによる一時点の観測値です。

7. 運用時の待機方法と制約

運用では当たり前ですが、固定16分を使わず、短いDurable待機を挟んで状態を確認します。context.wait_for_condition()を使えば、条件確認と待機、バックオフをまとめて記述できます。

CloudWatch Logsの実行中または保留中のエクスポートタスクは、1アカウント・1リージョンにつき1件です。ログがエクスポート可能になるまで最大12時間かかる場合があり、タスクは24時間でタイムアウトします。複数のロググループを扱う場合は直列で実行し、最大待機時間を決めます。

継続的だったりリアルタイム性が必要なログアーカイブには、CloudWatch Logsのサブスクリプションフィルターを検討することになるかと思います。

おわりに

ここまでお付き合いいただきありがとうございます。

Durable FunctionsとCloudWatch LogsのS3エクスポートを組み合わせた試みでした。

ではまた、お会いしましょう。

参考リンク

Lambda Durable Functions

CloudWatch Logs、IAM、料金

関連記事

0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?