GCP and OTEL refactor

This commit is contained in:
Jason Ross
2026-05-13 20:20:31 -05:00
parent 78c20162b4
commit 446e5444b8
12 changed files with 1909 additions and 43 deletions
+3 -1
View File
@@ -37,6 +37,7 @@ COPY --from=build --chown=astro:astro /app/package.json ./package.json
COPY --from=build --chown=astro:astro /app/node_modules ./node_modules
COPY --from=build --chown=astro:astro /app/dist ./dist
COPY --from=build --chown=astro:astro /app/astro.config.mjs ./astro.config.mjs
COPY --from=build --chown=astro:astro /app/otel.js ./otel.js
USER astro
@@ -47,4 +48,5 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
# Invoke astro directly via node to avoid corepack/pnpm shims at runtime
# (the rootfs is read-only and corepack would try to write a cache dir).
CMD ["node", "./node_modules/astro/bin/astro.mjs", "preview", "--host", "0.0.0.0", "--port", "4321"]
# Use --import to load the OTEL instrumentation in ESM mode.
CMD ["node", "--import", "./otel.js", "./node_modules/astro/bin/astro.mjs", "preview", "--host", "0.0.0.0", "--port", "4321"]
+25
View File
@@ -0,0 +1,25 @@
# Use a custom Containerfile to include restic and gsutil
FROM python:3.11-slim
# Suppress debconf warnings and pip root warnings
ENV DEBIAN_FRONTEND=noninteractive \
PIP_ROOT_USER_ACTION=ignore
# Install restic, curl, and gnupg
RUN apt-get update && apt-get install -y --no-install-recommends restic curl gnupg \
&& curl https://packages.cloud.google.com/apt/doc/apt-key.gpg | gpg --dearmor -o /usr/share/keyrings/cloud.google.gpg \
&& echo "deb [signed-by=/usr/share/keyrings/cloud.google.gpg] http://packages.cloud.google.com/apt cloud-sdk main" | tee /etc/apt/sources.list.d/google-cloud-sdk.list \
&& apt-get update && apt-get install -y --no-install-recommends google-cloud-cli \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --upgrade pip && \
pip install --no-cache-dir -r requirements.txt
COPY . .
# Cloud Run functions expect a specific entrypoint
ENTRYPOINT ["functions-framework", "--target", "run_backup", "--signature-type", "cloudevent"]
+2 -1
View File
@@ -1,5 +1,5 @@
# Use a custom Dockerfile to include restic and gsutil
FROM python:3.11-slim
FROM python:3.14-slim-trixie
# Install restic and curl (to get cloud-sdk)
RUN apt-get update && apt-get install -y restic curl gnupg \
@@ -9,6 +9,7 @@ RUN apt-get update && apt-get install -y restic curl gnupg \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
WORKDIR /app
RUN pip install --upgrade pip
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
Binary file not shown.
+57 -15
View File
@@ -1,28 +1,68 @@
import os
import subprocess
import tempfile
import logging
import functions_framework
from opentelemetry import _logs
from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler
from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
from opentelemetry.exporter.otlp.proto.http._log_exporter import OTLPLogExporter
from opentelemetry.sdk.resources import Resource
# ---------------------------------------------------------------------------
# OTEL Logging Setup
# ---------------------------------------------------------------------------
resource = Resource.create({
"service.name": "dev-blog-backup",
"deployment.environment": os.environ.get("ENVIRONMENT", "production")
})
logger_provider = LoggerProvider(resource=resource)
_logs.set_logger_provider(logger_provider)
# Export logs via OTLP (async/non-blocking via BatchLogRecordProcessor)
exporter = OTLPLogExporter()
logger_provider.add_log_record_processor(BatchLogRecordProcessor(exporter))
# Attach OTEL handler to the root logger
otel_handler = LoggingHandler(level=logging.INFO, logger_provider=logger_provider)
root_logger = logging.getLogger()
root_logger.addHandler(otel_handler)
# Create a logger for this module
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)
# Disable the default stream handler for GCP to minimize billable logs
# By setting the root logger to WARNING, we ensure that standard output
# (captured by GCP) only contains high-priority logs.
# We also want to make sure we don't duplicate logs.
for handler in root_logger.handlers:
if not isinstance(handler, LoggingHandler):
handler.setLevel(logging.WARNING)
root_logger.setLevel(logging.WARNING)
@functions_framework.cloud_event
def run_backup(cloud_event):
print(f"Triggered by event: {cloud_event['id']}")
logger.info(f"Triggered by event: {cloud_event['id']}")
# Restic environments are expected to be set via Secret Manager / Env vars
# Required: RESTIC_REPOSITORY, RESTIC_PASSWORD, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY
source_bucket = os.environ.get('SOURCE_BUCKET')
r2_account_id = os.environ.get('R2_ACCOUNT_ID')
r2_bucket = os.environ.get('R2_BUCKET')
source_bucket = os.environ.get('SOURCE_BUCKET') # e.g. gs://my-bucket
# Construct the Restic repository URL for Cloudflare R2
os.environ['RESTIC_REPOSITORY'] = f"s3:https://{r2_account_id}.r2.cloudflarestorage.com/{r2_bucket}"
with tempfile.TemporaryDirectory() as tmpdir:
# 1. Sync bucket to local temp dir (restic works best on local files for GCS source)
# Alternatively, restic can use rclone as a backend, but for a small blog,
# syncing to a temp dir is simpler.
print(f"Syncing {source_bucket} to {tmpdir}...")
subprocess.run(['gsutil', '-m', 'rsync', '-r', source_bucket, tmpdir], check=True)
logger.info(f"Syncing {source_bucket} to {tmpdir}...")
try:
subprocess.run(['gsutil', '-m', 'rsync', '-r', source_bucket, tmpdir], check=True, capture_output=True, text=True)
except subprocess.CalledProcessError as e:
logger.error(f"Sync failed: {e.stderr}")
raise e
# 2. Run restic backup
print("Starting restic backup to R2...")
# Note: In a real environment, you'd ensure the restic binary is in the path.
# We'll use a wrapper or ensure it's in the container.
logger.info("Starting restic backup to R2...")
try:
result = subprocess.run(
['restic', 'backup', tmpdir, '--tag', 'gcs-trigger'],
@@ -30,9 +70,11 @@ def run_backup(cloud_event):
text=True,
check=True
)
print(result.stdout)
logger.info(result.stdout)
except subprocess.CalledProcessError as e:
print(f"Restic failed: {e.stderr}")
logger.error(f"Restic failed: {e.stderr}")
raise e
print("Backup completed successfully.")
logger.info("Backup completed successfully.")
# Ensure logs are flushed before the function exits
logger_provider.force_flush()
+4
View File
@@ -1 +1,5 @@
functions-framework==3.8.1
opentelemetry-api
opentelemetry-sdk
opentelemetry-exporter-otlp
opentelemetry-instrumentation-logging
+31 -9
View File
@@ -212,32 +212,46 @@ resource "google_cloud_scheduler_job" "daily_trigger" {
}
# ---------------------------------------------------------------------------
# Secret Manager for Backup Credentials
# Secret Manager for Backup & DNS Credentials
# ---------------------------------------------------------------------------
resource "google_secret_manager_secret" "restic_password" {
secret_id = "restic-password"
secret_id = "RESTIC_PASSWORD"
replication {
auto {}
}
}
resource "google_secret_manager_secret" "r2_access_key" {
secret_id = "r2-access-key-id"
secret_id = "R2_ACCESS_KEY_ID"
replication {
auto {}
}
}
resource "google_secret_manager_secret" "r2_secret_key" {
secret_id = "r2-secret-access-key"
secret_id = "R2_SECRET_ACCESS_KEY"
replication {
auto {}
}
}
resource "google_secret_manager_secret" "restic_repo" {
secret_id = "restic-repository"
resource "google_secret_manager_secret" "r2_account_id" {
secret_id = "R2_ACCOUNT_ID"
replication {
auto {}
}
}
resource "google_secret_manager_secret" "r2_backup_bucket" {
secret_id = "R2_BACKUP_BUCKET_NAME"
replication {
auto {}
}
}
resource "google_secret_manager_secret" "gcp_dns_sa" {
secret_id = "gcp-dns-sa"
replication {
auto {}
}
@@ -305,9 +319,16 @@ resource "google_cloudfunctions2_function" "backup" {
}
secret_environment_variables {
key = "RESTIC_REPOSITORY"
key = "R2_ACCOUNT_ID"
project_id = var.gcp_project_id
secret = google_secret_manager_secret.restic_repo.secret_id
secret = google_secret_manager_secret.r2_account_id.secret_id
version = "latest"
}
secret_environment_variables {
key = "R2_BUCKET"
project_id = var.gcp_project_id
secret = google_secret_manager_secret.r2_backup_bucket.secret_id
version = "latest"
}
}
@@ -339,7 +360,8 @@ resource "google_secret_manager_secret_iam_member" "backup_secrets" {
google_secret_manager_secret.restic_password.id,
google_secret_manager_secret.r2_access_key.id,
google_secret_manager_secret.r2_secret_key.id,
google_secret_manager_secret.restic_repo.id
google_secret_manager_secret.r2_account_id.id,
google_secret_manager_secret.r2_backup_bucket.id
])
secret_id = each.key
role = "roles/secretmanager.secretAccessor"
+96
View File
@@ -0,0 +1,96 @@
import { NodeSDK } from '@opentelemetry/sdk-node';
import { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-http';
import { BatchLogRecordProcessor, LoggerProvider } from '@opentelemetry/sdk-logs';
import { Resource } from '@opentelemetry/resources';
import { SemanticResourceAttributes } from '@opentelemetry/semantic-conventions';
import { logs, SeverityNumber } from '@opentelemetry/api-logs';
// ---------------------------------------------------------------------------
// OTEL Logging Setup
// ---------------------------------------------------------------------------
const resource = new Resource({
[SemanticResourceAttributes.SERVICE_NAME]: 'dev-blog-app',
[SemanticResourceAttributes.DEPLOYMENT_ENVIRONMENT]: process.env.NODE_ENV || 'development',
});
const exporter = new OTLPLogExporter(); // Defaults to OTEL_EXPORTER_OTLP_ENDPOINT
const loggerProvider = new LoggerProvider({ resource });
loggerProvider.addLogRecordProcessor(new BatchLogRecordProcessor(exporter));
// Set as global logger provider
logs.setGlobalLoggerProvider(loggerProvider);
const logger = logs.getLogger('dev-blog-app');
// Monkey-patch console to send logs to OTEL
const originalLog = console.log;
const originalError = console.error;
const originalWarn = console.warn;
const originalInfo = console.info;
console.log = (...args) => {
logger.emit({
severityNumber: SeverityNumber.INFO,
severityText: 'INFO',
body: args.map(arg => (typeof arg === 'object' ? JSON.stringify(arg) : arg)).join(' '),
});
if (process.env.DISABLE_GCP_LOGGING !== 'true') {
originalLog(...args);
}
};
console.info = (...args) => {
logger.emit({
severityNumber: SeverityNumber.INFO,
severityText: 'INFO',
body: args.map(arg => (typeof arg === 'object' ? JSON.stringify(arg) : arg)).join(' '),
});
if (process.env.DISABLE_GCP_LOGGING !== 'true') {
originalInfo(...args);
}
};
console.warn = (...args) => {
logger.emit({
severityNumber: SeverityNumber.WARN,
severityText: 'WARN',
body: args.map(arg => (typeof arg === 'object' ? JSON.stringify(arg) : arg)).join(' '),
});
if (process.env.DISABLE_GCP_LOGGING !== 'true') {
originalWarn(...args);
}
};
console.error = (...args) => {
logger.emit({
severityNumber: SeverityNumber.ERROR,
severityText: 'ERROR',
body: args.map(arg => (typeof arg === 'object' ? JSON.stringify(arg) : arg)).join(' '),
});
if (process.env.DISABLE_GCP_LOGGING !== 'true') {
originalError(...args);
}
};
// Initialize SDK for traces/metrics
const sdk = new NodeSDK({
resource,
// Using logRecordProcessor here might be redundant if we use loggerProvider directly,
// but it's good for future-proofing traces/metrics.
});
sdk.start();
// Handle shutdown
process.on('SIGTERM', async () => {
try {
await loggerProvider.forceFlush();
await sdk.shutdown();
originalLog('OTEL SDK shut down');
} catch (error) {
originalError('Error shutting down OTEL SDK', error);
} finally {
process.exit(0);
}
});
+8
View File
@@ -21,6 +21,14 @@
"@astrojs/mdx": "^5.0.4",
"@astrojs/rss": "^4.0.18",
"@astrojs/sitemap": "^3.7.2",
"@opentelemetry/api": "^1.9.1",
"@opentelemetry/api-logs": "^0.218.0",
"@opentelemetry/auto-instrumentations-node": "^0.76.0",
"@opentelemetry/exporter-logs-otlp-http": "^0.218.0",
"@opentelemetry/resources": "^2.7.1",
"@opentelemetry/sdk-logs": "^0.218.0",
"@opentelemetry/sdk-node": "^0.218.0",
"@opentelemetry/semantic-conventions": "^1.41.1",
"astro": "^6.1.10",
"sharp": "^0.34.3"
},
+1678 -17
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -0,0 +1,4 @@
allowBuilds:
esbuild: true
protobufjs: true
sharp: true
+1
View File
@@ -16,6 +16,7 @@ Environment=NODE_ENV=production
Environment=HOST=0.0.0.0
Environment=PORT=4321
Environment=ASTRO_TELEMETRY_DISABLED=1
Environment=DISABLE_GCP_LOGGING=true
# Hardening
NoNewPrivileges=true