LoginSignup
1
0

More than 5 years have passed since last update.

Terraform を 使って OCI File Storage Serviceのマウント (2018/12/19)

Last updated at Posted at 2018-12-18

この記事は「Oracle Cloud その2 Advent Calendar 2018」の12月19日の記事として書かれています。

コードを使用してインフラストラクチャのトポロジを定義するHashicorp社のTerraformを使ってOracle Cloud InfrastructureでInfrastructure as Code(IaC)を実現することができます。

この記事では、Terraformを使ってOCI File Storage ServiceにLinuxインスタンスからマウントしてみたことを紹介します。

今回構築する構成

image04.jpg
Oracle LinuxのVMインスタンス2つから同じFile Storageをマウントする構成を以下のファイルから作成します。

ファイル名 内容
data_sources.tf 作成したリソース情報の取得
export.tf File Storage Exportの作成
export_set.tf File Storage ExportSetの作成
file_system.tf File Storageの作成
instance.tf Computeインスタンスの作成
mount_target.tf File Storage Mount Targetの作成
network.tf VCN/InternetGateway/RouteTableの作成
provider.tf Terraform OCI Provider設定
security_list.tf セキュリティリストの作成
variables.tf 変数・値の定義

(すべてのファイルは本ページの最下部にまとめて記載/env-vars.ps1を除く)

準備作業

環境を構築をする上で、使用する環境の情報および作成するインスタンスへの接続に必要な情報を準備します。
準備に必要な情報は
Oracle Cloud Advent Calendar 2018」の12月10日の記事
Terraform を 使って Oracle Cloud環境構築 (2018/12/10)」を参照し、Terraform の動作テスト~環境変数の設定まで実施してください。

ネットワーク構築

VCN,Subnet,Internet Gateway,Route Tableを作成(network.tf)
NFS通信を有効とするSecurity Listを作成します。

security_list.tf
# Protocols are specified as protocol numbers.
# http://www.iana.org/assignments/protocol-numbers/protocol-numbers.xhtml

resource "oci_core_security_list" "TF_security_list" {
  compartment_id = "${var.compartment_ocid}"
  display_name   = "TF_security_list"
  vcn_id         = "${oci_core_virtual_network.TF_vcn.id}"

  // Allow all outbound requests
  egress_security_rules = [
    {
      destination = "0.0.0.0/0"
      protocol    = "all"
    },
  ]

  // See https://docs.us-phoenix-1.oraclecloud.com/Content/File/Tasks/creatingfilesystems.htm.
  // Specific security list rules are required to allow mount targets to work properly.
  ingress_security_rules = [
    {
      // Allowing inbound SSH traffic to instances in the subnet from any source
      protocol = "6"
      source   = "0.0.0.0/0"

      tcp_options {
        "min" = 22
        "max" = 22
      }
    },
    {
      // Allowing inbound ICMP traffic of a specific type and code from any source
      protocol = 1
      source   = "0.0.0.0/0"

      icmp_options {
        "type" = 3
        "code" = 4
      }
    },
    {
      // Allowing inbound ICMP traffic of a specific type from within our VCN
      protocol = 1
      source   = "${var.TF_vcn-cidr}"

      icmp_options {
        "type" = 3
      }
    },
  ]
}

File Storage、Mount Target、Storage export / export setの作成

変数の追加

作成するリソースの名前、パス、サイズを指定します。

variables.tf
variable "file_system_1_display_name" {
  default = "TF_fs_1"
}

variable "mount_target_1_display_name" {
  default = "my_mount_target_1"
}

variable "export_path_fs1_mt1" {
  default = "/mnt/fs1"
}

variable "export_set_name_1" {
  default = "export set for mount target 1"
}

variable "max_byte" {
  default = 23843202333
}

variable "max_files" {
  default = 223442
}

variable "export_read_write_access_source" {
  default = "10.0.0.0/8"
}

variable "export_read_only_access_source" {
  default = "0.0.0.0/0"
}

variable "export_path_fs1_mt1" {
  default = "/mnt/fs1"
}

