Introduction
This article discusses mapping custom domains for Google Cloud Run Managed. In a future article, I will cover Google Cloud Run on GKE.
Google Cloud Run supports using a custom domain rather than the default address provided for a deployed service. This custom domain supports both HTTP and HTTPS. Google Cloud Run provisions a custom managed SSL certificate for your custom domain automatically. This feature requires support from your domain’s DNS servers.
In the Cloud Run API, this is called “DomainMappingService”. The Go interface is NamespacesDomainmappingsService
.
Routes
A Route provides a network endpoint for a user’s service (which consists of a series of software and configuration Revisions over time). A kubernetes namespace can have multiple routes. The route provides a long-lived, stable, named, HTTP-addressable endpoint that is backed by one or more Revisions. The default configuration is for the route to automatically route traffic to the latest revision created by a Configuration. For more complex scenarios, the API supports splitting traffic on a percentage basis, and CI tools could maintain multiple configurations for a single route (e.g. “golden path” and “experiments”) or reference multiple revisions directly to pin revisions during an incremental rollout and n-way traffic split. The route can optionally assign addressable subdomains to any or all backing revisions.
Text source: link
Domain-mappings
Google Cloud Run provides the ability to map a custom domain name (myservice.example.com) to a Cloud Run network endpoint (see Routes) and supports creating custom managed SSL certificates to provide services over HTTPS.
If you are using HTTPS, the following considerations apply:
- For Cloud Run Managed, a managed certificate for HTTPS connections is automatically issued when you map a service to a custom domain. Note that provisioning the SSL certificate should take about 15 minutes. You cannot upload and use your own certificates.
- For Cloud Run on GKE, only HTTP is available by default. You can install a wildcard SSL certificate to enable SSL for all services mapped to domains included in the wildcard SSL certificate. For more information, see Enabling HTTPS.
You can map multiple custom domains to the same Cloud Run service.
Text source: link
Service Name
When you create a Google Cloud Run service, you specify a name for the deployed service. The Service Name is used as part of the deployed URL. Service Names must follow the rules for DNS names:
- Start with a lowercase letter.
- Up to 64 characters long.
- Comprise lowercase letters, number or hyphens.
- Cannot end with a hyphen.
- Cannot include underscores.
For more information about DNS names, refer to RFC 2181.
Cloud Run Service URL
Google Cloud Run creates a Service URL based upon the Service Name concatenated with a hash identifier of your Google Cloud Project, plus the Google controlled Base Domain a.run.app
. An example is cloudrun-a1b2c37qaq-uc.a.run.app
. The Service URL supports both HTTP and HTTPS protocols. The SSL certificate contains the following details:
- Issued by “Google Internet Authority G3”.
- Issued to “*.appspot.com”.
- Includes SAN for “*.a.run.app”.
- Valid for 90 days (typically).
SAN: Subject Alternative Name
If you map a custom domain, without using Cloud Run Domain Mapping feature, with your DNS server, example cloudrun.example.com
CNAME cloudrun-a1b2c37qaq-uc.a.run.app
you will receive a certificate error “ERR_CERT_COMMON_NAME_INVALID” when you load the URL in your browser. The reason is that HTTPS requires the SSL certificate to contain the hostname of the URL that you are visiting in either the Subject or SAN fields. Cloud Run Domain Mapping adds the support to support custom domain names by creating a custom managed SSL certificate for you that matches your domain name.
The managed SSL certificate contains the following details:
- Issued by “Let’s Encrypt Authority X3”.
- Issued to “cloudrun.example.com”.
- Includes SAN for “cloudrun.example.com”.
- Valid for 90 days (typically).
- Google manages SSL certificate renewal automatically.
Domain Ownership
For this section, let’s assume that the domain name that you want to use is example.com
. You plan to deploy several Cloud Run services with custom domain names based from example.com
. The domain example.com
is the “Base Domain”.
Before you can use a Base Domain in Google Cloud Run, you must verify ownership. You can do this in the Google Cloud Console or with the Google Cloud SDK CLI. For these examples, we will use the CLI.
At your terminal, execute the following command to begin the process to verify domain ownership:
1 |
gcloud domains verify example.com |
This command will launch the Google Webmaster Central. In the Webmaster console complete domain ownership verification. If you have already verified your domain, you will be notified of that and the verification process can be skipped. For more information about Webmaster Central, refer to Verify your site ownership.
Once the domain ownership verification is complete, you can map Cloud Run services to the Base Domain or any subdomains of the Base Domain. Example cloudrun.example.com
.
Mapping a Custom Domain
Mapping a custom domain is a three-step process. First you create a domain-mapping in Google Cloud Run. Second, you create DNS Resource Records in your DNS server. Third, Google Cloud Run verifies the required DNS Resource Records and issues the custom SSL certificate bound to your Cloud Run service.
Step 1: Create a Domain Mapping
In this example, I have created a Cloud Run Service named cloudrun
. The Base Domain is example.com
and I want to create the custom domain cloudrun.example.com
.
1 |
<span id="selectionBoundary_1563167692096_6507159925725321" class="rangySelectionBoundary" style="line-height: 0; display: none;"></span>gcloud beta run domain-mappings create --service cloudrun --domain cloudrun.example.com --platform managed<span id="selectionBoundary_1563167692096_5721310392677901" class="rangySelectionBoundary" style="line-height: 0; display: none;"></span> |
Important Note: In my Go code that creates Cloud Run Domain Mappings, I use a service account for authorization. I had to add the service accounts email address to the Webmaster Console as a verified owner of the domain name. I will be publishing this source code in another article in this series and on GitHub.
Step 2: Create the DNS Resource Records
1 |
gcloud beta run domain-mappings describe --domain cloudrun.example.com --platform managed |
At the bottom of the listing is the DNS Resource Record(s) that you need to create in your DNS server:
1 2 3 4 |
resourceRecords: - name: cloudrun rrdata: ghs.googlehosted.com type: CNAME |
For my Google DNS Server, I have named my Zone “jhanley”. Example Google Cloud SDK CLI commands to add the required DNS Resource Record:
1 2 3 |
gcloud dns record-sets transaction start --zone jhanley gcloud dns record-sets transaction add --name cloudrun.jhanley.dev --ttl 600 --type CNAME ghs.googlehosted.com --zone jhanley gcloud dns record-sets transaction execute --zone jhanley |
Step 3: Google Deploys Custom SSL Certificate
In Step 1, where we created the domain mapping, Google automatically started a background process that polls your DNS server looking for the correct DNS Resource Records. Once found, Google automatically provisions the SSL certificate. The entire process takes about five to ten minutes after the correct DNS Resource Records are created by you.
Displaying Google Cloud Run Domain Mappings
The following command will display the current domain mappings. In the source code that I wrote for this article, the same information is displayed.
1 |
gcloud alpha run domain-mappings list --platform managed |
Example Program to List Google Cloud Run Domain Mappings
This program requires a command-line option --project PROJECT_ID
There is one addition command-line option --debug
. This option will enable displaying everything. This is useful for studying the various records and interfaces for Google Cloud Run.
This code only supports one region at a time. At line 21 is the constant endpoint
. Select the region for your services.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 |
package main import ( "context" "fmt" "os" "strings" "google.golang.org/api/option" "google.golang.org/api/run/v1alpha1" ) const ( createDefaultClientFlag = false // scopes = "https://www.googleapis.com/auth/cloud-platform" scopes = run.CloudPlatformScope saFile = "Enter the full path to your service account JSON key file" // endpoint = "https://asia-northeast1-run.googleapis.com/" // endpoint = "https://europe-west1-run.googleapis.com/" endpoint = "https://us-central1-run.googleapis.com/" // endpoint = "https://us-east1-run.googleapis.com/" ) // Set the Project ID here, or via command line option --project=PROJECT_ID var projectID string var debugFlag = false func main() { var err error var runService *run.APIService //************************************************************ // Process the command line and verify we have a Project ID //************************************************************ processCmdline() if projectID == "" { cmdHelp() os.Exit(1) } //************************************************************ // Create a Cloud Run Client //************************************************************ ctx := context.Background() if createDefaultClientFlag == true { runService, err = createDefaultClient(ctx) } else { runService, err = createScopedClient(ctx) } if err != nil { fmt.Println("Error:", err) os.Exit(1) } if debugFlag == true { fmt.Println("**************************************************") fmt.Println("APIService:") fmt.Println("BasePath: ", runService.BasePath) fmt.Println("UserAgent: ", runService.UserAgent) fmt.Printf("Namespaces: %p\n", runService.Namespaces) fmt.Printf("Projects: %p\n", runService.Projects) fmt.Println("**************************************************") } //************************************************************ // https://godoc.org/google.golang.org/api/run/v1alpha1#APIService // // Get the Namespaces Service interface //************************************************************ namespaces := runService.Namespaces if namespaces == nil { fmt.Println("Error: runService.Namespaces is nil") os.Exit(1) } //************************************************************ // https://godoc.org/google.golang.org/api/run/v1alpha1#NamespacesService // // Get the Domainmappings Service interface //************************************************************ domains := namespaces.Domainmappings if domains == nil { fmt.Println("Error: runService.Domainmappings is nil") os.Exit(1) } //************************************************************ // https://godoc.org/google.golang.org/api/run/v1alpha1#NamespacesDomainmappingsService.List // // List the Domainmappings //************************************************************ parent := "namespaces/" + projectID call := domains.List(parent) //************************************************************ // https://godoc.org/google.golang.org/api/run/v1alpha1#ListDomainMappingsResponse //************************************************************ response, err := call.Do() if err != nil { fmt.Println(err) return } if debugFlag == false { listDomainMappingsResponse(response) } else { debugPrintListDomainMappingsResponse(response) } } func processCmdline() { for index := 1; index < len(os.Args); index++ { arg := os.Args[index] if arg == "-project" || arg == "--project" { if index == len(os.Args) - 1 { fmt.Println("Error: Missing Project ID") os.Exit(1) } projectID = os.Args[index + 1] index++ } else if strings.HasPrefix(arg, "-project=") { p := arg[9:] projectID = p } else if strings.HasPrefix(arg, "--project=") { p := arg[10:] projectID = p } else if arg == "-debug" || arg == "--debug" { debugFlag = true } else { cmdHelp() os.Exit(1) } } } func cmdHelp() { fmt.Println("Usage: list_domain_mappings [--project=project_id]") fmt.Println("--project=PROJECT_ID") fmt.Println("--project PROJECT_ID") } func createDefaultClient(ctx context.Context) (*run.APIService, error) { // https://godoc.org/google.golang.org/api/run/v1alpha1#NewService return run.NewService(ctx, option.WithEndpoint(endpoint)) } func createScopedClient(ctx context.Context) (*run.APIService, error) { // https://godoc.org/google.golang.org/api/run/v1alpha1#NewService // https://godoc.org/google.golang.org/api/option#WithCredentials // https://godoc.org/google.golang.org/api/option#WithScopes return run.NewService(ctx, option.WithCredentialsFile(saFile), option.WithEndpoint(endpoint), option.WithScopes(scopes)) } func debugPrintMetadata(indent string, metadata *run.ObjectMeta) { // https://godoc.org/google.golang.org/api/run/v1alpha1#ObjectMeta if metadata == nil { return } fmt.Printf("%sannotations:\n", indent) debugPrintMap(indent + " ", metadata.Annotations) fmt.Printf("%sclusterName: %s\n", indent, metadata.ClusterName) fmt.Printf("%screationTimestamp: '%s'\n", indent, metadata.CreationTimestamp) fmt.Printf("%sdeletionGracePeriodSeconds: '%d'\n", indent, metadata.DeletionGracePeriodSeconds) fmt.Printf("%sdeletionTimestamp: '%s'\n", indent, metadata.DeletionTimestamp) fmt.Printf("%sfinalizers:\n", indent) for _, s := range metadata.Finalizers { fmt.Printf("%s %s\n", indent, s) } fmt.Printf("%sgenerateName: %s\n", indent, metadata.GenerateName) fmt.Printf("%sgeneration: %d\n", indent, metadata.Generation) fmt.Printf("%sinitializers:\n", indent) debugPrintInitializers(indent + " ", metadata.Initializers) fmt.Printf("%slabels:\n", indent) debugPrintMap(indent + " ", metadata.Labels) fmt.Printf("%sname: %s\n", indent, metadata.Name) fmt.Printf("%snamespace: %s\n", indent, metadata.Namespace) fmt.Printf("%sownerReferences:\n", indent) debugPrintOwnerReferences(indent + " ", metadata.OwnerReferences) fmt.Printf("%sresourceVersion: %s\n", indent, metadata.ResourceVersion) fmt.Printf("%sselfLink: %s\n", indent, metadata.SelfLink) fmt.Printf("%suid: %s\n", indent, metadata.Uid) fmt.Printf("%sforceSendFields:\n", indent) for _, str := range metadata.ForceSendFields { fmt.Printf("%s %s\n",indent, str) } fmt.Printf("%snullFields:\n", indent) for _, str := range metadata.NullFields { fmt.Printf("%s %s\n",indent, str) } } func debugPrintMap(indent string, m map[string]string) { for k,v := range m { fmt.Printf("%s%s: %s\n", indent, k, v) } } func debugPrintDomainMappingSpec(indent string, spec *run.DomainMappingSpec) { // https://godoc.org/google.golang.org/api/run/v1alpha1#DomainMappingSpec if spec == nil { return } fmt.Printf("%scertificateMode: %s\n", indent, spec.CertificateMode) fmt.Printf("%sforceOverride: %t\n", indent, spec.ForceOverride) fmt.Printf("%srouteName: %s\n", indent, spec.RouteName) fmt.Printf("%sforceSendFields:\n", indent) for _, str := range spec.ForceSendFields { fmt.Printf("%s %s\n",indent, str) } fmt.Printf("%snullFields:\n", indent) for _, str := range spec.NullFields { fmt.Printf("%s %s\n",indent, str) } } func debugPrintDomainMappingStatus(indent string, status *run.DomainMappingStatus) { // https://godoc.org/google.golang.org/api/run/v1alpha1#DomainMappingStatus if status == nil { return } fmt.Printf("%sconditions:\n", indent) debugPrintDomainMappingCondition(indent + " ", indent + "- ", status.Conditions) fmt.Printf("%smappedRouteName: %s\n", indent, status.MappedRouteName) fmt.Printf("%sobservedGeneration: %d\n", indent, status.ObservedGeneration) fmt.Printf("%sresourceRecords:\n", indent) debugPrintResourceRecords(indent + " ", status.ResourceRecords) fmt.Printf("%sforceSendFields: %s\n", indent, status.ForceSendFields) fmt.Printf("%snullFields: %s\n", indent, status.NullFields) } func debugPrintResourceRecords(indent string, records []*run.ResourceRecord) { // https://godoc.org/google.golang.org/api/run/v1alpha1#ResourceRecord for _, t := range records { fmt.Printf("%sname: %s\n", indent, t.Name) fmt.Printf("%srrdata: %s\n", indent, t.Rrdata) fmt.Printf("%stype: %s\n", indent, t.Type) fmt.Printf("%sforceSendFields:\n", indent) for _, str := range t.ForceSendFields { fmt.Printf("%s %s\n",indent, str) } fmt.Printf("%snullFields:\n", indent) for _, str := range t.NullFields { fmt.Printf("%s %s\n",indent, str) } } } func debugPrintDomainMappingCondition(indent, indent2 string, conditions []*run.DomainMappingCondition) { // https://godoc.org/google.golang.org/api/run/v1alpha1#DomainMappingCondition for _, t := range conditions { fmt.Printf("%slastTransitionTime: '%s'\n", indent2, t.LastTransitionTime) fmt.Printf("%smessage: '%s'\n", indent, t.Message) fmt.Printf("%sreason: '%s'\n", indent, t.Reason) fmt.Printf("%sseverity: '%s'\n", indent, t.Severity) fmt.Printf("%sstatus: '%s'\n", indent, t.Status) fmt.Printf("%stype: %s\n", indent, t.Type) fmt.Printf("%sforceSendFields:\n", indent) for _, str := range t.ForceSendFields { fmt.Printf("%s %s\n", indent + " ", str) } fmt.Printf("%snullFields:\n", indent) for _, str := range t.NullFields { fmt.Printf("%s %s\n", indent + " ", str) } } } func debugPrintInitializers(indent string, initializers *run.Initializers) { // https://godoc.org/google.golang.org/api/run/v1alpha1#Initializers if initializers == nil { return } for _, str := range initializers.ForceSendFields { fmt.Printf("%sforceSendFields: %s\n",indent, str) } } func debugPrintOwnerReferences(indent string, owners []*run.OwnerReference) { // https://godoc.org/google.golang.org/api/run/v1alpha1#OwnerReference for _, owner := range owners { fmt.Printf("%sapiVersion: %s\n", indent, owner.ApiVersion) fmt.Printf("%sblockOwnerDeletion: %t\n", indent, owner.BlockOwnerDeletion) fmt.Printf("%scontroller: %t\n", indent, owner.Controller) fmt.Printf("%skind: %s\n", indent, owner.Kind) fmt.Printf("%suid: %s\n", indent, owner.Uid) fmt.Printf("%sforceSendFields:\n", indent) for _, str := range owner.ForceSendFields { fmt.Printf("%s %s\n",indent, str) } fmt.Printf("%snullFields:\n", indent) for _, str := range owner.NullFields { fmt.Printf("%s %s\n",indent, str) } } } func listDomainMappingsResponse(response *run.ListDomainMappingsResponse) { //************************************************************ // https://godoc.org/google.golang.org/api/run/v1alpha1#ListDomainMappingsResponse // // Display output similar to the Cloud SDK CLI: // gcloud alpha run domain-mappings list //************************************************************ if len(response.Items) == 0 { fmt.Println() fmt.Println("No Domain Mapping Items") } fmt.Printf("%-40s %-20s %-20s\n", "DOMAIN", "SERVICE", "REGION") for _, item := range response.Items { domain := item.Metadata.Name service := item.Metadata.Labels["cloud.googleapis.com/location"] region := item.Spec.RouteName fmt.Printf("%-40s %-20s %-20s\n", domain, service, region) } } func debugPrintListDomainMappingsResponse(response *run.ListDomainMappingsResponse) { indent := " " //************************************************************ // https://godoc.org/google.golang.org/api/run/v1alpha1#ListDomainMappingsResponse // // Display the information returned // This displays everything for learning and debugging //************************************************************ fmt.Printf("apiVersion: %s\n", response.ApiVersion) fmt.Printf("kind: %s\n", response.Kind) fmt.Println("forceSendFields:") for _, str := range response.ForceSendFields { fmt.Println(str) } fmt.Println("nullFields:") for _, str := range response.NullFields { fmt.Println(str) } //************************************************************ // https://godoc.org/google.golang.org/api/run/v1alpha1#DomainMapping // // Display each item returned //************************************************************ if len(response.Items) == 0 { fmt.Println() fmt.Println("No Domain Mapping Items") } for _, item := range response.Items { fmt.Println() fmt.Println("**************************************************") fmt.Println("metadata:") debugPrintMetadata(indent, item.Metadata) fmt.Println("spec:") debugPrintDomainMappingSpec(indent, item.Spec) fmt.Println("status:") debugPrintDomainMappingStatus(indent, item.Status) } } |
Credits
I write free articles about technology. Recently, I learned about Pexels.com which provides free images. The image in this article is courtesy of Anthony at Pexels.
I design software for enterprise-class systems and data centers. My background is 30+ years in storage (SCSI, FC, iSCSI, disk arrays, imaging) virtualization. 20+ years in identity, security, and forensics.
For the past 14+ years, I have been working in the cloud (AWS, Azure, Google, Alibaba, IBM, Oracle) designing hybrid and multi-cloud software solutions. I am an MVP/GDE with several.
Leave a Reply