Help us understand the problem. What is going on with this article?

Oracle Cloudで始めるTerraform 自動化の真骨頂

はじめに

terraform Advent Calendar 2019 6日目:christmas_tree:です。

本記事は、Oracle Cloud環境におけるTerraform自動化についての記事になります。

使用するクラウドインフラストラクチャはOracle Cloudになりますが、tfファイルの基本的な書き方は変わらないため、汎用的な考え方は他のクラウドでも流用できるでしょう。

本記事では宣言的な構成管理にはじまり、Terraformの概要、基本的な使い方について解説し、以下の環境(※)をデプロイします。Terraformの威力をご賞味ください!

terraform.png

  • インスタンスはWebサーバ2台、DBサーバ1台、運用管理サーバ1台を構築
  • 業務LANと運用LANで分けるため、secondary_vnicを設定
  • ロードバランサーに自前のSSL証明書をインポートし、ラウンドロビン及びバックエンドの設定
  • WebサーバにはApache、DBサーバはPostgreSQLをインストール

(※)本記事で記載しているtfファイルはGitHubで公開しています。

宣言的な構成管理

Terraformを学ぶ前に、宣言的な構成管理構成管理ツールの目的を知りましょう。
Terraformの価値を最大限に引き出すことができます!

宣言的な構成管理

クラウドネイティブ(※)は従来のインフラリソースを抽象化し、クラウド環境においてアプリケーションを開発するアプローチです。

また、クラウドネイティブはクラウドインフラストラクチャを軸としているため、コンテナ型仮装技術やマイクロサービスなど最小コンポーネントに分割したアプリケーションと親和性が高いのが特徴です。アジャイルの様な短いサイクルのシステムの開発手法に対して柔軟な対応ができるため、高速なリリースが期待できます。

例えば、従来のオンプレミス環境におけるウォーターフォールの開発では、インフラリソースに対して以下のフェーズがあります。

  • 要件定義(非機能要件)の性能見積りでサーバリソースを決定
  • 基本設計及び詳細設計でシステム構成及びパラメータを設計
  • 構築及びテストを行いインフラ環境の完成

そして、リリース後の運用フェーズに入り、ITILに準じたサービスマネジメントを行う場合は構成管理と変更管理、リリース管理の各プロセスが発生します。

しかし、クラウドの民主化やコンテナ仮想化技術の浸透により、DevOpsやクラウドネイティブというバズワードが誕生し、構築から構成管理のライフサイクルを支える技術として、Puppet、Chefなどの構成管理ツールやKubernetes等のオーケストレーションツールが注目され、システムに対するアプローチは宣言的な手続きへと進化してきました。

今、クラウドネイティブにおけるインフラの概念は、宣言的な構成管理に考え方に基づくクラウドネイティブアーキテクチャと言えるでしょう。

宣言的な構成管理を前提とすることで、システムのアーキテクチャ設計時に運用設計も考慮することができます。かつては、運用が始まって発覚される顕在化された課題などについても、設計時に自動化の考慮ができます。また、スケーラブルを容易にします。

(※)クラウドネィティブという言葉は、人工知能と同じ様に言葉の意味として厳密に定義されていません。

構成管理ツールの目的

構成管理ツールの目的について考えます。

DevOpsの観点で大事なのは、構成管理ツールを導入することが目的ではなく、構成管理ツールという手段を活用にすることで開発プロセスをどれだけ最適化できるかが重要です。

以前、Ansibleとは何か 構成管理ツールの目的〜Ansible導入まで最速で理解するに書きましたが、構成管理ツールを導入する目的を明確にすることでやりたいことが見えてきます。

よって、構成管理ツールの目的を明確にし、メリットや運用後のフローを理解し予測できていないと、その恩恵を最大限に発揮できません。

従って、Terraformを導入することで、これまで手動で行っていたクラウドインフラストラクチャのリソース作成を自動化できます。また、Ansibleなどと組み合わせればOS及びMWセットアップを一気通貫で効率よく作業できます。

結果、Terraform等構成管理ツールを導入することで、これまで時間がかかっていた作業を劇的に短縮し、その時間を他の重要な課題などに当てることができます。

また、冪等性という観点で誰がやっても同じになるため、オペレーションミスを予防し品質を維持します。開発フェーズだけでなく、運用フェーズでも大いに機能します。

terraform自動化.png

本記事では、クラウドインフラストラクチャのリソース作成と、OS及びMWの初期セットアップについて、Terraformとcloud-initで行います。

Terraform概要

Terraformは、クラウドインフラストラクチャのリソースを管理するための構成管理ツールの一つです。

スクリーンショット 2019-12-05 10.46.43.png

DevOps及びInfrastructure as Code等現代のアプリケーション開発に必須なプロビジョニングを自動化するプロセスを提供します。

Terraformの使い方はとてもシンプルです。
tfファイルという定義ファイルに、インスタンスやネットワーク等リソース情報を記述し、terraformコマンドを実行するだけでクラウドインフラストラクチャが構築できます。

イメージとしては、アメリカではポピュラーなキッチン家電「スロークッカー」にとても似ています。食材をセットしてスイッチを押すと料理が出来上がるプロセスは、まさに同じです。

tfファイル.png

しかし、銀の弾丸ではありません。

銀の弾丸.png

Terraformで色々試していると、あれもこれもとやりたくなりますが、中にはクラウドプロパイダの仕様で、できないこともあります。

Terraformを導入する際は、たくさん検証を行い、できること及びできないことを明確にしておくとよいでしょう。

Terraform構築

Oracle Cloud環境におけるTerraformによる構築を行うためには、ユーザ作成を行い、必要なクレデンシャル情報を用意します。あとはTerraformをインストールし、Terraform実行に必要なtfファイルを用意します。

Oracle Cloud環境の前提条件を以下に記載します。

Terraformのインストール

本記事ではMacを例に解説します。

  1. はじめにDownload Terraformよりバイナリをダウンロードします。
  2. ダウンロード後、任意のディレクトリに展開します。
  3. .bashrcファイル等にパスを通します。以下はアプリケーション配下にパスを通す例です。
export PATH=$PATH:/Applications/