variable "export_set_name_1" {
  default = "export set for mount target 1"
}
File Storage の作成
file_system.tf
resource "oci_file_storage_file_system" "TF_fs_1" {
  #Required
  availability_domain = "${lookup(data.oci_identity_availability_domains.ADs.availability_domains[var.availability_domain - 1],"name")}"
  compartment_id      = "${var.compartment_ocid}"

  #Optional
  display_name = "${var.file_system_1_display_name}"
}
Mount Targetの作成
mount_target.tf
resource "oci_file_storage_mount_target" "TF_mount_target_1" {
  #Required
  availability_domain = "${lookup(data.oci_identity_availability_domains.ADs.availability_domains[var.availability_domain - 1],"name")}"
  compartment_id      = "${var.compartment_ocid}"
  subnet_id           = "${oci_core_subnet.TF_subnet.id}"

  #Optional
  display_name = "${var.mount_target_1_display_name}"
}
export / export set の作成
export.tf
resource "oci_file_storage_export" "TF_export_fs1_mt1" {
  #Required
  export_set_id  = "${oci_file_storage_export_set.TF_export_set_1.id}"
  file_system_id = "${oci_file_storage_file_system.TF_fs_1.id}"
  path           = "${var.export_path_fs1_mt1}"

  export_options = [
    {
      source                         = "${var.export_read_write_access_source}"
      access                         = "READ_WRITE"
      identity_squash                = "NONE"
      require_privileged_source_port = true
    },
    {
      source                         = "${var.export_read_only_access_source}"
      access                         = "READ_ONLY"
      identity_squash                = "ALL"
      require_privileged_source_port = true
    },
  ]
}
export_set.tf
resource "oci_file_storage_export_set" "TF_export_set_1" {
  # Required
  mount_target_id = "${oci_file_storage_mount_target.TF_mount_target_1.id}"

  # Optional
  display_name      = "${var.export_set_name_1}"
  max_fs_stat_bytes = "${var.max_byte}"
  max_fs_stat_files = "${var.max_files}"
}

インスタンス の作成とFile Storageのマウント

変数の追加(Linux VM/VM.Standard2.1 シェイプ を 2つ作成)

variables.tf
variable "NumInstances" {
  default = "2"
}
variable "instance_image_ocid" {
  type = "map"

  default = {
    // See https://docs.us-phoenix-1.oraclecloud.com/images/
    // Oracle-provided image "Oracle-Linux-7.5-2018.05.09-1"
    us-ashburn-1 = "ocid1.image.oc1.iad.aaaaaaaaXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
  }
}

variable "instance_shape" {
  default = "VM.Standard2.1"
}

locals {
  mount_target_1_ip_address = "${lookup(data.oci_core_private_ips.ip_mount_target1.private_ips[0], "ip_address")}"
}

NumInstances」で作成するインスタンスの数を指定します。
そのほか作成するインスタンスのシェイプ、イメージを指定します。

data_sources.tfへの追加

data_sources.tf
# Gets the list of file systems in the compartment
data "oci_file_storage_file_systems" "file_systems" {
  availability_domain = "${lookup(data.oci_identity_availability_domains.ADs.availability_domains[var.availability_domain - 1],"name")}"
  compartment_id      = "${var.compartment_ocid}"
}

# Gets the list of mount targets in the compartment
data "oci_file_storage_mount_targets" "mount_targets" {
  availability_domain = "${lookup(data.oci_identity_availability_domains.ADs.availability_domains[var.availability_domain - 1],"name")}"
  compartment_id      = "${var.compartment_ocid}"
}

# Gets the list of exports in the compartment
data "oci_file_storage_exports" "exports" {
  compartment_id = "${var.compartment_ocid}"
}

# Gets a list of snapshots for a particular file system
data "oci_file_storage_snapshots" "snapshots" {
  file_system_id = "${oci_file_storage_file_system.TF_fs_1.id}"
}

# Gets a list of export sets in a compartment and availability domain
data "oci_file_storage_export_sets" "export_sets" {
  availability_domain = "${lookup(data.oci_identity_availability_domains.ADs.availability_domains[var.availability_domain - 1],"name")}"
  compartment_id      = "${var.compartment_ocid}"
}

data "oci_core_private_ips" ip_mount_target1 {
  subnet_id = "${oci_file_storage_mount_target.TF_mount_target_1.subnet_id}"

  filter {
    name   = "id"
    values = ["${oci_file_storage_mount_target.TF_mount_target_1.private_ip_ids.0}"]
  }
}

