27 KiB
Presigned Upload URLs Implementation Guide
Current Architecture (Proxy Mode)
Upload Flow Today
- AppView receives blob upload request from Docker
- ProxyBlobStore.Create() creates streaming upload via pipe
- Data streams to Hold Service temp location:
uploads/temp-{id} - Hold service uploads to S3 via storage driver
- ProxyBlobWriter.Commit() moves blob: temp → final digest-based path
- Hold service performs S3 Move operation
Why Uploads Don't Use Presigned URLs Today
Create()doesn't know the blob digest upfront- Presigned S3 URLs require the full object key (which includes digest)
- Current approach streams to temp location, calculates digest, then moves
Bandwidth Flow (Current)
Docker → AppView → Hold Service → S3/Storj
(proxy) (proxy)
All upload bandwidth flows through Hold Service.
Proposed Architecture (Presigned Uploads)
New Upload Flow
- AppView receives blob upload request from Docker
- ProxyBlobStore.Create() creates buffered upload writer
- Data buffered in memory during
Write()calls - ProxyBlobWriter.Commit() calculates digest from buffer
- Request presigned PUT URL from Hold Service with digest
- Upload buffered data directly to S3 via presigned URL
- No move operation needed (uploaded to final path)
Bandwidth Flow (Presigned)
Docker → AppView → S3/Storj (direct via presigned URL)
(buffer)
Hold Service only issues presigned URLs (minimal bandwidth)
Detailed Implementation
Phase 1: Add Buffering to ProxyBlobWriter
File: pkg/storage/proxy_blob_store.go
Changes to ProxyBlobWriter struct
type ProxyBlobWriter struct {
store *ProxyBlobStore
options distribution.CreateOptions
// Remove pipe-based streaming
// pipeWriter *io.PipeWriter
// pipeReader *io.PipeReader
// digestChan chan string
// uploadErr chan error
// Add buffering
buffer *bytes.Buffer // In-memory buffer for blob data
hasher digest.Digester // Calculate digest while writing
finalDigest string
size int64
closed bool
id string
startedAt time.Time
}
Rationale:
- Remove pipe mechanism (no longer streaming to temp)
- Add buffer to store blob data in memory
- Add hasher to calculate digest incrementally
Modify Create() method
Before (lines 208-312):
func (p *ProxyBlobStore) Create(ctx context.Context, options ...distribution.BlobCreateOption) (distribution.BlobWriter, error) {
// Creates pipe and starts background goroutine for streaming
pipeReader, pipeWriter := io.Pipe()
// ... streams to temp location
}
After:
func (p *ProxyBlobStore) Create(ctx context.Context, options ...distribution.BlobCreateOption) (distribution.BlobWriter, error) {
fmt.Printf("🔧 [proxy_blob_store/Create] Starting buffered upload for presigned URL\n")
// Parse options
var opts distribution.CreateOptions
for _, option := range options {
if err := option.Apply(&opts); err != nil {
return nil, err
}
}
// Create buffered writer
writer := &ProxyBlobWriter{
store: p,
options: opts,
buffer: new(bytes.Buffer),
hasher: digest.Canonical.Digester(), // Usually SHA256
id: fmt.Sprintf("upload-%d", time.Now().UnixNano()),
startedAt: time.Now(),
}
// Store in global uploads map for resume support
globalUploadsMu.Lock()
globalUploads[writer.id] = writer
globalUploadsMu.Unlock()
fmt.Printf(" Upload ID: %s\n", writer.id)
fmt.Printf(" Repository: %s\n", p.repository)
return writer, nil
}
Key Changes:
- No more pipe creation
- No background goroutine
- Initialize buffer and hasher
- Everything else stays synchronous
Modify Write() method
Before (lines 440-455):
func (w *ProxyBlobWriter) Write(p []byte) (int, error) {
// Writes to pipe, streams to hold service
n, err := w.pipeWriter.Write(p)
w.size += int64(n)
return n, nil
}
After:
func (w *ProxyBlobWriter) Write(p []byte) (int, error) {
if w.closed {
return 0, fmt.Errorf("writer closed")
}
// Write to buffer
n, err := w.buffer.Write(p)
if err != nil {
return n, fmt.Errorf("failed to buffer data: %w", err)
}
// Update hasher for digest calculation
w.hasher.Hash().Write(p)
w.size += int64(n)
// Memory pressure check (optional safety)
if w.buffer.Len() > 500*1024*1024 { // 500MB limit
return n, fmt.Errorf("blob too large for buffered upload: %d bytes", w.buffer.Len())
}
return n, nil
}
Key Changes:
- Write to in-memory buffer instead of pipe
- Update hasher incrementally (efficient)
- Add safety check for excessive memory usage
- No streaming to hold service yet
Modify Commit() method
Before (lines 493-548):
func (w *ProxyBlobWriter) Commit(ctx context.Context, desc distribution.Descriptor) (distribution.Descriptor, error) {
// Close pipe, send digest to goroutine
// Wait for temp upload
// Move temp → final
}
After:
func (w *ProxyBlobWriter) Commit(ctx context.Context, desc distribution.Descriptor) (distribution.Descriptor, error) {
if w.closed {
return distribution.Descriptor{}, fmt.Errorf("writer closed")
}
w.closed = true
// Remove from global uploads map
globalUploadsMu.Lock()
delete(globalUploads, w.id)
globalUploadsMu.Unlock()
// Calculate digest from buffered data
calculatedDigest := w.hasher.Digest()
// Verify digest matches if provided
if desc.Digest != "" && desc.Digest != calculatedDigest {
return distribution.Descriptor{}, fmt.Errorf(
"digest mismatch: expected %s, got %s",
desc.Digest, calculatedDigest,
)
}
finalDigest := calculatedDigest
if desc.Digest != "" {
finalDigest = desc.Digest
}
fmt.Printf("📤 [ProxyBlobWriter.Commit] Uploading via presigned URL\n")
fmt.Printf(" Digest: %s\n", finalDigest)
fmt.Printf(" Size: %d bytes\n", w.size)
fmt.Printf(" Buffered: %d bytes\n", w.buffer.Len())
// Get presigned upload URL from hold service
url, err := w.store.getUploadURL(ctx, finalDigest, w.size)
if err != nil {
return distribution.Descriptor{}, fmt.Errorf("failed to get presigned upload URL: %w", err)
}
fmt.Printf(" Presigned URL: %s\n", url)
// Upload directly to S3 via presigned URL
req, err := http.NewRequestWithContext(ctx, "PUT", url, bytes.NewReader(w.buffer.Bytes()))
if err != nil {
return distribution.Descriptor{}, fmt.Errorf("failed to create upload request: %w", err)
}
req.Header.Set("Content-Type", "application/octet-stream")
req.ContentLength = w.size
resp, err := w.store.httpClient.Do(req)
if err != nil {
return distribution.Descriptor{}, fmt.Errorf("presigned upload failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
bodyBytes, _ := io.ReadAll(resp.Body)
return distribution.Descriptor{}, fmt.Errorf(
"presigned upload failed: status %d, body: %s",
resp.StatusCode, string(bodyBytes),
)
}
fmt.Printf("✅ [ProxyBlobWriter.Commit] Upload successful\n")
// Clear buffer to free memory
w.buffer = nil
return distribution.Descriptor{
Digest: finalDigest,
Size: w.size,
MediaType: desc.MediaType,
}, nil
}
Key Changes:
- Calculate digest from hasher (already computed incrementally)
- Verify digest if provided by client
- Get presigned upload URL with final digest
- Upload buffer contents directly to S3
- No temp location, no move operation
- Clear buffer to free memory immediately
Modify Cancel() method
Before (lines 551-572):
func (w *ProxyBlobWriter) Cancel(ctx context.Context) error {
// Close pipe, cancel temp upload
}
After:
func (w *ProxyBlobWriter) Cancel(ctx context.Context) error {
w.closed = true
// Remove from global uploads map
globalUploadsMu.Lock()
delete(globalUploads, w.id)
globalUploadsMu.Unlock()
// Clear buffer to free memory
w.buffer = nil
fmt.Printf("[ProxyBlobWriter.Cancel] Upload cancelled: id=%s\n", w.id)
return nil
}
Key Changes:
- Simply clear buffer
- No pipe cleanup needed
- No temp cleanup needed (nothing uploaded yet)
Phase 2: Update Hold Service (Optional Enhancement)
The current getUploadURL() implementation in cmd/hold/main.go (lines 528-587) already supports presigned uploads correctly. No changes needed unless you want to add additional logging.
Optional logging enhancement at line 547:
url, err := req.Presign(15 * time.Minute)
if err != nil {
log.Printf("Failed to generate presigned upload URL: %v", err)
return s.getProxyUploadURL(digest, did), nil
}
log.Printf("🔑 Generated presigned upload URL:")
log.Printf(" Digest: %s", digest)
log.Printf(" S3 Key: %s", s3Key)
log.Printf(" Size: %d bytes", size)
log.Printf(" URL length: %d chars", len(url))
log.Printf(" Expires: 15min")
return url, nil
Phase 3: Memory Management Considerations
Add Configuration for Max Buffer Size
File: pkg/storage/proxy_blob_store.go
Add constants at top of file:
const (
maxChunkSize = 5 * 1024 * 1024 // 5MB (existing)
// Maximum blob size for in-memory buffering
// Blobs larger than this will fail (alternative: fallback to proxy mode)
maxBufferedBlobSize = 500 * 1024 * 1024 // 500MB
)
Alternative: Disk-Based Buffering
For very large blobs, consider disk-based buffering:
type ProxyBlobWriter struct {
// ... existing fields ...
// Choose one:
buffer *bytes.Buffer // Memory buffer (current)
// OR
tempFile *os.File // Disk buffer (for large blobs)
bufferSize int64
}
Memory buffer (simple, fast):
- Pro: Fast, no disk I/O
- Con: Limited by available RAM
- Use for: Blobs < 500MB
Disk buffer (scalable):
- Pro: No memory limit
- Con: Slower, disk I/O overhead
- Use for: Blobs > 500MB
Hybrid Approach (Recommended)
const (
memoryBufferThreshold = 50 * 1024 * 1024 // 50MB
)
func (w *ProxyBlobWriter) Write(p []byte) (int, error) {
// If buffer exceeds threshold, switch to disk
if w.buffer != nil && w.buffer.Len() > memoryBufferThreshold {
return 0, fmt.Errorf("blob exceeds memory buffer threshold, disk buffering not implemented")
// TODO: Implement disk buffering or fallback to proxy mode
}
// Otherwise use memory buffer
// ... existing Write() logic ...
}
Optional Enhancement: Presigned HEAD URLs
Motivation
Currently HEAD requests (blob verification) are proxied through the Hold Service. This is fine because HEAD bandwidth is negligible (~300 bytes per request), but we can eliminate this round-trip by using presigned HEAD URLs.
Implementation
Step 1: Add getHeadURL() to Hold Service
File: cmd/hold/main.go
Add new function after getDownloadURL():
// getHeadURL generates a presigned HEAD URL for blob verification
func (s *HoldService) getHeadURL(ctx context.Context, digest string) (string, error) {
// Check if blob exists first
path := blobPath(digest)
_, err := s.driver.Stat(ctx, path)
if err != nil {
return "", fmt.Errorf("blob not found: %w", err)
}
// If S3 client available, generate presigned HEAD URL
if s.s3Client != nil {
s3Key := strings.TrimPrefix(path, "/")
if s.s3PathPrefix != "" {
s3Key = s.s3PathPrefix + "/" + s3Key
}
// Generate presigned HEAD URL (method-specific!)
req, _ := s.s3Client.HeadObjectRequest(&s3.HeadObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(s3Key),
})
log.Printf("🔍 [getHeadURL] Generating presigned HEAD URL:")
log.Printf(" Digest: %s", digest)
log.Printf(" S3 Key: %s", s3Key)
url, err := req.Presign(15 * time.Minute)
if err != nil {
log.Printf("[getHeadURL] Presign failed: %v", err)
// Fallback to proxy URL
return s.getProxyHeadURL(digest), nil
}
log.Printf("✅ [getHeadURL] Presigned HEAD URL generated")
return url, nil
}
// Fallback: return proxy URL
return s.getProxyHeadURL(digest), nil
}
// getProxyHeadURL returns a proxy URL for HEAD requests
func (s *HoldService) getProxyHeadURL(digest string) string {
// HEAD requests don't need DID in query string (read-only check)
return fmt.Sprintf("%s/blobs/%s", s.config.Server.PublicURL, digest)
}
Step 2: Add HTTP endpoint for presigned HEAD URLs
File: cmd/hold/main.go
Add handler similar to HandleGetPresignedURL():
// HeadPresignedURLRequest represents a request for a presigned HEAD URL
type HeadPresignedURLRequest struct {
DID string `json:"did"`
Digest string `json:"digest"`
}
// HeadPresignedURLResponse contains the presigned HEAD URL
type HeadPresignedURLResponse struct {
URL string `json:"url"`
ExpiresAt time.Time `json:"expires_at"`
}
// HandleHeadPresignedURL handles requests for HEAD URLs
func (s *HoldService) HandleHeadPresignedURL(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req HeadPresignedURLRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, fmt.Sprintf("invalid request: %v", err), http.StatusBadRequest)
return
}
// Validate DID authorization for READ
if !s.isAuthorizedRead(req.DID) {
if req.DID == "" {
http.Error(w, "unauthorized: authentication required", http.StatusUnauthorized)
} else {
http.Error(w, "forbidden: access denied", http.StatusForbidden)
}
return
}
// Generate presigned HEAD URL
ctx := context.Background()
expiry := time.Now().Add(15 * time.Minute)
url, err := s.getHeadURL(ctx, req.Digest)
if err != nil {
http.Error(w, fmt.Sprintf("failed to generate URL: %v", err), http.StatusInternalServerError)
return
}
resp := HeadPresignedURLResponse{
URL: url,
ExpiresAt: expiry,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
Step 3: Register endpoint in main()
File: cmd/hold/main.go
In main() function, add route:
mux.HandleFunc("/head-presigned-url", service.HandleHeadPresignedURL)
Step 4: Update ProxyBlobStore.ServeBlob()
File: pkg/storage/proxy_blob_store.go
Modify HEAD handling (currently lines 197-224):
Before:
if r.Method == http.MethodHead {
// Check if blob exists via hold service HEAD request
url := fmt.Sprintf("%s/blobs/%s?did=%s", p.storageEndpoint, dgst.String(), p.did)
req, err := http.NewRequestWithContext(ctx, "HEAD", url, nil)
// ... proxy through hold service ...
}
After:
if r.Method == http.MethodHead {
// Get presigned HEAD URL from hold service
headURL, err := p.getHeadURL(ctx, dgst)
if err != nil {
return distribution.ErrBlobUnknown
}
// Redirect to presigned HEAD URL
http.Redirect(w, r, headURL, http.StatusTemporaryRedirect)
return nil
}
Step 5: Add getHeadURL() to ProxyBlobStore
File: pkg/storage/proxy_blob_store.go
Add after getDownloadURL():
// getHeadURL requests a presigned HEAD URL from the storage service
func (p *ProxyBlobStore) getHeadURL(ctx context.Context, dgst digest.Digest) (string, error) {
reqBody := map[string]any{
"did": p.did,
"digest": dgst.String(),
}
body, err := json.Marshal(reqBody)
if err != nil {
return "", err
}
url := fmt.Sprintf("%s/head-presigned-url", p.storageEndpoint)
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
resp, err := p.httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("failed to get HEAD URL: status %d", resp.StatusCode)
}
var result struct {
URL string `json:"url"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", err
}
return result.URL, nil
}
Presigned HEAD URLs: Trade-offs
Benefits:
- Offloads HEAD requests from Hold Service
- Docker verifies blobs directly against S3
- Slightly lower latency (one fewer hop)
Costs:
- Requires round-trip to get presigned HEAD URL
- More complex code
- Two HTTP requests instead of one proxy request
Bandwidth Analysis:
- Current: 1 HEAD request to Hold Service (~300 bytes)
- Presigned: 1 POST to get URL (~200 bytes) + 1 HEAD to S3 (~300 bytes)
- Net difference: Adds ~200 bytes per verification
Recommendation: Optional enhancement. The current proxied HEAD approach is simpler and bandwidth difference is negligible. Only implement if:
- Hold Service is becoming a bottleneck
- You want to minimize Hold Service load completely
- Latency of HEAD requests becomes noticeable
Testing & Validation
Test Plan for Presigned Uploads
1. Small Blob Upload (< 1MB)
# Build test image with small layers
echo "FROM scratch" > Dockerfile
echo "COPY small-file /" >> Dockerfile
dd if=/dev/urandom of=small-file bs=1024 count=512 # 512KB
docker build -t atcr.io/youruser/test:small .
docker push atcr.io/youruser/test:small
Expected behavior:
- Blob buffered in memory
- Presigned upload URL requested with correct digest
- Direct upload to S3 via presigned URL
- No temp location, no move operation
Verify in logs:
📤 [ProxyBlobWriter.Commit] Uploading via presigned URL
Digest: sha256:...
Size: 524288 bytes
Presigned URL: https://gateway.storjshare.io/...
✅ [ProxyBlobWriter.Commit] Upload successful
2. Medium Blob Upload (10-50MB)
dd if=/dev/urandom of=medium-file bs=1048576 count=25 # 25MB
docker build -t atcr.io/youruser/test:medium .
docker push atcr.io/youruser/test:medium
Monitor memory usage:
# While push is running
docker stats atcr-appview
Should see ~25MB spike during buffer + upload.
3. Large Blob Upload (100-500MB)
dd if=/dev/urandom of=large-file bs=1048576 count=200 # 200MB
docker build -t atcr.io/youruser/test:large .
docker push atcr.io/youruser/test:large
Monitor:
- Memory usage (should see ~200MB spike)
- Upload completes successfully
- S3 shows blob in correct location
4. Concurrent Uploads
# Push multiple images in parallel
docker push atcr.io/youruser/test1:tag &
docker push atcr.io/youruser/test2:tag &
docker push atcr.io/youruser/test3:tag &
wait
Verify:
- All uploads complete successfully
- Memory usage peaks but doesn't OOM
- No data corruption (digests match)
5. Error Handling Tests
Test presigned URL failure:
- Temporarily break S3 credentials
- Verify graceful error message
- Check for memory leaks (buffer cleared on error)
Test digest mismatch:
- This shouldn't happen in practice, but verify error handling
- Buffer should be cleared even on error
Test network interruption:
- Kill network during upload
- Verify proper error propagation
- Check for hanging goroutines
Test Plan for Presigned HEAD URLs (Optional)
1. HEAD Request Redirect
# Pull image (triggers HEAD verification)
docker pull atcr.io/youruser/test:tag
Expected behavior:
- AppView redirects HEAD to presigned HEAD URL
- Docker follows redirect to S3
- S3 responds to HEAD request successfully
Verify in logs:
🔍 [getHeadURL] Generating presigned HEAD URL:
Digest: sha256:...
✅ [getHeadURL] Presigned HEAD URL generated
2. Method Verification
# Manually verify presigned HEAD URL works
curl -I "presigned-head-url-here"
Should return 200 OK with Content-Length header.
# Verify it ONLY works with HEAD (not GET)
curl "presigned-head-url-here"
Should return 403 Forbidden (method mismatch).
Performance Comparison
Current Architecture (Proxy Mode)
Upload:
Client → AppView (stream) → Hold Service (stream) → S3
~0ms delay ~0ms delay ~100ms
- Total latency: ~100ms + upload time
- Bandwidth: All through Hold Service
Download:
Client → AppView (redirect) → S3 (presigned GET)
~5ms ~50ms
- Total latency: ~55ms + download time
- Bandwidth: Direct from S3 ✅
Verification (HEAD):
Client → AppView (redirect) → Hold Service (proxy HEAD) → S3
~5ms ~10ms ~50ms
- Total latency: ~65ms
- Bandwidth: ~300 bytes through Hold Service
Presigned Upload Architecture
Upload:
Client → AppView (buffer) → S3 (presigned PUT)
~0ms ~100ms
- Total latency: ~100ms + upload time (same)
- Bandwidth: Direct to S3 ✅
- Memory: +blob_size during buffer
Download: (unchanged)
Client → AppView (redirect) → S3 (presigned GET)
Verification (HEAD): (if presigned HEAD enabled)
Client → AppView (redirect) → S3 (presigned HEAD)
~5ms ~50ms
- Total latency: ~55ms (10ms faster)
- Bandwidth: Direct to S3 ✅
Trade-offs Summary
Presigned Uploads
| Aspect | Proxy Mode (Current) | Presigned URLs |
|---|---|---|
| Upload Bandwidth | Through Hold Service | Direct to S3 ✅ |
| Hold Service Load | High (all upload traffic) | Low (only URL generation) ✅ |
| Memory Usage | Low (streaming) | High (buffering) ⚠️ |
| Disk Usage | None | Optional temp files for large blobs |
| Code Complexity | Simple ✅ | Moderate |
| Max Blob Size | Unlimited ✅ | Limited by memory (~500MB) ⚠️ |
| Latency | Same | Same |
| Error Recovery | Simple (cancel stream) | More complex (clear buffer) |
Presigned HEAD URLs
| Aspect | Proxy Mode (Current) | Presigned HEAD |
|---|---|---|
| Bandwidth | 300 bytes (negligible) | 500 bytes (still negligible) |
| Hold Service Load | Low (HEAD is tiny) | Lower (but minimal gain) |
| Latency | 65ms | 55ms (10ms faster) |
| Code Complexity | Simple ✅ | More complex |
| Reliability | High (fewer moving parts) ✅ | Moderate (more failure modes) |
Recommendations
Presigned Uploads
Implement if:
- ✅ Hold Service bandwidth is a concern
- ✅ You want to minimize Hold Service load
- ✅ Most blobs are < 100MB (typical Docker layers)
- ✅ AppView has sufficient memory (2-4GB+ RAM)
Skip if:
- ⚠️ Memory is constrained
- ⚠️ You regularly push very large layers (> 500MB)
- ⚠️ Current proxy mode is working fine
- ⚠️ Simplicity is priority
Presigned HEAD URLs
Implement if:
- ✅ You want complete S3 offloading
- ✅ You're already implementing presigned uploads
- ✅ Hold Service is CPU/bandwidth constrained
Skip if:
- ⚠️ Current HEAD proxying works fine (it does)
- ⚠️ You want to minimize code complexity
- ⚠️ 10ms latency difference doesn't matter
Suggested Approach
Phase 1: Implement presigned uploads first
- Bigger performance win (offloads upload bandwidth)
- More valuable for write-heavy workflows
- Test thoroughly with various blob sizes
Phase 2: Monitor and evaluate
- Check Hold Service load after presigned uploads
- Measure HEAD request impact
- Assess if presigned HEAD is worth the complexity
Phase 3: Optionally add presigned HEAD
- Only if Hold Service is still bottlenecked
- Or if you want feature completeness
Migration Path
Step 1: Feature Flag
Add configuration option to enable/disable presigned uploads:
// In AppView config
type Config struct {
// ... existing fields ...
UsePresignedUploads bool `yaml:"use_presigned_uploads"` // Default: false
}
Step 2: Gradual Rollout
- Deploy with
use_presigned_uploads: false(current behavior) - Test in staging with
use_presigned_uploads: true - Roll out to production incrementally
- Monitor memory usage and error rates
Step 3: Fallback Mechanism
If presigned upload fails, fallback to proxy mode:
func (w *ProxyBlobWriter) Commit(...) {
// Try presigned upload
url, err := w.store.getUploadURL(ctx, finalDigest, w.size)
if err != nil {
// Fallback: use proxy mode
log.Printf("⚠️ Presigned upload unavailable, falling back to proxy")
return w.proxyUpload(ctx, desc)
}
// ... presigned upload ...
}
Appendix: Memory Profiling
To monitor memory usage during development:
# Enable Go memory profiling
go tool pprof http://localhost:5000/debug/pprof/heap
# Or use runtime metrics
import "runtime"
var m runtime.MemStats
runtime.ReadMemStats(&m)
fmt.Printf("Alloc = %v MB", m.Alloc / 1024 / 1024)
Monitor these metrics:
Alloc: Current memory allocationTotalAlloc: Cumulative allocation (detect leaks)Sys: Total memory from OSNumGC: Garbage collection count
Expected behavior with presigned uploads:
- Memory spikes during
Write()calls - Memory drops after
Commit()completes - No memory leaks (TotalAlloc should plateau)
Questions for Decision
Before implementing, answer:
-
What's the typical size of your Docker layers?
- < 50MB: Presigned uploads perfect fit
- 50-200MB: Acceptable with memory monitoring
-
200MB: Consider disk buffering or stick with proxy
-
What's your AppView's available memory?
- 1GB: Skip presigned uploads
- 2-4GB: Fine for typical workloads
- 8GB+: No concerns
-
Is Hold Service bandwidth currently a problem?
- No: Current proxy mode is fine
- Yes: Presigned uploads will help significantly
-
How important is code simplicity?
- Very: Stick with proxy mode
- Moderate: Implement presigned uploads only
- Low: Implement both presigned uploads and HEAD
-
What's your deployment model?
- Single Hold Service: Bandwidth matters more
- Multiple Hold Services: Less critical
Implementation Checklist
Presigned Uploads
- Modify
ProxyBlobWriterstruct (remove pipe, add buffer/hasher) - Update
Create()to initialize buffer - Update
Write()to buffer + hash data - Update
Commit()to upload via presigned URL - Update
Cancel()to clear buffer - Add memory usage monitoring
- Add configuration flag
- Test with small blobs (< 1MB)
- Test with medium blobs (10-50MB)
- Test with large blobs (100-500MB)
- Test concurrent uploads
- Test error scenarios
- Update documentation
- Deploy to staging
- Monitor production rollout
Presigned HEAD URLs (Optional)
- Add
getHeadURL()to Hold Service - Add
HandleHeadPresignedURL()endpoint - Register
/head-presigned-urlroute - Add
getHeadURL()to ProxyBlobStore - Update
ServeBlob()to redirect HEAD requests - Test HEAD redirects
- Verify method-specific signatures
- Test with Docker pull operations
- Deploy to staging
- Monitor production rollout