Разобраться, какие в облаке есть сервисы, — половина дела. Вторая половина — обратиться к ним из кода так, чтобы это было безопасно (без секретов в репозитории) и тестируемо (без реального облака в тестах). Разберём это на четырёх стеках, Spring Boot, Go, Node и Python: переключатель языка над примерами. Прежде чем читать, полезно понимать модель доступа из статьи про Cloud IAM — весь фокус тут в том, откуда приложение берёт права.
Библиотека идёт по цепочке и берёт первый найденный источник: на ноутбуке это ваша учётка после входа, в облаке привязанный сервисный аккаунт, а забытая переменная с путём к старому ключу перебивает и то и другое.
Клиентские библиотеки и надстройки фреймворков
К сервисам GCP обращаются через официальные клиентские библиотеки Google Cloud для своего языка: по одному клиенту на сервис, один экземпляр на приложение, права из Application Default Credentials, а всё, что различается между средами (имя бакета, проект, регион), приходит из настроек.
В Java это библиотеки com.google.cloud, а в Spring-приложениях удобнее набор стартеров Spring Cloud GCP: клиента собирает стартер, вы получаете его внедрением зависимости, а настройки держите в application.yml.
@Service
public class ReportStorage {
private final Storage storage;
private final String bucket;
public ReportStorage(Storage storage, @Value("${app.reports.bucket}") String bucket) {
this.storage = storage;
this.bucket = bucket;
}
public void save(String name, byte[] content) {
BlobInfo blob = BlobInfo.newBuilder(bucket, name)
.setContentType("application/pdf")
.build();
storage.create(blob, content);
}
}
Бин Storage сюда приходит от стартера spring-cloud-gcp-starter-storage. Без Spring тот же клиент создают руками — StorageOptions.getDefaultInstance().getService(), и дальше всё одинаково.
В Go это модули cloud.google.com/go/storage, cloud.google.com/go/pubsub и другие; клиента создают в main через storage.NewClient(ctx) и передают дальше зависимостью:
type ReportStorage struct {
client *storage.Client
bucket string
}
func NewReportStorage(client *storage.Client, cfg Config) *ReportStorage {
return &ReportStorage{client: client, bucket: cfg.ReportsBucket}
}
func (s *ReportStorage) Save(ctx context.Context, name string, content []byte) error {
w := s.client.Bucket(s.bucket).Object(name).NewWriter(ctx)
w.ContentType = "application/pdf"
if _, err := w.Write(content); err != nil {
return err
}
return w.Close()
}
В Node это пакеты @google-cloud/storage, @google-cloud/pubsub и другие; клиента создают один раз при старте:
const { Storage } = require('@google-cloud/storage');
class ReportStorage {
constructor(bucket = process.env.APP_REPORTS_BUCKET, storage = new Storage()) {
this.bucket = storage.bucket(bucket);
}
save(name, content) {
return this.bucket.file(name).save(content, { contentType: 'application/pdf' });
}
}
В Python это пакеты google-cloud-storage, google-cloud-pubsub и другие; клиента создают один раз при старте:
from google.cloud import storage
class ReportStorage:
def __init__(self, client: storage.Client, bucket: str):
self.bucket = client.bucket(bucket)
def save(self, name: str, content: bytes) -> None:
self.bucket.blob(name).upload_from_string(content, content_type="application/pdf")
Имя бакета приходит из конфигурации: в тестовом и боевом окружении оно разное, и зашивать его строкой в коде нельзя.
Application Default Credentials: откуда берутся права
Главный принцип — никаких секретов в коде. За получение доступа отвечает Application Default Credentials (ADC) — библиотека ищет учётные данные в строго определённом порядке и берёт первое, что нашла:
- переменная окружения
GOOGLE_APPLICATION_CREDENTIALS— путь к файлу с учётными данными: JSON-ключу или конфигурации federation; - файл локальной учётки, который кладёт команда
gcloud auth application-default login, — под ним вы работаете на своей машине; - привязанный сервисный аккаунт — в облаке (Compute Engine, Cloud Run, GKE) платформа выдаёт временный токен через сервер метаданных, ни ключа, ни пароля в коде нет.
Порядок этот — не про «что лучше»: в облаке правильный источник как раз последний, просто первых двух там не оказывается. Зато он объясняет самый мутный отказ новичка. Когда-то на машине выставили GOOGLE_APPLICATION_CREDENTIALS со старым JSON-ключом — и код молча ходит не под тем аккаунтом, который вы настраивали: прав не хватает, а в сообщении об ошибке ни слова о причине. Первое, что проверяют при странном отказе, — эта переменная.
В CI вне GCP вместо вечного JSON-ключа настраивают Workload Identity Federation: она подставляется через тот же первый пункт, только в файле лежит не ключ, а описание обмена удостоверения на временный токен.
Один и тот же код работает и локально, и в облаке — меняется только источник учётных данных. Это избавляет от соблазна захардкодить ключ.
Подключение к Cloud SQL
К Cloud SQL подключаются обычным драйвером PostgreSQL (JDBC, pgx, pg, psycopg), но не по внешнему IP, а через коннектор Cloud SQL для своего языка (Java-коннектор, cloud.google.com/go/cloudsqlconn, @google-cloud/cloud-sql-connector, cloud-sql-python-connector) или Auth Proxy рядом с сервисом: он устанавливает защищённое соединение по правам сервисного аккаунта, так что у базы нет публичного адреса и пароль не ходит в открытом виде. Memorystore (Redis) подключают стандартным клиентом по внутреннему адресу.
Секрет как свойство приложения
Соседняя статья советует хранить секреты в хранилище, и вот как они попадают в настройки: у надстройки для Spring есть источник свойств, который читает значения прямо из хранилища секретов.
spring:
config:
import: sm://
datasource:
password: ${sm://projects/acme-prod/secrets/db-password/versions/latest}
Коротко про синтаксис: префикс sm:// означает обращение к хранилищу секретов; можно указывать полный путь с версией или короткую форму с именем секрета. Приложение при этом ничего не знает про хранилище — для него это обычное свойство.
Три вещи, которые надо знать.
Права. Служебная учётная запись приложения должна иметь роль доступа к секрету (roles/secretmanager.secretAccessor) — на конкретный секрет, а не на проект. Это самая частая причина «не стартует: отказано в доступе к секрету».
Версия. latest удобно и означает, что при добавлении новой версии значение поменяется — но только при следующем чтении, то есть при перезапуске. Явная версия (/versions/7) даёт предсказуемость: значение не изменится само.
Момент чтения. Свойства читаются при старте, поэтому смена секрета не доезжает до работающего приложения. Отсюда то же правило, что и в других облаках: смену пароля базы делают с двумя действующими значениями, а для секретов, которые обязаны обновляться на живом сервисе, читают значение из кода с кэшем на несколько минут.
В Go источника свойств нет: секрет читают при старте клиентом Secret Manager и кладут в структуру конфигурации рядом со значениями из переменных окружения.
client, err := secretmanager.NewClient(ctx)
if err != nil {
return err
}
defer client.Close()
resp, err := client.AccessSecretVersion(ctx, &secretmanagerpb.AccessSecretVersionRequest{
Name: "projects/acme-prod/secrets/db-password/versions/latest",
})
if err != nil {
return err
}
cfg.DBPassword = string(resp.Payload.Data)
Три вещи, которые надо знать. Права. Служебная учётная запись приложения должна иметь роль roles/secretmanager.secretAccessor на конкретный секрет, а не на проект; иначе сервис не стартует с отказом в доступе. Версия. latest означает, что при добавлении новой версии значение поменяется только при следующем чтении, то есть при перезапуске; явная версия (/versions/7) даёт предсказуемость. Момент чтения. Секрет прочитан при старте, поэтому смену пароля базы делают с двумя действующими значениями, а секреты, которые обязаны обновляться на живом сервисе, читают из кода с кэшем на несколько минут.
В Node секрет читают при старте клиентом Secret Manager и кладут в объект конфигурации рядом со значениями из переменных окружения.
const { SecretManagerServiceClient } = require('@google-cloud/secret-manager');
const secrets = new SecretManagerServiceClient();
const [version] = await secrets.accessSecretVersion({
name: 'projects/acme-prod/secrets/db-password/versions/latest',
});
config.dbPassword = version.payload.data.toString();
Три вещи, которые надо знать. Права. Служебная учётная запись приложения должна иметь роль roles/secretmanager.secretAccessor на конкретный секрет, а не на проект. Версия. latest поменяется только при следующем чтении, то есть при перезапуске; явная версия (/versions/7) даёт предсказуемость. Момент чтения. Секрет прочитан при старте, поэтому смену пароля базы делают с двумя действующими значениями, а секреты для живого сервиса читают из кода с кэшем на несколько минут.
В Python секрет читают при старте клиентом Secret Manager и кладут в настройки рядом со значениями из переменных окружения.
from google.cloud import secretmanager
client = secretmanager.SecretManagerServiceClient()
response = client.access_secret_version(
name="projects/acme-prod/secrets/db-password/versions/latest"
)
settings.db_password = response.payload.data.decode()
Три вещи, которые надо знать. Права. Служебная учётная запись приложения должна иметь роль roles/secretmanager.secretAccessor на конкретный секрет, а не на проект. Версия. latest поменяется только при следующем чтении, то есть при перезапуске; явная версия (/versions/7) даёт предсказуемость. Момент чтения. Секрет прочитан при старте, поэтому смену пароля базы делают с двумя действующими значениями, а секреты для живого сервиса читают из кода с кэшем на несколько минут.
Очередь из кода: отправка и подписка
Очередь вынесена в описание статьи, и без примера он неполон. Отправка сообщения:
@Service
@RequiredArgsConstructor
class OrderEventPublisher {
private final PubSubTemplate pubSubTemplate; // из Spring Cloud GCP
public void publish(OrderCreated event) {
pubSubTemplate.publish("order-events", toJson(event),
Map.of("eventId", event.id(), "eventType", "OrderCreated"));
}
}
type OrderEventPublisher struct{ topic *pubsub.Topic }
func (p *OrderEventPublisher) Publish(ctx context.Context, event OrderCreated) error {
result := p.topic.Publish(ctx, &pubsub.Message{
Data: toJSON(event),
Attributes: map[string]string{"eventId": event.ID, "eventType": "OrderCreated"},
})
_, err := result.Get(ctx)
return err
}
const { PubSub } = require('@google-cloud/pubsub');
const topic = new PubSub().topic('order-events');
async function publish(event) {
await topic.publishMessage({ json: event, attributes: { eventId: event.id, eventType: 'OrderCreated' } });
}
from google.cloud import pubsub_v1
publisher = pubsub_v1.PublisherClient()
topic = publisher.topic_path("acme-prod", "order-events")
def publish(event: OrderCreated) -> None:
publisher.publish(topic, to_json(event), eventId=event.id, eventType="OrderCreated").result()
Приём сообщений:
@Component
@RequiredArgsConstructor
class OrderEventListener {
private final ProcessedEvents processedEvents;
private final OrderService orders;
@Bean
public MessageHandler handler() {
return message -> {
String eventId = (String) message.getHeaders().get("eventId");
if (!processedEvents.tryInsert(eventId)) { // защита от повтора
return; // уже обработано
}
orders.apply(parse(message.getPayload()));
};
}
}
err := sub.Receive(ctx, func(ctx context.Context, m *pubsub.Message) {
if !processed.TryInsert(ctx, m.Attributes["eventId"]) { // защита от повтора
m.Ack() // уже обработано
return
}
if err := orders.Apply(ctx, parse(m.Data)); err != nil {
m.Nack()
return
}
m.Ack()
})
subscription.on('message', async (message) => {
if (!(await processedEvents.tryInsert(message.attributes.eventId))) { // защита от повтора
message.ack(); // уже обработано
return;
}
await orders.apply(parse(message.data));
message.ack();
});
def handle(message: pubsub_v1.subscriber.message.Message) -> None:
if not processed_events.try_insert(message.attributes["eventId"]): # защита от повтора
message.ack() # уже обработано
return
orders.apply(parse(message.data))
message.ack()
subscriber.subscribe(subscription_path, callback=handle)
Главное в этом примере — вторая строка обработчика. Очередь гарантирует доставку «хотя бы один раз»: сообщение может прийти дважды при повторной доставке, при переподключении, при истечении срока подтверждения. Значит, обработчик обязан быть идемпотентным — и самый простой способ это обеспечить — вставка идентификатора события в таблицу с уникальным ключом в той же транзакции, что и бизнес-действие. Разбор приёма — в статье про идемпотентность (Java, Go, Node, Python).
Три настройки подписки, которые задают осознанно: срок подтверждения (сколько у обработчика времени, прежде чем сообщение выдадут снова — ставят больше самой долгой обработки), тема недоставленных (куда уходит сообщение после N неудачных попыток — без неё «отравленное» сообщение ходит по кругу до истечения срока хранения), и предел одновременной обработки (сколько сообщений обрабатывается параллельно — это же и есть защита базы за обработчиком).
Тестирование без облака
Гонять тесты против реального облака — медленно, дорого и требует доступа. Его подменяют локально:
- для Pub/Sub — официальный эмулятор Pub/Sub. Клиентская библиотека подхватывает его через переменную окружения
PUBSUB_EMULATOR_HOST, менять код не нужно: адрес есть — идём в эмулятор, адреса нет — в облако; - для Cloud Storage официального эмулятора у Google нет, поэтому берут сторонний — fake-gcs-server в контейнере через Testcontainers. Тут одной переменной не обойтись: клиенту в тесте явно задают адрес эмулятора и отключают аутентификацию (
NoCredentialsв Java,option.WithEndpointиoption.WithoutAuthentication()в Go,apiEndpointв Node,AnonymousCredentialsв Python), иначе он честно попытается сходить в настоящее облако и потребует учётные данные; - для PostgreSQL, Redis используют те же Testcontainers с обычными образами — managed-сервис в тестах не нужен, драйвер один и тот же.
Список официальных эмуляторов вообще короткий: Pub/Sub, Firestore, Datastore, Bigtable и Spanner — и всё. Для остальных сервисов выбора нет: либо сторонняя подделка, либо настоящий сервис в отдельном тестовом проекте.
Так интеграционные тесты идут быстро и не зависят от сети и облака. Общие подходы к тестированию — в разделе тестирования.
Где это применяется
Эта связка — как приложение аутентифицируется через сервисный аккаунт (ADC) и ходит в облачные сервисы через клиентские библиотеки — стоит за любым боевым сервисом в GCP. ADC делает так, что один код работает в любом окружении, а секреты не оседают в репозитории.
Типичные ошибки новичков:
- кладут JSON-ключ сервисного аккаунта в git или код — классическая утечка;
- жёстко прописывают путь к ключу вместо ADC — код не переносится между окружениями;
- подключаются к Cloud SQL по внешнему IP вместо коннектора — открывают базу наружу;
- тестируют против реального облака вместо эмуляторов и Testcontainers — тесты медленные и хрупкие.
Глубже: повторы, таймауты и коды ошибокрасширенное
То, что отличает рабочий код от демонстрационного: клиентские библиотеки Google сами повторяют часть запросов, и об этом надо знать, чтобы не построить повтор поверх повтора.
Что повторяется само. Ошибки сети, UNAVAILABLE (сервис недоступен), DEADLINE_EXCEEDED в части операций, RESOURCE_EXHAUSTED (превышена квота или ограничение частоты) — с нарастающей паузой. Число попыток и пределы настраиваются у клиента.
Что не повторяется и не должно. INVALID_ARGUMENT, PERMISSION_DENIED, NOT_FOUND, ALREADY_EXISTS, FAILED_PRECONDITION — это ошибки запроса, и повтор их не исправит.
Как настроить. У большинства клиентов настройки задаются при сборке:
var settings = PublisherStubSettings.newBuilder()
.publishSettings()
.setRetrySettings(RetrySettings.newBuilder()
.setMaxAttempts(4)
.setInitialRetryDelay(Duration.ofMillis(200))
.setMaxRetryDelay(Duration.ofSeconds(5))
.setTotalTimeout(Duration.ofSeconds(20)) // предел на всю операцию
.build())
.build();
У клиента Cloud Storage повторы настраивают на бакете или объекте, а предел на всю операцию задаёт контекст:
bucket := client.Bucket(name).Retryer(
storage.WithBackoff(gax.Backoff{Initial: 200 * time.Millisecond, Max: 5 * time.Second, Multiplier: 2}),
storage.WithMaxAttempts(4),
storage.WithPolicy(storage.RetryAlways),
)
ctx, cancel := context.WithTimeout(ctx, 20*time.Second) // предел на всю операцию
defer cancel()
const storage = new Storage({
retryOptions: {
autoRetry: true,
maxRetries: 4,
retryDelayMultiplier: 2,
maxRetryDelay: 5,
totalTimeout: 20, // предел на всю операцию, в секундах
},
});
from google.api_core.retry import Retry
retry = Retry(initial=0.2, maximum=5.0, multiplier=2, deadline=20.0) # deadline — предел на всю операцию
bucket.blob(name).upload_from_string(content, retry=retry)
Три правила. Свой повтор сверху не добавляют — либо настраиваете библиотеку, либо отключаете её повторы и пишете свой, но не оба. Предел на всю операцию обязателен (totalTimeout): без него набор повторов растягивается на минуты, и запрос пользователя висит. И повтор безопасен только для идемпотентных операций: чтение — да; отправка сообщения — с оговоркой (возможен дубль, поэтому у получателя должна быть защита); создание сущности без ключа идемпотентности — нет.
Отдельно про коды: библиотеки Google бросают исключения с кодом состояния, и разбор ошибок делают по коду, а не по тексту. RESOURCE_EXHAUSTED означает «подождите и повторите» (или поднимите квоту), PERMISSION_DENIED — идите к правам, UNAUTHENTICATED — проблема с учётными данными, а не с правами. Разница между последними двумя экономит много времени.
Глубже: идентификатор проекта и регионрасширенное
Мелочь, которая ломает тесты и локальный запуск: откуда библиотека берёт проект и регион.
Проект определяется цепочкой: явно переданное значение → переменная окружения (GOOGLE_CLOUD_PROJECT) → настройки инструмента командной строки → метаданные машины (в облаке). В облаке всё работает само; на ноутбуке и в тестах цепочка часто обрывается, и появляется ошибка вида «не удалось определить проект».
Поэтому в настройках приложения проект и регион задают явно, а не полагаются на окружение:
spring:
cloud:
gcp:
project-id: ${GOOGLE_CLOUD_PROJECT:acme-orders-dev}
pubsub:
emulator-host: ${PUBSUB_EMULATOR_HOST:}
type Config struct {
ProjectID string `env:"GOOGLE_CLOUD_PROJECT" envDefault:"acme-orders-dev"`
Region string `env:"GOOGLE_CLOUD_REGION" envDefault:"europe-west1"`
}
client, err := pubsub.NewClient(ctx, cfg.ProjectID) // адрес эмулятора библиотека сама берёт из PUBSUB_EMULATOR_HOST
const config = {
projectId: process.env.GOOGLE_CLOUD_PROJECT ?? 'acme-orders-dev',
region: process.env.GOOGLE_CLOUD_REGION ?? 'europe-west1',
};
const pubsub = new PubSub({ projectId: config.projectId }); // адрес эмулятора библиотека сама берёт из PUBSUB_EMULATOR_HOST
class Settings(BaseSettings):
google_cloud_project: str = "acme-orders-dev"
google_cloud_region: str = "europe-west1"
settings = Settings()
publisher = pubsub_v1.PublisherClient() # адрес эмулятора библиотека сама берёт из PUBSUB_EMULATOR_HOST
topic = publisher.topic_path(settings.google_cloud_project, "order-events")
В тестах то же самое: проект задаётся фиктивным значением (эмулятору настоящий не нужен), а адрес эмулятора приходит из переменной, которую выставляет контейнер. Регион у части сервисов обязателен (у реестра, у Cloud Run, у базы) — и его тоже держат в настройках, потому что он разный в средах.
Глубже: локальная разработка целикомрасширенное
«Один код везде» работает, если собрать маршрут для локального запуска. Вот он целиком.
1. Учётные данные. Один раз выполняется вход, который кладёт на машину учётные данные для библиотек:
gcloud auth application-default login
После этого библиотеки на ноутбуке работают с вашими правами — тем же кодом, что в облаке работает от служебной учётной записи. Если нужны именно её права (а это правильнее для отладки), используют работу от её имени:
gcloud auth application-default login --impersonate-service-account=orders-app@acme-dev.iam.gserviceaccount.com
Разбор механизма — в статье про доступ; главное здесь то, что ключ скачивать не нужно.
2. Эмуляторы для того, что не должно ходить в облако. Для очереди и для документной базы есть локальные эмуляторы, которые запускаются командой или контейнером:
gcloud beta emulators pubsub start --project=local-dev --host-port=localhost:8085
export PUBSUB_EMULATOR_HOST=localhost:8085
Библиотеки читают эту переменную и идут в эмулятор вместо облака — то есть код не меняется, меняется только окружение. Для базы поднимают обычный PostgreSQL в контейнере, для кэша — обычный Redis.
3. Профиль приложения для локального запуска. В нём собрано всё локальное: адреса эмуляторов, фиктивный проект, отключённые внешние вызовы, подробное журналирование.
# application-local.yml
spring:
cloud:
gcp:
project-id: local-dev
pubsub:
emulator-host: localhost:8085
secretmanager:
enabled: false # секреты локально берём из переменных
datasource:
url: jdbc:postgresql://localhost:5432/orders
В Go профилей нет: локальный набор переменных держат в файле .env.local, который читает godotenv при старте или подхватывает цель make run-local; в бою этого файла нет, и те же имена приходят из окружения контейнера.
# .env.local
GOOGLE_CLOUD_PROJECT=local-dev
PUBSUB_EMULATOR_HOST=localhost:8085
STORAGE_EMULATOR_HOST=http://localhost:4443
APP_SECRETS_FROM_ENV=true # секреты локально берём из переменных
DATABASE_URL=postgres://localhost:5432/orders
В Node локальный набор переменных держат в .env.local и читают через dotenv только вне боевого окружения; в контейнере те же имена приходят из окружения.
# .env.local
GOOGLE_CLOUD_PROJECT=local-dev
PUBSUB_EMULATOR_HOST=localhost:8085
STORAGE_EMULATOR_HOST=http://localhost:4443
APP_SECRETS_FROM_ENV=true # секреты локально берём из переменных
DATABASE_URL=postgres://localhost:5432/orders
В Python pydantic-settings читает файл .env рядом с проектом, если он есть; в бою файла нет, и те же имена приходят из окружения контейнера.
# .env
GOOGLE_CLOUD_PROJECT=local-dev
PUBSUB_EMULATOR_HOST=localhost:8085
STORAGE_EMULATOR_HOST=http://localhost:4443
APP_SECRETS_FROM_ENV=true # секреты локально берём из переменных
DATABASE_URL=postgresql://localhost:5432/orders
4. Что делать, если доступа в облако нет вовсе. Бывает: подрядчик, новый сотрудник, закрытый контур. Тогда весь доступ к облачным сервисам держат за своими интерфейсами, а в локальном профиле подставляют реализации, которые пишут в локальный каталог и в память. Приложение запускается без облака и без эмуляторов — и это самый надёжный вариант для разработки логики.
Итоговое правило: в коде не должно быть ничего, что различает среды. Различаются только настройки — адреса, профиль, переменные. Тогда «работает локально» действительно означает «работает».
Коротко
- Клиент сервиса создают один раз на приложение, имя бакета и проект берут из настроек; в Spring это стартеры Spring Cloud GCP, в Go, Node и Python по пакету на сервис.
- Application Default Credentials ищет права по порядку: переменная GOOGLE_APPLICATION_CREDENTIALS, файл локальной учётки после gcloud auth application-default login, привязанный сервисный аккаунт через метаданные; забытая переменная перебивает облако, а конвейеру вне GCP дают федерацию вместо JSON-ключа.
- Библиотеки повторяют сеть, UNAVAILABLE и RESOURCE_EXHAUSTED сами: свой повтор сверху не добавляют, предел на всю операцию обязателен, ошибки разбирают по коду состояния.
- Проект и регион задают в настройках явно, а не полагаются на окружение; в тестах проект фиктивный, адрес эмулятора из переменной.
- К Cloud SQL подключаются обычным драйвером через коннектор, а не по внешнему IP.
- Секрет читают из Secret Manager при старте: роль secretAccessor на конкретный секрет, явная версия предсказуемее latest, смена пароля базы с двумя действующими и перезапуском.
- Очередь доставляет хотя бы раз: подтверждение после обработки, идемпотентный обработчик, срок подтверждения больше самой долгой обработки, тема недоставленных.
- Тесты идут на эмуляторе Pub/Sub через PUBSUB_EMULATOR_HOST, fake-gcs-server для хранилища и Testcontainers для базы и кэша; локальный запуск это вход разработчика, эмуляторы и профиль, а без доступа в облако подмена на границе интерфейсами.
Что почитать дальше
- Cloud IAM — откуда ADC берёт сервисный аккаунт и как работать от его имени.
- Безопасность и наблюдаемость в GCP — как секреты и журналы устроены на стороне облака.
- Управляемые данные в GCP — Cloud SQL, Memorystore и Pub/Sub, к которым подключается код.
- Cloud Storage — бакеты, которые в тестах заменяет эмулятор.