作成したFile Storageに関してマウントに必要な情報を取得します。

instance.tf
resource "oci_core_instance" "TF_instance" {
  count               = "${var.NumInstances}"
  availability_domain = "${lookup(data.oci_identity_availability_domains.ADs.availability_domains[var.availability_domain - 1],"name")}"
  compartment_id      = "${var.compartment_ocid}"
  display_name        = "TF instance${count.index}"
  hostname_label      = "TFinstance${count.index}"
  shape               = "${var.instance_shape}"
  subnet_id           = "${oci_core_subnet.TF_subnet.id}"

  metadata {
    ssh_authorized_keys = "${var.ssh_public_key}"
  }

  source_details {
    source_type = "image"
    source_id   = "${var.instance_image_ocid[var.region]}"
  }

  timeouts {
    create = "60m"
  }

}

resource "null_resource" "mount_fss_on_instance" {
  depends_on = ["oci_core_instance.TF_instance",
    "oci_file_storage_export.TF_export_fs1_mt1",
  ]
  count               = "${var.NumInstances}"

  provisioner "remote-exec" {
    connection {
      agent       = false
      timeout     = "15m"
      host        = "${oci_core_instance.TF_instance.*.public_ip[count.index % var.NumInstances]}"
      user        = "opc"
      private_key = "${var.ssh_private_key}"
    }

    inline = [
      "sudo yum -y install nfs-utils > nfs-utils-install.log",
      "sudo mkdir -p /mnt/fs1",
      "sudo mount ${local.mount_target_1_ip_address}:${var.export_path_fs1_mt1} ${var.export_path_fs1_mt1}",
    ]
  }

}

resource」 「null_resource」でOS起動後にマウント用のディレクトリ作成、マウントの実行をするコマンドを記述します。

環境構築の実行

すべてのファイルが用意できたら以下のコマンドを実行し環境を作成します。

  • terraform init
  • terraform plan -out plan01
  • terraform apply "plan01"

作成されたインスタンスの確認

instance0 への接続

10.0.1.5:/mnt/fs1 が /mnt/fs1 にマウントされていることを確認

[opc@tfinstance0 ~]$ df -h
Filesystem         Size  Used Avail Use% Mounted on
devtmpfs           7.2G     0  7.2G   0% /dev
tmpfs              7.3G     0  7.3G   0% /dev/shm
tmpfs              7.3G  8.6M  7.3G   1% /run
tmpfs              7.3G     0  7.3G   0% /sys/fs/cgroup
/dev/sda3           39G  2.0G   37G   6% /
/dev/sda1          512M  9.8M  502M   2% /boot/efi
10.0.1.5:/mnt/fs1   23G     0   23G   0% /mnt/fs1
tmpfs              1.5G     0  1.5G   0% /run/user/1000
instance1 への接続

10.0.1.5:/mnt/fs1 が /mnt/fs1 にマウントされていることを確認

[opc@tfinstance1 ~]$ df -h
Filesystem         Size  Used Avail Use% Mounted on
devtmpfs           7.2G     0  7.2G   0% /dev
tmpfs              7.3G     0  7.3G   0% /dev/shm
tmpfs              7.3G  8.6M  7.3G   1% /run
tmpfs              7.3G     0  7.3G   0% /sys/fs/cgroup
/dev/sda3           39G  2.0G   37G   6% /
/dev/sda1          512M  9.8M  502M   2% /boot/efi
10.0.1.5:/mnt/fs1   23G     0   23G   0% /mnt/fs1
tmpfs              1.5G     0  1.5G   0% /run/user/1000
instance0 で作成したディレクトリに instance1 でファイルを作成し、そのファイルが instance0 で 確認できることを確認
[opc@tfinstance0 ~]$ ls /mnt/fs1
[opc@tfinstance0 ~]$ sudo mkdir /mnt/fs1/test01
[opc@tfinstance1 ~]$ ls /mnt/fs1/
test01
[opc@tfinstance1 ~]$ sudo touch /mnt/fs1/test01/sample.txt
[opc@tfinstance0 ~]$ ls /mnt/fs1/test01/
sample.txt

おわりに

terraform を使って 複数インスタンスの共有用ファイルシステムとしてFile Storage Serviceがマウントできることを確認しました。