パスを通した後は以下のコマンドを実行し、バージョン情報が出力されればパスは通っています。

# terraform -v

Terraform v0.12.7

Your version of Terraform is out of date! The latest version
is 0.12.13. You can update by downloading from www.terraform.io/downloads.html

(※)上記は、使用しているTerraformのバージョンが古いため出力されています。新しいバージョンのTerraformを使用している場合は表示されません。

Terraform Provider for Oracle Cloud Infrastructure のインストール

後述するterraform init実行時に自動的にダウンロードされるため、個別のダウンロードは不要です。

tfファイルの作成

tfファイルについて解説します。

はじめに任意の作業ディレクトリに移動します。
本記事では以下のディレクトリ構成になり、カレントディレクトリはociディレクトリとします。

  • ディレクトリ構成
.
|-- common
|   |--  userdata
|        |-- cloud-init1.tpl
|        |-- cloud-init2.tpl
|   |-- compute.tf
|   |-- env-vars
|   |-- lb.tf
|   |-- network.tf
|   |-- provider.tf
|   |-- securitylist.tf
|-- oci_api_key.pem
|-- oci_api_key_public.pem
`-- ssh
    |-- id_rsa
    |-- id_rsa.pub
  • 各種ファイル説明
ファイル名 役割
cloud-init1.tpl Webサーバ用の初期構築スクリプト
cloud-init2.tpl DBサーバ用の初期構築スクリプト
compute.tf インスタンスのtfファイル
env-vars プロパイダーで使用する変数のtfファイル
lb.tf ロードバランサーのtfファイル
network.tf ネットワークのtfファイル
provider.tf プロパイダーのtfファイル
securitylist.tf セキュリティリストのtfファイル
oci_api_key.pem API秘密鍵
oci_api_key_public.pem API公開鍵
id_rsa SSH秘密鍵
id_rsa.pub SSH公開鍵

tfファイルを作成時のポイントについて以下に記載します。

  • tfファイル名は分かりやすくし、リソース毎に分割する
  • tfファイルの項目はデフォルトから変更がなければ記述しない
  • tfファイルの項目の順番は関係ないが、ルールを決めて分かりやすい順番で記述する
  • tfファイルの値を記述する際に、=の間は詰めなくてもいいので、揃えるなら揃えるなどルールを決めて分かりやすく記述する
  • リソース名にアンダーバーなど使えない記号がある

本記事で使用するtfファイルについて記載します。(※)

(※)一部の値はクレデンシャルな情報となるため、例としてxで記載

  • env-vars
### Authentication details
export TF_VAR_tenancy_ocid=ocid1.tenancy.oc1..aaaaaaaaxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
export TF_VAR_user_ocid=ocid1.user.oc1..aaaaaaaaxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
export TF_VAR_fingerprint=12:34:56:78:90:ab:cd:ef:12:34:56:78:90:ab:cd:ef
export TF_VAR_private_key_path=/oci/oci_api_key.pem
export TF_VAR_private_key_password=xxxxxxxx

### Region
export TF_VAR_region=ap-tokyo-1

### Compartment
export TF_VAR_compartment_ocid=ocid1.compartment.oc1..aaaaaaaaxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

### Public/private keys used on the instance
export TF_VAR_ssh_public_key=$(cat /oci/ssh/id_rsa.pub)
export TF_VAR_ssh_private_key=$(cat /oci/ssh/id_rsa)

(※)API秘密鍵にパスフレーズを設定しない場合は、private_key_passwordは不要です。

  • provider.tf
# Variable
variable "tenancy_ocid" {}
variable "user_ocid" {}
variable "fingerprint" {}
variable "private_key_path" {}
variable "private_key_password" {}
variable "region" {}
variable "compartment_ocid" {}
variable "ssh_private_key" {}
variable "ssh_public_key" {}

# Configure the Oracle Cloud Infrastructure provider with an API Key
provider "oci" {
  tenancy_ocid = "${var.tenancy_ocid}"
  user_ocid = "${var.user_ocid}"
  fingerprint = "${var.fingerprint}"
  private_key_path = "${var.private_key_path}"
  private_key_password = "${var.private_key_password}"
  region = "${var.region}"
}
  • network.tf
# Virtual Cloud Network
## vcn1
resource "oci_core_virtual_network" "vcn1" {
   display_name = "vcn1"
   compartment_id = "${var.compartment_ocid}"
   cidr_block = "10.0.0.0/16"
   dns_label = "vcn1"
}

## vcn2
resource "oci_core_virtual_network" "vcn2" {
   display_name = "vcn2"
   compartment_id = "${var.compartment_ocid}"
   cidr_block = "192.168.0.0/16"
   dns_label = "vcn2"
}

# Subnet
## Subnet LB
resource "oci_core_subnet" "LB_Segment" {
  display_name        = "開発環境_LBセグメント"
  compartment_id = "${var.compartment_ocid}"
  vcn_id         = "${oci_core_virtual_network.vcn1.id}"
  cidr_block          = "10.0.0.0/24"
  route_table_id      = "${oci_core_default_route_table.default-route-table1.id}"
  security_list_ids   = ["${oci_core_security_list.LB_securitylist.id}"]
}

## Subnet Web
resource "oci_core_subnet" "Web_Segment" {
  display_name        = "開発環境_WEBセグメント"
  compartment_id = "${var.compartment_ocid}"
  vcn_id         = "${oci_core_virtual_network.vcn1.id}"
  cidr_block          = "10.0.1.0/24"
  route_table_id      = "${oci_core_default_route_table.default-route-table1.id}"
  security_list_ids   = ["${oci_core_security_list.Web_securitylist.id}"]
}

## Subnet DB
resource "oci_core_subnet" "DB_Segment" {
  display_name        = "開発環境_DBセグメント"
  compartment_id = "${var.compartment_ocid}"
  vcn_id         = "${oci_core_virtual_network.vcn1.id}"
  cidr_block          = "10.0.2.0/24"
  route_table_id      = "${oci_core_route_table.nat-route-table.id}"
  prohibit_public_ip_on_vnic = "true"
  security_list_ids   = ["${oci_core_security_list.DB_securitylist.id}"]
}

## Subnet Operation
resource "oci_core_subnet" "Ope_Segment" {
  display_name        = "開発環境_運用セグメント"
  compartment_id = "${var.compartment_ocid}"
  vcn_id         = "${oci_core_virtual_network.vcn2.id}"
  cidr_block          = "192.168.1.0/24"
  route_table_id      = "${oci_core_default_route_table.default-route-table2.id}"
  security_list_ids   = ["${oci_core_security_list.Ope_securitylist.id}"]
}

# Route Table
## default-route-table1
resource "oci_core_default_route_table" "default-route-table1" {
  manage_default_resource_id = "${oci_core_virtual_network.vcn1.default_route_table_id}"

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

## nat-route-table
resource "oci_core_route_table" "nat-route-table" {
  display_name   = "nat-route-table"
  compartment_id = "${var.compartment_ocid}"
  vcn_id         = "${oci_core_virtual_network.vcn1.id}"
  route_rules {
    destination        = "0.0.0.0/0"
    network_entity_id = "${oci_core_nat_gateway.nat-gateway.id}"
  }
}

## default-route-table2
resource "oci_core_default_route_table" "default-route-table2" {
  manage_default_resource_id = "${oci_core_virtual_network.vcn2.default_route_table_id}"

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

# Internet Gateway
## internet-gateway1
resource "oci_core_internet_gateway" "internet-gateway1" {
  display_name   = "internet-gateway1"
  compartment_id = "${var.compartment_ocid}"
  vcn_id         = "${oci_core_virtual_network.vcn1.id}"
}

## internet-gateway2
resource "oci_core_internet_gateway" "internet-gateway2" {
  display_name   = "internet-gateway2"
  compartment_id = "${var.compartment_ocid}"
  vcn_id         = "${oci_core_virtual_network.vcn2.id}"
}

# Nat-Gateway
resource "oci_core_nat_gateway" "nat-gateway" {
  display_name   = "nat-gateway"
  compartment_id = "${var.compartment_ocid}"
  vcn_id         = "${oci_core_virtual_network.vcn1.id}"
}

(※)サブネットに対してパブリックIPアドレスを許可したくない場合は、prohibit_public_ip_on_vnic = "true"を記述します。

  • lb.tf
/* Load Balancer */

resource "oci_load_balancer" "load-balancer" {
  shape          = "100Mbps"
  compartment_id = "${var.compartment_ocid}"

  subnet_ids = [
    "${oci_core_subnet.LB_Segment.id}",
  ]

  display_name = "load-balancer"
}

resource "oci_load_balancer_backend_set" "lb-bes1" {
  name             = "lb-bes1"
  load_balancer_id = "${oci_load_balancer.load-balancer.id}"
  policy           = "ROUND_ROBIN"

  health_checker {
    port                = "80"
    protocol            = "HTTP"
    response_body_regex = ".*"
    url_path            = "/"
  }
}

resource "oci_load_balancer_certificate" "lb-cert1" {
  load_balancer_id   = "${oci_load_balancer.load-balancer.id}"
  ca_certificate     = "-----BEGIN CERTIFICATE-----\nMIIC9jCCAd4CCQD2rPUVJETHGzANBgkqhkiG9w0BAQsFADA9MQswCQYDVQQGEwJV\nUzELMAkGA1UECAwCV0ExEDAOBgNVBAcMB1NlYXR0bGUxDzANBgNVBAoMBk9yYWNs\nZTAeFw0xOTAxMTcyMjU4MDVaFw0yMTAxMTYyMjU4MDVaMD0xCzAJBgNVBAYTAlVT\nMQswCQYDVQQIDAJXQTEQMA4GA1UEBwwHU2VhdHRsZTEPMA0GA1UECgwGT3JhY2xl\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA30+wt7OlUB/YpmWbTRkx\nnLG0lKWiV+oupNKj8luXmC5jvOFTUejt1pQhpA47nCqywlOAfk2N8hJWTyJZUmKU\n+DWVV2So2B/obYxpiiyWF2tcF/cYi1kBYeAIu5JkVFwDe4ITK/oQUFEhIn3Qg/oC\nMQ2985/MTdCXONgnbmePU64GrJwfvOeJcQB3VIL1BBfISj4pPw5708qTRv5MJBOO\njLKRM68KXC5us4879IrSA77NQr1KwjGnQlykyCgGvvgwgrUTd5c/dH8EKrZVcFi6\nytM66P/1CTpk1YpbI4gqiG0HBbuXG4JRIjyzW4GT4JXeSjgvrkIYL8k/M4Az1WEc\n2wIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQAuI53m8Va6EafDi6GQdQrzNNQFCAVQ\nxIABAB0uaSYCs3H+pqTktHzOrOluSUEogXRl0UU5/OuvxAz4idA4cfBdId4i7AcY\nqZsBjA/xqH/rxR3pcgfaGyxQzrUsJFf0ZwnzqYJs7fUvuatHJYi/cRBxrKR2+4Oj\nlUbb9TSmezlzHK5CaD5XzN+lZqbsSvN3OQbOryJCbtjZVQFGZ1SmL6OLrwpbBKuP\nn2ob+gaP57YSzO3zk1NDXMlQPHRsdSOqocyKx8y+7J0g6MqPvBzIe+wI3QW85MQY\nj1/IHmj84LNGp7pHCyiYx/oI+00gRch04H2pJv0TP3sAQ37gplBwDrUo\n-----END CERTIFICATE-----"

  certificate_name   = "certificate1"

  private_key        = "-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEA30+wt7OlUB/YpmWbTRkxnLG0lKWiV+oupNKj8luXmC5jvOFT\nUejt1pQhpA47nCqywlOAfk2N8hJWTyJZUmKU+DWVV2So2B/obYxpiiyWF2tcF/cY\n\ni1kBYeAIu5JkVFwDe4ITK/oQUFEhIn3Qg/oCMQ2985/MTdCXONgnbmePU64GrJwf\nvOeJcQB3VIL1BBfISj4pPw5708qTRv5MJBOOjLKRM68KXC5us4879IrSA77NQr1K\nwjGnQlykyCgGvvgwgrUTd5c/dH8EKrZVcFi6ytM66P/1CTpk1YpbI4gqiG0HBbuX\nG4JRIjyzW4GT4JXeSjgvrkIYL8k/M4Az1WEc2wIDAQABAoIBAGQznukfG/uS/qTT\njNcQifl0p8HXfLwUIa/lsJkMTj6D+k8DkF59tVMGjv3NQSQ26JVX4J1L8XiAj+fc\nUtYr1Ap4CLX5PeYUkzesvKK6lPKXQvCh+Ip2eq9PVrvL2WcdDpb5695cy7suXD7c\n05aUtS0LrINH3eXAxkpEe5UHtQFni5YLrCLEXd+SSA3OKdCB+23HRELc1iCTvqjK\n5AtR916uHTBhtREHRMvWIdu4InRUsedlJhaJOLJ8G8r64JUtfm3wLUK1U8HFOsd0\nLAx9ZURU6cXl4osTWiy1vigGaM8Xuish2HkOLNYZADDUiDBB3SshmW5IDAJ5XTn5\nqVrszRECgYEA79j1y+WLTyV7yz7XkWk3OqoQXG4b2JfKItJI1M95UwllzQ8U/krM\n+QZjP3NTtB9i1YoHyaEfic103hV9Fkgz8jvKS5ocLGJulpN4CgqbHN6v9EJ3dqTk\no6X8mpx2eP2E0ngRekFyC/OCp0Zhe2KR9PXhijMa5eB2LTeCMIS/tzkCgYEA7lmk\nIdVjcpfqY7UFJ2R8zqPJHOne2+llrl9vzo6N5kx4DzAg7MP6XO9MekOvfmD1X1Lm\nFckXWFEF+0TlN5YvCTR/+OmVufYM3xp4GBT8RZdLFbyI4+xpAAeSC4SeM0ZkC9Jt\nrKqCS24+Kqy/+qSqtkxiPLQrXSdCSfCUlmn0ALMCgYBB7SLy3q+CG82BOk7Km18g\n8un4XhOtX1uiYqa+SCETH/wpd0HP/AOHV6gkIrEZS59BDuXBGFaw7BZ5jPKLE2Gj\n7adXTI797Dh1jydpqyyjrNo0i6iGpiBqkw9x+Bvged7ucy5qql6MxmxdSk01Owzf\nhk5uTEnScfZJy34vk+2WkQKBgBXx5uy+iuN4HTqE5i6UT/FunwusdLpmqNf/LXol\nIed8TumHEuD5wklgNvhi1vuZzb2zEkAbPa0B+L0DwN73UulUDhxK1WBDyTeZZklB\nVWDK5zzfGPNzRs+b4tRwp2gtKPT1sOde45QyWELxmNNo6dbS/ZB9Pijbfnz0S5n1\ns2OFAoGBAJUohI1+d2hKlkSUzpCorQarDe8lFVEbGMu0kX0JNDI7QU+H8vDp9NOl\nGqLm3sCVBYypT8sWfchgZpcVaLRLQCQtWy4+CbMN6DT3j/uBWeDpayU5Gvqt0/no\nvwqbG6b0NEYLRPLEdsS/c8TV9mMlvb0EW+GXfmkpTrTNt3hyXniu\n-----END RSA PRIVATE KEY-----"

  public_certificate = "-----BEGIN CERTIFICATE-----\nMIIC9jCCAd4CCQD2rPUVJETHGzANBgkqhkiG9w0BAQsFADA9MQswCQYDVQQGEwJV\nUzELMAkGA1UECAwCV0ExEDAOBgNVBAcMB1NlYXR0bGUxDzANBgNVBAoMBk9yYWNs\nZTAeFw0xOTAxMTcyMjU4MDVaFw0yMTAxMTYyMjU4MDVaMD0xCzAJBgNVBAYTAlVT\nMQswCQYDVQQIDAJXQTEQMA4GA1UEBwwHU2VhdHRsZTEPMA0GA1UECgwGT3JhY2xl\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA30+wt7OlUB/YpmWbTRkx\nnLG0lKWiV+oupNKj8luXmC5jvOFTUejt1pQhpA47nCqywlOAfk2N8hJWTyJZUmKU\n+DWVV2So2B/obYxpiiyWF2tcF/cYi1kBYeAIu5JkVFwDe4ITK/oQUFEhIn3Qg/oC\nMQ2985/MTdCXONgnbmePU64GrJwfvOeJcQB3VIL1BBfISj4pPw5708qTRv5MJBOO\njLKRM68KXC5us4879IrSA77NQr1KwjGnQlykyCgGvvgwgrUTd5c/dH8EKrZVcFi6\nytM66P/1CTpk1YpbI4gqiG0HBbuXG4JRIjyzW4GT4JXeSjgvrkIYL8k/M4Az1WEc\n2wIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQAuI53m8Va6EafDi6GQdQrzNNQFCAVQ\nxIABAB0uaSYCs3H+pqTktHzOrOluSUEogXRl0UU5/OuvxAz4idA4cfBdId4i7AcY\nqZsBjA/xqH/rxR3pcgfaGyxQzrUsJFf0ZwnzqYJs7fUvuatHJYi/cRBxrKR2+4Oj\nlUbb9TSmezlzHK5CaD5XzN+lZqbsSvN3OQbOryJCbtjZVQFGZ1SmL6OLrwpbBKuP\nn2ob+gaP57YSzO3zk1NDXMlQPHRsdSOqocyKx8y+7J0g6MqPvBzIe+wI3QW85MQY\nj1/IHmj84LNGp7pHCyiYx/oI+00gRch04H2pJv0TP3sAQ37gplBwDrUo\n-----END CERTIFICATE-----"

  lifecycle {
    create_before_destroy = true
  }
}

resource "oci_load_balancer_path_route_set" "test_path_route_set" {
  #Required
  load_balancer_id = "${oci_load_balancer.load-balancer.id}"
  name             = "pr-set1"

  path_routes {
    #Required
    backend_set_name = "${oci_load_balancer_backend_set.lb-bes1.name}"
    path             = "/test"

    path_match_type {
      #Required
      match_type = "EXACT_MATCH"
    }
  }
}

resource "oci_load_balancer_hostname" "test_hostname1" {
  #Required
  hostname         = "app.example.com"
  load_balancer_id = "${oci_load_balancer.load-balancer.id}"
  name             = "hostname1"
}

resource "oci_load_balancer_listener" "lb-listener1" {
  load_balancer_id         = "${oci_load_balancer.load-balancer.id}"
  name                     = "http"
  default_backend_set_name = "${oci_load_balancer_backend_set.lb-bes1.name}"
  hostname_names           = ["${oci_load_balancer_hostname.test_hostname1.name}"]
  port                     = 80
  protocol                 = "HTTP"

  connection_configuration {
    idle_timeout_in_seconds = "2"
  }
}

resource "oci_load_balancer_listener" "lb-listener2" {
  load_balancer_id         = "${oci_load_balancer.load-balancer.id}"
  name                     = "https"
  default_backend_set_name = "${oci_load_balancer_backend_set.lb-bes1.name}"
  port                     = 443
  protocol                 = "HTTP"

  ssl_configuration {
    certificate_name        = "${oci_load_balancer_certificate.lb-cert1.certificate_name}"
    verify_peer_certificate = false
  }
}

resource "oci_load_balancer_backend" "lb-be1" {
  load_balancer_id = "${oci_load_balancer.load-balancer.id}"
  backendset_name  = "${oci_load_balancer_backend_set.lb-bes1.name}"
  ip_address = "${var.ip_address3}"
  port             = 80
  backup           = false
  drain            = false
  offline          = false
  weight           = 1
}

resource "oci_load_balancer_backend" "lb-be2" {
  load_balancer_id = "${oci_load_balancer.load-balancer.id}"
  backendset_name  = "${oci_load_balancer_backend_set.lb-bes1.name}"
  ip_address = "${var.ip_address4}"
  port             = 80
  backup           = false
  drain            = false
  offline          = false
  weight           = 1
}

output "lb_public_ip" {
  value = ["${oci_load_balancer.load-balancer.ip_address_details}"]
}

(※)outputを記述すると、リソース作成後にoutputで指定した値を出力することができます。

  • securitylist.tf
# Security list
## LB
resource "oci_core_security_list" "LB_securitylist" {
  display_name   = "開発環境_LBセグメント"
  compartment_id = "${var.compartment_ocid}"
  vcn_id         = "${oci_core_virtual_network.vcn1.id}"

  ingress_security_rules {
    source = "0.0.0.0/0"
    protocol = "6"
    tcp_options {
      min = 443
      max = 443
    }
  }

  egress_security_rules {
    destination = "0.0.0.0/0"
    protocol = "ALL"
    }
  }

## Web
resource "oci_core_security_list" "Web_securitylist" {
  display_name   = "開発環境_Webセグメント"
  compartment_id = "${var.compartment_ocid}"
  vcn_id         = "${oci_core_virtual_network.vcn1.id}"

  ingress_security_rules {
    source = "10.0.0.0/24"
    protocol = "6"
    tcp_options {
      min = 80
      max = 80
    }
  }

  egress_security_rules {
    destination = "0.0.0.0/0"
    protocol = "ALL"
    }
  }

## DB
resource "oci_core_security_list" "DB_securitylist" {
  display_name   = "開発環境_DBセグメント"
  compartment_id = "${var.compartment_ocid}"
  vcn_id         = "${oci_core_virtual_network.vcn1.id}"

  ingress_security_rules {
    source = "10.0.1.0/24"
    protocol = "6"
    tcp_options {
      min = 5432
      max = 5432
    }
  }

  egress_security_rules {
    destination = "0.0.0.0/0"
    protocol = "ALL"
    }
  }

## Security list Ope
resource "oci_core_security_list" "Ope_securitylist" {
  display_name   = "開発環境_運用セグメント"
  compartment_id = "${var.compartment_ocid}"
  vcn_id         = "${oci_core_virtual_network.vcn2.id}"

  ingress_security_rules {
    source = "192.168.1.0/24"
    protocol = "1"
  }

  ingress_security_rules {
    source = "x.x.x.x/32"
    protocol = "6"
    tcp_options {
      min = 22
      max = 22
    }
  }

  ingress_security_rules {
    source = "192.168.1.0/24"
    protocol = "6"
    tcp_options {
      min = 22
      max = 22
    }
  }

  egress_security_rules {
    destination = "0.0.0.0/0"
    protocol = "ALL"
    }
  }

(※)パブリックIPアドレスに対するSSHの制限として、source = "x.x.x.x/32"を記述しています。

  • compute.tf
# Variable
variable "ImageOS" {
  default = "Oracle Linux"
}

variable "ImageOSVersion" {
  default = "7.7"
}

variable "instance_shape" {
  default = "VM.Standard.E2.1"
}

variable "fault_domain" {
  default = "FAULT-DOMAIN-1"
}

variable "ip_address1" {
  default = "192.168.1.2"
}

variable "ip_address2" {
  default = "192.168.1.3"
}

variable "ip_address3" {
  default = "10.0.1.2"
}

variable "ip_address4" {
  default = "10.0.1.3"
}

variable "ip_address5" {
  default = "192.168.1.4"
}

variable "ip_address6" {
  default = "10.0.2.2"
}

variable "ip_address7" {
  default = "192.168.1.5"
}

# Gets a list of Availability Domains
data "oci_identity_availability_domains" "ADs" {
  compartment_id = "${var.tenancy_ocid}"
}

# Gets a list of all Oracle Linux 7.7 images that support a given Instance shape
data "oci_core_images" "instance" {
  compartment_id           = "${var.tenancy_ocid}"
  operating_system         = "${var.ImageOS}"
  operating_system_version = "${var.ImageOSVersion}"
  shape                    = "${var.instance_shape}"
}

# Instance
## Compute Web-Server#1
resource "oci_core_instance" "instance1" {
  source_details {
    source_type = "image"
    source_id   = "${lookup(data.oci_core_images.instance.images[0], "id")}"
  }

  display_name        = "Web-Server#1"
  availability_domain = "${lookup(data.oci_identity_availability_domains.ADs.availability_domains[0], "name")}"
  shape               = "${var.instance_shape}"
  compartment_id      = "${var.compartment_ocid}"

  create_vnic_details {
    subnet_id        = "${oci_core_subnet.Ope_Segment.id}"
    assign_public_ip = "true"
    private_ip       = "${var.ip_address1}"
  }

  metadata = {
        ssh_authorized_keys = "${var.ssh_public_key}"
        user_data           = "${base64encode(file("./userdata/cloud-init1.tpl"))}"
    }

  fault_domain        = "${var.fault_domain}"

  provisioner "remote-exec" {
    connection {
      host    = "${oci_core_instance.instance1.public_ip}"
      type    = "ssh"
      user    = "opc"
      agent   = "true"
      timeout = "3m"
    }

    inline = [
      "crontab -l | { cat; echo \"@reboot sudo /usr/local/bin/secondary_vnic_all_configure.sh -c\"; } | crontab -"
      ]
  }
}

### SecondaryVNIC Web-Server#1
resource "oci_core_vnic_attachment" "Web1_secondary_vnic_attachment" {
  create_vnic_details {
    display_name           = "SecondaryVNIC"
    subnet_id              = "${oci_core_subnet.Web_Segment.id}"
    assign_public_ip       = "true"
    private_ip             = "${var.ip_address3}"
    skip_source_dest_check = "false"
}

  instance_id = "${oci_core_instance.instance1.id}"

}

## Compute Web-Server#2
resource "oci_core_instance" "instance2" {
  source_details {
    source_type = "image"
    source_id   = "${lookup(data.oci_core_images.instance.images[0], "id")}"
  }

  display_name        = "Web-Server#2"
  availability_domain = "${lookup(data.oci_identity_availability_domains.ADs.availability_domains[0], "name")}"
  shape               = "${var.instance_shape}"
  compartment_id      = "${var.compartment_ocid}"

  create_vnic_details {
    subnet_id        = "${oci_core_subnet.Ope_Segment.id}"
    assign_public_ip = "true"
    private_ip       = "${var.ip_address2}"
  }

  metadata = {
        ssh_authorized_keys = "${var.ssh_public_key}"
        user_data           = "${base64encode(file("./userdata/cloud-init1.tpl"))}"
    }

  fault_domain        = "${var.fault_domain}"

  provisioner "remote-exec" {
    connection {
      host    = "${oci_core_instance.instance2.public_ip}"
      type    = "ssh"
      user    = "opc"
      agent   = "true"
      timeout = "3m"
      }

    inline = [
      "crontab -l | { cat; echo \"@reboot sudo /usr/local/bin/secondary_vnic_all_configure.sh -c\"; } | crontab -"
      ]
  }
}

### SecondaryVNIC Web-Server#2
resource "oci_core_vnic_attachment" "Web2_secondary_vnic_attachment" {
  create_vnic_details {
    display_name           = "SecondaryVNIC"
    subnet_id              = "${oci_core_subnet.Web_Segment.id}"
    assign_public_ip       = "true"
    private_ip             = "${var.ip_address4}"
    skip_source_dest_check = "false"
}

  instance_id = "${oci_core_instance.instance2.id}"

}

## Compute DB-Server
resource "oci_core_instance" "instance3" {
  source_details {
    source_type = "image"
    source_id   = "${lookup(data.oci_core_images.instance.images[0], "id")}"
  }

  display_name        = "DB-Server"
  availability_domain = "${lookup(data.oci_identity_availability_domains.ADs.availability_domains[0], "name")}"
  shape               = "${var.instance_shape}"
  compartment_id      = "${var.compartment_ocid}"

  create_vnic_details {
    subnet_id        = "${oci_core_subnet.Ope_Segment.id}"
    private_ip       = "${var.ip_address5}"
  }

  metadata = {
        ssh_authorized_keys = "${var.ssh_public_key}"
        user_data           = "${base64encode(file("./userdata/cloud-init2.tpl"))}"
    }

  fault_domain        = "${var.fault_domain}"

  provisioner "remote-exec" {
    connection {
      host    = "${oci_core_instance.instance3.public_ip}"
      type    = "ssh"
      user    = "opc"
      agent   = "true"
      timeout = "3m"
      }

    inline = [
      "crontab -l | { cat; echo \"@reboot sudo /usr/local/bin/secondary_vnic_all_configure.sh -c\"; } | crontab -"
      ]
  }
}

### SecondaryVNIC DB-Server
resource "oci_core_vnic_attachment" "DB_secondary_vnic_attachment" {
  create_vnic_details {
    display_name = "SecondaryVNIC"
    subnet_id  = "${oci_core_subnet.DB_Segment.id}"
    assign_public_ip = false
    private_ip = "${var.ip_address6}"
    skip_source_dest_check = "false"
}

  instance_id = "${oci_core_instance.instance3.id}"

}

## Compute Operation-Server
resource "oci_core_instance" "instance4" {
  source_details {
    source_type = "image"
    source_id   = "${lookup(data.oci_core_images.instance.images[0], "id")}"
  }

  display_name        = " Operation-Server"
  availability_domain = "${lookup(data.oci_identity_availability_domains.ADs.availability_domains[0], "name")}"
  shape               = "${var.instance_shape}"
  compartment_id      = "${var.compartment_ocid}"

  create_vnic_details {
    subnet_id        = "${oci_core_subnet.Ope_Segment.id}"
    private_ip       = "${var.ip_address7}"
  }

  metadata = {
        ssh_authorized_keys = "${var.ssh_public_key}"
        user_data           = "${base64encode(file("./userdata/cloud-init1.tpl"))}"
    }

  fault_domain        = "${var.fault_domain}"

  provisioner "remote-exec" {
    connection {
      host = "${oci_core_instance.instance3.public_ip}"
      type    = "ssh"
      user    = "opc"
      agent   = "true"
      timeout = "3m"
      }

    inline = [
      "crontab -l | { cat; echo \"@reboot sudo /usr/local/bin/secondary_vnic_all_configure.sh -c\"; } | crontab -"
      ]
  }
}
  • cloud-init1.tpl
#cloud-config

runcmd:
# download the secondary vnic script
- wget -O /usr/local/bin/secondary_vnic_all_configure.sh https://docs.cloud.oracle.com/iaas/Content/Resources/Assets/secondary_vnic_all_configure.sh
- chmod +x /usr/local/bin/secondary_vnic_all_configure.sh
- sleep 60
- /usr/local/bin/secondary_vnic_all_configure.sh -c

- yum update -y
- echo "Hello World.  The time is now $(date -R)!" | tee /root/output.txt

- echo '################### webserver userdata begins #####################'
- touch ~opc/userdata.`date +%s`.start
# echo '########## yum update all ###############'
# yum update -y
- echo '########## basic webserver ##############'
- yum install -y httpd
- systemctl enable  httpd.service
- systemctl start  httpd.service
- echo '<html><head></head><body><pre><code>' > /var/www/html/index.html
- hostname >> /var/www/html/index.html
- echo '' >> /var/www/html/index.html
- cat /etc/os-release >> /var/www/html/index.html
- echo '</code></pre></body></html>' >> /var/www/html/index.html
- firewall-offline-cmd --add-service=http
- systemctl enable  firewalld
- systemctl restart  firewalld
- touch ~opc/userdata.`date +%s`.finish
- echo '################### webserver userdata ends #######################'
  • cloud-init2.tpl
#cloud-config

runcmd:
# download the secondary vnic script
- wget -O /usr/local/bin/secondary_vnic_all_configure.sh https://docs.cloud.oracle.com/iaas/Content/Resources/Assets/secondary_vnic_all_configure.sh
- chmod +x /usr/local/bin/secondary_vnic_all_configure.sh
- sleep 60
- /usr/local/bin/secondary_vnic_all_configure.sh -c
- echo 'PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin:/root/bin' > /var/spool/cron/root
- echo '@reboot /usr/local/bin/secondary_vnic_all_configure.sh -c' >> /var/spool/cron/root

# Postgresqlで使用するポート設定
- setenforce 0
- firewall-cmd --permanent --add-port=5432/tcp
- firewall-cmd --permanent --add-port=5432/udp
- firewall-cmd --reload

# 必要パッケージのインストール
- yum install -y gcc
- yum install -y readline-devel
- yum install -y zlib-devel

# PostgreSQLのインストール
- cd /usr/local/src/
- wget https://ftp.postgresql.org/pub/source/v11.3/postgresql-11.3.tar.gz
- tar xvfz postgresql-11.3.tar.gz
- cd postgresql-11.3/

# コンパイル
- ./configure
- make
- make install

# 起動スクリプト作成
- cp /usr/local/src/postgresql-11.3/contrib/start-scripts/linux /etc/init.d/postgres
- chmod 755 /etc/init.d/postgres
- chkconfig --add postgres
- chkconfig --list | grep postgres

# Postgresユーザ作成
- adduser postgres

Terraform構築

はじめに、以下の準備作業を行います。

  • 環境変数の有効化
    $ source env-vars
  • 環境変数の確認
    $ env
  • SSH秘密鍵の登録(※)
    $ ssh-add /oci/ssh/id_rsa

(※)Terraformではパスフレーズで保護されたSSHキーをサポートしていません。SSHエージェントにsshキーを登録することで回避しています。SSHの秘密鍵にパスフレーズを設定していない場合は不要です。

準備作業完了後、いよいよTerraform構築です。
Terraform構築作業は次の3Stepです!

  1. terraform initで初期化
  2. terraform planで確認
  3. terraform applyで適用

では、順番に見ていきましょう。

terraform init

terraform initは、Terraform構成ファイルを含む作業ディレクトリを初期化します。引数が指定されていない場合、現在の作業ディレクトリの構成が初期化されます。初期化中に、Terraformはプロバイダーへの直接および間接参照の構成を検索し、必要なプラグインをロードします。

# terraform init

Initializing the backend...

Initializing provider plugins...

The following providers do not have any version constraints in configuration,
so the latest version was installed.

To prevent automatic upgrades to new major versions that may contain breaking
changes, it is recommended to add version = "..." constraints to the
corresponding provider blocks in configuration, with the constraint strings
suggested below.

* provider.oci: version = "~> 3.40"

Terraform has been successfully initialized!

You may now begin working with Terraform. Try running "terraform plan" to see
any changes that are required for your infrastructure. All Terraform commands
should now work.

If you ever set or change modules or backend configuration for Terraform,
rerun this command to reinitialize your working directory. If you forget, other
commands will detect it and remind you to do so if necessary.

terraform plan

terraform planは、実行計画を作成するために使用されます。本コマンドを実行しただけでは、実際に反映されません。実際のリソースや状態を変更せずに、期待通りに動くかテストのために使用されます。また、オプションの-out引数を使用すると、生成されたプランを後で実行するためにファイルに保存されます。なお、tfファイルにエラーがあった場合は検知されますが、terraform planが成功してもterraform applyで失敗する場合もあるので注意が必要です。

# terraform plan

The refreshed state will be used to calculate this plan, but will not be
persisted to local or remote state storage.

data.oci_identity_availability_domains.ADs: Refreshing state...
data.oci_core_images.instance: Refreshing state...

------------------------------------------------------------------------

An execution plan has been generated and is shown below.
Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:

  # oci_core_default_route_table.default-route-table1 will be created
  + resource "oci_core_default_route_table" "default-route-table1" {
      + defined_tags               = (known after apply)
      + display_name               = (known after apply)
      + freeform_tags              = (known after apply)
      + id                         = (known after apply)
      + manage_default_resource_id = (known after apply)
      + state                      = (known after apply)
      + time_created               = (known after apply)

      + route_rules {
          + cidr_block        = (known after apply)
          + destination       = "0.0.0.0/0"
          + destination_type  = "CIDR_BLOCK"
          + network_entity_id = (known after apply)
        }
    }

/*中略*/

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

------------------------------------------------------------------------

Note: You didn't specify an "-out" parameter to save this plan, so Terraform
can't guarantee that exactly these actions will be performed if
"terraform apply" is subsequently run.

terraform apply

Terraform applyは、実行計画によってリソースの構成変更が適用されます。
本コマンドを実行すると、terraform.tfstateファイルが生成されます。

# terraform apply

data.oci_identity_availability_domains.ADs: Refreshing state...
data.oci_core_images.instance: Refreshing state...

An execution plan has been generated and is shown below.
Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:

  # oci_core_default_route_table.default-route-table1 will be created
  + resource "oci_core_default_route_table" "default-route-table1" {
      + defined_tags               = (known after apply)
      + display_name               = (known after apply)
      + freeform_tags              = (known after apply)
      + id                         = (known after apply)
      + manage_default_resource_id = (known after apply)
      + state                      = (known after apply)
      + time_created               = (known after apply)

      + route_rules {
          + cidr_block        = (known after apply)
          + destination       = "0.0.0.0/0"
          + destination_type  = "CIDR_BLOCK"
          + network_entity_id = (known after apply)
        }
    }

/*中略*/

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

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

oci_core_virtual_network.vcn2: Creating...
oci_core_virtual_network.vcn1: Creating...

/*中略*/

Apply complete! Resources: 32 added, 0 changed, 0 destroyed.

Outputs:

lb_public_ip = [
  [
    {
      "ip_address" = "X.X.X.X"
      "is_public" = true
    },
  ],
]

注意事項
ディレクトリ毎にtfファイルを分けている場合に、コンパートメントを変えて作業するときは、ディレクトリ変更時にsource env-varsの実行を忘れいない様にしましょう。

ヒヤリハットとして、A環境で作業しました。次にB環境のディクトりに移動して、terraform applyを実行したとしましょう。A環境で使用した変数が残っていると、意図しないコンパートメントにリソースの作成や削除を起こしてしまう恐れがあります。

リソース確認

terraform apply実行後、Oracle Cloudのコンソール画面にアクセスして、作成したリソースを確認してみましょう。

  • 仮想クラウドネットワーク

スクリーンショット 2019-12-02 23.46.38.png

  • インスタンス

スクリーンショット 2019-12-02 23.50.30.png

cloud-initも完了後、Outputsで出力されたロードバランサーのIPアドレスにアクセスすると、Webサーバのindex.htmlが表示されます。

スクリーンショット 2019-12-04 21.15.54.png

terraform destroy

terraform destroyは、構築した環境を全て削除します。

# terraform destroy

data.oci_identity_availability_domains.ADs: Refreshing state...

/*中略*/

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

Do you really want to destroy all resources?
  Terraform will destroy all your managed infrastructure, as shown above.
  There is no undo. Only 'yes' will be accepted to confirm.

  Enter a value: yes

/*中略*/

oci_core_virtual_network.vcn1: Destruction complete after 0s

Destroy complete! Resources: 32 destroyed.

ナレッジ

Oracle Cloudでサポートされている「オペレーティングシステム」

Terraformで利用できるOracle Cloudのイメージは以下になります。

$ oci compute image list -c <コンパートメントのOCID> --all | jq -r '.data[] | ."operating-system"' | sort | uniq

Canonical Ubuntu
CentOS
Custom
Oracle Linux
Windows

よって、「Oracle Database」などのカスタムイメージを使用することはできません。
Terraformでデータベースサービスを起動したい場合は、マネージドデータベースサービス-DBaaSを使用することで実現できます。

複数台のインスタンス構築

複数台のインスタンスを構築する場合は、台数分記述するとコード量が増えます。tfファイルで変数の配列を利用することで、繰り返しの実行を行うことができます。本記事では、インスタンス数は多くないので台数分定義しています。

cloud-init

tfファイルでuser_dataの値に、カスタムスクリプトを指定できます。
ユーザーデータを活用する方法については、User-Data Formatsを参照。

cloud-initを使用するときのポイントは、カスタムスクリプトの中身まではチェックしないため、terraform applyが成功しても、スクリプトが失敗する場合があります。

例として、以下の様に改行が入っている場合は失敗します。

$ sudo apt-get install \
    apt-transport-https \
    ca-certificates \
    curl \
    gnupg-agent \
    software-properties-common

また、terraform applyが成功してもCloud-Initの処理はまだ完了していません。
意図した通りに動作しているかはインスタンスにログイン後以下のログファイルで確認できます。

# cat /var/log/cloud-init-output.log

Secondary VNIC

Oracle CloudでSecondary VNICを設定する場合は考慮が必要です。
本記事では、secondary_vnic_all_configure.shを実行して、IP構成を行っています。

例えば、terraform apply実行時に、secondary_vnic_all_configure.shの呼び出すタイミングが早いとIP構成が失敗する場合があります。そのため、本記事では、cloud-Initのuserdataで指定したcloud-init.tplの中で、sleep処理を入れて確実に実行しています。

また、OS再起動時にsecondary VNICを有効にするため、tfファイルのremote-execinlineでcronの設定を行っています。仮に、インスタンスにパブリックIPアドレスを設定しない場合は、remote-execが使用できません。その場合は、cloud-initを使用するといいでしょう。

あとは、本記事の様に業務LANと運用LANを分ける場合は、運用LANをプライマリのVNICを指定するのがベストプラクティスです。

既存環境のコード化

Terraformを導入する前に作成したリソースについても、terraform importを使用すれば既存環境もコード化することができます。

おわりに

Terraformは、検証環境の払い出しから本番環境構築及び運用フェーズの構成管理等どのシーンでも真価を発揮します。

オンプレ育ちの私の様なレガシー環境でインフラエンジニアをやってきたものからすると、terraform applyを初めて実行し、リソースを作成したときはとても感銘を受けました。

今はアプリケーションエンジニアの方もDockerなどを使用して開発する時代ですが、Terraformはインフラ領域を得意とするSREの役割だと思います。インフラをやってきた人間じゃないと考慮できないところがあると率直に感じました。

これからもTerraformを活用し、開発者が開発のみに集中できる環境を作りたいと思います。

参考

クラウドネイティブ アーキテクチャ

Oracle Cloud/Terraform

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした