commit f7ef3d241aff28d12f30ff36b213cac8b12ad9e6 Author: Ryan Boehning Date: Thu Apr 16 20:05:56 2020 -0700 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7b840f0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +# Local .terraform directories +**/.terraform/* + +# .tfstate files +*.tfstate +*.tfstate.* + +# Crash log files +crash.log + +# Ignore any .tfvars files that are generated automatically for each Terraform run. Most +# .tfvars files are managed as part of configuration and so should be included in +# version control. +# +# example.tfvars + +# Ignore override files as they are usually used to override resources locally and so +# are not checked in +override.tf +override.tf.json +*_override.tf +*_override.tf.json + +# Include override files you do wish to add to version control using negated pattern +# +# !example_override.tf + +# Include tfplan files to ignore the plan output of command: terraform plan -out=tfplan +# example: *tfplan* + +# This file is created by init_dev_cluster.sh and contains a workspace name that +# is unique to the developer. +terraform/development/backend.hcl + +api/target +.terraform/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..dfc3921 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2020 Ryan Boehning + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..2c6f814 --- /dev/null +++ b/README.md @@ -0,0 +1,35 @@ +# Cloud SQL DB with a Private IP + +This repo demonstrates how to create a Cloud SQL DB with a private IP address +only, and connect to it with [Cloud SQL Proxy](https://cloud.google.com/sql/docs/postgres/sql-proxy). The full explanation of how this works can be found in [this blog post](https://medium.com/@ryanboehning/how-to-deploy-a-cloud-sql-db-with-a-private-ip-only-using-terraform-e184b08eca64). + +## Deploy the db and Cloud SQL Proxy + +```bash +gcloud services enable \ + cloudresourcemanager.googleapis.com \ + compute.googleapis.com \ + iam.googleapis.com \ + oslogin.googleapis.com \ + servicenetworking.googleapis.com \ + sqladmin.googleapis.com + +terraform init +terraform apply +``` + +## Upload your public SSH key to Google's OS Login service + +```bash +gcloud compute os-login ssh-keys add --key-file=~/.ssh/id_rsa.pub --ttl=365d +``` + +## Connect to the private db through Cloud SQL Proxy + +```bash +# get your SSH username +gcloud compute os-login describe-profile | grep username + +# psql into your private db +ssh -t @ docker run --rm --network=host -it postgres:11-alpine psql -U postgres -h localhost +``` diff --git a/main.tf b/main.tf new file mode 100644 index 0000000..c1572d7 --- /dev/null +++ b/main.tf @@ -0,0 +1,83 @@ +// root module + +terraform { + required_version = "~> 0.12.24" + required_providers { + tfe = "~> 0.16.0" + google = "~> 3.17.0" + google-beta = "~> 3.17.0" # for enabling private services access + } + backend "remote" { + organization = "my-terraform-cloud-org" + workspaces { + name = "private-ip-cloud-sql-db" + } + } +} + +locals { + db_username = "my_user" # Postgres username + gcp_project_name = "norse-baton-274601" + gcp_region = "us-central1" + gcp_zone = "us-central1-b" +} + +provider "google" { + project = local.gcp_project_name + region = local.gcp_region + zone = local.gcp_zone +} + +provider "google-beta" { + project = local.gcp_project_name + region = local.gcp_region + zone = local.gcp_zone +} + +module "vpc" { + # Override the default google provider with the google-beta provider. We need + # the beta provider to enable setting a private IP for the db. + providers = { + google = google-beta + } + source = "./modules/vpc" + + name = "main-vpc" +} + +module "db" { + providers = { + google = google-beta + } + + source = "./modules/db" + + disk_size = 10 + instance_type = "db-f1-micro" + password = var.db_password # This is a variable because it's a secret. It's stored here: https://app.terraform.io/app//workspaces//variables + user = local.db_username + vpc_name = module.vpc.name + vpc_link = module.vpc.link + + # There's a dependency relationship between the db and the VPC that + # terraform can't figure out. The db instance depends on the VPC because it + # uses a private IP from a block of IPs defined in the VPC. If we just giving + # the db a public IP, there wouldn't be a dependency. The dependency exists + # because we've configured private services access. We need to explicitly + # specify the dependency here. For details, see the note in the docs here: + # https://www.terraform.io/docs/providers/google/r/sql_database_instance.html#private-ip-instance + db_depends_on = module.vpc.private_vpc_connection +} + +module "dbproxy" { + source = "./modules/dbproxy" + + machine_type = "f1-micro" + db_instance_name = module.db.connection_name # e.g. my-project:us-central1:my-db + region = local.gcp_region + zone = local.gcp_zone + + # By passing the VPC name ("main-vpc") as the output of the VPC module + # (module.vpc.name), we ensure the VPC will be created before the proxy. + vpc_name = module.vpc.name +} diff --git a/modules/db/main.tf b/modules/db/main.tf new file mode 100644 index 0000000..8785851 --- /dev/null +++ b/modules/db/main.tf @@ -0,0 +1,29 @@ +// db module + +resource "google_sql_database" "main" { + name = "main" + instance = google_sql_database_instance.main_primary.name +} + +resource "google_sql_database_instance" "main_primary" { + name = "main-primary" + database_version = "POSTGRES_11" + depends_on = [var.db_depends_on] + + settings { + tier = var.instance_type + availability_type = "ZONAL" # use "REGIONAL" for prod to distribute data storage across zones + disk_size = var.disk_size + + ip_configuration { + ipv4_enabled = false # don't give the db a public IPv4 + private_network = var.vpc_link # the VPC where the db will be assigned a private IP + } + } +} + +resource "google_sql_user" "db_user" { + name = var.user + instance = google_sql_database_instance.main_primary.name + password = var.password +} diff --git a/modules/db/outputs.tf b/modules/db/outputs.tf new file mode 100644 index 0000000..6ead9ad --- /dev/null +++ b/modules/db/outputs.tf @@ -0,0 +1,6 @@ +// db module + +output "connection_name" { + description = "The connection string used by Cloud SQL Proxy, e.g. my-project:us-central1:my-db" + value = google_sql_database_instance.main_primary.connection_name +} diff --git a/modules/db/variables.tf b/modules/db/variables.tf new file mode 100644 index 0000000..b12a099 --- /dev/null +++ b/modules/db/variables.tf @@ -0,0 +1,36 @@ +// db module + +variable "db_depends_on" { + description = "A single resource that the database instance depends on" + type = any +} + +variable "disk_size" { + description = "The size in GB of the disk used by the db" + type = number +} + +variable "instance_type" { + description = "The instance type of the VM that will run the db (e.g. db-f1-micro, db-custom-8-32768)" + type = string +} + +variable "password" { + description = "The db password used to connect to the Postgers db" + type = string +} + +variable "user" { + description = "The username of the db user" + type = string +} + +variable "vpc_link" { + description = "A link to the VPC where the db will live (i.e. google_compute_network.some_vpc.self_link)" + type = string +} + +variable "vpc_name" { + description = "The name of the VPC where the db will live" + type = string +} diff --git a/modules/dbproxy/main.tf b/modules/dbproxy/main.tf new file mode 100644 index 0000000..a52af2f --- /dev/null +++ b/modules/dbproxy/main.tf @@ -0,0 +1,71 @@ + +data "google_compute_subnetwork" "regional_subnet" { + name = var.vpc_name + region = var.region +} + +resource "google_compute_instance" "db_proxy" { + name = "db-proxy" + description = <<-EOT + A public-facing instance that proxies traffic to the database. This allows + the db to only have a private IP address, but still be reachable from + outside the VPC. + EOT + machine_type = var.machine_type + zone = var.zone + desired_status = "RUNNING" + allow_stopping_for_update = true + + # Our firewall looks for this tag when deciding whether to allow SSH traffic + # to an instance. + tags = ["ssh-enabled"] + + boot_disk { + initialize_params { + image = "cos-cloud/cos-stable" # latest stable Container-Optimized OS. + size = 10 # smallest disk possible is 10 GB. + type = "pd-ssd" # use an SSD, not an HDD, because c'mon. + } + } + + metadata = { + enable-oslogin = "TRUE" + } + + metadata_startup_script = templatefile("${path.module}/run_cloud_sql_proxy.tpl", { + "db_instance_name" = var.db_instance_name, + "service_account_key" = module.serviceaccount.private_key, + }) + + network_interface { + network = var.vpc_name + subnetwork = data.google_compute_subnetwork.regional_subnet.self_link + + # The access_config block must be set for the instance to have a public IP, + # even if it's empty. + access_config {} + } + + scheduling { + # Migrate to another physical host during OS updates to avoid downtime. + on_host_maintenance = "MIGRATE" + } + + service_account { + email = module.serviceaccount.email + # These are OAuth scopes for the various Google Cloud APIs. We're already + # using IAM roles (specifically, Cloud SQL Editor) to control what this + # instance can and cannot do. We don't need another layer of OAuth + # permissions on top of IAM, so we grant cloud-platform scope to the + # instance. This is the maximum possible scope. It gives the instance + # access to all Google Cloud APIs through OAuth. + scopes = ["cloud-platform"] + } +} + +module "serviceaccount" { + source = "../serviceaccount" + + name = "cloud-sql-proxy" + role = "roles/cloudsql.editor" +} diff --git a/modules/dbproxy/run_cloud_sql_proxy.tpl b/modules/dbproxy/run_cloud_sql_proxy.tpl new file mode 100644 index 0000000..adc27fd --- /dev/null +++ b/modules/dbproxy/run_cloud_sql_proxy.tpl @@ -0,0 +1,23 @@ +#!/bin/bash +set -euo pipefail + +# We write the key to /var because it's one of the few directories that A) is +# writeable, and B) persists between reboots. B is important because GCP will +# automatically reboot the server if it goes down. We don't want to lose the +# key after a reboot. +echo '${service_account_key}' >/var/svc_account_key.json +chmod 400 /var/svc_account_key.json + +# TODO: delete this line and add the `--pull=always` flag to `docker run` +docker pull gcr.io/cloudsql-docker/gce-proxy:latest + +# -p 127.0.0.1:5432:3306 -- cloud_sql_proxy exposes port 3306 on the container, even for Postgres. +# We map 3306 in the container to 5432 on the host. '127.0.0.1' means +# that you can only connect to host port 5432 over localhost. +# -v /var/svc_account_key.json:/key.json:ro -- The file provisioner will copy the service account key file to /key.json +# on the host. We will mount it read-only into the container at the +# same path. +# -ip_address_types=PRIVATE -- The proxy should only try to connect to the db's private IP. +# -instances=${db_instance_name}=tcp:0.0.0.0:3306 -- The instance name will be something like 'my-project:us-central1:my-db'. +# The proxy should accept incoming TCP connections on port 3306. +docker run --rm -p 127.0.0.1:5432:3306 -v /var/svc_account_key.json:/key.json:ro gcr.io/cloudsql-docker/gce-proxy:latest /cloud_sql_proxy -credential_file=/key.json -ip_address_types=PRIVATE -instances=${db_instance_name}=tcp:0.0.0.0:3306 diff --git a/modules/dbproxy/variables.tf b/modules/dbproxy/variables.tf new file mode 100644 index 0000000..c114bf8 --- /dev/null +++ b/modules/dbproxy/variables.tf @@ -0,0 +1,26 @@ +// dbproxy module + +variable "db_instance_name" { + description = "The name of the Cloud SQL db, e.g. my-project:us-centra1:my-sql-db" + type = string +} + +variable "machine_type" { + description = "The type of VM you want, e.g. f1-micro, c2-standard-4" + type = string +} + +variable "region" { + description = "The region that the proxy instance will run in (e.g. us-central1)" + type = string +} + +variable "vpc_name" { + description = "The name of the VPC that the proxy instance will run in" + type = string +} + +variable "zone" { + description = "The zone where the VM will be created, e.g. us-centra1-a" + type = string +} diff --git a/modules/serviceaccount/main.tf b/modules/serviceaccount/main.tf new file mode 100644 index 0000000..9e2c496 --- /dev/null +++ b/modules/serviceaccount/main.tf @@ -0,0 +1,15 @@ +// serviceaccount module + +resource "google_service_account" "account" { + account_id = var.name + description = "The service account used by Cloud SQL Proxy to connect to the db" +} + +resource "google_project_iam_member" "role" { + role = var.role + member = "serviceAccount:${google_service_account.account.email}" +} + +resource "google_service_account_key" "key" { + service_account_id = google_service_account.account.name +} diff --git a/modules/serviceaccount/outputs.tf b/modules/serviceaccount/outputs.tf new file mode 100644 index 0000000..6a1c11f --- /dev/null +++ b/modules/serviceaccount/outputs.tf @@ -0,0 +1,8 @@ + +output "email" { + value = google_service_account.account.email +} + +output "private_key" { + value = base64decode(google_service_account_key.key.private_key) +} diff --git a/modules/serviceaccount/variables.tf b/modules/serviceaccount/variables.tf new file mode 100644 index 0000000..7205a8a --- /dev/null +++ b/modules/serviceaccount/variables.tf @@ -0,0 +1,11 @@ +// serviceaccount module + +variable "name" { + description = "The service account name (e.g. cloud-sql-proxy)" + type = string +} + +variable "role" { + description = "The role assigned to the service account (e.g. roles/cloudsql.editor)" + type = string +} diff --git a/modules/vpc/main.tf b/modules/vpc/main.tf new file mode 100644 index 0000000..724db4c --- /dev/null +++ b/modules/vpc/main.tf @@ -0,0 +1,46 @@ +// vpc module + +resource "google_compute_network" "vpc" { + name = var.name + routing_mode = "GLOBAL" + auto_create_subnetworks = true +} + +# We need to allocate an IP block for private IPs. We want everything in the VPC +# to have a private IP. This improves security and latency, since requests to +# private IPs are routed through Google's network, not the Internet. +resource "google_compute_global_address" "private_ip_block" { + name = "private-ip-block" + description = "A block of private IP addresses that are accessible only from within the VPC." + purpose = "VPC_PEERING" + address_type = "INTERNAL" + ip_version = "IPV4" + # We don't specify a address range because Google will automatically assign one for us. + prefix_length = 20 # ~4k IPs + network = google_compute_network.vpc.self_link +} + +# This enables private services access. This makes it possible for instances +# within the VPC and Google services to communicate exclusively using internal +# IP addresses. Details here: +# https://cloud.google.com/sql/docs/postgres/configure-private-services-access +resource "google_service_networking_connection" "private_vpc_connection" { + network = google_compute_network.vpc.self_link + service = "servicenetworking.googleapis.com" + reserved_peering_ranges = [google_compute_global_address.private_ip_block.name] +} + +# We'll need this to connect to the Cloud SQL Proxy. +resource "google_compute_firewall" "allow_ssh" { + name = "allow-ssh" + description = "Allow SSH traffic to any instance tagged with 'ssh-enabled'" + network = google_compute_network.vpc.name + direction = "INGRESS" + + allow { + protocol = "tcp" + ports = ["22"] + } + + target_tags = ["ssh-enabled"] +} diff --git a/modules/vpc/outputs.tf b/modules/vpc/outputs.tf new file mode 100644 index 0000000..101bdbe --- /dev/null +++ b/modules/vpc/outputs.tf @@ -0,0 +1,16 @@ +// vpc module + +output "link" { + description = "A link to the VPC resource, useful for creating resources inside the VPC" + value = google_compute_network.vpc.self_link +} + +output "name" { + description = "The name of the VPC" + value = google_compute_network.vpc.name +} + +output "private_vpc_connection" { + description = "The private VPC connection" + value = google_service_networking_connection.private_vpc_connection +} diff --git a/modules/vpc/variables.tf b/modules/vpc/variables.tf new file mode 100644 index 0000000..39af353 --- /dev/null +++ b/modules/vpc/variables.tf @@ -0,0 +1,6 @@ +// vpc module + +variable "name" { + description = "The name of the VPC to create" + type = string +} diff --git a/variables.tf b/variables.tf new file mode 100644 index 0000000..c183ac2 --- /dev/null +++ b/variables.tf @@ -0,0 +1,6 @@ +// root module + +variable "db_password" { + description = "The Postgres password" + type = string +}