data_sources.tf
# Gets the list of file systems in the compartment
data "oci_file_storage_file_systems" "file_systems" {
  #Required
  availability_domain = "${lookup(data.oci_identity_availability_domains.ADs.availability_domains[var.availability_domain - 1],"name")}"
  compartment_id      = "${var.compartment_ocid}"

  #Optional fields. Used by the service to filter the results when returning data to the client.
  #display_name = "TF_fs_1"
  #id = "ocid1.filesystem.oc1.phx.aaaaaaaaXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
  #state = "DELETED"
}

# Gets the list of mount targets in the compartment
data "oci_file_storage_mount_targets" "mount_targets" {
  #Required
  availability_domain = "${lookup(data.oci_identity_availability_domains.ADs.availability_domains[var.availability_domain - 1],"name")}"
  compartment_id      = "${var.compartment_ocid}"

  #Optional fields. Used by the service to filter the results when returning data to the client.
  #display_name = "${var.mount_target_display_name}"
  #export_set_id = "${var.mount_target_export_set_id}"
  #id = "${var.mount_target_id}"
  #state = "${var.mount_target_state}"
}

# Gets the list of exports in the compartment
data "oci_file_storage_exports" "exports" {
  #Required
  compartment_id = "${var.compartment_ocid}"

  #Optional fields. Used by the service to filter the results when returning data to the client.
  #export_set_id = "${oci_file_storage_mount_target.TF_mount_target_1.export_set_id}"
  #file_system_id = "${oci_file_storage_file_system.TF_fs.id}"
  #id = "${var.export_id}"
  #state = "${var.export_state}"
}

# Gets a list of snapshots for a particular file system
data "oci_file_storage_snapshots" "snapshots" {
  #Required
  file_system_id = "${oci_file_storage_file_system.TF_fs_1.id}"

  #Optional fields. Used by the service to filter the results when returning data to the client.
  #id = "${var.snapshot_id}"
  #state = "${var.snapshot_state}"
}

# Gets a list of export sets in a compartment and availability domain
data "oci_file_storage_export_sets" "export_sets" {
  #Required
  availability_domain = "${lookup(data.oci_identity_availability_domains.ADs.availability_domains[var.availability_domain - 1],"name")}"
  compartment_id      = "${var.compartment_ocid}"

  #Optional fields. Used by the service to filter the results when returning data to the client.
  #display_name = "${var.export_set_display_name}"
  #id = "${var.export_set_id}"
  #state = "${var.export_set_state}"
}

data "oci_core_private_ips" ip_mount_target1 {
  subnet_id = "${oci_file_storage_mount_target.TF_mount_target_1.subnet_id}"

  filter {
    name   = "id"
    values = ["${oci_file_storage_mount_target.TF_mount_target_1.private_ip_ids.0}"]
  }
}
export.tf
resource "oci_file_storage_export" "TF_export_fs1_mt1" {
  #Required
  export_set_id  = "${oci_file_storage_export_set.TF_export_set_1.id}"
  file_system_id = "${oci_file_storage_file_system.TF_fs_1.id}"
  path           = "${var.export_path_fs1_mt1}"

  export_options = [
    {
      source                         = "${var.export_read_write_access_source}"
      access                         = "READ_WRITE"
      identity_squash                = "NONE"
      require_privileged_source_port = true
    },
    {
      source                         = "${var.export_read_only_access_source}"
      access                         = "READ_ONLY"
      identity_squash                = "ALL"
      require_privileged_source_port = true
    },
  ]
}

export_set.tf
resource "oci_file_storage_export_set" "TF_export_set_1" {
  # Required
  mount_target_id = "${oci_file_storage_mount_target.TF_mount_target_1.id}"

  # Optional
  display_name      = "${var.export_set_name_1}"
  max_fs_stat_bytes = "${var.max_byte}"
  max_fs_stat_files = "${var.max_files}"
}

file_system.tf
resource "oci_file_storage_file_system" "TF_fs_1" {
  #Required
  availability_domain = "${lookup(data.oci_identity_availability_domains.ADs.availability_domains[var.availability_domain - 1],"name")}"
  compartment_id      = "${var.compartment_ocid}"

  #Optional
  display_name = "${var.file_system_1_display_name}"
}
instance.tf
resource "oci_core_instance" "TF_instance" {
  count               = "${var.NumInstances}"
  availability_domain = "${lookup(data.oci_identity_availability_domains.ADs.availability_domains[var.availability_domain - 1],"name")}"
  compartment_id      = "${var.compartment_ocid}"
  display_name        = "TF instance${count.index}"
  hostname_label      = "TFinstance${count.index}"
  shape               = "${var.instance_shape}"
  subnet_id           = "${oci_core_subnet.TF_subnet.id}"

  metadata {
    ssh_authorized_keys = "${var.ssh_public_key}"
  }

  source_details {
    source_type = "image"
    source_id   = "${var.instance_image_ocid[var.region]}"
  }

  timeouts {
    create = "60m"
  }

}

resource "null_resource" "mount_fss_on_instance" {
  depends_on = ["oci_core_instance.TF_instance",
    "oci_file_storage_export.TF_export_fs1_mt1",
  ]
  count               = "${var.NumInstances}"

  provisioner "remote-exec" {
    connection {
      agent       = false
      timeout     = "15m"
      host        = "${oci_core_instance.TF_instance.*.public_ip[count.index % var.NumInstances]}"
      user        = "opc"
      private_key = "${var.ssh_private_key}"
    }

    inline = [
      "sudo yum -y install nfs-utils > nfs-utils-install.log",
      "sudo mkdir -p /mnt/fs1",
      "sudo mount ${local.mount_target_1_ip_address}:${var.export_path_fs1_mt1} ${var.export_path_fs1_mt1}",
    ]
  }

}
mount_target.tf
resource "oci_file_storage_mount_target" "TF_mount_target_1" {
  #Required
  availability_domain = "${lookup(data.oci_identity_availability_domains.ADs.availability_domains[var.availability_domain - 1],"name")}"
  compartment_id      = "${var.compartment_ocid}"
  subnet_id           = "${oci_core_subnet.TF_subnet.id}"

  #Optional
  display_name = "${var.mount_target_1_display_name}"
}

network.tf
resource "oci_core_virtual_network" "TF_vcn" {
  cidr_block     = "${var.TF_vcn-cidr}"
  dns_label      = "TFvcn"
  compartment_id = "${var.compartment_ocid}"
  display_name   = "TFvcn"
  dns_label      = "TFvcn"
}

resource "oci_core_internet_gateway" "TF_internet_gateway" {
  compartment_id = "${var.compartment_ocid}"
  display_name   = "TF internet gateway"
  vcn_id         = "${oci_core_virtual_network.TF_vcn.id}"
}

resource "oci_core_route_table" "TF_route_table" {
  compartment_id = "${var.compartment_ocid}"
  vcn_id         = "${oci_core_virtual_network.TF_vcn.id}"
  display_name   = "TF route table"

  route_rules {
    destination       = "0.0.0.0/0"
    destination_type  = "CIDR_BLOCK"
    network_entity_id = "${oci_core_internet_gateway.TF_internet_gateway.id}"
  }
}

resource "oci_core_subnet" "TF_subnet" {
  availability_domain = "${lookup(data.oci_identity_availability_domains.ADs.availability_domains[var.availability_domain - 1],"name")}"
  cidr_block          = "${var.TF_subnet_cidr}"
  display_name        = "TFsubnet"
  dns_label           = "TFsubnet"
  compartment_id      = "${var.compartment_ocid}"
  vcn_id              = "${oci_core_virtual_network.TF_vcn.id}"
  security_list_ids   = ["${oci_core_security_list.TF_security_list.id}"]
  route_table_id      = "${oci_core_route_table.TF_route_table.id}"
}
provider.tf
provider "oci" {
  version          = ">= 3.0.0"
  tenancy_ocid     = "${var.tenancy_ocid}"
  user_ocid        = "${var.user_ocid}"
  fingerprint      = "${var.fingerprint}"
  private_key_path = "${var.private_key_path}"
  region           = "${var.region}"
}
security_list.tf
# Protocols are specified as protocol numbers.
# http://www.iana.org/assignments/protocol-numbers/protocol-numbers.xhtml

resource "oci_core_security_list" "TF_security_list" {
  compartment_id = "${var.compartment_ocid}"
  display_name   = "TF_security_list"
  vcn_id         = "${oci_core_virtual_network.TF_vcn.id}"

  // Allow all outbound requests
  egress_security_rules = [
    {
      destination = "0.0.0.0/0"
      protocol    = "all"
    },
  ]

  // See https://docs.us-phoenix-1.oraclecloud.com/Content/File/Tasks/creatingfilesystems.htm.
  // Specific security list rules are required to allow mount targets to work properly.
  ingress_security_rules = [
    {
      protocol = "6"
      source   = "${var.TF_vcn-cidr}"

      tcp_options {
        "min" = 2048
        "max" = 2050
      }
    },
    {
      protocol = "6"
      source   = "${var.TF_vcn-cidr}"

      tcp_options {
        source_port_range {
          "min" = 2048
          "max" = 2050
        }
      }
    },
    {
      protocol = "6"
      source   = "${var.TF_vcn-cidr}"

      tcp_options {
        "min" = 111
        "max" = 111
      }
    },
    {
      // Allowing inbound SSH traffic to instances in the subnet from any source
      protocol = "6"
      source   = "0.0.0.0/0"

      tcp_options {
        "min" = 22
        "max" = 22
      }
    },
    {
      // Allowing inbound ICMP traffic of a specific type and code from any source
      protocol = 1
      source   = "0.0.0.0/0"

      icmp_options {
        "type" = 3
        "code" = 4
      }
    },
    {
      // Allowing inbound ICMP traffic of a specific type from within our VCN
      protocol = 1
      source   = "${var.TF_vcn-cidr}"

      icmp_options {
        "type" = 3
      }
    },
  ]
}
variables.tf
variable "tenancy_ocid" {}
variable "user_ocid" {}
variable "fingerprint" {}
variable "private_key_path" {}
variable "region" {}

variable "compartment_ocid" {}

# Refer https://docs.us-phoenix-1.oraclecloud.com/Content/Compute/Tasks/managingkeypairs.htm on how to setup SSH key pairs for compute instances
variable "ssh_public_key" {}

variable "ssh_private_key" {}

variable "NumInstances" {
  default = "2"
}

# Choose an Availability Domain
variable "availability_domain" {
  default = "2"
}

variable "TF_vcn-cidr" {
  default = "10.0.0.0/16"
}

variable "TF_subnet_cidr" {
  default = "10.0.1.0/24"
}

variable "file_system_1_display_name" {
  default = "TF_fs_1"
}

variable "mount_target_1_display_name" {
  default = "my_mount_target_1"
}

variable "export_path_fs1_mt1" {
  default = "/mnt/fs1"
}

variable "export_set_name_1" {
  default = "export set for mount target 1"
}

variable "max_byte" {
  default = 23843202333
}

variable "max_files" {
  default = 223442
}

variable "export_read_write_access_source" {
  default = "10.0.0.0/8"
}

variable "export_read_only_access_source" {
  default = "0.0.0.0/0"
}

variable "instance_image_ocid" {
  type = "map"

  default = {
    // See https://docs.us-phoenix-1.oraclecloud.com/images/
    // Oracle-provided image "Oracle-Linux-7.5-2018.05.09-1"
    eu-frankfurt-1 = "ocid1.image.oc1.eu-frankfurt-1.aaaaaaaazregkysspxnktw35k4r5vzwurxk6myu44umqthjeakbkvxvxdlkq"

    us-ashburn-1 = "ocid1.image.oc1.iad.aaaaaaaa6ybn2lkqp2ejhijhehf5i65spqh3igt53iyvncyjmo7uhm5235ca"
    uk-london-1  = "ocid1.image.oc1.uk-london-1.aaaaaaaayodsld656eh5stds5mo4hrmwuhk2ugin4eyfpgoiiskqfxll6a4a"
    us-phoenix-1 = "ocid1.image.oc1.phx.aaaaaaaaozjbzisykoybkppaiwviyfzusjzokq7jzwxi7nvwdiopk7ligoia"
  }
}

variable "instance_shape" {
  default = "VM.Standard2.1"
}

locals {
  mount_target_1_ip_address = "${lookup(data.oci_core_private_ips.ip_mount_target1.private_ips[0], "ip_address")}"
}
1
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
1
0